@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,174 @@
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
+ translateDiagramOp,
34
+ } from './asciiDiagramOps';
35
+
36
+ export interface ApplyAsciiDiagramCommandOptions {
37
+ /**
38
+ * Virtual grid offset currently shown by the canvas for legacy art. It is
39
+ * folded into the source in the same transaction as the user's first edit.
40
+ */
41
+ diagramOffset?: { col: number; row: number };
42
+ }
43
+
44
+ /**
45
+ * Replace the TEXT inside the codeBlock at `pos` and, when `ensureLanguage`
46
+ * is given, promote its `language` attribute in the SAME transaction (one
47
+ * undo step). Promoting the language to the explicit `diagram`/`tree` tag is
48
+ * how a semantic edit makes the block's identity "sticky" — the language
49
+ * class survives markdown ↔ Tiptap round-trips, so a once-edited diagram/tree
50
+ * is re-recognized even after it's flattened. No-op (returns false) only when
51
+ * BOTH the text is already identical AND the language already matches, so
52
+ * history stays clean on a true no-op.
53
+ */
54
+ export function replaceAsciiFenceText(
55
+ editor: Editor,
56
+ pos: number,
57
+ nextText: string,
58
+ ensureLanguage?: string,
59
+ ): boolean {
60
+ return editor
61
+ .chain()
62
+ .command(({ tr, state }) => {
63
+ const node = tr.doc.nodeAt(pos);
64
+ if (!node || node.type.name !== 'codeBlock') return false;
65
+ const textChanged = node.textContent !== nextText;
66
+ const currentLang = (node.attrs as { language?: string | null }).language ?? null;
67
+ const langChanged = ensureLanguage !== undefined && currentLang !== ensureLanguage;
68
+ if (!textChanged && !langChanged) return false;
69
+ if (langChanged) {
70
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, language: ensureLanguage });
71
+ }
72
+ if (textChanged) {
73
+ tr.replaceWith(pos + 1, pos + node.nodeSize - 1, state.schema.text(nextText));
74
+ }
75
+ return true;
76
+ })
77
+ .run();
78
+ }
79
+
80
+ /**
81
+ * Reconstruct a broken box-art fence into clean, `diagram`-tagged art in a
82
+ * single transaction (one undo step). The AsciiDiagramExtension then claims
83
+ * the tagged fence and mounts the interactive canvas. Returns false when the
84
+ * block is gone or nothing recoverable (the button simply no-ops).
85
+ */
86
+ export function applyRepairCommand(editor: Editor, blockId: string): boolean {
87
+ const pos = findRepairableBlockPos(editor, blockId);
88
+ if (pos === null) return false;
89
+ const node = editor.state.doc.nodeAt(pos);
90
+ if (!node || node.type.name !== 'codeBlock') return false;
91
+ const repaired = repairAsciiDiagram(node.textContent);
92
+ if (!repaired) return false;
93
+ return replaceAsciiFenceText(editor, pos, repaired.art, 'diagram');
94
+ }
95
+
96
+ /** Apply one pure op to a registered block's parsed diagram. */
97
+ function applyOp(
98
+ editor: Editor,
99
+ blockId: string,
100
+ op: (diagram: AsciiDiagram) => AsciiDiagram,
101
+ diagramOffset?: { col: number; row: number },
102
+ ): boolean {
103
+ // Resolve the position at dispatch time — captured positions go stale
104
+ // the moment anything above the block changes.
105
+ const pos = findAsciiDiagramBlockPos(editor, blockId);
106
+ if (pos === null) return false;
107
+ const node = editor.state.doc.nodeAt(pos);
108
+ if (!node || node.type.name !== 'codeBlock') return false;
109
+ const sourceDiagram = parseAsciiDiagramForNode(node);
110
+ if (!sourceDiagram) return false;
111
+ const diagram = diagramOffset
112
+ ? translateDiagramOp(sourceDiagram, diagramOffset.col, diagramOffset.row)
113
+ : sourceDiagram;
114
+
115
+ const next = op(diagram);
116
+ if (next === diagram && diagram === sourceDiagram) return false;
117
+ const rendered = renderAsciiDiagram(next);
118
+
119
+ // Verify before committing: the rendered art must re-parse to the same
120
+ // node count, or we'd write art the canvas can't read back.
121
+ const verification = parseAsciiDiagram(rendered);
122
+ if (verification.nodes.length !== next.nodes.length) return false;
123
+
124
+ // Promote the fence language to the explicit `diagram` tag so this block's
125
+ // identity survives a later flatten → markdown → re-import round-trip.
126
+ return replaceAsciiFenceText(editor, pos, rendered, 'diagram');
127
+ }
128
+
129
+ /** Full pipeline for a canvas command against a registered fence block. */
130
+ export function applyAsciiDiagramCommand(
131
+ editor: Editor,
132
+ blockId: string,
133
+ cmd: DiagramCommand,
134
+ options: ApplyAsciiDiagramCommandOptions = {},
135
+ ): boolean {
136
+ const apply = (op: (diagram: AsciiDiagram) => AsciiDiagram) =>
137
+ applyOp(editor, blockId, op, options.diagramOffset);
138
+ switch (cmd.kind) {
139
+ case 'moveNode': {
140
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
141
+ return apply((d) => moveNodeOp(d, cmd.nodeId, col, row));
142
+ }
143
+ case 'resizeNode': {
144
+ const wCols = Math.max(3, Math.round(cmd.width / ASCII_CHAR_W));
145
+ const hRows = Math.max(3, Math.round(cmd.height / ASCII_CHAR_H));
146
+ return apply((d) => {
147
+ // A north/west resize changes position and size. Apply both to the
148
+ // same parsed model and render once so collision/layout normalization
149
+ // cannot run between the two halves of one pointer gesture.
150
+ let next = d;
151
+ if (cmd.x !== undefined && cmd.y !== undefined) {
152
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
153
+ next = moveNodeOp(next, cmd.nodeId, col, row);
154
+ }
155
+ return resizeNodeOp(next, cmd.nodeId, wCols, hRows);
156
+ });
157
+ }
158
+ case 'addConnection':
159
+ return apply((d) => addEdgeOp(d, cmd.source, cmd.target, cmd.type));
160
+ case 'removeConnection':
161
+ return apply((d) => removeEdgeOp(d, cmd.source, cmd.target, cmd.type));
162
+ case 'renameNode':
163
+ return apply((d) => renameNodeOp(d, cmd.nodeId, cmd.newLabel));
164
+ case 'addNode': {
165
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
166
+ return apply((d) => addNodeOp(d, { col, row }).diagram);
167
+ }
168
+ case 'removeNode':
169
+ return apply((d) => removeNodeOp(d, cmd.nodeId));
170
+ }
171
+ const _exhaustive: never = cmd;
172
+ void _exhaustive;
173
+ return false;
174
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Read direction of the ASCII diagram loop: fence text → canvas data.
3
+ *
4
+ * `useAsciiDiagramData` re-derives on every editor transaction (mirroring
5
+ * the philosophy of the old heading-based `useDiagramData`: no store, so
6
+ * nothing to invalidate). Parsing is memoized per PMNode instance by the
7
+ * extension's WeakMap caches, so untouched fences cost nothing.
8
+ */
9
+
10
+ import { useEffect, useMemo, useState } from 'react';
11
+ import type { Editor } from '@tiptap/react';
12
+ import {
13
+ asciiCellToCanvas,
14
+ asciiDiagramToTemplateData,
15
+ ASCII_CHAR_H,
16
+ ASCII_CHAR_W,
17
+ type AsciiDiagram,
18
+ } from '@bendyline/squisq/doc';
19
+ import type { DiagramEdge, DiagramNode } from '../diagram/types';
20
+ import { findAsciiDiagramBlockPos, parseAsciiDiagramForNode } from './AsciiDiagramExtension';
21
+ import { translateDiagramOp } from './asciiDiagramOps';
22
+
23
+ /** Persisted grid space reserved north/west of legacy origin-hugging art. */
24
+ export const ASCII_DIAGRAM_GUTTER_COLS = 8;
25
+ export const ASCII_DIAGRAM_GUTTER_ROWS = 2;
26
+
27
+ export interface AsciiDiagramCanvasOffset {
28
+ col: number;
29
+ row: number;
30
+ }
31
+
32
+ export interface AsciiDiagramView {
33
+ /** Canvas nodes, containers ordered first so their cards paint behind. */
34
+ nodes: DiagramNode[];
35
+ edges: DiagramEdge[];
36
+ warnings: string[];
37
+ style: 'unicode' | 'ascii';
38
+ /** The current fence text. */
39
+ text: string;
40
+ /** The parse behind `nodes`/`edges` (grid units) — ops operate on this. */
41
+ diagram: AsciiDiagram;
42
+ }
43
+
44
+ /**
45
+ * Offset needed to give an existing diagram the same editing gutter as a
46
+ * newly inserted one. The widget applies this virtually until the first real
47
+ * canvas edit, which then writes the shifted coordinates into the fence.
48
+ */
49
+ export function initialAsciiDiagramCanvasOffset(diagram: AsciiDiagram): AsciiDiagramCanvasOffset {
50
+ if (diagram.nodes.length === 0) return { col: 0, row: 0 };
51
+ const minCol = Math.min(...diagram.nodes.map((node) => node.col));
52
+ const minRow = Math.min(...diagram.nodes.map((node) => node.row));
53
+ return {
54
+ col: Math.max(0, ASCII_DIAGRAM_GUTTER_COLS - minCol),
55
+ row: Math.max(0, ASCII_DIAGRAM_GUTTER_ROWS - minRow),
56
+ };
57
+ }
58
+
59
+ /** Apply a canvas-only origin offset to a parsed grid model. */
60
+ export function offsetAsciiDiagram(
61
+ diagram: AsciiDiagram,
62
+ offset: AsciiDiagramCanvasOffset,
63
+ ): AsciiDiagram {
64
+ return translateDiagramOp(diagram, offset.col, offset.row);
65
+ }
66
+
67
+ /** Grid model → canvas model, containers-first for paint order. */
68
+ export function asciiDiagramToCanvas(
69
+ diagram: AsciiDiagram,
70
+ ): Pick<AsciiDiagramView, 'nodes' | 'edges'> {
71
+ const containerIds = new Set(
72
+ diagram.nodes.map((n) => n.containerId).filter((id): id is string => id !== undefined),
73
+ );
74
+ const depthOf = (id: string | undefined): number => {
75
+ let depth = 0;
76
+ let current = diagram.nodes.find((n) => n.id === id);
77
+ while (current?.containerId) {
78
+ depth++;
79
+ current = diagram.nodes.find((n) => n.id === current?.containerId);
80
+ if (depth > diagram.nodes.length) break;
81
+ }
82
+ return depth;
83
+ };
84
+ const ordered = [...diagram.nodes].sort((a, b) => {
85
+ const aContainer = containerIds.has(a.id) ? 0 : 1;
86
+ const bContainer = containerIds.has(b.id) ? 0 : 1;
87
+ return aContainer - bContainer || depthOf(a.id) - depthOf(b.id);
88
+ });
89
+ const nodes: DiagramNode[] = ordered.map((n) => ({
90
+ id: n.id,
91
+ position: asciiCellToCanvas(n.col, n.row),
92
+ data: { label: n.label },
93
+ width: n.wCols * ASCII_CHAR_W,
94
+ height: n.hRows * ASCII_CHAR_H,
95
+ ...(containerIds.has(n.id) ? { kind: 'container' as const } : {}),
96
+ }));
97
+ const templateEdges = asciiDiagramToTemplateData(diagram).edges;
98
+ const edges: DiagramEdge[] = diagram.edges.map((e, index) => ({
99
+ id: e.label ? `${e.source}->${e.target}:${e.label}` : `${e.source}->${e.target}`,
100
+ source: e.source,
101
+ target: e.target,
102
+ ...(e.label ? { label: e.label } : {}),
103
+ ...(e.directed ? {} : { directed: false }),
104
+ ...(templateEdges[index]?.sourceAnchor
105
+ ? { sourceAnchor: templateEdges[index].sourceAnchor }
106
+ : {}),
107
+ ...(templateEdges[index]?.targetAnchor
108
+ ? { targetAnchor: templateEdges[index].targetAnchor }
109
+ : {}),
110
+ ...(templateEdges[index]?.routing ? { routing: templateEdges[index].routing } : {}),
111
+ }));
112
+ return { nodes, edges };
113
+ }
114
+
115
+ /**
116
+ * Live view of one registered ASCII diagram block. Returns null when the
117
+ * block no longer exists or no longer parses (the extension will drop the
118
+ * widget on its next pass).
119
+ */
120
+ export function useAsciiDiagramData(editor: Editor, blockId: string): AsciiDiagramView | null {
121
+ const [version, setVersion] = useState(0);
122
+ useEffect(() => {
123
+ const onUpdate = () => setVersion((v) => v + 1);
124
+ editor.on('transaction', onUpdate);
125
+ return () => {
126
+ editor.off('transaction', onUpdate);
127
+ };
128
+ }, [editor]);
129
+
130
+ return useMemo(() => {
131
+ const pos = findAsciiDiagramBlockPos(editor, blockId);
132
+ if (pos === null) return null;
133
+ const node = editor.state.doc.nodeAt(pos);
134
+ if (!node || node.type.name !== 'codeBlock') return null;
135
+ const diagram = parseAsciiDiagramForNode(node);
136
+ if (!diagram) return null;
137
+ const { nodes, edges } = asciiDiagramToCanvas(diagram);
138
+ return {
139
+ nodes,
140
+ edges,
141
+ warnings: diagram.warnings,
142
+ style: diagram.style,
143
+ text: node.textContent,
144
+ diagram,
145
+ };
146
+ // `version` tracks transactions; editor/blockId are stable.
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [editor, blockId, version]);
149
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Pure data operations over the ASCII diagram model — the write-direction
3
+ * half of the fence-is-source-of-truth loop:
4
+ *
5
+ * fence text → parseAsciiDiagram → op → renderAsciiDiagram → fence text
6
+ *
7
+ * Every op returns a new AsciiDiagram (inputs are never mutated) and
8
+ * maintains the invariants the renderer/parser fixpoint relies on:
9
+ * container moves translate whole subtrees, removals promote children and
10
+ * drop incident edges, sizes never fall below what a label needs.
11
+ */
12
+
13
+ import type { AsciiDiagram, AsciiDiagramEdge, AsciiDiagramNode } from '@bendyline/squisq/doc';
14
+
15
+ /**
16
+ * Make arbitrary (rename-dialog) input safe as a box label: box-drawing /
17
+ * arrow glyphs would corrupt the art, and the Scene's inline editor is
18
+ * single-line, so newlines collapse to spaces.
19
+ */
20
+ export function sanitizeAsciiLabel(label: string): string {
21
+ return label
22
+ .replace(/[─-╿▲▼◀-◄▶-►←-↓`]/gu, ' ') // backticks would terminate the fence
23
+ .replace(/\s+/g, ' ')
24
+ .trim();
25
+ }
26
+
27
+ function descendantsOf(diagram: AsciiDiagram, nodeId: string): Set<string> {
28
+ const out = new Set<string>();
29
+ let grew = true;
30
+ while (grew) {
31
+ grew = false;
32
+ for (const n of diagram.nodes) {
33
+ if (out.has(n.id)) continue;
34
+ if (n.containerId === nodeId || (n.containerId && out.has(n.containerId))) {
35
+ out.add(n.id);
36
+ grew = true;
37
+ }
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** Translate every node by the same grid delta (edges/containment are unchanged). */
44
+ export function translateDiagramOp(
45
+ diagram: AsciiDiagram,
46
+ dCol: number,
47
+ dRow: number,
48
+ ): AsciiDiagram {
49
+ const colDelta = Math.round(dCol);
50
+ const rowDelta = Math.round(dRow);
51
+ if (colDelta === 0 && rowDelta === 0) return diagram;
52
+ return {
53
+ ...diagram,
54
+ nodes: diagram.nodes.map((node) => ({
55
+ ...node,
56
+ col: Math.max(0, node.col + colDelta),
57
+ row: Math.max(0, node.row + rowDelta),
58
+ })),
59
+ };
60
+ }
61
+
62
+ /** Move a node to (col, row); a container drags its whole subtree along. */
63
+ export function moveNodeOp(
64
+ diagram: AsciiDiagram,
65
+ nodeId: string,
66
+ col: number,
67
+ row: number,
68
+ ): AsciiDiagram {
69
+ const node = diagram.nodes.find((n) => n.id === nodeId);
70
+ if (!node) return diagram;
71
+ const dCol = Math.max(0, col) - node.col;
72
+ const dRow = Math.max(0, row) - node.row;
73
+ if (dCol === 0 && dRow === 0) return diagram;
74
+ const moving = descendantsOf(diagram, nodeId);
75
+ moving.add(nodeId);
76
+ return {
77
+ ...diagram,
78
+ nodes: diagram.nodes.map((n) =>
79
+ moving.has(n.id)
80
+ ? { ...n, col: Math.max(0, n.col + dCol), row: Math.max(0, n.row + dRow) }
81
+ : n,
82
+ ),
83
+ };
84
+ }
85
+
86
+ /** Resize a node (grid cells). Clamped so the label always fits. */
87
+ export function resizeNodeOp(
88
+ diagram: AsciiDiagram,
89
+ nodeId: string,
90
+ wCols: number,
91
+ hRows: number,
92
+ ): AsciiDiagram {
93
+ const node = diagram.nodes.find((n) => n.id === nodeId);
94
+ if (!node) return diagram;
95
+ const labelLines = node.label.length > 0 ? node.label.split('\n') : [];
96
+ const minW = Math.max(3, labelLines.reduce((w, l) => Math.max(w, l.length), 0) + 4);
97
+ const minH = Math.max(3, labelLines.length + 2);
98
+ return {
99
+ ...diagram,
100
+ nodes: diagram.nodes.map((n) =>
101
+ n.id === nodeId
102
+ ? {
103
+ ...n,
104
+ wCols: Math.max(minW, Math.round(wCols)),
105
+ hRows: Math.max(minH, Math.round(hRows)),
106
+ }
107
+ : n,
108
+ ),
109
+ };
110
+ }
111
+
112
+ /** Add a directed edge; duplicates (same source/target/label) are no-ops. */
113
+ export function addEdgeOp(
114
+ diagram: AsciiDiagram,
115
+ source: string,
116
+ target: string,
117
+ label?: string,
118
+ ): AsciiDiagram {
119
+ const ids = new Set(diagram.nodes.map((n) => n.id));
120
+ if (!ids.has(source) || !ids.has(target) || source === target) return diagram;
121
+ const exists = diagram.edges.some(
122
+ (e) => e.source === source && e.target === target && (e.label ?? '') === (label ?? ''),
123
+ );
124
+ if (exists) return diagram;
125
+ const edge: AsciiDiagramEdge = {
126
+ source,
127
+ target,
128
+ ...(label ? { label } : {}),
129
+ directed: true,
130
+ };
131
+ return { ...diagram, edges: [...diagram.edges, edge] };
132
+ }
133
+
134
+ /**
135
+ * Remove an edge. A label-qualified call removes the exact match; without
136
+ * a label the first (source, target) match goes — mirroring the legacy
137
+ * `removeConnection` semantics.
138
+ */
139
+ export function removeEdgeOp(
140
+ diagram: AsciiDiagram,
141
+ source: string,
142
+ target: string,
143
+ label?: string,
144
+ ): AsciiDiagram {
145
+ let removed = false;
146
+ const edges = diagram.edges.filter((e) => {
147
+ if (removed) return true;
148
+ const match =
149
+ e.source === source &&
150
+ e.target === target &&
151
+ (label !== undefined ? (e.label ?? '') === label : true);
152
+ if (match) {
153
+ removed = true;
154
+ return false;
155
+ }
156
+ return true;
157
+ });
158
+ return removed ? { ...diagram, edges } : diagram;
159
+ }
160
+
161
+ /**
162
+ * Rename a node. The label drives the node's parse-derived id, so edges
163
+ * and containment references are rewritten to the id the next parse will
164
+ * produce. (Selection in the canvas drops after a rename — documented.)
165
+ */
166
+ export function renameNodeOp(diagram: AsciiDiagram, nodeId: string, label: string): AsciiDiagram {
167
+ const node = diagram.nodes.find((n) => n.id === nodeId);
168
+ if (!node) return diagram;
169
+ const clean = sanitizeAsciiLabel(label);
170
+ if (clean.length === 0 || clean === node.label) return diagram;
171
+ return {
172
+ ...diagram,
173
+ nodes: diagram.nodes.map((n) => (n.id === nodeId ? { ...n, label: clean } : n)),
174
+ };
175
+ }
176
+
177
+ /** Add a node at (col, row). Inherits a container when placed inside one. */
178
+ export function addNodeOp(
179
+ diagram: AsciiDiagram,
180
+ opts: { col: number; row: number; label?: string },
181
+ ): { diagram: AsciiDiagram; label: string } {
182
+ // First free "Node N" label.
183
+ let label = opts.label;
184
+ if (!label) {
185
+ let i = 1;
186
+ const labels = new Set(diagram.nodes.map((n) => n.label.split('\n')[0]));
187
+ while (labels.has(`Node ${i}`)) i++;
188
+ label = `Node ${i}`;
189
+ }
190
+ const clean = sanitizeAsciiLabel(label) || 'Node';
191
+ const col = Math.max(0, Math.round(opts.col));
192
+ const row = Math.max(0, Math.round(opts.row));
193
+ const wCols = clean.length + 4;
194
+ const hRows = 3;
195
+ // Container inheritance: the new box's center falls inside a container.
196
+ const centerCol = col + wCols / 2;
197
+ const centerRow = row + hRows / 2;
198
+ const containers = diagram.nodes
199
+ .filter((n) => diagram.nodes.some((m) => m.containerId === n.id))
200
+ .filter(
201
+ (n) =>
202
+ centerCol > n.col &&
203
+ centerCol < n.col + n.wCols &&
204
+ centerRow > n.row &&
205
+ centerRow < n.row + n.hRows,
206
+ )
207
+ .sort((a, b) => a.wCols * a.hRows - b.wCols * b.hRows);
208
+ const node: AsciiDiagramNode = {
209
+ id: `__new-${diagram.nodes.length}`, // provisional; the re-parse derives the real slug id
210
+ label: clean,
211
+ col,
212
+ row,
213
+ wCols,
214
+ hRows,
215
+ ...(containers[0] ? { containerId: containers[0].id } : {}),
216
+ };
217
+ return { diagram: { ...diagram, nodes: [...diagram.nodes, node] }, label: clean };
218
+ }
219
+
220
+ /**
221
+ * Remove a node: incident edges (both directions) drop, direct children
222
+ * are promoted to the removed node's own container (or top level).
223
+ */
224
+ export function removeNodeOp(diagram: AsciiDiagram, nodeId: string): AsciiDiagram {
225
+ const node = diagram.nodes.find((n) => n.id === nodeId);
226
+ if (!node) return diagram;
227
+ const nodes = diagram.nodes
228
+ .filter((n) => n.id !== nodeId)
229
+ .map((n) => {
230
+ if (n.containerId !== nodeId) return n;
231
+ const promoted = { ...n };
232
+ if (node.containerId) promoted.containerId = node.containerId;
233
+ else delete promoted.containerId;
234
+ return promoted;
235
+ });
236
+ const edges = diagram.edges.filter((e) => e.source !== nodeId && e.target !== nodeId);
237
+ return { ...diagram, nodes, edges };
238
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Paste gate for bare (unfenced) ASCII diagram art.
3
+ *
4
+ * `+--+ | |` art pasted as plain text is otherwise mangled: its `| x |`
5
+ * rows match the GFM table-row pattern in `detectMarkdown`, so the paste
6
+ * handler routes it through the markdown converter, fails table
7
+ * validation, and emits one broken paragraph per line. When this gate
8
+ * matches, the paste handler inserts the text verbatim into a fresh code
9
+ * block instead — which the AsciiDiagramExtension then picks up as an
10
+ * interactive diagram.
11
+ */
12
+
13
+ import { detectAsciiDiagram } from '@bendyline/squisq/doc';
14
+
15
+ export function shouldPasteAsAsciiFence(text: string): boolean {
16
+ if (!text || text.includes('```')) return false;
17
+ const detection = detectAsciiDiagram(text);
18
+ return detection.isDiagram && (detection.diagram?.nodes.length ?? 0) >= 2;
19
+ }