@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,653 @@
|
|
|
1
|
+
/** Shared diagram chrome around a lossless Mermaid source/render/edit loop. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import type { Editor } from '@tiptap/react';
|
|
12
|
+
import { Icon } from '../Icon';
|
|
13
|
+
import { DiagramMaximizedOverlay } from '../diagram/DiagramMaximizedOverlay';
|
|
14
|
+
import { SceneBlockToolbar, type SceneBlockAction } from '../scene/SceneBlockToolbar';
|
|
15
|
+
import { SceneSideToolbar } from '../scene/SceneSideToolbar';
|
|
16
|
+
import { isMermaidSourceVisible, toggleMermaidSource } from './MermaidDiagramExtension';
|
|
17
|
+
import { MermaidDiagramCanvas, type MermaidNodeCanvasAction } from './MermaidDiagramCanvas';
|
|
18
|
+
import { MermaidShapePalette } from './MermaidShapePalette';
|
|
19
|
+
import { applyMermaidSourceEdit } from './mermaidCommands';
|
|
20
|
+
import { useMermaidDiagramData } from './mermaidData';
|
|
21
|
+
import type {
|
|
22
|
+
MermaidEditableEdge,
|
|
23
|
+
MermaidEditableModel,
|
|
24
|
+
MermaidEditableNode,
|
|
25
|
+
MermaidFlowchartDirection,
|
|
26
|
+
} from './mermaidModel';
|
|
27
|
+
import {
|
|
28
|
+
addMermaidNode,
|
|
29
|
+
changeMermaidNodeShape,
|
|
30
|
+
connectMermaidNodes,
|
|
31
|
+
deleteMermaidNode,
|
|
32
|
+
disconnectMermaidEdge,
|
|
33
|
+
duplicateMermaidNode,
|
|
34
|
+
renameMermaidNode,
|
|
35
|
+
setMermaidEdgeLabel,
|
|
36
|
+
setMermaidFlowchartDirection,
|
|
37
|
+
type MermaidSourceEditResult,
|
|
38
|
+
} from './mermaidSourceOps';
|
|
39
|
+
import type { MermaidFlowchartShapeId } from './mermaidShapes';
|
|
40
|
+
|
|
41
|
+
const MIN_DIAGRAM_HEIGHT = 160;
|
|
42
|
+
const DEFAULT_DIAGRAM_HEIGHT = 420;
|
|
43
|
+
const DIRECTION_PICKER_WIDTH = 344;
|
|
44
|
+
const DIRECTION_PICKER_MAX_HEIGHT = 360;
|
|
45
|
+
const DIRECTION_PICKER_GAP = 6;
|
|
46
|
+
const VIEWPORT_GUTTER = 8;
|
|
47
|
+
|
|
48
|
+
interface DirectionPickerPosition extends CSSProperties {
|
|
49
|
+
position: 'fixed';
|
|
50
|
+
top: number;
|
|
51
|
+
left: number;
|
|
52
|
+
right: 'auto';
|
|
53
|
+
width: number;
|
|
54
|
+
maxHeight: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MermaidDiagramWidgetProps {
|
|
58
|
+
editor: Editor;
|
|
59
|
+
blockId: string;
|
|
60
|
+
host?: HTMLElement | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function MermaidDiagramWidget({ editor, blockId, host }: MermaidDiagramWidgetProps) {
|
|
64
|
+
const data = useMermaidDiagramData(editor, blockId);
|
|
65
|
+
const [model, setModel] = useState<MermaidEditableModel | null>(null);
|
|
66
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
67
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
68
|
+
const [renamingNodeId, setRenamingNodeId] = useState<string | null>(null);
|
|
69
|
+
const [renamingEdgeId, setRenamingEdgeId] = useState<string | null>(null);
|
|
70
|
+
const [connecting, setConnecting] = useState(false);
|
|
71
|
+
const [connectSourceId, setConnectSourceId] = useState<string | null>(null);
|
|
72
|
+
const [openPopover, setOpenPopover] = useState<'shape' | 'direction' | null>(null);
|
|
73
|
+
const [editNotice, setEditNotice] = useState('');
|
|
74
|
+
const [maximized, setMaximized] = useState(false);
|
|
75
|
+
const [height, setHeight] = useState<number | null>(null);
|
|
76
|
+
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
|
77
|
+
const inlineRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const effectiveHeight = dragHeight ?? height;
|
|
79
|
+
|
|
80
|
+
const onModelChange = useCallback((next: MermaidEditableModel | null) => {
|
|
81
|
+
setModel(next);
|
|
82
|
+
setSelectedNodeId((id) => (id && next?.nodes.some((node) => node.id === id) ? id : null));
|
|
83
|
+
setSelectedEdgeId((id) => (id && next?.edges.some((edge) => edge.id === id) ? id : null));
|
|
84
|
+
setRenamingNodeId((id) => (id && next?.nodes.some((node) => node.id === id) ? id : null));
|
|
85
|
+
setRenamingEdgeId((id) => (id && next?.edges.some((edge) => edge.id === id) ? id : null));
|
|
86
|
+
setConnectSourceId((id) => (id && next?.nodes.some((node) => node.id === id) ? id : null));
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const applyEdit = useCallback(
|
|
90
|
+
(edit: MermaidSourceEditResult): boolean => {
|
|
91
|
+
if (!edit.ok) {
|
|
92
|
+
setEditNotice(edit.reason ?? 'This Mermaid statement can only be changed in Source.');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const applied = applyMermaidSourceEdit(editor, blockId, edit);
|
|
96
|
+
setEditNotice(applied ? '' : 'The Mermaid code block changed before this edit was applied.');
|
|
97
|
+
return applied;
|
|
98
|
+
},
|
|
99
|
+
[blockId, editor],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const flowchart = model?.kind === 'flowchart' ? model : null;
|
|
103
|
+
const selectedNode = flowchart?.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
|
104
|
+
const selectedEdge = flowchart?.edges.find((edge) => edge.id === selectedEdgeId) ?? null;
|
|
105
|
+
|
|
106
|
+
const beginRename = useCallback((node: MermaidEditableNode) => {
|
|
107
|
+
setSelectedNodeId(node.id);
|
|
108
|
+
setSelectedEdgeId(null);
|
|
109
|
+
setRenamingEdgeId(null);
|
|
110
|
+
setOpenPopover(null);
|
|
111
|
+
setRenamingNodeId(node.id);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const beginRenameEdge = useCallback((edge: MermaidEditableEdge) => {
|
|
115
|
+
setSelectedNodeId(null);
|
|
116
|
+
setSelectedEdgeId(edge.id);
|
|
117
|
+
setRenamingNodeId(null);
|
|
118
|
+
setOpenPopover(null);
|
|
119
|
+
setRenamingEdgeId(edge.id);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const commitRename = useCallback(
|
|
123
|
+
(nodeId: string, label: string): boolean => {
|
|
124
|
+
const node = flowchart?.nodes.find((candidate) => candidate.id === nodeId);
|
|
125
|
+
if (!data || !node) return false;
|
|
126
|
+
if (label.trim() === node.label) {
|
|
127
|
+
setRenamingNodeId(null);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
const applied = applyEdit(renameMermaidNode(data.source, node, label));
|
|
131
|
+
if (applied) setRenamingNodeId(null);
|
|
132
|
+
return applied;
|
|
133
|
+
},
|
|
134
|
+
[applyEdit, data, flowchart],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const commitEdgeLabel = useCallback(
|
|
138
|
+
(edgeId: string, label: string): boolean => {
|
|
139
|
+
const edge = flowchart?.edges.find((candidate) => candidate.id === edgeId);
|
|
140
|
+
if (!data || !flowchart || !edge) return false;
|
|
141
|
+
if (label.trim() === edge.label.trim()) {
|
|
142
|
+
setRenamingEdgeId(null);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const applied = applyEdit(setMermaidEdgeLabel(data.source, flowchart, edge, label));
|
|
146
|
+
if (applied) setRenamingEdgeId(null);
|
|
147
|
+
return applied;
|
|
148
|
+
},
|
|
149
|
+
[applyEdit, data, flowchart],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const beginConnect = useCallback((nodeId?: string) => {
|
|
153
|
+
setConnecting(true);
|
|
154
|
+
setConnectSourceId(nodeId ?? null);
|
|
155
|
+
setSelectedEdgeId(null);
|
|
156
|
+
setRenamingEdgeId(null);
|
|
157
|
+
if (nodeId) setSelectedNodeId(nodeId);
|
|
158
|
+
setEditNotice('');
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const handleSelectNode = useCallback(
|
|
162
|
+
(nodeId: string | null) => {
|
|
163
|
+
if (!connecting || !nodeId || !flowchart || !data) {
|
|
164
|
+
setSelectedNodeId(nodeId);
|
|
165
|
+
setRenamingNodeId((current) => (current === nodeId ? current : null));
|
|
166
|
+
if (nodeId) setRenamingEdgeId(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!connectSourceId) {
|
|
170
|
+
setConnectSourceId(nodeId);
|
|
171
|
+
setSelectedNodeId(nodeId);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const applied = applyEdit(
|
|
175
|
+
connectMermaidNodes(data.source, flowchart, connectSourceId, nodeId),
|
|
176
|
+
);
|
|
177
|
+
if (applied) {
|
|
178
|
+
setConnecting(false);
|
|
179
|
+
setConnectSourceId(null);
|
|
180
|
+
setSelectedNodeId(nodeId);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[applyEdit, connectSourceId, connecting, data, flowchart],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const handleNodeAction = useCallback(
|
|
187
|
+
(action: MermaidNodeCanvasAction, nodeId: string) => {
|
|
188
|
+
if (!flowchart || !data) return;
|
|
189
|
+
const node = flowchart.nodes.find((candidate) => candidate.id === nodeId);
|
|
190
|
+
if (!node) return;
|
|
191
|
+
setSelectedNodeId(nodeId);
|
|
192
|
+
setSelectedEdgeId(null);
|
|
193
|
+
setRenamingEdgeId(null);
|
|
194
|
+
switch (action) {
|
|
195
|
+
case 'rename':
|
|
196
|
+
beginRename(node);
|
|
197
|
+
break;
|
|
198
|
+
case 'shape':
|
|
199
|
+
setOpenPopover('shape');
|
|
200
|
+
break;
|
|
201
|
+
case 'duplicate': {
|
|
202
|
+
const result = duplicateMermaidNode(data.source, flowchart, node);
|
|
203
|
+
if (applyEdit(result) && result.nodeId) setSelectedNodeId(result.nodeId);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'connect':
|
|
207
|
+
beginConnect(nodeId);
|
|
208
|
+
break;
|
|
209
|
+
case 'delete':
|
|
210
|
+
if (applyEdit(deleteMermaidNode(data.source, flowchart, nodeId))) {
|
|
211
|
+
setSelectedNodeId(null);
|
|
212
|
+
setConnectSourceId(null);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
[applyEdit, beginConnect, beginRename, data, flowchart],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const handleDisconnect = useCallback(
|
|
221
|
+
(edgeId: string) => {
|
|
222
|
+
if (!flowchart || !data) return;
|
|
223
|
+
const edge = flowchart.edges.find((candidate) => candidate.id === edgeId);
|
|
224
|
+
if (!edge) return;
|
|
225
|
+
if (applyEdit(disconnectMermaidEdge(data.source, flowchart, edge))) {
|
|
226
|
+
setSelectedEdgeId(null);
|
|
227
|
+
setRenamingEdgeId(null);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
[applyEdit, data, flowchart],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const onResizeStart = useCallback(
|
|
234
|
+
(event: React.PointerEvent) => {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
event.stopPropagation();
|
|
237
|
+
const startY = event.clientY;
|
|
238
|
+
const startHeight =
|
|
239
|
+
inlineRef.current?.getBoundingClientRect().height ??
|
|
240
|
+
effectiveHeight ??
|
|
241
|
+
DEFAULT_DIAGRAM_HEIGHT;
|
|
242
|
+
const heightAt = (clientY: number) =>
|
|
243
|
+
Math.max(MIN_DIAGRAM_HEIGHT, Math.round(startHeight + clientY - startY));
|
|
244
|
+
const previousCursor = document.body.style.cursor;
|
|
245
|
+
const previousSelect = document.body.style.userSelect;
|
|
246
|
+
document.body.style.cursor = 'ns-resize';
|
|
247
|
+
document.body.style.userSelect = 'none';
|
|
248
|
+
const onMove = (moveEvent: PointerEvent) => setDragHeight(heightAt(moveEvent.clientY));
|
|
249
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
250
|
+
window.removeEventListener('pointermove', onMove);
|
|
251
|
+
window.removeEventListener('pointerup', onUp);
|
|
252
|
+
document.body.style.cursor = previousCursor;
|
|
253
|
+
document.body.style.userSelect = previousSelect;
|
|
254
|
+
setDragHeight(null);
|
|
255
|
+
setHeight(heightAt(upEvent.clientY));
|
|
256
|
+
};
|
|
257
|
+
window.addEventListener('pointermove', onMove);
|
|
258
|
+
window.addEventListener('pointerup', onUp);
|
|
259
|
+
},
|
|
260
|
+
[effectiveHeight],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!editNotice) return;
|
|
265
|
+
const timeout = window.setTimeout(() => setEditNotice(''), 7000);
|
|
266
|
+
return () => window.clearTimeout(timeout);
|
|
267
|
+
}, [editNotice]);
|
|
268
|
+
|
|
269
|
+
if (!data) return null;
|
|
270
|
+
const sourceVisible = isMermaidSourceVisible(editor, blockId);
|
|
271
|
+
const structured = flowchart !== null;
|
|
272
|
+
|
|
273
|
+
const actions: SceneBlockAction[] = [
|
|
274
|
+
{
|
|
275
|
+
id: 'add-node',
|
|
276
|
+
label: 'Node',
|
|
277
|
+
icon: <Icon icon="fa-solid fa-plus" />,
|
|
278
|
+
title: structured ? 'Add Mermaid node' : 'Structured actions require a flowchart',
|
|
279
|
+
disabled: !structured,
|
|
280
|
+
onClick: () => {
|
|
281
|
+
if (!flowchart) return;
|
|
282
|
+
const result = addMermaidNode(data.source, flowchart);
|
|
283
|
+
if (applyEdit(result) && result.nodeId) setSelectedNodeId(result.nodeId);
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'connect',
|
|
288
|
+
label: 'Connect',
|
|
289
|
+
icon: <Icon icon="fa-solid fa-link" />,
|
|
290
|
+
title: connecting ? 'Cancel connection' : 'Connect two Mermaid nodes',
|
|
291
|
+
disabled: !structured,
|
|
292
|
+
active: connecting,
|
|
293
|
+
onClick: () => {
|
|
294
|
+
if (connecting) {
|
|
295
|
+
setConnecting(false);
|
|
296
|
+
setConnectSourceId(null);
|
|
297
|
+
} else beginConnect(selectedNode?.id);
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'rename-label',
|
|
302
|
+
label: selectedEdge ? 'Label' : 'Rename',
|
|
303
|
+
icon: <Icon icon="fa-solid fa-pen" />,
|
|
304
|
+
title: selectedEdge ? 'Edit connection label' : 'Rename Mermaid node',
|
|
305
|
+
disabled: !selectedNode && !selectedEdge,
|
|
306
|
+
active: renamingNodeId === selectedNode?.id || renamingEdgeId === selectedEdge?.id,
|
|
307
|
+
onClick: () => {
|
|
308
|
+
if (selectedNode) beginRename(selectedNode);
|
|
309
|
+
else if (selectedEdge) beginRenameEdge(selectedEdge);
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'node-shape',
|
|
314
|
+
label: 'Shape',
|
|
315
|
+
icon: <Icon icon="fa-solid fa-shapes" />,
|
|
316
|
+
disabled: !selectedNode,
|
|
317
|
+
active: openPopover === 'shape',
|
|
318
|
+
onClick: () => setOpenPopover((value) => (value === 'shape' ? null : 'shape')),
|
|
319
|
+
popover:
|
|
320
|
+
openPopover === 'shape' && selectedNode ? (
|
|
321
|
+
<MermaidShapePalette
|
|
322
|
+
selected={selectedNode.shape}
|
|
323
|
+
onClose={() => setOpenPopover(null)}
|
|
324
|
+
onPick={(shape: MermaidFlowchartShapeId) => {
|
|
325
|
+
applyEdit(changeMermaidNodeShape(data.source, selectedNode, shape));
|
|
326
|
+
setOpenPopover(null);
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
) : undefined,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: 'duplicate-node',
|
|
333
|
+
label: 'Duplicate',
|
|
334
|
+
icon: <Icon icon="fa-solid fa-copy" />,
|
|
335
|
+
disabled: !selectedNode,
|
|
336
|
+
onClick: () => selectedNode && handleNodeAction('duplicate', selectedNode.id),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: 'disconnect-edge',
|
|
340
|
+
label: 'Disconnect',
|
|
341
|
+
icon: <Icon icon="fa-solid fa-link-slash" />,
|
|
342
|
+
disabled: !selectedEdge,
|
|
343
|
+
onClick: () => selectedEdge && handleDisconnect(selectedEdge.id),
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: 'delete-node',
|
|
347
|
+
label: 'Delete',
|
|
348
|
+
icon: <Icon icon="fa-solid fa-trash" />,
|
|
349
|
+
danger: true,
|
|
350
|
+
disabled: !selectedNode,
|
|
351
|
+
onClick: () => selectedNode && handleNodeAction('delete', selectedNode.id),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: 'direction',
|
|
355
|
+
label: 'Direction',
|
|
356
|
+
icon: <Icon icon="fa-solid fa-arrows-up-down-left-right" />,
|
|
357
|
+
disabled: !structured,
|
|
358
|
+
active: openPopover === 'direction',
|
|
359
|
+
onClick: () => setOpenPopover((value) => (value === 'direction' ? null : 'direction')),
|
|
360
|
+
popover:
|
|
361
|
+
openPopover === 'direction' && flowchart ? (
|
|
362
|
+
<MermaidDirectionPicker
|
|
363
|
+
selected={flowchart.direction}
|
|
364
|
+
onClose={() => setOpenPopover(null)}
|
|
365
|
+
onPick={(direction) => {
|
|
366
|
+
applyEdit(setMermaidFlowchartDirection(data.source, direction));
|
|
367
|
+
setOpenPopover(null);
|
|
368
|
+
}}
|
|
369
|
+
/>
|
|
370
|
+
) : undefined,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: 'mermaid-source',
|
|
374
|
+
label: 'Source',
|
|
375
|
+
icon: <Icon icon="fa-solid fa-code" />,
|
|
376
|
+
title: sourceVisible ? 'Hide Mermaid source' : 'Edit Mermaid source',
|
|
377
|
+
onClick: () => toggleMermaidSource(editor, blockId),
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
const toolbar = <SceneBlockToolbar actions={actions} />;
|
|
381
|
+
const canvas = (
|
|
382
|
+
<MermaidDiagramCanvas
|
|
383
|
+
source={data.source}
|
|
384
|
+
maximized={maximized}
|
|
385
|
+
onToggleMaximize={() => setMaximized((value) => !value)}
|
|
386
|
+
selectedNodeId={selectedNodeId}
|
|
387
|
+
selectedEdgeId={selectedEdgeId}
|
|
388
|
+
renamingNodeId={renamingNodeId}
|
|
389
|
+
renamingEdgeId={renamingEdgeId}
|
|
390
|
+
connecting={connecting}
|
|
391
|
+
connectSourceId={connectSourceId}
|
|
392
|
+
onModelChange={onModelChange}
|
|
393
|
+
onSelectNode={handleSelectNode}
|
|
394
|
+
onSelectEdge={(id) => {
|
|
395
|
+
setSelectedEdgeId(id);
|
|
396
|
+
if (id) {
|
|
397
|
+
setSelectedNodeId(null);
|
|
398
|
+
setRenamingNodeId(null);
|
|
399
|
+
setRenamingEdgeId((current) => (current === id ? current : null));
|
|
400
|
+
} else {
|
|
401
|
+
setRenamingEdgeId(null);
|
|
402
|
+
}
|
|
403
|
+
}}
|
|
404
|
+
onNodeAction={handleNodeAction}
|
|
405
|
+
onEditEdgeLabel={(id) => {
|
|
406
|
+
const edge = flowchart?.edges.find((candidate) => candidate.id === id);
|
|
407
|
+
if (edge) beginRenameEdge(edge);
|
|
408
|
+
}}
|
|
409
|
+
onCommitRename={commitRename}
|
|
410
|
+
onCommitEdgeLabel={commitEdgeLabel}
|
|
411
|
+
onCancelRename={() => setRenamingNodeId(null)}
|
|
412
|
+
onCancelEdgeLabel={() => setRenamingEdgeId(null)}
|
|
413
|
+
onDisconnectEdge={handleDisconnect}
|
|
414
|
+
/>
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (maximized) {
|
|
418
|
+
return (
|
|
419
|
+
<div
|
|
420
|
+
className="squisq-diagram-inline-placeholder"
|
|
421
|
+
style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
|
|
422
|
+
>
|
|
423
|
+
<DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
|
|
424
|
+
<div className="squisq-scene-block-max">
|
|
425
|
+
{canvas}
|
|
426
|
+
<div className="squisq-scene-side-toolbar">{toolbar}</div>
|
|
427
|
+
</div>
|
|
428
|
+
</DiagramMaximizedOverlay>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div className="squisq-scene-shell squisq-mermaid-shell">
|
|
435
|
+
<SceneSideToolbar>{toolbar}</SceneSideToolbar>
|
|
436
|
+
<div
|
|
437
|
+
className="squisq-diagram-inline"
|
|
438
|
+
ref={inlineRef}
|
|
439
|
+
style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
|
|
440
|
+
>
|
|
441
|
+
{canvas}
|
|
442
|
+
<div
|
|
443
|
+
className="squisq-diagram-resize-handle"
|
|
444
|
+
onPointerDown={onResizeStart}
|
|
445
|
+
onDoubleClick={() => {
|
|
446
|
+
setDragHeight(null);
|
|
447
|
+
setHeight(null);
|
|
448
|
+
}}
|
|
449
|
+
role="separator"
|
|
450
|
+
aria-orientation="horizontal"
|
|
451
|
+
aria-label="Resize diagram height"
|
|
452
|
+
title="Drag to resize · double-click to reset"
|
|
453
|
+
/>
|
|
454
|
+
</div>
|
|
455
|
+
{editNotice && (
|
|
456
|
+
<div className="squisq-mermaid-edit-notice" role="status">
|
|
457
|
+
{editNotice} Open Source for the full Mermaid syntax.
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function MermaidDirectionPicker({
|
|
465
|
+
selected,
|
|
466
|
+
onPick,
|
|
467
|
+
onClose,
|
|
468
|
+
}: {
|
|
469
|
+
selected: MermaidFlowchartDirection;
|
|
470
|
+
onPick: (direction: MermaidFlowchartDirection) => void;
|
|
471
|
+
onClose: () => void;
|
|
472
|
+
}) {
|
|
473
|
+
const [position, setPosition] = useState<DirectionPickerPosition | null>(null);
|
|
474
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
477
|
+
const target = event.target as Element | null;
|
|
478
|
+
if (target?.closest('.squisq-scene-block-toolbar')) return;
|
|
479
|
+
if (ref.current && !ref.current.contains(target)) onClose();
|
|
480
|
+
};
|
|
481
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
482
|
+
if (event.key === 'Escape') onClose();
|
|
483
|
+
};
|
|
484
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
485
|
+
document.addEventListener('keydown', onKeyDown);
|
|
486
|
+
return () => {
|
|
487
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
488
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
489
|
+
};
|
|
490
|
+
}, [onClose]);
|
|
491
|
+
|
|
492
|
+
useLayoutEffect(() => {
|
|
493
|
+
const picker = ref.current;
|
|
494
|
+
const anchor = picker?.parentElement;
|
|
495
|
+
if (!picker || !anchor) return;
|
|
496
|
+
|
|
497
|
+
const editorShell = picker.closest('.squisq-editor-shell') as HTMLElement | null;
|
|
498
|
+
const statusBar = editorShell?.querySelector('.squisq-status-bar') as HTMLElement | null;
|
|
499
|
+
|
|
500
|
+
const measure = () => {
|
|
501
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
502
|
+
const shellRect = editorShell?.getBoundingClientRect();
|
|
503
|
+
const statusRect = statusBar?.getBoundingClientRect();
|
|
504
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
505
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
506
|
+
const boundaryTop = Math.max(VIEWPORT_GUTTER, (shellRect?.top ?? 0) + VIEWPORT_GUTTER);
|
|
507
|
+
const boundaryBottom =
|
|
508
|
+
Math.min(viewportHeight, statusRect?.top ?? viewportHeight) - VIEWPORT_GUTTER;
|
|
509
|
+
const targetHeight = Math.min(
|
|
510
|
+
DIRECTION_PICKER_MAX_HEIGHT,
|
|
511
|
+
picker.scrollHeight,
|
|
512
|
+
Math.floor(viewportHeight * 0.7),
|
|
513
|
+
);
|
|
514
|
+
const belowTop = anchorRect.bottom + DIRECTION_PICKER_GAP;
|
|
515
|
+
const roomBelow = Math.max(0, boundaryBottom - belowTop);
|
|
516
|
+
const roomAbove = Math.max(0, anchorRect.top - DIRECTION_PICKER_GAP - boundaryTop);
|
|
517
|
+
const opensAbove = roomBelow < targetHeight && roomAbove > roomBelow;
|
|
518
|
+
const availableHeight = opensAbove ? roomAbove : roomBelow;
|
|
519
|
+
const maxHeight = Math.max(0, Math.min(targetHeight, Math.floor(availableHeight)));
|
|
520
|
+
const top = opensAbove
|
|
521
|
+
? Math.max(boundaryTop, anchorRect.top - DIRECTION_PICKER_GAP - maxHeight)
|
|
522
|
+
: belowTop;
|
|
523
|
+
const width = Math.max(
|
|
524
|
+
0,
|
|
525
|
+
Math.min(DIRECTION_PICKER_WIDTH, viewportWidth - VIEWPORT_GUTTER * 2),
|
|
526
|
+
);
|
|
527
|
+
const opensLeft = Boolean(anchor.closest('.squisq-scene-side-toolbar'));
|
|
528
|
+
const preferredLeft = opensLeft
|
|
529
|
+
? anchorRect.left - DIRECTION_PICKER_GAP - width
|
|
530
|
+
: anchorRect.left;
|
|
531
|
+
const left = Math.max(
|
|
532
|
+
VIEWPORT_GUTTER,
|
|
533
|
+
Math.min(preferredLeft, viewportWidth - width - VIEWPORT_GUTTER),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const next: DirectionPickerPosition = {
|
|
537
|
+
position: 'fixed',
|
|
538
|
+
top: Math.round(top),
|
|
539
|
+
left: Math.round(left),
|
|
540
|
+
right: 'auto',
|
|
541
|
+
width: Math.round(width),
|
|
542
|
+
maxHeight,
|
|
543
|
+
};
|
|
544
|
+
setPosition((current) =>
|
|
545
|
+
current &&
|
|
546
|
+
current.top === next.top &&
|
|
547
|
+
current.left === next.left &&
|
|
548
|
+
current.width === next.width &&
|
|
549
|
+
current.maxHeight === next.maxHeight
|
|
550
|
+
? current
|
|
551
|
+
: next,
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
measure();
|
|
556
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
|
557
|
+
observer?.observe(anchor);
|
|
558
|
+
if (editorShell) observer?.observe(editorShell);
|
|
559
|
+
if (statusBar) observer?.observe(statusBar);
|
|
560
|
+
window.addEventListener('resize', measure);
|
|
561
|
+
window.addEventListener('scroll', measure, true);
|
|
562
|
+
return () => {
|
|
563
|
+
observer?.disconnect();
|
|
564
|
+
window.removeEventListener('resize', measure);
|
|
565
|
+
window.removeEventListener('scroll', measure, true);
|
|
566
|
+
};
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
const directions: {
|
|
570
|
+
id: Exclude<MermaidFlowchartDirection, 'TD'>;
|
|
571
|
+
label: string;
|
|
572
|
+
detail: string;
|
|
573
|
+
}[] = [
|
|
574
|
+
{ id: 'LR', label: 'Horizontal', detail: 'Left to right' },
|
|
575
|
+
{ id: 'TB', label: 'Vertical', detail: 'Top to bottom' },
|
|
576
|
+
{ id: 'RL', label: 'Horizontal reversed', detail: 'Right to left' },
|
|
577
|
+
{ id: 'BT', label: 'Vertical reversed', detail: 'Bottom to top' },
|
|
578
|
+
];
|
|
579
|
+
return (
|
|
580
|
+
<div
|
|
581
|
+
ref={ref}
|
|
582
|
+
className="squisq-mermaid-direction-picker"
|
|
583
|
+
role="dialog"
|
|
584
|
+
aria-label="Flowchart layout gallery"
|
|
585
|
+
style={position ?? undefined}
|
|
586
|
+
>
|
|
587
|
+
<div className="squisq-mermaid-direction-heading">
|
|
588
|
+
<strong>Flow direction</strong>
|
|
589
|
+
<span>Re-layout this flowchart without changing its content.</span>
|
|
590
|
+
</div>
|
|
591
|
+
{directions.map((direction) => (
|
|
592
|
+
<button
|
|
593
|
+
key={direction.id}
|
|
594
|
+
type="button"
|
|
595
|
+
className="squisq-mermaid-direction-card"
|
|
596
|
+
aria-label={`${direction.label}: ${direction.detail.toLowerCase()}`}
|
|
597
|
+
aria-pressed={selected === direction.id || (selected === 'TD' && direction.id === 'TB')}
|
|
598
|
+
onClick={() => onPick(direction.id)}
|
|
599
|
+
>
|
|
600
|
+
<MermaidDirectionThumbnail direction={direction.id} />
|
|
601
|
+
<span className="squisq-mermaid-direction-copy">
|
|
602
|
+
<strong>{direction.label}</strong>
|
|
603
|
+
<small>{direction.detail}</small>
|
|
604
|
+
</span>
|
|
605
|
+
</button>
|
|
606
|
+
))}
|
|
607
|
+
</div>
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function MermaidDirectionThumbnail({
|
|
612
|
+
direction,
|
|
613
|
+
}: {
|
|
614
|
+
direction: Exclude<MermaidFlowchartDirection, 'TD'>;
|
|
615
|
+
}) {
|
|
616
|
+
const horizontal = direction === 'LR' || direction === 'RL';
|
|
617
|
+
const reversed = direction === 'RL' || direction === 'BT';
|
|
618
|
+
const first = horizontal ? { x: reversed ? 79 : 7, y: 21 } : { x: 43, y: reversed ? 39 : 3 };
|
|
619
|
+
const second = horizontal ? { x: reversed ? 7 : 79, y: 21 } : { x: 43, y: reversed ? 3 : 39 };
|
|
620
|
+
const path =
|
|
621
|
+
direction === 'LR'
|
|
622
|
+
? 'M41 32H75'
|
|
623
|
+
: direction === 'RL'
|
|
624
|
+
? 'M79 32H45'
|
|
625
|
+
: direction === 'TB'
|
|
626
|
+
? 'M60 25V35'
|
|
627
|
+
: 'M60 39V29';
|
|
628
|
+
const arrow =
|
|
629
|
+
direction === 'LR'
|
|
630
|
+
? '75,32 68,28 68,36'
|
|
631
|
+
: direction === 'RL'
|
|
632
|
+
? '45,32 52,28 52,36'
|
|
633
|
+
: direction === 'TB'
|
|
634
|
+
? '60,35 56,28 64,28'
|
|
635
|
+
: '60,29 56,36 64,36';
|
|
636
|
+
|
|
637
|
+
return (
|
|
638
|
+
<svg viewBox="0 0 120 64" aria-hidden="true">
|
|
639
|
+
<rect x={first.x} y={first.y} width="34" height="22" rx="3" />
|
|
640
|
+
<rect x={second.x} y={second.y} width="34" height="22" rx="3" />
|
|
641
|
+
<path d={path} />
|
|
642
|
+
<polygon points={arrow} />
|
|
643
|
+
<text x={first.x + 17} y={first.y + 14}>
|
|
644
|
+
A
|
|
645
|
+
</text>
|
|
646
|
+
<text x={second.x + 17} y={second.y + 14}>
|
|
647
|
+
B
|
|
648
|
+
</text>
|
|
649
|
+
</svg>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export default MermaidDiagramWidget;
|