@bendyline/squisq-editor-react 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -2,7 +2,8 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
6
+ import { compileTheme } from '@bendyline/squisq/schemas';
6
7
  import { EditorProvider, useEditorContext } from '../EditorContext';
7
8
  import {
8
9
  PreviewModeSwitch,
@@ -10,10 +11,25 @@ import {
10
11
  PreviewToolbarControls,
11
12
  usePreviewSettings,
12
13
  } from '../PreviewControls';
14
+ import { Toolbar } from '../Toolbar';
15
+ import {
16
+ clearThemeLibrary,
17
+ CustomThemeProvider,
18
+ saveLibraryTheme,
19
+ useDocCustomThemes,
20
+ } from '../customThemes';
13
21
 
14
22
  function ModeProbe() {
15
- const { activeDisplayMode } = usePreviewSettings();
16
- return <div data-testid="active-mode">{activeDisplayMode}</div>;
23
+ const { activeDisplayMode, activeThemeId, activeTransformStyle } = usePreviewSettings();
24
+ return (
25
+ <div
26
+ data-testid="active-mode"
27
+ data-theme-id={activeThemeId}
28
+ data-transform-style={activeTransformStyle}
29
+ >
30
+ {activeDisplayMode}
31
+ </div>
32
+ );
17
33
  }
18
34
 
19
35
  function PreviewHarness() {
@@ -35,6 +51,112 @@ function PreviewToolbarHarness() {
35
51
  );
36
52
  }
37
53
 
54
+ function UseTabHarness() {
55
+ const { activeView, doc } = useEditorContext();
56
+ return (
57
+ <PreviewSettingsProvider doc={doc}>
58
+ <Toolbar />
59
+ <ModeProbe />
60
+ <div data-testid="active-view">{activeView}</div>
61
+ </PreviewSettingsProvider>
62
+ );
63
+ }
64
+
65
+ function LibraryThemeProbe() {
66
+ const { activeTheme, setSelectedThemeId } = usePreviewSettings();
67
+ const { markdownSource } = useEditorContext();
68
+ return (
69
+ <>
70
+ <button type="button" onClick={() => setSelectedThemeId('library-theme')}>
71
+ Select library theme
72
+ </button>
73
+ <div data-testid="active-theme-name">{activeTheme.name}</div>
74
+ <pre data-testid="markdown-source">{markdownSource}</pre>
75
+ </>
76
+ );
77
+ }
78
+
79
+ function LibraryThemeHarness() {
80
+ const { doc } = useEditorContext();
81
+ const { docThemes, onDocThemesChange } = useDocCustomThemes();
82
+ return (
83
+ <CustomThemeProvider docThemes={docThemes} onDocThemesChange={onDocThemesChange}>
84
+ <PreviewSettingsProvider doc={doc}>
85
+ <LibraryThemeProbe />
86
+ </PreviewSettingsProvider>
87
+ </CustomThemeProvider>
88
+ );
89
+ }
90
+
91
+ function CoverSlideProbe() {
92
+ const { activeCoverSlide, setCoverSlideEnabled } = usePreviewSettings();
93
+ const { markdownSource } = useEditorContext();
94
+ return (
95
+ <>
96
+ <button type="button" onClick={() => setCoverSlideEnabled(false)}>
97
+ Hide cover slide
98
+ </button>
99
+ <button type="button" onClick={() => setCoverSlideEnabled(true)}>
100
+ Use default cover slide
101
+ </button>
102
+ <div data-testid="active-cover-slide">{String(activeCoverSlide)}</div>
103
+ <pre data-testid="markdown-source">{markdownSource}</pre>
104
+ </>
105
+ );
106
+ }
107
+
108
+ function CoverSlideHarness() {
109
+ const { doc } = useEditorContext();
110
+ return (
111
+ <PreviewSettingsProvider doc={doc}>
112
+ <CoverSlideProbe />
113
+ </PreviewSettingsProvider>
114
+ );
115
+ }
116
+
117
+ function ManagedDefaultsProbe() {
118
+ const {
119
+ activeThemeId,
120
+ activeTransformStyle,
121
+ activeCaptionStyle,
122
+ activeCaptionsEnabled,
123
+ setSelectedThemeId,
124
+ setSelectedTransformStyle,
125
+ setCaptionMode,
126
+ } = usePreviewSettings();
127
+ const { markdownSource } = useEditorContext();
128
+ return (
129
+ <>
130
+ <button type="button" onClick={() => setSelectedThemeId('standard')}>
131
+ Use default theme
132
+ </button>
133
+ <button type="button" onClick={() => setSelectedTransformStyle('')}>
134
+ Use default transform
135
+ </button>
136
+ <button type="button" onClick={() => setCaptionMode('standard')}>
137
+ Use default captions
138
+ </button>
139
+ <div
140
+ data-testid="managed-defaults"
141
+ data-theme={activeThemeId}
142
+ data-transform={activeTransformStyle}
143
+ data-caption-style={activeCaptionStyle}
144
+ data-captions-enabled={String(activeCaptionsEnabled)}
145
+ />
146
+ <pre data-testid="markdown-source">{markdownSource}</pre>
147
+ </>
148
+ );
149
+ }
150
+
151
+ function ManagedDefaultsHarness() {
152
+ const { doc } = useEditorContext();
153
+ return (
154
+ <PreviewSettingsProvider doc={doc}>
155
+ <ManagedDefaultsProbe />
156
+ </PreviewSettingsProvider>
157
+ );
158
+ }
159
+
38
160
  function renderPreviewControls(markdown: string) {
39
161
  render(
40
162
  <EditorProvider initialMarkdown={markdown}>
@@ -51,7 +173,10 @@ function renderPreviewToolbar(markdown: string) {
51
173
  );
52
174
  }
53
175
 
54
- afterEach(() => cleanup());
176
+ afterEach(() => {
177
+ cleanup();
178
+ clearThemeLibrary();
179
+ });
55
180
 
56
181
  describe('PreviewModeSwitch', () => {
57
182
  it('labels the plain document preview as Document and the styled view as Page', () => {
@@ -62,7 +187,8 @@ describe('PreviewModeSwitch', () => {
62
187
  .map((button) => button.textContent)
63
188
  .filter(Boolean);
64
189
 
65
- expect(labels).toEqual(['Video', 'Slideshow', 'Page', 'Document']);
190
+ expect(labels).toEqual(['Slideshow', 'Video', 'Page', 'Document', 'Narrate']);
191
+ expect(screen.getByTestId('active-mode').textContent).toBe('slideshow');
66
192
 
67
193
  fireEvent.click(screen.getByRole('button', { name: 'Document' }));
68
194
  expect(screen.getByTestId('active-mode').textContent).toBe('page');
@@ -89,9 +215,178 @@ describe('PreviewModeSwitch', () => {
89
215
  });
90
216
  expect(screen.getByRole('button', { name: 'Page' }).getAttribute('aria-pressed')).toBe('true');
91
217
  });
218
+
219
+ it('uses the shared squisq-theme, themeId, theme fallback order', async () => {
220
+ renderPreviewControls('---\nthemeId: bold\ntheme: cinematic\n---\n\n# Hello');
221
+
222
+ await waitFor(() => {
223
+ expect(screen.getByTestId('active-mode').getAttribute('data-theme-id')).toBe('bold');
224
+ });
225
+ });
226
+
227
+ it('canonicalizes the stored dataDriven transform id', async () => {
228
+ renderPreviewControls('---\nsquisq-transform: dataDriven\n---\n\n# Hello');
229
+
230
+ await waitFor(() => {
231
+ expect(screen.getByTestId('active-mode').getAttribute('data-transform-style')).toBe(
232
+ 'data-driven',
233
+ );
234
+ });
235
+ });
236
+
237
+ it('copies a selected browser-library theme into the document atomically', async () => {
238
+ saveLibraryTheme(
239
+ compileTheme({
240
+ id: 'library-theme',
241
+ name: 'Library Theme',
242
+ seedColors: { primary: '#456789' },
243
+ }),
244
+ );
245
+ render(
246
+ <EditorProvider initialMarkdown="# Hello">
247
+ <LibraryThemeHarness />
248
+ </EditorProvider>,
249
+ );
250
+
251
+ fireEvent.click(screen.getByRole('button', { name: 'Select library theme' }));
252
+
253
+ await waitFor(() => {
254
+ expect(screen.getByTestId('active-theme-name').textContent).toBe('Library Theme');
255
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
256
+ expect(source).toContain('squisq-theme: library-theme');
257
+ expect(source).toContain('squisq-custom-themes:');
258
+ expect(source).toContain('library-theme');
259
+ });
260
+ });
261
+ });
262
+
263
+ describe('cover-slide frontmatter', () => {
264
+ it('writes the non-default as a boolean and removes the default value', async () => {
265
+ render(
266
+ <EditorProvider initialMarkdown="# Hello">
267
+ <CoverSlideHarness />
268
+ </EditorProvider>,
269
+ );
270
+
271
+ expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
272
+ fireEvent.click(screen.getByRole('button', { name: 'Hide cover slide' }));
273
+
274
+ await waitFor(() => {
275
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
276
+ expect(source).toContain('squisq-cover-slide: false');
277
+ expect(source).not.toContain('squisq-cover-slide: "false"');
278
+ });
279
+ expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
280
+
281
+ fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
282
+
283
+ await waitFor(() => {
284
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
285
+ expect(source).not.toContain('squisq-cover-slide');
286
+ expect(source).not.toContain('---');
287
+ });
288
+ expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
289
+ });
290
+
291
+ it('removes a legacy cover-slide override when restoring the default', async () => {
292
+ render(
293
+ <EditorProvider initialMarkdown={'---\ncover-slide: false\ntitle: Hello\n---\n\n# Hello'}>
294
+ <CoverSlideHarness />
295
+ </EditorProvider>,
296
+ );
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
300
+ });
301
+ fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
302
+
303
+ await waitFor(() => {
304
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
305
+ expect(source).not.toContain('cover-slide');
306
+ expect(source).toContain('title: Hello');
307
+ });
308
+ expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
309
+ });
310
+ });
311
+
312
+ describe('managed preview-setting defaults', () => {
313
+ it('removes default theme, transform, and caption values plus their legacy aliases', async () => {
314
+ const markdown = `---
315
+ squisq-theme: documentary
316
+ themeId: bold
317
+ theme: cinematic
318
+ squisq-transform: documentary
319
+ transform-style: magazine
320
+ squisq-captions: social
321
+ caption-style: off
322
+ title: Hello
323
+ ---
324
+
325
+ # Hello`;
326
+ render(
327
+ <EditorProvider initialMarkdown={markdown}>
328
+ <ManagedDefaultsHarness />
329
+ </EditorProvider>,
330
+ );
331
+
332
+ fireEvent.click(screen.getByRole('button', { name: 'Use default theme' }));
333
+ await waitFor(() => {
334
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
335
+ expect(source).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
336
+ });
337
+ expect(screen.getByTestId('managed-defaults').getAttribute('data-theme')).toBe('standard');
338
+
339
+ fireEvent.click(screen.getByRole('button', { name: 'Use default transform' }));
340
+ await waitFor(() => {
341
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
342
+ expect(source).not.toMatch(/^(?:squisq-transform|transform-style):/m);
343
+ });
344
+ expect(screen.getByTestId('managed-defaults').getAttribute('data-transform')).toBe('');
345
+
346
+ fireEvent.click(screen.getByRole('button', { name: 'Use default captions' }));
347
+ await waitFor(() => {
348
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
349
+ expect(source).not.toMatch(/^(?:squisq-captions|caption-style):/m);
350
+ expect(source).toContain('title: Hello');
351
+ });
352
+ const defaults = screen.getByTestId('managed-defaults');
353
+ expect(defaults.getAttribute('data-caption-style')).toBe('standard');
354
+ expect(defaults.getAttribute('data-captions-enabled')).toBe('true');
355
+ });
92
356
  });
93
357
 
94
358
  describe('PreviewToolbarControls', () => {
359
+ it('presents transforms as summarization without implying the source is changed', () => {
360
+ const originalResizeObserver = globalThis.ResizeObserver;
361
+ class ResizeObserverStub implements ResizeObserver {
362
+ observe() {}
363
+ unobserve() {}
364
+ disconnect() {}
365
+ }
366
+
367
+ globalThis.ResizeObserver = ResizeObserverStub;
368
+ try {
369
+ renderPreviewToolbar('# Hello');
370
+
371
+ const summarizeLabels = screen.getAllByText('Summarize:');
372
+ expect(summarizeLabels.length).toBeGreaterThan(0);
373
+ for (const label of summarizeLabels) {
374
+ expect(label.getAttribute('title')).toBe(
375
+ 'Extract and summarize content for presentation with these Use modes. Your underlying content is not changed.',
376
+ );
377
+ }
378
+ expect(screen.queryByText('Transform:')).toBeNull();
379
+ expect(document.querySelector('[role="group"][aria-label="Display mode"]')).toBeNull();
380
+ expect(document.querySelector('[role="group"][aria-label="Aspect ratio"]')).not.toBeNull();
381
+ } finally {
382
+ if (originalResizeObserver) {
383
+ globalThis.ResizeObserver = originalResizeObserver;
384
+ } else {
385
+ Reflect.deleteProperty(globalThis, 'ResizeObserver');
386
+ }
387
+ }
388
+ });
389
+
95
390
  it('keeps the overflow popover inside the left viewport edge', async () => {
96
391
  const originalResizeObserver = globalThis.ResizeObserver;
97
392
  const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -161,3 +456,85 @@ describe('PreviewToolbarControls', () => {
161
456
  }
162
457
  });
163
458
  });
459
+
460
+ describe('Use tab mode menu', () => {
461
+ it('lists the Use modes beside the tab and selects a mode without a toolbar radio group', () => {
462
+ render(
463
+ <EditorProvider initialMarkdown="# Hello" initialView="wysiwyg" allowRecording={false}>
464
+ <UseTabHarness />
465
+ </EditorProvider>,
466
+ );
467
+
468
+ const useTab = screen.getByRole('tab', { name: 'Slideshow' });
469
+ expect(screen.queryByRole('tab', { name: 'Use' })).toBeNull();
470
+ const tabGroup = useTab.parentElement;
471
+ expect(tabGroup).not.toBeNull();
472
+ expect(within(tabGroup!).getByRole('button', { name: 'Choose Use mode' })).toBeTruthy();
473
+
474
+ // The first click enters Use; once active, clicking the tab again opens
475
+ // the mode menu.
476
+ fireEvent.click(useTab);
477
+ expect(screen.getByTestId('active-view').textContent).toBe('preview');
478
+ expect(screen.queryByRole('menu', { name: 'Use mode' })).toBeNull();
479
+
480
+ fireEvent.click(screen.getByRole('tab', { name: 'Slideshow' }));
481
+
482
+ const menu = screen.getByRole('menu', { name: 'Use mode' });
483
+ expect(
484
+ within(menu)
485
+ .getAllByRole('menuitemradio')
486
+ .map((item) => item.querySelector('.squisq-use-mode-menu-label')?.textContent),
487
+ ).toEqual(['Slideshow', 'Video', 'Page', 'Document', 'Narrate']);
488
+ const slideshowItem = within(menu).getByRole('menuitemradio', { name: 'Slideshow' });
489
+ expect(slideshowItem.getAttribute('aria-checked')).toBe('true');
490
+ expect(within(slideshowItem).getByText('Present designed slides one at a time.')).toBeTruthy();
491
+
492
+ fireEvent.click(within(menu).getByRole('menuitemradio', { name: 'Document' }));
493
+ expect(screen.getByTestId('active-mode').textContent).toBe('page');
494
+ expect(screen.getByTestId('active-view').textContent).toBe('preview');
495
+ expect(screen.getByRole('tab', { name: 'Document' })).toBeTruthy();
496
+ expect(screen.queryByRole('menu', { name: 'Use mode' })).toBeNull();
497
+ });
498
+ });
499
+
500
+ describe('Narrate mode gating', () => {
501
+ function renderWithNarrateGate(markdown: string, allowNarrate: boolean) {
502
+ render(
503
+ <EditorProvider initialMarkdown={markdown} allowNarrate={allowNarrate}>
504
+ <PreviewHarness />
505
+ </EditorProvider>,
506
+ );
507
+ }
508
+
509
+ it('selects narrate from the switch and resolves teleprompter frontmatter aliases', async () => {
510
+ renderPreviewControls('# Hello');
511
+ fireEvent.click(screen.getByRole('button', { name: 'Narrate' }));
512
+ expect(screen.getByTestId('active-mode').textContent).toBe('narrate');
513
+
514
+ cleanup();
515
+ renderPreviewControls('---\ndisplay-mode: teleprompter\n---\n\n# Hello');
516
+ await waitFor(() => {
517
+ expect(screen.getByTestId('active-mode').textContent).toBe('narrate');
518
+ });
519
+ expect(screen.getByRole('button', { name: 'Narrate' }).getAttribute('aria-pressed')).toBe(
520
+ 'true',
521
+ );
522
+ });
523
+
524
+ it('allowNarrate=false hides the button and clamps frontmatter-forced narrate to video', async () => {
525
+ renderWithNarrateGate('---\ndisplay-mode: narrate\n---\n\n# Hello', false);
526
+
527
+ expect(screen.queryByRole('button', { name: 'Narrate' })).toBeNull();
528
+ // Give the frontmatter parse a tick, then confirm the clamp held.
529
+ await waitFor(() => {
530
+ expect(screen.getByTestId('active-mode').textContent).toBe('video');
531
+ });
532
+ });
533
+
534
+ it('allowNarrate=true (default) honors frontmatter narrate', async () => {
535
+ renderWithNarrateGate('---\ndisplay-mode: narrate\n---\n\n# Hello', true);
536
+ await waitFor(() => {
537
+ expect(screen.getByTestId('active-mode').textContent).toBe('narrate');
538
+ });
539
+ });
540
+ });
@@ -0,0 +1,46 @@
1
+ /** @vitest-environment jsdom */
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
5
+
6
+ const resolveAudioMapping = vi.hoisted(() =>
7
+ vi.fn(async () => {
8
+ throw new Error('timing.json is corrupt');
9
+ }),
10
+ );
11
+
12
+ vi.mock('@bendyline/squisq/doc', async (importOriginal) => ({
13
+ ...(await importOriginal<typeof import('@bendyline/squisq/doc')>()),
14
+ resolveAudioMapping,
15
+ }));
16
+
17
+ import { EditorProvider, useEditorContext } from '../EditorContext';
18
+ import { PreviewSettingsProvider } from '../PreviewControls';
19
+ import { PreviewPanel } from '../PreviewPanel';
20
+
21
+ function Harness({ container }: { container: MemoryContentContainer }) {
22
+ const { doc } = useEditorContext();
23
+ return (
24
+ <PreviewSettingsProvider doc={doc}>
25
+ <PreviewPanel workspaceContainer={container} />
26
+ </PreviewSettingsProvider>
27
+ );
28
+ }
29
+
30
+ describe('PreviewPanel optional audio mapping', () => {
31
+ it('keeps the synchronous preview when audio discovery rejects', async () => {
32
+ const load = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
33
+ const container = new MemoryContentContainer();
34
+ const view = render(
35
+ <EditorProvider initialMarkdown="# Preview\n\nBody" initialView="preview">
36
+ <Harness container={container} />
37
+ </EditorProvider>,
38
+ );
39
+ await waitFor(() => expect(resolveAudioMapping).toHaveBeenCalled());
40
+ await waitFor(() => {
41
+ expect(screen.getByTestId('preview-panel').textContent).not.toContain('No content');
42
+ });
43
+ view.unmount();
44
+ load.mockRestore();
45
+ });
46
+ });
@@ -0,0 +1,126 @@
1
+ /** @vitest-environment jsdom */
2
+
3
+ import { useRef } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import type { Doc } from '@bendyline/squisq/schemas';
7
+ import type { DocPlayerProps, PlaybackState } from '@bendyline/squisq-react';
8
+
9
+ vi.mock('@bendyline/squisq-react', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('@bendyline/squisq-react')>();
11
+ const { useEffect } = await import('react');
12
+ const state: PlaybackState = {
13
+ isPlaying: false,
14
+ currentTime: 0,
15
+ totalDuration: 10,
16
+ isCoverVisible: true,
17
+ currentBlockIndex: 0,
18
+ totalBlocks: 2,
19
+ docProgress: 0,
20
+ hasCaptions: false,
21
+ captionsEnabled: false,
22
+ captionMode: 'off',
23
+ currentSegmentIndex: 0,
24
+ currentSegmentName: null,
25
+ currentBlock: null,
26
+ };
27
+ function MockDocPlayer(props: DocPlayerProps & { doc: Doc }) {
28
+ const { onPlaybackStateChange, onControlsReady } = props;
29
+ useEffect(() => {
30
+ if (props.audioController) return;
31
+ onPlaybackStateChange?.(state);
32
+ onControlsReady?.({
33
+ play: () => undefined,
34
+ pause: () => undefined,
35
+ toggle: () => undefined,
36
+ restart: () => undefined,
37
+ seekTo: () => undefined,
38
+ setCaptionsEnabled: () => undefined,
39
+ cycleCaptionMode: () => undefined,
40
+ });
41
+ }, [onControlsReady, onPlaybackStateChange, props.audioController]);
42
+ return (
43
+ <div
44
+ data-testid="mock-doc-player"
45
+ data-article={props.doc.articleId}
46
+ data-audience={props.audioController ? 'true' : 'false'}
47
+ data-controls={props.showControls ? 'true' : 'false'}
48
+ data-cover={String(props.coverVisible)}
49
+ />
50
+ );
51
+ }
52
+ return {
53
+ ...actual,
54
+ useMediaProvider: () => null,
55
+ DocPlayer: MockDocPlayer,
56
+ };
57
+ });
58
+
59
+ import { EditorProvider, useEditorContext } from '../EditorContext';
60
+ import { PreviewSettingsProvider } from '../PreviewControls';
61
+ import { PreviewPanel } from '../PreviewPanel';
62
+ import {
63
+ PresentationModeControl,
64
+ PresentationModeProvider,
65
+ } from '../presentation/PresentationMode';
66
+
67
+ function Harness() {
68
+ const { doc } = useEditorContext();
69
+ const rootRef = useRef<HTMLDivElement>(null);
70
+ return (
71
+ <div ref={rootRef}>
72
+ <PreviewSettingsProvider doc={doc}>
73
+ <PresentationModeProvider rootRef={rootRef}>
74
+ <PresentationModeControl />
75
+ <PreviewPanel />
76
+ </PresentationModeProvider>
77
+ </PreviewSettingsProvider>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ afterEach(() => {
83
+ cleanup();
84
+ vi.restoreAllMocks();
85
+ });
86
+
87
+ describe('PreviewPanel presentation audience', () => {
88
+ it('renders a clockless, controlled audience follower in the popup', async () => {
89
+ const popupDocument = document.implementation.createHTMLDocument('');
90
+ const popupEvents = new EventTarget();
91
+ let closed = false;
92
+ const popup = {
93
+ document: popupDocument,
94
+ get closed() {
95
+ return closed;
96
+ },
97
+ close: vi.fn(() => {
98
+ closed = true;
99
+ }),
100
+ focus: vi.fn(),
101
+ addEventListener: popupEvents.addEventListener.bind(popupEvents),
102
+ removeEventListener: popupEvents.removeEventListener.bind(popupEvents),
103
+ } as unknown as Window;
104
+ vi.spyOn(window, 'open').mockReturnValue(popup);
105
+
106
+ render(
107
+ <EditorProvider initialMarkdown="# Audience mirror\n\nBody" initialView="preview">
108
+ <Harness />
109
+ </EditorProvider>,
110
+ );
111
+
112
+ await waitFor(() => expect(screen.getByTestId('mock-doc-player')).toBeTruthy());
113
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
114
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /New window/ }));
115
+ fireEvent.click(screen.getByRole('button', { name: 'Present: New window' }));
116
+
117
+ await waitFor(() => {
118
+ const audience = popupDocument.querySelector<HTMLElement>(
119
+ '[data-testid="mock-doc-player"][data-audience="true"]',
120
+ );
121
+ expect(audience).toBeTruthy();
122
+ expect(audience?.dataset.controls).toBe('false');
123
+ expect(audience?.dataset.cover).toBe('true');
124
+ });
125
+ });
126
+ });