@bendyline/squisq-editor-react 1.6.1 → 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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  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 +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -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 +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  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 +38 -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__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -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 +312 -6
  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__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,240 @@
1
+ /**
2
+ * TreeOutlineWidget — the interactive outline editor mounted below a hidden
3
+ * ASCII tree fence. Reads the live tree via `useTreeViewData` and writes
4
+ * every structural edit back as regenerated tree art via `applyTreeCommand`
5
+ * (op → render → verify → single-transaction fence rewrite).
6
+ *
7
+ * Manage-items UX: a toolbar (add file / add folder), inline-editable
8
+ * labels, per-row controls (add child, indent/outdent, move up/down,
9
+ * folder⇄file, delete), and collapse chevrons (widget-local, not
10
+ * persisted). Enter in a label adds a sibling; Backspace on an empty label
11
+ * deletes it.
12
+ */
13
+
14
+ import { useCallback, useState } from 'react';
15
+ import type { Editor } from '@tiptap/react';
16
+ import type { TreeNode } from '@bendyline/squisq/doc';
17
+ import { Icon } from '../Icon';
18
+ import { useTreeViewData } from './treeViewData';
19
+ import { applyTreeCommand, type TreeCommand } from './treeViewCommands';
20
+
21
+ interface TreeOutlineWidgetProps {
22
+ editor: Editor;
23
+ blockId: string;
24
+ fallbackPos: number;
25
+ host?: HTMLElement | null;
26
+ }
27
+
28
+ export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
29
+ const view = useTreeViewData(editor, blockId);
30
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
31
+
32
+ const dispatch = useCallback(
33
+ (cmd: TreeCommand) => applyTreeCommand(editor, blockId, cmd),
34
+ [editor, blockId],
35
+ );
36
+ const toggleCollapse = useCallback((id: string) => {
37
+ setCollapsed((prev) => {
38
+ const next = new Set(prev);
39
+ if (next.has(id)) next.delete(id);
40
+ else next.add(id);
41
+ return next;
42
+ });
43
+ }, []);
44
+
45
+ if (!view) return null;
46
+ const roots = view.tree.roots;
47
+ const firstRootId = roots[0]?.id;
48
+
49
+ return (
50
+ <div className="squisq-tree-outline">
51
+ <div className="squisq-tree-toolbar">
52
+ <button
53
+ type="button"
54
+ className="squisq-tree-btn"
55
+ title="Add file"
56
+ disabled={!firstRootId}
57
+ onClick={() =>
58
+ firstRootId
59
+ ? dispatch({ kind: 'addItem', targetId: firstRootId, position: 'siblingAfter' })
60
+ : undefined
61
+ }
62
+ >
63
+ <Icon icon="fa-solid fa-plus" /> Item
64
+ </button>
65
+ <button
66
+ type="button"
67
+ className="squisq-tree-btn"
68
+ title="Add folder"
69
+ disabled={!firstRootId}
70
+ onClick={() =>
71
+ firstRootId
72
+ ? dispatch({
73
+ kind: 'addItem',
74
+ targetId: firstRootId,
75
+ position: 'siblingAfter',
76
+ isDir: true,
77
+ })
78
+ : undefined
79
+ }
80
+ >
81
+ <Icon icon="fa-solid fa-folder-plus" /> Folder
82
+ </button>
83
+ </div>
84
+
85
+ <ul className="squisq-tree-rows" role="tree">
86
+ {roots.map((node) => (
87
+ <TreeRowView
88
+ key={node.id}
89
+ node={node}
90
+ depth={0}
91
+ collapsed={collapsed}
92
+ toggleCollapse={toggleCollapse}
93
+ dispatch={dispatch}
94
+ />
95
+ ))}
96
+ </ul>
97
+
98
+ {view.warnings.length > 0 ? (
99
+ <div className="squisq-tree-warnings">
100
+ {view.warnings.length} parser note{view.warnings.length === 1 ? '' : 's'}
101
+ </div>
102
+ ) : null}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function TreeRowView({
108
+ node,
109
+ depth,
110
+ collapsed,
111
+ toggleCollapse,
112
+ dispatch,
113
+ }: {
114
+ node: TreeNode;
115
+ depth: number;
116
+ collapsed: ReadonlySet<string>;
117
+ toggleCollapse: (id: string) => void;
118
+ dispatch: (cmd: TreeCommand) => boolean;
119
+ }) {
120
+ const hasChildren = node.children.length > 0;
121
+ const isCollapsed = collapsed.has(node.id);
122
+ const isDir = node.isDir || hasChildren;
123
+ const [draft, setDraft] = useState(node.label);
124
+
125
+ const commit = () => {
126
+ if (draft !== node.label && draft.trim().length > 0) {
127
+ dispatch({ kind: 'renameItem', id: node.id, label: draft });
128
+ }
129
+ };
130
+
131
+ return (
132
+ <li role="treeitem" style={{ paddingLeft: `${depth * 18}px` }}>
133
+ <div className="squisq-tree-row">
134
+ {hasChildren ? (
135
+ <button
136
+ type="button"
137
+ className="squisq-tree-chevron"
138
+ aria-label={isCollapsed ? 'Expand' : 'Collapse'}
139
+ onClick={() => toggleCollapse(node.id)}
140
+ >
141
+ <Icon icon={`fa-solid ${isCollapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`} />
142
+ </button>
143
+ ) : (
144
+ <span className="squisq-tree-chevron squisq-tree-chevron--empty" />
145
+ )}
146
+ <button
147
+ type="button"
148
+ className="squisq-tree-icon"
149
+ title={isDir ? 'Make a file' : 'Make a folder'}
150
+ onClick={() => dispatch({ kind: 'toggleDir', id: node.id })}
151
+ >
152
+ <Icon icon={`fa-solid ${isDir ? 'fa-folder' : 'fa-file'}`} />
153
+ </button>
154
+ <input
155
+ className="squisq-tree-label"
156
+ value={draft}
157
+ onChange={(e) => setDraft(e.target.value)}
158
+ onBlur={commit}
159
+ onKeyDown={(e) => {
160
+ if (e.key === 'Enter') {
161
+ e.preventDefault();
162
+ commit();
163
+ dispatch({ kind: 'addItem', targetId: node.id, position: 'siblingAfter' });
164
+ } else if (e.key === 'Backspace' && draft.length === 0) {
165
+ e.preventDefault();
166
+ dispatch({ kind: 'removeItem', id: node.id });
167
+ } else if (e.key === 'Tab') {
168
+ // Structural indent/outdent operate on the committed label's id;
169
+ // commit the draft first so no typing is lost.
170
+ e.preventDefault();
171
+ commit();
172
+ dispatch({ kind: e.shiftKey ? 'outdentItem' : 'indentItem', id: node.id });
173
+ }
174
+ }}
175
+ />
176
+ <span className="squisq-tree-controls">
177
+ <button
178
+ type="button"
179
+ title="Add child"
180
+ onClick={() => dispatch({ kind: 'addItem', targetId: node.id, position: 'child' })}
181
+ >
182
+ <Icon icon="fa-solid fa-plus" />
183
+ </button>
184
+ <button
185
+ type="button"
186
+ title="Outdent"
187
+ onClick={() => dispatch({ kind: 'outdentItem', id: node.id })}
188
+ >
189
+ <Icon icon="fa-solid fa-outdent" />
190
+ </button>
191
+ <button
192
+ type="button"
193
+ title="Indent"
194
+ onClick={() => dispatch({ kind: 'indentItem', id: node.id })}
195
+ >
196
+ <Icon icon="fa-solid fa-indent" />
197
+ </button>
198
+ <button
199
+ type="button"
200
+ title="Move up"
201
+ onClick={() => dispatch({ kind: 'moveItemUp', id: node.id })}
202
+ >
203
+ <Icon icon="fa-solid fa-arrow-up" />
204
+ </button>
205
+ <button
206
+ type="button"
207
+ title="Move down"
208
+ onClick={() => dispatch({ kind: 'moveItemDown', id: node.id })}
209
+ >
210
+ <Icon icon="fa-solid fa-arrow-down" />
211
+ </button>
212
+ <button
213
+ type="button"
214
+ title="Delete"
215
+ className="squisq-tree-delete"
216
+ onClick={() => dispatch({ kind: 'removeItem', id: node.id })}
217
+ >
218
+ <Icon icon="fa-solid fa-trash" />
219
+ </button>
220
+ </span>
221
+ </div>
222
+ {hasChildren && !isCollapsed ? (
223
+ <ul className="squisq-tree-rows" role="group">
224
+ {node.children.map((child) => (
225
+ <TreeRowView
226
+ key={child.id}
227
+ node={child}
228
+ depth={depth + 1}
229
+ collapsed={collapsed}
230
+ toggleCollapse={toggleCollapse}
231
+ dispatch={dispatch}
232
+ />
233
+ ))}
234
+ </ul>
235
+ ) : null}
236
+ </li>
237
+ );
238
+ }
239
+
240
+ export default TreeOutlineWidget;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * TreeViewExtension — Tiptap/ProseMirror plugin that turns code blocks
3
+ * containing ASCII file-tree / outline art into interactive outline editors.
4
+ *
5
+ * Structurally identical to AsciiDiagramExtension (position registry, stable
6
+ * synthetic ids, hysteresis, self-rewrite-survival via the decoration key);
7
+ * only the detect/parse gates and class names differ. The fence text stays
8
+ * the source of truth — every edit re-renders the tree art.
9
+ *
10
+ * Mutual exclusion with the diagram extension: a fence with ≥2 closed boxes
11
+ * is a diagram, never a tree (checked in both gates), so at most one
12
+ * extension claims any given fence.
13
+ */
14
+
15
+ import { Extension } from '@tiptap/core';
16
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
17
+ import type { Transaction } from '@tiptap/pm/state';
18
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
19
+ import type { Node as PMNode } from '@tiptap/pm/model';
20
+ import type { Editor } from '@tiptap/react';
21
+ import { createRoot, type Root } from 'react-dom/client';
22
+ import { createElement } from 'react';
23
+ import {
24
+ detectTree,
25
+ isEligibleTreeFenceLang,
26
+ isExplicitTreeLang,
27
+ parseAsciiDiagram,
28
+ parseTree,
29
+ type Tree,
30
+ } from '@bendyline/squisq/doc';
31
+ import { TreeOutlineWidget } from './TreeOutlineWidget';
32
+
33
+ export interface TreeBlockEntry {
34
+ /** Synthetic session id (`tree-N`), stable across edits for one block. */
35
+ id: string;
36
+ /** Document position of the codeBlock node at the current state. */
37
+ pos: number;
38
+ }
39
+
40
+ export interface TreeViewPluginState {
41
+ entries: TreeBlockEntry[];
42
+ decorations: DecorationSet;
43
+ seq: number;
44
+ }
45
+
46
+ export const TREEVIEW_KEY = new PluginKey<TreeViewPluginState>('squisq-treeview');
47
+
48
+ const detectCache = new WeakMap<PMNode, Tree | null>();
49
+ const parseCache = new WeakMap<PMNode, Tree | null>();
50
+
51
+ function fenceLangOf(node: PMNode): string | null {
52
+ const lang = (node.attrs as { language?: string | null }).language;
53
+ return typeof lang === 'string' ? lang : null;
54
+ }
55
+
56
+ function countTreeNodes(nodes: readonly Tree['roots'][number][]): number {
57
+ let n = 0;
58
+ for (const node of nodes) n += 1 + countTreeNodes(node.children);
59
+ return n;
60
+ }
61
+
62
+ /** Full-detection gate for NEW blocks: lang allowlist + tree thresholds. */
63
+ export function getTreeForNode(node: PMNode): Tree | null {
64
+ if (node.type.name !== 'codeBlock') return null;
65
+ const cached = detectCache.get(node);
66
+ if (cached !== undefined) return cached;
67
+ let result: Tree | null = null;
68
+ const lang = fenceLangOf(node);
69
+ if (isEligibleTreeFenceLang(lang)) {
70
+ const detection = detectTree(node.textContent, { explicit: isExplicitTreeLang(lang) });
71
+ if (detection.isTree && detection.tree) {
72
+ result = detection.tree;
73
+ parseCache.set(node, result);
74
+ }
75
+ }
76
+ detectCache.set(node, result);
77
+ return result;
78
+ }
79
+
80
+ /** Relaxed gate for ALREADY-REGISTERED blocks: any parse with ≥1 node and no boxes. */
81
+ export function parseTreeForNode(node: PMNode): Tree | null {
82
+ if (node.type.name !== 'codeBlock') return null;
83
+ const cached = parseCache.get(node);
84
+ if (cached !== undefined) return cached;
85
+ let result: Tree | null = null;
86
+ if (isEligibleTreeFenceLang(fenceLangOf(node))) {
87
+ const text = node.textContent;
88
+ // Box-diagram fences belong to the diagram extension, never here.
89
+ if (parseAsciiDiagram(text).nodes.length < 2) {
90
+ const parsed = parseTree(text);
91
+ if (countTreeNodes(parsed.roots) > 0) result = parsed;
92
+ }
93
+ }
94
+ parseCache.set(node, result);
95
+ return result;
96
+ }
97
+
98
+ export function findTreeBlockPos(editor: Editor, blockId: string): number | null {
99
+ return TREEVIEW_KEY.getState(editor.state)?.entries.find((e) => e.id === blockId)?.pos ?? null;
100
+ }
101
+
102
+ interface WidgetRootRef {
103
+ root: Root;
104
+ }
105
+
106
+ function buildDecorations(doc: PMNode, entries: TreeBlockEntry[], editor: Editor): DecorationSet {
107
+ const decos: Decoration[] = [];
108
+ for (const entry of entries) {
109
+ const node = doc.nodeAt(entry.pos);
110
+ if (!node || node.type.name !== 'codeBlock') continue;
111
+ decos.push(
112
+ Decoration.node(entry.pos, entry.pos + node.nodeSize, { class: 'squisq-tree-fence-hidden' }),
113
+ );
114
+ const blockId = entry.id;
115
+ const fallbackPos = entry.pos;
116
+ decos.push(
117
+ Decoration.widget(
118
+ entry.pos + node.nodeSize,
119
+ (view) => {
120
+ const container = document.createElement('div');
121
+ container.className = 'squisq-tree-widget-host';
122
+ container.contentEditable = 'false';
123
+ container.addEventListener('mousedown', (e) => e.stopPropagation());
124
+ container.addEventListener('keydown', (e) => e.stopPropagation());
125
+ const root = createRoot(container);
126
+ root.render(
127
+ createElement(TreeOutlineWidget, {
128
+ editor,
129
+ blockId,
130
+ fallbackPos,
131
+ host: view.dom.parentElement ?? view.dom,
132
+ }),
133
+ );
134
+ (container as HTMLElement & { __squisqTreeRoot?: WidgetRootRef }).__squisqTreeRoot = {
135
+ root,
136
+ };
137
+ return container;
138
+ },
139
+ {
140
+ destroy: (dom) => {
141
+ const ref = (dom as HTMLElement & { __squisqTreeRoot?: WidgetRootRef })
142
+ .__squisqTreeRoot;
143
+ if (ref) setTimeout(() => ref.root.unmount(), 0);
144
+ },
145
+ side: 1,
146
+ ignoreSelection: true,
147
+ key: `squisq-tree-${entry.id}`,
148
+ },
149
+ ),
150
+ );
151
+ }
152
+ return DecorationSet.create(doc, decos);
153
+ }
154
+
155
+ function applyState(
156
+ tr: Transaction,
157
+ prev: TreeViewPluginState,
158
+ editor: Editor,
159
+ doc: PMNode,
160
+ ): TreeViewPluginState {
161
+ if (!tr.docChanged) return prev;
162
+
163
+ const mapped = new Map<number, string>();
164
+ const claimed = new Set<string>();
165
+ // Pass 1: survivors whose start position wasn't touched.
166
+ for (const entry of prev.entries) {
167
+ const result = tr.mapping.mapResult(entry.pos, 1);
168
+ if (!result.deleted) {
169
+ mapped.set(result.pos, entry.id);
170
+ claimed.add(entry.id);
171
+ }
172
+ }
173
+ // Pass 2: boundary-churn survivors. An attrs-only `setNodeMarkup` (the
174
+ // language-promotion step) rewrites the codeBlock's opening token, so
175
+ // `mapResult` reports the start as `deleted` even though the block is still
176
+ // there. Adopt the old id when a codeBlock still sits at the mapped position
177
+ // and that slot isn't already claimed — keeps the widget alive across the
178
+ // one-time promotion instead of remounting it.
179
+ for (const entry of prev.entries) {
180
+ if (claimed.has(entry.id)) continue;
181
+ const result = tr.mapping.mapResult(entry.pos, 1);
182
+ if (mapped.has(result.pos)) continue;
183
+ if (doc.nodeAt(result.pos)?.type.name === 'codeBlock') {
184
+ mapped.set(result.pos, entry.id);
185
+ claimed.add(entry.id);
186
+ }
187
+ }
188
+
189
+ let seq = prev.seq;
190
+ const entries: TreeBlockEntry[] = [];
191
+ doc.descendants((node, pos) => {
192
+ if (node.type.name !== 'codeBlock') return;
193
+ const knownId = mapped.get(pos);
194
+ const tree = knownId ? parseTreeForNode(node) : getTreeForNode(node);
195
+ if (!tree) return false;
196
+ entries.push({ id: knownId ?? `tree-${++seq}`, pos });
197
+ return false;
198
+ });
199
+
200
+ return { entries, seq, decorations: buildDecorations(doc, entries, editor) };
201
+ }
202
+
203
+ export interface TreeViewExtensionOptions {
204
+ /** When false, the extension is inert (no widgets, no decorations). */
205
+ enabled?: boolean;
206
+ }
207
+
208
+ export const TreeViewExtension = Extension.create<TreeViewExtensionOptions>({
209
+ name: 'squisqTreeView',
210
+
211
+ addOptions() {
212
+ return { enabled: true };
213
+ },
214
+
215
+ addProseMirrorPlugins() {
216
+ const editor = this.editor as Editor;
217
+ if (this.options.enabled === false) return [];
218
+
219
+ return [
220
+ new Plugin<TreeViewPluginState>({
221
+ key: TREEVIEW_KEY,
222
+ state: {
223
+ init: (_config, state) => {
224
+ let seq = 0;
225
+ const entries: TreeBlockEntry[] = [];
226
+ state.doc.descendants((node, pos) => {
227
+ if (node.type.name !== 'codeBlock') return;
228
+ if (!getTreeForNode(node)) return false;
229
+ entries.push({ id: `tree-${++seq}`, pos });
230
+ return false;
231
+ });
232
+ return {
233
+ entries,
234
+ seq,
235
+ decorations: buildDecorations(state.doc, entries, editor),
236
+ };
237
+ },
238
+ apply: (tr, prev, _oldState, newState) => applyState(tr, prev, editor, newState.doc),
239
+ },
240
+ props: {
241
+ decorations(state) {
242
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
243
+ },
244
+ },
245
+ }),
246
+ ];
247
+ },
248
+ });
249
+
250
+ export default TreeViewExtension;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Registry / decoration behavior of TreeViewExtension + mutual exclusion
3
+ * with the diagram extension.
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 { markdownToTiptap } from '../../tiptapBridge';
10
+ import { HeadingWithTemplate } from '../../TemplateAnnotation';
11
+ import { TreeViewExtension, TREEVIEW_KEY, findTreeBlockPos } from '../TreeViewExtension';
12
+ import { AsciiDiagramExtension, ASCII_DIAGRAM_KEY } from '../../asciiDiagram/AsciiDiagramExtension';
13
+ import { replaceAsciiFenceText } from '../../asciiDiagram/asciiDiagramCommands';
14
+
15
+ beforeAll(() => {
16
+ if (typeof globalThis.ResizeObserver === 'undefined') {
17
+ class RO {
18
+ observe(): void {}
19
+ unobserve(): void {}
20
+ disconnect(): void {}
21
+ }
22
+ globalThis.ResizeObserver = RO as unknown as typeof ResizeObserver;
23
+ }
24
+ });
25
+
26
+ const TREE = ['src/', '├── a.ts', '└── sub/', ' └── b.ts'].join('\n');
27
+ const TREE_B = ['docs/', '├── guide.md', '└── api.md'].join('\n');
28
+ const DIAGRAM = [
29
+ '┌────────┐',
30
+ '│ Alpha │',
31
+ '└───┬────┘',
32
+ ' ▼',
33
+ '┌────────┐',
34
+ '│ Beta │',
35
+ '└────────┘',
36
+ ].join('\n');
37
+
38
+ let editors: Editor[] = [];
39
+ function makeEditor(md: string, options?: { enabled?: boolean }): 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
+ options ? TreeViewExtension.configure(options) : TreeViewExtension,
49
+ ],
50
+ content: markdownToTiptap(md),
51
+ });
52
+ editors.push(editor);
53
+ return editor;
54
+ }
55
+ afterEach(() => {
56
+ for (const e of editors) e.destroy();
57
+ editors = [];
58
+ });
59
+
60
+ const treeEntries = (e: Editor) => TREEVIEW_KEY.getState(e.state)?.entries ?? [];
61
+ const diagramEntries = (e: Editor) => ASCII_DIAGRAM_KEY.getState(e.state)?.entries ?? [];
62
+
63
+ describe('TreeViewExtension registry', () => {
64
+ it('registers each qualifying tree fence with a distinct id', () => {
65
+ const editor = makeEditor('```\n' + TREE + '\n```\n\nMid.\n\n```\n' + TREE_B + '\n```\n');
66
+ const entries = treeEntries(editor);
67
+ expect(entries).toHaveLength(2);
68
+ expect(entries[0].id).not.toBe(entries[1].id);
69
+ });
70
+
71
+ it('ignores real-language and non-tree fences', () => {
72
+ const editor = makeEditor('```bash\n' + TREE + '\n```\n\n```\nplain code\nno tree\n```\n');
73
+ expect(treeEntries(editor)).toHaveLength(0);
74
+ });
75
+
76
+ it('mutual exclusion: a box diagram gets the diagram widget, not the tree', () => {
77
+ const editor = makeEditor('```\n' + DIAGRAM + '\n```\n');
78
+ expect(treeEntries(editor)).toHaveLength(0);
79
+ expect(diagramEntries(editor)).toHaveLength(1);
80
+ });
81
+
82
+ it('keeps ids stable when content above changes', () => {
83
+ const editor = makeEditor('First.\n\n```\n' + TREE + '\n```\n');
84
+ const [before] = treeEntries(editor);
85
+ editor.commands.insertContentAt(0, '<p>inserted</p>');
86
+ const [after] = treeEntries(editor);
87
+ expect(after.id).toBe(before.id);
88
+ expect(after.pos).toBeGreaterThan(before.pos);
89
+ });
90
+
91
+ it('keeps the id across a self-inflicted fence rewrite', () => {
92
+ const editor = makeEditor('```\n' + TREE + '\n```\n');
93
+ const [before] = treeEntries(editor);
94
+ expect(replaceAsciiFenceText(editor, before.pos, TREE.replace('a.ts', 'main.ts'))).toBe(true);
95
+ const [after] = treeEntries(editor);
96
+ expect(after.id).toBe(before.id);
97
+ expect(findTreeBlockPos(editor, before.id)).toBe(after.pos);
98
+ });
99
+
100
+ it('hysteresis: degraded to a single node still stays interactive', () => {
101
+ const editor = makeEditor('```\n' + TREE + '\n```\n');
102
+ const [entry] = treeEntries(editor);
103
+ replaceAsciiFenceText(editor, entry.pos, 'src/\n└── only.ts');
104
+ const after = treeEntries(editor);
105
+ expect(after).toHaveLength(1);
106
+ expect(after[0].id).toBe(entry.id);
107
+ });
108
+
109
+ it('drops a block whose fence is rewritten into a box diagram', () => {
110
+ const editor = makeEditor('```\n' + TREE + '\n```\n');
111
+ const [entry] = treeEntries(editor);
112
+ replaceAsciiFenceText(editor, entry.pos, DIAGRAM);
113
+ expect(treeEntries(editor)).toHaveLength(0);
114
+ // The diagram extension now owns the fence.
115
+ expect(diagramEntries(editor)).toHaveLength(1);
116
+ });
117
+
118
+ it('enabled: false disables the plugin', () => {
119
+ const editor = makeEditor('```\n' + TREE + '\n```\n', { enabled: false });
120
+ expect(TREEVIEW_KEY.getState(editor.state)).toBeUndefined();
121
+ });
122
+ });