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;