@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. 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,227 @@
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 } 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('addConnection adds a reciprocal edge (renders as a double arrow)', () => {
98
+ const editor = makeEditor('```\n' + ART + '\n```\n');
99
+ const id = firstBlockId(editor);
100
+ expect(
101
+ applyAsciiDiagramCommand(editor, id, {
102
+ kind: 'addConnection',
103
+ source: 'beta',
104
+ target: 'alpha',
105
+ }),
106
+ ).toBe(true);
107
+ const reparsed = parseAsciiDiagram(fenceOf(editor).text);
108
+ expect(reparsed.edges).toHaveLength(2);
109
+ });
110
+
111
+ it('renameNode rewrites labels (and therefore ids)', () => {
112
+ const editor = makeEditor('```\n' + ART + '\n```\n');
113
+ const id = firstBlockId(editor);
114
+ expect(
115
+ applyAsciiDiagramCommand(editor, id, {
116
+ kind: 'renameNode',
117
+ nodeId: 'alpha',
118
+ newLabel: 'Gateway',
119
+ }),
120
+ ).toBe(true);
121
+ const reparsed = parseAsciiDiagram(fenceOf(editor).text);
122
+ expect(reparsed.nodes.map((n) => n.id).sort()).toEqual(['beta', 'gateway']);
123
+ expect(reparsed.edges).toEqual([{ source: 'gateway', target: 'beta', directed: true }]);
124
+ });
125
+
126
+ it('addNode / removeNode change the box count', () => {
127
+ const editor = makeEditor('```\n' + ART + '\n```\n');
128
+ const id = firstBlockId(editor);
129
+ expect(
130
+ applyAsciiDiagramCommand(editor, id, { kind: 'addNode', x: 40 * ASCII_CHAR_W, y: 0 }),
131
+ ).toBe(true);
132
+ expect(parseAsciiDiagram(fenceOf(editor).text).nodes).toHaveLength(3);
133
+ expect(applyAsciiDiagramCommand(editor, id, { kind: 'removeNode', nodeId: 'node-1' })).toBe(
134
+ true,
135
+ );
136
+ expect(parseAsciiDiagram(fenceOf(editor).text).nodes).toHaveLength(2);
137
+ });
138
+
139
+ it('promotes the fence language to the explicit `diagram` tag on edit', () => {
140
+ // A once-edited diagram should carry `language: 'diagram'` so its identity
141
+ // survives a later flatten → markdown → re-import round-trip (the language
142
+ // class round-trips; fence meta does not).
143
+ const editor = makeEditor('```text\n' + ART + '\n```\n');
144
+ const id = firstBlockId(editor);
145
+ expect(fenceOf(editor).language).toBe('text');
146
+ applyAsciiDiagramCommand(editor, id, {
147
+ kind: 'moveNode',
148
+ nodeId: 'beta',
149
+ x: 15 * ASCII_CHAR_W,
150
+ y: 9 * ASCII_CHAR_H,
151
+ });
152
+ expect(fenceOf(editor).language).toBe('diagram');
153
+ });
154
+
155
+ it('keeps the block id stable across the language-promotion rewrite', () => {
156
+ // The promotion is a `setNodeMarkup` boundary rewrite; the position
157
+ // registry must still recognize the same block so a follow-up command
158
+ // resolves it (and the widget's React root survives).
159
+ const editor = makeEditor('```\n' + ART + '\n```\n');
160
+ const id = firstBlockId(editor);
161
+ applyAsciiDiagramCommand(editor, id, {
162
+ kind: 'moveNode',
163
+ nodeId: 'beta',
164
+ x: 15 * ASCII_CHAR_W,
165
+ y: 9 * ASCII_CHAR_H,
166
+ });
167
+ expect(firstBlockId(editor)).toBe(id);
168
+ expect(fenceOf(editor).language).toBe('diagram');
169
+ });
170
+
171
+ it('one undo restores the original fence bytes', () => {
172
+ const editor = makeEditor('```\n' + ART + '\n```\n');
173
+ const id = firstBlockId(editor);
174
+ const before = fenceOf(editor).text;
175
+ applyAsciiDiagramCommand(editor, id, {
176
+ kind: 'moveNode',
177
+ nodeId: 'beta',
178
+ x: 22 * ASCII_CHAR_W,
179
+ y: 12 * ASCII_CHAR_H,
180
+ });
181
+ expect(fenceOf(editor).text).not.toBe(before);
182
+ editor.commands.undo();
183
+ expect(fenceOf(editor).text).toBe(before);
184
+ });
185
+
186
+ it('no-op commands leave the document untouched', () => {
187
+ const editor = makeEditor('```\n' + ART + '\n```\n');
188
+ const id = firstBlockId(editor);
189
+ const beta = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
190
+ const json = JSON.stringify(editor.state.doc.toJSON());
191
+ const ok = applyAsciiDiagramCommand(editor, id, {
192
+ kind: 'moveNode',
193
+ nodeId: 'beta',
194
+ x: (beta?.col ?? 0) * ASCII_CHAR_W,
195
+ y: (beta?.row ?? 0) * ASCII_CHAR_H,
196
+ });
197
+ expect(ok).toBe(false);
198
+ expect(JSON.stringify(editor.state.doc.toJSON())).toBe(json);
199
+ });
200
+
201
+ it('returns false for an unknown block id', () => {
202
+ const editor = makeEditor('```\n' + ART + '\n```\n');
203
+ expect(
204
+ applyAsciiDiagramCommand(editor, 'ascii-999', {
205
+ kind: 'removeNode',
206
+ nodeId: 'alpha',
207
+ }),
208
+ ).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('replaceAsciiFenceText', () => {
213
+ it('replaces only the text content, not attributes', () => {
214
+ const editor = makeEditor('```text\n' + ART + '\n```\n');
215
+ const state = ASCII_DIAGRAM_KEY.getState(editor.state);
216
+ const pos = state?.entries[0].pos as number;
217
+ expect(replaceAsciiFenceText(editor, pos, 'replacement')).toBe(true);
218
+ expect(fenceOf(editor)).toEqual({ text: 'replacement', language: 'text' });
219
+ });
220
+
221
+ it('no-ops on identical text', () => {
222
+ const editor = makeEditor('```\n' + ART + '\n```\n');
223
+ const state = ASCII_DIAGRAM_KEY.getState(editor.state);
224
+ const pos = state?.entries[0].pos as number;
225
+ expect(replaceAsciiFenceText(editor, pos, ART)).toBe(false);
226
+ });
227
+ });
@@ -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
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Write direction of the ASCII diagram loop: canvas command → fence rewrite.
3
+ *
4
+ * Every semantic edit is one functional transformation of the fence text
5
+ * (`renderAscii(applyOp(parseAscii(text)))`) committed as a single
6
+ * ProseMirror transaction (= a single undo step). Before committing, the
7
+ * rendered art is re-parsed and verified — if the renderer ever produced
8
+ * something the parser disagrees with, the command aborts instead of
9
+ * corrupting the fence.
10
+ */
11
+
12
+ import type { Editor } from '@tiptap/react';
13
+ import {
14
+ canvasToAsciiCell,
15
+ ASCII_CHAR_H,
16
+ ASCII_CHAR_W,
17
+ parseAsciiDiagram,
18
+ renderAsciiDiagram,
19
+ repairAsciiDiagram,
20
+ type AsciiDiagram,
21
+ } from '@bendyline/squisq/doc';
22
+ import type { DiagramCommand } from '../diagram/DiagramCanvas';
23
+ import { findAsciiDiagramBlockPos, parseAsciiDiagramForNode } from './AsciiDiagramExtension';
24
+ import { findRepairableBlockPos } from './RepairableDiagramExtension';
25
+ import {
26
+ addEdgeOp,
27
+ addNodeOp,
28
+ moveNodeOp,
29
+ removeEdgeOp,
30
+ removeNodeOp,
31
+ renameNodeOp,
32
+ resizeNodeOp,
33
+ } from './asciiDiagramOps';
34
+
35
+ /**
36
+ * Replace the TEXT inside the codeBlock at `pos` and, when `ensureLanguage`
37
+ * is given, promote its `language` attribute in the SAME transaction (one
38
+ * undo step). Promoting the language to the explicit `diagram`/`tree` tag is
39
+ * how a semantic edit makes the block's identity "sticky" — the language
40
+ * class survives markdown ↔ Tiptap round-trips, so a once-edited diagram/tree
41
+ * is re-recognized even after it's flattened. No-op (returns false) only when
42
+ * BOTH the text is already identical AND the language already matches, so
43
+ * history stays clean on a true no-op.
44
+ */
45
+ export function replaceAsciiFenceText(
46
+ editor: Editor,
47
+ pos: number,
48
+ nextText: string,
49
+ ensureLanguage?: string,
50
+ ): boolean {
51
+ return editor
52
+ .chain()
53
+ .command(({ tr, state }) => {
54
+ const node = tr.doc.nodeAt(pos);
55
+ if (!node || node.type.name !== 'codeBlock') return false;
56
+ const textChanged = node.textContent !== nextText;
57
+ const currentLang = (node.attrs as { language?: string | null }).language ?? null;
58
+ const langChanged = ensureLanguage !== undefined && currentLang !== ensureLanguage;
59
+ if (!textChanged && !langChanged) return false;
60
+ if (langChanged) {
61
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, language: ensureLanguage });
62
+ }
63
+ if (textChanged) {
64
+ tr.replaceWith(pos + 1, pos + node.nodeSize - 1, state.schema.text(nextText));
65
+ }
66
+ return true;
67
+ })
68
+ .run();
69
+ }
70
+
71
+ /**
72
+ * Reconstruct a broken box-art fence into clean, `diagram`-tagged art in a
73
+ * single transaction (one undo step). The AsciiDiagramExtension then claims
74
+ * the tagged fence and mounts the interactive canvas. Returns false when the
75
+ * block is gone or nothing recoverable (the button simply no-ops).
76
+ */
77
+ export function applyRepairCommand(editor: Editor, blockId: string): boolean {
78
+ const pos = findRepairableBlockPos(editor, blockId);
79
+ if (pos === null) return false;
80
+ const node = editor.state.doc.nodeAt(pos);
81
+ if (!node || node.type.name !== 'codeBlock') return false;
82
+ const repaired = repairAsciiDiagram(node.textContent);
83
+ if (!repaired) return false;
84
+ return replaceAsciiFenceText(editor, pos, repaired.art, 'diagram');
85
+ }
86
+
87
+ /** Apply one pure op to a registered block's parsed diagram. */
88
+ function applyOp(
89
+ editor: Editor,
90
+ blockId: string,
91
+ op: (diagram: AsciiDiagram) => AsciiDiagram,
92
+ ): boolean {
93
+ // Resolve the position at dispatch time — captured positions go stale
94
+ // the moment anything above the block changes.
95
+ const pos = findAsciiDiagramBlockPos(editor, blockId);
96
+ if (pos === null) return false;
97
+ const node = editor.state.doc.nodeAt(pos);
98
+ if (!node || node.type.name !== 'codeBlock') return false;
99
+ const diagram = parseAsciiDiagramForNode(node);
100
+ if (!diagram) return false;
101
+
102
+ const next = op(diagram);
103
+ if (next === diagram) return false;
104
+ const rendered = renderAsciiDiagram(next);
105
+
106
+ // Verify before committing: the rendered art must re-parse to the same
107
+ // node count, or we'd write art the canvas can't read back.
108
+ const verification = parseAsciiDiagram(rendered);
109
+ if (verification.nodes.length !== next.nodes.length) return false;
110
+
111
+ // Promote the fence language to the explicit `diagram` tag so this block's
112
+ // identity survives a later flatten → markdown → re-import round-trip.
113
+ return replaceAsciiFenceText(editor, pos, rendered, 'diagram');
114
+ }
115
+
116
+ /** Full pipeline for a canvas command against a registered fence block. */
117
+ export function applyAsciiDiagramCommand(
118
+ editor: Editor,
119
+ blockId: string,
120
+ cmd: DiagramCommand,
121
+ ): boolean {
122
+ switch (cmd.kind) {
123
+ case 'moveNode': {
124
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
125
+ return applyOp(editor, blockId, (d) => moveNodeOp(d, cmd.nodeId, col, row));
126
+ }
127
+ case 'resizeNode': {
128
+ const wCols = Math.max(3, Math.round(cmd.width / ASCII_CHAR_W));
129
+ const hRows = Math.max(3, Math.round(cmd.height / ASCII_CHAR_H));
130
+ return applyOp(editor, blockId, (d) => resizeNodeOp(d, cmd.nodeId, wCols, hRows));
131
+ }
132
+ case 'addConnection':
133
+ return applyOp(editor, blockId, (d) => addEdgeOp(d, cmd.source, cmd.target, cmd.type));
134
+ case 'removeConnection':
135
+ return applyOp(editor, blockId, (d) => removeEdgeOp(d, cmd.source, cmd.target, cmd.type));
136
+ case 'renameNode':
137
+ return applyOp(editor, blockId, (d) => renameNodeOp(d, cmd.nodeId, cmd.newLabel));
138
+ case 'addNode': {
139
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
140
+ return applyOp(editor, blockId, (d) => addNodeOp(d, { col, row }).diagram);
141
+ }
142
+ case 'removeNode':
143
+ return applyOp(editor, blockId, (d) => removeNodeOp(d, cmd.nodeId));
144
+ }
145
+ const _exhaustive: never = cmd;
146
+ void _exhaustive;
147
+ return false;
148
+ }