@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
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
7
+ import { MediaBin } from '../MediaBin.js';
8
+
9
+ function createProvider(): { provider: MediaProvider; added: string[] } {
10
+ const entries: MediaEntry[] = [];
11
+ const added: string[] = [];
12
+ return {
13
+ added,
14
+ provider: {
15
+ async addMedia(name, data, mimeType) {
16
+ const relativePath = `attachments/${name}`;
17
+ added.push(relativePath);
18
+ entries.push({
19
+ name: relativePath,
20
+ mimeType,
21
+ size: data instanceof Blob ? data.size : data.byteLength,
22
+ });
23
+ return relativePath;
24
+ },
25
+ async resolveUrl(path) {
26
+ return `blob:${path}`;
27
+ },
28
+ async listMedia() {
29
+ return [...entries];
30
+ },
31
+ async removeMedia() {},
32
+ dispose() {},
33
+ },
34
+ };
35
+ }
36
+
37
+ function dataTransfer(files: File[], itemTypes: string[]) {
38
+ return {
39
+ files,
40
+ items: itemTypes.map((type) => ({ kind: 'file', type })),
41
+ types: ['Files'],
42
+ dropEffect: 'none',
43
+ };
44
+ }
45
+
46
+ describe('MediaBin image drop target', () => {
47
+ it('shows a drop affordance and uploads a dropped image through the normal callback', async () => {
48
+ const { provider, added } = createProvider();
49
+ const onMediaUploaded = vi.fn();
50
+ const image = new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' });
51
+ Object.defineProperty(image, 'arrayBuffer', {
52
+ configurable: true,
53
+ value: async () => new Uint8Array([1, 2, 3]).buffer,
54
+ });
55
+
56
+ const { container } = render(
57
+ <MediaBin mediaProvider={provider} isDark onMediaUploaded={onMediaUploaded} />,
58
+ );
59
+ const bin = container.querySelector('.squisq-media-bin') as HTMLElement;
60
+ const transfer = dataTransfer([image], ['image/png']);
61
+ await screen.findByText('No files yet.');
62
+
63
+ fireEvent.dragEnter(bin, { dataTransfer: transfer });
64
+ expect(bin.classList.contains('squisq-media-bin--drop-active')).toBe(true);
65
+ expect(screen.getByText('Drop images here')).toBeTruthy();
66
+
67
+ fireEvent.drop(bin, { dataTransfer: transfer });
68
+
69
+ await waitFor(() => expect(added).toEqual(['attachments/photo.png']));
70
+ expect(onMediaUploaded).toHaveBeenCalledWith('attachments/photo.png', 'photo.png', 'image/png');
71
+ expect(await screen.findByText('photo.png')).toBeTruthy();
72
+ expect(screen.queryByText('Drop images here')).toBeNull();
73
+ });
74
+
75
+ it('does not activate or upload for a non-image file', async () => {
76
+ const { provider, added } = createProvider();
77
+ const text = new File(['notes'], 'notes.txt', { type: 'text/plain' });
78
+ const { container } = render(<MediaBin mediaProvider={provider} isDark={false} />);
79
+ const bin = container.querySelector('.squisq-media-bin') as HTMLElement;
80
+ const transfer = dataTransfer([text], ['text/plain']);
81
+ await screen.findByText('No files yet.');
82
+
83
+ fireEvent.dragEnter(bin, { dataTransfer: transfer });
84
+ fireEvent.drop(bin, { dataTransfer: transfer });
85
+
86
+ expect(bin.classList.contains('squisq-media-bin--drop-active')).toBe(false);
87
+ expect(screen.queryByText('Drop images here')).toBeNull();
88
+ await waitFor(() => expect(added).toEqual([]));
89
+ });
90
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it } from 'vitest';
5
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
6
+ import { markdownToDoc } from '@bendyline/squisq/doc';
7
+ import {
8
+ buildNarrationScript,
9
+ parseNarrationTimingJson,
10
+ type NarrationAlignment,
11
+ } from '@bendyline/squisq/narration';
12
+ import {
13
+ insertNarrationPreamble,
14
+ narrationAnnotationLine,
15
+ } from '../teleprompter/recording/insertPreamble';
16
+ import { buildNarrationSavePlan } from '../teleprompter/recording/narrationSave';
17
+
18
+ describe('insertNarrationPreamble', () => {
19
+ it('inserts at the top of a plain document', () => {
20
+ const next = insertNarrationPreamble('# Title\n\nBody.\n', 'audio/narration-1.webm', null);
21
+ expect(next.startsWith('{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title')).toBe(
22
+ true,
23
+ );
24
+ });
25
+
26
+ it('inserts after the frontmatter fence', () => {
27
+ const source = '---\ntitle: Doc\n---\n\n# Title\n\nBody.\n';
28
+ const next = insertNarrationPreamble(source, 'audio/narration-1.webm', null);
29
+ const lines = next.split('\n');
30
+ expect(lines[2]).toBe('---');
31
+ expect(next).toContain(
32
+ '---\n\n{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title',
33
+ );
34
+ });
35
+
36
+ it('replaces a previous take (and its camera line) on retake', () => {
37
+ const withTake = insertNarrationPreamble(
38
+ '# Title\n\nBody.\n',
39
+ 'audio/narration-old.webm',
40
+ 'video/narration-cam-old.webm',
41
+ );
42
+ expect(withTake).toContain('narration-old');
43
+ expect(withTake).toContain('<video src="video/narration-cam-old.webm"');
44
+
45
+ const retaken = insertNarrationPreamble(withTake, 'audio/narration-new.webm', null);
46
+ expect(retaken).not.toContain('narration-old');
47
+ expect(retaken).not.toContain('narration-cam-old');
48
+ expect(retaken).toContain('{[audio src=audio/narration-new.webm anchor=document]}');
49
+ // No duplicate annotations.
50
+ expect(retaken.match(/\{\[audio /g)?.length).toBe(1);
51
+ });
52
+
53
+ it('adds the camera companion as an inline video line', () => {
54
+ const next = insertNarrationPreamble(
55
+ '# Title\n\nBody.\n',
56
+ 'audio/narration-1.webm',
57
+ 'video/narration-cam-1.webm',
58
+ );
59
+ expect(next).toContain('{[audio src=audio/narration-1.webm anchor=document]}');
60
+ expect(next).toContain('<video src="video/narration-cam-1.webm" controls width="240"></video>');
61
+ });
62
+
63
+ it('quotes srcs with spaces', () => {
64
+ expect(narrationAnnotationLine('audio/my take.webm')).toBe(
65
+ '{[audio src="audio/my take.webm" anchor=document]}',
66
+ );
67
+ });
68
+
69
+ it('round-trips through markdownToDoc as documentMedia', () => {
70
+ const next = insertNarrationPreamble(
71
+ '---\ntitle: Doc\n---\n\n# Title\n\nBody.\n',
72
+ 'audio/narration-1.webm',
73
+ null,
74
+ );
75
+ const doc = markdownToDoc(parseMarkdown(next));
76
+ expect(doc.documentMedia?.length).toBe(1);
77
+ expect(doc.documentMedia![0].src).toBe('audio/narration-1.webm');
78
+ expect(doc.documentMedia![0].anchor).toBe('document');
79
+ });
80
+ });
81
+
82
+ describe('buildNarrationSavePlan', () => {
83
+ const script = buildNarrationScript(
84
+ markdownToDoc(parseMarkdown('# One\n\nAlpha beta.\n\n# Two\n\nGamma delta.\n')),
85
+ );
86
+ const alignment: NarrationAlignment = {
87
+ words: script.tokens.map((_, i) => ({ tokenIndex: i, tSec: i * 0.4, interpolated: false })),
88
+ blocks: script.blocks.map((range, i) => ({
89
+ blockId: range.blockId,
90
+ blockIndex: i,
91
+ charStart: range.charStart,
92
+ charEnd: range.charEnd,
93
+ startSec: i * 5,
94
+ endSec: (i + 1) * 5,
95
+ })),
96
+ detectedSyllables: script.totalSyllables,
97
+ cost: 0,
98
+ };
99
+
100
+ it('produces recorder-convention paths and a v3 sidecar the parser accepts', () => {
101
+ const plan = buildNarrationSavePlan({
102
+ script,
103
+ alignment,
104
+ durationSec: 10,
105
+ audioExt: '.webm',
106
+ cameraExt: '.webm',
107
+ baseWpm: 150,
108
+ cameraOffsetSec: 0.02,
109
+ });
110
+ expect(plan.audioRelativeName).toMatch(/^audio\/narration-.*\.webm$/);
111
+ expect(plan.cameraRelativeName).toMatch(/^video\/narration-cam.*\.webm$/);
112
+ expect(plan.sidecarPathFor('audio/renamed.webm')).toBe('audio/renamed.webm.timing.json');
113
+
114
+ const parsed = parseNarrationTimingJson(JSON.stringify(plan.sidecarPayload));
115
+ expect(parsed).not.toBeNull();
116
+ expect(parsed!.blocks.length).toBe(script.blocks.length);
117
+ expect(parsed!.bookmarks.length).toBe(script.tokens.length);
118
+ expect(parsed!.cameraOffsetSec).toBeCloseTo(0.02, 6);
119
+ });
120
+
121
+ it('degrades to an empty-timing sidecar when alignment failed', () => {
122
+ const plan = buildNarrationSavePlan({
123
+ script,
124
+ alignment: null,
125
+ durationSec: 10,
126
+ audioExt: '.webm',
127
+ cameraExt: null,
128
+ baseWpm: 150,
129
+ });
130
+ expect(plan.cameraRelativeName).toBeNull();
131
+ expect(plan.sidecarPayload.blocks).toEqual([]);
132
+ expect(plan.sidecarPayload.bookmarks).toEqual([]);
133
+ expect(plan.sidecarPayload.sourceText).toBe(script.sourceText);
134
+ });
135
+
136
+ it('nextMarkdown composes the preamble from one snapshot', () => {
137
+ const plan = buildNarrationSavePlan({
138
+ script,
139
+ alignment,
140
+ durationSec: 10,
141
+ audioExt: '.webm',
142
+ cameraExt: null,
143
+ baseWpm: 150,
144
+ });
145
+ const next = plan.nextMarkdown('# One\n\nAlpha beta.\n', 'audio/narration-x.webm', null);
146
+ expect(next).toContain('{[audio src=audio/narration-x.webm anchor=document]}');
147
+ });
148
+ });
@@ -1,6 +1,6 @@
1
- import { describe, expect, it, beforeAll } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
3
- import { EditorProvider } from '../EditorContext';
1
+ import { describe, expect, it, beforeAll, vi } from 'vitest';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { EditorProvider, useEditorContext } from '../EditorContext';
4
4
  import { OutlinePanel } from '../OutlinePanel';
5
5
 
6
6
  // jsdom lacks ResizeObserver — the pane wires one up to track the editor's
@@ -23,6 +23,33 @@ function renderOutline(markdown: string) {
23
23
  );
24
24
  }
25
25
 
26
+ function SourceProbe() {
27
+ const { markdownSource } = useEditorContext();
28
+ return <output data-testid="markdown-source">{markdownSource}</output>;
29
+ }
30
+
31
+ function renderOutlineWithSource(markdown: string, readOnly = false) {
32
+ return render(
33
+ <EditorProvider initialMarkdown={markdown} initialView="wysiwyg" articleId="test">
34
+ <OutlinePanel readOnly={readOnly} />
35
+ <SourceProbe />
36
+ </EditorProvider>,
37
+ );
38
+ }
39
+
40
+ function makeDataTransfer(): DataTransfer {
41
+ const values = new Map<string, string>();
42
+ return {
43
+ effectAllowed: 'uninitialized',
44
+ dropEffect: 'none',
45
+ setData: vi.fn((type: string, value: string) => values.set(type, value)),
46
+ getData: vi.fn((type: string) => values.get(type) ?? ''),
47
+ get types() {
48
+ return [...values.keys()];
49
+ },
50
+ } as unknown as DataTransfer;
51
+ }
52
+
26
53
  describe('OutlinePanel', () => {
27
54
  it('renders the empty placeholder when the doc has no headings', async () => {
28
55
  renderOutline('Just a paragraph, no headings.\n');
@@ -76,4 +103,67 @@ describe('OutlinePanel', () => {
76
103
  expect(chip).not.toBeNull();
77
104
  expect(chip!.textContent).toContain('Title');
78
105
  });
106
+
107
+ it('drags a section after a sibling and rewrites the underlying markdown', async () => {
108
+ const source = '# Alpha\n\nalpha\n\n# Bravo\n\nbravo\n';
109
+ const expected = '# Bravo\n\nbravo\n\n# Alpha\n\nalpha\n';
110
+ const { container } = renderOutlineWithSource(source);
111
+ const alpha = await screen.findByRole('button', { name: 'Alpha' });
112
+ const bravo = await screen.findByRole('button', { name: 'Bravo' });
113
+ const bravoWrap = bravo.closest<HTMLElement>('.squisq-outline-row-wrap');
114
+ expect(bravoWrap).not.toBeNull();
115
+ vi.spyOn(bravoWrap!, 'getBoundingClientRect').mockReturnValue({
116
+ x: 0,
117
+ y: 0,
118
+ top: 0,
119
+ right: 200,
120
+ bottom: 20,
121
+ left: 0,
122
+ width: 200,
123
+ height: 20,
124
+ toJSON: () => ({}),
125
+ });
126
+ const transfer = makeDataTransfer();
127
+
128
+ fireEvent.dragStart(alpha, { dataTransfer: transfer });
129
+ expect(transfer.effectAllowed).toBe('move');
130
+ expect(transfer.setData).toHaveBeenCalledWith('text/plain', expect.any(String));
131
+
132
+ fireEvent.dragOver(bravoWrap!, { dataTransfer: transfer, clientY: 18 });
133
+ expect(transfer.dropEffect).toBe('move');
134
+ expect(bravo.closest('.squisq-outline-item')?.classList).toContain(
135
+ 'squisq-outline-item--drop-after',
136
+ );
137
+
138
+ fireEvent.drop(bravoWrap!, { dataTransfer: transfer, clientY: 18 });
139
+ expect(container.querySelector('.squisq-outline-item--drop-after')).toBeNull();
140
+
141
+ await waitFor(() => {
142
+ expect(screen.getByTestId('markdown-source').textContent).toBe(expected);
143
+ const labels = Array.from(container.querySelectorAll('.squisq-outline-row-text')).map(
144
+ (row) => row.textContent,
145
+ );
146
+ expect(labels).toEqual(['Bravo', 'Alpha']);
147
+ });
148
+ });
149
+
150
+ it('disables outline mutations in read-only mode', async () => {
151
+ const source = '# Alpha\n\nalpha\n\n# Bravo\n\nbravo\n';
152
+ renderOutlineWithSource(source, true);
153
+ const alpha = await screen.findByRole('button', { name: 'Alpha' });
154
+ const bravo = await screen.findByRole('button', { name: 'Bravo' });
155
+ const bravoWrap = bravo.closest<HTMLElement>('.squisq-outline-row-wrap');
156
+ const transfer = makeDataTransfer();
157
+
158
+ expect(alpha.getAttribute('draggable')).toBe('false');
159
+ expect(
160
+ screen
161
+ .getAllByRole('button', { name: /demote heading \(currently h1\)/i })[0]
162
+ .hasAttribute('disabled'),
163
+ ).toBe(true);
164
+ fireEvent.dragStart(alpha, { dataTransfer: transfer });
165
+ fireEvent.drop(bravoWrap!, { dataTransfer: transfer, clientY: 100 });
166
+
167
+ expect(screen.getByTestId('markdown-source').textContent).toBe(source);
168
+ });
79
169
  });
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { moveHeadingSectionInSource } from '../outlineSource';
3
+
4
+ function lineOf(source: string, heading: string): number {
5
+ const index = source.indexOf(heading);
6
+ if (index < 0) throw new Error(`Missing heading: ${heading}`);
7
+ return source.slice(0, index).split(/\r\n|\r|\n/).length;
8
+ }
9
+
10
+ describe('moveHeadingSectionInSource', () => {
11
+ it('moves a parent section with its body and nested subsections', () => {
12
+ const source = [
13
+ '# Document',
14
+ '',
15
+ 'Intro.',
16
+ '',
17
+ '## First',
18
+ '',
19
+ 'First body.',
20
+ '',
21
+ '### First child',
22
+ '',
23
+ 'Child body.',
24
+ '',
25
+ '## Second',
26
+ '',
27
+ 'Second body.',
28
+ '',
29
+ '### Second child',
30
+ '',
31
+ 'Second child body.',
32
+ '',
33
+ '## Third',
34
+ '',
35
+ 'Third body.',
36
+ '',
37
+ ].join('\n');
38
+
39
+ const next = moveHeadingSectionInSource(
40
+ source,
41
+ lineOf(source, '## First'),
42
+ lineOf(source, '## Second'),
43
+ 'after',
44
+ );
45
+
46
+ expect(next).toBe(
47
+ [
48
+ '# Document',
49
+ '',
50
+ 'Intro.',
51
+ '',
52
+ '## Second',
53
+ '',
54
+ 'Second body.',
55
+ '',
56
+ '### Second child',
57
+ '',
58
+ 'Second child body.',
59
+ '',
60
+ '## First',
61
+ '',
62
+ 'First body.',
63
+ '',
64
+ '### First child',
65
+ '',
66
+ 'Child body.',
67
+ '',
68
+ '## Third',
69
+ '',
70
+ 'Third body.',
71
+ '',
72
+ ].join('\n'),
73
+ );
74
+ });
75
+
76
+ it('moves a later section before an earlier sibling', () => {
77
+ const source = '# A\n\na\n\n# B\n\nb\n\n# C\n\nc\n';
78
+ expect(
79
+ moveHeadingSectionInSource(source, lineOf(source, '# C'), lineOf(source, '# A'), 'before'),
80
+ ).toBe('# C\n\nc\n\n# A\n\na\n\n# B\n\nb\n');
81
+ });
82
+
83
+ it('preserves CRLF and the absence of a final newline', () => {
84
+ const source = '# A\r\n\r\na\r\n\r\n# B\r\n\r\nb';
85
+ expect(
86
+ moveHeadingSectionInSource(source, lineOf(source, '# B'), lineOf(source, '# A'), 'before'),
87
+ ).toBe('# B\r\n\r\nb\r\n\r\n# A\r\n\r\na');
88
+ });
89
+
90
+ it('leaves frontmatter and preamble bytes in place', () => {
91
+ const source = [
92
+ '---',
93
+ 'title: Example',
94
+ '---',
95
+ '',
96
+ 'Preamble stays here.',
97
+ '',
98
+ '# A {[sectionHeader]}',
99
+ '',
100
+ 'a',
101
+ '',
102
+ '# B {#custom .wide}',
103
+ '',
104
+ 'b',
105
+ '',
106
+ ].join('\n');
107
+ const prefix = source.slice(0, source.indexOf('# A'));
108
+
109
+ const next = moveHeadingSectionInSource(
110
+ source,
111
+ lineOf(source, '# B'),
112
+ lineOf(source, '# A'),
113
+ 'before',
114
+ );
115
+
116
+ expect(next?.startsWith(prefix)).toBe(true);
117
+ expect(next).toContain('# B {#custom .wide}\n\nb\n\n# A {[sectionHeader]}\n\na\n');
118
+ });
119
+
120
+ it('ignores heading lookalikes inside fenced code', () => {
121
+ const source = ['# A', '', '```md', '# not an outline heading', '```', '', '# B', '', 'b'].join(
122
+ '\n',
123
+ );
124
+
125
+ const next = moveHeadingSectionInSource(
126
+ source,
127
+ lineOf(source, '# A'),
128
+ lineOf(source, '# B'),
129
+ 'after',
130
+ );
131
+
132
+ expect(next).toBe(
133
+ ['# B', '', 'b', '', '# A', '', '```md', '# not an outline heading', '```'].join('\n'),
134
+ );
135
+ expect(
136
+ moveHeadingSectionInSource(
137
+ source,
138
+ lineOf(source, '# not an outline heading'),
139
+ lineOf(source, '# B'),
140
+ 'before',
141
+ ),
142
+ ).toBeNull();
143
+ });
144
+
145
+ it('supports duplicate heading text and Setext headings by source line', () => {
146
+ const source = [
147
+ 'Document',
148
+ '========',
149
+ '',
150
+ 'First duplicate',
151
+ '---------------',
152
+ '',
153
+ 'one',
154
+ '',
155
+ 'First duplicate',
156
+ '---------------',
157
+ '',
158
+ 'two',
159
+ ].join('\n');
160
+ const firstLine = lineOf(source, 'First duplicate');
161
+ const secondLine = source
162
+ .slice(0, source.lastIndexOf('First duplicate'))
163
+ .split(/\r\n|\r|\n/).length;
164
+
165
+ const next = moveHeadingSectionInSource(source, secondLine, firstLine, 'before');
166
+
167
+ expect(next).toBe(
168
+ [
169
+ 'Document',
170
+ '========',
171
+ '',
172
+ 'First duplicate',
173
+ '---------------',
174
+ '',
175
+ 'two',
176
+ '',
177
+ 'First duplicate',
178
+ '---------------',
179
+ '',
180
+ 'one',
181
+ ].join('\n'),
182
+ );
183
+ });
184
+
185
+ it('rejects hierarchy changes, stale lines, self-drops, and adjacent no-ops', () => {
186
+ const source = [
187
+ '# Parent A',
188
+ '',
189
+ '## Child A',
190
+ '',
191
+ '### Detail',
192
+ '',
193
+ '# Parent B',
194
+ '',
195
+ '## Child B',
196
+ '',
197
+ '### Skipped sibling',
198
+ '',
199
+ '## Last child',
200
+ ].join('\n');
201
+ const parentA = lineOf(source, '# Parent A');
202
+ const parentB = lineOf(source, '# Parent B');
203
+ const childA = lineOf(source, '## Child A');
204
+ const childB = lineOf(source, '## Child B');
205
+ const detail = lineOf(source, '### Detail');
206
+ const skipped = lineOf(source, '### Skipped sibling');
207
+ const lastChild = lineOf(source, '## Last child');
208
+
209
+ expect(moveHeadingSectionInSource(source, parentA, parentA, 'before')).toBeNull();
210
+ expect(moveHeadingSectionInSource(source, childA, detail, 'before')).toBeNull();
211
+ expect(moveHeadingSectionInSource(source, childA, childB, 'before')).toBeNull();
212
+ expect(moveHeadingSectionInSource(source, skipped, lastChild, 'before')).toBeNull();
213
+ expect(moveHeadingSectionInSource(source, parentA, parentB, 'before')).toBeNull();
214
+ expect(moveHeadingSectionInSource(source, parentB, parentA, 'after')).toBeNull();
215
+ expect(moveHeadingSectionInSource(source, 999, parentA, 'before')).toBeNull();
216
+ });
217
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import type { MediaProvider } from '@bendyline/squisq/schemas';
4
4
  import { resolveTheme } from '@bendyline/squisq/schemas';
5
5
  import { PlainHtmlPreview } from '../PlainHtmlPreview';
@@ -26,6 +26,22 @@ describe('PlainHtmlPreview', () => {
26
26
  expect(sandbox).not.toContain('allow-scripts');
27
27
  });
28
28
 
29
+ it('globally scrolls the iframe with up and down arrows when enabled', () => {
30
+ render(<PlainHtmlPreview markdown={'# Scroll me'} globalKeyboardShortcuts />);
31
+ const iframe = screen.getByTestId('plain-html-preview') as HTMLIFrameElement;
32
+ const scrollBy = vi.fn();
33
+ Object.defineProperty(iframe.contentWindow, 'scrollBy', {
34
+ configurable: true,
35
+ value: scrollBy,
36
+ });
37
+
38
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
39
+ fireEvent.keyDown(document, { key: 'ArrowUp' });
40
+
41
+ expect(scrollBy).toHaveBeenNthCalledWith(1, { top: 64, behavior: 'smooth' });
42
+ expect(scrollBy).toHaveBeenNthCalledWith(2, { top: -64, behavior: 'smooth' });
43
+ });
44
+
29
45
  it('applies pre-resolved images via the images prop', async () => {
30
46
  render(
31
47
  <PlainHtmlPreview