@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,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
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseTree, renderTree, type Tree, type TreeNode } from '@bendyline/squisq/doc';
3
+ import {
4
+ addItemOp,
5
+ indentItemOp,
6
+ moveItemDownOp,
7
+ moveItemUpOp,
8
+ outdentItemOp,
9
+ removeItemOp,
10
+ renameItemOp,
11
+ sanitizeTreeLabel,
12
+ toggleDirOp,
13
+ } from '../treeOps';
14
+
15
+ function tree(art: string): Tree {
16
+ return parseTree(art);
17
+ }
18
+ function flat(t: Tree): string[] {
19
+ const out: string[] = [];
20
+ const walk = (ns: TreeNode[], d: number) => {
21
+ for (const n of ns) {
22
+ out.push(`${d}:${n.label}`);
23
+ walk(n.children, d + 1);
24
+ }
25
+ };
26
+ walk(t.roots, 0);
27
+ return out;
28
+ }
29
+
30
+ const BASE = ['root/', '├── a', '├── b/', '│ └── b1', '└── c'].join('\n');
31
+
32
+ describe('sanitizeTreeLabel', () => {
33
+ it('strips connector glyphs and newlines', () => {
34
+ expect(sanitizeTreeLabel('a ├── b │ c\nd')).toBe('a b c d');
35
+ });
36
+ });
37
+
38
+ describe('treeOps', () => {
39
+ it('renameItemOp renames without mutating the input', () => {
40
+ const t = tree(BASE);
41
+ const next = renameItemOp(t, 'a', 'alpha');
42
+ expect(flat(next)).toContain('1:alpha');
43
+ expect(flat(t)).toContain('1:a'); // original untouched
44
+ });
45
+
46
+ it('addItemOp adds a child and a sibling', () => {
47
+ const child = addItemOp(tree(BASE), 'a', 'child', 'a1');
48
+ const aNode = child.roots[0].children.find((n) => n.label === 'a');
49
+ expect(aNode?.children.map((c) => c.label)).toEqual(['a1']);
50
+
51
+ const sib = addItemOp(tree(BASE), 'a', 'siblingAfter', 'a2');
52
+ expect(sib.roots[0].children.map((c) => c.label)).toEqual(['a', 'a2', 'b/', 'c']);
53
+ });
54
+
55
+ it('addItemOp with isDir adds a trailing-slash folder with a folder default name', () => {
56
+ // Explicit label gets a slash.
57
+ const named = addItemOp(tree(BASE), 'a', 'siblingAfter', 'assets', true);
58
+ const assets = named.roots[0].children.find((n) => n.label === 'assets/');
59
+ expect(assets?.isDir).toBe(true);
60
+
61
+ // Default name is a fresh `folderN/`.
62
+ const auto = addItemOp(tree(BASE), 'a', 'siblingAfter', undefined, true);
63
+ const folder = auto.roots[0].children.find((n) => n.label.startsWith('folder'));
64
+ expect(folder?.label).toBe('folder1/');
65
+ expect(folder?.isDir).toBe(true);
66
+ });
67
+
68
+ it('removeItemOp drops a node and its subtree', () => {
69
+ const next = removeItemOp(tree(BASE), 'b');
70
+ expect(flat(next)).toEqual(['0:root/', '1:a', '1:c']);
71
+ });
72
+
73
+ it('indentItemOp re-parents under the preceding sibling', () => {
74
+ const next = indentItemOp(tree(BASE), 'c'); // c indents under b/
75
+ expect(flat(next)).toEqual(['0:root/', '1:a', '1:b/', '2:b1', '2:c']);
76
+ });
77
+
78
+ it('indentItemOp is a no-op for the first sibling', () => {
79
+ const t = tree(BASE);
80
+ expect(indentItemOp(t, 'a')).toBe(t);
81
+ });
82
+
83
+ it('outdentItemOp promotes a node to a sibling of its parent (subtree carried)', () => {
84
+ const next = outdentItemOp(tree(BASE), 'b1'); // b1 outdents next to b/
85
+ expect(flat(next)).toEqual(['0:root/', '1:a', '1:b/', '1:b1', '1:c']);
86
+ });
87
+
88
+ it('outdentItemOp is a no-op for a root', () => {
89
+ const t = tree(BASE);
90
+ expect(outdentItemOp(t, 'root')).toBe(t);
91
+ });
92
+
93
+ it('moveItemUp / moveItemDown reorder siblings within bounds', () => {
94
+ expect(flat(moveItemDownOp(tree(BASE), 'a'))).toEqual([
95
+ '0:root/',
96
+ '1:b/',
97
+ '2:b1',
98
+ '1:a',
99
+ '1:c',
100
+ ]);
101
+ const t = tree(BASE);
102
+ expect(moveItemUpOp(t, 'a')).toBe(t); // already first
103
+ });
104
+
105
+ it('toggleDirOp toggles the trailing slash', () => {
106
+ const on = toggleDirOp(tree(BASE), 'a'); // a → a/
107
+ expect(on.roots[0].children[0].label).toBe('a/');
108
+ const off = toggleDirOp(on, 'a'); // a/ → a
109
+ expect(off.roots[0].children.find((n) => n.label === 'a')).toBeDefined();
110
+ });
111
+
112
+ it('every op keeps the tree renderable + byte-stable', () => {
113
+ for (const op of [
114
+ (t: Tree) => renameItemOp(t, 'a', 'alpha'),
115
+ (t: Tree) => addItemOp(t, 'b', 'child', 'b2'),
116
+ (t: Tree) => indentItemOp(t, 'c'),
117
+ (t: Tree) => outdentItemOp(t, 'b1'),
118
+ (t: Tree) => moveItemDownOp(t, 'a'),
119
+ ]) {
120
+ const next = op(tree(BASE));
121
+ const r1 = renderTree(next);
122
+ expect(renderTree(parseTree(r1))).toBe(r1);
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldPasteAsTreeFence } from '../treePaste';
3
+
4
+ const FILE_TREE = ['src/', '├── index.ts', '├── utils/', '│ └── math.ts', '└── config.ts'].join(
5
+ '\n',
6
+ );
7
+ const ASCII_TREE = ['root', '|-- a', '|-- b', '`-- c'].join('\n');
8
+ const BOX_DIAGRAM = [
9
+ '┌────────┐',
10
+ '│ Alpha │',
11
+ '└───┬────┘',
12
+ ' ▼',
13
+ '┌────────┐',
14
+ '│ Beta │',
15
+ '└────────┘',
16
+ ].join('\n');
17
+
18
+ describe('shouldPasteAsTreeFence', () => {
19
+ it('accepts bare Unicode file-tree art', () => {
20
+ expect(shouldPasteAsTreeFence(FILE_TREE)).toBe(true);
21
+ });
22
+
23
+ it('accepts bare ASCII tree art', () => {
24
+ expect(shouldPasteAsTreeFence(ASCII_TREE)).toBe(true);
25
+ });
26
+
27
+ it('rejects a box diagram (belongs to the diagram gate)', () => {
28
+ expect(shouldPasteAsTreeFence(BOX_DIAGRAM)).toBe(false);
29
+ });
30
+
31
+ it('rejects a GFM table', () => {
32
+ expect(shouldPasteAsTreeFence(['| a | b |', '|---|---|', '| 1 | 2 |'].join('\n'))).toBe(false);
33
+ });
34
+
35
+ it('rejects text already containing a fence, and prose', () => {
36
+ expect(shouldPasteAsTreeFence('```\n' + FILE_TREE + '\n```')).toBe(false);
37
+ expect(shouldPasteAsTreeFence('just a normal paragraph of text')).toBe(false);
38
+ expect(shouldPasteAsTreeFence('')).toBe(false);
39
+ });
40
+ });