import { Extension } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Plugin, PluginKey, NodeSelection, TextSelection, type Transaction, } from "@tiptap/pm/state"; import { type EditorView } from "@tiptap/pm/view"; /** * Default editor-wrapper CSS selector the drag handle scopes itself to. * * The handle, the drop indicator, and the `position: relative` anchor are all * appended to / measured against the closest ancestor matching this selector. * Content's editor wraps its ProseMirror DOM in a `.visual-editor-wrapper` * element, so that is the historical default. Other apps (e.g. the plan editor) * pass their own wrapper selector via {@link DragHandleOptions.wrapperSelector}. */ export const DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR = ".visual-editor-wrapper"; export interface DragHandleOptions { /** * CSS selector for the editor wrapper element the handle is anchored to. * * Must match an ancestor of the ProseMirror editor DOM. The wrapper gets * `position: relative` so the absolutely-positioned grip and drop indicator * can be placed relative to it. Defaults to * {@link DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR} so Content keeps working * unchanged. */ wrapperSelector: string; /** * Optional source-side payload for a cross-editor block move. The editor doc * carries ProseMirror node content, but app-owned side-map data (for example a * plan `diagram` block's HTML/CSS) can live outside the doc; this lets the * host carry that data to the receiving editor before the node is inserted. */ getDragTransferData?: (context: { view: EditorView; node: ProseMirrorNode; pos: number; }) => unknown; /** * Optional target-side receiver for cross-editor transfer data. Called before * the node is inserted into the target editor so the target's serializer can * resolve app-owned data during the synchronous ProseMirror update. */ receiveDragTransferData?: ( data: unknown, context: { view: EditorView; node: ProseMirrorNode; pos: number; sourceView: EditorView; }, ) => void; /** * Optional host-level drop handler for document-specific structure changes. * Returning true tells the shared drag handle that the host fully handled the * move and no ProseMirror insert/delete should run. This is used for * Notion-style side drops where dropping a block to the left/right creates or * inserts into a column layout rather than inserting into the target editor. */ handleDrop?: (data: unknown, context: DragHandleDropContext) => boolean; } const dragHandleKey = new PluginKey("dragHandle"); const HOVER_SIDE_OUTSET_REM = 8; // Notion-style side drop: drag a block to a neighbour's LEFT/RIGHT region and it // builds (or joins) a column layout instead of reordering. The activation region // has to be GENEROUS or the gesture is dead for a real human — a natural drag // releases somewhere over the block's body, nowhere near a thin edge sliver. The // old values (28% of width, capped at 140px, AND only the vertical middle 60%) // left a wide ~820px plan block with two ~17%-of-width edge slivers in a 35px-tall // band as the ONLY column targets — ~66% of the block (the whole centre) plus the // top/bottom only ever reordered, so "drag side by side" essentially never made // columns. Now each side claims ~a third of the width across the FULL block // height, with a middle band always preserved for before/after reorder. const SIDE_DROP_ZONE_RATIO = 0.33; const SIDE_DROP_ZONE_MIN_PX = 56; const SIDE_DROP_ZONE_MAX_PX = 320; // Never let the two side zones swallow the whole block: keep at least the middle // ~10% of the width as the before/after reorder band so dropping over the centre // still moves the block above/below the target (Notion keeps reorder reachable). const SIDE_DROP_ZONE_MAX_WIDTH_FRACTION = 0.45; const DRAG_HANDLE_MENU_STYLE_ID = "an-rich-md-drag-menu-styles"; const DRAG_HANDLE_MENU_WIDTH = 220; const DRAG_HANDLE_MENU_GAP = 6; const DRAG_HANDLE_MENU_VIEWPORT_PADDING = 8; /** * Wraps Tabler outline icon path data in the standard 24×24 stroke SVG so the * DOM-based block menu renders the same icons the React UI uses (Tabler is the * framework-wide icon set). The editor is plain DOM, not React, so we inline the * markup instead of importing `@tabler/icons-react` components. */ const tablerIconSvg = (paths: string): string => ``; // Tabler `copy`, `trash`, and `plus` (outline). Path data copied verbatim from // @tabler/icons so the glyphs stay pixel-identical to the React icon set. const DRAG_HANDLE_MENU_ICON_DUPLICATE = tablerIconSvg( '', ); const DRAG_HANDLE_MENU_ICON_DELETE = tablerIconSvg( '', ); const DRAG_HANDLE_MENU_ICON_INSERT = tablerIconSvg( '', ); // Tabler `grip-vertical` (outline) for the left-margin drag grip. const DRAG_HANDLE_GRIP_ICON = tablerIconSvg( '', ); type DropTarget = { registration: DragHandleRegistration; view: EditorView; block: HTMLElement; placement: DragHandleDropPlacement; pos: number; targetPos: number; targetNodeSize: number; rect: DOMRect; }; export type DragHandleDropPlacement = "before" | "after" | "left" | "right"; export type DragHandleDropContext = { view: EditorView; sourceView: EditorView; sourceNode: ProseMirrorNode; sourcePos: number; sourceNodeSize: number; targetNode: ProseMirrorNode; targetPos: number; targetNodeSize: number; insertPos: number; placement: DragHandleDropPlacement; }; type DragSession = { view: EditorView; sourceBlock: HTMLElement; sourcePos: number; sourceNodeSize: number; startX: number; startY: number; dragging: boolean; preview: HTMLElement | null; dropLine: HTMLElement | null; dropTarget: DropTarget | null; }; type HoverBlock = { node: HTMLElement; pmPos: number; rect: DOMRect; }; type DragHandleMenuContext = { view: EditorView; sourceBlock: HTMLElement; sourcePos: number; sourceNodeSize: number; }; type DragHandleRegistration = { view: EditorView; wrapperSelector: string; getDragTransferData?: DragHandleOptions["getDragTransferData"]; receiveDragTransferData?: DragHandleOptions["receiveDragTransferData"]; handleDrop?: DragHandleOptions["handleDrop"]; canHover?: () => boolean; findHoverBlock?: (clientX: number, clientY: number) => HoverBlock | null; showHoverBlock?: (block: HoverBlock) => void; hideHover?: () => void; /** The currently displayed grip's bounding rect, or null when hidden. */ gripRect?: () => DOMRect | null; }; const dragHandleRegistrations = new Set(); let dragHandleGlobalHoverListeners = 0; let activeDragRegistration: DragHandleRegistration | null = null; // The registration whose grip is currently shown. Used to keep that grip alive // while the cursor travels from a block's body to its grip, even when the grip // sits in a contested gap (an inter-column gap or a tab body's left offset) // where another editor's wide forgiving zone would otherwise re-win the hover // and hide the grip out from under the approaching cursor. let activeHoverRegistration: DragHandleRegistration | null = null; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const editorArea = (registration: DragHandleRegistration) => { const rect = registration.view.dom.getBoundingClientRect(); return rect.width * rect.height; }; const updateRegisteredHover = (clientX: number, clientY: number) => { if (activeDragRegistration) { for (const registration of dragHandleRegistrations) { registration.hideHover?.(); } activeHoverRegistration = null; return; } const candidates: Array<{ registration: DragHandleRegistration; block: HoverBlock; }> = []; for (const registration of dragHandleRegistrations) { if (!registration.view.dom.isConnected || !registration.canHover?.()) { registration.hideHover?.(); continue; } const block = registration.findHoverBlock?.(clientX, clientY); if (block) { candidates.push({ registration, block }); } else { registration.hideHover?.(); } } // Grip keepalive. Once a block's grip is showing, hold it while the cursor // travels LEFT of that block's content toward its grip glyph — within the // block's own vertical row and no further left than the glyph itself. This is // what makes grips grabbable for blocks that are NOT flush with the page's // left gutter (a right column, a tab body): their grip sits in a gap that the // neighbour's wide forgiving zone also claims, so the normal picker would flip // hover to the neighbour mid-approach and the grip would vanish before the // cursor reaches it. The keepalive only bridges the body→grip gap — it does // NOT fire while the cursor is over content (so the innermost/nested picking // and gutter-grab rules below still decide there) and the row guard stops it // from sticking the grip across vertical moves to another block's row. if (activeHoverRegistration) { const held = candidates.find( (candidate) => candidate.registration === activeHoverRegistration, ); const grip = activeHoverRegistration.gripRect?.(); if ( held && grip && clientY >= held.block.rect.top && clientY < held.block.rect.bottom && clientX >= grip.left - 4 && clientX < held.block.rect.left ) { for (const registration of dragHandleRegistrations) { if (registration !== held.registration) registration.hideHover?.(); } held.registration.showHoverBlock?.(held.block); return; } } // Pick which editor owns the grip when several register a hover block at this // point. Nested region editors (e.g. each column inside a `columns` block) tile // their container's whole footprint AND extend a wide forgiving zone // (HOVER_SIDE_OUTSET_REM) into its left-margin gutter, so a pure // "smallest editor wins" rule lets an inner block beat the container everywhere // and leaves the container itself impossible to grab. Split candidates by where // the cursor sits relative to each block: // - Over a block's body (clientX at/after its left edge) the innermost // (smallest) editor wins, so nested blocks stay grabbable from their content. // - In the shared left-margin gutter (clientX left of every candidate's // content, where the grip lives) the outermost (largest) editor wins, so the // container block can be picked up and reordered. // Prefer the candidate whose block actually sits UNDER the cursor // horizontally. Without this, a left column's forgiving side zone reaches // across the inter-column gap, ties the right column's editor on area, and // wins — so hovering a right-column block shows the grip for the LEFT block // (and right-column blocks appear to have no grip at all). `overContent` // restricts to blocks the cursor is genuinely within; `rightOfLeftEdge` keeps // the gutter-grab behaviour; fully left of every block → the container wins. const overContent = candidates.filter( (candidate) => clientX >= candidate.block.rect.left && clientX <= candidate.block.rect.right, ); // The grip renders in a narrow band just LEFT of each block (≈24px). A block // must OWN that band so moving the cursor onto its grip keeps showing (and // lets you press) that block's grip — otherwise, for a column block whose grip // sits in the gutter/inter-column gap, the "gutter → largest editor" rule // below would flip the hover to the columns container and the grip would // vanish out from under the cursor, making inner column blocks impossible to // drag. The band is narrow, so it does not collide with the neighbouring // column's content (the right column's grip lives in the inter-column gap, // left of its own content but right of the left column's content). const GRIP_HOVER_ZONE_PX = 28; const overGrip = candidates.filter( (candidate) => clientX >= candidate.block.rect.left - GRIP_HOVER_ZONE_PX && clientX < candidate.block.rect.left, ); const rightOfLeftEdge = candidates.filter( (candidate) => clientX >= candidate.block.rect.left, ); let active: { registration: DragHandleRegistration; block: HoverBlock; } | null; const innerPool = overContent.length > 0 ? overContent : overGrip.length > 0 ? overGrip : rightOfLeftEdge; if (innerPool.length > 0) { innerPool.sort( (a, b) => editorArea(a.registration) - editorArea(b.registration), ); active = innerPool[0]; } else { candidates.sort( (a, b) => editorArea(b.registration) - editorArea(a.registration), ); active = candidates[0] ?? null; } for (const registration of dragHandleRegistrations) { if (registration !== active?.registration) registration.hideHover?.(); } active?.registration.showHoverBlock?.(active.block); activeHoverRegistration = active?.registration ?? null; }; const handleGlobalHoverMove = (event: MouseEvent) => { updateRegisteredHover(event.clientX, event.clientY); }; const retainGlobalHoverListener = () => { dragHandleGlobalHoverListeners += 1; if (dragHandleGlobalHoverListeners === 1) { document.addEventListener("mousemove", handleGlobalHoverMove); } }; const releaseGlobalHoverListener = () => { dragHandleGlobalHoverListeners = Math.max( 0, dragHandleGlobalHoverListeners - 1, ); if (dragHandleGlobalHoverListeners === 0) { document.removeEventListener("mousemove", handleGlobalHoverMove); } }; const ensureDragHandleMenuStyles = () => { if (document.getElementById(DRAG_HANDLE_MENU_STYLE_ID)) return; const style = document.createElement("style"); style.id = DRAG_HANDLE_MENU_STYLE_ID; style.textContent = ` .an-rich-md-drag-menu { position: fixed; z-index: 9999; width: ${DRAG_HANDLE_MENU_WIDTH}px; padding: 4px; border: 1px solid hsl(var(--border, 214.3 31.8% 91.4%)); border-radius: 7px; background: hsl(var(--popover, 0 0% 100%)); color: hsl(var(--popover-foreground, var(--foreground, 222.2 84% 4.9%))); box-shadow: 0 12px 32px rgb(15 23 42 / 0.16), 0 2px 8px rgb(15 23 42 / 0.08); font-family: inherit; font-size: 13px; line-height: 1.35; } .an-rich-md-drag-menu__item { display: flex; width: 100%; align-items: center; gap: 9px; border: 0; border-radius: 5px; background: transparent; color: inherit; cursor: pointer; font: inherit; letter-spacing: 0; padding: 7px 8px; text-align: left; } .an-rich-md-drag-menu__item:hover, .an-rich-md-drag-menu__item:focus-visible { background: hsl(var(--accent, 210 40% 96.1%)); color: hsl(var(--accent-foreground, var(--foreground, 222.2 84% 4.9%))); outline: none; } .an-rich-md-drag-menu__item[data-danger="true"] { color: hsl(var(--destructive, 0 84.2% 60.2%)); } .an-rich-md-drag-menu__item[data-danger="true"]:hover, .an-rich-md-drag-menu__item[data-danger="true"]:focus-visible { background: hsl(var(--destructive, 0 84.2% 60.2%) / 0.1); } .an-rich-md-drag-menu__icon { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 18px; height: 18px; color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%)); } .an-rich-md-drag-menu__item[data-danger="true"] .an-rich-md-drag-menu__icon { color: currentColor; } .an-rich-md-drag-menu__icon svg { width: 17px; height: 17px; } .an-rich-md-drag-menu__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `; document.head.appendChild(style); }; /** * App-agnostic Tiptap extension providing a Notion-style left-margin drag grip * (the `::` handle), block selection, and drag-to-reorder over top-level block * nodes. * * Behavior: * - On hover over any top-level block, a `.drag-handle` grip appears in the left * margin (forgiving hit zone extends {@link HOVER_SIDE_OUTSET_REM}rem to the * sides and into the gap above/between blocks). * - Single-clicking the grip selects the block and opens a block action menu. * Dragging past a small threshold starts a reorder, showing a floating clone * preview (`.notion-drag-preview`) and a `.notion-drop-indicator` line. * `Escape` cancels. * - While dragging, the source block carries `.notion-block--dragging` and the * document element carries `.notion-editor-is-dragging` so apps can style the * in-flight state. Apps own all of these CSS class names. * - Works for ANY top-level node ProseMirror renders as a direct child of the * editor — including `group: "block"`, `draggable: true` atoms such as the * plan editor's `planBlock`. * * The only app-specific coupling — the editor wrapper element the handle and * drop indicator are anchored to — is configurable via * {@link DragHandleOptions.wrapperSelector}, defaulting to * {@link DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR} (`.visual-editor-wrapper`) so the * Content editor keeps working byte-identically. The plan editor passes its own * wrapper selector via `DragHandle.configure({ wrapperSelector })`. */ export const DragHandle = Extension.create({ name: "dragHandle", addOptions() { return { wrapperSelector: DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR, getDragTransferData: undefined, receiveDragTransferData: undefined, }; }, addProseMirrorPlugins() { const editor = this.editor; const wrapperSelector = this.options.wrapperSelector; const getDragTransferData = this.options.getDragTransferData; const receiveDragTransferData = this.options.receiveDragTransferData; const handleDrop = this.options.handleDrop; let handle: HTMLElement | null = null; let menu: HTMLElement | null = null; let menuContext: DragHandleMenuContext | null = null; let currentBlock: HTMLElement | null = null; let dragStartPos: number | null = null; let dragSession: DragSession | null = null; let currentRegistration: DragHandleRegistration | null = null; const getHoverSideOutset = () => { const rootFontSize = Number.parseFloat( getComputedStyle(document.documentElement).fontSize, ); return ( (Number.isFinite(rootFontSize) ? rootFontSize : 16) * HOVER_SIDE_OUTSET_REM ); }; const getTopLevelBlocks = (editorView: EditorView): HoverBlock[] => { const blocks: HoverBlock[] = []; editorView.state.doc.forEach((_node, offset) => { const dom = editorView.nodeDOM(offset); if (!(dom instanceof HTMLElement)) return; blocks.push({ node: dom, pmPos: offset, rect: dom.getBoundingClientRect(), }); }); return blocks; }; const registrationForView = ( editorView: EditorView, ): DragHandleRegistration | null => { for (const registration of dragHandleRegistrations) { if (registration.view === editorView) return registration; } return null; }; const findForgivingBlock = ( editorView: EditorView, clientX: number, clientY: number, ): HoverBlock | null => { const blocks = getTopLevelBlocks(editorView); if (blocks.length === 0) return null; const sideOutset = getHoverSideOutset(); const pageLeft = 0; const pageRight = window.visualViewport?.width ?? window.innerWidth; for (let index = 0; index < blocks.length; index++) { const block = blocks[index]; const nextBlock = blocks[index + 1]; const blockBottomGap = nextBlock ? Math.max(0, nextBlock.rect.top - block.rect.bottom) : 0; const zoneLeft = Math.max(pageLeft, block.rect.left - sideOutset); const zoneRight = Math.min(pageRight, block.rect.right + sideOutset); const zoneTop = index === 0 ? Math.max(0, block.rect.top - blockBottomGap) : block.rect.top; const zoneBottom = nextBlock ? nextBlock.rect.top : block.rect.bottom; if ( clientX >= zoneLeft && clientX <= zoneRight && clientY >= zoneTop && clientY < zoneBottom ) { return block; } } return null; }; const showHandleForBlock = (editorView: EditorView, block: HoverBlock) => { if (!handle) return; currentBlock = block.node; dragStartPos = block.pmPos; const wrapper = editorView.dom.closest(wrapperSelector); if (!wrapper) return; // Lazily (re)attach the grip the first time a wrapper is actually // available. At plugin `view()` init the editor DOM may not yet be mounted // inside the wrapper (React mounts `EditorContent` after the EditorView is // constructed), so the init-time append can silently no-op and leave the // grip orphaned. Re-home it here once the wrapper exists. if (handle.parentElement !== wrapper) { (wrapper as HTMLElement).style.position = "relative"; wrapper.appendChild(handle); } const wrapperRect = wrapper.getBoundingClientRect(); const handleLeft = block.rect.left - wrapperRect.left - 24; handle.style.display = "flex"; handle.style.top = `${block.rect.top - wrapperRect.top + 2}px`; handle.style.left = `${handleLeft}px`; }; const selectBlockAt = (editorView: EditorView, pos: number) => { try { const sel = NodeSelection.create(editorView.state.doc, pos); editorView.dispatch(editorView.state.tr.setSelection(sel)); editorView.focus(); return sel; } catch { return null; } }; const cleanupDragVisuals = () => { dragSession?.preview?.remove(); dragSession?.dropLine?.remove(); dragSession?.sourceBlock.classList.remove("notion-block--dragging"); document.documentElement.classList.remove("notion-editor-is-dragging"); }; const createDragPreview = (block: HTMLElement): HTMLElement => { const blockRect = block.getBoundingClientRect(); const preview = document.createElement("div"); const clone = block.cloneNode(true) as HTMLElement; clone.classList.remove( "ProseMirror-selectednode", "notion-block--dragging", ); clone.removeAttribute("contenteditable"); clone.style.background = "transparent"; clone.style.backgroundColor = "transparent"; clone.querySelectorAll("[contenteditable]").forEach((node) => { node.removeAttribute("contenteditable"); }); clone.querySelectorAll("*").forEach((node) => { node.style.background = "transparent"; node.style.backgroundColor = "transparent"; }); preview.className = "notion-drag-preview"; preview.style.width = `${blockRect.width}px`; preview.appendChild(clone); document.body.appendChild(preview); return preview; }; const createDropLine = ( registration: DragHandleRegistration, ): HTMLElement | null => { const wrapper = registration.view.dom.closest( registration.wrapperSelector, ); if (!wrapper) return null; const line = document.createElement("div"); line.className = "notion-drop-indicator"; wrapper.appendChild(line); return line; }; const forceHideHandle = () => { if (handle) { handle.style.display = "none"; handle.setAttribute("aria-expanded", "false"); } currentBlock = null; dragStartPos = null; }; const closeMenu = ({ hideGrip = false }: { hideGrip?: boolean } = {}) => { menu?.remove(); menu = null; menuContext = null; handle?.setAttribute("aria-expanded", "false"); document.removeEventListener("mousedown", handleMenuDocumentMouseDown, { capture: true, }); document.removeEventListener("keydown", handleMenuKeyDown, { capture: true, }); window.removeEventListener("resize", handleMenuViewportChange); window.removeEventListener("scroll", handleMenuViewportChange, { capture: true, }); if (hideGrip) forceHideHandle(); }; const resolveMenuContext = (context: DragHandleMenuContext) => { const latestBlock = getTopLevelBlocks(context.view).find( (block) => block.node === context.sourceBlock, ); const sourcePos = latestBlock?.pmPos ?? context.sourcePos; const sourceNode = context.view.state.doc.nodeAt(sourcePos); if (!sourceNode) return null; return { ...context, sourcePos, sourceNode, sourceNodeSize: sourceNode.nodeSize, }; }; const focusSelectionNear = ( view: EditorView, tr: Transaction, pos: number, bias: -1 | 1, ) => { tr.setSelection( TextSelection.near( tr.doc.resolve(clamp(pos, 0, tr.doc.content.size)), bias, ), ); view.dispatch(tr.scrollIntoView()); view.focus(); }; const duplicateBlock = (context: DragHandleMenuContext) => { const resolved = resolveMenuContext(context); if (!resolved) return; const insertPos = resolved.sourcePos + resolved.sourceNodeSize; const tr = resolved.view.state.tr.insert(insertPos, resolved.sourceNode); try { tr.setSelection(NodeSelection.create(tr.doc, insertPos)); resolved.view.dispatch(tr.scrollIntoView()); resolved.view.focus(); } catch { focusSelectionNear(resolved.view, tr, insertPos, 1); } }; const deleteBlock = (context: DragHandleMenuContext) => { const resolved = resolveMenuContext(context); if (!resolved) return; const { view, sourcePos, sourceNodeSize } = resolved; const paragraph = view.state.schema.nodes.paragraph; const sourceEnd = sourcePos + sourceNodeSize; if (view.state.doc.childCount <= 1 && paragraph) { const replacement = paragraph.createAndFill() ?? paragraph.create(); const tr = view.state.tr.replaceWith(sourcePos, sourceEnd, replacement); focusSelectionNear(view, tr, sourcePos + 1, 1); return; } const tr = view.state.tr.delete(sourcePos, sourceEnd); const selectionBias = sourcePos >= tr.doc.content.size ? -1 : 1; focusSelectionNear(view, tr, sourcePos, selectionBias); }; const insertParagraphBelow = (context: DragHandleMenuContext) => { const resolved = resolveMenuContext(context); const paragraph = resolved?.view.state.schema.nodes.paragraph; if (!resolved || !paragraph) return; const insertPos = resolved.sourcePos + resolved.sourceNodeSize; const paragraphNode = paragraph.createAndFill() ?? paragraph.create(); const tr = resolved.view.state.tr.insert(insertPos, paragraphNode); tr.setSelection(TextSelection.create(tr.doc, insertPos + 1)); resolved.view.dispatch(tr.scrollIntoView()); resolved.view.focus(); }; const positionMenu = (anchorRect: DOMRect) => { if (!menu) return; const viewportWidth = window.visualViewport?.width ?? window.innerWidth; const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const menuHeight = menu.offsetHeight || 118; const preferredLeft = anchorRect.right + DRAG_HANDLE_MENU_GAP; const alternateLeft = anchorRect.left - DRAG_HANDLE_MENU_WIDTH - DRAG_HANDLE_MENU_GAP; const left = preferredLeft + DRAG_HANDLE_MENU_WIDTH + DRAG_HANDLE_MENU_VIEWPORT_PADDING <= viewportWidth ? preferredLeft : alternateLeft; menu.style.left = `${clamp( left, DRAG_HANDLE_MENU_VIEWPORT_PADDING, viewportWidth - DRAG_HANDLE_MENU_WIDTH - DRAG_HANDLE_MENU_VIEWPORT_PADDING, )}px`; menu.style.top = `${clamp( anchorRect.top - 4, DRAG_HANDLE_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - DRAG_HANDLE_MENU_VIEWPORT_PADDING, )}px`; }; const createMenuItem = ( label: string, iconSvg: string, action: (context: DragHandleMenuContext) => void, options: { danger?: boolean } = {}, ) => { const button = document.createElement("button"); button.type = "button"; button.className = "an-rich-md-drag-menu__item"; button.setAttribute("role", "menuitem"); button.setAttribute("data-plan-interactive", "true"); if (options.danger) button.setAttribute("data-danger", "true"); const icon = document.createElement("span"); icon.className = "an-rich-md-drag-menu__icon"; icon.setAttribute("aria-hidden", "true"); icon.innerHTML = iconSvg; const labelElement = document.createElement("span"); labelElement.className = "an-rich-md-drag-menu__label"; labelElement.textContent = label; button.append(icon, labelElement); button.addEventListener("mousedown", (event) => { event.preventDefault(); }); button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); const context = menuContext; if (!context) return; closeMenu({ hideGrip: true }); action(context); }); return button; }; const openMenu = (context: DragHandleMenuContext, anchorRect: DOMRect) => { const resolved = resolveMenuContext(context); if (!resolved) return; closeMenu(); selectBlockAt(resolved.view, resolved.sourcePos); ensureDragHandleMenuStyles(); const el = document.createElement("div"); el.className = "an-rich-md-drag-menu"; el.setAttribute("role", "menu"); el.setAttribute("aria-label", "Block actions"); el.setAttribute("data-plan-interactive", "true"); el.append( createMenuItem( "Duplicate", DRAG_HANDLE_MENU_ICON_DUPLICATE, duplicateBlock, ), createMenuItem("Delete", DRAG_HANDLE_MENU_ICON_DELETE, deleteBlock, { danger: true, }), createMenuItem( "Insert block below", DRAG_HANDLE_MENU_ICON_INSERT, insertParagraphBelow, ), ); menu = el; menuContext = { view: resolved.view, sourceBlock: resolved.sourceBlock, sourcePos: resolved.sourcePos, sourceNodeSize: resolved.sourceNodeSize, }; document.body.appendChild(el); positionMenu(anchorRect); handle?.setAttribute("aria-expanded", "true"); document.addEventListener("mousedown", handleMenuDocumentMouseDown, { capture: true, }); document.addEventListener("keydown", handleMenuKeyDown, { capture: true, }); window.addEventListener("resize", handleMenuViewportChange); window.addEventListener("scroll", handleMenuViewportChange, { capture: true, }); el.querySelector("button")?.focus({ preventScroll: true, }); }; const findDropTarget = ( registration: DragHandleRegistration, clientX: number, clientY: number, ): DropTarget | null => { const view = registration.view; const block = findForgivingBlock(view, clientX, clientY); if (!block) return null; const node = view.state.doc.nodeAt(block.pmPos); if (!node) return null; let placement: DragHandleDropPlacement; const withinBlockY = clientY >= block.rect.top && clientY <= block.rect.bottom; // Side (column) zones span the FULL block height — only the horizontal // position decides column-vs-reorder. Restricting to the vertical middle // (the old 0.2 band) made the already-tiny edge slivers nearly unhittable. const sideZoneWidth = Math.min( clamp( block.rect.width * SIDE_DROP_ZONE_RATIO, SIDE_DROP_ZONE_MIN_PX, SIDE_DROP_ZONE_MAX_PX, ), block.rect.width * SIDE_DROP_ZONE_MAX_WIDTH_FRACTION, ); if ( registration.handleDrop && withinBlockY && clientX <= block.rect.left + sideZoneWidth ) { placement = "left"; } else if ( registration.handleDrop && withinBlockY && clientX >= block.rect.right - sideZoneWidth ) { placement = "right"; } else { placement = clientY < block.rect.top || (clientY <= block.rect.bottom && clientY < block.rect.top + block.rect.height / 2) ? "before" : "after"; } const before = placement === "before" || placement === "left"; return { registration, view, block: block.node, placement, pos: before ? block.pmPos : block.pmPos + node.nodeSize, targetPos: block.pmPos, targetNodeSize: node.nodeSize, rect: block.rect, }; }; const findAnyDropTarget = ( session: DragSession, clientX: number, clientY: number, ): DropTarget | null => { const candidates: DropTarget[] = []; for (const registration of dragHandleRegistrations) { if (!registration.view.dom.isConnected) continue; if ( registration.view !== session.view && session.sourceBlock.contains(registration.view.dom) ) { continue; } const target = findDropTarget(registration, clientX, clientY); if (target) candidates.push(target); } candidates.sort((a, b) => { const aRect = a.view.dom.getBoundingClientRect(); const bRect = b.view.dom.getBoundingClientRect(); return aRect.width * aRect.height - bRect.width * bRect.height; }); return candidates[0] ?? null; }; const positionDragPreview = ( session: DragSession, clientX: number, clientY: number, ) => { if (!session.preview) return; session.preview.style.transform = `translate3d(${clientX + 12}px, ${clientY + 10}px, 0)`; }; const updateDropLine = ( session: DragSession, target: DropTarget | null, ) => { const sourceEnd = session.sourcePos + session.sourceNodeSize; const isSideDrop = target?.placement === "left" || target?.placement === "right"; if ( !target || (target.view === session.view && (isSideDrop ? // A side drop only ever builds columns; the ProseMirror seam // position is irrelevant. The only no-op is dropping a block on // ITS OWN side — adjacent *different* blocks must still form // columns (otherwise dropping onto an immediate neighbour's // facing edge silently does nothing, which reads as "side drop // works sometimes"). target.targetPos === session.sourcePos : target.pos === session.sourcePos || target.pos === sourceEnd || (target.pos > session.sourcePos && target.pos < sourceEnd))) ) { session.dropTarget = null; session.dropLine?.remove(); session.dropLine = null; return; } const wrapper = target.view.dom.closest( target.registration.wrapperSelector, ); if (!wrapper) return; if (!session.dropLine || session.dropLine.parentElement !== wrapper) { session.dropLine?.remove(); session.dropLine = createDropLine(target.registration); } if (!session.dropLine) return; const wrapperRect = wrapper.getBoundingClientRect(); const editorRect = target.view.dom.getBoundingClientRect(); session.dropTarget = target; // A column (side) drop and a reorder (before/after) drop both draw the // `.notion-drop-indicator`, but they mean very different things, so the // column case carries a modifier class apps style distinctly (a bolder, // glowing vertical bar) — without a clear cue a human can't tell they've // entered column-build mode before releasing. const isColumnDrop = target.placement === "left" || target.placement === "right"; session.dropLine.classList.toggle( "notion-drop-indicator--column", isColumnDrop, ); if (isColumnDrop) { // A vertical bar centred on the seam at the target's left/right edge, // spanning the block's full height. const SIDE_BAR_WIDTH = 4; const seam = target.placement === "left" ? target.rect.left : target.rect.right; session.dropLine.style.left = `${seam - wrapperRect.left - SIDE_BAR_WIDTH / 2}px`; session.dropLine.style.top = `${target.rect.top - wrapperRect.top}px`; session.dropLine.style.width = `${SIDE_BAR_WIDTH}px`; session.dropLine.style.height = `${target.rect.height}px`; return; } const top = target.placement === "before" ? target.rect.top : target.rect.bottom; session.dropLine.style.left = `${editorRect.left - wrapperRect.left}px`; session.dropLine.style.top = `${top - wrapperRect.top}px`; session.dropLine.style.width = `${editorRect.width}px`; session.dropLine.style.height = "3px"; }; const createHandle = () => { const el = document.createElement("div"); el.className = "drag-handle"; el.contentEditable = "false"; el.draggable = false; el.tabIndex = 0; el.setAttribute("role", "button"); el.setAttribute("aria-label", "Open block menu or drag to reorder"); el.setAttribute("aria-haspopup", "menu"); el.setAttribute("aria-expanded", "false"); el.title = "Open block menu or drag to reorder"; // The icon must not be its own hit target: a real mouse-down inside a // nested editor (a column) lands on the SVG, and a container block's // capture-phase block-select handler (RegistryBlockNode) only spares the // grip DIV — so a press on the icon gets swallowed and the block can't be // dragged out of / between columns. `pointer-events:none` makes every // press in the grip area resolve to the DIV instead. // Tabler `grip-vertical` (the framework-wide icon set). `pointer-events:none` // keeps every press in the grip area resolving to the DIV, not the SVG. el.innerHTML = DRAG_HANDLE_GRIP_ICON; const gripSvg = el.querySelector("svg"); if (gripSvg) { gripSvg.setAttribute("width", "16"); gripSvg.setAttribute("height", "16"); gripSvg.style.pointerEvents = "none"; } return el; }; const hideHandle = () => { if (menu) return; forceHideHandle(); }; const removeDragListeners = () => { document.removeEventListener("mousemove", handleDocumentMouseMove); document.removeEventListener("mouseup", handleDocumentMouseUp); document.removeEventListener("keydown", handleDocumentKeyDown); }; function handleMenuDocumentMouseDown(event: MouseEvent) { const target = event.target; if (!(target instanceof Node)) return; if (menu?.contains(target) || handle?.contains(target)) return; closeMenu({ hideGrip: true }); } function handleMenuKeyDown(event: KeyboardEvent) { if (!menu) return; if (event.key === "Escape") { event.preventDefault(); closeMenu({ hideGrip: true }); return; } if ( event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End" ) { return; } const buttons = Array.from( menu.querySelectorAll("button"), ); if (buttons.length === 0) return; event.preventDefault(); const activeIndex = buttons.indexOf( document.activeElement as HTMLButtonElement, ); let nextIndex = activeIndex < 0 ? 0 : activeIndex; if (event.key === "ArrowDown") { nextIndex = (nextIndex + 1) % buttons.length; } else if (event.key === "ArrowUp") { nextIndex = (nextIndex - 1 + buttons.length) % buttons.length; } else if (event.key === "Home") { nextIndex = 0; } else if (event.key === "End") { nextIndex = buttons.length - 1; } buttons[nextIndex]?.focus({ preventScroll: true }); } function handleMenuViewportChange() { closeMenu({ hideGrip: true }); } const finishDragSession = (commit: boolean, event?: MouseEvent) => { const session = dragSession; if (!session) return; removeDragListeners(); if (commit && session.dragging && session.dropTarget) { const sourceStart = session.sourcePos; const sourceEnd = session.sourcePos + session.sourceNodeSize; const target = session.dropTarget; const dropPos = target.pos; const isSideDrop = target.placement === "left" || target.placement === "right"; if ( target.view !== session.view || (isSideDrop ? // Side drop (column build): proceed for any block that isn't the // source itself, including the source's immediate neighbour. target.targetPos !== sourceStart : dropPos !== sourceStart && dropPos !== sourceEnd && !(dropPos > sourceStart && dropPos < sourceEnd)) ) { const sourceNode = session.view.state.doc.nodeAt(sourceStart); if (sourceNode) { const sourceRegistration = registrationForView(session.view); const transferData = sourceRegistration?.getDragTransferData?.({ view: session.view, node: sourceNode, pos: sourceStart, }); const targetNode = target.view.state.doc.nodeAt(target.targetPos); const handled = !!targetNode && (target.registration.handleDrop?.(transferData, { view: target.view, sourceView: session.view, sourceNode, sourcePos: sourceStart, sourceNodeSize: sourceNode.nodeSize, targetNode, targetPos: target.targetPos, targetNodeSize: target.targetNodeSize, insertPos: dropPos, placement: target.placement, }) ?? false); if (handled) { target.view.focus(); } else if (target.view === session.view) { const insertPos = dropPos > sourceStart ? dropPos - sourceNode.nodeSize : dropPos; const tr = session.view.state.tr .delete(sourceStart, sourceEnd) .insert(insertPos, sourceNode); tr.setSelection(NodeSelection.create(tr.doc, insertPos)); session.view.dispatch(tr.scrollIntoView()); session.view.focus(); } else { try { const targetNode = target.view.state.schema.nodeFromJSON( sourceNode.toJSON(), ); target.registration.receiveDragTransferData?.(transferData, { view: target.view, node: targetNode, pos: dropPos, sourceView: session.view, }); const insertTr = target.view.state.tr.insert( dropPos, targetNode, ); insertTr.setSelection( NodeSelection.create(insertTr.doc, dropPos), ); target.view.dispatch(insertTr.scrollIntoView()); const deleteTr = session.view.state.tr.delete( sourceStart, sourceEnd, ); session.view.dispatch(deleteTr); target.view.focus(); } catch { // If the target schema cannot accept this node, leave the // source document untouched. } } } } } else if (commit && !session.dragging && event) { openMenu( { view: session.view, sourceBlock: session.sourceBlock, sourcePos: session.sourcePos, sourceNodeSize: session.sourceNodeSize, }, handle?.getBoundingClientRect() ?? session.sourceBlock.getBoundingClientRect(), ); } cleanupDragVisuals(); dragSession = null; if (activeDragRegistration === currentRegistration) { activeDragRegistration = null; } if (session.dragging || !commit) hideHandle(); }; const beginDragSession = (session: DragSession, event: MouseEvent) => { session.dragging = true; session.preview = createDragPreview(session.sourceBlock); session.sourceBlock.classList.add("notion-block--dragging"); document.documentElement.classList.add("notion-editor-is-dragging"); positionDragPreview(session, event.clientX, event.clientY); updateDropLine( session, findAnyDropTarget(session, event.clientX, event.clientY), ); }; function handleDocumentMouseMove(event: MouseEvent) { if (!dragSession) return; event.preventDefault(); const movedEnough = Math.hypot( event.clientX - dragSession.startX, event.clientY - dragSession.startY, ) > 4; if (!dragSession.dragging && movedEnough) { beginDragSession(dragSession, event); } if (!dragSession.dragging) return; positionDragPreview(dragSession, event.clientX, event.clientY); updateDropLine( dragSession, findAnyDropTarget(dragSession, event.clientX, event.clientY), ); } function handleDocumentMouseUp(event: MouseEvent) { event.preventDefault(); finishDragSession(true, event); } function handleDocumentKeyDown(event: KeyboardEvent) { if (event.key !== "Escape") return; event.preventDefault(); finishDragSession(false); } return [ new Plugin({ key: dragHandleKey, view(editorView) { const registration: DragHandleRegistration = { view: editorView, wrapperSelector, getDragTransferData, receiveDragTransferData, handleDrop, canHover: () => !!handle && !menu && !dragSession && editor.isEditable, findHoverBlock: (clientX, clientY) => findForgivingBlock(editorView, clientX, clientY), showHoverBlock: (block) => showHandleForBlock(editorView, block), hideHover: () => hideHandle(), gripRect: () => handle && handle.style.display !== "none" ? handle.getBoundingClientRect() : null, }; currentRegistration = registration; dragHandleRegistrations.add(registration); retainGlobalHoverListener(); handle = createHandle(); const wrapper = editorView.dom.closest(wrapperSelector); if (wrapper) { (wrapper as HTMLElement).style.position = "relative"; wrapper.appendChild(handle); } handle.addEventListener("mousedown", (e) => { e.stopPropagation(); if (e.button !== 0) return; closeMenu(); if (!editor.isEditable) { e.preventDefault(); return; } if (!currentBlock || dragStartPos === null) return; const sourceNode = editorView.state.doc.nodeAt(dragStartPos); if (!sourceNode) return; e.preventDefault(); dragSession = { view: editorView, sourceBlock: currentBlock, sourcePos: dragStartPos, sourceNodeSize: sourceNode.nodeSize, startX: e.clientX, startY: e.clientY, dragging: false, preview: null, dropLine: null, dropTarget: null, }; activeDragRegistration = registration; document.addEventListener("mousemove", handleDocumentMouseMove); document.addEventListener("mouseup", handleDocumentMouseUp); document.addEventListener("keydown", handleDocumentKeyDown); }); handle.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); e.stopPropagation(); closeMenu(); if (!editor.isEditable || !currentBlock || dragStartPos === null) { return; } const sourceNode = editorView.state.doc.nodeAt(dragStartPos); if (!sourceNode) return; openMenu( { view: editorView, sourceBlock: currentBlock, sourcePos: dragStartPos, sourceNodeSize: sourceNode.nodeSize, }, handle?.getBoundingClientRect() ?? currentBlock.getBoundingClientRect(), ); }); return { destroy() { closeMenu({ hideGrip: true }); finishDragSession(false); releaseGlobalHoverListener(); dragHandleRegistrations.delete(registration); if (activeDragRegistration === registration) { activeDragRegistration = null; } if (activeHoverRegistration === registration) { activeHoverRegistration = null; } handle?.remove(); handle = null; currentRegistration = null; }, }; }, props: { handleDOMEvents: { mousemove(_view, event) { updateRegisteredHover(event.clientX, event.clientY); return false; }, drop() { closeMenu({ hideGrip: true }); finishDragSession(false); hideHandle(); return false; }, }, }, }), ]; }, });