[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
This commit is contained in:
parent
6070ed4d36
commit
20260f0720
3 changed files with 255 additions and 182 deletions
|
|
@ -82,7 +82,8 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dropcursor: {
|
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 }),
|
...(enableHistory ? {} : { history: false }),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
|
||||||
ai: aiEnabled,
|
ai: aiEnabled,
|
||||||
dragDrop: dragDropEnabled,
|
dragDrop: dragDropEnabled,
|
||||||
},
|
},
|
||||||
scrollThreshold: { up: 200, down: 100 },
|
scrollThreshold: { up: 200, down: 150 },
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
import { NodeSelection } from "@tiptap/pm/state";
|
||||||
// @ts-expect-error __serializeForClipboard's is not exported
|
// @ts-expect-error __serializeForClipboard's is not exported
|
||||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||||
// extensions
|
// extensions
|
||||||
|
|
@ -8,6 +8,29 @@ import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||||
const verticalEllipsisIcon =
|
const verticalEllipsisIcon =
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
|
||||||
|
|
||||||
|
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 createDragHandleElement = (): HTMLElement => {
|
||||||
const dragHandleElement = document.createElement("button");
|
const dragHandleElement = document.createElement("button");
|
||||||
dragHandleElement.type = "button";
|
dragHandleElement.type = "button";
|
||||||
|
|
@ -30,21 +53,39 @@ const createDragHandleElement = (): HTMLElement => {
|
||||||
return dragHandleElement;
|
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 }) => {
|
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||||
const elements = document.elementsFromPoint(coords.x, coords.y);
|
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) {
|
for (const elem of elements) {
|
||||||
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
||||||
|
|
@ -85,140 +126,73 @@ const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
|
||||||
})?.inside;
|
})?.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 => {
|
export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
|
||||||
let listType = "";
|
let listType = "";
|
||||||
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
let isDragging = false;
|
||||||
view.focus();
|
let lastClientY = 0;
|
||||||
|
let scrollAnimationFrame = null;
|
||||||
if (!event.dataTransfer) return;
|
let isDraggedOutsideWindow: "top" | "bottom" | boolean = false;
|
||||||
|
let isMouseInsideWhileDragging = false;
|
||||||
const node = nodeDOMAtCoords({
|
let currentScrollSpeed = 0;
|
||||||
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) => {
|
const handleClick = (event: MouseEvent, view: EditorView) => {
|
||||||
view.focus();
|
handleNodeSelection(event, view, false, options);
|
||||||
|
};
|
||||||
|
|
||||||
const node = nodeDOMAtCoords({
|
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options);
|
||||||
y: event.clientY,
|
listType = listTypeFromDragStart;
|
||||||
});
|
isDragging = true;
|
||||||
|
lastClientY = event.clientY;
|
||||||
|
scroll();
|
||||||
|
};
|
||||||
|
|
||||||
if (!(node instanceof Element)) return;
|
const handleDragEnd = <TEvent extends DragEvent | FocusEvent>(event: TEvent, view?: EditorView) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
isMouseInsideWhileDragging = false;
|
||||||
|
if (scrollAnimationFrame) {
|
||||||
|
cancelAnimationFrame(scrollAnimationFrame);
|
||||||
|
scrollAnimationFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (node.matches("blockquote")) {
|
view?.dom.classList.remove("dragging");
|
||||||
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
|
};
|
||||||
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
|
|
||||||
|
|
||||||
const docSize = view.state.doc.content.size;
|
function scroll() {
|
||||||
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
|
if (!isDragging) {
|
||||||
|
currentScrollSpeed = 0;
|
||||||
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
|
|
||||||
// TODO FIX ERROR
|
|
||||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
|
|
||||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
|
||||||
}
|
|
||||||
return;
|
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
|
let targetScrollAmount = 0;
|
||||||
nodePos = calcNodePos(nodePos, view, node);
|
|
||||||
|
|
||||||
// TODO FIX ERROR
|
if (isDraggedOutsideWindow === "top") {
|
||||||
// Use NodeSelection to select the node at the calculated position
|
targetScrollAmount = -maxScrollSpeed * 5;
|
||||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
|
} 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
|
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
|
||||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
|
||||||
};
|
if (Math.abs(currentScrollSpeed) > 0.1) {
|
||||||
|
scrollableParent.scrollBy({ top: currentScrollSpeed });
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
let dragHandleElement: HTMLElement | null = null;
|
let dragHandleElement: HTMLElement | null = null;
|
||||||
// drag handle view actions
|
// drag handle view actions
|
||||||
|
|
@ -231,51 +205,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||||
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
|
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
|
||||||
dragHandleElement = createDragHandleElement();
|
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("click", (e) => handleClick(e, view));
|
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
|
||||||
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
||||||
|
|
||||||
const isScrollable = (node: HTMLElement | SVGElement) => {
|
const dragOverHandler = (e: DragEvent) => {
|
||||||
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
e.preventDefault();
|
||||||
return false;
|
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) => {
|
const mouseMoveHandler = (e: MouseEvent) => {
|
||||||
let currentParent = node.parentElement;
|
if (isMouseInsideWhileDragging) {
|
||||||
while (currentParent) {
|
handleDragEnd(e, view);
|
||||||
if (isScrollable(currentParent)) {
|
}
|
||||||
return currentParent;
|
};
|
||||||
|
|
||||||
|
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) => {
|
window.addEventListener("dragleave", dragLeaveHandler);
|
||||||
hideDragHandle();
|
window.addEventListener("dragenter", dragEnterHandler);
|
||||||
const scrollableParent = getScrollParent(dragHandleElement);
|
|
||||||
if (!scrollableParent) return;
|
|
||||||
const scrollThreshold = options.scrollThreshold;
|
|
||||||
|
|
||||||
if (e.clientY < scrollThreshold.up) {
|
document.addEventListener("dragover", dragOverHandler);
|
||||||
const overflow = scrollThreshold.up - e.clientY;
|
document.addEventListener("mousemove", mouseMoveHandler);
|
||||||
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();
|
hideDragHandle();
|
||||||
|
|
||||||
|
|
@ -285,6 +254,15 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
dragHandleElement?.remove?.();
|
dragHandleElement?.remove?.();
|
||||||
dragHandleElement = null;
|
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);
|
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||||
let isDroppedInsideList = false;
|
let isDroppedInsideList = false;
|
||||||
|
let dropDepth = 0;
|
||||||
|
|
||||||
// Traverse up the document tree to find if we're inside a list item
|
// Traverse up the document tree to find if we're inside a list item
|
||||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||||
if (resolvedPos.node(i).type.name === "listItem") {
|
if (resolvedPos.node(i).type.name === "listItem") {
|
||||||
isDroppedInsideList = true;
|
isDroppedInsideList = true;
|
||||||
|
dropDepth = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
// Handle nested list items and task items
|
||||||
if (
|
if (droppedNode.type.name === "listItem") {
|
||||||
view.state.selection instanceof NodeSelection &&
|
let slice = view.state.selection.content();
|
||||||
view.state.selection.node.type.name === "listItem" &&
|
let newFragment = slice.content;
|
||||||
!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);
|
// If dropping outside a list or at a different depth, adjust the structure
|
||||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
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 };
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -349,3 +334,90 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||||
domEvents,
|
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 };
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue