From 843205a43743c14b286b8d75336ea32a23f48c67 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 26 Apr 2026 13:36:32 -0400 Subject: [PATCH] fix mermaid --- app/editor/extensions/DragHandle.ts | 97 ++++++++++++++++++++++++++--- shared/editor/extensions/Mermaid.ts | 16 ++++- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/app/editor/extensions/DragHandle.ts b/app/editor/extensions/DragHandle.ts index 82cc28cf58..2c68aa1ff5 100644 --- a/app/editor/extensions/DragHandle.ts +++ b/app/editor/extensions/DragHandle.ts @@ -17,6 +17,7 @@ type Target = { pos: number; element: HTMLElement; isListItem: boolean; + isCheckboxItem: boolean; }; type PluginState = { @@ -118,9 +119,7 @@ export default class DragHandle extends Extension { const show = (next: Target) => { target = next; const rect = next.element.getBoundingClientRect(); - // List items render their own marker in the gutter so the - // handle needs a small extra offset to clear it. - const offsetX = next.isListItem ? 40 : 29; + const offsetX = next.isCheckboxItem ? 0 : next.isListItem ? 40 : 24; const offsetY = 2; handle.style.top = `${rect.top - offsetY}px`; handle.style.left = `${rect.left - offsetX}px`; @@ -184,6 +183,9 @@ export default class DragHandle extends Extension { ); event.dataTransfer.clearData(); event.dataTransfer.effectAllowed = "copyMove"; + // Hide the handle for the duration of the drag — leave + // pointer-events alone so the in-flight drag isn't cancelled. + handle.style.opacity = "0"; const selection = NodeSelection.create(view.state.doc, pos); // Use the slice from the original NodeSelection rather than // view.state.selection, which prosemirror-tables' tableEditing @@ -301,6 +303,15 @@ function findTarget(view: EditorView, event: MouseEvent): Target | null { contentLeft, Math.min(contentRight, event.clientX) ); + + // Mermaid diagrams hide their source code block and render an SVG widget + // next to it — target the underlying code block but anchor the handle to + // the visible diagram element. + const mermaid = findMermaidTarget(view, event, projectedX); + if (mermaid) { + return mermaid; + } + const coords = view.posAtCoords({ left: projectedX, top: event.clientY, @@ -316,20 +327,80 @@ function findTarget(view: EditorView, event: MouseEvent): Target | null { if (!(dom instanceof HTMLElement)) { return null; } - return { pos: resolved.pos, element: dom, isListItem: resolved.isListItem }; + return { + pos: resolved.pos, + element: dom, + isListItem: resolved.isListItem, + isCheckboxItem: resolved.isCheckboxItem, + }; +} + +function findMermaidTarget( + view: EditorView, + event: MouseEvent, + projectedX: number +): Target | null { + // Look at the element directly under the cursor, and at the projected X + // inside the editor so we still find the diagram when the cursor sits in + // the left gutter where the handle is rendered. + const eventTarget = event.target as HTMLElement | null; + const projectedTarget = + projectedX !== event.clientX + ? document.elementFromPoint(projectedX, event.clientY) + : null; + const diagram = + eventTarget?.closest(".mermaid-diagram-wrapper") ?? + (projectedTarget instanceof Element + ? projectedTarget.closest(".mermaid-diagram-wrapper") + : null); + if (!diagram) { + return null; + } + const codeBlockDom = diagram.previousElementSibling; + if (!(codeBlockDom instanceof HTMLElement)) { + return null; + } + let pos: number; + try { + pos = view.posAtDOM(codeBlockDom, 0); + } catch { + return null; + } + if (pos < 0) { + return null; + } + const $pos = view.state.doc.resolve(pos); + const blockPos = $pos.depth > 0 ? $pos.before($pos.depth) : 0; + if (!view.state.doc.nodeAt(blockPos)) { + return null; + } + return { + pos: blockPos, + element: diagram, + isListItem: false, + isCheckboxItem: false, + }; } function resolveTargetPos( state: EditorState, pos: number -): { pos: number; isListItem: boolean } | null { +): { + pos: number; + isListItem: boolean; + isCheckboxItem: boolean; +} | null { const $pos = state.doc.resolve(pos); const listItem = findParentNodeClosestToPos($pos, (node) => LIST_ITEM_TYPES.includes(node.type.name) ); if (listItem) { - return { pos: listItem.pos, isListItem: true }; + return { + pos: listItem.pos, + isListItem: true, + isCheckboxItem: listItem.node.type.name === "checkbox_item", + }; } if ($pos.depth >= 1) { @@ -342,7 +413,11 @@ function resolveTargetPos( if (node.type.name === "paragraph" && node.content.size === 0) { return null; } - return { pos: $pos.before(1), isListItem: false }; + return { + pos: $pos.before(1), + isListItem: false, + isCheckboxItem: false, + }; } // Hovering on a top-level atom block (video, etc.) — there are no @@ -351,11 +426,15 @@ function resolveTargetPos( // position is adjacent to. const after = $pos.nodeAfter; if (after?.isBlock && after.isAtom) { - return { pos: $pos.pos, isListItem: false }; + return { pos: $pos.pos, isListItem: false, isCheckboxItem: false }; } const before = $pos.nodeBefore; if (before?.isBlock && before.isAtom) { - return { pos: $pos.pos - before.nodeSize, isListItem: false }; + return { + pos: $pos.pos - before.nodeSize, + isListItem: false, + isCheckboxItem: false, + }; } return null; } diff --git a/shared/editor/extensions/Mermaid.ts b/shared/editor/extensions/Mermaid.ts index e31c00d667..38be370071 100644 --- a/shared/editor/extensions/Mermaid.ts +++ b/shared/editor/extensions/Mermaid.ts @@ -430,9 +430,23 @@ export default function Mermaid({ const node = state.selection.$head.parent; const previousNode = oldState.selection.$head.parent; + // For a NodeSelection on a top-level code_fence, $head.parent + // resolves to the doc rather than the code_fence — so also inspect + // the selected node directly to catch e.g. drag-and-drop reordering. + const selectedNode = + state.selection instanceof NodeSelection + ? state.selection.node + : null; + const previousSelectedNode = + oldState.selection instanceof NodeSelection + ? oldState.selection.node + : null; const codeBlockChanged = transaction.docChanged && - (isMermaid(node) || isMermaid(previousNode)); + (isMermaid(node) || + isMermaid(previousNode) || + (!!selectedNode && isMermaid(selectedNode)) || + (!!previousSelectedNode && isMermaid(previousSelectedNode))); // @ts-expect-error accessing private field. const isPaste = transaction.meta?.paste;