@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,183 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ BlockTagActivityExtension,
6
+ BLOCK_TAG_HOVERED_CLASS,
7
+ BLOCK_TAG_SELECTED_CLASS,
8
+ } from '../blockTagActivity';
9
+ import { HeadingWithTemplate } from '../TemplateAnnotation';
10
+ import { markdownToTiptap } from '../tiptapBridge';
11
+
12
+ const MARKDOWN = `# First block {[title]}
13
+
14
+ First body.
15
+
16
+ - Outer item with **nested inline target**
17
+
18
+ # Second block {[quote]}
19
+
20
+ Second body.
21
+ `;
22
+
23
+ const editors: Editor[] = [];
24
+ const editorHosts: HTMLElement[] = [];
25
+
26
+ function makeEditor(): Editor {
27
+ const element = document.createElement('div');
28
+ document.body.appendChild(element);
29
+ editorHosts.push(element);
30
+
31
+ const editor = new Editor({
32
+ element,
33
+ extensions: [
34
+ StarterKit.configure({ heading: false }),
35
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
36
+ BlockTagActivityExtension,
37
+ ],
38
+ content: markdownToTiptap(MARKDOWN),
39
+ });
40
+ editors.push(editor);
41
+ return editor;
42
+ }
43
+
44
+ function positionInsideTopLevelNode(editor: Editor, text: string): number {
45
+ let position: number | null = null;
46
+ editor.state.doc.forEach((node, offset) => {
47
+ if (position === null && node.textContent === text) position = offset + 1;
48
+ });
49
+ if (position === null) throw new Error(`Top-level node not found: ${text}`);
50
+ return position;
51
+ }
52
+
53
+ function headings(editor: Editor): HTMLElement[] {
54
+ return Array.from(editor.view.dom.children).filter((element): element is HTMLElement =>
55
+ /^H[1-6]$/.test(element.tagName),
56
+ );
57
+ }
58
+
59
+ function elementWithExactText(editor: Editor, selector: string, text: string): HTMLElement {
60
+ const element = Array.from(editor.view.dom.querySelectorAll<HTMLElement>(selector)).find(
61
+ (candidate) => candidate.textContent === text,
62
+ );
63
+ if (!element) throw new Error(`Element not found: ${selector} containing ${text}`);
64
+ return element;
65
+ }
66
+
67
+ afterEach(() => {
68
+ for (const editor of editors.splice(0)) editor.destroy();
69
+ for (const host of editorHosts.splice(0)) host.remove();
70
+ });
71
+
72
+ function waitForDomReconciliation(): Promise<void> {
73
+ return new Promise((resolve) => setTimeout(resolve, 0));
74
+ }
75
+
76
+ describe('block-tag activity', () => {
77
+ it('decorates the initial selection before the editor view settles', async () => {
78
+ const editor = makeEditor();
79
+ await waitForDomReconciliation();
80
+
81
+ const [firstHeading] = headings(editor);
82
+ expect(firstHeading.isConnected).toBe(true);
83
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
84
+ });
85
+
86
+ it('keeps cursor and hover decorations through DOM reconciliation', async () => {
87
+ const editor = makeEditor();
88
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'First body.'));
89
+ await waitForDomReconciliation();
90
+
91
+ const [firstHeading, secondHeading] = headings(editor);
92
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
93
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
94
+
95
+ const secondBody = elementWithExactText(editor, 'p', 'Second body.');
96
+ secondBody.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
97
+ await waitForDomReconciliation();
98
+
99
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
100
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
101
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
102
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
103
+
104
+ editor.view.dom.dispatchEvent(new MouseEvent('mouseleave'));
105
+ await waitForDomReconciliation();
106
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
107
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
108
+
109
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
110
+ await waitForDomReconciliation();
111
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
112
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
113
+ });
114
+
115
+ it('maps a nested list target to the heading that owns the list', async () => {
116
+ const editor = makeEditor();
117
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
118
+ await waitForDomReconciliation();
119
+
120
+ const [firstHeading, secondHeading] = headings(editor);
121
+ const nestedTarget = elementWithExactText(editor, 'strong', 'nested inline target');
122
+ nestedTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
123
+ await waitForDomReconciliation();
124
+
125
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
126
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
127
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
128
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
129
+ });
130
+
131
+ it('keeps the owning block hovered across blank space between its nodes', async () => {
132
+ const editor = makeEditor();
133
+ await waitForDomReconciliation();
134
+
135
+ const [firstHeading, secondHeading] = headings(editor);
136
+ const topLevelChildren = Array.from(editor.view.dom.children);
137
+ const firstHeadingIndex = topLevelChildren.indexOf(firstHeading);
138
+ const secondHeadingIndex = topLevelChildren.indexOf(secondHeading);
139
+
140
+ topLevelChildren.forEach((element, index) => {
141
+ const top =
142
+ index < secondHeadingIndex
143
+ ? 10 + (index - firstHeadingIndex) * 25
144
+ : 120 + (index - secondHeadingIndex) * 25;
145
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
146
+ top,
147
+ bottom: top + 18,
148
+ } as DOMRect);
149
+ });
150
+
151
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 105 }));
152
+ await waitForDomReconciliation();
153
+
154
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
155
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
156
+
157
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 145 }));
158
+ await waitForDomReconciliation();
159
+
160
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
161
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
162
+ });
163
+
164
+ it('clears block hover below the final content node', async () => {
165
+ const editor = makeEditor();
166
+ await waitForDomReconciliation();
167
+
168
+ const [, secondHeading] = headings(editor);
169
+ const topLevelChildren = Array.from(editor.view.dom.children);
170
+ topLevelChildren.forEach((element, index) => {
171
+ const top = index * 25;
172
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
173
+ top,
174
+ bottom: top + 18,
175
+ } as DOMRect);
176
+ });
177
+
178
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 1000 }));
179
+ await waitForDomReconciliation();
180
+
181
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
182
+ });
183
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Block, Doc } from '@bendyline/squisq/schemas';
3
+ import { buildDocumentPreviewMarkdown } from '../buildDocumentPreviewMarkdown';
4
+
5
+ function docWith(blocks: Block[]): Doc {
6
+ return {
7
+ articleId: 'summary-doc',
8
+ duration: 10,
9
+ blocks,
10
+ audio: { segments: [] },
11
+ frontmatter: { title: 'Quarterly Report' },
12
+ };
13
+ }
14
+
15
+ describe('buildDocumentPreviewMarkdown', () => {
16
+ it('serializes programmatic transform blocks as readable document content', () => {
17
+ const markdown = buildDocumentPreviewMarkdown(
18
+ docWith([
19
+ {
20
+ id: 'transform-stat',
21
+ template: 'statHighlight',
22
+ startTime: 0,
23
+ duration: 5,
24
+ audioSegment: 0,
25
+ stat: '42%',
26
+ description: 'Year-over-year growth',
27
+ } as Block,
28
+ {
29
+ id: 'transform-list',
30
+ template: 'list',
31
+ startTime: 5,
32
+ duration: 5,
33
+ audioSegment: 0,
34
+ title: 'Priorities',
35
+ items: ['Reliability', 'Speed'],
36
+ } as Block,
37
+ ]),
38
+ );
39
+
40
+ expect(markdown).toContain('# Quarterly Report');
41
+ expect(markdown).toContain('## 42%');
42
+ expect(markdown).toContain('Year-over-year growth');
43
+ expect(markdown).toContain('## Priorities');
44
+ expect(markdown).toContain('Reliability');
45
+ expect(markdown).toContain('Speed');
46
+ expect(markdown).not.toContain('{[statHighlight');
47
+ });
48
+
49
+ it('preserves authored blocks that a conservative transform kept', () => {
50
+ const markdown = buildDocumentPreviewMarkdown(
51
+ docWith([
52
+ {
53
+ id: 'authored',
54
+ startTime: 0,
55
+ duration: 5,
56
+ audioSegment: 0,
57
+ sourceHeading: {
58
+ type: 'heading',
59
+ depth: 2,
60
+ children: [{ type: 'text', value: 'Context' }],
61
+ },
62
+ contents: [
63
+ {
64
+ type: 'paragraph',
65
+ children: [{ type: 'text', value: 'Original supporting detail.' }],
66
+ },
67
+ ],
68
+ },
69
+ ]),
70
+ );
71
+
72
+ expect(markdown).toContain('## Context');
73
+ expect(markdown).toContain('Original supporting detail.');
74
+ });
75
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markdownToDoc, materializeBlockLayers } from '@bendyline/squisq/doc';
3
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
4
+ import type { DocBlock, TextLayer } from '@bendyline/squisq/schemas';
5
+ import { buildPreviewDoc } from '../buildPreviewDoc';
6
+
7
+ function firstPreviewSlide(markdown: string): Record<string, unknown> {
8
+ const source = markdownToDoc(parseMarkdown(markdown), {
9
+ articleId: 'stat-content-test',
10
+ generateCoverBlock: false,
11
+ });
12
+ return buildPreviewDoc(source).blocks[0] as unknown as Record<string, unknown>;
13
+ }
14
+
15
+ describe('buildPreviewDoc content mapping', () => {
16
+ it('maps the leading bold metric to the large stat layer', () => {
17
+ const slide = firstPreviewSlide(`### The Big Number {[statHighlight colorScheme=green]}
18
+
19
+ **42%** of teams prefer visual blocks over raw slides.`);
20
+
21
+ expect(slide).toMatchObject({
22
+ title: 'The Big Number',
23
+ stat: '42%',
24
+ description: 'of teams prefer visual blocks over raw slides.',
25
+ colorScheme: 'green',
26
+ });
27
+
28
+ const { layers } = materializeBlockLayers(slide as unknown as DocBlock);
29
+ const stat = layers.find((layer) => layer.id === 'stat') as TextLayer;
30
+ const description = layers.find((layer) => layer.id === 'description') as TextLayer;
31
+
32
+ expect(stat.content.text).toBe('42%');
33
+ expect(description.content.text).toBe('of teams prefer visual blocks over raw slides.');
34
+ expect(stat.content.style.fontSize).toBeGreaterThan(description.content.style.fontSize);
35
+ });
36
+
37
+ it('lets explicit annotation inputs override Markdown-derived defaults', () => {
38
+ const slide = firstPreviewSlide(
39
+ '### The Big Number {[statHighlight stat="99%" description="Pinned description"]}\n\n' +
40
+ '**42%** derived description.',
41
+ );
42
+
43
+ expect(slide).toMatchObject({
44
+ stat: '99%',
45
+ description: 'Pinned description',
46
+ });
47
+ });
48
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { parseMarkdown } from '@bendyline/squisq/markdown';
3
3
  import { markdownToDoc } from '@bendyline/squisq/doc';
4
- import type { Transition } from '@bendyline/squisq/schemas';
4
+ import type { Doc, Transition } from '@bendyline/squisq/schemas';
5
5
  import { buildPreviewDoc } from '../buildPreviewDoc';
6
6
 
7
7
  function previewSlides(md: string) {
@@ -11,8 +11,7 @@ function previewSlides(md: string) {
11
11
 
12
12
  describe('buildPreviewDoc transition mapping', () => {
13
13
  it('carries an authored transition through to the player slide', () => {
14
- // Transition written the way the toolbar / properties palette writes it:
15
- // into the heading's Pandoc `{…}` attribute block.
14
+ // Legacy Pandoc transition syntax remains supported.
16
15
  const md = ['# Intro', '', '# Second {transition=vortex}', '', 'body'].join('\n');
17
16
  const slides = previewSlides(md);
18
17
  expect(slides[1].transition).toEqual({ type: 'vortex' });
@@ -70,4 +69,24 @@ describe('buildPreviewDoc transition mapping', () => {
70
69
  expect(slides[1].transition).toEqual({ type: 'push', direction: 'left', duration: 0.8 });
71
70
  });
72
71
  });
72
+
73
+ it('preserves document-wide fields and mapped audio', () => {
74
+ const source = markdownToDoc(parseMarkdown('# Intro'), { articleId: 'full' }) as Doc;
75
+ const enriched = {
76
+ ...source,
77
+ frontmatter: { owner: 'team' },
78
+ customThemes: [{ id: 'custom-theme' }],
79
+ persistentLayers: { layers: [] },
80
+ documentMedia: [{ id: 'bed', kind: 'audio', src: 'bed.mp3', startTime: 0 }],
81
+ audio: {
82
+ segments: [{ src: 'narration.mp3', name: 'narration', duration: 4, startTime: 0 }],
83
+ },
84
+ } as unknown as Doc;
85
+ const preview = buildPreviewDoc(enriched);
86
+ expect(preview.frontmatter).toEqual({ owner: 'team' });
87
+ expect(preview.customThemes).toBe(enriched.customThemes);
88
+ expect(preview.persistentLayers).toBe(enriched.persistentLayers);
89
+ expect(preview.documentMedia).toBe(enriched.documentMedia);
90
+ expect(preview.audio).toBe(enriched.audio);
91
+ });
73
92
  });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { render, screen, waitFor } from '@testing-library/react';
6
+ import type { ImageEditDoc } from '@bendyline/squisq/schemas';
7
+ import { CanvasSurface } from '../imageEditor/CanvasSurface.js';
8
+ import type { ImageEditorAction } from '../imageEditor/state.js';
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ describe('CanvasSurface text editing', () => {
15
+ it('uses one tightly measured edit box and applies SVG zoom only once', async () => {
16
+ const context = {
17
+ font: '',
18
+ measureText: (text: string) => ({ width: text.length * 10 }),
19
+ } as unknown as CanvasRenderingContext2D;
20
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => context);
21
+
22
+ const doc: ImageEditDoc = {
23
+ version: 1,
24
+ canvas: { width: 640, height: 480 },
25
+ layers: [
26
+ {
27
+ id: 'text-1',
28
+ type: 'text',
29
+ name: 'Text',
30
+ position: { x: 100, y: 80, width: 240, height: 48 },
31
+ content: {
32
+ text: 'hello',
33
+ style: { fontSize: 20, lineHeight: 1.4, color: '#111111' },
34
+ },
35
+ },
36
+ ],
37
+ };
38
+
39
+ const { container } = render(
40
+ <CanvasSurface
41
+ doc={doc}
42
+ selectedLayerId="text-1"
43
+ tool="select"
44
+ resolveAssetUrl={async (path) => path}
45
+ dispatch={vi.fn<(action: ImageEditorAction) => void>()}
46
+ requestEditLayerId="text-1"
47
+ zoom={2}
48
+ />,
49
+ );
50
+
51
+ const textarea = (await screen.findByRole('textbox')) as HTMLTextAreaElement;
52
+ const editBox = container.querySelector('foreignObject');
53
+
54
+ expect(editBox?.getAttribute('width')).toBe('50');
55
+ expect(textarea.style.fontSize).toBe('20px');
56
+ await waitFor(() => {
57
+ expect(container.querySelector('.squisq-image-editor-selection-handles')).toBeNull();
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ vi.mock('../EditorContext', () => ({
8
+ useEditorContext: () => ({
9
+ colorScheme: 'dark',
10
+ mediaProvider: null,
11
+ doc: null,
12
+ markdownSource: '',
13
+ setMarkdownSource: vi.fn(),
14
+ }),
15
+ }));
16
+
17
+ // The regression is at the manager's portaled theme boundary; the designer's
18
+ // interaction surface is unrelated and expensive to mount for this assertion.
19
+ vi.mock('../customTemplates/TemplateDesigner', () => ({
20
+ TemplateDesigner: () => <div data-testid="template-designer" />,
21
+ }));
22
+
23
+ import { CustomLayoutManager } from '../customTemplates/CustomLayoutManager';
24
+
25
+ describe('CustomLayoutManager theme propagation', () => {
26
+ it('copies the editor dark scheme onto its portaled theme scope', () => {
27
+ render(<CustomLayoutManager onClose={vi.fn()} />);
28
+
29
+ const dialog = screen.getByRole('dialog', { name: 'Custom layouts' });
30
+ expect(dialog.getAttribute('data-theme')).toBe('dark');
31
+ expect(dialog.classList.contains('squisq-editor-shell')).toBe(true);
32
+ expect(screen.getByTestId('template-designer')).toBeTruthy();
33
+ });
34
+ });
@@ -26,7 +26,7 @@ function pickTheme(name: string) {
26
26
  // Scope the option lookup to the popover — the Transform <select> in
27
27
  // the same dialog also exposes role="option" entries (one of which is
28
28
  // literally "Documentary"), so a global query would collide.
29
- const popover = document.getElementById('squisq-theme-picker-popover')!;
29
+ const popover = document.querySelector<HTMLElement>('.squisq-theme-picker-popover')!;
30
30
  fireEvent.click(within(popover).getByRole('option', { name }));
31
31
  }
32
32
 
@@ -99,6 +99,20 @@ describe('DocumentSettingsDialog', () => {
99
99
  expect(next).not.toMatch(/^theme:/m);
100
100
  });
101
101
 
102
+ it('reads themeId and canonicalizes both legacy theme keys on save', () => {
103
+ const onSave = vi.fn();
104
+ const src = '---\nthemeId: bold\ntheme: cinematic\n---\n\n# Doc\n';
105
+ open(src, onSave);
106
+
107
+ expect(screen.getByLabelText('Theme').textContent).toContain('Bold');
108
+ clickSave();
109
+
110
+ const next = onSave.mock.calls[0][0] as string;
111
+ expect(next).toContain('squisq-theme: bold');
112
+ expect(next).not.toMatch(/^themeId:/m);
113
+ expect(next).not.toMatch(/^theme:/m);
114
+ });
115
+
102
116
  it('removes squisq-theme when reverting to default', () => {
103
117
  const onSave = vi.fn();
104
118
  const src = '---\nsquisq-theme: documentary\n---\n\n# Doc\n';
@@ -122,6 +136,20 @@ describe('DocumentSettingsDialog', () => {
122
136
  expect(next).toContain(`squisq-transform: ${first.value}`);
123
137
  });
124
138
 
139
+ it('canonicalizes a stored dataDriven transform id', () => {
140
+ const onSave = vi.fn();
141
+ open('---\ntransform-style: dataDriven\n---\n\n# Doc\n', onSave);
142
+
143
+ const select = screen.getByLabelText('Transform') as HTMLSelectElement;
144
+ expect(select.value).toBe('data-driven');
145
+ clickSave();
146
+
147
+ const next = onSave.mock.calls[0][0] as string;
148
+ expect(next).toContain('squisq-transform: data-driven');
149
+ expect(next).not.toContain('dataDriven');
150
+ expect(next).not.toContain('transform-style');
151
+ });
152
+
125
153
  it('writes squisq-captions when picked', () => {
126
154
  const onSave = vi.fn();
127
155
  open('# Doc\n', onSave);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act, cleanup, render } from '@testing-library/react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { EditorProvider, useEditorContext, type EditorContextValue } from '../EditorContext';
7
+
8
+ let currentContext: EditorContextValue | null = null;
9
+ let animationFrames: FrameRequestCallback[] = [];
10
+
11
+ function ContextProbe() {
12
+ currentContext = useEditorContext();
13
+ return null;
14
+ }
15
+
16
+ function context(): EditorContextValue {
17
+ if (!currentContext) throw new Error('EditorContext has not mounted');
18
+ return currentContext;
19
+ }
20
+
21
+ function wysiwygEditor(
22
+ scrollTop: number,
23
+ scrollHeight: number,
24
+ clientHeight: number,
25
+ ): { editor: unknown; container: HTMLElement } {
26
+ const container = document.createElement('div');
27
+ container.className = 'squisq-wysiwyg-container';
28
+ Object.defineProperties(container, {
29
+ scrollTop: { value: scrollTop, writable: true },
30
+ scrollHeight: { value: scrollHeight },
31
+ clientHeight: { value: clientHeight },
32
+ });
33
+ const dom = document.createElement('div');
34
+ container.append(dom);
35
+ return { editor: { view: { dom } }, container };
36
+ }
37
+
38
+ function flushAnimationFrame(): void {
39
+ const callback = animationFrames.shift();
40
+ if (callback) act(() => callback(0));
41
+ }
42
+
43
+ beforeEach(() => {
44
+ currentContext = null;
45
+ animationFrames = [];
46
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
47
+ animationFrames.push(callback);
48
+ return animationFrames.length;
49
+ });
50
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
51
+ });
52
+
53
+ afterEach(() => {
54
+ cleanup();
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ describe('EditorProvider scroll handoff', () => {
59
+ it('restores proportional positions from Write to Source and back', () => {
60
+ render(
61
+ <EditorProvider initialMarkdown="# One\n\nBody" initialView="wysiwyg">
62
+ <ContextProbe />
63
+ </EditorProvider>,
64
+ );
65
+
66
+ const write = wysiwygEditor(300, 1100, 100);
67
+ act(() => context().setTiptapEditor(write.editor as never));
68
+
69
+ act(() => {
70
+ context().setActiveView('raw');
71
+ context().setTiptapEditor(null);
72
+ });
73
+
74
+ let sourceScrollTop = 1500;
75
+ const setSourceScrollTop = vi.fn();
76
+ const source = {
77
+ getScrollTop: () => sourceScrollTop,
78
+ getScrollHeight: () => 2100,
79
+ getLayoutInfo: () => ({ height: 100 }),
80
+ setScrollTop: setSourceScrollTop,
81
+ };
82
+ act(() => context().setMonacoEditor(source as never));
83
+ flushAnimationFrame();
84
+
85
+ // Write was 30% through its scrollable range, so Source lands at 30%.
86
+ expect(setSourceScrollTop).toHaveBeenCalledWith(600);
87
+
88
+ // A real Monaco instance reports the new position after setScrollTop.
89
+ sourceScrollTop = 1500;
90
+ act(() => {
91
+ context().setActiveView('wysiwyg');
92
+ context().setMonacoEditor(null);
93
+ });
94
+
95
+ const nextWrite = wysiwygEditor(0, 1300, 100);
96
+ act(() => context().setTiptapEditor(nextWrite.editor as never));
97
+ flushAnimationFrame();
98
+
99
+ // Source was 75% through; the newly mounted Write surface lands at 75%.
100
+ expect(nextWrite.container.scrollTop).toBe(900);
101
+ });
102
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ readMonacoScrollRatio,
4
+ readWysiwygScrollRatio,
5
+ restoreMonacoScrollRatio,
6
+ restoreWysiwygScrollRatio,
7
+ scrollRatio,
8
+ scrollTopForRatio,
9
+ } from '../editorScrollSync';
10
+
11
+ function scrollElement(scrollTop: number, scrollHeight: number, clientHeight: number): HTMLElement {
12
+ const container = document.createElement('div');
13
+ container.className = 'squisq-wysiwyg-container';
14
+ Object.defineProperties(container, {
15
+ scrollTop: { value: scrollTop, writable: true },
16
+ scrollHeight: { value: scrollHeight },
17
+ clientHeight: { value: clientHeight },
18
+ });
19
+ return container;
20
+ }
21
+
22
+ describe('editor scroll synchronization', () => {
23
+ it('converts pixel offsets to a normalized position and back', () => {
24
+ expect(scrollRatio(450, 1000, 100)).toBe(0.5);
25
+ expect(scrollTopForRatio(0.5, 2100, 100)).toBe(1000);
26
+ });
27
+
28
+ it('clamps positions and handles documents that do not scroll', () => {
29
+ expect(scrollRatio(-20, 1000, 100)).toBe(0);
30
+ expect(scrollRatio(1200, 1000, 100)).toBe(1);
31
+ expect(scrollRatio(20, 100, 100)).toBe(0);
32
+ expect(scrollTopForRatio(Number.NaN, 1000, 100)).toBe(0);
33
+ });
34
+
35
+ it('reads and restores Monaco using its scrollable height', () => {
36
+ const setScrollTop = vi.fn();
37
+ const editor = {
38
+ getScrollTop: () => 450,
39
+ getScrollHeight: () => 1000,
40
+ getLayoutInfo: () => ({ height: 100 }),
41
+ setScrollTop,
42
+ };
43
+
44
+ expect(readMonacoScrollRatio(editor)).toBe(0.5);
45
+ restoreMonacoScrollRatio(editor, 0.25);
46
+ expect(setScrollTop).toHaveBeenCalledWith(225);
47
+ });
48
+
49
+ it('reads and restores the WYSIWYG scroll container', () => {
50
+ const container = scrollElement(300, 1100, 100);
51
+ const editorDom = document.createElement('div');
52
+ container.append(editorDom);
53
+ const editor = { view: { dom: editorDom } };
54
+
55
+ expect(readWysiwygScrollRatio(editor)).toBe(0.3);
56
+ expect(restoreWysiwygScrollRatio(editor, 0.8)).toBe(true);
57
+ expect(container.scrollTop).toBe(800);
58
+ });
59
+
60
+ it('declines WYSIWYG restoration when the editor is detached', () => {
61
+ const editor = { view: { dom: document.createElement('div') } };
62
+ expect(readWysiwygScrollRatio(editor)).toBeNull();
63
+ expect(restoreWysiwygScrollRatio(editor, 0.5)).toBe(false);
64
+ });
65
+ });