Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 9706ba2e01 Exclude empty diagrams from zoom cursor and selection outline
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-28 17:46:02 +00:00
copilot-swe-agent[bot] de6e1cc943 Initial plan 2026-02-28 17:43:36 +00:00
Tom Moor 6bde4342b3 refactor 2026-02-28 12:26:01 -05:00
Tom Moor d22fbe9f85 fix node selection 2026-02-28 12:26:01 -05:00
Tom Moor 3094bf88ad fix: New mermaid diagram should auto-edit
fix: Empty mermaid diagram should not show zoom control
2026-02-28 12:26:01 -05:00
7 changed files with 133 additions and 93 deletions
+9 -2
View File
@@ -90,12 +90,19 @@ function usePosition({
} as DOMRect);
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const isCodeNodeSelection =
selection instanceof NodeSelection && isCode(selection.node);
const codeBlock = isCodeNodeSelection
? { pos: selection.from, node: selection.node }
: findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
if (
(codeBlock || noticeBlock) &&
(view.state.selection.empty || isCodeNodeSelection)
) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
+4 -1
View File
@@ -240,7 +240,10 @@ export function SelectionToolbar(props: Props) {
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
if (
isCodeSelection &&
(selection.empty || selection instanceof NodeSelection)
) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
+2 -3
View File
@@ -45,9 +45,8 @@ export default class SelectionToolbarExtension extends Extension {
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)
) {
return selection;
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { CopyIcon, EditIcon, ExpandedIcon } from "outline-icons";
import type { Node as ProseMirrorNode } from "prosemirror-model";
import { NodeSelection } from "prosemirror-state";
import type { EditorState } from "prosemirror-state";
import {
pluginKey as mermaidPluginKey,
@@ -19,7 +20,10 @@ export default function codeMenuItems(
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node();
const node =
state.selection instanceof NodeSelection
? state.selection.node
: state.selection.$from.node();
const frequentLanguages = getFrequentCodeLanguages();
+10 -1
View File
@@ -1770,6 +1770,15 @@ mark {
overflow: hidden;
position: relative;
}
&.ProseMirror-selectednode {
outline: none;
& + .mermaid-diagram-wrapper:not(.empty) {
cursor: zoom-in;
outline: 2px solid ${props.theme.selected};
}
}
}
.ProseMirror[contenteditable="false"] .code-block[data-language=mermaid],
@@ -1777,7 +1786,7 @@ mark {
height: 0;
overflow: hidden;
& + .mermaid-diagram-wrapper {
& + .mermaid-diagram-wrapper:not(.empty) {
cursor: zoom-in;
}
}
+92 -83
View File
@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid";
import type MermaidUnsafe from "mermaid";
import type { Node } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { toast } from "sonner";
import { isCode, isMermaid } from "../lib/isCode";
@@ -160,12 +160,15 @@ function getNewState({
doc,
pluginState,
editor,
autoEditEmpty = false,
}: {
doc: Node;
pluginState: MermaidState;
editor: Editor;
autoEditEmpty?: boolean;
}): MermaidState {
const decorations: Decoration[] = [];
let newEditingId: string | undefined;
// Find all blocks that represent Mermaid diagrams (supports both "mermaid" and "mermaidjs")
const blocks = findBlockNodes(doc).filter((item) => isMermaid(item.node));
@@ -182,9 +185,19 @@ function getNewState({
block
);
const isNewBlock = !bestDecoration;
const renderer: MermaidRenderer =
bestDecoration?.spec?.renderer ?? new MermaidRenderer(editor);
// Auto-enter edit mode for newly created empty mermaid diagrams
if (
autoEditEmpty &&
isNewBlock &&
block.node.textContent.trim().length === 0
) {
newEditingId = renderer.diagramId;
}
const diagramDecoration = Decoration.widget(
block.pos + block.node.nodeSize,
() => {
@@ -214,6 +227,7 @@ function getNewState({
return {
...pluginState,
...(newEditingId !== undefined ? { editingId: newEditingId } : {}),
decorationSet: DecorationSet.create(doc, decorations),
};
}
@@ -307,12 +321,48 @@ export default function Mermaid({
doc: transaction.doc,
pluginState: nextPluginState,
editor,
autoEditEmpty:
codeBlockChanged &&
transaction.docChanged &&
!isPaste &&
!isRemoteTransaction(transaction),
});
}
return nextPluginState;
},
},
appendTransaction(_transactions, _oldState, newState) {
const { selection } = newState;
if (selection instanceof NodeSelection) {
return null;
}
const codeBlock = findParentNode(isCode)(selection);
if (!codeBlock || !isMermaid(codeBlock.node)) {
return null;
}
const mermaidState = pluginKey.getState(newState) as MermaidState;
const decorations = mermaidState?.decorationSet.find(
codeBlock.pos,
codeBlock.pos + codeBlock.node.nodeSize
);
const nodeDecoration = decorations?.find(
(d) => d.spec.diagramId && d.from === codeBlock.pos
);
if (
nodeDecoration?.spec.diagramId &&
mermaidState?.editingId === nodeDecoration.spec.diagramId
) {
return null;
}
return newState.tr.setSelection(
NodeSelection.create(newState.doc, codeBlock.pos)
);
},
view: (view) => {
view.dispatch(view.state.tr.setMeta(pluginKey, { loaded: true }));
return {};
@@ -334,12 +384,50 @@ export default function Mermaid({
return true;
},
mousedown(view, event) {
const target = event.target as HTMLElement;
const diagram = target?.closest(".mermaid-diagram-wrapper");
if (!diagram) {
return false;
}
const codeBlock = diagram.previousElementSibling;
if (!codeBlock) {
return false;
}
const pos = view.posAtDOM(codeBlock, 0);
const $pos = view.state.doc.resolve(pos);
const nodePos = $pos.before();
const node = view.state.doc.nodeAt(nodePos);
const isSelected =
view.state.selection instanceof NodeSelection &&
view.state.selection.from === nodePos;
event.preventDefault();
if (isSelected || editor.props.readOnly) {
// Already selected or read-only, open lightbox
if (node && node.textContent.trim().length > 0) {
editor.updateActiveLightboxImage(
LightboxImageFactory.createLightboxImage(view, nodePos)
);
}
} else {
// First click, select the node
view.dispatch(
view.state.tr
.setSelection(NodeSelection.create(view.state.doc, nodePos))
.scrollIntoView()
);
}
return true;
},
mouseup(view, event) {
const target = event.target as HTMLElement;
const diagram = target?.closest(".mermaid-diagram-wrapper");
const codeBlock = diagram?.previousElementSibling;
if (!codeBlock) {
if (!diagram) {
return false;
}
@@ -356,85 +444,6 @@ export default function Mermaid({
} catch (_err) {
toast.error(dictionary.openLinkError);
}
return false;
}
const pos = view.posAtDOM(codeBlock, 0);
if (!pos) {
return false;
}
if (diagram && event.detail === 1) {
const { selection: textSelection } = view.state;
const $pos = view.state.doc.resolve(pos);
const selected =
textSelection.from >= $pos.start() &&
textSelection.to <= $pos.end();
if (selected || editor.props.readOnly) {
editor.updateActiveLightboxImage(
LightboxImageFactory.createLightboxImage(view, $pos.before())
);
return true;
}
// select node
view.dispatch(
view.state.tr
.setSelection(TextSelection.near(view.state.doc.resolve(pos)))
.scrollIntoView()
);
return true;
}
return false;
},
keydown: (view, event) => {
switch (event.key) {
case "ArrowDown": {
const { selection } = view.state;
const $pos = view.state.doc.resolve(
Math.min(selection.from + 1, view.state.doc.nodeSize)
);
const nextBlock = $pos.nodeAfter;
if (nextBlock && isMermaid(nextBlock)) {
view.dispatch(
view.state.tr
.setSelection(
TextSelection.near(
view.state.doc.resolve(selection.to + 1)
)
)
.scrollIntoView()
);
event.preventDefault();
return true;
}
return false;
}
case "ArrowUp": {
const { selection } = view.state;
const $pos = view.state.doc.resolve(
Math.max(0, selection.from - 1)
);
const prevBlock = $pos.nodeBefore;
if (prevBlock && isMermaid(prevBlock)) {
view.dispatch(
view.state.tr
.setSelection(
TextSelection.near(
view.state.doc.resolve(selection.from - 2)
)
)
.scrollIntoView()
);
event.preventDefault();
return true;
}
return false;
}
}
return false;
+11 -2
View File
@@ -8,7 +8,12 @@ import type {
Node as ProsemirrorNode,
} from "prosemirror-model";
import type { Command, EditorState } from "prosemirror-state";
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { toast } from "sonner";
import type { Primitive } from "utility-types";
@@ -124,7 +129,11 @@ export default class CodeFence extends Node {
});
},
edit_mermaid: (): Command => (state, dispatch) => {
const codeBlock = findParentNode(isCode)(state.selection);
const codeBlock =
state.selection instanceof NodeSelection &&
isCode(state.selection.node)
? { pos: state.selection.from, node: state.selection.node }
: findParentNode(isCode)(state.selection);
if (!codeBlock || !isMermaid(codeBlock.node)) {
return false;
}