@bendyline/squisq-editor-react 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- package/src/writeCanvasSettings.ts +30 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Icon } from '../Icon';
|
|
3
|
+
import { calculateFitScale, type SceneSize } from '../scene/fitScale';
|
|
4
|
+
import { SceneViewControls } from '../scene/SceneViewControls';
|
|
5
|
+
import type { MermaidEditableModel } from './mermaidModel';
|
|
6
|
+
import { mermaidErrorMessage, renderMermaidDiagram } from './mermaidRenderer';
|
|
7
|
+
|
|
8
|
+
let renderSequence = 0;
|
|
9
|
+
|
|
10
|
+
export type MermaidNodeCanvasAction = 'rename' | 'shape' | 'duplicate' | 'connect' | 'delete';
|
|
11
|
+
|
|
12
|
+
export interface MermaidDiagramCanvasProps {
|
|
13
|
+
source: string;
|
|
14
|
+
maximized?: boolean;
|
|
15
|
+
onToggleMaximize?: () => void;
|
|
16
|
+
selectedNodeId?: string | null;
|
|
17
|
+
selectedEdgeId?: string | null;
|
|
18
|
+
renamingNodeId?: string | null;
|
|
19
|
+
renamingEdgeId?: string | null;
|
|
20
|
+
connectSourceId?: string | null;
|
|
21
|
+
connecting?: boolean;
|
|
22
|
+
onModelChange?: (model: MermaidEditableModel | null) => void;
|
|
23
|
+
onSelectNode?: (id: string | null) => void;
|
|
24
|
+
onSelectEdge?: (id: string | null) => void;
|
|
25
|
+
onNodeAction?: (action: MermaidNodeCanvasAction, id: string) => void;
|
|
26
|
+
onEditEdgeLabel?: (id: string) => void;
|
|
27
|
+
onCommitRename?: (id: string, label: string) => boolean;
|
|
28
|
+
onCommitEdgeLabel?: (id: string, label: string) => boolean;
|
|
29
|
+
onCancelRename?: () => void;
|
|
30
|
+
onCancelEdgeLabel?: () => void;
|
|
31
|
+
onDisconnectEdge?: (id: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SelectionAnchor {
|
|
35
|
+
left: number;
|
|
36
|
+
top: number;
|
|
37
|
+
placement: 'above' | 'below';
|
|
38
|
+
editorLeft: number;
|
|
39
|
+
editorTop: number;
|
|
40
|
+
editorWidth: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PanOffset {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PanDragState {
|
|
49
|
+
startX: number;
|
|
50
|
+
startY: number;
|
|
51
|
+
originX: number;
|
|
52
|
+
originY: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function excludesCanvasPan(target: EventTarget | null, viewport: Element): boolean {
|
|
56
|
+
if (!(target instanceof Element)) return false;
|
|
57
|
+
const interactive = target.closest(
|
|
58
|
+
'button, input, textarea, select, a, [contenteditable="true"]',
|
|
59
|
+
);
|
|
60
|
+
// The whole Tiptap document is contenteditable, so only exclude an
|
|
61
|
+
// interactive element that is actually inside this diagram viewport.
|
|
62
|
+
return Boolean(interactive && viewport.contains(interactive));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesMermaidNodeDomId(elementId: string, modelDomId: string): boolean {
|
|
66
|
+
return elementId === modelDomId || elementId.endsWith(`-${modelDomId}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readSvgViewBox(root: HTMLElement | null): SceneSize | null {
|
|
70
|
+
const raw = root?.querySelector('svg')?.getAttribute('viewBox');
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
const values = raw
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/[\s,]+/)
|
|
75
|
+
.map(Number);
|
|
76
|
+
const width = values[2];
|
|
77
|
+
const height = values[3];
|
|
78
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
|
|
79
|
+
? { width, height }
|
|
80
|
+
: null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function MermaidDiagramCanvas({
|
|
84
|
+
source,
|
|
85
|
+
maximized = false,
|
|
86
|
+
onToggleMaximize,
|
|
87
|
+
selectedNodeId = null,
|
|
88
|
+
selectedEdgeId = null,
|
|
89
|
+
renamingNodeId = null,
|
|
90
|
+
renamingEdgeId = null,
|
|
91
|
+
connectSourceId = null,
|
|
92
|
+
connecting = false,
|
|
93
|
+
onModelChange,
|
|
94
|
+
onSelectNode,
|
|
95
|
+
onSelectEdge,
|
|
96
|
+
onNodeAction,
|
|
97
|
+
onEditEdgeLabel,
|
|
98
|
+
onCommitRename,
|
|
99
|
+
onCommitEdgeLabel,
|
|
100
|
+
onCancelRename,
|
|
101
|
+
onCancelEdgeLabel,
|
|
102
|
+
onDisconnectEdge,
|
|
103
|
+
}: MermaidDiagramCanvasProps) {
|
|
104
|
+
const svgRootRef = useRef<HTMLDivElement>(null);
|
|
105
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
107
|
+
const panDragRef = useRef<PanDragState | null>(null);
|
|
108
|
+
const renameHintId = useId();
|
|
109
|
+
const [svg, setSvg] = useState('');
|
|
110
|
+
const [model, setModel] = useState<MermaidEditableModel | null>(null);
|
|
111
|
+
const [diagramType, setDiagramType] = useState('mermaid');
|
|
112
|
+
const [error, setError] = useState('');
|
|
113
|
+
const [rendering, setRendering] = useState(true);
|
|
114
|
+
const [zoom, setZoom] = useState(1);
|
|
115
|
+
const [viewMode, setViewMode] = useState<'fit' | 'manual'>('fit');
|
|
116
|
+
const [contentSize, setContentSize] = useState<SceneSize | null>(null);
|
|
117
|
+
const [canvasSize, setCanvasSize] = useState<SceneSize | null>(null);
|
|
118
|
+
const [pan, setPan] = useState<PanOffset>({ x: 0, y: 0 });
|
|
119
|
+
const [panning, setPanning] = useState(false);
|
|
120
|
+
const [selectionAnchor, setSelectionAnchor] = useState<SelectionAnchor | null>(null);
|
|
121
|
+
const [renameValue, setRenameValue] = useState('');
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!panning) return;
|
|
125
|
+
|
|
126
|
+
const finish = () => {
|
|
127
|
+
panDragRef.current = null;
|
|
128
|
+
setPanning(false);
|
|
129
|
+
};
|
|
130
|
+
const move = (event: MouseEvent) => {
|
|
131
|
+
const drag = panDragRef.current;
|
|
132
|
+
if (!drag) return;
|
|
133
|
+
if ((event.buttons & 2) === 0) {
|
|
134
|
+
finish();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setPan({
|
|
138
|
+
x: Math.round(drag.originX + event.clientX - drag.startX),
|
|
139
|
+
y: Math.round(drag.originY + event.clientY - drag.startY),
|
|
140
|
+
});
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
window.addEventListener('mousemove', move, true);
|
|
145
|
+
window.addEventListener('mouseup', finish, true);
|
|
146
|
+
window.addEventListener('blur', finish);
|
|
147
|
+
return () => {
|
|
148
|
+
window.removeEventListener('mousemove', move, true);
|
|
149
|
+
window.removeEventListener('mouseup', finish, true);
|
|
150
|
+
window.removeEventListener('blur', finish);
|
|
151
|
+
};
|
|
152
|
+
}, [panning]);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
let current = true;
|
|
156
|
+
const id = `squisq-mermaid-svg-${++renderSequence}`;
|
|
157
|
+
setRendering(true);
|
|
158
|
+
setError('');
|
|
159
|
+
// Mermaid measures HTML labels during rendering. Its temporary host must
|
|
160
|
+
// outlive this editor canvas: switching to Page/Slideshow can unmount the
|
|
161
|
+
// widget while an async render is in flight. The renderer's body host is
|
|
162
|
+
// stable and also avoids measurements beneath the zoomed canvas.
|
|
163
|
+
void renderMermaidDiagram(id, source)
|
|
164
|
+
.then((result) => {
|
|
165
|
+
if (!current) return;
|
|
166
|
+
const nextModel = result.model ?? null;
|
|
167
|
+
setSvg(result.svg);
|
|
168
|
+
setModel(nextModel);
|
|
169
|
+
onModelChange?.(nextModel);
|
|
170
|
+
setDiagramType(result.diagramType);
|
|
171
|
+
})
|
|
172
|
+
.catch((caught: unknown) => {
|
|
173
|
+
if (!current) return;
|
|
174
|
+
setSvg('');
|
|
175
|
+
setModel(null);
|
|
176
|
+
onModelChange?.(null);
|
|
177
|
+
setError(mermaidErrorMessage(caught));
|
|
178
|
+
})
|
|
179
|
+
.finally(() => {
|
|
180
|
+
if (current) setRendering(false);
|
|
181
|
+
});
|
|
182
|
+
return () => {
|
|
183
|
+
current = false;
|
|
184
|
+
};
|
|
185
|
+
}, [source, onModelChange]);
|
|
186
|
+
|
|
187
|
+
useLayoutEffect(() => {
|
|
188
|
+
const scroll = scrollRef.current;
|
|
189
|
+
if (!scroll) return;
|
|
190
|
+
const measure = () => {
|
|
191
|
+
const rect = scroll.getBoundingClientRect();
|
|
192
|
+
const styles = window.getComputedStyle(scroll);
|
|
193
|
+
const horizontalPadding =
|
|
194
|
+
(Number.parseFloat(styles.paddingLeft) || 0) +
|
|
195
|
+
(Number.parseFloat(styles.paddingRight) || 0);
|
|
196
|
+
const verticalPadding =
|
|
197
|
+
(Number.parseFloat(styles.paddingTop) || 0) +
|
|
198
|
+
(Number.parseFloat(styles.paddingBottom) || 0);
|
|
199
|
+
const next = {
|
|
200
|
+
width: Math.max(0, rect.width - horizontalPadding),
|
|
201
|
+
height: Math.max(0, rect.height - verticalPadding),
|
|
202
|
+
};
|
|
203
|
+
setCanvasSize((current) =>
|
|
204
|
+
current?.width === next.width && current.height === next.height ? current : next,
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
measure();
|
|
208
|
+
const observer = new ResizeObserver(measure);
|
|
209
|
+
observer.observe(scroll);
|
|
210
|
+
return () => observer.disconnect();
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
useLayoutEffect(() => {
|
|
214
|
+
setContentSize(readSvgViewBox(svgRootRef.current));
|
|
215
|
+
}, [svg]);
|
|
216
|
+
|
|
217
|
+
const fitZoom = contentSize && canvasSize ? calculateFitScale(contentSize, canvasSize) : 1;
|
|
218
|
+
|
|
219
|
+
const applyFit = useCallback(() => {
|
|
220
|
+
setViewMode('fit');
|
|
221
|
+
setZoom(fitZoom);
|
|
222
|
+
setPan({ x: 0, y: 0 });
|
|
223
|
+
}, [fitZoom]);
|
|
224
|
+
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
if (viewMode !== 'fit') return;
|
|
227
|
+
setZoom(fitZoom);
|
|
228
|
+
setPan({ x: 0, y: 0 });
|
|
229
|
+
}, [fitZoom, viewMode]);
|
|
230
|
+
|
|
231
|
+
const findNodeElement = useCallback(
|
|
232
|
+
(id: string): SVGGElement | null => {
|
|
233
|
+
const root = svgRootRef.current;
|
|
234
|
+
const node = model?.nodes.find((candidate) => candidate.id === id);
|
|
235
|
+
if (!root || !node) return null;
|
|
236
|
+
return (
|
|
237
|
+
[...root.querySelectorAll<SVGGElement>('g.node')].find((element) =>
|
|
238
|
+
matchesMermaidNodeDomId(element.id, node.domId),
|
|
239
|
+
) ?? null
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
[model],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const findEdgeElements = useCallback((id: string): Element[] => {
|
|
246
|
+
const root = svgRootRef.current;
|
|
247
|
+
if (!root) return [];
|
|
248
|
+
return [...root.querySelectorAll<Element>('[data-id]')].filter(
|
|
249
|
+
(element) => element.getAttribute('data-id') === id,
|
|
250
|
+
);
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
const updateSelectionAnchor = useCallback(() => {
|
|
254
|
+
const root = svgRootRef.current;
|
|
255
|
+
const selected = selectedNodeId
|
|
256
|
+
? findNodeElement(selectedNodeId)
|
|
257
|
+
: selectedEdgeId
|
|
258
|
+
? findEdgeElements(selectedEdgeId)[0]
|
|
259
|
+
: null;
|
|
260
|
+
if (!root || !selected) {
|
|
261
|
+
setSelectionAnchor(null);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const rootRect = root.getBoundingClientRect();
|
|
265
|
+
const selectedRect = selected.getBoundingClientRect();
|
|
266
|
+
const selectedTop = selectedRect.top - rootRect.top;
|
|
267
|
+
const placement = selectedTop < 52 ? 'below' : 'above';
|
|
268
|
+
setSelectionAnchor({
|
|
269
|
+
// Five 28 px actions plus chrome are about 156 px wide. Keeping the
|
|
270
|
+
// anchor away from either edge prevents the palette being clipped when
|
|
271
|
+
// Mermaid lays a node against the scroll viewport boundary.
|
|
272
|
+
left: Math.min(
|
|
273
|
+
Math.max(selectedRect.left - rootRect.left + selectedRect.width / 2, 82),
|
|
274
|
+
Math.max(rootRect.width - 82, 82),
|
|
275
|
+
),
|
|
276
|
+
top: placement === 'above' ? selectedTop : selectedRect.bottom - rootRect.top,
|
|
277
|
+
placement,
|
|
278
|
+
editorLeft: selectedRect.left - rootRect.left + selectedRect.width / 2,
|
|
279
|
+
editorTop: selectedTop + selectedRect.height / 2,
|
|
280
|
+
editorWidth: Math.min(420, Math.max(140, selectedRect.width - 20)),
|
|
281
|
+
});
|
|
282
|
+
}, [findEdgeElements, findNodeElement, selectedEdgeId, selectedNodeId]);
|
|
283
|
+
|
|
284
|
+
useLayoutEffect(() => {
|
|
285
|
+
const node = renamingNodeId
|
|
286
|
+
? model?.nodes.find((candidate) => candidate.id === renamingNodeId)
|
|
287
|
+
: null;
|
|
288
|
+
const edge = renamingEdgeId
|
|
289
|
+
? model?.edges.find((candidate) => candidate.id === renamingEdgeId)
|
|
290
|
+
: null;
|
|
291
|
+
const label = node?.label ?? edge?.label;
|
|
292
|
+
if (label === undefined) return;
|
|
293
|
+
setRenameValue(label);
|
|
294
|
+
const frame = window.requestAnimationFrame(() => {
|
|
295
|
+
renameInputRef.current?.focus();
|
|
296
|
+
renameInputRef.current?.select();
|
|
297
|
+
});
|
|
298
|
+
return () => window.cancelAnimationFrame(frame);
|
|
299
|
+
}, [model, renamingEdgeId, renamingNodeId]);
|
|
300
|
+
|
|
301
|
+
// Decorate Mermaid-owned SVG groups with editor-only hit metadata. The SVG
|
|
302
|
+
// string remains Mermaid output; these attributes are session UI state.
|
|
303
|
+
useLayoutEffect(() => {
|
|
304
|
+
const root = svgRootRef.current;
|
|
305
|
+
if (!root || !model) {
|
|
306
|
+
setSelectionAnchor(null);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const groups = [...root.querySelectorAll<SVGGElement>('g.node')];
|
|
310
|
+
for (const group of groups) {
|
|
311
|
+
const node = model.nodes.find((candidate) =>
|
|
312
|
+
matchesMermaidNodeDomId(group.id, candidate.domId),
|
|
313
|
+
);
|
|
314
|
+
if (!node) continue;
|
|
315
|
+
group.dataset.squisqNodeId = node.id;
|
|
316
|
+
group.setAttribute('role', 'button');
|
|
317
|
+
group.setAttribute('tabindex', '0');
|
|
318
|
+
group.setAttribute('aria-label', `${node.label}, ${node.shape} Mermaid node`);
|
|
319
|
+
group.classList.toggle('squisq-mermaid-node-selected', node.id === selectedNodeId);
|
|
320
|
+
group.classList.toggle('squisq-mermaid-connect-source', node.id === connectSourceId);
|
|
321
|
+
}
|
|
322
|
+
for (const previous of root.querySelectorAll('.squisq-mermaid-edge-hit-target')) {
|
|
323
|
+
previous.remove();
|
|
324
|
+
}
|
|
325
|
+
// Mermaid's visible relationship paths are usually only 1–2px wide.
|
|
326
|
+
// Duplicate their geometry as an editor-only transparent stroke so edge
|
|
327
|
+
// selection is practical with a mouse or pen without changing the render.
|
|
328
|
+
for (const path of root.querySelectorAll<SVGPathElement>('path.flowchart-link')) {
|
|
329
|
+
const hitTarget = path.cloneNode(false) as SVGPathElement;
|
|
330
|
+
const edge = model.edges.find((candidate) => candidate.id === path.dataset.id);
|
|
331
|
+
hitTarget.removeAttribute('id');
|
|
332
|
+
hitTarget.removeAttribute('marker-start');
|
|
333
|
+
hitTarget.removeAttribute('marker-mid');
|
|
334
|
+
hitTarget.removeAttribute('marker-end');
|
|
335
|
+
hitTarget.setAttribute('class', 'squisq-mermaid-edge-hit-target');
|
|
336
|
+
if (edge) {
|
|
337
|
+
const sourceLabel =
|
|
338
|
+
model.nodes.find((node) => node.id === edge.source)?.label ?? edge.source;
|
|
339
|
+
const targetLabel =
|
|
340
|
+
model.nodes.find((node) => node.id === edge.target)?.label ?? edge.target;
|
|
341
|
+
hitTarget.setAttribute('role', 'button');
|
|
342
|
+
hitTarget.setAttribute('tabindex', '0');
|
|
343
|
+
hitTarget.setAttribute(
|
|
344
|
+
'aria-label',
|
|
345
|
+
`Connection from ${sourceLabel} to ${targetLabel}${edge.label ? `: ${edge.label}` : ''}`,
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
hitTarget.setAttribute('aria-hidden', 'true');
|
|
349
|
+
}
|
|
350
|
+
path.after(hitTarget);
|
|
351
|
+
}
|
|
352
|
+
for (const element of root.querySelectorAll('.squisq-mermaid-edge-selected')) {
|
|
353
|
+
element.classList.remove('squisq-mermaid-edge-selected');
|
|
354
|
+
}
|
|
355
|
+
if (selectedEdgeId) {
|
|
356
|
+
for (const element of findEdgeElements(selectedEdgeId)) {
|
|
357
|
+
element.classList.add('squisq-mermaid-edge-selected');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
updateSelectionAnchor();
|
|
361
|
+
}, [
|
|
362
|
+
connectSourceId,
|
|
363
|
+
findEdgeElements,
|
|
364
|
+
model,
|
|
365
|
+
selectedEdgeId,
|
|
366
|
+
selectedNodeId,
|
|
367
|
+
svg,
|
|
368
|
+
updateSelectionAnchor,
|
|
369
|
+
zoom,
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const scroll = scrollRef.current;
|
|
374
|
+
if (!scroll) return;
|
|
375
|
+
const update = () => updateSelectionAnchor();
|
|
376
|
+
scroll.addEventListener('scroll', update, { passive: true });
|
|
377
|
+
window.addEventListener('resize', update);
|
|
378
|
+
return () => {
|
|
379
|
+
scroll.removeEventListener('scroll', update);
|
|
380
|
+
window.removeEventListener('resize', update);
|
|
381
|
+
};
|
|
382
|
+
}, [updateSelectionAnchor]);
|
|
383
|
+
|
|
384
|
+
const nodeIdFromTarget = (target: EventTarget | null): string | null => {
|
|
385
|
+
if (!(target instanceof Element)) return null;
|
|
386
|
+
return target.closest<SVGGElement>('g.node')?.dataset.squisqNodeId ?? null;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const edgeIdFromTarget = (target: EventTarget | null): string | null => {
|
|
390
|
+
if (!(target instanceof Element) || !model) return null;
|
|
391
|
+
const element = target.closest<Element>('[data-id]');
|
|
392
|
+
const id = element?.getAttribute('data-id');
|
|
393
|
+
return id && model.edges.some((edge) => edge.id === id) ? id : null;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const onSvgClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
397
|
+
const nodeId = nodeIdFromTarget(event.target);
|
|
398
|
+
if (nodeId) {
|
|
399
|
+
onSelectEdge?.(null);
|
|
400
|
+
onSelectNode?.(nodeId);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const edgeId = edgeIdFromTarget(event.target);
|
|
404
|
+
if (edgeId) {
|
|
405
|
+
onSelectNode?.(null);
|
|
406
|
+
onSelectEdge?.(edgeId);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
onSelectNode?.(null);
|
|
410
|
+
onSelectEdge?.(null);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const onSvgDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
414
|
+
const nodeId = nodeIdFromTarget(event.target);
|
|
415
|
+
if (nodeId) {
|
|
416
|
+
onNodeAction?.('rename', nodeId);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const edgeId = edgeIdFromTarget(event.target);
|
|
420
|
+
if (edgeId) onEditEdgeLabel?.(edgeId);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const onSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
424
|
+
const nodeId = nodeIdFromTarget(event.target);
|
|
425
|
+
const edgeId = edgeIdFromTarget(event.target);
|
|
426
|
+
if (nodeId && (event.key === 'Enter' || event.key === ' ')) {
|
|
427
|
+
onSelectNode?.(nodeId);
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
} else if (nodeId && (event.key === 'Delete' || event.key === 'Backspace')) {
|
|
430
|
+
onNodeAction?.('delete', nodeId);
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
} else if (edgeId && (event.key === 'Enter' || event.key === ' ')) {
|
|
433
|
+
onSelectNode?.(null);
|
|
434
|
+
onSelectEdge?.(edgeId);
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
} else if (edgeId && event.key === 'F2') {
|
|
437
|
+
onSelectNode?.(null);
|
|
438
|
+
onSelectEdge?.(edgeId);
|
|
439
|
+
onEditEdgeLabel?.(edgeId);
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
} else if (edgeId && (event.key === 'Delete' || event.key === 'Backspace')) {
|
|
442
|
+
onDisconnectEdge?.(edgeId);
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
} else if (event.key === 'Escape') {
|
|
445
|
+
onSelectNode?.(null);
|
|
446
|
+
onSelectEdge?.(null);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const adjustZoom = (delta: number) => {
|
|
451
|
+
setViewMode('manual');
|
|
452
|
+
setZoom((value) => Math.min(3, Math.max(0.02, Math.round((value + delta) * 100) / 100)));
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const onPanMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
456
|
+
if (event.button !== 2 || excludesCanvasPan(event.target, event.currentTarget)) return;
|
|
457
|
+
setViewMode('manual');
|
|
458
|
+
panDragRef.current = {
|
|
459
|
+
startX: event.clientX,
|
|
460
|
+
startY: event.clientY,
|
|
461
|
+
originX: pan.x,
|
|
462
|
+
originY: pan.y,
|
|
463
|
+
};
|
|
464
|
+
setPanning(true);
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const editingNodeLabel = renamingNodeId !== null && renamingNodeId === selectedNodeId;
|
|
469
|
+
const editingEdgeLabel = renamingEdgeId !== null && renamingEdgeId === selectedEdgeId;
|
|
470
|
+
|
|
471
|
+
const commitInlineLabel = () => {
|
|
472
|
+
const committed = editingNodeLabel
|
|
473
|
+
? (onCommitRename?.(renamingNodeId, renameValue) ?? true)
|
|
474
|
+
: editingEdgeLabel
|
|
475
|
+
? (onCommitEdgeLabel?.(renamingEdgeId, renameValue) ?? true)
|
|
476
|
+
: true;
|
|
477
|
+
if (!committed) {
|
|
478
|
+
window.requestAnimationFrame(() => renameInputRef.current?.focus());
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<div className="squisq-mermaid-canvas" aria-label={`${diagramType} diagram editor`}>
|
|
484
|
+
<SceneViewControls
|
|
485
|
+
scale={zoom}
|
|
486
|
+
fit={viewMode === 'fit'}
|
|
487
|
+
onZoomOut={() => adjustZoom(-0.2)}
|
|
488
|
+
onZoomIn={() => adjustZoom(0.2)}
|
|
489
|
+
onFit={applyFit}
|
|
490
|
+
/>
|
|
491
|
+
{connecting && (
|
|
492
|
+
<div className="squisq-mermaid-connect-hint">
|
|
493
|
+
{connectSourceId ? 'Choose a target node' : 'Choose a source node'}
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
{onToggleMaximize && (
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
className="squisq-diagram-maximize-btn"
|
|
500
|
+
onClick={onToggleMaximize}
|
|
501
|
+
aria-label={maximized ? 'Restore diagram' : 'Maximize diagram'}
|
|
502
|
+
title={maximized ? 'Restore diagram' : 'Maximize diagram'}
|
|
503
|
+
>
|
|
504
|
+
<Icon icon={maximized ? 'fa-solid fa-compress' : 'fa-solid fa-expand'} />
|
|
505
|
+
</button>
|
|
506
|
+
)}
|
|
507
|
+
<div
|
|
508
|
+
ref={scrollRef}
|
|
509
|
+
className="squisq-mermaid-canvas-scroll"
|
|
510
|
+
data-panning={panning || undefined}
|
|
511
|
+
title="Right-drag to pan · Ctrl/⌘ + wheel to zoom"
|
|
512
|
+
onMouseDown={onPanMouseDown}
|
|
513
|
+
onContextMenu={(event) => {
|
|
514
|
+
if (!excludesCanvasPan(event.target, event.currentTarget)) event.preventDefault();
|
|
515
|
+
}}
|
|
516
|
+
onWheel={(event) => {
|
|
517
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
518
|
+
event.preventDefault();
|
|
519
|
+
adjustZoom(event.deltaY > 0 ? -0.1 : 0.1);
|
|
520
|
+
}}
|
|
521
|
+
>
|
|
522
|
+
{rendering && <div className="squisq-mermaid-status">Rendering Mermaid diagram…</div>}
|
|
523
|
+
{!rendering && error && (
|
|
524
|
+
<div className="squisq-mermaid-error" role="alert">
|
|
525
|
+
<strong>Mermaid could not render this source.</strong>
|
|
526
|
+
<pre>{error}</pre>
|
|
527
|
+
<span>Open Source to edit the code block without losing it.</span>
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
{!error && svg && (
|
|
531
|
+
<div
|
|
532
|
+
ref={svgRootRef}
|
|
533
|
+
className="squisq-mermaid-svg"
|
|
534
|
+
style={{
|
|
535
|
+
width: contentSize ? `${contentSize.width * zoom}px` : `${zoom * 100}%`,
|
|
536
|
+
transform: `translate3d(${pan.x}px, ${pan.y}px, 0)`,
|
|
537
|
+
}}
|
|
538
|
+
onClick={onSvgClick}
|
|
539
|
+
onDoubleClick={onSvgDoubleClick}
|
|
540
|
+
onKeyDown={onSvgKeyDown}
|
|
541
|
+
>
|
|
542
|
+
<div
|
|
543
|
+
// Mermaid's strict security level sanitizes authored markup.
|
|
544
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
545
|
+
/>
|
|
546
|
+
{selectionAnchor && (editingNodeLabel || editingEdgeLabel) && (
|
|
547
|
+
<form
|
|
548
|
+
className="squisq-mermaid-inline-rename"
|
|
549
|
+
aria-label={
|
|
550
|
+
editingNodeLabel ? 'Edit Mermaid node label' : 'Edit Mermaid connection label'
|
|
551
|
+
}
|
|
552
|
+
contentEditable={false}
|
|
553
|
+
style={{
|
|
554
|
+
left: selectionAnchor.editorLeft,
|
|
555
|
+
top: selectionAnchor.editorTop,
|
|
556
|
+
width: selectionAnchor.editorWidth,
|
|
557
|
+
}}
|
|
558
|
+
onClick={(event) => event.stopPropagation()}
|
|
559
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
560
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
561
|
+
onSubmit={(event) => {
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
commitInlineLabel();
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
<input
|
|
567
|
+
ref={renameInputRef}
|
|
568
|
+
type="text"
|
|
569
|
+
value={renameValue}
|
|
570
|
+
required={editingNodeLabel}
|
|
571
|
+
spellCheck
|
|
572
|
+
aria-label={editingNodeLabel ? 'Mermaid node label' : 'Mermaid connection label'}
|
|
573
|
+
aria-describedby={renameHintId}
|
|
574
|
+
onChange={(event) => setRenameValue(event.target.value)}
|
|
575
|
+
onBeforeInput={(event) => event.stopPropagation()}
|
|
576
|
+
onInput={(event) => {
|
|
577
|
+
event.stopPropagation();
|
|
578
|
+
setRenameValue(event.currentTarget.value);
|
|
579
|
+
}}
|
|
580
|
+
onKeyUp={(event) => event.stopPropagation()}
|
|
581
|
+
onPaste={(event) => event.stopPropagation()}
|
|
582
|
+
onBlur={commitInlineLabel}
|
|
583
|
+
onKeyDown={(event) => {
|
|
584
|
+
event.stopPropagation();
|
|
585
|
+
if (event.key === 'Enter') {
|
|
586
|
+
event.preventDefault();
|
|
587
|
+
commitInlineLabel();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (event.key !== 'Escape') return;
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
if (editingNodeLabel) onCancelRename?.();
|
|
593
|
+
else onCancelEdgeLabel?.();
|
|
594
|
+
}}
|
|
595
|
+
/>
|
|
596
|
+
<span id={renameHintId} aria-hidden="true">
|
|
597
|
+
{editingEdgeLabel
|
|
598
|
+
? 'Enter to save · leave blank to remove · Esc to cancel'
|
|
599
|
+
: 'Enter to save · Esc to cancel'}
|
|
600
|
+
</span>
|
|
601
|
+
</form>
|
|
602
|
+
)}
|
|
603
|
+
{selectionAnchor && selectedNodeId && renamingNodeId !== selectedNodeId && (
|
|
604
|
+
<div
|
|
605
|
+
className="squisq-mermaid-selection-actions"
|
|
606
|
+
role="toolbar"
|
|
607
|
+
aria-label="Selected Mermaid node actions"
|
|
608
|
+
data-placement={selectionAnchor.placement}
|
|
609
|
+
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
|
610
|
+
onClick={(event) => event.stopPropagation()}
|
|
611
|
+
>
|
|
612
|
+
<CanvasAction
|
|
613
|
+
icon="fa-solid fa-pen"
|
|
614
|
+
label="Rename node"
|
|
615
|
+
onClick={() => onNodeAction?.('rename', selectedNodeId)}
|
|
616
|
+
/>
|
|
617
|
+
<CanvasAction
|
|
618
|
+
icon="fa-solid fa-shapes"
|
|
619
|
+
label="Change shape"
|
|
620
|
+
onClick={() => onNodeAction?.('shape', selectedNodeId)}
|
|
621
|
+
/>
|
|
622
|
+
<CanvasAction
|
|
623
|
+
icon="fa-solid fa-copy"
|
|
624
|
+
label="Duplicate node"
|
|
625
|
+
onClick={() => onNodeAction?.('duplicate', selectedNodeId)}
|
|
626
|
+
/>
|
|
627
|
+
<CanvasAction
|
|
628
|
+
icon="fa-solid fa-link"
|
|
629
|
+
label="Connect node"
|
|
630
|
+
onClick={() => onNodeAction?.('connect', selectedNodeId)}
|
|
631
|
+
/>
|
|
632
|
+
<CanvasAction
|
|
633
|
+
icon="fa-solid fa-trash"
|
|
634
|
+
label="Delete node"
|
|
635
|
+
danger
|
|
636
|
+
onClick={() => onNodeAction?.('delete', selectedNodeId)}
|
|
637
|
+
/>
|
|
638
|
+
</div>
|
|
639
|
+
)}
|
|
640
|
+
{selectionAnchor && selectedEdgeId && renamingEdgeId !== selectedEdgeId && (
|
|
641
|
+
<div
|
|
642
|
+
className="squisq-mermaid-selection-actions"
|
|
643
|
+
role="toolbar"
|
|
644
|
+
aria-label="Selected Mermaid connection actions"
|
|
645
|
+
data-placement={selectionAnchor.placement}
|
|
646
|
+
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
|
647
|
+
onClick={(event) => event.stopPropagation()}
|
|
648
|
+
>
|
|
649
|
+
<CanvasAction
|
|
650
|
+
icon="fa-solid fa-pen"
|
|
651
|
+
label="Edit connection label"
|
|
652
|
+
onClick={() => onEditEdgeLabel?.(selectedEdgeId)}
|
|
653
|
+
/>
|
|
654
|
+
<CanvasAction
|
|
655
|
+
icon="fa-solid fa-link-slash"
|
|
656
|
+
label="Disconnect nodes"
|
|
657
|
+
danger
|
|
658
|
+
onClick={() => onDisconnectEdge?.(selectedEdgeId)}
|
|
659
|
+
/>
|
|
660
|
+
</div>
|
|
661
|
+
)}
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function CanvasAction({
|
|
670
|
+
icon,
|
|
671
|
+
label,
|
|
672
|
+
danger = false,
|
|
673
|
+
onClick,
|
|
674
|
+
}: {
|
|
675
|
+
icon: string;
|
|
676
|
+
label: string;
|
|
677
|
+
danger?: boolean;
|
|
678
|
+
onClick: () => void;
|
|
679
|
+
}) {
|
|
680
|
+
return (
|
|
681
|
+
<button
|
|
682
|
+
type="button"
|
|
683
|
+
title={label}
|
|
684
|
+
aria-label={label}
|
|
685
|
+
data-danger={danger || undefined}
|
|
686
|
+
onClick={onClick}
|
|
687
|
+
>
|
|
688
|
+
<Icon icon={icon} />
|
|
689
|
+
</button>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export default MermaidDiagramCanvas;
|