diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index 98872cc22ff..063eb4da741 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -488,7 +488,7 @@ def stream_data():
yield b""
response = StreamingHttpResponse(
- page.description_binary, content_type="application/octet-stream"
+ stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = 'attachment; filename="page_description.bin"'
return response
@@ -530,23 +530,21 @@ def partial_update(self, request, slug, project_id, pk):
print("before base 64")
# Get the base64 data from the request
- base64_data = request.body
- print("after base 64", base64_data)
+ base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
- # new_binary_data = base64.b64decode(base64_data)
+ new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
- page.description_binary = base64_data
- # page.description_html = request.data.get("description_html")
- # page.description = request.data.get("description")
- print("before save")
+ page.description_binary = new_binary_data
+ page.description_html = request.data.get("description_html")
+ page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
diff --git a/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts b/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts
new file mode 100644
index 00000000000..a93528a675f
--- /dev/null
+++ b/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts
@@ -0,0 +1,560 @@
+import { Fragment, Slice, Node } from "@tiptap/pm/model";
+import { NodeSelection, TextSelection } from "@tiptap/pm/state";
+// @ts-expect-error __serializeForClipboard's is not exported
+import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
+// extensions
+import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
+
+const verticalEllipsisIcon =
+ '';
+
+const createDragHandleElement = (): HTMLElement => {
+ const dragHandleElement = document.createElement("button");
+ dragHandleElement.type = "button";
+ dragHandleElement.id = "drag-handle";
+ dragHandleElement.draggable = false;
+ dragHandleElement.dataset.dragHandle = "";
+ dragHandleElement.classList.value =
+ "hidden sm:flex items-center size-5 aspect-square rounded-sm cursor-grab outline-none hover:bg-custom-background-80 active:bg-custom-background-80 active:cursor-grabbing transition-[background-color,_opacity] duration-200 ease-linear";
+
+ const iconElement1 = document.createElement("span");
+ iconElement1.classList.value = "pointer-events-none text-custom-text-300";
+ iconElement1.innerHTML = verticalEllipsisIcon;
+ const iconElement2 = document.createElement("span");
+ iconElement2.classList.value = "pointer-events-none text-custom-text-300 -ml-2.5";
+ iconElement2.innerHTML = verticalEllipsisIcon;
+
+ dragHandleElement.appendChild(iconElement1);
+ dragHandleElement.appendChild(iconElement2);
+
+ return dragHandleElement;
+};
+
+const isScrollable = (node: HTMLElement | SVGElement) => {
+ if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
+ return false;
+ }
+ const style = getComputedStyle(node);
+ return ["overflow", "overflow-y"].some((propertyName) => {
+ const value = style.getPropertyValue(propertyName);
+ return value === "auto" || value === "scroll";
+ });
+};
+
+const getScrollParent = (node: HTMLElement | SVGElement) => {
+ let currentParent = node.parentElement;
+
+ while (currentParent) {
+ if (isScrollable(currentParent)) {
+ return currentParent;
+ }
+ currentParent = currentParent.parentElement;
+ }
+ return document.scrollingElement || document.documentElement;
+};
+
+export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
+ const elements = document.elementsFromPoint(coords.x, coords.y);
+ const generalSelectors = [
+ "li",
+ "p:not(:first-child)",
+ ".code-block",
+ "blockquote",
+ "h1, h2, h3, h4, h5, h6",
+ "[data-type=horizontalRule]",
+ ".table-wrapper",
+ ".issue-embed",
+ ".image-component",
+ ".image-upload-component",
+ ".editor-callout-component",
+ ].join(", ");
+
+ for (const elem of elements) {
+ if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
+ return elem;
+ }
+
+ // if the element is a
tag that is the first child of a td or th
+ if (
+ (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
+ elem?.textContent?.trim() !== ""
+ ) {
+ return elem; // Return only if p tag is not empty in td or th
+ }
+
+ // apply general selector
+ if (elem.matches(generalSelectors)) {
+ return elem;
+ }
+ }
+ return null;
+};
+
+const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 50 + options.dragHandleWidth,
+ top: boundingRect.top + 1,
+ })?.inside;
+};
+
+const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 1,
+ top: boundingRect.top + 1,
+ })?.inside;
+};
+
+const calcNodePos = (pos: number, view: EditorView, node: Element) => {
+ const maxPos = view.state.doc.content.size;
+ const safePos = Math.max(0, Math.min(pos, maxPos));
+ const $pos = view.state.doc.resolve(safePos);
+
+ if ($pos.depth > 1) {
+ if (node.matches("ul li, ol li")) {
+ // only for nested lists
+ const newPos = $pos.before($pos.depth);
+ return Math.max(0, Math.min(newPos, maxPos));
+ }
+ }
+
+ return safePos;
+};
+
+export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
+ let listType = "";
+ let isDragging = false;
+ let lastClientY = 0;
+ let scrollAnimationFrame = null;
+ let ghostElement: HTMLElement | null = null;
+ const initialMouseOffset = { x: 0, y: 0 };
+ let mouseDownTime = 0;
+
+ const handleMouseDown = (event: MouseEvent, view: EditorView) => {
+ if (event.button !== 0) return;
+
+ mouseDownTime = Date.now();
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ if (!(node instanceof Element)) return;
+
+ // Get initial position for selection
+ let draggedNodePos = nodePosAtDOM(node, view, options);
+ if (draggedNodePos == null || draggedNodePos < 0) return;
+ draggedNodePos = calcNodePos(draggedNodePos, view, node);
+
+ // Start scroll handling when drag begins
+ const scroll = () => {
+ if (!isDragging) return;
+
+ const scrollableParent = getScrollParent(view.dom);
+ const scrollThreshold = {
+ up: 100,
+ down: 100,
+ };
+ const maxScrollSpeed = 10;
+ let scrollAmount = 0;
+
+ const scrollRegionUp = scrollThreshold.up;
+ const scrollRegionDown = window.innerHeight - scrollThreshold.down;
+
+ // Calculate scroll amount based on mouse position
+ if (lastClientY < scrollRegionUp) {
+ const overflow = scrollRegionUp - lastClientY;
+ const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1);
+ const speed = maxScrollSpeed * ratio;
+ scrollAmount = -speed;
+ } else if (lastClientY > scrollRegionDown) {
+ const overflow = lastClientY - scrollRegionDown;
+ const ratio = Math.min(Math.pow(overflow / scrollThreshold.down, 2), 1);
+ const speed = maxScrollSpeed * ratio;
+ scrollAmount = speed;
+ }
+
+ // Handle cases when mouse is outside the window
+ if (lastClientY <= 0) {
+ const overflow = scrollThreshold.up + Math.abs(lastClientY);
+ const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1);
+ const speed = maxScrollSpeed * ratio;
+ scrollAmount = -speed;
+ } else if (lastClientY >= window.innerHeight) {
+ const overflow = lastClientY - window.innerHeight + scrollThreshold.down;
+ const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1);
+ const speed = maxScrollSpeed * ratio;
+ scrollAmount = speed;
+ }
+
+ if (scrollAmount !== 0) {
+ scrollableParent.scrollBy({ top: scrollAmount });
+ }
+
+ scrollAnimationFrame = requestAnimationFrame(scroll);
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (Date.now() - mouseDownTime < 200) return;
+
+ if (!isDragging) {
+ isDragging = true;
+ event.preventDefault();
+
+ // Apply the same selection logic as in original code
+ const { from, to } = view.state.selection;
+ const diff = from - to;
+
+ const fromSelectionPos = calcNodePos(from, view, node);
+ let differentNodeSelected = false;
+
+ const nodePos = view.state.doc.resolve(fromSelectionPos);
+
+ if (nodePos.node().type.name === "doc") differentNodeSelected = true;
+ else {
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
+ differentNodeSelected = !(
+ draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
+ );
+ }
+
+ if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
+ const endSelection = NodeSelection.create(view.state.doc, to - 1);
+ const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
+ view.dispatch(view.state.tr.setSelection(multiNodeSelection));
+ } else {
+ const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+
+ // Handle special cases
+ if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
+ listType = node.parentElement!.tagName;
+ }
+
+ if (node.matches("blockquote")) {
+ let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
+ if (nodePosForBlockQuotes !== null && nodePosForBlockQuotes !== undefined) {
+ const docSize = view.state.doc.content.size;
+ nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
+
+ if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+ }
+ }
+
+ // Create ghost after selection is set
+ const slice = view.state.selection.content();
+ console.log("slice", slice);
+ ghostElement = createGhostElement(view, slice);
+ document.body.appendChild(ghostElement);
+
+ // Set dragging state for ProseMirror
+ view.dragging = { slice, move: event.ctrlKey };
+
+ // Start scroll handling when drag begins
+ scroll();
+ }
+
+ if (!ghostElement) return;
+
+ ghostElement.style.left = `${e.clientX}px`;
+ ghostElement.style.top = `${e.clientY}px`;
+
+ lastClientY = e.clientY;
+
+ view.dom.dispatchEvent(
+ new DragEvent("dragover", {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ bubbles: true,
+ dataTransfer: new DataTransfer(),
+ })
+ );
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Cancel scroll animation
+ if (scrollAnimationFrame) {
+ cancelAnimationFrame(scrollAnimationFrame);
+ scrollAnimationFrame = null;
+ }
+ if (isDragging) {
+ // Create drop event with proper data transfer
+ const dropEvent = new DragEvent("drop", {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ bubbles: true,
+ dataTransfer: new DataTransfer(),
+ });
+
+ // Set the same data that we set in the initial selection
+ const slice = view.state.selection.content();
+ const { dom, text } = __serializeForClipboard(view, slice);
+ dropEvent.dataTransfer?.setData("text/html", dom.innerHTML);
+ dropEvent.dataTransfer?.setData("text/plain", text);
+ // Trigger ProseMirror's drop handling
+ view.dom.dispatchEvent(dropEvent);
+ }
+
+ // Cleanup
+ isDragging = false;
+ ghostElement?.remove();
+ ghostElement = null;
+
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ };
+
+ const handleClick = (event: MouseEvent, view: EditorView) => {
+ view.focus();
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ if (!(node instanceof Element)) return;
+
+ if (node.matches("blockquote")) {
+ let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
+ if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
+
+ const docSize = view.state.doc.content.size;
+ nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
+
+ if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
+ // TODO FIX ERROR
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+ return;
+ }
+
+ let nodePos = nodePosAtDOM(node, view, options);
+
+ if (nodePos === null || nodePos === undefined) return;
+
+ // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
+ nodePos = calcNodePos(nodePos, view, node);
+
+ // TODO FIX ERROR
+ // Use NodeSelection to select the node at the calculated position
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
+
+ // Dispatch the transaction to update the selection
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ };
+
+ let dragHandleElement: HTMLElement | null = null;
+ // drag handle view actions
+ const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
+ const hideDragHandle = () => {
+ if (!dragHandleElement?.classList.contains("drag-handle-hidden"))
+ dragHandleElement?.classList.add("drag-handle-hidden");
+ };
+
+ const handleCleanup = (event: MouseEvent | FocusEvent, view: EditorView) => {
+ event.preventDefault();
+ isDragging = false;
+ ghostElement?.remove();
+ ghostElement = null;
+
+ if (scrollAnimationFrame) {
+ cancelAnimationFrame(scrollAnimationFrame);
+ scrollAnimationFrame = null;
+ }
+
+ view.dom.classList.remove("dragging");
+ };
+
+ const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
+ dragHandleElement = createDragHandleElement();
+ dragHandleElement.addEventListener("mousedown", (e) => handleMouseDown(e, view));
+ dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
+ dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
+
+ // Replace dragend/blur handlers with cleanup
+ window.addEventListener("blur", (e) => handleCleanup(e, view));
+
+ document.addEventListener("dragover", (event) => {
+ event.preventDefault();
+ if (isDragging) {
+ lastClientY = event.clientY;
+ }
+ });
+
+ hideDragHandle();
+
+ sideMenu?.appendChild(dragHandleElement);
+
+ return {
+ destroy: () => {
+ dragHandleElement?.remove?.();
+ dragHandleElement = null;
+ isDragging = false;
+ ghostElement?.remove();
+ ghostElement = null;
+ if (scrollAnimationFrame) {
+ cancelAnimationFrame(scrollAnimationFrame);
+ scrollAnimationFrame = null;
+ }
+ },
+ };
+ };
+ const domEvents = {
+ mousemove: () => showDragHandle(),
+ dragenter: (view: EditorView) => {
+ view.dom.classList.add("dragging");
+ hideDragHandle();
+ },
+ drop: (view: EditorView, event: DragEvent) => {
+ view.dom.classList.remove("dragging");
+ hideDragHandle();
+ let droppedNode: Node | null = null;
+ const dropPos = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+
+ if (!dropPos) return;
+
+ if (view.state.selection instanceof NodeSelection) {
+ droppedNode = view.state.selection.node;
+ }
+
+ if (!droppedNode) return;
+
+ const resolvedPos = view.state.doc.resolve(dropPos.pos);
+ let isDroppedInsideList = false;
+
+ // Traverse up the document tree to find if we're inside a list item
+ for (let i = resolvedPos.depth; i > 0; i--) {
+ if (resolvedPos.node(i).type.name === "listItem") {
+ isDroppedInsideList = true;
+ break;
+ }
+ }
+
+ // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
tag otherwise ol list items will be transformed into ul list item when dropped
+ if (
+ view.state.selection instanceof NodeSelection &&
+ view.state.selection.node.type.name === "listItem" &&
+ !isDroppedInsideList &&
+ listType == "OL"
+ ) {
+ const text = droppedNode.textContent;
+ if (!text) return;
+ const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
+ const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
+
+ const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
+ const slice = new Slice(Fragment.from(newList), 0, 0);
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+ },
+ dragend: (view: EditorView) => {
+ view.dom.classList.remove("dragging");
+ },
+ };
+
+ return {
+ view,
+ domEvents,
+ };
+};
+
+const createGhostElement = (view: EditorView, slice: Slice) => {
+ console.log("asfd");
+ const { dom: domNodeForSlice, text } = __serializeForClipboard(view, slice);
+ let contentNode: HTMLElement;
+
+ let parentNode: Element | null = null;
+ let closestValidNode: Element | null = null;
+ let closestEditorContainer: Element;
+ let closestProseMirrorContainer: Element;
+ if (true) {
+ const dom = getSelectedDOMNode(view);
+
+ const parent = dom.closest("ul, ol, blockquote");
+ console.log("parent", parent);
+
+ switch (parent?.tagName.toLowerCase()) {
+ case "ul":
+ case "ol":
+ parentNode = parent.cloneNode() as HTMLElement;
+ console.log("parentNode", parentNode);
+ closestValidNode = parent.querySelector("li").cloneNode(true) as HTMLElement;
+ console.log("closestValidNode", closestValidNode);
+ break;
+ case "blockquote":
+ parentNode = parent.cloneNode() as HTMLElement;
+ break;
+ default:
+ break;
+ }
+ // console.log("parent", parentNode);
+ closestProseMirrorContainer = dom.closest(".ProseMirror") || document.querySelector(".ProseMirror-focused");
+ closestEditorContainer = closestProseMirrorContainer.closest(".editor-container");
+ contentNode = dom.cloneNode(true) as HTMLElement;
+ console.log("contentNode", contentNode);
+ } else if (domNodeForSlice) {
+ console.log("slice", domNodeForSlice);
+ }
+
+ const ghostParent = document.createElement("div");
+ ghostParent.classList.value = closestEditorContainer?.classList.value;
+ const ghost = document.createElement("div");
+ ghost.classList.value = closestProseMirrorContainer?.classList.value;
+ if (parentNode) {
+ const parentWrapper = parentNode;
+ parentWrapper.appendChild(closestValidNode);
+ ghost.appendChild(parentWrapper);
+ } else if (contentNode) {
+ ghost.appendChild(contentNode);
+ } else if (domNodeForSlice) {
+ ghost.appendChild(domNodeForSlice);
+ }
+ ghostParent.appendChild(ghost);
+ ghostParent.style.position = "fixed";
+ ghostParent.style.pointerEvents = "none";
+ ghostParent.style.zIndex = "1000";
+ ghostParent.style.opacity = "0.8";
+ ghostParent.style.padding = "8px";
+ ghostParent.style.width = closestProseMirrorContainer?.clientWidth + "px";
+ console.log("ghostParent", ghostParent);
+
+ return ghostParent;
+};
+
+function getSelectedDOMNode(editorView: EditorView): HTMLElement | null {
+ const { selection } = editorView.state;
+
+ if (selection instanceof NodeSelection) {
+ const coords = editorView.coordsAtPos(selection.from);
+
+ // Use the center point of the node's bounding rectangle
+ const x = Math.round((coords.left + coords.right) / 2);
+ const y = Math.round((coords.top + coords.bottom) / 2);
+
+ // Use document.elementFromPoint to get the element at these coordinates
+ const element = document.elementFromPoint(x, y);
+
+ // If element is found and it's within the editor's DOM, return it
+ if (element && editorView.dom.contains(element)) {
+ return element as HTMLElement;
+ }
+ }
+
+ return null;
+}
diff --git a/packages/editor/src/core/plugins/drag-handle-old.ts b/packages/editor/src/core/plugins/drag-handle-old.ts
new file mode 100644
index 00000000000..1c015dcb0f7
--- /dev/null
+++ b/packages/editor/src/core/plugins/drag-handle-old.ts
@@ -0,0 +1,351 @@
+import { Fragment, Slice, Node } from "@tiptap/pm/model";
+import { NodeSelection, TextSelection } from "@tiptap/pm/state";
+// @ts-expect-error __serializeForClipboard's is not exported
+import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
+// extensions
+import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
+
+const verticalEllipsisIcon =
+ '';
+
+const createDragHandleElement = (): HTMLElement => {
+ const dragHandleElement = document.createElement("button");
+ dragHandleElement.type = "button";
+ dragHandleElement.id = "drag-handle";
+ dragHandleElement.draggable = true;
+ dragHandleElement.dataset.dragHandle = "";
+ dragHandleElement.classList.value =
+ "hidden sm:flex items-center size-5 aspect-square rounded-sm cursor-grab outline-none hover:bg-custom-background-80 active:bg-custom-background-80 active:cursor-grabbing transition-[background-color,_opacity] duration-200 ease-linear";
+
+ const iconElement1 = document.createElement("span");
+ iconElement1.classList.value = "pointer-events-none text-custom-text-300";
+ iconElement1.innerHTML = verticalEllipsisIcon;
+ const iconElement2 = document.createElement("span");
+ iconElement2.classList.value = "pointer-events-none text-custom-text-300 -ml-2.5";
+ iconElement2.innerHTML = verticalEllipsisIcon;
+
+ dragHandleElement.appendChild(iconElement1);
+ dragHandleElement.appendChild(iconElement2);
+
+ return dragHandleElement;
+};
+
+export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
+ const elements = document.elementsFromPoint(coords.x, coords.y);
+ const generalSelectors = [
+ "li",
+ "p:not(:first-child)",
+ ".code-block",
+ "blockquote",
+ "h1, h2, h3, h4, h5, h6",
+ "[data-type=horizontalRule]",
+ ".table-wrapper",
+ ".issue-embed",
+ ".image-component",
+ ".image-upload-component",
+ ".editor-callout-component",
+ ].join(", ");
+
+ for (const elem of elements) {
+ if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
+ return elem;
+ }
+
+ // if the element is a tag that is the first child of a td or th
+ if (
+ (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
+ elem?.textContent?.trim() !== ""
+ ) {
+ return elem; // Return only if p tag is not empty in td or th
+ }
+
+ // apply general selector
+ if (elem.matches(generalSelectors)) {
+ return elem;
+ }
+ }
+ return null;
+};
+
+const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 50 + options.dragHandleWidth,
+ top: boundingRect.top + 1,
+ })?.inside;
+};
+
+const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 1,
+ top: boundingRect.top + 1,
+ })?.inside;
+};
+
+const calcNodePos = (pos: number, view: EditorView, node: Element) => {
+ const maxPos = view.state.doc.content.size;
+ const safePos = Math.max(0, Math.min(pos, maxPos));
+ const $pos = view.state.doc.resolve(safePos);
+
+ if ($pos.depth > 1) {
+ if (node.matches("ul li, ol li")) {
+ // only for nested lists
+ const newPos = $pos.before($pos.depth);
+ return Math.max(0, Math.min(newPos, maxPos));
+ }
+ }
+
+ return safePos;
+};
+
+export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
+ let listType = "";
+ const handleDragStart = (event: DragEvent, view: EditorView) => {
+ view.focus();
+
+ if (!event.dataTransfer) return;
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ if (!(node instanceof Element)) return;
+
+ let draggedNodePos = nodePosAtDOM(node, view, options);
+ if (draggedNodePos == null || draggedNodePos < 0) return;
+ draggedNodePos = calcNodePos(draggedNodePos, view, node);
+
+ const { from, to } = view.state.selection;
+ const diff = from - to;
+
+ const fromSelectionPos = calcNodePos(from, view, node);
+ let differentNodeSelected = false;
+
+ const nodePos = view.state.doc.resolve(fromSelectionPos);
+
+ // Check if nodePos points to the top level node
+ if (nodePos.node().type.name === "doc") differentNodeSelected = true;
+ else {
+ // TODO FIX ERROR
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
+ // Check if the node where the drag event started is part of the current selection
+ differentNodeSelected = !(
+ draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
+ );
+ }
+
+ if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
+ const endSelection = NodeSelection.create(view.state.doc, to - 1);
+ const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
+ view.dispatch(view.state.tr.setSelection(multiNodeSelection));
+ } else {
+ // TODO FIX ERROR
+ const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+
+ // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
+ if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
+ listType = node.parentElement!.tagName;
+ }
+
+ if (node.matches("blockquote")) {
+ let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
+ if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
+
+ const docSize = view.state.doc.content.size;
+ nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
+
+ if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
+ // TODO FIX ERROR
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+ }
+
+ const slice = view.state.selection.content();
+ const { dom, text } = __serializeForClipboard(view, slice);
+
+ event.dataTransfer.clearData();
+ event.dataTransfer.setData("text/html", dom.innerHTML);
+ event.dataTransfer.setData("text/plain", text);
+ event.dataTransfer.effectAllowed = "copyMove";
+
+ event.dataTransfer.setDragImage(node, 0, 0);
+
+ view.dragging = { slice, move: event.ctrlKey };
+ };
+
+ const handleClick = (event: MouseEvent, view: EditorView) => {
+ view.focus();
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ if (!(node instanceof Element)) return;
+
+ if (node.matches("blockquote")) {
+ let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
+ if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
+
+ const docSize = view.state.doc.content.size;
+ nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
+
+ if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
+ // TODO FIX ERROR
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ }
+ return;
+ }
+
+ let nodePos = nodePosAtDOM(node, view, options);
+
+ if (nodePos === null || nodePos === undefined) return;
+
+ // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
+ nodePos = calcNodePos(nodePos, view, node);
+
+ // TODO FIX ERROR
+ // Use NodeSelection to select the node at the calculated position
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
+
+ // Dispatch the transaction to update the selection
+ view.dispatch(view.state.tr.setSelection(nodeSelection));
+ };
+
+ let dragHandleElement: HTMLElement | null = null;
+ // drag handle view actions
+ const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
+ const hideDragHandle = () => {
+ if (!dragHandleElement?.classList.contains("drag-handle-hidden"))
+ dragHandleElement?.classList.add("drag-handle-hidden");
+ };
+
+ const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
+ dragHandleElement = createDragHandleElement();
+ dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
+ dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
+ dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
+
+ const isScrollable = (node: HTMLElement | SVGElement) => {
+ if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
+ return false;
+ }
+ const style = getComputedStyle(node);
+ return ["overflow", "overflow-y"].some((propertyName) => {
+ const value = style.getPropertyValue(propertyName);
+ return value === "auto" || value === "scroll";
+ });
+ };
+
+ const getScrollParent = (node: HTMLElement | SVGElement) => {
+ let currentParent = node.parentElement;
+ while (currentParent) {
+ if (isScrollable(currentParent)) {
+ return currentParent;
+ }
+ currentParent = currentParent.parentElement;
+ }
+ return document.scrollingElement || document.documentElement;
+ };
+
+ const maxScrollSpeed = 100;
+
+ dragHandleElement.addEventListener("drag", (e) => {
+ hideDragHandle();
+ const scrollableParent = getScrollParent(dragHandleElement);
+ if (!scrollableParent) return;
+ const scrollThreshold = options.scrollThreshold;
+
+ if (e.clientY < scrollThreshold.up) {
+ const overflow = scrollThreshold.up - e.clientY;
+ const ratio = Math.min(overflow / scrollThreshold.up, 1);
+ const scrollAmount = -maxScrollSpeed * ratio;
+ scrollableParent.scrollBy({ top: scrollAmount });
+ } else if (window.innerHeight - e.clientY < scrollThreshold.down) {
+ const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
+ const ratio = Math.min(overflow / scrollThreshold.down, 1);
+ const scrollAmount = maxScrollSpeed * ratio;
+ scrollableParent.scrollBy({ top: scrollAmount });
+ }
+ });
+
+ hideDragHandle();
+
+ sideMenu?.appendChild(dragHandleElement);
+
+ return {
+ destroy: () => {
+ dragHandleElement?.remove?.();
+ dragHandleElement = null;
+ },
+ };
+ };
+ const domEvents = {
+ mousemove: () => showDragHandle(),
+ dragenter: (view: EditorView) => {
+ view.dom.classList.add("dragging");
+ hideDragHandle();
+ },
+ drop: (view: EditorView, event: DragEvent) => {
+ view.dom.classList.remove("dragging");
+ hideDragHandle();
+ let droppedNode: Node | null = null;
+ const dropPos = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+
+ if (!dropPos) return;
+
+ if (view.state.selection instanceof NodeSelection) {
+ droppedNode = view.state.selection.node;
+ }
+
+ if (!droppedNode) return;
+
+ const resolvedPos = view.state.doc.resolve(dropPos.pos);
+ let isDroppedInsideList = false;
+
+ // Traverse up the document tree to find if we're inside a list item
+ for (let i = resolvedPos.depth; i > 0; i--) {
+ if (resolvedPos.node(i).type.name === "listItem") {
+ isDroppedInsideList = true;
+ break;
+ }
+ }
+
+ // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
tag otherwise ol list items will be transformed into ul list item when dropped
+ if (
+ view.state.selection instanceof NodeSelection &&
+ view.state.selection.node.type.name === "listItem" &&
+ !isDroppedInsideList &&
+ listType == "OL"
+ ) {
+ const text = droppedNode.textContent;
+ if (!text) return;
+ const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
+ const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
+
+ const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
+ const slice = new Slice(Fragment.from(newList), 0, 0);
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+ },
+ dragend: (view: EditorView) => {
+ view.dom.classList.remove("dragging");
+ },
+ };
+
+ return {
+ view,
+ domEvents,
+ };
+};
diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts
index 16ac24d73b4..87df59e09e2 100644
--- a/packages/editor/src/core/plugins/drag-handle.ts
+++ b/packages/editor/src/core/plugins/drag-handle.ts
@@ -1,4 +1,4 @@
-import { Fragment, Slice, Node, DOMSerializer } from "@tiptap/pm/model";
+import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
@@ -130,37 +130,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
let lastClientY = 0;
let scrollAnimationFrame = null;
let ghostElement: HTMLElement | null = null;
- let initialMouseOffset = { x: 0, y: 0 };
let mouseDownTime = 0;
- const createGhostElement = (view: EditorView, slice: Slice) => {
- let contentNode;
- if (view.state.selection instanceof NodeSelection) {
- const node = view.state.selection.node;
- const fragment = Fragment.from(node);
-
- const schema = view.state.schema;
- const serializer = DOMSerializer.fromSchema(schema);
- contentNode = serializer.serializeFragment(fragment);
- } else {
- const { dom } = __serializeForClipboard(view, slice);
- contentNode = dom;
- }
-
- const ghost = document.createElement('div');
- ghost.classList.add('drag-ghost');
- ghost.appendChild(contentNode);
- ghost.style.position = 'fixed';
- ghost.style.pointerEvents = 'none';
- ghost.style.zIndex = '1000';
- ghost.style.opacity = '0.8';
- ghost.style.background = 'var(--custom-background-100)';
- ghost.style.padding = '8px';
- ghost.style.borderRadius = '4px';
- ghost.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
- return ghost;
- };
-
const handleMouseDown = (event: MouseEvent, view: EditorView) => {
if (event.button !== 0) return;
@@ -187,7 +158,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
up: 100,
down: 100,
};
- const maxScrollSpeed = 10;
+ const maxScrollSpeed = 50; // Increased max scroll speed
let scrollAmount = 0;
const scrollRegionUp = scrollThreshold.up;
@@ -196,12 +167,12 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
// Calculate scroll amount based on mouse position
if (lastClientY < scrollRegionUp) {
const overflow = scrollRegionUp - lastClientY;
- const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1);
+ const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 1.5), 1); // Use a power of 1.5 for smoother acceleration
const speed = maxScrollSpeed * ratio;
scrollAmount = -speed;
} else if (lastClientY > scrollRegionDown) {
const overflow = lastClientY - scrollRegionDown;
- const ratio = Math.min(Math.pow(overflow / scrollThreshold.down, 2), 1);
+ const ratio = Math.min(Math.pow(overflow / scrollThreshold.down, 1.5), 1);
const speed = maxScrollSpeed * ratio;
scrollAmount = speed;
}
@@ -209,18 +180,22 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
// Handle cases when mouse is outside the window
if (lastClientY <= 0) {
const overflow = scrollThreshold.up + Math.abs(lastClientY);
- const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1);
- const speed = maxScrollSpeed * ratio;
+ const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 1.5), 1);
+ const speed = maxScrollSpeed * 2 * ratio; // Double the speed when outside the window
scrollAmount = -speed;
} else if (lastClientY >= window.innerHeight) {
const overflow = lastClientY - window.innerHeight + scrollThreshold.down;
- const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1);
- const speed = maxScrollSpeed * ratio;
+ const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 1.5), 1);
+ const speed = maxScrollSpeed * 2 * ratio; // Double the speed when outside the window
scrollAmount = speed;
}
if (scrollAmount !== 0) {
- scrollableParent.scrollBy({ top: scrollAmount });
+ // Use smooth scrolling for a more fluid animation
+ scrollableParent.scrollBy({
+ top: scrollAmount,
+ behavior: "smooth",
+ });
}
scrollAnimationFrame = requestAnimationFrame(scroll);
@@ -307,36 +282,34 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
};
const handleMouseUp = (e: MouseEvent) => {
- if (!isDragging) return;
-
// Cancel scroll animation
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
+ if (isDragging) {
+ // Create drop event with proper data transfer
+ const dropEvent = new DragEvent("drop", {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ bubbles: true,
+ dataTransfer: new DataTransfer(),
+ });
- // Create drop event with proper data transfer
- const dropEvent = new DragEvent("drop", {
- clientX: e.clientX,
- clientY: e.clientY,
- bubbles: true,
- dataTransfer: new DataTransfer(),
- });
-
- // Set the same data that we set in the initial selection
- const slice = view.state.selection.content();
- const { dom, text } = __serializeForClipboard(view, slice);
- dropEvent.dataTransfer?.setData("text/html", dom.innerHTML);
- dropEvent.dataTransfer?.setData("text/plain", text);
+ // Set the same data that we set in the initial selection
+ const slice = view.state.selection.content();
+ const { dom, text } = __serializeForClipboard(view, slice);
+ dropEvent.dataTransfer?.setData("text/html", dom.innerHTML);
+ dropEvent.dataTransfer?.setData("text/plain", text);
+ // Trigger ProseMirror's drop handling
+ view.dom.dispatchEvent(dropEvent);
+ }
// Cleanup
isDragging = false;
ghostElement?.remove();
ghostElement = null;
- // Trigger ProseMirror's drop handling
- view.dom.dispatchEvent(dropEvent);
-
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
@@ -502,3 +475,88 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
domEvents,
};
};
+
+const createGhostElement = (view: EditorView, slice: Slice) => {
+ console.log("asfd");
+ const { dom: domNodeForSlice, text } = __serializeForClipboard(view, slice);
+ let contentNode: HTMLElement;
+
+ let parentNode: Element | null = null;
+ let closestValidNode: Element | null = null;
+ let closestEditorContainer: Element;
+ let closestProseMirrorContainer: Element;
+ if (true) {
+ const dom = getSelectedDOMNode(view);
+
+ const parent = dom.closest("ul, ol, blockquote");
+ console.log("parent", parent);
+
+ switch (parent?.tagName.toLowerCase()) {
+ case "ul":
+ case "ol":
+ parentNode = parent.cloneNode() as HTMLElement;
+ console.log("parentNode", parentNode);
+ closestValidNode = parent.querySelector("li").cloneNode(true) as HTMLElement;
+ console.log("closestValidNode", closestValidNode);
+ break;
+ case "blockquote":
+ parentNode = parent.cloneNode() as HTMLElement;
+ break;
+ default:
+ break;
+ }
+ // console.log("parent", parentNode);
+ closestProseMirrorContainer = dom.closest(".ProseMirror") || document.querySelector(".ProseMirror-focused");
+ closestEditorContainer = closestProseMirrorContainer.closest(".editor-container");
+ contentNode = dom.cloneNode(true) as HTMLElement;
+ console.log("contentNode", contentNode);
+ } else if (domNodeForSlice) {
+ console.log("slice", domNodeForSlice);
+ }
+
+ const ghostParent = document.createElement("div");
+ ghostParent.classList.value = closestEditorContainer?.classList.value;
+ const ghost = document.createElement("div");
+ ghost.classList.value = closestProseMirrorContainer?.classList.value;
+ if (parentNode) {
+ const parentWrapper = parentNode;
+ parentWrapper.appendChild(closestValidNode);
+ ghost.appendChild(parentWrapper);
+ } else if (contentNode) {
+ ghost.appendChild(contentNode);
+ } else if (domNodeForSlice) {
+ ghost.appendChild(domNodeForSlice);
+ }
+ ghostParent.appendChild(ghost);
+ ghostParent.style.position = "fixed";
+ ghostParent.style.pointerEvents = "none";
+ ghostParent.style.zIndex = "1000";
+ ghostParent.style.opacity = "0.8";
+ ghostParent.style.padding = "8px";
+ ghostParent.style.width = closestProseMirrorContainer?.clientWidth + "px";
+ console.log("ghostParent", ghostParent);
+
+ return ghostParent;
+};
+
+function getSelectedDOMNode(editorView: EditorView): HTMLElement | null {
+ const { selection } = editorView.state;
+
+ if (selection instanceof NodeSelection) {
+ const coords = editorView.coordsAtPos(selection.from);
+
+ // Use the center point of the node's bounding rectangle
+ const x = Math.round((coords.left + coords.right) / 2);
+ const y = Math.round((coords.top + coords.bottom) / 2);
+
+ // Use document.elementFromPoint to get the element at these coordinates
+ const element = document.elementFromPoint(x, y);
+
+ // If element is found and it's within the editor's DOM, return it
+ if (element && editorView.dom.contains(element)) {
+ return element as HTMLElement;
+ }
+ }
+
+ return null;
+}
diff --git a/packages/editor/src/core/plugins/global-drag-handle.ts b/packages/editor/src/core/plugins/global-drag-handle.ts
new file mode 100644
index 00000000000..ac2b51295d9
--- /dev/null
+++ b/packages/editor/src/core/plugins/global-drag-handle.ts
@@ -0,0 +1,357 @@
+import { Extension } from "@tiptap/core";
+import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
+import { Fragment, Slice, Node } from "@tiptap/pm/model";
+// @ts-expect-error some
+import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
+
+export interface GlobalDragHandleOptions {
+ /**
+ * The width of the drag handle
+ */
+ dragHandleWidth: number;
+
+ /**
+ * The treshold for scrolling
+ */
+ scrollTreshold: number;
+
+ /*
+ * The css selector to query for the drag handle. (eg: '.custom-handle').
+ * If handle element is found, that element will be used as drag handle. If not, a default handle will be created
+ */
+ dragHandleSelector?: string;
+
+ /**
+ * Tags to be excluded for drag handle
+ */
+ excludedTags: string[];
+
+ /**
+ * Custom nodes to be included for drag handle
+ */
+ customNodes: string[];
+}
+function absoluteRect(node: Element) {
+ const data = node.getBoundingClientRect();
+ const modal = node.closest('[role="dialog"]');
+
+ if (modal && window.getComputedStyle(modal).transform !== "none") {
+ const modalRect = modal.getBoundingClientRect();
+
+ return {
+ top: data.top - modalRect.top,
+ left: data.left - modalRect.left,
+ width: data.width,
+ };
+ }
+ return {
+ top: data.top,
+ left: data.left,
+ width: data.width,
+ };
+}
+
+function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
+ const selectors = [
+ "li",
+ "p:not(:first-child)",
+ "pre",
+ "blockquote",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ ...options.customNodes.map((node) => `[data-type=${node}]`),
+ ].join(", ");
+ return document
+ .elementsFromPoint(coords.x, coords.y)
+ .find((elem: Element) => elem.parentElement?.matches?.(".ProseMirror") || elem.matches(selectors));
+}
+function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 50 + options.dragHandleWidth,
+ top: boundingRect.top + 1,
+ })?.inside;
+}
+
+function calcNodePos(pos: number, view: EditorView) {
+ const $pos = view.state.doc.resolve(pos);
+ if ($pos.depth > 1) return $pos.before($pos.depth);
+ return pos;
+}
+
+export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
+ let listType = "";
+ function handleDragStart(event: DragEvent, view: EditorView) {
+ view.focus();
+
+ if (!event.dataTransfer) return;
+
+ const node = nodeDOMAtCoords(
+ {
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ },
+ options
+ );
+
+ if (!(node instanceof Element)) return;
+
+ let draggedNodePos = nodePosAtDOM(node, view, options);
+ if (draggedNodePos == null || draggedNodePos < 0) return;
+ draggedNodePos = calcNodePos(draggedNodePos, view);
+
+ const { from, to } = view.state.selection;
+ const diff = from - to;
+
+ const fromSelectionPos = calcNodePos(from, view);
+ let differentNodeSelected = false;
+
+ const nodePos = view.state.doc.resolve(fromSelectionPos);
+
+ // Check if nodePos points to the top level node
+ if (nodePos.node().type.name === "doc") differentNodeSelected = true;
+ else {
+ const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
+
+ // Check if the node where the drag event started is part of the current selection
+ differentNodeSelected = !(
+ draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
+ );
+ }
+ let selection = view.state.selection;
+ if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
+ const endSelection = NodeSelection.create(view.state.doc, to - 1);
+ selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
+ } else {
+ selection = NodeSelection.create(view.state.doc, draggedNodePos);
+
+ // if inline node is selected, e.g mention -> go to the parent node to select the whole node
+ // if table row is selected, go to the parent node to select the whole node
+ if (
+ (selection as NodeSelection).node.type.isInline ||
+ (selection as NodeSelection).node.type.name === "tableRow"
+ ) {
+ let $pos = view.state.doc.resolve(selection.from);
+ selection = NodeSelection.create(view.state.doc, $pos.before());
+ }
+ }
+ view.dispatch(view.state.tr.setSelection(selection));
+
+ // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
+ if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
+ listType = node.parentElement!.tagName;
+ }
+
+ const slice = view.state.selection.content();
+ const { dom, text } = __serializeForClipboard(view, slice);
+
+ event.dataTransfer.clearData();
+ event.dataTransfer.setData("text/html", dom.innerHTML);
+ event.dataTransfer.setData("text/plain", text);
+ event.dataTransfer.effectAllowed = "copyMove";
+
+ event.dataTransfer.setDragImage(node, 0, 0);
+
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+
+ let dragHandleElement: HTMLElement | null = null;
+
+ function hideDragHandle() {
+ if (dragHandleElement) {
+ dragHandleElement.classList.add("hide");
+ }
+ }
+
+ function showDragHandle() {
+ if (dragHandleElement) {
+ dragHandleElement.classList.remove("hide");
+ }
+ }
+
+ function hideHandleOnEditorOut(event: MouseEvent) {
+ if (event.target instanceof Element) {
+ // Check if the relatedTarget class is still inside the editor
+ const relatedTarget = event.relatedTarget as HTMLElement;
+ const isInsideEditor =
+ relatedTarget?.classList.contains("tiptap") || relatedTarget?.classList.contains("drag-handle");
+
+ if (isInsideEditor) return;
+ }
+ hideDragHandle();
+ }
+
+ return new Plugin({
+ key: new PluginKey(options.pluginKey),
+ view: (view) => {
+ const handleBySelector = options.dragHandleSelector
+ ? document.querySelector(options.dragHandleSelector)
+ : null;
+ dragHandleElement = handleBySelector ?? document.createElement("div");
+ dragHandleElement.draggable = true;
+ dragHandleElement.dataset.dragHandle = "";
+ dragHandleElement.classList.add("drag-handle");
+
+ function onDragHandleDragStart(e: DragEvent) {
+ handleDragStart(e, view);
+ }
+
+ dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
+
+ function onDragHandleDrag(e: DragEvent) {
+ hideDragHandle();
+ let scrollY = window.scrollY;
+ if (e.clientY < options.scrollTreshold) {
+ window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
+ } else if (window.innerHeight - e.clientY < options.scrollTreshold) {
+ window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
+ }
+ }
+
+ dragHandleElement.addEventListener("drag", onDragHandleDrag);
+
+ hideDragHandle();
+
+ if (!handleBySelector) {
+ view?.dom?.parentElement?.appendChild(dragHandleElement);
+ }
+ view?.dom?.parentElement?.addEventListener("mouseout", hideHandleOnEditorOut);
+
+ return {
+ destroy: () => {
+ if (!handleBySelector) {
+ dragHandleElement?.remove?.();
+ }
+ dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
+ dragHandleElement?.removeEventListener("dragstart", onDragHandleDragStart);
+ dragHandleElement = null;
+ view?.dom?.parentElement?.removeEventListener("mouseout", hideHandleOnEditorOut);
+ },
+ };
+ },
+ props: {
+ handleDOMEvents: {
+ mousemove: (view, event) => {
+ if (!view.editable) {
+ return;
+ }
+
+ const node = nodeDOMAtCoords(
+ {
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ },
+ options
+ );
+
+ const notDragging = node?.closest(".not-draggable");
+ const excludedTagList = options.excludedTags.concat(["ol", "ul"]).join(", ");
+
+ if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
+ hideDragHandle();
+ return;
+ }
+
+ const compStyle = window.getComputedStyle(node);
+ const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
+ const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight;
+ const paddingTop = parseInt(compStyle.paddingTop, 10);
+
+ const rect = absoluteRect(node);
+
+ rect.top += (lineHeight - 24) / 2;
+ rect.top += paddingTop;
+ // Li markers
+ if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
+ rect.left -= options.dragHandleWidth;
+ }
+ rect.width = options.dragHandleWidth;
+
+ if (!dragHandleElement) return;
+
+ dragHandleElement.style.left = `${rect.left - rect.width}px`;
+ dragHandleElement.style.top = `${rect.top}px`;
+ showDragHandle();
+ },
+ keydown: () => {
+ hideDragHandle();
+ },
+ mousewheel: () => {
+ hideDragHandle();
+ },
+ // dragging class is used for CSS
+ dragstart: (view) => {
+ view.dom.classList.add("dragging");
+ },
+ drop: (view, event) => {
+ view.dom.classList.remove("dragging");
+ hideDragHandle();
+ let droppedNode: Node | null = null;
+ const dropPos = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+
+ if (!dropPos) return;
+
+ if (view.state.selection instanceof NodeSelection) {
+ droppedNode = view.state.selection.node;
+ }
+ if (!droppedNode) return;
+
+ const resolvedPos = view.state.doc.resolve(dropPos.pos);
+
+ const isDroppedInsideList = resolvedPos.parent.type.name === "listItem";
+
+ // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside tag otherwise ol list items will be transformed into ul list item when dropped
+ if (
+ view.state.selection instanceof NodeSelection &&
+ view.state.selection.node.type.name === "listItem" &&
+ !isDroppedInsideList &&
+ listType == "OL"
+ ) {
+ const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
+ const slice = new Slice(Fragment.from(newList), 0, 0);
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+ },
+ dragend: (view) => {
+ view.dom.classList.remove("dragging");
+ },
+ },
+ },
+ });
+}
+
+const GlobalDragHandle = Extension.create({
+ name: "globalDragHandle",
+
+ addOptions() {
+ return {
+ dragHandleWidth: 20,
+ scrollTreshold: 100,
+ excludedTags: [],
+ customNodes: [],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ DragHandlePlugin({
+ pluginKey: "globalDragHandle",
+ dragHandleWidth: this.options.dragHandleWidth,
+ scrollTreshold: this.options.scrollTreshold,
+ dragHandleSelector: this.options.dragHandleSelector,
+ excludedTags: this.options.excludedTags,
+ customNodes: this.options.customNodes,
+ }),
+ ];
+ },
+});
+
+export default GlobalDragHandle;