@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.
Files changed (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
@@ -0,0 +1,245 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
2
+ import { MERMAID_FLOWCHART_SHAPES, type MermaidFlowchartShapeId } from './mermaidShapes';
3
+
4
+ export interface MermaidShapePaletteProps {
5
+ selected?: MermaidFlowchartShapeId;
6
+ onPick: (shape: MermaidFlowchartShapeId) => void;
7
+ onClose: () => void;
8
+ }
9
+
10
+ const CATEGORIES = ['Basic', 'Process', 'Data', 'Documents', 'Symbols'] as const;
11
+ const PALETTE_WIDTH = 360;
12
+ const PALETTE_MAX_HEIGHT = 520;
13
+ const PALETTE_GAP = 6;
14
+ const VIEWPORT_GUTTER = 8;
15
+
16
+ interface PalettePosition extends CSSProperties {
17
+ position: 'fixed';
18
+ top: number;
19
+ left: number;
20
+ right: 'auto';
21
+ width: number;
22
+ maxHeight: number;
23
+ }
24
+
25
+ export function MermaidShapePalette({ selected, onPick, onClose }: MermaidShapePaletteProps) {
26
+ const [query, setQuery] = useState('');
27
+ const [position, setPosition] = useState<PalettePosition | null>(null);
28
+ const ref = useRef<HTMLDivElement>(null);
29
+
30
+ useEffect(() => {
31
+ const onPointerDown = (event: PointerEvent) => {
32
+ const target = event.target as Element | null;
33
+ if (target?.closest('.squisq-scene-block-toolbar')) return;
34
+ if (ref.current && !ref.current.contains(target)) onClose();
35
+ };
36
+ const onKeyDown = (event: KeyboardEvent) => {
37
+ if (event.key === 'Escape') onClose();
38
+ };
39
+ document.addEventListener('pointerdown', onPointerDown);
40
+ document.addEventListener('keydown', onKeyDown);
41
+ return () => {
42
+ document.removeEventListener('pointerdown', onPointerDown);
43
+ document.removeEventListener('keydown', onKeyDown);
44
+ };
45
+ }, [onClose]);
46
+
47
+ const sections = useMemo(() => {
48
+ const normalized = query.trim().toLowerCase();
49
+ return CATEGORIES.map((category) => ({
50
+ category,
51
+ shapes: MERMAID_FLOWCHART_SHAPES.filter(
52
+ (shape) =>
53
+ shape.category === category &&
54
+ (!normalized ||
55
+ shape.label.toLowerCase().includes(normalized) ||
56
+ shape.id.includes(normalized) ||
57
+ ('aliases' in shape &&
58
+ (shape.aliases as readonly string[]).some((alias) => alias.includes(normalized)))),
59
+ ),
60
+ })).filter((section) => section.shapes.length > 0);
61
+ }, [query]);
62
+
63
+ useLayoutEffect(() => {
64
+ const palette = ref.current;
65
+ const anchor = palette?.parentElement;
66
+ if (!palette || !anchor) return;
67
+
68
+ const editorShell = palette.closest('.squisq-editor-shell') as HTMLElement | null;
69
+ const statusBar = editorShell?.querySelector('.squisq-status-bar') as HTMLElement | null;
70
+
71
+ const measure = () => {
72
+ const anchorRect = anchor.getBoundingClientRect();
73
+ const shellRect = editorShell?.getBoundingClientRect();
74
+ const statusRect = statusBar?.getBoundingClientRect();
75
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
76
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
77
+ const boundaryTop = Math.max(VIEWPORT_GUTTER, (shellRect?.top ?? 0) + VIEWPORT_GUTTER);
78
+ const boundaryBottom =
79
+ Math.min(viewportHeight, statusRect?.top ?? viewportHeight) - VIEWPORT_GUTTER;
80
+ const targetHeight = Math.min(PALETTE_MAX_HEIGHT, Math.floor(viewportHeight * 0.7));
81
+ const belowTop = anchorRect.bottom + PALETTE_GAP;
82
+ const roomBelow = Math.max(0, boundaryBottom - belowTop);
83
+ const roomAbove = Math.max(0, anchorRect.top - PALETTE_GAP - boundaryTop);
84
+ const opensAbove = roomBelow < Math.min(targetHeight, 220) && roomAbove > roomBelow;
85
+ const availableHeight = opensAbove ? roomAbove : roomBelow;
86
+ const maxHeight = Math.max(0, Math.min(targetHeight, Math.floor(availableHeight)));
87
+ const top = opensAbove
88
+ ? Math.max(boundaryTop, anchorRect.top - PALETTE_GAP - maxHeight)
89
+ : belowTop;
90
+
91
+ const width = Math.max(0, Math.min(PALETTE_WIDTH, viewportWidth - VIEWPORT_GUTTER * 2));
92
+ const opensLeft = Boolean(anchor.closest('.squisq-scene-side-toolbar'));
93
+ const preferredLeft = opensLeft ? anchorRect.left - PALETTE_GAP - width : anchorRect.left;
94
+ const left = Math.max(
95
+ VIEWPORT_GUTTER,
96
+ Math.min(preferredLeft, viewportWidth - width - VIEWPORT_GUTTER),
97
+ );
98
+
99
+ const next: PalettePosition = {
100
+ position: 'fixed',
101
+ top: Math.round(top),
102
+ left: Math.round(left),
103
+ right: 'auto',
104
+ width: Math.round(width),
105
+ maxHeight,
106
+ };
107
+ setPosition((current) =>
108
+ current &&
109
+ current.top === next.top &&
110
+ current.left === next.left &&
111
+ current.width === next.width &&
112
+ current.maxHeight === next.maxHeight
113
+ ? current
114
+ : next,
115
+ );
116
+ };
117
+
118
+ measure();
119
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
120
+ observer?.observe(anchor);
121
+ if (editorShell) observer?.observe(editorShell);
122
+ if (statusBar) observer?.observe(statusBar);
123
+ window.addEventListener('resize', measure);
124
+ window.addEventListener('scroll', measure, true);
125
+ return () => {
126
+ observer?.disconnect();
127
+ window.removeEventListener('resize', measure);
128
+ window.removeEventListener('scroll', measure, true);
129
+ };
130
+ }, []);
131
+
132
+ return (
133
+ <div
134
+ ref={ref}
135
+ className="squisq-mermaid-shape-palette"
136
+ role="dialog"
137
+ aria-label="Mermaid node shapes"
138
+ style={position ?? undefined}
139
+ >
140
+ <input
141
+ className="squisq-mermaid-shape-search"
142
+ type="search"
143
+ value={query}
144
+ placeholder="Search 48 Mermaid shapes…"
145
+ autoFocus
146
+ onChange={(event) => setQuery(event.target.value)}
147
+ />
148
+ <div className="squisq-mermaid-shape-scroll">
149
+ {sections.map((section) => (
150
+ <section key={section.category}>
151
+ <div className="squisq-mermaid-shape-heading">{section.category}</div>
152
+ <div className="squisq-mermaid-shape-grid">
153
+ {section.shapes.map((shape) => (
154
+ <button
155
+ key={shape.id}
156
+ type="button"
157
+ className={`squisq-mermaid-shape-item${selected === shape.id ? ' squisq-mermaid-shape-item--selected' : ''}`}
158
+ onClick={() => onPick(shape.id)}
159
+ title={`${shape.label} (${shape.id})`}
160
+ aria-label={`${shape.label} shape`}
161
+ aria-pressed={selected === shape.id}
162
+ >
163
+ <MermaidShapeThumb shape={shape.id} />
164
+ <span>{shape.label}</span>
165
+ </button>
166
+ ))}
167
+ </div>
168
+ </section>
169
+ ))}
170
+ {sections.length === 0 && (
171
+ <div className="squisq-mermaid-shape-empty">No matching Mermaid shape.</div>
172
+ )}
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ function MermaidShapeThumb({ shape }: { shape: MermaidFlowchartShapeId }) {
179
+ const common = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 } as const;
180
+ if (shape.includes('circ') || shape === 'circle') {
181
+ return (
182
+ <svg viewBox="0 0 34 24" aria-hidden="true">
183
+ <ellipse cx="17" cy="12" rx="10" ry="9" {...common} />
184
+ {(shape === 'dbl-circ' || shape === 'fr-circ' || shape === 'cross-circ') && (
185
+ <ellipse cx="17" cy="12" rx="7" ry="6" {...common} />
186
+ )}
187
+ </svg>
188
+ );
189
+ }
190
+ if (shape === 'diam' || shape === 'hourglass' || shape === 'bow-rect') {
191
+ return (
192
+ <svg viewBox="0 0 34 24" aria-hidden="true">
193
+ <path
194
+ d={shape === 'diam' ? 'M17 2 31 12 17 22 3 12Z' : 'M4 3 30 3 20 12 30 21 4 21 14 12Z'}
195
+ {...common}
196
+ />
197
+ </svg>
198
+ );
199
+ }
200
+ if (shape === 'tri' || shape === 'flip-tri') {
201
+ return (
202
+ <svg viewBox="0 0 34 24" aria-hidden="true">
203
+ <path d={shape === 'tri' ? 'M17 2 31 21 3 21Z' : 'M3 3 31 3 17 22Z'} {...common} />
204
+ </svg>
205
+ );
206
+ }
207
+ if (shape === 'cloud') {
208
+ return (
209
+ <svg viewBox="0 0 34 24" aria-hidden="true">
210
+ <path d="M8 20c-7 0-7-9-1-10C7 3 16 1 20 6c6-2 10 3 8 7 6 4 1 8-4 8Z" {...common} />
211
+ </svg>
212
+ );
213
+ }
214
+ if (shape === 'cyl' || shape === 'h-cyl' || shape === 'lin-cyl') {
215
+ return (
216
+ <svg viewBox="0 0 34 24" aria-hidden="true">
217
+ <path d="M5 5c0-4 24-4 24 0v14c0 4-24 4-24 0ZM5 5c0 4 24 4 24 0" {...common} />
218
+ </svg>
219
+ );
220
+ }
221
+ if (shape === 'hex' || shape.includes('pent') || shape.startsWith('trap')) {
222
+ return (
223
+ <svg viewBox="0 0 34 24" aria-hidden="true">
224
+ <path d="M8 3h18l5 9-5 9H8l-5-9Z" {...common} />
225
+ </svg>
226
+ );
227
+ }
228
+ return (
229
+ <svg viewBox="0 0 34 24" aria-hidden="true">
230
+ <rect
231
+ x="3"
232
+ y="4"
233
+ width="28"
234
+ height="16"
235
+ rx={shape === 'rounded' || shape === 'stadium' || shape === 'delay' ? 8 : 1}
236
+ {...common}
237
+ />
238
+ {(shape.startsWith('lin-') || shape.startsWith('fr-') || shape.startsWith('div-')) && (
239
+ <path d="M8 4v16M26 4v16" {...common} />
240
+ )}
241
+ </svg>
242
+ );
243
+ }
244
+
245
+ export default MermaidShapePalette;
@@ -0,0 +1,361 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import { fireEvent } from '@testing-library/react';
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
5
+ import { markdownToTiptap, tiptapToMarkdown } from '../../tiptapBridge';
6
+ import { replaceAsciiFenceText } from '../../asciiDiagram/asciiDiagramCommands';
7
+ import {
8
+ MERMAID_DIAGRAM_KEY,
9
+ MermaidDiagramExtension,
10
+ findMermaidDiagramBlockPos,
11
+ isMermaidSourceVisible,
12
+ toggleMermaidSource,
13
+ } from '../MermaidDiagramExtension';
14
+
15
+ vi.mock('mermaid', () => ({
16
+ default: {
17
+ initialize: vi.fn(),
18
+ mermaidAPI: {
19
+ getDiagramFromText: vi.fn(async () => ({
20
+ type: 'flowchart-v2',
21
+ db: {
22
+ getVertices: () =>
23
+ new Map([
24
+ [
25
+ 'start',
26
+ {
27
+ id: 'start',
28
+ domId: 'flowchart-start-0',
29
+ text: 'Start',
30
+ type: 'square',
31
+ classes: [],
32
+ },
33
+ ],
34
+ [
35
+ 'next',
36
+ {
37
+ id: 'next',
38
+ domId: 'flowchart-next-1',
39
+ text: 'Next',
40
+ type: 'square',
41
+ classes: [],
42
+ },
43
+ ],
44
+ ]),
45
+ getEdges: () => [
46
+ {
47
+ id: 'L_start_next_0',
48
+ start: 'start',
49
+ end: 'next',
50
+ text: '',
51
+ type: 'arrow_point',
52
+ },
53
+ ],
54
+ getDirection: () => 'LR',
55
+ },
56
+ })),
57
+ },
58
+ render: vi.fn(async (id: string) => ({
59
+ svg: `<svg id="${id}" viewBox="0 0 200 100"><g class="node" id="${id}-flowchart-start-0"><rect x="5" y="10" width="70" height="40" /></g><g class="node" id="${id}-flowchart-next-1"><rect x="125" y="10" width="70" height="40" /></g><path class="flowchart-link" data-id="L_start_next_0" d="M75 30L125 30" /></svg>`,
60
+ diagramType: 'flowchart-v2',
61
+ })),
62
+ },
63
+ }));
64
+
65
+ const SAMPLE = [
66
+ 'flowchart LR',
67
+ ' client["MCP client"] <--> transport["Local stdio transport"]',
68
+ ' transport <--> sdk["MCP SDK server"]',
69
+ '',
70
+ ' subgraph docblocks["DocBlocks MCP process"]',
71
+ ' sdk --> tools["19 strict tools"]',
72
+ ' tools --> guard["Guarded expensive operations"]',
73
+ ' end',
74
+ ].join('\n');
75
+
76
+ const editors: Editor[] = [];
77
+
78
+ beforeAll(() => {
79
+ if (typeof globalThis.ResizeObserver !== 'undefined') return;
80
+ class ResizeObserverStub {
81
+ observe(): void {}
82
+ unobserve(): void {}
83
+ disconnect(): void {}
84
+ }
85
+ globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
86
+ });
87
+
88
+ function makeEditor(markdown: string, enabled = true): Editor {
89
+ const editor = new Editor({
90
+ extensions: [
91
+ StarterKit.configure({
92
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
93
+ }),
94
+ MermaidDiagramExtension.configure({ enabled }),
95
+ ],
96
+ content: markdownToTiptap(markdown),
97
+ });
98
+ editors.push(editor);
99
+ return editor;
100
+ }
101
+
102
+ function entriesOf(editor: Editor) {
103
+ return MERMAID_DIAGRAM_KEY.getState(editor.state)?.entries ?? [];
104
+ }
105
+
106
+ afterEach(() => {
107
+ for (const editor of editors) editor.destroy();
108
+ editors.length = 0;
109
+ });
110
+
111
+ describe('MermaidDiagramExtension', () => {
112
+ it('claims explicit Mermaid fences regardless of diagram type or validity', () => {
113
+ const editor = makeEditor(
114
+ `\`\`\`mermaid\n${SAMPLE}\n\`\`\`\n\n\`\`\`mermaid\nnot finished yet\n\`\`\`\n`,
115
+ );
116
+ expect(entriesOf(editor)).toHaveLength(2);
117
+ });
118
+
119
+ it('does not steal ordinary code or ASCII diagram fences', () => {
120
+ const editor = makeEditor(
121
+ '```js\nflowchart LR\n a --> b\n```\n\n```diagram\n+---+ +---+\n| A |->| B |\n+---+ +---+\n```\n',
122
+ );
123
+ expect(entriesOf(editor)).toHaveLength(0);
124
+ });
125
+
126
+ it('keeps one block id across edits above and inside the source fence', () => {
127
+ const editor = makeEditor(`\`\`\`mermaid\n${SAMPLE}\n\`\`\`\n`);
128
+ const [before] = entriesOf(editor);
129
+ editor.commands.insertContentAt(0, '<p>Before</p>');
130
+ const [shifted] = entriesOf(editor);
131
+ expect(shifted.id).toBe(before.id);
132
+ expect(shifted.pos).toBeGreaterThan(before.pos);
133
+
134
+ expect(replaceAsciiFenceText(editor, shifted.pos, `${SAMPLE}\n guard --> documents`)).toBe(
135
+ true,
136
+ );
137
+ const [rewritten] = entriesOf(editor);
138
+ expect(rewritten.id).toBe(before.id);
139
+ expect(findMermaidDiagramBlockPos(editor, before.id)).toBe(rewritten.pos);
140
+ });
141
+
142
+ it('source visibility is session-only and does not mutate Mermaid text', () => {
143
+ const editor = makeEditor(`\`\`\`mermaid\n${SAMPLE}\n\`\`\`\n`);
144
+ const [entry] = entriesOf(editor);
145
+ const before = tiptapToMarkdown(editor.getHTML());
146
+ expect(isMermaidSourceVisible(editor, entry.id)).toBe(false);
147
+ toggleMermaidSource(editor, entry.id);
148
+ expect(isMermaidSourceVisible(editor, entry.id)).toBe(true);
149
+ expect(tiptapToMarkdown(editor.getHTML())).toBe(before);
150
+ });
151
+
152
+ it('round-trips the complex graph as a Mermaid code block byte-for-byte', () => {
153
+ const markdown = `\`\`\`mermaid\n${SAMPLE}\n\`\`\`\n`;
154
+ const editor = makeEditor(markdown);
155
+ expect(tiptapToMarkdown(editor.getHTML())).toBe(markdown);
156
+ });
157
+
158
+ it('selects rendered nodes and exposes palette plus on-canvas edit gestures', async () => {
159
+ const editor = makeEditor('```mermaid\nflowchart LR\n start["Start"] --> next["Next"]\n```\n');
160
+ const root = editor.view.dom;
161
+ await vi.waitFor(() => {
162
+ expect(root.querySelector('g[data-squisq-node-id="start"]')).not.toBeNull();
163
+ });
164
+
165
+ root
166
+ .querySelector('g[data-squisq-node-id="start"]')
167
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
168
+
169
+ await vi.waitFor(() => {
170
+ expect(root.querySelector('[aria-label="Selected Mermaid node actions"]')).not.toBeNull();
171
+ });
172
+ expect(
173
+ root.querySelectorAll('[aria-label="Selected Mermaid node actions"] button'),
174
+ ).toHaveLength(5);
175
+
176
+ const shapeButton = [...root.querySelectorAll<HTMLButtonElement>('button')].find(
177
+ (button) => button.textContent?.trim() === 'Shape',
178
+ );
179
+ expect(shapeButton?.disabled).toBe(false);
180
+ shapeButton?.click();
181
+ await vi.waitFor(() => {
182
+ expect(root.querySelector('[aria-label="Mermaid node shapes"]')).not.toBeNull();
183
+ });
184
+ expect(root.querySelectorAll('[aria-label="Mermaid node shapes"] button')).toHaveLength(48);
185
+
186
+ root
187
+ .querySelector<HTMLButtonElement>(
188
+ '[aria-label="Selected Mermaid node actions"] button[aria-label="Rename node"]',
189
+ )
190
+ ?.click();
191
+ await vi.waitFor(() => {
192
+ expect(root.querySelector('[aria-label="Mermaid node label"]')).not.toBeNull();
193
+ });
194
+ const renameInput = root.querySelector<HTMLInputElement>('[aria-label="Mermaid node label"]');
195
+ expect(renameInput?.value).toBe('Start');
196
+ fireEvent.change(renameInput!, { target: { value: 'Begin' } });
197
+ fireEvent.keyDown(renameInput!, { key: 'Enter' });
198
+ await vi.waitFor(() => {
199
+ expect(tiptapToMarkdown(editor.getHTML())).toContain('start@{ shape: rect, label: "Begin" }');
200
+ });
201
+ expect(root.querySelector('[aria-label="Mermaid node label"]')).toBeNull();
202
+
203
+ await vi.waitFor(() => {
204
+ expect(root.querySelector('g[data-squisq-node-id="start"]')).not.toBeNull();
205
+ });
206
+ fireEvent.doubleClick(root.querySelector('g[data-squisq-node-id="start"]')!);
207
+ await vi.waitFor(() => {
208
+ expect(root.querySelector<HTMLInputElement>('[aria-label="Mermaid node label"]')?.value).toBe(
209
+ 'Start',
210
+ );
211
+ });
212
+ const cancelInput = root.querySelector<HTMLInputElement>('[aria-label="Mermaid node label"]')!;
213
+ fireEvent.change(cancelInput, { target: { value: 'Cancelled label' } });
214
+ fireEvent.keyDown(cancelInput, { key: 'Escape' });
215
+ expect(root.querySelector('[aria-label="Mermaid node label"]')).toBeNull();
216
+ expect(tiptapToMarkdown(editor.getHTML())).not.toContain('Cancelled label');
217
+
218
+ fireEvent.doubleClick(root.querySelector('g[data-squisq-node-id="start"]')!);
219
+ await vi.waitFor(() => {
220
+ expect(root.querySelector('[aria-label="Mermaid node label"]')).not.toBeNull();
221
+ });
222
+ const blurInput = root.querySelector<HTMLInputElement>('[aria-label="Mermaid node label"]')!;
223
+ fireEvent.change(blurInput, { target: { value: 'Click away' } });
224
+ fireEvent.blur(blurInput);
225
+ await vi.waitFor(() => {
226
+ expect(tiptapToMarkdown(editor.getHTML())).toContain(
227
+ 'start@{ shape: rect, label: "Click away" }',
228
+ );
229
+ });
230
+ });
231
+
232
+ it('edits connector labels inline and exposes on-canvas disconnect', async () => {
233
+ const editor = makeEditor('```mermaid\nflowchart LR\n start["Start"] --> next["Next"]\n```\n');
234
+ const root = editor.view.dom;
235
+ await vi.waitFor(() => {
236
+ expect(root.querySelector('.squisq-mermaid-edge-hit-target')).not.toBeNull();
237
+ });
238
+ expect(root.querySelector('.squisq-mermaid-edge-hit-target')?.getAttribute('aria-label')).toBe(
239
+ 'Connection from Start to Next',
240
+ );
241
+
242
+ root
243
+ .querySelector('.squisq-mermaid-edge-hit-target')
244
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
245
+
246
+ await vi.waitFor(() => {
247
+ expect(
248
+ root.querySelector('[aria-label="Selected Mermaid connection actions"]'),
249
+ ).not.toBeNull();
250
+ });
251
+ expect(root.querySelector('[aria-label="Mermaid node label"]')).toBeNull();
252
+ expect(
253
+ root.querySelectorAll('[aria-label="Selected Mermaid connection actions"] button'),
254
+ ).toHaveLength(2);
255
+
256
+ const sideLabelButton = [...root.querySelectorAll<HTMLButtonElement>('button')].find(
257
+ (button) => button.textContent?.trim() === 'Label',
258
+ );
259
+ expect(sideLabelButton?.disabled).toBe(false);
260
+
261
+ root.querySelector<HTMLButtonElement>('button[aria-label="Edit connection label"]')?.click();
262
+ await vi.waitFor(() => {
263
+ expect(root.querySelector('[aria-label="Mermaid connection label"]')).not.toBeNull();
264
+ });
265
+ const labelInput = root.querySelector<HTMLInputElement>(
266
+ '[aria-label="Mermaid connection label"]',
267
+ )!;
268
+ expect(labelInput.value).toBe('');
269
+ fireEvent.change(labelInput, { target: { value: 'continues' } });
270
+ fireEvent.keyDown(labelInput, { key: 'Enter' });
271
+ await vi.waitFor(() => {
272
+ expect(tiptapToMarkdown(editor.getHTML())).toContain('-->|"continues"| next["Next"]');
273
+ });
274
+ expect(root.querySelector('[aria-label="Mermaid connection label"]')).toBeNull();
275
+
276
+ await vi.waitFor(() => {
277
+ expect(root.querySelector('button[aria-label="Disconnect nodes"]')).not.toBeNull();
278
+ });
279
+ root.querySelector<HTMLButtonElement>('button[aria-label="Disconnect nodes"]')?.click();
280
+ await vi.waitFor(() => {
281
+ const markdown = tiptapToMarkdown(editor.getHTML());
282
+ expect(markdown).not.toContain('-->');
283
+ expect(markdown).toContain('start@{ shape: rect, label: "Start" }');
284
+ expect(markdown).toContain('next@{ shape: rect, label: "Next" }');
285
+ });
286
+ });
287
+
288
+ it('offers a visual gallery of horizontal and vertical flow layouts', async () => {
289
+ const editor = makeEditor('```mermaid\nflowchart LR\n start["Start"] --> next["Next"]\n```\n');
290
+ const root = editor.view.dom;
291
+ await vi.waitFor(() => {
292
+ expect(root.querySelector('button[aria-label="Direction"]')).not.toBeNull();
293
+ });
294
+
295
+ root.querySelector<HTMLButtonElement>('button[aria-label="Direction"]')?.click();
296
+ await vi.waitFor(() => {
297
+ expect(root.querySelector('[aria-label="Flowchart layout gallery"]')).not.toBeNull();
298
+ });
299
+
300
+ const gallery = root.querySelector('[aria-label="Flowchart layout gallery"]')!;
301
+ expect(gallery.querySelectorAll('.squisq-mermaid-direction-card')).toHaveLength(4);
302
+ expect(
303
+ gallery
304
+ .querySelector('[aria-label="Horizontal: left to right"]')
305
+ ?.getAttribute('aria-pressed'),
306
+ ).toBe('true');
307
+
308
+ gallery.querySelector<HTMLButtonElement>('[aria-label="Vertical: top to bottom"]')?.click();
309
+ await vi.waitFor(() => {
310
+ expect(tiptapToMarkdown(editor.getHTML())).toContain('flowchart TB');
311
+ });
312
+ });
313
+
314
+ it('pans the rendered diagram with right-mouse drag and resets from Fit diagram', async () => {
315
+ const editor = makeEditor('```mermaid\nflowchart LR\n start["Start"] --> next["Next"]\n```\n');
316
+ const root = editor.view.dom;
317
+ await vi.waitFor(() => {
318
+ expect(root.querySelector('.squisq-mermaid-svg')).not.toBeNull();
319
+ });
320
+
321
+ const viewport = root.querySelector<HTMLElement>('.squisq-mermaid-canvas-scroll')!;
322
+ const diagram = root.querySelector<HTMLElement>('.squisq-mermaid-svg')!;
323
+ const fitButton = root.querySelector<HTMLButtonElement>('button[aria-label="Fit diagram"]')!;
324
+ expect(fitButton.getAttribute('aria-pressed')).toBe('true');
325
+ expect(diagram.style.transform).toBe('translate3d(0px, 0px, 0)');
326
+
327
+ root.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]')?.click();
328
+ await vi.waitFor(() => expect(fitButton.getAttribute('aria-pressed')).toBe('false'));
329
+
330
+ fireEvent.mouseDown(viewport, {
331
+ button: 2,
332
+ buttons: 2,
333
+ clientX: 100,
334
+ clientY: 80,
335
+ });
336
+ await vi.waitFor(() => expect(viewport.dataset.panning).toBe('true'));
337
+ fireEvent.mouseMove(window, {
338
+ buttons: 2,
339
+ clientX: 145,
340
+ clientY: 110,
341
+ });
342
+ await vi.waitFor(() => {
343
+ expect(diagram.style.transform).toBe('translate3d(45px, 30px, 0)');
344
+ });
345
+ expect(fireEvent.contextMenu(viewport)).toBe(false);
346
+
347
+ fireEvent.mouseUp(window, { button: 2, buttons: 0 });
348
+ await vi.waitFor(() => expect(viewport.dataset.panning).toBeUndefined());
349
+
350
+ fitButton.click();
351
+ await vi.waitFor(() => {
352
+ expect(fitButton.getAttribute('aria-pressed')).toBe('true');
353
+ expect(diagram.style.transform).toBe('translate3d(0px, 0px, 0)');
354
+ });
355
+ });
356
+
357
+ it('can be disabled without registering a plugin state', () => {
358
+ const editor = makeEditor(`\`\`\`mermaid\n${SAMPLE}\n\`\`\`\n`, false);
359
+ expect(MERMAID_DIAGRAM_KEY.getState(editor.state)).toBeUndefined();
360
+ });
361
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import mermaid from 'mermaid';
3
+ import {
4
+ DEFAULT_MERMAID_DIAGRAM_TYPE,
5
+ MERMAID_DIAGRAM_TYPES,
6
+ mermaidDiagramMarkdown,
7
+ } from '../mermaidDiagramTypes';
8
+
9
+ describe('Mermaid diagram type gallery', () => {
10
+ it('offers distinct starter grammars across structure, planning, and data', () => {
11
+ expect(MERMAID_DIAGRAM_TYPES).toHaveLength(18);
12
+ expect(new Set(MERMAID_DIAGRAM_TYPES.map((entry) => entry.id)).size).toBe(18);
13
+ expect(new Set(MERMAID_DIAGRAM_TYPES.map((entry) => entry.category))).toEqual(
14
+ new Set(['Structure', 'Planning', 'Data']),
15
+ );
16
+ expect(DEFAULT_MERMAID_DIAGRAM_TYPE.id).toBe('flowchart');
17
+ for (const entry of MERMAID_DIAGRAM_TYPES) {
18
+ expect(entry.starter.trim()).not.toBe('');
19
+ expect(entry.starter).not.toContain('```');
20
+ }
21
+ });
22
+
23
+ it('wraps a starter in an authoritative Mermaid fence', () => {
24
+ expect(mermaidDiagramMarkdown('gantt\n title Plan')).toBe(
25
+ '\n```mermaid\ngantt\n title Plan\n```\n',
26
+ );
27
+ });
28
+
29
+ it('ships starters accepted by the pinned Mermaid parser', async () => {
30
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
31
+ for (const entry of MERMAID_DIAGRAM_TYPES) {
32
+ await expect(mermaid.parse(entry.starter), entry.label).resolves.toBeTruthy();
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ initialize: vi.fn(),
5
+ render: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('mermaid', () => ({
9
+ default: {
10
+ initialize: mocks.initialize,
11
+ render: mocks.render,
12
+ },
13
+ }));
14
+
15
+ import { mermaidErrorMessage, renderMermaidDiagram } from '../mermaidRenderer';
16
+
17
+ describe('Mermaid renderer', () => {
18
+ beforeEach(() => {
19
+ mocks.render.mockReset();
20
+ });
21
+
22
+ it('uses Mermaid output without narrowing the authored syntax', async () => {
23
+ const source = 'sequenceDiagram\n Alice->>Bob: Hello';
24
+ mocks.render.mockResolvedValue({
25
+ svg: '<svg viewBox="0 0 200 100"></svg>',
26
+ diagramType: 'sequence',
27
+ bindFunctions: vi.fn(),
28
+ });
29
+ const container = document.createElement('div');
30
+ await expect(renderMermaidDiagram('diagram-1', source, container)).resolves.toEqual({
31
+ svg: '<svg viewBox="0 0 200 100"></svg>',
32
+ diagramType: 'sequence',
33
+ });
34
+ expect(mocks.render).toHaveBeenCalledWith('diagram-1', source, container);
35
+ expect(mocks.initialize).toHaveBeenCalledWith(
36
+ expect.objectContaining({
37
+ securityLevel: 'strict',
38
+ startOnLoad: false,
39
+ suppressErrorRendering: true,
40
+ }),
41
+ );
42
+ });
43
+
44
+ it('keeps parser errors concise for the inline error panel', () => {
45
+ const lines = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
46
+ expect(mermaidErrorMessage(new Error(lines.join('\n'))).split('\n')).toHaveLength(8);
47
+ expect(mermaidErrorMessage(new Error(''))).toBe('Unknown Mermaid rendering error.');
48
+ });
49
+ });