File
Blob: src/client/components/editor/extensions/block-drag-drop.ts
| 1 | import { Extension } from "@tiptap/core"; |
| 2 | import type { Slice } from "@tiptap/pm/model"; |
| 3 | import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state"; |
| 4 | import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; |
| 5 | import { isChangeOrigin } from "@tiptap/extension-collaboration"; |
| 6 | import { scheduleMovedTextblockSelectionFinalization } from "../lib/moved-textblock-selection"; |
| 7 | import { findTopLevelBlockByBid, getTopLevelStructureSignature } from "../lib/top-level-blocks"; |
| 8 | import "../styles/block-drag-drop.css"; |
| 9 | |
| 10 | export interface TopLevelBlockRect { |
| 11 | pos: number; |
| 12 | end: number; |
| 13 | top: number; |
| 14 | bottom: number; |
| 15 | left: number; |
| 16 | right: number; |
| 17 | } |
| 18 | |
| 19 | interface BlockDragDropState { |
| 20 | canceled: boolean; |
| 21 | sourceBid: string | null; |
| 22 | target: number | null; |
| 23 | sourcePos: number | null; |
| 24 | sourceEnd: number | null; |
| 25 | } |
| 26 | |
| 27 | interface DragPreview { |
| 28 | dom: HTMLElement; |
| 29 | marginTop: string; |
| 30 | marginBottom: string; |
| 31 | } |
| 32 | |
| 33 | const blockDragDropKey = new PluginKey<BlockDragDropState>("blockDragDrop"); |
| 34 | const dragTargets = new WeakMap<EditorView, number | null>(); |
| 35 | const 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. |
| 39 | function 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. |
| 65 | export 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 | |
| 78 | export 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 | |
| 93 | function 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 | |
| 101 | function 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. |
| 119 | function 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. |
| 157 | function 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 | |
| 168 | class 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. |
| 251 | function 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 | |
| 298 | export 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. |
| 427 | export 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 | |
| 455 | export 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 | } |