@bendyline/squisq-editor-react 1.6.2 → 2.0.1

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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RepairableDiagramExtension: a broken box-art fence gets a repair entry (and
3
+ * the inline button); applying the repair rewrites it to clean, `diagram`-
4
+ * tagged art that the AsciiDiagramExtension then claims. One undo restores the
5
+ * original broken bytes.
6
+ */
7
+
8
+ import { Editor } from '@tiptap/core';
9
+ import StarterKit from '@tiptap/starter-kit';
10
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
11
+ import { detectAsciiDiagram } from '@bendyline/squisq/doc';
12
+ import { markdownToTiptap } from '../../tiptapBridge';
13
+ import { HeadingWithTemplate } from '../../TemplateAnnotation';
14
+ import { AsciiDiagramExtension, ASCII_DIAGRAM_KEY } from '../AsciiDiagramExtension';
15
+ import { RepairableDiagramExtension, REPAIRABLE_KEY } from '../RepairableDiagramExtension';
16
+ import { applyRepairCommand } from '../asciiDiagramCommands';
17
+
18
+ beforeAll(() => {
19
+ if (typeof globalThis.ResizeObserver === 'undefined') {
20
+ class ResizeObserverStub {
21
+ observe(): void {}
22
+ unobserve(): void {}
23
+ disconnect(): void {}
24
+ }
25
+ globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
26
+ }
27
+ });
28
+
29
+ // Broken box art: labels overflow + collide, so the columns desync row-to-row
30
+ // and conservative detection declines it.
31
+ const BROKEN = [
32
+ '┌──────────┐ ┌──────────┐',
33
+ '│ @scope/alpha-svc │ │ @scope/beta-svc │',
34
+ '│ one │ │ two │',
35
+ '└────┬─────┘ └────┬─────┘',
36
+ ' │ │',
37
+ ' ▼ ▼',
38
+ '┌────────────────────────────┐',
39
+ '│ @scope/gamma-store │',
40
+ '│ persists │',
41
+ '└────────────────────────────┘',
42
+ ].join('\n');
43
+
44
+ const CLEAN = [
45
+ '┌────────┐',
46
+ '│ Alpha │',
47
+ '└───┬────┘',
48
+ ' ▼',
49
+ '┌────────┐',
50
+ '│ Beta │',
51
+ '└────────┘',
52
+ ].join('\n');
53
+
54
+ let editors: Editor[] = [];
55
+
56
+ function makeEditor(md: string): Editor {
57
+ const editor = new Editor({
58
+ extensions: [
59
+ StarterKit.configure({
60
+ heading: false,
61
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
62
+ }),
63
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
64
+ AsciiDiagramExtension,
65
+ RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
66
+ ],
67
+ content: markdownToTiptap(md),
68
+ });
69
+ editors.push(editor);
70
+ return editor;
71
+ }
72
+
73
+ afterEach(() => {
74
+ for (const e of editors) e.destroy();
75
+ editors = [];
76
+ });
77
+
78
+ const fenced = (art: string) => '```\n' + art + '\n```\n';
79
+
80
+ function fenceOf(editor: Editor): { text: string; language: string | null } {
81
+ let text = '';
82
+ let language: string | null = null;
83
+ editor.state.doc.descendants((node) => {
84
+ if (node.type.name === 'codeBlock' && text === '') {
85
+ text = node.textContent;
86
+ language = (node.attrs as { language?: string | null }).language ?? null;
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ return { text, language };
92
+ }
93
+
94
+ function repairId(editor: Editor): string | undefined {
95
+ return REPAIRABLE_KEY.getState(editor.state)?.entries[0]?.id;
96
+ }
97
+
98
+ describe('RepairableDiagramExtension', () => {
99
+ it('registers a repair entry for broken box art', () => {
100
+ const editor = makeEditor(fenced(BROKEN));
101
+ expect(REPAIRABLE_KEY.getState(editor.state)?.entries.length).toBe(1);
102
+ // …and the AsciiDiagramExtension does NOT claim it (garbled labels).
103
+ expect(ASCII_DIAGRAM_KEY.getState(editor.state)?.entries.length).toBe(0);
104
+ });
105
+
106
+ it('offers no button for a CLEAN diagram (the canvas handles it)', () => {
107
+ const editor = makeEditor(fenced(CLEAN));
108
+ expect(REPAIRABLE_KEY.getState(editor.state)?.entries.length).toBe(0);
109
+ expect(ASCII_DIAGRAM_KEY.getState(editor.state)?.entries.length).toBe(1);
110
+ });
111
+
112
+ it('offers no button for ordinary prose fences', () => {
113
+ const editor = makeEditor(fenced('just some\nplain prose\nlines here'));
114
+ expect(REPAIRABLE_KEY.getState(editor.state)?.entries.length).toBe(0);
115
+ });
116
+
117
+ it('repairs the fence into clean, diagram-tagged art the canvas then claims', () => {
118
+ const editor = makeEditor(fenced(BROKEN));
119
+ const id = repairId(editor)!;
120
+ expect(applyRepairCommand(editor, id)).toBe(true);
121
+
122
+ const fence = fenceOf(editor);
123
+ // Language promoted to the explicit `diagram` tag.
124
+ expect(fence.language).toBe('diagram');
125
+ // The art is now a clean, detectable diagram with the recovered labels.
126
+ const detection = detectAsciiDiagram(fence.text);
127
+ expect(detection.isDiagram).toBe(true);
128
+ expect(fence.text).toContain('@scope/alpha-svc');
129
+ expect(fence.text).toContain('@scope/gamma-store');
130
+ // The repairable entry is gone; the AsciiDiagramExtension now claims it.
131
+ expect(REPAIRABLE_KEY.getState(editor.state)?.entries.length).toBe(0);
132
+ expect(ASCII_DIAGRAM_KEY.getState(editor.state)?.entries.length).toBe(1);
133
+ });
134
+
135
+ it('one undo restores the original broken art', () => {
136
+ const editor = makeEditor(fenced(BROKEN));
137
+ const before = fenceOf(editor).text;
138
+ applyRepairCommand(editor, repairId(editor)!);
139
+ expect(fenceOf(editor).text).not.toBe(before);
140
+ editor.commands.undo();
141
+ expect(fenceOf(editor).text).toBe(before);
142
+ // …and it is a repair candidate again, NOT still claimed as a diagram
143
+ // (the relaxed hysteresis gate must reject the garbled art).
144
+ expect(REPAIRABLE_KEY.getState(editor.state)?.entries.length).toBe(1);
145
+ expect(ASCII_DIAGRAM_KEY.getState(editor.state)?.entries.length).toBe(0);
146
+ });
147
+
148
+ it('is inert when disabled', () => {
149
+ const editor = new Editor({
150
+ extensions: [
151
+ StarterKit.configure({ heading: false }),
152
+ RepairableDiagramExtension.configure({ enabled: false }),
153
+ ],
154
+ content: markdownToTiptap(fenced(BROKEN)),
155
+ });
156
+ editors.push(editor);
157
+ expect(REPAIRABLE_KEY.getState(editor.state)).toBeUndefined();
158
+ });
159
+ });
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Command pipeline through a REAL tiptap editor: fence text in →
3
+ * `applyAsciiDiagramCommand` → fence text out, verified by re-parsing.
4
+ */
5
+
6
+ import { Editor } from '@tiptap/core';
7
+ import StarterKit from '@tiptap/starter-kit';
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
9
+ import { ASCII_CHAR_H, ASCII_CHAR_W, parseAsciiDiagram } from '@bendyline/squisq/doc';
10
+ import { markdownToTiptap } from '../../tiptapBridge';
11
+ import { HeadingWithTemplate } from '../../TemplateAnnotation';
12
+ import { AsciiDiagramExtension, ASCII_DIAGRAM_KEY } from '../AsciiDiagramExtension';
13
+ import { applyAsciiDiagramCommand, replaceAsciiFenceText } from '../asciiDiagramCommands';
14
+
15
+ beforeAll(() => {
16
+ if (typeof globalThis.ResizeObserver === 'undefined') {
17
+ class ResizeObserverStub {
18
+ observe(): void {}
19
+ unobserve(): void {}
20
+ disconnect(): void {}
21
+ }
22
+ globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
23
+ }
24
+ });
25
+
26
+ const ART = [
27
+ '┌────────┐',
28
+ '│ Alpha │',
29
+ '└───┬────┘',
30
+ ' │',
31
+ ' ▼',
32
+ '┌────────┐',
33
+ '│ Beta │',
34
+ '└────────┘',
35
+ ].join('\n');
36
+
37
+ let editors: Editor[] = [];
38
+
39
+ function makeEditor(md: string): Editor {
40
+ const editor = new Editor({
41
+ extensions: [
42
+ StarterKit.configure({
43
+ heading: false,
44
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
45
+ }),
46
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
47
+ AsciiDiagramExtension,
48
+ ],
49
+ content: markdownToTiptap(md),
50
+ });
51
+ editors.push(editor);
52
+ return editor;
53
+ }
54
+
55
+ afterEach(() => {
56
+ for (const e of editors) e.destroy();
57
+ editors = [];
58
+ });
59
+
60
+ function firstBlockId(editor: Editor): string {
61
+ const state = ASCII_DIAGRAM_KEY.getState(editor.state);
62
+ expect(state?.entries.length).toBeGreaterThan(0);
63
+ return state?.entries[0].id as string;
64
+ }
65
+
66
+ function fenceOf(editor: Editor): { text: string; language: string | null } {
67
+ let text = '';
68
+ let language: string | null = null;
69
+ editor.state.doc.descendants((node) => {
70
+ if (node.type.name === 'codeBlock' && text === '') {
71
+ text = node.textContent;
72
+ language = (node.attrs as { language?: string | null }).language ?? null;
73
+ return false;
74
+ }
75
+ return true;
76
+ });
77
+ return { text, language };
78
+ }
79
+
80
+ describe('applyAsciiDiagramCommand', () => {
81
+ it('moveNode rewrites the fence with the node at the new cell', () => {
82
+ const editor = makeEditor('```\n' + ART + '\n```\n');
83
+ const id = firstBlockId(editor);
84
+ const ok = applyAsciiDiagramCommand(editor, id, {
85
+ kind: 'moveNode',
86
+ nodeId: 'beta',
87
+ x: 20 * ASCII_CHAR_W,
88
+ y: 10 * ASCII_CHAR_H,
89
+ });
90
+ expect(ok).toBe(true);
91
+ const reparsed = parseAsciiDiagram(fenceOf(editor).text);
92
+ expect(reparsed.nodes.map((n) => n.id).sort()).toEqual(['alpha', 'beta']);
93
+ expect(reparsed.edges).toEqual([{ source: 'alpha', target: 'beta', directed: true }]);
94
+ expect(reparsed.nodes.find((n) => n.id === 'beta')?.col).toBe(20);
95
+ });
96
+
97
+ it('commits a north/west resize position and size in one fence rewrite', () => {
98
+ const editor = makeEditor('```\n' + ART + '\n```\n');
99
+ const id = firstBlockId(editor);
100
+ const onTransaction = vi.fn();
101
+ editor.on('transaction', onTransaction);
102
+
103
+ const ok = applyAsciiDiagramCommand(editor, id, {
104
+ kind: 'resizeNode',
105
+ nodeId: 'beta',
106
+ x: 18 * ASCII_CHAR_W,
107
+ y: 10 * ASCII_CHAR_H,
108
+ width: 14 * ASCII_CHAR_W,
109
+ height: 5 * ASCII_CHAR_H,
110
+ });
111
+
112
+ expect(ok).toBe(true);
113
+ expect(onTransaction).toHaveBeenCalledTimes(1);
114
+ const beta = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
115
+ expect(beta).toMatchObject({ col: 18, row: 10, wCols: 14, hRows: 5 });
116
+
117
+ editor.commands.undo();
118
+ const restored = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
119
+ expect(restored).toMatchObject({ col: 0, row: 5, wCols: 10, hRows: 3 });
120
+ });
121
+
122
+ it('persists a virtual gutter with the first edit of legacy origin-hugging art', () => {
123
+ const editor = makeEditor('```diagram\n' + ART + '\n```\n');
124
+ const id = firstBlockId(editor);
125
+
126
+ expect(
127
+ applyAsciiDiagramCommand(
128
+ editor,
129
+ id,
130
+ {
131
+ kind: 'resizeNode',
132
+ nodeId: 'alpha',
133
+ x: 6 * ASCII_CHAR_W,
134
+ y: 1 * ASCII_CHAR_H,
135
+ width: 12 * ASCII_CHAR_W,
136
+ height: 4 * ASCII_CHAR_H,
137
+ },
138
+ { diagramOffset: { col: 8, row: 2 } },
139
+ ),
140
+ ).toBe(true);
141
+
142
+ const nodes = parseAsciiDiagram(fenceOf(editor).text).nodes;
143
+ expect(nodes.find((node) => node.id === 'alpha')).toMatchObject({
144
+ col: 6,
145
+ row: 1,
146
+ wCols: 12,
147
+ hRows: 4,
148
+ });
149
+ // The untouched peer receives the persisted origin shift, preserving the
150
+ // same coordinates that were already projected on the canvas.
151
+ expect(nodes.find((node) => node.id === 'beta')).toMatchObject({ col: 8, row: 7 });
152
+ });
153
+
154
+ it('addConnection adds a reciprocal edge (renders as a double arrow)', () => {
155
+ const editor = makeEditor('```\n' + ART + '\n```\n');
156
+ const id = firstBlockId(editor);
157
+ expect(
158
+ applyAsciiDiagramCommand(editor, id, {
159
+ kind: 'addConnection',
160
+ source: 'beta',
161
+ target: 'alpha',
162
+ }),
163
+ ).toBe(true);
164
+ const reparsed = parseAsciiDiagram(fenceOf(editor).text);
165
+ expect(reparsed.edges).toHaveLength(2);
166
+ });
167
+
168
+ it('renameNode rewrites labels (and therefore ids)', () => {
169
+ const editor = makeEditor('```\n' + ART + '\n```\n');
170
+ const id = firstBlockId(editor);
171
+ expect(
172
+ applyAsciiDiagramCommand(editor, id, {
173
+ kind: 'renameNode',
174
+ nodeId: 'alpha',
175
+ newLabel: 'Gateway',
176
+ }),
177
+ ).toBe(true);
178
+ const reparsed = parseAsciiDiagram(fenceOf(editor).text);
179
+ expect(reparsed.nodes.map((n) => n.id).sort()).toEqual(['beta', 'gateway']);
180
+ expect(reparsed.edges).toEqual([{ source: 'gateway', target: 'beta', directed: true }]);
181
+ });
182
+
183
+ it('addNode / removeNode change the box count', () => {
184
+ const editor = makeEditor('```\n' + ART + '\n```\n');
185
+ const id = firstBlockId(editor);
186
+ expect(
187
+ applyAsciiDiagramCommand(editor, id, { kind: 'addNode', x: 40 * ASCII_CHAR_W, y: 0 }),
188
+ ).toBe(true);
189
+ expect(parseAsciiDiagram(fenceOf(editor).text).nodes).toHaveLength(3);
190
+ expect(applyAsciiDiagramCommand(editor, id, { kind: 'removeNode', nodeId: 'node-1' })).toBe(
191
+ true,
192
+ );
193
+ expect(parseAsciiDiagram(fenceOf(editor).text).nodes).toHaveLength(2);
194
+ });
195
+
196
+ it('promotes the fence language to the explicit `diagram` tag on edit', () => {
197
+ // A once-edited diagram should carry `language: 'diagram'` so its identity
198
+ // survives a later flatten → markdown → re-import round-trip (the language
199
+ // class round-trips; fence meta does not).
200
+ const editor = makeEditor('```text\n' + ART + '\n```\n');
201
+ const id = firstBlockId(editor);
202
+ expect(fenceOf(editor).language).toBe('text');
203
+ applyAsciiDiagramCommand(editor, id, {
204
+ kind: 'moveNode',
205
+ nodeId: 'beta',
206
+ x: 15 * ASCII_CHAR_W,
207
+ y: 9 * ASCII_CHAR_H,
208
+ });
209
+ expect(fenceOf(editor).language).toBe('diagram');
210
+ });
211
+
212
+ it('keeps the block id stable across the language-promotion rewrite', () => {
213
+ // The promotion is a `setNodeMarkup` boundary rewrite; the position
214
+ // registry must still recognize the same block so a follow-up command
215
+ // resolves it (and the widget's React root survives).
216
+ const editor = makeEditor('```\n' + ART + '\n```\n');
217
+ const id = firstBlockId(editor);
218
+ applyAsciiDiagramCommand(editor, id, {
219
+ kind: 'moveNode',
220
+ nodeId: 'beta',
221
+ x: 15 * ASCII_CHAR_W,
222
+ y: 9 * ASCII_CHAR_H,
223
+ });
224
+ expect(firstBlockId(editor)).toBe(id);
225
+ expect(fenceOf(editor).language).toBe('diagram');
226
+ });
227
+
228
+ it('one undo restores the original fence bytes', () => {
229
+ const editor = makeEditor('```\n' + ART + '\n```\n');
230
+ const id = firstBlockId(editor);
231
+ const before = fenceOf(editor).text;
232
+ applyAsciiDiagramCommand(editor, id, {
233
+ kind: 'moveNode',
234
+ nodeId: 'beta',
235
+ x: 22 * ASCII_CHAR_W,
236
+ y: 12 * ASCII_CHAR_H,
237
+ });
238
+ expect(fenceOf(editor).text).not.toBe(before);
239
+ editor.commands.undo();
240
+ expect(fenceOf(editor).text).toBe(before);
241
+ });
242
+
243
+ it('no-op commands leave the document untouched', () => {
244
+ const editor = makeEditor('```\n' + ART + '\n```\n');
245
+ const id = firstBlockId(editor);
246
+ const beta = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
247
+ const json = JSON.stringify(editor.state.doc.toJSON());
248
+ const ok = applyAsciiDiagramCommand(editor, id, {
249
+ kind: 'moveNode',
250
+ nodeId: 'beta',
251
+ x: (beta?.col ?? 0) * ASCII_CHAR_W,
252
+ y: (beta?.row ?? 0) * ASCII_CHAR_H,
253
+ });
254
+ expect(ok).toBe(false);
255
+ expect(JSON.stringify(editor.state.doc.toJSON())).toBe(json);
256
+ });
257
+
258
+ it('returns false for an unknown block id', () => {
259
+ const editor = makeEditor('```\n' + ART + '\n```\n');
260
+ expect(
261
+ applyAsciiDiagramCommand(editor, 'ascii-999', {
262
+ kind: 'removeNode',
263
+ nodeId: 'alpha',
264
+ }),
265
+ ).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe('replaceAsciiFenceText', () => {
270
+ it('replaces only the text content, not attributes', () => {
271
+ const editor = makeEditor('```text\n' + ART + '\n```\n');
272
+ const state = ASCII_DIAGRAM_KEY.getState(editor.state);
273
+ const pos = state?.entries[0].pos as number;
274
+ expect(replaceAsciiFenceText(editor, pos, 'replacement')).toBe(true);
275
+ expect(fenceOf(editor)).toEqual({ text: 'replacement', language: 'text' });
276
+ });
277
+
278
+ it('no-ops on identical text', () => {
279
+ const editor = makeEditor('```\n' + ART + '\n```\n');
280
+ const state = ASCII_DIAGRAM_KEY.getState(editor.state);
281
+ const pos = state?.entries[0].pos as number;
282
+ expect(replaceAsciiFenceText(editor, pos, ART)).toBe(false);
283
+ });
284
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseAsciiDiagram, type AsciiDiagram } from '@bendyline/squisq/doc';
3
+ import {
4
+ addEdgeOp,
5
+ addNodeOp,
6
+ moveNodeOp,
7
+ removeEdgeOp,
8
+ removeNodeOp,
9
+ renameNodeOp,
10
+ resizeNodeOp,
11
+ sanitizeAsciiLabel,
12
+ } from '../asciiDiagramOps';
13
+
14
+ const TWO_BOX = [
15
+ '┌────────┐',
16
+ '│ Alpha │',
17
+ '└───┬────┘',
18
+ ' │',
19
+ ' ▼',
20
+ '┌────────┐',
21
+ '│ Beta │',
22
+ '└────────┘',
23
+ ].join('\n');
24
+
25
+ const NESTED = [
26
+ '┌─── Cluster ────┐',
27
+ '│ ┌──────────┐ │',
28
+ '│ │ Worker │ │',
29
+ '│ └──────────┘ │',
30
+ '└────────────────┘',
31
+ ].join('\n');
32
+
33
+ function base(): AsciiDiagram {
34
+ return parseAsciiDiagram(TWO_BOX);
35
+ }
36
+
37
+ describe('sanitizeAsciiLabel', () => {
38
+ it('strips structural glyphs, backticks, and newlines', () => {
39
+ expect(sanitizeAsciiLabel('a │ b ── c ▼ `d`\ne')).toBe('a b c d e');
40
+ });
41
+ });
42
+
43
+ describe('asciiDiagramOps', () => {
44
+ it('moveNodeOp translates a node without mutating the input', () => {
45
+ const d = base();
46
+ const next = moveNodeOp(d, 'beta', 20, 10);
47
+ expect(next.nodes.find((n) => n.id === 'beta')).toMatchObject({ col: 20, row: 10 });
48
+ expect(d.nodes.find((n) => n.id === 'beta')).toMatchObject({ col: 0, row: 5 });
49
+ });
50
+
51
+ it('moveNodeOp on a container translates the whole subtree', () => {
52
+ const d = parseAsciiDiagram(NESTED);
53
+ const worker = d.nodes.find((n) => n.id === 'worker');
54
+ const next = moveNodeOp(d, 'cluster', 10, 8);
55
+ const movedWorker = next.nodes.find((n) => n.id === 'worker');
56
+ expect(movedWorker?.col).toBe((worker?.col ?? 0) + 10);
57
+ expect(movedWorker?.row).toBe((worker?.row ?? 0) + 8);
58
+ });
59
+
60
+ it('resizeNodeOp clamps to the label minimum', () => {
61
+ const next = resizeNodeOp(base(), 'alpha', 1, 1);
62
+ const alpha = next.nodes.find((n) => n.id === 'alpha');
63
+ expect(alpha?.wCols).toBeGreaterThanOrEqual('Alpha'.length + 4);
64
+ expect(alpha?.hRows).toBeGreaterThanOrEqual(3);
65
+ });
66
+
67
+ it('addEdgeOp adds directed edges and dedupes', () => {
68
+ const d = base();
69
+ const withEdge = addEdgeOp(d, 'beta', 'alpha', 'back');
70
+ expect(withEdge.edges).toHaveLength(2);
71
+ expect(addEdgeOp(withEdge, 'beta', 'alpha', 'back').edges).toHaveLength(2);
72
+ // Self-edges and unknown endpoints are no-ops.
73
+ expect(addEdgeOp(d, 'alpha', 'alpha')).toBe(d);
74
+ expect(addEdgeOp(d, 'alpha', 'ghost')).toBe(d);
75
+ });
76
+
77
+ it('removeEdgeOp removes the first (source, target) match', () => {
78
+ const next = removeEdgeOp(base(), 'alpha', 'beta');
79
+ expect(next.edges).toHaveLength(0);
80
+ });
81
+
82
+ it('renameNodeOp sanitizes and updates the label', () => {
83
+ const next = renameNodeOp(base(), 'alpha', 'Gate │ way');
84
+ expect(next.nodes.find((n) => n.id === 'alpha')?.label).toBe('Gate way');
85
+ });
86
+
87
+ it('addNodeOp picks a fresh label and inherits containment', () => {
88
+ const nested = parseAsciiDiagram(NESTED);
89
+ const cluster = nested.nodes.find((n) => n.id === 'cluster');
90
+ const inside = addNodeOp(nested, {
91
+ col: (cluster?.col ?? 0) + 2,
92
+ row: (cluster?.row ?? 0) + 1,
93
+ });
94
+ const added = inside.diagram.nodes[inside.diagram.nodes.length - 1];
95
+ expect(added.label).toBe('Node 1');
96
+ expect(added.containerId).toBe('cluster');
97
+
98
+ const outside = addNodeOp(nested, { col: 100, row: 100 });
99
+ const addedOutside = outside.diagram.nodes[outside.diagram.nodes.length - 1];
100
+ expect(addedOutside.containerId).toBeUndefined();
101
+ });
102
+
103
+ it('removeNodeOp drops incident edges and promotes children', () => {
104
+ const d = base();
105
+ const next = removeNodeOp(d, 'beta');
106
+ expect(next.nodes.map((n) => n.id)).toEqual(['alpha']);
107
+ expect(next.edges).toHaveLength(0);
108
+
109
+ const nested = parseAsciiDiagram(NESTED);
110
+ const promoted = removeNodeOp(nested, 'cluster');
111
+ expect(promoted.nodes.map((n) => n.id)).toEqual(['worker']);
112
+ expect(promoted.nodes[0].containerId).toBeUndefined();
113
+ });
114
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldPasteAsAsciiFence } from '../asciiPaste';
3
+
4
+ const ASCII_ART = [
5
+ '+--------+ +--------+',
6
+ '| Input | --> | Output |',
7
+ '+--------+ +--------+',
8
+ ].join('\n');
9
+
10
+ const UNICODE_ART = [
11
+ '┌────────┐',
12
+ '│ Alpha │',
13
+ '└───┬────┘',
14
+ ' │',
15
+ ' ▼',
16
+ '┌────────┐',
17
+ '│ Beta │',
18
+ '└────────┘',
19
+ ].join('\n');
20
+
21
+ describe('shouldPasteAsAsciiFence', () => {
22
+ it('accepts bare ASCII art (which would otherwise misdetect as a GFM table)', () => {
23
+ expect(shouldPasteAsAsciiFence(ASCII_ART)).toBe(true);
24
+ });
25
+
26
+ it('accepts bare Unicode box art', () => {
27
+ expect(shouldPasteAsAsciiFence(UNICODE_ART)).toBe(true);
28
+ });
29
+
30
+ it('rejects a real GFM table', () => {
31
+ const table = ['| Name | Role |', '|------|------|', '| Ada | Eng |'].join('\n');
32
+ expect(shouldPasteAsAsciiFence(table)).toBe(false);
33
+ });
34
+
35
+ it('rejects text that already contains a fence', () => {
36
+ expect(shouldPasteAsAsciiFence('```\n' + ASCII_ART + '\n```')).toBe(false);
37
+ });
38
+
39
+ it('rejects prose and empty input', () => {
40
+ expect(shouldPasteAsAsciiFence('Just a normal paragraph of text.')).toBe(false);
41
+ expect(shouldPasteAsAsciiFence('')).toBe(false);
42
+ });
43
+ });