Skip to content
File

Blob: src/client/components/editor/extensions/block-drag-drop.ts

typescript463 lines
1import { Extension } from "@tiptap/core";
2import type { Slice } from "@tiptap/pm/model";
3import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
4import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
5import { isChangeOrigin } from "@tiptap/extension-collaboration";
6import { scheduleMovedTextblockSelectionFinalization } from "../lib/moved-textblock-selection";
7import { findTopLevelBlockByBid, getTopLevelStructureSignature } from "../lib/top-level-blocks";
8import "../styles/block-drag-drop.css";
9 
10export interface TopLevelBlockRect {
11 pos: number;
12 end: number;
13 top: number;
14 bottom: number;
15 left: number;
16 right: number;
17}
18 
19interface BlockDragDropState {
20 canceled: boolean;
21 sourceBid: string | null;
22 target: number | null;
23 sourcePos: number | null;
24 sourceEnd: number | null;
25}
26 
27interface DragPreview {
28 dom: HTMLElement;
29 marginTop: string;
30 marginBottom: string;
31}
32 
33const blockDragDropKey = new PluginKey<BlockDragDropState>("blockDragDrop");
34const dragTargets = new WeakMap<EditorView, number | null>();
35const dragPreviews = new WeakMap<EditorView, DragPreview>();
36 
37// Read the current top-level block boxes from the DOM so drag placement is based
38// on visible layout rather than ProseMirror caret heuristics in the gaps between blocks.
39function getTopLevelBlockRects(view: EditorView): TopLevelBlockRect[] {
40 const blocks: TopLevelBlockRect[] = [];
41 
42 view.state.doc.forEach((node, pos) => {
43 const dom = view.nodeDOM(pos);
44 if (!(dom instanceof HTMLElement)) return;
45 
46 const rect = dom.getBoundingClientRect();
47 if (rect.height <= 0 && rect.width <= 0) return;
48 
49 blocks.push({
50 pos,
51 end: pos + node.nodeSize,
52 top: rect.top,
53 bottom: rect.bottom,
54 left: rect.left,
55 right: rect.right,
56 });
57 });
58 
59 return blocks;
60}
61 
62// Convert a screen-space Y coordinate into a top-level insertion slot.
63// Above a block means "before it", lower half means "after it", and the gap
64// before the next block belongs to the slot before that next block.
65export function resolveTopLevelDropTarget(blocks: TopLevelBlockRect[], y: number): number | null {
66 if (!blocks.length || !Number.isFinite(y)) return null;
67 
68 for (const block of blocks) {
69 if (y < block.top) return block.pos;
70 if (y <= block.bottom) {
71 return y < (block.top + block.bottom) / 2 ? block.pos : block.end;
72 }
73 }
74 
75 return blocks[blocks.length - 1]?.end ?? null;
76}
77 
78export function resolveMovedTopLevelDropTarget(
79 blocks: TopLevelBlockRect[],
80 y: number,
81 previousTarget: number | null,
82): number | null {
83 if (!blocks.length || !Number.isFinite(y)) return null;
84 
85 const firstBlock = blocks[0];
86 if (previousTarget === firstBlock?.pos && y <= firstBlock.bottom) {
87 return firstBlock.pos;
88 }
89 
90 return resolveTopLevelDropTarget(blocks, y);
91}
92 
93function resolveMovedBlockDropPos(
94 view: EditorView,
95 event: DragEvent,
96 previousTarget: number | null = null,
97): number | null {
98 return resolveMovedTopLevelDropTarget(getTopLevelBlockRects(view), event.clientY, previousTarget);
99}
100 
101function resolveDropCursorTarget(view: EditorView, event: DragEvent): number | null {
102 if (view.dragging?.slice) {
103 return resolveMovedBlockDropPos(view, event);
104 }
105 
106 const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
107 if (!pos) return null;
108 
109 const node = pos.inside >= 0 ? view.state.doc.nodeAt(pos.inside) : null;
110 const disableDropCursor = node?.type.spec.disableDropCursor;
111 const disabled = typeof disableDropCursor === "function" ? disableDropCursor(view, pos, event) : disableDropCursor;
112 
113 if (disabled) return null;
114 return pos.pos;
115}
116 
117// Render the dragged block in-flow at the current target slot so the preview
118// matches where the block will land instead of showing a separate floating cue.
119function createDropPlaceholder(view: EditorView) {
120 const wrapper = document.createElement("div");
121 wrapper.className = "tiptap tiptap-drop-placeholder";
122 wrapper.contentEditable = "false";
123 
124 const preview = dragPreviews.get(view);
125 if (preview) {
126 // Reserve the dragged block's own vertical margins on the placeholder
127 // wrapper so its outlined footprint matches where the block will land —
128 // important for blocks with meaningful space above/below like headings.
129 wrapper.style.marginTop = preview.marginTop;
130 wrapper.style.marginBottom = preview.marginBottom;
131 
132 // Zero vertical margins on the clone AND its immediate children. React
133 // node views (callout, code block) sit inside an extra
134 // `.react-renderer.node-<name>` wrapper, so `view.nodeDOM(pos)` returns a
135 // wrapper whose own margin is 0 while the real block
136 // (`.tiptap-callout`, `.tiptap-code-block-wrapper`) one level in carries
137 // `margin: 0.75rem 0` via CSS. Without zeroing that inner margin it
138 // renders inside the outlined placeholder and reads as phantom padding
139 // top and bottom. Native nodes like details/headings sit at the top
140 // level and are handled by the outer zero alone; the child walk is a
141 // no-op for them because their own children don't carry block margins.
142 const clone = preview.dom.cloneNode(true) as HTMLElement;
143 clone.style.marginTop = "0";
144 clone.style.marginBottom = "0";
145 for (const child of Array.from(clone.children) as HTMLElement[]) {
146 child.style.marginTop = "0";
147 child.style.marginBottom = "0";
148 }
149 wrapper.appendChild(clone);
150 }
151 
152 return wrapper;
153}
154 
155// When the target is immediately before/after the source, decorating the source
156// itself is less jumpy than rendering a second placeholder widget next to it.
157function isAdjacentToSource(state: BlockDragDropState): boolean {
158 return (
159 !state.canceled &&
160 state.target !== null &&
161 state.sourceBid !== null &&
162 state.sourcePos !== null &&
163 state.sourceEnd !== null &&
164 (state.target === state.sourcePos || state.target === state.sourceEnd)
165 );
166}
167 
168class BlockDragPreviewView {
169 target: number | null = null;
170 timeout = -1;
171 handlers: Array<{ name: string; handler: (event: Event) => void }>;
172 
173 constructor(readonly editorView: EditorView) {
174 this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
175 const handler = (event: Event) => {
176 (this as unknown as Record<string, (event: Event) => void>)[name](event);
177 };
178 editorView.dom.addEventListener(name, handler);
179 return { name, handler };
180 });
181 }
182 
183 destroy() {
184 this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
185 this.setTarget(null);
186 }
187 
188 update() {
189 const pluginState = blockDragDropKey.getState(this.editorView.state);
190 if (pluginState?.canceled) {
191 dragTargets.delete(this.editorView);
192 this.target = null;
193 return;
194 }
195 
196 if (this.target !== null) {
197 if (this.target > this.editorView.state.doc.content.size) {
198 this.setTarget(null);
199 }
200 }
201 }
202 
203 setTarget(pos: number | null) {
204 if (pos === this.target) return;
205 this.target = pos;
206 this.editorView.dispatch(
207 this.editorView.state.tr.setMeta(blockDragDropKey, { target: pos }).setMeta("addToHistory", false),
208 );
209 }
210 
211 scheduleRemoval(timeout: number) {
212 clearTimeout(this.timeout);
213 this.timeout = window.setTimeout(() => this.setTarget(null), timeout);
214 }
215 
216 dragover(event: Event) {
217 if (!this.editorView.editable) return;
218 
219 const target = this.editorView.dragging?.slice
220 ? resolveMovedBlockDropPos(this.editorView, event as DragEvent, this.target)
221 : resolveDropCursorTarget(this.editorView, event as DragEvent);
222 if (target === null) return;
223 
224 dragTargets.set(this.editorView, target);
225 this.setTarget(target);
226 this.scheduleRemoval(5000);
227 }
228 
229 dragend() {
230 dragTargets.delete(this.editorView);
231 this.scheduleRemoval(20);
232 }
233 
234 drop() {
235 dragTargets.delete(this.editorView);
236 this.scheduleRemoval(20);
237 }
238 
239 dragleave(event: Event) {
240 if (dragPreviews.has(this.editorView)) return;
241 
242 if (!this.editorView.dom.contains((event as DragEvent).relatedTarget as Node | null)) {
243 dragTargets.delete(this.editorView);
244 this.setTarget(null);
245 }
246 }
247}
248 
249// Use the stored slot target for internal block moves so the actual insertion
250// always matches the in-flow placeholder the user is dragging against.
251function handleMovedDrop(view: EditorView, event: DragEvent, slice: Slice, moved: boolean): boolean {
252 if (!moved || !slice.content.size) return false;
253 const pluginState = blockDragDropKey.getState(view.state);
254 if (pluginState?.canceled || !pluginState?.sourceBid) {
255 event.preventDefault();
256 dragTargets.delete(view);
257 return true;
258 }
259 
260 const insertPos = dragTargets.get(view) ?? resolveMovedBlockDropPos(view, event);
261 if (insertPos === null) {
262 event.preventDefault();
263 dragTargets.delete(view);
264 return true;
265 }
266 
267 event.preventDefault();
268 
269 const source = findTopLevelBlockByBid(view.state.doc, pluginState.sourceBid);
270 if (!source) {
271 dragTargets.delete(view);
272 return true;
273 }
274 
275 const tr = view.state.tr.delete(source.pos, source.pos + source.node.nodeSize);
276 const pos = tr.mapping.map(insertPos);
277 const beforeInsert = tr.doc;
278 
279 tr.replaceRangeWith(pos, pos, source.node);
280 
281 if (tr.doc.eq(beforeInsert)) return true;
282 
283 const $pos = tr.doc.resolve(pos);
284 if (NodeSelection.isSelectable(source.node) && $pos.nodeAfter && $pos.nodeAfter.sameMarkup(source.node)) {
285 tr.setSelection(new NodeSelection($pos));
286 } else {
287 tr.setSelection(Selection.near($pos));
288 }
289 
290 view.focus();
291 view.dispatch(tr.setMeta("uiEvent", "drop"));
292 scheduleMovedTextblockSelectionFinalization(view);
293 
294 dragTargets.delete(view);
295 return true;
296}
297 
298export const BlockDragDropBehavior = Extension.create({
299 name: "blockDragDropBehavior",
300 
301 addProseMirrorPlugins() {
302 return [
303 new Plugin({
304 key: blockDragDropKey,
305 state: {
306 init: (): BlockDragDropState => ({
307 canceled: false,
308 sourceBid: null,
309 target: null,
310 sourcePos: null,
311 sourceEnd: null,
312 }),
313 apply(tr, value, oldState, newState) {
314 const meta = tr.getMeta(blockDragDropKey) as Partial<BlockDragDropState> | undefined;
315 const next: BlockDragDropState = {
316 canceled: "canceled" in (meta ?? {}) ? !!meta?.canceled : value.canceled,
317 sourceBid: "sourceBid" in (meta ?? {}) ? (meta?.sourceBid ?? null) : value.sourceBid,
318 target:
319 "target" in (meta ?? {})
320 ? (meta?.target ?? null)
321 : value.target !== null
322 ? tr.mapping.map(value.target)
323 : null,
324 sourcePos: "sourcePos" in (meta ?? {}) ? (meta?.sourcePos ?? null) : value.sourcePos,
325 sourceEnd: "sourceEnd" in (meta ?? {}) ? (meta?.sourceEnd ?? null) : value.sourceEnd,
326 };
327 
328 if (next.canceled || next.sourceBid === null) {
329 return next;
330 }
331 
332 const currentSource = findTopLevelBlockByBid(newState.doc, next.sourceBid);
333 if (!currentSource) {
334 return {
335 canceled: true,
336 sourceBid: null,
337 target: null,
338 sourcePos: null,
339 sourceEnd: null,
340 };
341 }
342 
343 if (tr.docChanged && isChangeOrigin(tr)) {
344 const structureChanged =
345 getTopLevelStructureSignature(oldState.doc) !== getTopLevelStructureSignature(newState.doc);
346 if (structureChanged) {
347 return {
348 canceled: true,
349 sourceBid: null,
350 target: null,
351 sourcePos: null,
352 sourceEnd: null,
353 };
354 }
355 }
356 
357 next.sourcePos = currentSource.pos;
358 next.sourceEnd = currentSource.pos + currentSource.node.nodeSize;
359 if (next.target !== null && next.target > newState.doc.content.size) {
360 next.target = null;
361 }
362 return next;
363 },
364 },
365 props: {
366 handleDOMEvents: {
367 dragstart(view: EditorView, event: Event) {
368 if (!view.editable) return false;
369 // Image blocks own their drag via a document-level capture
370 // listener in image/node-view.tsx — let those through.
371 const el = event.target instanceof Element ? event.target : (event.target as Node | null)?.parentElement;
372 if (el?.closest(".react-renderer.node-image")) return false;
373 // Suppress everything else (text-selection drags, details
374 // summary drags, etc.). Drag-handle drags are unaffected —
375 // the handle lives outside view.dom.
376 event.preventDefault();
377 return true;
378 },
379 },
380 handleDrop: handleMovedDrop,
381 decorations(state) {
382 const pluginState = blockDragDropKey.getState(state);
383 if (!pluginState || pluginState.canceled) return null;
384 
385 const decorations = [];
386 const hasSource =
387 pluginState.sourceBid !== null && pluginState.sourcePos !== null && pluginState.sourceEnd !== null;
388 const hasTarget = pluginState.target !== null;
389 const adjacentToSource = isAdjacentToSource(pluginState);
390 
391 if (hasSource && hasTarget && pluginState.sourcePos !== null && pluginState.sourceEnd !== null) {
392 decorations.push(
393 Decoration.node(
394 pluginState.sourcePos,
395 pluginState.sourceEnd,
396 adjacentToSource
397 ? { class: "tiptap-drop-placeholder-source" }
398 : { class: "tiptap-drag-source-hidden" },
399 ),
400 );
401 }
402 
403 if (!hasTarget || adjacentToSource) {
404 return decorations.length ? DecorationSet.create(state.doc, decorations) : null;
405 }
406 
407 const target = pluginState.target;
408 decorations.push(
409 Decoration.widget(target!, (view) => createDropPlaceholder(view), {
410 side: -1,
411 ignoreSelection: true,
412 key: `drag-drop-placeholder-${target}`,
413 }),
414 );
415 
416 return DecorationSet.create(state.doc, decorations);
417 },
418 },
419 view: (editorView) => new BlockDragPreviewView(editorView),
420 }),
421 ];
422 },
423});
424 
425// Capture a static DOM snapshot of the dragged source block. The live source
426// may be hidden while dragging away from it, so the placeholder needs its own copy.
427export function setDraggedBlockPreview(
428 view: EditorView,
429 dom: HTMLElement,
430 sourceBid: string,
431 sourcePos: number,
432 sourceEnd: number,
433) {
434 // Read the margin off `dom` directly. For native nodes (headings, details,
435 // paragraphs) `dom` is the block element and its margin is the real value
436 // the preview should reserve. For React node views (callout, code block),
437 // `dom` is the outer `.react-renderer` wrapper whose own margin is 0 —
438 // which collapses the preview flush to neighbors, and is exactly what we
439 // want because the inner block's margin is zeroed out in
440 // createDropPlaceholder to prevent it from rendering as phantom padding
441 // inside the outlined placeholder.
442 const styles = window.getComputedStyle(dom);
443 dragPreviews.set(view, {
444 dom: dom.cloneNode(true) as HTMLElement,
445 marginTop: styles.marginTop,
446 marginBottom: styles.marginBottom,
447 });
448 view.dispatch(
449 view.state.tr
450 .setMeta(blockDragDropKey, { canceled: false, sourceBid, sourcePos, sourceEnd, target: null })
451 .setMeta("addToHistory", false),
452 );
453}
454 
455export function clearDraggedBlockPreview(view: EditorView) {
456 dragPreviews.delete(view);
457 view.dispatch(
458 view.state.tr
459 .setMeta(blockDragDropKey, { canceled: false, sourceBid: null, sourcePos: null, sourceEnd: null, target: null })
460 .setMeta("addToHistory", false),
461 );
462}