@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,252 @@
1
+ /**
2
+ * TimelineViewExtension — mounts a WYSIWYG timeline editor over qualifying
3
+ * authored timeline code fences while keeping the fence text as source of
4
+ * truth. Every semantic edit is rendered back through the core codec.
5
+ */
6
+
7
+ import { Extension } from '@tiptap/core';
8
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
9
+ import type { Transaction } from '@tiptap/pm/state';
10
+ import type { Node as PMNode } from '@tiptap/pm/model';
11
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
12
+ import type { Editor } from '@tiptap/react';
13
+ import { createElement } from 'react';
14
+ import { createRoot, type Root } from 'react-dom/client';
15
+ import {
16
+ detectAsciiTimeline,
17
+ isEligibleAsciiTimelineFenceLang,
18
+ isExplicitTimelineLang,
19
+ type AsciiTimeline,
20
+ } from '@bendyline/squisq/doc';
21
+ import { TimelineEditorWidget } from './TimelineEditorWidget';
22
+
23
+ export interface TimelineBlockEntry {
24
+ /** Synthetic session id, stable across edits to one fence. */
25
+ id: string;
26
+ /** Current document position of the codeBlock node. */
27
+ pos: number;
28
+ }
29
+
30
+ export interface TimelineViewPluginState {
31
+ entries: TimelineBlockEntry[];
32
+ decorations: DecorationSet;
33
+ seq: number;
34
+ }
35
+
36
+ export const TIMELINE_VIEW_KEY = new PluginKey<TimelineViewPluginState>('squisq-timeline-view');
37
+
38
+ const detectCache = new WeakMap<PMNode, AsciiTimeline | null>();
39
+ const parseCache = new WeakMap<PMNode, AsciiTimeline | null>();
40
+
41
+ function fenceLangOf(node: PMNode): string | null {
42
+ const language = (node.attrs as { language?: string | null }).language;
43
+ return typeof language === 'string' ? language : null;
44
+ }
45
+
46
+ function eventCount(timeline: AsciiTimeline): number {
47
+ return timeline.tracks.reduce((sum, track) => sum + track.events.length, 0);
48
+ }
49
+
50
+ /** Conservative full-detection gate used when first claiming a code fence. */
51
+ export function getTimelineForNode(node: PMNode): AsciiTimeline | null {
52
+ if (node.type.name !== 'codeBlock') return null;
53
+ const cached = detectCache.get(node);
54
+ if (cached !== undefined) return cached;
55
+
56
+ let result: AsciiTimeline | null = null;
57
+ const language = fenceLangOf(node);
58
+ if (isEligibleAsciiTimelineFenceLang(language)) {
59
+ const detection = detectAsciiTimeline(node.textContent, {
60
+ explicit: isExplicitTimelineLang(language),
61
+ });
62
+ if (detection.isTimeline && detection.timeline && eventCount(detection.timeline) > 0) {
63
+ result = detection.timeline;
64
+ parseCache.set(node, result);
65
+ }
66
+ }
67
+ detectCache.set(node, result);
68
+ return result;
69
+ }
70
+
71
+ /** Relaxed/sticky gate for a block already registered by this extension. */
72
+ export function parseTimelineForNode(node: PMNode): AsciiTimeline | null {
73
+ if (node.type.name !== 'codeBlock') return null;
74
+ const cached = parseCache.get(node);
75
+ if (cached !== undefined) return cached;
76
+
77
+ let result: AsciiTimeline | null = null;
78
+ if (isEligibleAsciiTimelineFenceLang(fenceLangOf(node))) {
79
+ const detection = detectAsciiTimeline(node.textContent, { explicit: true });
80
+ if (detection.isTimeline && detection.timeline && eventCount(detection.timeline) > 0) {
81
+ result = detection.timeline;
82
+ }
83
+ }
84
+ parseCache.set(node, result);
85
+ return result;
86
+ }
87
+
88
+ export function findTimelineBlockPos(editor: Editor, blockId: string): number | null {
89
+ return (
90
+ TIMELINE_VIEW_KEY.getState(editor.state)?.entries.find((entry) => entry.id === blockId)?.pos ??
91
+ null
92
+ );
93
+ }
94
+
95
+ interface TimelineWidgetHost extends HTMLElement {
96
+ __squisqTimelineRoot?: Root;
97
+ }
98
+
99
+ function buildDecorations(
100
+ doc: PMNode,
101
+ entries: readonly TimelineBlockEntry[],
102
+ editor: Editor,
103
+ ): DecorationSet {
104
+ const decorations: Decoration[] = [];
105
+ for (const entry of entries) {
106
+ const node = doc.nodeAt(entry.pos);
107
+ if (!node || node.type.name !== 'codeBlock') continue;
108
+
109
+ decorations.push(
110
+ Decoration.node(entry.pos, entry.pos + node.nodeSize, {
111
+ class: 'squisq-ascii-timeline-fence-hidden',
112
+ }),
113
+ );
114
+ decorations.push(
115
+ Decoration.widget(
116
+ entry.pos + node.nodeSize,
117
+ () => {
118
+ const container = document.createElement('div') as TimelineWidgetHost;
119
+ container.className = 'squisq-ascii-timeline-widget-host';
120
+ container.contentEditable = 'false';
121
+ // The widget contains real form controls inside a ProseMirror
122
+ // decoration. Keep their complete edit/composition stream inside
123
+ // the React root so browser/IME `beforeinput` events cannot also be
124
+ // interpreted as text insertion at the document selection.
125
+ for (const eventName of [
126
+ 'pointerdown',
127
+ 'mousedown',
128
+ 'keydown',
129
+ 'keyup',
130
+ 'beforeinput',
131
+ 'input',
132
+ 'change',
133
+ 'compositionstart',
134
+ 'compositionupdate',
135
+ 'compositionend',
136
+ 'paste',
137
+ 'cut',
138
+ ]) {
139
+ container.addEventListener(eventName, (event) => event.stopPropagation());
140
+ }
141
+ const root = createRoot(container);
142
+ root.render(createElement(TimelineEditorWidget, { editor, blockId: entry.id }));
143
+ container.__squisqTimelineRoot = root;
144
+ return container;
145
+ },
146
+ {
147
+ destroy: (dom) => {
148
+ const root = (dom as TimelineWidgetHost).__squisqTimelineRoot;
149
+ if (root) setTimeout(() => root.unmount(), 0);
150
+ },
151
+ side: 1,
152
+ ignoreSelection: true,
153
+ key: `squisq-timeline-${entry.id}`,
154
+ },
155
+ ),
156
+ );
157
+ }
158
+ return DecorationSet.create(doc, decorations);
159
+ }
160
+
161
+ function applyState(
162
+ tr: Transaction,
163
+ previous: TimelineViewPluginState,
164
+ editor: Editor,
165
+ doc: PMNode,
166
+ ): TimelineViewPluginState {
167
+ if (!tr.docChanged) return previous;
168
+
169
+ const mapped = new Map<number, string>();
170
+ const claimed = new Set<string>();
171
+ for (const entry of previous.entries) {
172
+ const result = tr.mapping.mapResult(entry.pos, 1);
173
+ if (!result.deleted) {
174
+ mapped.set(result.pos, entry.id);
175
+ claimed.add(entry.id);
176
+ }
177
+ }
178
+ // `setNodeMarkup` during language promotion can mark the opening token as
179
+ // deleted even though the codeBlock still occupies the mapped position.
180
+ for (const entry of previous.entries) {
181
+ if (claimed.has(entry.id)) continue;
182
+ const result = tr.mapping.mapResult(entry.pos, 1);
183
+ if (mapped.has(result.pos)) continue;
184
+ if (doc.nodeAt(result.pos)?.type.name === 'codeBlock') {
185
+ mapped.set(result.pos, entry.id);
186
+ claimed.add(entry.id);
187
+ }
188
+ }
189
+
190
+ let seq = previous.seq;
191
+ const entries: TimelineBlockEntry[] = [];
192
+ doc.descendants((node, pos) => {
193
+ if (node.type.name !== 'codeBlock') return;
194
+ const knownId = mapped.get(pos);
195
+ const timeline = knownId ? parseTimelineForNode(node) : getTimelineForNode(node);
196
+ if (!timeline) return false;
197
+ entries.push({ id: knownId ?? `timeline-${++seq}`, pos });
198
+ return false;
199
+ });
200
+
201
+ return { entries, seq, decorations: buildDecorations(doc, entries, editor) };
202
+ }
203
+
204
+ export interface TimelineViewExtensionOptions {
205
+ /** When false, the extension does not claim fences or mount widgets. */
206
+ enabled?: boolean;
207
+ }
208
+
209
+ export const TimelineViewExtension = Extension.create<TimelineViewExtensionOptions>({
210
+ name: 'squisqTimelineView',
211
+
212
+ addOptions() {
213
+ return { enabled: true };
214
+ },
215
+
216
+ addProseMirrorPlugins() {
217
+ const editor = this.editor as Editor;
218
+ if (this.options.enabled === false) return [];
219
+
220
+ return [
221
+ new Plugin<TimelineViewPluginState>({
222
+ key: TIMELINE_VIEW_KEY,
223
+ state: {
224
+ init: (_config, state) => {
225
+ let seq = 0;
226
+ const entries: TimelineBlockEntry[] = [];
227
+ state.doc.descendants((node, pos) => {
228
+ if (node.type.name !== 'codeBlock') return;
229
+ if (!getTimelineForNode(node)) return false;
230
+ entries.push({ id: `timeline-${++seq}`, pos });
231
+ return false;
232
+ });
233
+ return {
234
+ entries,
235
+ seq,
236
+ decorations: buildDecorations(state.doc, entries, editor),
237
+ };
238
+ },
239
+ apply: (tr, previous, _oldState, newState) =>
240
+ applyState(tr, previous, editor, newState.doc),
241
+ },
242
+ props: {
243
+ decorations(state) {
244
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
245
+ },
246
+ },
247
+ }),
248
+ ];
249
+ },
250
+ });
251
+
252
+ export default TimelineViewExtension;
@@ -0,0 +1,355 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import { EditorContent } from '@tiptap/react';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
6
+ import { parseAsciiTimeline, renderAsciiTimeline, type AsciiTimeline } from '@bendyline/squisq/doc';
7
+ import { markdownToTiptap } from '../../tiptapBridge';
8
+ import { HeadingWithTemplate } from '../../TemplateAnnotation';
9
+ import { TimelineEditorWidget } from '../TimelineEditorWidget';
10
+ import { TIMELINE_VIEW_KEY, TimelineViewExtension } from '../TimelineViewExtension';
11
+ import { useTimelineData, type TimelineViewData } from '../timelineData';
12
+
13
+ const TIMELINE: AsciiTimeline = {
14
+ tracks: [
15
+ {
16
+ id: 'kernel',
17
+ label: 'Kernel',
18
+ row: 0,
19
+ startColumn: 0,
20
+ endColumn: 100,
21
+ events: [
22
+ { id: 'start', label: 'Start', column: 0, side: 'above', marker: 'filled' },
23
+ { id: 'review', label: 'Review', column: 50, side: 'below', marker: 'hollow' },
24
+ ],
25
+ },
26
+ {
27
+ id: 'client',
28
+ label: 'Client',
29
+ row: 1,
30
+ startColumn: 0,
31
+ endColumn: 100,
32
+ events: [
33
+ {
34
+ id: 'frame',
35
+ label: 'f',
36
+ column: 25,
37
+ side: 'above',
38
+ callout: false,
39
+ marker: 'hollow',
40
+ },
41
+ { id: 'paint', label: 'Paint', column: 100, side: 'below', marker: 'filled' },
42
+ ],
43
+ },
44
+ ],
45
+ links: [{ source: 'review', target: 'paint', label: 'handoff' }],
46
+ width: 101,
47
+ height: 2,
48
+ style: 'unicode',
49
+ warnings: [],
50
+ };
51
+
52
+ const ART = renderAsciiTimeline(TIMELINE);
53
+ const editors: Editor[] = [];
54
+
55
+ beforeAll(() => {
56
+ if (typeof globalThis.PointerEvent !== 'undefined') return;
57
+ class PointerEventStub extends MouseEvent {
58
+ readonly pointerId: number;
59
+
60
+ constructor(type: string, init: PointerEventInit = {}) {
61
+ super(type, init);
62
+ this.pointerId = init.pointerId ?? 0;
63
+ }
64
+ }
65
+ globalThis.PointerEvent = PointerEventStub as typeof PointerEvent;
66
+ });
67
+
68
+ function makeEditor(editable = true, art = ART): Editor {
69
+ const editor = new Editor({
70
+ editable,
71
+ extensions: [
72
+ StarterKit.configure({
73
+ heading: false,
74
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
75
+ }),
76
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
77
+ TimelineViewExtension,
78
+ ],
79
+ content: markdownToTiptap(`\`\`\`timeline\n${art}\n\`\`\`\n`),
80
+ });
81
+ editors.push(editor);
82
+ return editor;
83
+ }
84
+
85
+ function blockIdOf(editor: Editor): string {
86
+ const entries = TIMELINE_VIEW_KEY.getState(editor.state)?.entries ?? [];
87
+ expect(entries).toHaveLength(1);
88
+ return entries[0].id;
89
+ }
90
+
91
+ function renderWidget(editable = true): Editor {
92
+ const editor = makeEditor(editable);
93
+ render(<TimelineEditorWidget editor={editor} blockId={blockIdOf(editor)} />);
94
+ return editor;
95
+ }
96
+
97
+ function timelineOf(editor: Editor): AsciiTimeline {
98
+ let fence = '';
99
+ editor.state.doc.descendants((node) => {
100
+ if (node.type.name !== 'codeBlock') return true;
101
+ fence = node.textContent;
102
+ return false;
103
+ });
104
+ return parseAsciiTimeline(fence);
105
+ }
106
+
107
+ function eventOf(editor: Editor, id: string) {
108
+ return timelineOf(editor)
109
+ .tracks.flatMap((track) => track.events)
110
+ .find((event) => event.id === id);
111
+ }
112
+
113
+ afterEach(() => {
114
+ cleanup();
115
+ for (const editor of editors) editor.destroy();
116
+ editors.length = 0;
117
+ });
118
+
119
+ describe('TimelineEditorWidget', () => {
120
+ it('selects different dots, including a cadence-only dot, and exposes branches', async () => {
121
+ const editor = makeEditor();
122
+ const rendered = render(<TimelineEditorWidget editor={editor} blockId={blockIdOf(editor)} />);
123
+
124
+ const start = screen.getByRole('button', { name: /Edit Start, Kernel/ });
125
+ await waitFor(() => expect(start.getAttribute('aria-pressed')).toBe('true'));
126
+
127
+ const review = screen.getByRole('button', { name: /Edit Review, Kernel/ });
128
+ fireEvent.click(review);
129
+ expect(review.getAttribute('aria-pressed')).toBe('true');
130
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('Review');
131
+
132
+ const cadence = screen.getByRole('button', { name: /Edit f, Client/ });
133
+ fireEvent.click(cadence);
134
+ expect(cadence.getAttribute('aria-pressed')).toBe('true');
135
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('f');
136
+
137
+ const branchList = screen.getByRole('list', { name: 'Timeline branches' });
138
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
139
+ expect(branchList.textContent).toContain('review');
140
+ expect(branchList.textContent).toContain('paint');
141
+ expect(branchList.textContent).toContain('handoff');
142
+ expect(
143
+ rendered.container.querySelectorAll('.squisq-ascii-timeline-branches path'),
144
+ ).toHaveLength(1);
145
+ });
146
+
147
+ it('rewrites label and callout text on blur while preserving the event id and branch', async () => {
148
+ const editor = renderWidget();
149
+ fireEvent.click(screen.getByRole('button', { name: /Edit Review, Kernel/ }));
150
+
151
+ const label = screen.getByLabelText('Label') as HTMLInputElement;
152
+ fireEvent.change(label, { target: { value: 'Gate' } });
153
+ fireEvent.blur(label);
154
+
155
+ await waitFor(() => expect(eventOf(editor, 'review')?.label).toBe('Gate'));
156
+ expect(timelineOf(editor).links).toEqual([
157
+ { source: 'review', target: 'paint', label: 'handoff' },
158
+ ]);
159
+
160
+ const callout = screen.getByLabelText('Callout text') as HTMLTextAreaElement;
161
+ fireEvent.change(callout, { target: { value: 'Interpolate the latest snapshot' } });
162
+ fireEvent.blur(callout);
163
+
164
+ await waitFor(() =>
165
+ expect(eventOf(editor, 'review')?.description).toBe('Interpolate the latest snapshot'),
166
+ );
167
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('Gate');
168
+ expect(
169
+ screen.getByRole('button', { name: /Edit Gate, Kernel/ }).getAttribute('aria-pressed'),
170
+ ).toBe('true');
171
+ });
172
+
173
+ it('adds and selects a point where the empty rail is clicked', async () => {
174
+ const editor = renderWidget();
175
+ const rail = screen.getByRole('group', { name: 'Kernel timeline rail' });
176
+ rail.getBoundingClientRect = () =>
177
+ ({
178
+ x: 100,
179
+ y: 0,
180
+ left: 100,
181
+ top: 0,
182
+ right: 500,
183
+ bottom: 40,
184
+ width: 400,
185
+ height: 40,
186
+ toJSON: () => ({}),
187
+ }) as DOMRect;
188
+
189
+ fireEvent.click(rail, { clientX: 240 });
190
+
191
+ await waitFor(() => expect(eventOf(editor, 'new-event')?.column).toBe(35));
192
+ const marker = screen.getByRole('button', { name: /Edit New event, Kernel/ });
193
+ expect(marker.getAttribute('aria-pressed')).toBe('true');
194
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('New event');
195
+ expect(screen.getByText('New timeline point added. Edit its text below.')).toBeTruthy();
196
+ });
197
+
198
+ it('previews a dragged dot and commits its new position only on drop', async () => {
199
+ const editor = renderWidget();
200
+ const rail = screen.getByRole('group', { name: 'Kernel timeline rail' });
201
+ rail.getBoundingClientRect = () =>
202
+ ({
203
+ x: 100,
204
+ y: 0,
205
+ left: 100,
206
+ top: 0,
207
+ right: 500,
208
+ bottom: 40,
209
+ width: 400,
210
+ height: 40,
211
+ toJSON: () => ({}),
212
+ }) as DOMRect;
213
+
214
+ const review = screen.getByRole('button', { name: /Edit Review, Kernel/ });
215
+ fireEvent.pointerDown(review, { pointerId: 7, button: 0, clientX: 300 });
216
+ fireEvent.pointerMove(review, { pointerId: 7, clientX: 400 });
217
+
218
+ await waitFor(() =>
219
+ expect(review.closest('.squisq-ascii-timeline-point')?.getAttribute('style')).toContain(
220
+ '75%',
221
+ ),
222
+ );
223
+ expect(eventOf(editor, 'review')?.column).toBe(50);
224
+ expect(screen.queryAllByRole('button', { name: /Add point to Kernel at/ })).toHaveLength(0);
225
+
226
+ fireEvent.pointerUp(review, { pointerId: 7, button: 0, clientX: 400 });
227
+
228
+ await waitFor(() => expect(eventOf(editor, 'review')?.column).toBe(75));
229
+ expect(screen.getByText('Review moved to 75 percent.')).toBeTruthy();
230
+ expect(
231
+ screen
232
+ .getByRole('button', { name: /Edit Review, Kernel, 75 percent/ })
233
+ .getAttribute('aria-pressed'),
234
+ ).toBe('true');
235
+ });
236
+
237
+ it('cancels a pointer drag without rewriting the timeline', async () => {
238
+ const editor = renderWidget();
239
+ const rail = screen.getByRole('group', { name: 'Kernel timeline rail' });
240
+ rail.getBoundingClientRect = () =>
241
+ ({ left: 0, right: 400, top: 0, bottom: 40, width: 400, height: 40 }) as DOMRect;
242
+ const before = JSON.stringify(editor.state.doc.toJSON());
243
+ const review = screen.getByRole('button', { name: /Edit Review, Kernel/ });
244
+
245
+ fireEvent.pointerDown(review, { pointerId: 8, button: 0, clientX: 200 });
246
+ fireEvent.pointerMove(review, { pointerId: 8, clientX: 320 });
247
+ fireEvent.pointerCancel(review, { pointerId: 8 });
248
+
249
+ await waitFor(() => expect(screen.getByText('Timeline point move cancelled.')).toBeTruthy());
250
+ expect(JSON.stringify(editor.state.doc.toJSON())).toBe(before);
251
+ expect(eventOf(editor, 'review')?.column).toBe(50);
252
+ });
253
+
254
+ it('always exposes a keyboard-reachable add-point control for each track', async () => {
255
+ const editor = renderWidget();
256
+ fireEvent.click(screen.getByRole('button', { name: 'Add point to Kernel timeline' }));
257
+
258
+ await waitFor(() => expect(eventOf(editor, 'new-event')?.column).toBe(25));
259
+ expect(screen.getByRole('button', { name: /Edit New event, Kernel/ })).toBeTruthy();
260
+ });
261
+
262
+ it('keeps dots selectable but prevents every mutation in read-only mode', async () => {
263
+ const editor = renderWidget(false);
264
+ const before = JSON.stringify(editor.state.doc.toJSON());
265
+ expect(screen.getByText('Read only')).toBeTruthy();
266
+
267
+ const review = screen.getByRole('button', { name: /Edit Review, Kernel/ });
268
+ fireEvent.click(review);
269
+ expect(review.getAttribute('aria-pressed')).toBe('true');
270
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('Review');
271
+ expect(
272
+ (screen.getByLabelText('Label').closest('fieldset') as HTMLFieldSetElement).disabled,
273
+ ).toBe(true);
274
+
275
+ const rail = screen.getByRole('group', { name: 'Kernel timeline rail' });
276
+ fireEvent.click(rail, { clientX: 200 });
277
+ await Promise.resolve();
278
+ expect(JSON.stringify(editor.state.doc.toJSON())).toBe(before);
279
+ expect(screen.queryByRole('button', { name: /Add point to Kernel at/ })).toBeNull();
280
+ expect(screen.queryByRole('button', { name: 'Add point to Kernel timeline' })).toBeNull();
281
+ });
282
+
283
+ it('reacts when a mounted editor toggles between editable and read-only', async () => {
284
+ const editor = renderWidget();
285
+ expect(screen.getByRole('button', { name: 'Add point to Kernel timeline' })).toBeTruthy();
286
+
287
+ editor.setEditable(false);
288
+ await screen.findByText('Read only');
289
+ expect(screen.queryByRole('button', { name: 'Add point to Kernel timeline' })).toBeNull();
290
+ expect(
291
+ (screen.getByLabelText('Label').closest('fieldset') as HTMLFieldSetElement).disabled,
292
+ ).toBe(true);
293
+
294
+ editor.setEditable(true);
295
+ await waitFor(() =>
296
+ expect(screen.getByRole('button', { name: 'Add point to Kernel timeline' })).toBeTruthy(),
297
+ );
298
+ });
299
+
300
+ it('retains parsed timeline identity across unrelated editor transactions', async () => {
301
+ const editor = makeEditor();
302
+ const blockId = blockIdOf(editor);
303
+ const observed: TimelineViewData[] = [];
304
+ function Probe() {
305
+ const data = useTimelineData(editor, blockId);
306
+ if (data) observed.push(data);
307
+ return null;
308
+ }
309
+ render(<Probe />);
310
+ await waitFor(() => expect(observed.length).toBeGreaterThan(0));
311
+ const first = observed[observed.length - 1];
312
+
313
+ act(() => {
314
+ editor.commands.insertContentAt(0, '<p>Unrelated content</p>');
315
+ });
316
+
317
+ await waitFor(() => expect(observed.length).toBeGreaterThan(1));
318
+ expect(observed[observed.length - 1]).toBe(first);
319
+ expect(observed[observed.length - 1]?.timeline).toBe(first.timeline);
320
+ });
321
+
322
+ it('pauses visual mutations when source cannot be rewritten losslessly', async () => {
323
+ const unsafeArt = `${ART}\nbranch: review -> missing : preserve me`;
324
+ const editor = makeEditor(true, unsafeArt);
325
+ render(<TimelineEditorWidget editor={editor} blockId={blockIdOf(editor)} />);
326
+ const before = JSON.stringify(editor.state.doc.toJSON());
327
+
328
+ expect(screen.getByText('Source repair needed')).toBeTruthy();
329
+ expect(screen.getByRole('status').textContent).toContain('Visual editing paused');
330
+ expect(screen.queryByRole('button', { name: 'Add point to Kernel timeline' })).toBeNull();
331
+ expect(
332
+ (screen.getByLabelText('Label').closest('fieldset') as HTMLFieldSetElement).disabled,
333
+ ).toBe(true);
334
+
335
+ fireEvent.click(screen.getByRole('button', { name: /Edit Review, Kernel/ }));
336
+ expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('Review');
337
+ expect(JSON.stringify(editor.state.doc.toJSON())).toBe(before);
338
+ });
339
+
340
+ it('keeps mounted widget input events out of the underlying ProseMirror selection', async () => {
341
+ const editor = makeEditor();
342
+ render(<EditorContent editor={editor} />);
343
+
344
+ const review = await screen.findByRole('button', { name: /Edit Review, Kernel/ });
345
+ fireEvent.click(review);
346
+ const label = screen.getByLabelText('Label') as HTMLInputElement;
347
+ fireEvent.change(label, { target: { value: 'Gate' } });
348
+ fireEvent.blur(label);
349
+
350
+ await waitFor(() => expect(eventOf(editor, 'review')?.label).toBe('Gate'));
351
+ expect(editor.state.doc.childCount).toBe(1);
352
+ expect(editor.state.doc.firstChild?.type.name).toBe('codeBlock');
353
+ expect(editor.state.doc.firstChild?.textContent).toContain('Gate');
354
+ });
355
+ });