mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 14:14:06 +08:00
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import { Extension } from "@tiptap/core";
|
|
import {
|
|
NodeSelection,
|
|
Plugin,
|
|
PluginKey,
|
|
TextSelection,
|
|
} from "@tiptap/pm/state";
|
|
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
|
import { EditorView } from "@tiptap/pm/view";
|
|
|
|
export interface GlobalDragHandleOptions {
|
|
/**
|
|
* The width of the drag handle
|
|
*/
|
|
dragHandleWidth: number;
|
|
|
|
/**
|
|
* The treshold for scrolling
|
|
*/
|
|
scrollThreshold: 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,
|
|
view: EditorView,
|
|
) {
|
|
// Custom nodes (transclusion, …) render via tiptap's React node-view
|
|
// renderer, which emits `class="react-renderer node-${name}"` on the
|
|
// live wrapper — the `data-type` attribute is for static HTML
|
|
// serialization only. Match both so we cover live and parsed DOM.
|
|
// Inside a custom node, also match plain `p` so the first paragraph
|
|
// (which doesn't match `:not(:first-child)`) still gets its own
|
|
// handle; only hovers on the custom node's padding/border fall
|
|
// through to the wrapper.
|
|
const customSelectors = options.customNodes.flatMap((node) => [
|
|
`[data-type=${node}]`,
|
|
`.node-${node}`,
|
|
]);
|
|
const customParagraphSelectors = options.customNodes.flatMap((node) => [
|
|
`[data-type=${node}] p`,
|
|
`.node-${node} p`,
|
|
]);
|
|
|
|
const selectors = [
|
|
"li",
|
|
"p:not(:first-child)",
|
|
"pre",
|
|
"blockquote",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
// Tables nested in another block (toggle, transclusion, …) have a
|
|
// wrapper that isn't a direct child of .ProseMirror, so the
|
|
// parent-check below skips it. Match the wrapper explicitly so the
|
|
// handle shows up even with empty cells.
|
|
".tableWrapper",
|
|
...customParagraphSelectors,
|
|
...customSelectors,
|
|
].join(", ");
|
|
return document
|
|
.elementsFromPoint(coords.x, coords.y)
|
|
.find((elem: Element) => {
|
|
// Skip elements that belong to a nested editor (e.g. transclusion
|
|
// references render their own ProseMirror instance). Only consider
|
|
// elements whose closest editor is this host view.
|
|
if (elem.closest(".ProseMirror") !== view.dom) return false;
|
|
return (
|
|
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 isCustomNodeDOM(
|
|
elem: Element | null | undefined,
|
|
options: GlobalDragHandleOptions,
|
|
): boolean {
|
|
if (!elem) return false;
|
|
for (const name of options.customNodes) {
|
|
if (
|
|
elem.getAttribute("data-type") === name ||
|
|
elem.classList.contains(`node-${name}`)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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,
|
|
view,
|
|
);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
const $sel = view.state.doc.resolve(selection.from);
|
|
|
|
if (isCustomNodeDOM(node, options)) {
|
|
// The drag landed on a custom-node container (transclusion etc.).
|
|
// Walk up to the matching node so the drag moves the whole
|
|
// container, not whatever inner element the click landed on.
|
|
const customTypes = new Set(options.customNodes);
|
|
for (let d = $sel.depth; d > 0; d--) {
|
|
if (customTypes.has($sel.node(d).type.name)) {
|
|
selection = NodeSelection.create(
|
|
view.state.doc,
|
|
$sel.before(d),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// If the selected node lives inside a table (at any nesting
|
|
// depth), promote to the whole table — the global drag handle is
|
|
// meant to move the table as a single block, not a row/cell. The
|
|
// earlier tableRow-only check only worked when the table sat at
|
|
// the doc root; once wrapped in another node (toggle, layout,
|
|
// etc.) the selection lands on a cell/paragraph and that check
|
|
// never fired.
|
|
let tableDepth = -1;
|
|
for (let d = $sel.depth; d > 0; d--) {
|
|
if ($sel.node(d).type.name === "table") {
|
|
tableDepth = d;
|
|
break;
|
|
}
|
|
}
|
|
if (tableDepth > 0) {
|
|
selection = NodeSelection.create(
|
|
view.state.doc,
|
|
$sel.before(tableDepth),
|
|
);
|
|
} else if ((selection as NodeSelection).node.type.isInline) {
|
|
// Inline node (e.g. mention): walk up to the parent block.
|
|
selection = NodeSelection.create(view.state.doc, $sel.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 } = view.serializeForClipboard(slice);
|
|
|
|
event.dataTransfer.clearData();
|
|
event.dataTransfer.setData("text/html", dom.innerHTML);
|
|
event.dataTransfer.setData("text/plain", text);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
|
|
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<HTMLElement>(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.scrollThreshold) {
|
|
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
|
} else if (window.innerHeight - e.clientY < options.scrollThreshold) {
|
|
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,
|
|
view,
|
|
);
|
|
|
|
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 isCustomNode = isCustomNodeDOM(node, options);
|
|
|
|
// Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
|
|
// the natural anchor sits in transient/empty space outside the visible block.
|
|
if (isCustomNode) {
|
|
// tiptap React node-views emit an outer `.react-renderer` whose first
|
|
// child is the visible NodeViewWrapper; walk to that outer first since
|
|
// `node` may be either the outer or an inner element with data-type.
|
|
const rendererOuter =
|
|
(node.closest(".react-renderer") as HTMLElement | null) ?? node;
|
|
const inner =
|
|
(rendererOuter.firstElementChild as HTMLElement | null) ??
|
|
rendererOuter;
|
|
const innerRect = absoluteRect(inner);
|
|
if (!dragHandleElement) return;
|
|
dragHandleElement.style.left = `${innerRect.left + 4}px`;
|
|
dragHandleElement.style.top = `${innerRect.top + 4}px`;
|
|
showDragHandle();
|
|
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;
|
|
}
|
|
// Tables: clear the table's own row-drag handle so the two
|
|
// grips don't stack on each other. `nodeDOMAtCoords` returns
|
|
// the wrapper for top-level hovers (wrapper is direct child of
|
|
// .ProseMirror) and a descendant for deeper hovers — cover both.
|
|
if (node.closest(".tableWrapper")) {
|
|
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 <ol> 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,
|
|
scrollThreshold: 100,
|
|
excludedTags: [],
|
|
customNodes: [],
|
|
};
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
DragHandlePlugin({
|
|
pluginKey: "globalDragHandle",
|
|
dragHandleWidth: this.options.dragHandleWidth,
|
|
scrollThreshold: this.options.scrollThreshold,
|
|
dragHandleSelector: this.options.dragHandleSelector,
|
|
excludedTags: this.options.excludedTags,
|
|
customNodes: this.options.customNodes,
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
|
|
export default GlobalDragHandle;
|