@bendyline/squisq-editor-react 2.0.1 → 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.
Files changed (70) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +505 -15
  3. package/dist/index.js +6035 -2283
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +870 -40
  6. package/package.json +6 -5
  7. package/src/EditorContext.tsx +27 -0
  8. package/src/EditorShell.tsx +24 -0
  9. package/src/PreviewControls.tsx +20 -5
  10. package/src/PreviewPanel.tsx +5 -1
  11. package/src/RawEditor.tsx +12 -0
  12. package/src/RecorderEntry.tsx +3 -0
  13. package/src/Toolbar.tsx +315 -17
  14. package/src/WysiwygEditor.tsx +25 -11
  15. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  16. package/src/__tests__/editorShellProps.test.tsx +65 -0
  17. package/src/__tests__/findMode.test.tsx +104 -0
  18. package/src/__tests__/findModel.test.ts +55 -0
  19. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  20. package/src/__tests__/mediaReferences.test.ts +15 -0
  21. package/src/__tests__/previewControls.test.tsx +31 -0
  22. package/src/__tests__/tiptapBridge.test.ts +9 -0
  23. package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
  24. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  25. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  26. package/src/buildPreviewDoc.ts +9 -7
  27. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  28. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  29. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  30. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  31. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  32. package/src/codeSnippet/codeSnippetData.ts +43 -0
  33. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  34. package/src/diagram/DiagramCanvas.tsx +1 -0
  35. package/src/find/FindHighlightExtension.ts +81 -0
  36. package/src/find/FindToolbar.tsx +314 -0
  37. package/src/find/findModel.ts +77 -0
  38. package/src/index.ts +89 -0
  39. package/src/markdownCodeFence.ts +72 -0
  40. package/src/mediaReferences.ts +5 -3
  41. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  42. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  43. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  44. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  45. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  46. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  47. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  48. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  49. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  50. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  51. package/src/mermaid/mermaidCommands.ts +34 -0
  52. package/src/mermaid/mermaidData.ts +31 -0
  53. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  54. package/src/mermaid/mermaidModel.ts +31 -0
  55. package/src/mermaid/mermaidRenderer.ts +181 -0
  56. package/src/mermaid/mermaidShapes.ts +113 -0
  57. package/src/mermaid/mermaidSourceOps.ts +454 -0
  58. package/src/scene/Scene.tsx +46 -15
  59. package/src/scene/SceneBlockToolbar.tsx +29 -14
  60. package/src/scene/SceneViewControls.tsx +43 -0
  61. package/src/scene/__tests__/fitScale.test.ts +19 -0
  62. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  63. package/src/scene/fitScale.ts +17 -0
  64. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  65. package/src/scene/scene.css +85 -3
  66. package/src/styles/code-snippet.css +76 -0
  67. package/src/styles/editor.css +322 -2
  68. package/src/styles/index.css +2 -0
  69. package/src/styles/mermaid-diagram.css +487 -0
  70. 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;