From af6034d55383973bfffea40893e3f0af94c9e89b Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Fri, 25 Oct 2024 01:50:25 +0530 Subject: [PATCH 01/14] fix: smoother drag scrolling --- apiserver/plane/app/views/page/base.py | 32 +- .../editor/src/core/extensions/side-menu.tsx | 2 +- .../editor/src/core/plugins/drag-handle.ts | 215 ++++++-- .../src/core/plugins/smoooooother-drag.ts | 473 ++++++++++++++++++ .../editor/src/core/plugins/smoother-drag.ts | 363 ++++++++++++++ 5 files changed, 1028 insertions(+), 57 deletions(-) create mode 100644 packages/editor/src/core/plugins/smoooooother-drag.ts create mode 100644 packages/editor/src/core/plugins/smoother-drag.ts diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 5e56cc7036f..565b42deaca 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -438,7 +438,6 @@ def destroy(self, request, slug, project_id, pk): class PageFavoriteViewSet(BaseViewSet): - model = UserFavorite @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -465,7 +464,6 @@ def destroy(self, request, slug, project_id, pk): class PageLogEndpoint(BaseAPIView): - serializer_class = PageLogSerializer model = PageLog @@ -504,7 +502,6 @@ def delete(self, request, slug, project_id, page_id, transaction): class SubPagesEndpoint(BaseAPIView): - @method_decorator(gzip_page) def get(self, request, slug, project_id, page_id): pages = ( @@ -522,7 +519,6 @@ def get(self, request, slug, project_id, page_id): class PagesDescriptionViewSet(BaseViewSet): - @allow_permission( [ ROLE.ADMIN, @@ -552,7 +548,7 @@ def stream_data(): yield b"" response = StreamingHttpResponse( - stream_data(), content_type="application/octet-stream" + page.description_binary, content_type="application/octet-stream" ) response["Content-Disposition"] = ( 'attachment; filename="page_description.bin"' @@ -561,6 +557,7 @@ def stream_data(): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): + print("inside partial update") page = ( Page.objects.filter( pk=pk, workspace__slug=slug, projects__id=project_id @@ -601,24 +598,27 @@ def partial_update(self, request, slug, project_id, pk): cls=DjangoJSONEncoder, ) + print("before base 64") # Get the base64 data from the request - base64_data = request.data.get("description_binary") + base64_data = request.body + print("after base 64", base64_data) # 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, - ) + # 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 = new_binary_data - page.description_html = request.data.get("description_html") - page.description = request.data.get("description") + page.description_binary = base64_data + # page.description_html = request.data.get("description_html") + # page.description = request.data.get("description") + print("before save") page.save() # Return a success response page_version.delay( diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 5ab6fbdf5b3..bb5051848be 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => { ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 200, down: 100 }, + scrollThreshold: { up: 250, down: 120 }, }), ]; }, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 1c015dcb0f7..4a7be514335 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -103,8 +103,14 @@ const calcNodePos = (pos: number, view: EditorView, node: Element) => { export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => { let listType = ""; + let isDragging = false; + let lastClientY = 0; + let scrollAnimationFrame = null; const handleDragStart = (event: DragEvent, view: EditorView) => { view.focus(); + isDragging = true; + lastClientY = event.clientY; + scroll(); if (!event.dataTransfer) return; @@ -180,6 +186,96 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view.dragging = { slice, move: event.ctrlKey }; }; + const handleDragEnd = (event: DragEvent, view: EditorView) => { + isDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } + + view.dom.classList.remove("dragging"); + // [Existing handleDragEnd logic...] + }; + + function scroll() { + if (!isDragging) { + return; + } + const getScrollParent = (node: HTMLElement | SVGElement) => { + let currentParent = node.parentElement; + 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"; + }); + }; + + while (currentParent) { + if (isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document.scrollingElement || document.documentElement; + }; + + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + const maxScrollSpeed = 20; // Adjusted for smoother scrolling + const clientY = lastClientY; // Use the last known clientY + let scrollAmount = 0; + + // Define the upper and lower scroll regions + const scrollRegionUp = scrollThreshold.up; + const scrollRegionDown = window.innerHeight - scrollThreshold.down; + + console.log("clientY: %s", clientY, scrollRegionUp, scrollRegionDown); + // Calculate scroll amount when mouse is near the top + if (clientY < scrollRegionUp) { + const overflow = scrollRegionUp - clientY; + const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } + // Calculate scroll amount when mouse is near the bottom + else if (clientY > scrollRegionDown) { + const overflow = clientY - 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 (above or below) + console.log("clientY: %s", clientY); + if (clientY <= 0) { + const overflow = scrollThreshold.up + Math.abs(clientY); + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); + // __AUTO_GENERATED_PRINT_VAR_START__ + console.log("DragHandlePlugin#scroll#if : %s", ratio); // __AUTO_GENERATED_PRINT_VAR_END__ + const speed = maxScrollSpeed * 2; + scrollAmount = -speed; + } else if (clientY >= window.innerHeight) { + const overflow = clientY - window.innerHeight + scrollThreshold.down; + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); + // __AUTO_GENERATED_PRINT_VAR_START__ + console.log("DragHandlePlugin#scroll#if#if ratio: %s", ratio); // __AUTO_GENERATED_PRINT_VAR_END__ + const speed = maxScrollSpeed * 2; + scrollAmount = speed; + } + + if (scrollAmount !== 0) { + scrollableParent.scrollBy({ top: scrollAmount }); + } + + // Continue the scrolling loop + scrollAnimationFrame = requestAnimationFrame(scroll); + } const handleClick = (event: MouseEvent, view: EditorView) => { view.focus(); @@ -230,61 +326,100 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { dragHandleElement = createDragHandleElement(); + // dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); 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; + dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); + dragHandleElement.addEventListener("blur", (e) => handleDragEnd(e, view)); + window.addEventListener("blur", (e) => handleDragEnd(e, view)); + // End drag on visibility change + document.addEventListener("visibilitychange", (e) => { + if (document.visibilityState === "hidden" && isDragging) { + handleDragEnd(e, view); } - 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; + document.addEventListener("dragover", (event) => { + event.preventDefault(); + if (isDragging) { + lastClientY = event.clientY; } - return document.scrollingElement || document.documentElement; - }; + }); - const maxScrollSpeed = 100; + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - 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 }); - } - }); + // const maxScrollSpeed = 15; + // + // 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"; + // }); + // }; + + // dragHandleElement.addEventListener("drag", (e) => { + // hideDragHandle(); + // 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 scrollableParent = getScrollParent(dragHandleElement); + // if (!scrollableParent) return; + // const scrollThreshold = options.scrollThreshold; + // + // console.log("e", e.clientY); + // if (e.clientY < scrollThreshold.up) { + // const overflow = scrollThreshold.up - e.clientY; + // const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration + // 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration + // const scrollAmount = maxScrollSpeed * ratio; + // scrollableParent.scrollBy({ top: scrollAmount }); + // } + // // 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; + // }, destroy: () => { dragHandleElement?.remove?.(); dragHandleElement = null; + isDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } }, }; }; diff --git a/packages/editor/src/core/plugins/smoooooother-drag.ts b/packages/editor/src/core/plugins/smoooooother-drag.ts new file mode 100644 index 00000000000..12cb9c883d5 --- /dev/null +++ b/packages/editor/src/core/plugins/smoooooother-drag.ts @@ -0,0 +1,473 @@ +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", + ".prosemirror-flat-list", + ].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; + const handleDragStart = (event: DragEvent, view: EditorView) => { + view.focus(); + isDragging = true; + lastClientY = event.clientY; + scroll(); + + 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 handleDragEnd = (event: DragEvent, view: EditorView) => { + isDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } + + view.dom.classList.remove("dragging"); + // [Existing handleDragEnd logic...] + }; + + function scroll() { + if (!isDragging) { + return; + } + const getScrollParent = (node: HTMLElement | SVGElement) => { + let currentParent = node.parentElement; + 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"; + }); + }; + + while (currentParent) { + if (isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document.scrollingElement || document.documentElement; + }; + + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + const maxScrollSpeed = 10; // Adjusted for smoother scrolling + const clientY = lastClientY; // Use the last known clientY + let scrollAmount = 0; + + // Define the upper and lower scroll regions + const scrollRegionUp = scrollThreshold.up; + const scrollRegionDown = window.innerHeight - scrollThreshold.down; + + // Calculate scroll amount when mouse is near the top + if (clientY < scrollRegionUp) { + const overflow = scrollRegionUp - clientY; + const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } + // Calculate scroll amount when mouse is near the bottom + else if (clientY > scrollRegionDown) { + const overflow = clientY - 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 (above or below) + if (clientY <= 0) { + const overflow = scrollThreshold.up + Math.abs(clientY); + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } else if (clientY >= window.innerHeight) { + const overflow = clientY - 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 }); + } + + // Continue the scrolling loop + scrollAnimationFrame = requestAnimationFrame(scroll); + } + 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("dragstart", (e) => handleDragStart(e, view)); + dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); + dragHandleElement.addEventListener("blur", (e) => handleDragEnd(e, view)); + + document.addEventListener("dragover", (event) => { + event.preventDefault(); + if (isDragging) { + lastClientY = event.clientY; + } + }); + + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); + + // const maxScrollSpeed = 15; + // + // 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"; + // }); + // }; + + // dragHandleElement.addEventListener("drag", (e) => { + // hideDragHandle(); + // 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 scrollableParent = getScrollParent(dragHandleElement); + // if (!scrollableParent) return; + // const scrollThreshold = options.scrollThreshold; + // + // console.log("e", e.clientY); + // if (e.clientY < scrollThreshold.up) { + // const overflow = scrollThreshold.up - e.clientY; + // const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration + // 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration + // const scrollAmount = maxScrollSpeed * ratio; + // scrollableParent.scrollBy({ top: scrollAmount }); + // } + // // 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; + // }, + destroy: () => { + dragHandleElement?.remove?.(); + dragHandleElement = null; + isDragging = false; + 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, + }; +}; diff --git a/packages/editor/src/core/plugins/smoother-drag.ts b/packages/editor/src/core/plugins/smoother-drag.ts new file mode 100644 index 00000000000..f8ce23fb601 --- /dev/null +++ b/packages/editor/src/core/plugins/smoother-drag.ts @@ -0,0 +1,363 @@ +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", + ".prosemirror-flat-list", + ].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 = 15; + + dragHandleElement.addEventListener("drag", (e) => { + hideDragHandle(); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + console.log("e", e.clientY); + if (e.clientY < scrollThreshold.up) { + const overflow = scrollThreshold.up - e.clientY; + const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration + 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration + const scrollAmount = maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); + } + // 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, + }; +}; From 01d043813837df1cf4dd4cf12b94893cf2a4443a Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Fri, 25 Oct 2024 16:27:49 +0530 Subject: [PATCH 02/14] fix: refactoring out common fns --- .../editor/src/core/plugins/drag-handle.ts | 151 ++++++------------ 1 file changed, 53 insertions(+), 98 deletions(-) diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 4a7be514335..0812986e481 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -30,6 +30,29 @@ const createDragHandleElement = (): HTMLElement => { 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 = [ @@ -107,6 +130,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let lastClientY = 0; let scrollAnimationFrame = null; const handleDragStart = (event: DragEvent, view: EditorView) => { + console.log("window.innerHeight: %s", window.innerHeight); + view.focus(); isDragging = true; lastClientY = event.clientY; @@ -186,7 +211,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view.dragging = { slice, move: event.ctrlKey }; }; - const handleDragEnd = (event: DragEvent, view: EditorView) => { + const handleDragEnd = (event: TEvent, view: EditorView) => { + event.preventDefault(); isDragging = false; if (scrollAnimationFrame) { cancelAnimationFrame(scrollAnimationFrame); @@ -201,33 +227,12 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp if (!isDragging) { return; } - const getScrollParent = (node: HTMLElement | SVGElement) => { - let currentParent = node.parentElement; - 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"; - }); - }; - - while (currentParent) { - if (isScrollable(currentParent)) { - return currentParent; - } - currentParent = currentParent.parentElement; - } - return document.scrollingElement || document.documentElement; - }; const scrollableParent = getScrollParent(dragHandleElement); if (!scrollableParent) return; const scrollThreshold = options.scrollThreshold; - const maxScrollSpeed = 20; // Adjusted for smoother scrolling + const maxScrollSpeed = 20; const clientY = lastClientY; // Use the last known clientY let scrollAmount = 0; @@ -235,7 +240,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const scrollRegionUp = scrollThreshold.up; const scrollRegionDown = window.innerHeight - scrollThreshold.down; - console.log("clientY: %s", clientY, scrollRegionUp, scrollRegionDown); // Calculate scroll amount when mouse is near the top if (clientY < scrollRegionUp) { const overflow = scrollRegionUp - clientY; @@ -243,6 +247,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const speed = maxScrollSpeed * ratio; scrollAmount = -speed; } + // Calculate scroll amount when mouse is near the bottom else if (clientY > scrollRegionDown) { const overflow = clientY - scrollRegionDown; @@ -252,23 +257,39 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } // Handle cases when mouse is outside the window (above or below) - console.log("clientY: %s", clientY); if (clientY <= 0) { + console.log("ran above"); const overflow = scrollThreshold.up + Math.abs(clientY); const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - // __AUTO_GENERATED_PRINT_VAR_START__ - console.log("DragHandlePlugin#scroll#if : %s", ratio); // __AUTO_GENERATED_PRINT_VAR_END__ - const speed = maxScrollSpeed * 2; + const speed = maxScrollSpeed * ratio; scrollAmount = -speed; } else if (clientY >= window.innerHeight) { + console.log("ran below"); const overflow = clientY - window.innerHeight + scrollThreshold.down; const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); - // __AUTO_GENERATED_PRINT_VAR_START__ - console.log("DragHandlePlugin#scroll#if#if ratio: %s", ratio); // __AUTO_GENERATED_PRINT_VAR_END__ - const speed = maxScrollSpeed * 2; + const speed = maxScrollSpeed * ratio; scrollAmount = speed; } + document.addEventListener("mouseout", function (event) { + // Check if the mouse has left the window from the top or bottom + if (event.clientY <= 0) { + console.log("Mouse left from the top"); + // Handle the logic for when the mouse leaves from the top + const overflow = scrollThreshold.up + Math.abs(event.clientY); + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } else if (event.clientY >= window.innerHeight) { + console.log("Mouse left from the bottom"); + // Handle the logic for when the mouse leaves from the bottom + const overflow = event.clientY - 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 }); } @@ -326,17 +347,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { dragHandleElement = createDragHandleElement(); - // dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); - dragHandleElement.addEventListener("blur", (e) => handleDragEnd(e, view)); + window.addEventListener("blur", (e) => handleDragEnd(e, view)); - // End drag on visibility change - document.addEventListener("visibilitychange", (e) => { - if (document.visibilityState === "hidden" && isDragging) { - handleDragEnd(e, view); - } - }); document.addEventListener("dragover", (event) => { event.preventDefault(); @@ -348,70 +362,11 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - // const maxScrollSpeed = 15; - // - // 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"; - // }); - // }; - - // dragHandleElement.addEventListener("drag", (e) => { - // hideDragHandle(); - // 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 scrollableParent = getScrollParent(dragHandleElement); - // if (!scrollableParent) return; - // const scrollThreshold = options.scrollThreshold; - // - // console.log("e", e.clientY); - // if (e.clientY < scrollThreshold.up) { - // const overflow = scrollThreshold.up - e.clientY; - // const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration - // 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration - // const scrollAmount = maxScrollSpeed * ratio; - // scrollableParent.scrollBy({ top: scrollAmount }); - // } - // // 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; - // }, destroy: () => { dragHandleElement?.remove?.(); dragHandleElement = null; From 46a5bcaf687966de10b1f5ebf23240fdd3dfc4ef Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Thu, 28 Nov 2024 18:19:50 +0530 Subject: [PATCH 03/14] fix: moved to mouse events instead of drag --- .../editor/src/core/plugins/drag-handle.ts | 323 ++++++----- .../editor/src/core/plugins/drag-handle2.ts | 510 ++++++++++++++++++ packages/editor/src/styles/drag-drop.css | 11 + 3 files changed, 707 insertions(+), 137 deletions(-) create mode 100644 packages/editor/src/core/plugins/drag-handle2.ts diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 0812986e481..b35739ebede 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -12,7 +12,7 @@ const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; dragHandleElement.id = "drag-handle"; - dragHandleElement.draggable = true; + 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"; @@ -129,15 +129,28 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let isDragging = false; let lastClientY = 0; let scrollAnimationFrame = null; - const handleDragStart = (event: DragEvent, view: EditorView) => { - console.log("window.innerHeight: %s", window.innerHeight); + let ghostElement: HTMLElement | null = null; + let initialMouseOffset = { x: 0, y: 0 }; + let mouseDownTime = 0; + + const createGhostElement = (view: EditorView, slice: Slice) => { + const { dom } = __serializeForClipboard(view, slice); + dom.classList.add("drag-ghost"); + dom.style.position = "fixed"; + dom.style.pointerEvents = "none"; + dom.style.zIndex = "1000"; + dom.style.opacity = "0.8"; + dom.style.background = "var(--custom-background-100)"; + dom.style.padding = "8px"; + dom.style.borderRadius = "4px"; + dom.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.1)"; + return dom; + }; - view.focus(); - isDragging = true; - lastClientY = event.clientY; - scroll(); + const handleMouseDown = (event: MouseEvent, view: EditorView) => { + if (event.button !== 0) return; - if (!event.dataTransfer) return; + mouseDownTime = Date.now(); const node = nodeDOMAtCoords({ x: event.clientX + 50 + options.dragHandleWidth, @@ -146,157 +159,178 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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); - const { from, to } = view.state.selection; - const diff = from - to; + // 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; + } - const fromSelectionPos = calcNodePos(from, view, node); - let differentNodeSelected = false; + // 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; + } - const nodePos = view.state.doc.resolve(fromSelectionPos); + if (scrollAmount !== 0) { + scrollableParent.scrollBy({ top: scrollAmount }); + } - // 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 - ); - } + scrollAnimationFrame = requestAnimationFrame(scroll); + }; - 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)); - } + const handleMouseMove = (e: MouseEvent) => { + if (Date.now() - mouseDownTime < 200) return; - // 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 (!isDragging) { + isDragging = true; + event.preventDefault(); - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + // Apply the same selection logic as in original code + const { from, to } = view.state.selection; + const diff = from - to; - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + const fromSelectionPos = calcNodePos(from, view, node); + let differentNodeSelected = false; - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - } + const nodePos = view.state.doc.resolve(fromSelectionPos); - const slice = view.state.selection.content(); - const { dom, text } = __serializeForClipboard(view, slice); + 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 + ); + } - event.dataTransfer.clearData(); - event.dataTransfer.setData("text/html", dom.innerHTML); - event.dataTransfer.setData("text/plain", text); - event.dataTransfer.effectAllowed = "copyMove"; + 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)); + } - event.dataTransfer.setDragImage(node, 0, 0); + // Handle special cases + if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { + listType = node.parentElement!.tagName; + } - view.dragging = { slice, move: event.ctrlKey }; - }; + 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)); + } + } + } - const handleDragEnd = (event: TEvent, view: EditorView) => { - event.preventDefault(); - isDragging = false; - if (scrollAnimationFrame) { - cancelAnimationFrame(scrollAnimationFrame); - scrollAnimationFrame = null; - } + // Create ghost after selection is set + const slice = view.state.selection.content(); + ghostElement = createGhostElement(view, slice); + document.body.appendChild(ghostElement); - view.dom.classList.remove("dragging"); - // [Existing handleDragEnd logic...] - }; - - function scroll() { - if (!isDragging) { - return; - } + // Set dragging state for ProseMirror + view.dragging = { slice, move: event.ctrlKey }; - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; + // Start scroll handling when drag begins + scroll(); + } - const maxScrollSpeed = 20; - const clientY = lastClientY; // Use the last known clientY - let scrollAmount = 0; + if (!ghostElement) return; - // Define the upper and lower scroll regions - const scrollRegionUp = scrollThreshold.up; - const scrollRegionDown = window.innerHeight - scrollThreshold.down; + ghostElement.style.left = `${e.clientX}px`; + ghostElement.style.top = `${e.clientY}px`; - // Calculate scroll amount when mouse is near the top - if (clientY < scrollRegionUp) { - const overflow = scrollRegionUp - clientY; - const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } + lastClientY = e.clientY; - // Calculate scroll amount when mouse is near the bottom - else if (clientY > scrollRegionDown) { - const overflow = clientY - scrollRegionDown; - const ratio = Math.min(Math.pow(overflow / scrollThreshold.down, 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = speed; - } + view.dom.dispatchEvent( + new DragEvent("dragover", { + clientX: e.clientX, + clientY: e.clientY, + bubbles: true, + dataTransfer: new DataTransfer(), + }) + ); + }; - // Handle cases when mouse is outside the window (above or below) - if (clientY <= 0) { - console.log("ran above"); - const overflow = scrollThreshold.up + Math.abs(clientY); - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } else if (clientY >= window.innerHeight) { - console.log("ran below"); - const overflow = clientY - window.innerHeight + scrollThreshold.down; - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = speed; - } + const handleMouseUp = (e: MouseEvent) => { + if (!isDragging) return; - document.addEventListener("mouseout", function (event) { - // Check if the mouse has left the window from the top or bottom - if (event.clientY <= 0) { - console.log("Mouse left from the top"); - // Handle the logic for when the mouse leaves from the top - const overflow = scrollThreshold.up + Math.abs(event.clientY); - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } else if (event.clientY >= window.innerHeight) { - console.log("Mouse left from the bottom"); - // Handle the logic for when the mouse leaves from the bottom - const overflow = event.clientY - window.innerHeight + scrollThreshold.down; - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = speed; + // Cancel scroll animation + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; } - }); - if (scrollAmount !== 0) { - scrollableParent.scrollBy({ top: scrollAmount }); - } + // 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); + + // Cleanup + isDragging = false; + ghostElement?.remove(); + ghostElement = null; + + // Trigger ProseMirror's drop handling + view.dom.dispatchEvent(dropEvent); + + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; - // Continue the scrolling loop - scrollAnimationFrame = requestAnimationFrame(scroll); - } const handleClick = (event: MouseEvent, view: EditorView) => { view.focus(); @@ -345,12 +379,28 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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("dragstart", (e) => handleDragStart(e, view)); - dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); + dragHandleElement.addEventListener("mousedown", (e) => handleMouseDown(e, view)); + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - window.addEventListener("blur", (e) => handleDragEnd(e, view)); + // Replace dragend/blur handlers with cleanup + window.addEventListener("blur", (e) => handleCleanup(e, view)); document.addEventListener("dragover", (event) => { event.preventDefault(); @@ -359,9 +409,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } }); - dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); - dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - hideDragHandle(); sideMenu?.appendChild(dragHandleElement); @@ -371,6 +418,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement?.remove?.(); dragHandleElement = null; isDragging = false; + ghostElement?.remove(); + ghostElement = null; if (scrollAnimationFrame) { cancelAnimationFrame(scrollAnimationFrame); scrollAnimationFrame = null; diff --git a/packages/editor/src/core/plugins/drag-handle2.ts b/packages/editor/src/core/plugins/drag-handle2.ts new file mode 100644 index 00000000000..027d92ee945 --- /dev/null +++ b/packages/editor/src/core/plugins/drag-handle2.ts @@ -0,0 +1,510 @@ +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; + let mouseDownTime = 0; + + const createGhostElement = (view: EditorView, slice: Slice) => { + const { dom } = __serializeForClipboard(view, slice); + dom.classList.add("drag-ghost"); + dom.style.position = "fixed"; + dom.style.pointerEvents = "none"; + dom.style.zIndex = "1000"; + dom.style.opacity = "0.8"; + dom.style.background = "var(--custom-background-100)"; + dom.style.padding = "8px"; + dom.style.borderRadius = "4px"; + dom.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.1)"; + return dom; + }; + + 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); + + 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(); + ghostElement = createGhostElement(view, slice); + document.body.appendChild(ghostElement); + + // Set dragging state for ProseMirror + view.dragging = { slice, move: event.ctrlKey }; + } + + 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) => { + const dragDuration = Date.now() - mouseDownTime; + + if (dragDuration < 200 || !isDragging) { + // This was a click, not a drag + handleClick(event, view); + } else if (isDragging) { + // Handle drop + const dropEvent = new DragEvent("drop", { + clientX: e.clientX, + clientY: e.clientY, + bubbles: true, + dataTransfer: new DataTransfer(), + }); + + 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); + + 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)); + }; + + function scroll() { + if (!isDragging) { + return; + } + + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + const maxScrollSpeed = 20; + const clientY = lastClientY; // Use the last known clientY + let scrollAmount = 0; + + // Define the upper and lower scroll regions + const scrollRegionUp = scrollThreshold.up; + const scrollRegionDown = window.innerHeight - scrollThreshold.down; + + // Calculate scroll amount when mouse is near the top + if (clientY < scrollRegionUp) { + const overflow = scrollRegionUp - clientY; + const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } + + // Calculate scroll amount when mouse is near the bottom + else if (clientY > scrollRegionDown) { + const overflow = clientY - 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 (above or below) + if (clientY <= 0) { + console.log("ran above"); + const overflow = scrollThreshold.up + Math.abs(clientY); + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } else if (clientY >= window.innerHeight) { + console.log("ran below"); + const overflow = clientY - window.innerHeight + scrollThreshold.down; + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = speed; + } + + document.addEventListener("mouseout", function (event) { + // Check if the mouse has left the window from the top or bottom + if (event.clientY <= 0) { + console.log("Mouse left from the top"); + // Handle the logic for when the mouse leaves from the top + const overflow = scrollThreshold.up + Math.abs(event.clientY); + const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); + const speed = maxScrollSpeed * ratio; + scrollAmount = -speed; + } else if (event.clientY >= window.innerHeight) { + console.log("Mouse left from the bottom"); + // Handle the logic for when the mouse leaves from the bottom + const overflow = event.clientY - 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 }); + } + + // Continue the scrolling loop + scrollAnimationFrame = requestAnimationFrame(scroll); + } + + 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, + }; +}; diff --git a/packages/editor/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 7db6ed87554..1ed83f8e093 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -114,3 +114,14 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { filter: brightness(90%); } } + +.drag-ghost { + pointer-events: none; + position: fixed; + z-index: 1000; + opacity: 0.8; + transform: translate(-50%, -50%); + background: var(--color-background-100); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 4px; +} From b0fb59a3e422f0425dff24f6011fc7737995c84d Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Fri, 29 Nov 2024 13:16:36 +0530 Subject: [PATCH 04/14] fix: improving the drag preview --- .../editor/src/core/plugins/drag-handle.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index b35739ebede..16ac24d73b4 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 } from "@tiptap/pm/model"; +import { Fragment, Slice, Node, DOMSerializer } 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"; @@ -134,17 +134,31 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let mouseDownTime = 0; const createGhostElement = (view: EditorView, slice: Slice) => { - const { dom } = __serializeForClipboard(view, slice); - dom.classList.add("drag-ghost"); - dom.style.position = "fixed"; - dom.style.pointerEvents = "none"; - dom.style.zIndex = "1000"; - dom.style.opacity = "0.8"; - dom.style.background = "var(--custom-background-100)"; - dom.style.padding = "8px"; - dom.style.borderRadius = "4px"; - dom.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.1)"; - return dom; + 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) => { From 8b77def3ce59af1388bbde40e6e695dd814d664f Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Thu, 19 Dec 2024 09:56:30 +0530 Subject: [PATCH 05/14] fix: added better selection logic --- apiserver/plane/app/views/page/base.py | 14 +- .../core/plugins/drag-handle-new-but-weird.ts | 560 ++++++++++++++++++ .../src/core/plugins/drag-handle-old.ts | 351 +++++++++++ .../editor/src/core/plugins/drag-handle.ts | 170 ++++-- .../src/core/plugins/global-drag-handle.ts | 357 +++++++++++ 5 files changed, 1388 insertions(+), 64 deletions(-) create mode 100644 packages/editor/src/core/plugins/drag-handle-new-but-weird.ts create mode 100644 packages/editor/src/core/plugins/drag-handle-old.ts create mode 100644 packages/editor/src/core/plugins/global-drag-handle.ts 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; From d8c9826220f743c2dc919079f55dcc0b77703bf5 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Thu, 19 Dec 2024 16:38:51 +0530 Subject: [PATCH 06/14] fix: drag handle new way almost working --- .../core/plugins/drag-handle-new-but-weird.ts | 26 ++++++++++--------- .../editor/src/core/plugins/drag-handle.ts | 4 +-- 2 files changed, 16 insertions(+), 14 deletions(-) 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 index a93528a675f..36cef8d1646 100644 --- a/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts +++ b/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts @@ -130,7 +130,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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) => { @@ -159,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; @@ -168,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; } @@ -181,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); @@ -251,7 +254,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp // Create ghost after selection is set const slice = view.state.selection.content(); - console.log("slice", slice); ghostElement = createGhostElement(view, slice); document.body.appendChild(ghostElement); @@ -483,7 +485,7 @@ const createGhostElement = (view: EditorView, slice: Slice) => { let closestValidNode: Element | null = null; let closestEditorContainer: Element; let closestProseMirrorContainer: Element; - if (true) { + if (view.state.selection instanceof NodeSelection) { const dom = getSelectedDOMNode(view); const parent = dom.closest("ul, ol, blockquote"); @@ -492,7 +494,7 @@ const createGhostElement = (view: EditorView, slice: Slice) => { switch (parent?.tagName.toLowerCase()) { case "ul": case "ol": - parentNode = parent.cloneNode() as HTMLElement; + parentNode = parent.cloneNode(false) as HTMLElement; console.log("parentNode", parentNode); closestValidNode = parent.querySelector("li").cloneNode(true) as HTMLElement; console.log("closestValidNode", closestValidNode); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 87df59e09e2..36cef8d1646 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -485,7 +485,7 @@ const createGhostElement = (view: EditorView, slice: Slice) => { let closestValidNode: Element | null = null; let closestEditorContainer: Element; let closestProseMirrorContainer: Element; - if (true) { + if (view.state.selection instanceof NodeSelection) { const dom = getSelectedDOMNode(view); const parent = dom.closest("ul, ol, blockquote"); @@ -494,7 +494,7 @@ const createGhostElement = (view: EditorView, slice: Slice) => { switch (parent?.tagName.toLowerCase()) { case "ul": case "ol": - parentNode = parent.cloneNode() as HTMLElement; + parentNode = parent.cloneNode(false) as HTMLElement; console.log("parentNode", parentNode); closestValidNode = parent.querySelector("li").cloneNode(true) as HTMLElement; console.log("closestValidNode", closestValidNode); From e4de2ffcfcd861cae28c8e7b75e6020819b4acc3 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Thu, 19 Dec 2024 18:53:01 +0530 Subject: [PATCH 07/14] fix: drag-handle old behaviour with better scrolling --- apiserver/plane/app/views/page/base.py | 2 - .../editor/src/core/plugins/drag-handle.ts | 383 ++++++------------ 2 files changed, 120 insertions(+), 265 deletions(-) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 063eb4da741..46ce81ce179 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -495,7 +495,6 @@ def stream_data(): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): - print("inside partial update") page = ( Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) .filter(Q(owned_by=self.request.user) | Q(access=0)) @@ -528,7 +527,6 @@ def partial_update(self, request, slug, project_id, pk): {"description_html": page.description_html}, cls=DjangoJSONEncoder ) - print("before base 64") # Get the base64 data from the request base64_data = request.data.get("description_binary") diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 36cef8d1646..4c375d43e11 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -12,7 +12,7 @@ const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; dragHandleElement.id = "drag-handle"; - dragHandleElement.draggable = false; + 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"; @@ -128,14 +128,16 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let listType = ""; let isDragging = false; let lastClientY = 0; + let lastClientX = 0; let scrollAnimationFrame = null; - let ghostElement: HTMLElement | null = null; - let mouseDownTime = 0; - const handleMouseDown = (event: MouseEvent, view: EditorView) => { - if (event.button !== 0) return; - - mouseDownTime = Date.now(); + const handleDragStart = (event: DragEvent, view: EditorView) => { + view.focus(); + isDragging = true; + lastClientY = event.clientY; + lastClientX = event.clientX; + scroll(); + if (!event.dataTransfer) return; const node = nodeDOMAtCoords({ x: event.clientX + 50 + options.dragHandleWidth, @@ -144,180 +146,122 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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 = 50; // Increased max scroll speed - 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, 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, 1.5), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = speed; - } + const { from, to } = view.state.selection; + const diff = from - to; - // 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), 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), 1.5), 1); - const speed = maxScrollSpeed * 2 * ratio; // Double the speed when outside the window - scrollAmount = speed; - } + const fromSelectionPos = calcNodePos(from, view, node); + let differentNodeSelected = false; - if (scrollAmount !== 0) { - // Use smooth scrolling for a more fluid animation - scrollableParent.scrollBy({ - top: scrollAmount, - behavior: "smooth", - }); - } + const nodePos = view.state.doc.resolve(fromSelectionPos); - scrollAnimationFrame = requestAnimationFrame(scroll); - }; - - const handleMouseMove = (e: MouseEvent) => { - if (Date.now() - mouseDownTime < 200) return; + // 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 + ); + } - if (!isDragging) { - isDragging = true; - event.preventDefault(); + // if (node.className.includes("prosemirror-flat-list")) { + // draggedNodePos = draggedNodePos - 1; + // console.log("draggedNodePos", draggedNodePos); + // } + + 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)); + } - // Apply the same selection logic as in original code - const { from, to } = view.state.selection; - const diff = from - to; + // 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 fromSelectionPos = calcNodePos(from, view, node); - let differentNodeSelected = false; + if (node.matches("blockquote")) { + let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); + if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; - const nodePos = view.state.doc.resolve(fromSelectionPos); + const docSize = view.state.doc.content.size; + nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); - 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 (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + } - 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)); - } + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); - // Handle special cases - if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { - listType = node.parentElement!.tagName; - } + event.dataTransfer.clearData(); + event.dataTransfer.setData("text/html", dom.innerHTML); + event.dataTransfer.setData("text/plain", text); + event.dataTransfer.effectAllowed = "copyMove"; - 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)); - } - } - } + event.dataTransfer.setDragImage(node, 0, 0); - // Create ghost after selection is set - const slice = view.state.selection.content(); - ghostElement = createGhostElement(view, slice); - document.body.appendChild(ghostElement); + view.dragging = { slice, move: event.ctrlKey }; + }; - // Set dragging state for ProseMirror - view.dragging = { slice, move: event.ctrlKey }; + let isMouseInsideWhileDragging = false; + const handleDragEnd = (event: TEvent, view?: EditorView, message?: any) => { + console.log("ran", message); + event.preventDefault(); + isDragging = false; + isMouseInsideWhileDragging = false; + if (scrollAnimationFrame) { + console.log("ran the clean "); + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } - // Start scroll handling when drag begins - scroll(); - } + view?.dom.classList.remove("dragging"); + }; - if (!ghostElement) return; + function scroll() { + if (!isDragging) { + return; + } - ghostElement.style.left = `${e.clientX}px`; - ghostElement.style.top = `${e.clientY}px`; + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; - lastClientY = e.clientY; + const maxScrollSpeed = 20; + let scrollAmount = 0; - view.dom.dispatchEvent( - new DragEvent("dragover", { - clientX: e.clientX, - clientY: e.clientY, - bubbles: true, - dataTransfer: new DataTransfer(), - }) - ); - }; + // Normal scroll behavior when mouse is inside viewport + const scrollRegionUp = scrollThreshold.up; + const scrollRegionDown = window.innerHeight - scrollThreshold.down; - 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); - }; + if (lastClientY < scrollRegionUp) { + const ratio = Math.min(Math.pow((scrollRegionUp - lastClientY) / scrollThreshold.up, 3), 1); + scrollAmount = -maxScrollSpeed * ratio; + } else if (lastClientY > scrollRegionDown) { + // More gradual downward scroll with higher power and dampening + const ratio = Math.min(Math.pow((lastClientY - scrollRegionDown) / scrollThreshold.down, 4), 1) * 0.8; + scrollAmount = maxScrollSpeed * ratio; + } - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; + if (scrollAmount !== 0) { + scrollableParent.scrollBy({ top: scrollAmount }); + } + // Continue the scrolling loop + scrollAnimationFrame = requestAnimationFrame(scroll); + } const handleClick = (event: MouseEvent, view: EditorView) => { view.focus(); @@ -350,7 +294,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp // 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); @@ -366,28 +309,21 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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"); - }; + document.addEventListener("mouseenter", (e) => { + console.log("ran mouseenter "); + handleDragEnd(e); + }); 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)); + dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); + dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); + window.addEventListener("dragleave", (e) => { + if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + console.log("set to true"); + isMouseInsideWhileDragging = true; + } + }); document.addEventListener("dragover", (event) => { event.preventDefault(); @@ -396,6 +332,14 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } }); + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); + document.addEventListener("mousemove", (e) => { + if (isMouseInsideWhileDragging) { + handleDragEnd(e, view); + } + }); + hideDragHandle(); sideMenu?.appendChild(dragHandleElement); @@ -405,8 +349,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement?.remove?.(); dragHandleElement = null; isDragging = false; - ghostElement?.remove(); - ghostElement = null; if (scrollAnimationFrame) { cancelAnimationFrame(scrollAnimationFrame); scrollAnimationFrame = null; @@ -475,88 +417,3 @@ 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 (view.state.selection instanceof NodeSelection) { - 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(false) 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; -} From fee5e0b05339492fc57c020014faee9d4888e645 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Thu, 19 Dec 2024 18:54:44 +0530 Subject: [PATCH 08/14] fix: remove experiments --- .../core/plugins/drag-handle-new-but-weird.ts | 562 ------------------ .../src/core/plugins/drag-handle-old.ts | 351 ----------- .../editor/src/core/plugins/drag-handle2.ts | 510 ---------------- .../src/core/plugins/global-drag-handle.ts | 357 ----------- .../src/core/plugins/smoooooother-drag.ts | 473 --------------- .../editor/src/core/plugins/smoother-drag.ts | 363 ----------- packages/editor/src/styles/drag-drop.css | 11 - 7 files changed, 2627 deletions(-) delete mode 100644 packages/editor/src/core/plugins/drag-handle-new-but-weird.ts delete mode 100644 packages/editor/src/core/plugins/drag-handle-old.ts delete mode 100644 packages/editor/src/core/plugins/drag-handle2.ts delete mode 100644 packages/editor/src/core/plugins/global-drag-handle.ts delete mode 100644 packages/editor/src/core/plugins/smoooooother-drag.ts delete mode 100644 packages/editor/src/core/plugins/smoother-drag.ts 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 deleted file mode 100644 index 36cef8d1646..00000000000 --- a/packages/editor/src/core/plugins/drag-handle-new-but-weird.ts +++ /dev/null @@ -1,562 +0,0 @@ -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; - 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 = 50; // Increased max scroll speed - 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, 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, 1.5), 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), 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), 1.5), 1); - const speed = maxScrollSpeed * 2 * ratio; // Double the speed when outside the window - scrollAmount = speed; - } - - if (scrollAmount !== 0) { - // Use smooth scrolling for a more fluid animation - scrollableParent.scrollBy({ - top: scrollAmount, - behavior: "smooth", - }); - } - - 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(); - 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 (view.state.selection instanceof NodeSelection) { - 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(false) 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 deleted file mode 100644 index 1c015dcb0f7..00000000000 --- a/packages/editor/src/core/plugins/drag-handle-old.ts +++ /dev/null @@ -1,351 +0,0 @@ -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-handle2.ts b/packages/editor/src/core/plugins/drag-handle2.ts deleted file mode 100644 index 027d92ee945..00000000000 --- a/packages/editor/src/core/plugins/drag-handle2.ts +++ /dev/null @@ -1,510 +0,0 @@ -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; - let mouseDownTime = 0; - - const createGhostElement = (view: EditorView, slice: Slice) => { - const { dom } = __serializeForClipboard(view, slice); - dom.classList.add("drag-ghost"); - dom.style.position = "fixed"; - dom.style.pointerEvents = "none"; - dom.style.zIndex = "1000"; - dom.style.opacity = "0.8"; - dom.style.background = "var(--custom-background-100)"; - dom.style.padding = "8px"; - dom.style.borderRadius = "4px"; - dom.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.1)"; - return dom; - }; - - 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); - - 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(); - ghostElement = createGhostElement(view, slice); - document.body.appendChild(ghostElement); - - // Set dragging state for ProseMirror - view.dragging = { slice, move: event.ctrlKey }; - } - - 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) => { - const dragDuration = Date.now() - mouseDownTime; - - if (dragDuration < 200 || !isDragging) { - // This was a click, not a drag - handleClick(event, view); - } else if (isDragging) { - // Handle drop - const dropEvent = new DragEvent("drop", { - clientX: e.clientX, - clientY: e.clientY, - bubbles: true, - dataTransfer: new DataTransfer(), - }); - - 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); - - 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)); - }; - - function scroll() { - if (!isDragging) { - return; - } - - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; - - const maxScrollSpeed = 20; - const clientY = lastClientY; // Use the last known clientY - let scrollAmount = 0; - - // Define the upper and lower scroll regions - const scrollRegionUp = scrollThreshold.up; - const scrollRegionDown = window.innerHeight - scrollThreshold.down; - - // Calculate scroll amount when mouse is near the top - if (clientY < scrollRegionUp) { - const overflow = scrollRegionUp - clientY; - const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } - - // Calculate scroll amount when mouse is near the bottom - else if (clientY > scrollRegionDown) { - const overflow = clientY - 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 (above or below) - if (clientY <= 0) { - console.log("ran above"); - const overflow = scrollThreshold.up + Math.abs(clientY); - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } else if (clientY >= window.innerHeight) { - console.log("ran below"); - const overflow = clientY - window.innerHeight + scrollThreshold.down; - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.down + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = speed; - } - - document.addEventListener("mouseout", function (event) { - // Check if the mouse has left the window from the top or bottom - if (event.clientY <= 0) { - console.log("Mouse left from the top"); - // Handle the logic for when the mouse leaves from the top - const overflow = scrollThreshold.up + Math.abs(event.clientY); - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } else if (event.clientY >= window.innerHeight) { - console.log("Mouse left from the bottom"); - // Handle the logic for when the mouse leaves from the bottom - const overflow = event.clientY - 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 }); - } - - // Continue the scrolling loop - scrollAnimationFrame = requestAnimationFrame(scroll); - } - - 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, - }; -}; diff --git a/packages/editor/src/core/plugins/global-drag-handle.ts b/packages/editor/src/core/plugins/global-drag-handle.ts deleted file mode 100644 index ac2b51295d9..00000000000 --- a/packages/editor/src/core/plugins/global-drag-handle.ts +++ /dev/null @@ -1,357 +0,0 @@ -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; diff --git a/packages/editor/src/core/plugins/smoooooother-drag.ts b/packages/editor/src/core/plugins/smoooooother-drag.ts deleted file mode 100644 index 12cb9c883d5..00000000000 --- a/packages/editor/src/core/plugins/smoooooother-drag.ts +++ /dev/null @@ -1,473 +0,0 @@ -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", - ".prosemirror-flat-list", - ].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; - const handleDragStart = (event: DragEvent, view: EditorView) => { - view.focus(); - isDragging = true; - lastClientY = event.clientY; - scroll(); - - 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 handleDragEnd = (event: DragEvent, view: EditorView) => { - isDragging = false; - if (scrollAnimationFrame) { - cancelAnimationFrame(scrollAnimationFrame); - scrollAnimationFrame = null; - } - - view.dom.classList.remove("dragging"); - // [Existing handleDragEnd logic...] - }; - - function scroll() { - if (!isDragging) { - return; - } - const getScrollParent = (node: HTMLElement | SVGElement) => { - let currentParent = node.parentElement; - 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"; - }); - }; - - while (currentParent) { - if (isScrollable(currentParent)) { - return currentParent; - } - currentParent = currentParent.parentElement; - } - return document.scrollingElement || document.documentElement; - }; - - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; - - const maxScrollSpeed = 10; // Adjusted for smoother scrolling - const clientY = lastClientY; // Use the last known clientY - let scrollAmount = 0; - - // Define the upper and lower scroll regions - const scrollRegionUp = scrollThreshold.up; - const scrollRegionDown = window.innerHeight - scrollThreshold.down; - - // Calculate scroll amount when mouse is near the top - if (clientY < scrollRegionUp) { - const overflow = scrollRegionUp - clientY; - const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } - // Calculate scroll amount when mouse is near the bottom - else if (clientY > scrollRegionDown) { - const overflow = clientY - 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 (above or below) - if (clientY <= 0) { - const overflow = scrollThreshold.up + Math.abs(clientY); - const ratio = Math.min(Math.pow(overflow / (scrollThreshold.up + 100), 2), 1); - const speed = maxScrollSpeed * ratio; - scrollAmount = -speed; - } else if (clientY >= window.innerHeight) { - const overflow = clientY - 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 }); - } - - // Continue the scrolling loop - scrollAnimationFrame = requestAnimationFrame(scroll); - } - 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("dragstart", (e) => handleDragStart(e, view)); - dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); - dragHandleElement.addEventListener("blur", (e) => handleDragEnd(e, view)); - - document.addEventListener("dragover", (event) => { - event.preventDefault(); - if (isDragging) { - lastClientY = event.clientY; - } - }); - - dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); - dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - - // const maxScrollSpeed = 15; - // - // 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"; - // }); - // }; - - // dragHandleElement.addEventListener("drag", (e) => { - // hideDragHandle(); - // 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 scrollableParent = getScrollParent(dragHandleElement); - // if (!scrollableParent) return; - // const scrollThreshold = options.scrollThreshold; - // - // console.log("e", e.clientY); - // if (e.clientY < scrollThreshold.up) { - // const overflow = scrollThreshold.up - e.clientY; - // const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration - // 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration - // const scrollAmount = maxScrollSpeed * ratio; - // scrollableParent.scrollBy({ top: scrollAmount }); - // } - // // 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; - // }, - destroy: () => { - dragHandleElement?.remove?.(); - dragHandleElement = null; - isDragging = false; - 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, - }; -}; diff --git a/packages/editor/src/core/plugins/smoother-drag.ts b/packages/editor/src/core/plugins/smoother-drag.ts deleted file mode 100644 index f8ce23fb601..00000000000 --- a/packages/editor/src/core/plugins/smoother-drag.ts +++ /dev/null @@ -1,363 +0,0 @@ -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", - ".prosemirror-flat-list", - ].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 = 15; - - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; - - console.log("e", e.clientY); - if (e.clientY < scrollThreshold.up) { - const overflow = scrollThreshold.up - e.clientY; - const ratio = Math.min(Math.pow(overflow / scrollThreshold.up, 3), 1); // Use power of 3 for smoother acceleration - 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(Math.pow(overflow / scrollThreshold.down, 3), 1); // Use power of 3 for smoother acceleration - const scrollAmount = maxScrollSpeed * ratio; - scrollableParent.scrollBy({ top: scrollAmount }); - } - // 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/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 1ed83f8e093..7db6ed87554 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -114,14 +114,3 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { filter: brightness(90%); } } - -.drag-ghost { - pointer-events: none; - position: fixed; - z-index: 1000; - opacity: 0.8; - transform: translate(-50%, -50%); - background: var(--color-background-100); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - border-radius: 4px; -} From 49b7216567be3297e92b136b7a2d473520ae6822 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 13:09:20 +0530 Subject: [PATCH 09/14] fix: better scroll thresholds --- .../editor/src/core/extensions/side-menu.tsx | 2 +- .../editor/src/core/plugins/drag-handle.ts | 98 +++++++++++++------ 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index bb5051848be..a3f7d57e9c1 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => { ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 250, down: 120 }, + scrollThreshold: { up: 250, down: 150 }, }), ]; }, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 4c375d43e11..b19fdaee55f 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -128,14 +128,14 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let listType = ""; let isDragging = false; let lastClientY = 0; - let lastClientX = 0; let scrollAnimationFrame = null; + let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; const handleDragStart = (event: DragEvent, view: EditorView) => { view.focus(); isDragging = true; lastClientY = event.clientY; - lastClientX = event.clientX; scroll(); if (!event.dataTransfer) return; @@ -168,11 +168,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp ); } - // if (node.className.includes("prosemirror-flat-list")) { - // draggedNodePos = draggedNodePos - 1; - // console.log("draggedNodePos", draggedNodePos); - // } - 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); @@ -215,14 +210,11 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view.dragging = { slice, move: event.ctrlKey }; }; - let isMouseInsideWhileDragging = false; const handleDragEnd = (event: TEvent, view?: EditorView, message?: any) => { - console.log("ran", message); event.preventDefault(); isDragging = false; isMouseInsideWhileDragging = false; if (scrollAnimationFrame) { - console.log("ran the clean "); cancelAnimationFrame(scrollAnimationFrame); scrollAnimationFrame = null; } @@ -230,38 +222,70 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view?.dom.classList.remove("dragging"); }; + let currentScrollSpeed = 0; + const maxScrollSpeed = 10; + const acceleration = 0.2; + + // Add variables to track scrolling state: + let isScrolling = false; + let wasScrolling = false; + function scroll() { + const dropCursorElement = document.querySelector(".prosemirror-drop-cursor"); if (!isDragging) { + currentScrollSpeed = 0; return; } const scrollableParent = getScrollParent(dragHandleElement); if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; - const maxScrollSpeed = 20; - let scrollAmount = 0; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; - // Normal scroll behavior when mouse is inside viewport - const scrollRegionUp = scrollThreshold.up; - const scrollRegionDown = window.innerHeight - scrollThreshold.down; + let targetScrollAmount = 0; - if (lastClientY < scrollRegionUp) { - const ratio = Math.min(Math.pow((scrollRegionUp - lastClientY) / scrollThreshold.up, 3), 1); - scrollAmount = -maxScrollSpeed * ratio; + if (isDraggedOutsideWindow === "top") { + targetScrollAmount = -maxScrollSpeed * 5; + } else if (isDraggedOutsideWindow === "bottom") { + targetScrollAmount = maxScrollSpeed * 5; + } else if (lastClientY < scrollRegionUp) { + const ratio = easeOutQuad((scrollRegionUp - lastClientY) / options.scrollThreshold.up); + targetScrollAmount = -maxScrollSpeed * ratio; } else if (lastClientY > scrollRegionDown) { - // More gradual downward scroll with higher power and dampening - const ratio = Math.min(Math.pow((lastClientY - scrollRegionDown) / scrollThreshold.down, 4), 1) * 0.8; - scrollAmount = maxScrollSpeed * ratio; + const ratio = easeOutQuad((lastClientY - scrollRegionDown) / options.scrollThreshold.down); + targetScrollAmount = maxScrollSpeed * ratio; + } + + currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; + + const maxSpeedLimit = 50; + currentScrollSpeed = Math.max(-maxSpeedLimit, Math.min(maxSpeedLimit, currentScrollSpeed)); + + // Determine if scrolling should be active: + isScrolling = Math.abs(currentScrollSpeed) > 0.1; + + // Detect changes from wasScrolling to isScrolling: + if (!wasScrolling && isScrolling) { + dropCursorElement?.classList.add("text-transparent"); + } else if (wasScrolling && !isScrolling) { + dropCursorElement?.classList.remove("text-transparent"); } - if (scrollAmount !== 0) { - scrollableParent.scrollBy({ top: scrollAmount }); + // Track new state: + wasScrolling = isScrolling; + + if (isScrolling) { + scrollableParent.scrollBy({ top: currentScrollSpeed }); } - // Continue the scrolling loop scrollAnimationFrame = requestAnimationFrame(scroll); } + + function easeOutQuad(t) { + return t * (2 - t); + } + const handleClick = (event: MouseEvent, view: EditorView) => { view.focus(); @@ -309,18 +333,12 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement?.classList.add("drag-handle-hidden"); }; - document.addEventListener("mouseenter", (e) => { - console.log("ran mouseenter "); - handleDragEnd(e); - }); - const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { dragHandleElement = createDragHandleElement(); dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); window.addEventListener("dragleave", (e) => { if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { - console.log("set to true"); isMouseInsideWhileDragging = true; } }); @@ -340,6 +358,24 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } }); + window.addEventListener("dragleave", (e) => { + if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + isMouseInsideWhileDragging = true; + + const windowMiddleY = window.innerHeight / 2; + + if (lastClientY < windowMiddleY) { + isDraggedOutsideWindow = "top"; + } else { + isDraggedOutsideWindow = "bottom"; + } + } + }); + + window.addEventListener("dragenter", () => { + isDraggedOutsideWindow = false; + }); + hideDragHandle(); sideMenu?.appendChild(dragHandleElement); From 6331570fd61bdd57e2bd048356db646ed26a8c00 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 13:10:32 +0530 Subject: [PATCH 10/14] fix: transition to drop cursor added --- packages/editor/src/core/extensions/extensions.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 71126c57664..a002e73381f 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -85,7 +85,8 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { }, }, dropcursor: { - class: "text-custom-text-300", + class: + "text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]", }, ...(enableHistory ? {} : { history: false }), }), From 424e3c5193fee47973fd65fb5bb064f96e560ba7 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 13:54:27 +0530 Subject: [PATCH 11/14] fix: drag handling speed --- .../editor/src/core/plugins/drag-handle.ts | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index b19fdaee55f..2703aecb4b6 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -130,7 +130,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let lastClientY = 0; let scrollAnimationFrame = null; let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; - let isMouseInsideWhileDragging = false; const handleDragStart = (event: DragEvent, view: EditorView) => { view.focus(); @@ -210,6 +209,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view.dragging = { slice, move: event.ctrlKey }; }; + let isMouseInsideWhileDragging = false; const handleDragEnd = (event: TEvent, view?: EditorView, message?: any) => { event.preventDefault(); isDragging = false; @@ -223,15 +223,11 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp }; let currentScrollSpeed = 0; - const maxScrollSpeed = 10; - const acceleration = 0.2; - - // Add variables to track scrolling state: - let isScrolling = false; - let wasScrolling = false; + const maxScrollSpeed = 10; // Increased from 40 + const acceleration = 0.2; // Reduced for faster response + const scrollDivisor = 1; // Reduced from 4 for faster scrolling function scroll() { - const dropCursorElement = document.querySelector(".prosemirror-drop-cursor"); if (!isDragging) { currentScrollSpeed = 0; return; @@ -240,8 +236,9 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const scrollableParent = getScrollParent(dragHandleElement); if (!scrollableParent) return; - const scrollRegionUp = options.scrollThreshold.up; - const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; + const scrollThreshold = Math.min(100, scrollableParent.clientHeight * 0.15); + const scrollRegionUp = scrollThreshold; + const scrollRegionDown = window.innerHeight - scrollThreshold; let targetScrollAmount = 0; @@ -250,33 +247,17 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } else if (isDraggedOutsideWindow === "bottom") { targetScrollAmount = maxScrollSpeed * 5; } else if (lastClientY < scrollRegionUp) { - const ratio = easeOutQuad((scrollRegionUp - lastClientY) / options.scrollThreshold.up); + const ratio = easeOutQuad((scrollRegionUp - lastClientY) / scrollThreshold); targetScrollAmount = -maxScrollSpeed * ratio; } else if (lastClientY > scrollRegionDown) { - const ratio = easeOutQuad((lastClientY - scrollRegionDown) / options.scrollThreshold.down); + const ratio = easeOutQuad((lastClientY - scrollRegionDown) / scrollThreshold); targetScrollAmount = maxScrollSpeed * ratio; } currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; - const maxSpeedLimit = 50; - currentScrollSpeed = Math.max(-maxSpeedLimit, Math.min(maxSpeedLimit, currentScrollSpeed)); - - // Determine if scrolling should be active: - isScrolling = Math.abs(currentScrollSpeed) > 0.1; - - // Detect changes from wasScrolling to isScrolling: - if (!wasScrolling && isScrolling) { - dropCursorElement?.classList.add("text-transparent"); - } else if (wasScrolling && !isScrolling) { - dropCursorElement?.classList.remove("text-transparent"); - } - - // Track new state: - wasScrolling = isScrolling; - - if (isScrolling) { - scrollableParent.scrollBy({ top: currentScrollSpeed }); + if (Math.abs(currentScrollSpeed) > 0.1) { + scrollableParent.scrollBy({ top: currentScrollSpeed / scrollDivisor }); } scrollAnimationFrame = requestAnimationFrame(scroll); @@ -372,7 +353,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } }); - window.addEventListener("dragenter", () => { + window.addEventListener("dragenter", (e) => { isDraggedOutsideWindow = false; }); From 42e768a36f2b5315d47148df6a6098637ab7b03f Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 14:21:14 +0530 Subject: [PATCH 12/14] fix: cleaning up listeners --- .../editor/src/core/plugins/drag-handle.ts | 90 +++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 2703aecb4b6..d53a0ed1dae 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -8,6 +8,26 @@ import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; const verticalEllipsisIcon = ''; +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(", "); + +const maxScrollSpeed = 10; +const acceleration = 0.2; +const scrollDivisor = 1; + +const scrollParentCache = new WeakMap(); + const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; @@ -42,32 +62,27 @@ const isScrollable = (node: HTMLElement | SVGElement) => { }; const getScrollParent = (node: HTMLElement | SVGElement) => { + if (scrollParentCache.has(node)) { + return scrollParentCache.get(node); + } + let currentParent = node.parentElement; while (currentParent) { if (isScrollable(currentParent)) { + scrollParentCache.set(node, currentParent); return currentParent; } currentParent = currentParent.parentElement; } - return document.scrollingElement || document.documentElement; + + const result = document.scrollingElement || document.documentElement; + scrollParentCache.set(node, result); + return result; }; 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")) { @@ -130,6 +145,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let lastClientY = 0; let scrollAnimationFrame = null; let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; + let currentScrollSpeed = 0; const handleDragStart = (event: DragEvent, view: EditorView) => { view.focus(); @@ -209,8 +226,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view.dragging = { slice, move: event.ctrlKey }; }; - let isMouseInsideWhileDragging = false; - const handleDragEnd = (event: TEvent, view?: EditorView, message?: any) => { + const handleDragEnd = (event: TEvent, view?: EditorView) => { event.preventDefault(); isDragging = false; isMouseInsideWhileDragging = false; @@ -222,11 +238,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp view?.dom.classList.remove("dragging"); }; - let currentScrollSpeed = 0; - const maxScrollSpeed = 10; // Increased from 40 - const acceleration = 0.2; // Reduced for faster response - const scrollDivisor = 1; // Reduced from 4 for faster scrolling - function scroll() { if (!isDragging) { currentScrollSpeed = 0; @@ -318,28 +329,23 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement = createDragHandleElement(); dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); - window.addEventListener("dragleave", (e) => { - if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { - isMouseInsideWhileDragging = true; - } - }); + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - document.addEventListener("dragover", (event) => { + const dragOverHandler = (e: DragEvent) => { event.preventDefault(); if (isDragging) { - lastClientY = event.clientY; + lastClientY = e.clientY; } - }); + }; - dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); - dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - document.addEventListener("mousemove", (e) => { + const mouseMoveHandler = (e: MouseEvent) => { if (isMouseInsideWhileDragging) { handleDragEnd(e, view); } - }); + }; - window.addEventListener("dragleave", (e) => { + const dragLeaveHandler = (e: DragEvent) => { if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { isMouseInsideWhileDragging = true; @@ -351,11 +357,17 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp isDraggedOutsideWindow = "bottom"; } } - }); + }; - window.addEventListener("dragenter", (e) => { + const dragEnterHandler = () => { isDraggedOutsideWindow = false; - }); + }; + + window.addEventListener("dragleave", dragLeaveHandler); + window.addEventListener("dragenter", dragEnterHandler); + + document.addEventListener("dragover", dragOverHandler); + document.addEventListener("mousemove", mouseMoveHandler); hideDragHandle(); @@ -370,6 +382,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp cancelAnimationFrame(scrollAnimationFrame); scrollAnimationFrame = null; } + window.removeEventListener("dragleave", dragLeaveHandler); + window.removeEventListener("dragenter", dragEnterHandler); + document.removeEventListener("dragover", dragOverHandler); + document.removeEventListener("mousemove", mouseMoveHandler); }, }; }; From c28d35a1aac685719eafd3996a52589e422b5e2d Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 16:37:52 +0530 Subject: [PATCH 13/14] fix: common out selection and dragging logic --- .../editor/src/core/plugins/drag-handle.ts | 271 ++++++++---------- 1 file changed, 122 insertions(+), 149 deletions(-) diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index d53a0ed1dae..ffa2f2e4f7e 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -1,5 +1,5 @@ -import { Fragment, Slice, Node } from "@tiptap/pm/model"; -import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; // @ts-expect-error __serializeForClipboard's is not exported import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; // extensions @@ -28,6 +28,10 @@ const scrollDivisor = 1; const scrollParentCache = new WeakMap(); +function easeOutQuadAnimation(t: number) { + return t * (2 - t); +} + const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; @@ -123,22 +127,6 @@ const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => { })?.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; @@ -148,82 +136,16 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let isMouseInsideWhileDragging = false; let currentScrollSpeed = 0; + const handleClick = (event: MouseEvent, view: EditorView) => { + handleNodeSelection(event, view, false, options); + }; + const handleDragStart = (event: DragEvent, view: EditorView) => { - view.focus(); + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); + listType = listTypeFromDragStart; isDragging = true; lastClientY = event.clientY; scroll(); - 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 { - 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 handleDragEnd = (event: TEvent, view?: EditorView) => { @@ -258,10 +180,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } else if (isDraggedOutsideWindow === "bottom") { targetScrollAmount = maxScrollSpeed * 5; } else if (lastClientY < scrollRegionUp) { - const ratio = easeOutQuad((scrollRegionUp - lastClientY) / scrollThreshold); + const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollThreshold); targetScrollAmount = -maxScrollSpeed * ratio; } else if (lastClientY > scrollRegionDown) { - const ratio = easeOutQuad((lastClientY - scrollRegionDown) / scrollThreshold); + const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / scrollThreshold); targetScrollAmount = maxScrollSpeed * ratio; } @@ -274,49 +196,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp scrollAnimationFrame = requestAnimationFrame(scroll); } - function easeOutQuad(t) { - return t * (2 - t); - } - - 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); - - // 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"); @@ -333,7 +212,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); const dragOverHandler = (e: DragEvent) => { - event.preventDefault(); + e.preventDefault(); if (isDragging) { lastClientY = e.clientY; } @@ -414,29 +293,36 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const resolvedPos = view.state.doc.resolve(dropPos.pos); let isDroppedInsideList = false; + let dropDepth = 0; // 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; + dropDepth = i; 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); + // Handle nested list items and task items + if (droppedNode.type.name === "listItem") { + let slice = view.state.selection.content(); + let newFragment = slice.content; + + // If dropping outside a list or at a different depth, adjust the structure + if (!isDroppedInsideList || dropDepth !== resolvedPos.depth) { + // Flatten the structure if needed + newFragment = flattenListStructure(newFragment, view.state.schema); + } - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); + // Wrap in appropriate list type if dropped outside a list + if (!isDroppedInsideList) { + const listNodeType = + listType === "OL" ? view.state.schema.nodes.orderedList : view.state.schema.nodes.bulletList; + newFragment = Fragment.from(listNodeType.create(null, newFragment)); + } + + slice = new Slice(newFragment, slice.openStart, slice.openEnd); view.dragging = { slice, move: event.ctrlKey }; } }, @@ -450,3 +336,90 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp domEvents, }; }; + +// Helper function to flatten nested list structure +function flattenListStructure(fragment: Fragment, schema: Schema): Fragment { + const result: Node[] = []; + fragment.forEach((node) => { + if (node.type === schema.nodes.listItem || node.type === schema.nodes.taskItem) { + result.push(node); + if ( + node.content.firstChild && + (node.content.firstChild.type === schema.nodes.bulletList || + node.content.firstChild.type === schema.nodes.orderedList) + ) { + const sublist = node.content.firstChild; + const flattened = flattenListStructure(sublist.content, schema); + flattened.forEach((subNode) => result.push(subNode)); + } + } + }); + return Fragment.from(result); +} + +const handleNodeSelection = ( + event: MouseEvent | DragEvent, + view: EditorView, + isDragStart: boolean, + options: SideMenuPluginProps +) => { + let listType = ""; + view.focus(); + + 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; + + // Handle blockquotes separately + if (node.matches("blockquote")) { + draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); + if (draggedNodePos === null || draggedNodePos === undefined) return; + } else { + // Resolve the position to get the parent node + const $pos = view.state.doc.resolve(draggedNodePos); + + // If it's a nested list item or task item, move up to the item level + if (($pos.parent.type.name === "listItem" || $pos.parent.type.name === "taskItem") && $pos.depth > 1) { + draggedNodePos = $pos.before($pos.depth); + } + } + + const docSize = view.state.doc.content.size; + draggedNodePos = Math.max(0, Math.min(draggedNodePos, docSize)); + + // Use NodeSelection to select the node at the calculated position + const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); + + // Dispatch the transaction to update the selection + view.dispatch(view.state.tr.setSelection(nodeSelection)); + + if (isDragStart) { + // Additional logic for drag start + if (event instanceof DragEvent && !event.dataTransfer) return; + + if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") { + listType = node.closest("ol, ul")?.tagName || ""; + } + + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); + + if (event instanceof DragEvent) { + 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 }; + } + + return { listType }; +}; From 065c5d21d8070ca357a92d2e16ad1368de7e9ba2 Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 23 Dec 2024 17:15:41 +0530 Subject: [PATCH 14/14] fix: scroll threshold logic fixed --- .../editor/src/core/extensions/side-menu.tsx | 2 +- packages/editor/src/core/plugins/drag-handle.ts | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index a3f7d57e9c1..eac71301200 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => { ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 250, down: 150 }, + scrollThreshold: { up: 200, down: 150 }, }), ]; }, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index ffa2f2e4f7e..fabb38f527d 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -22,9 +22,8 @@ const generalSelectors = [ ".editor-callout-component", ].join(", "); -const maxScrollSpeed = 10; -const acceleration = 0.2; -const scrollDivisor = 1; +const maxScrollSpeed = 20; +const acceleration = 0.5; const scrollParentCache = new WeakMap(); @@ -169,9 +168,8 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const scrollableParent = getScrollParent(dragHandleElement); if (!scrollableParent) return; - const scrollThreshold = Math.min(100, scrollableParent.clientHeight * 0.15); - const scrollRegionUp = scrollThreshold; - const scrollRegionDown = window.innerHeight - scrollThreshold; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; let targetScrollAmount = 0; @@ -180,17 +178,17 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } else if (isDraggedOutsideWindow === "bottom") { targetScrollAmount = maxScrollSpeed * 5; } else if (lastClientY < scrollRegionUp) { - const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollThreshold); + const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up); targetScrollAmount = -maxScrollSpeed * ratio; } else if (lastClientY > scrollRegionDown) { - const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / scrollThreshold); + const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down); targetScrollAmount = maxScrollSpeed * ratio; } currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; if (Math.abs(currentScrollSpeed) > 0.1) { - scrollableParent.scrollBy({ top: currentScrollSpeed / scrollDivisor }); + scrollableParent.scrollBy({ top: currentScrollSpeed }); } scrollAnimationFrame = requestAnimationFrame(scroll);