From 20260f0720761143fd394539c94c72a990e9d697 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 23 Dec 2024 20:04:34 +0530 Subject: [PATCH] [PE-101] feat: smooth scrolling in editor while dragging and dropping nodes (#6233) * fix: smoother drag scrolling * fix: refactoring out common fns * fix: moved to mouse events instead of drag * fix: improving the drag preview * fix: added better selection logic * fix: drag handle new way almost working * fix: drag-handle old behaviour with better scrolling * fix: remove experiments * fix: better scroll thresholds * fix: transition to drop cursor added * fix: drag handling speed * fix: cleaning up listeners * fix: common out selection and dragging logic * fix: scroll threshold logic fixed --- .../editor/src/core/extensions/extensions.tsx | 3 +- .../editor/src/core/extensions/side-menu.tsx | 2 +- .../editor/src/core/plugins/drag-handle.ts | 432 ++++++++++-------- 3 files changed, 255 insertions(+), 182 deletions(-) diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 748bd10407..9fc98bf587 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -82,7 +82,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 }), }), diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 5ab6fbdf5b..eac7130120 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: 200, down: 150 }, }), ]; }, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 1c015dcb0f..fabb38f527 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 @@ -8,6 +8,29 @@ 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 = 20; +const acceleration = 0.5; + +const scrollParentCache = new WeakMap(); + +function easeOutQuadAnimation(t: number) { + return t * (2 - t); +} + const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; @@ -30,21 +53,39 @@ 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) => { + 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; + } + + 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")) { @@ -85,140 +126,73 @@ 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 = ""; - 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 }; - }; + let isDragging = false; + let lastClientY = 0; + let scrollAnimationFrame = null; + let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; + let currentScrollSpeed = 0; const handleClick = (event: MouseEvent, view: EditorView) => { - view.focus(); + handleNodeSelection(event, view, false, options); + }; - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); + const handleDragStart = (event: DragEvent, view: EditorView) => { + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); + listType = listTypeFromDragStart; + isDragging = true; + lastClientY = event.clientY; + scroll(); + }; - if (!(node instanceof Element)) return; + const handleDragEnd = (event: TEvent, view?: EditorView) => { + event.preventDefault(); + isDragging = false; + isMouseInsideWhileDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + view?.dom.classList.remove("dragging"); + }; - 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)); - } + function scroll() { + if (!isDragging) { + currentScrollSpeed = 0; return; } - let nodePos = nodePosAtDOM(node, view, options); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; - if (nodePos === null || nodePos === undefined) return; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; - // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view, node); + let targetScrollAmount = 0; - // TODO FIX ERROR - // Use NodeSelection to select the node at the calculated position - const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + if (isDraggedOutsideWindow === "top") { + targetScrollAmount = -maxScrollSpeed * 5; + } else if (isDraggedOutsideWindow === "bottom") { + targetScrollAmount = maxScrollSpeed * 5; + } else if (lastClientY < scrollRegionUp) { + const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up); + targetScrollAmount = -maxScrollSpeed * ratio; + } else if (lastClientY > scrollRegionDown) { + const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down); + targetScrollAmount = maxScrollSpeed * ratio; + } - // Dispatch the transaction to update the selection - view.dispatch(view.state.tr.setSelection(nodeSelection)); - }; + currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; + + if (Math.abs(currentScrollSpeed) > 0.1) { + scrollableParent.scrollBy({ top: currentScrollSpeed }); + } + + scrollAnimationFrame = requestAnimationFrame(scroll); + } let dragHandleElement: HTMLElement | null = null; // drag handle view actions @@ -231,51 +205,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp 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("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 dragOverHandler = (e: DragEvent) => { + e.preventDefault(); + if (isDragging) { + lastClientY = e.clientY; } - 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; + const mouseMoveHandler = (e: MouseEvent) => { + if (isMouseInsideWhileDragging) { + handleDragEnd(e, view); + } + }; + + const dragLeaveHandler = (e: DragEvent) => { + 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"; } - currentParent = currentParent.parentElement; } - return document.scrollingElement || document.documentElement; }; - const maxScrollSpeed = 100; + const dragEnterHandler = () => { + isDraggedOutsideWindow = false; + }; - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; + window.addEventListener("dragleave", dragLeaveHandler); + window.addEventListener("dragenter", dragEnterHandler); - 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 }); - } - }); + document.addEventListener("dragover", dragOverHandler); + document.addEventListener("mousemove", mouseMoveHandler); hideDragHandle(); @@ -285,6 +254,15 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp destroy: () => { dragHandleElement?.remove?.(); dragHandleElement = null; + isDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } + window.removeEventListener("dragleave", dragLeaveHandler); + window.removeEventListener("dragenter", dragEnterHandler); + document.removeEventListener("dragover", dragOverHandler); + document.removeEventListener("mousemove", mouseMoveHandler); }, }; }; @@ -313,29 +291,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; - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); + // 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); + } + + // 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 }; } }, @@ -349,3 +334,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 }; +};