@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,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
+ });
@@ -0,0 +1,117 @@
1
+ /** @vitest-environment jsdom */
2
+ import type { ReactNode } from 'react';
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import type { Doc } from '@bendyline/squisq/schemas';
6
+
7
+ vi.mock('@bendyline/squisq-react', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('@bendyline/squisq-react')>();
9
+ const blockSummary = (doc: Doc) =>
10
+ JSON.stringify(doc.blocks.map((block) => ({ id: block.id, template: block.template })));
11
+ return {
12
+ ...actual,
13
+ useMediaProvider: () => null,
14
+ DocPlayer: ({ doc }: { doc: Doc }) => (
15
+ <div data-testid="mock-doc-player">{blockSummary(doc)}</div>
16
+ ),
17
+ LinearDocView: ({ doc }: { doc: Doc }) => (
18
+ <div data-testid="mock-linear-doc">{blockSummary(doc)}</div>
19
+ ),
20
+ };
21
+ });
22
+
23
+ vi.mock('../PlainHtmlPreview', () => ({
24
+ PlainHtmlPreview: ({ markdown }: { markdown: string }) => (
25
+ <div data-testid="mock-plain-document">{markdown}</div>
26
+ ),
27
+ }));
28
+
29
+ import { EditorProvider, useEditorContext } from '../EditorContext';
30
+ import { PreviewSettingsProvider, usePreviewSettings } from '../PreviewControls';
31
+ import { PreviewPanel } from '../PreviewPanel';
32
+
33
+ const SOURCE = `---
34
+ title: Metrics Brief
35
+ ---
36
+
37
+ # Metrics Brief {[title]}
38
+
39
+ This report collects the strongest signals from a year of product work.
40
+
41
+ ## Growth
42
+
43
+ Revenue increased 42% year over year while customer retention reached 91%.
44
+
45
+ ## Milestone
46
+
47
+ On January 15, 2026 the team shipped the largest release in company history.
48
+
49
+ ## Customer voice
50
+
51
+ > "The new workflow saves our team hours every week."
52
+ `;
53
+
54
+ function TransformButton() {
55
+ const { setSelectedTransformStyle } = usePreviewSettings();
56
+ return <button onClick={() => setSelectedTransformStyle('data-driven')}>Summarize now</button>;
57
+ }
58
+
59
+ function Harness({ children }: { children?: ReactNode }) {
60
+ const { doc } = useEditorContext();
61
+ return (
62
+ <PreviewSettingsProvider doc={doc}>
63
+ <TransformButton />
64
+ {children}
65
+ <PreviewPanel />
66
+ </PreviewSettingsProvider>
67
+ );
68
+ }
69
+
70
+ function renderMode(displayMode?: string) {
71
+ const source = displayMode
72
+ ? SOURCE.replace('title: Metrics Brief', `title: Metrics Brief\ndisplay-mode: ${displayMode}`)
73
+ : SOURCE;
74
+ return render(
75
+ <EditorProvider initialMarkdown={source} initialView="preview">
76
+ <Harness />
77
+ </EditorProvider>,
78
+ );
79
+ }
80
+
81
+ describe('PreviewPanel transform projection', () => {
82
+ it('replaces the slideshow deck when a summarization style is selected', async () => {
83
+ renderMode('slideshow');
84
+ await waitFor(() => expect(screen.getByTestId('mock-doc-player')).toBeTruthy());
85
+ expect(screen.getByTestId('mock-doc-player').textContent).not.toContain('transform-');
86
+
87
+ fireEvent.click(screen.getByRole('button', { name: 'Summarize now' }));
88
+
89
+ await waitFor(() =>
90
+ expect(screen.getByTestId('mock-doc-player').textContent).toContain('transform-'),
91
+ );
92
+ });
93
+
94
+ it('feeds the transformed content model to Page mode', async () => {
95
+ renderMode('page');
96
+ await waitFor(() => expect(screen.getByTestId('mock-linear-doc')).toBeTruthy());
97
+
98
+ fireEvent.click(screen.getByRole('button', { name: 'Summarize now' }));
99
+
100
+ await waitFor(() =>
101
+ expect(screen.getByTestId('mock-linear-doc').textContent).toContain('transform-'),
102
+ );
103
+ });
104
+
105
+ it('feeds readable transformed Markdown to Document mode', async () => {
106
+ renderMode('document');
107
+ await waitFor(() => expect(screen.getByTestId('mock-plain-document')).toBeTruthy());
108
+
109
+ fireEvent.click(screen.getByRole('button', { name: 'Summarize now' }));
110
+
111
+ await waitFor(() => {
112
+ const markdown = screen.getByTestId('mock-plain-document').textContent ?? '';
113
+ expect(markdown).toContain('Metrics Brief');
114
+ expect(markdown).toContain('42%');
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { canHandleSquisqMediaDrop, ownsMonacoModel } from '../rawEditorIsolation';
3
+ import { SQUISQ_MEDIA_MIME } from '../mediaDragMime';
4
+
5
+ describe('RawEditor Monaco provider isolation', () => {
6
+ it('accepts only the model owned by the registering editor', () => {
7
+ const firstModel = {} as never;
8
+ const secondModel = {} as never;
9
+ const firstEditor = { getModel: () => firstModel } as never;
10
+ expect(ownsMonacoModel(firstEditor, firstModel)).toBe(true);
11
+ expect(ownsMonacoModel(firstEditor, secondModel)).toBe(false);
12
+ });
13
+
14
+ it('rejects media-bin drops while read-only', () => {
15
+ const transfer = { types: [SQUISQ_MEDIA_MIME] } as unknown as DataTransfer;
16
+ expect(canHandleSquisqMediaDrop(true, transfer)).toBe(false);
17
+ expect(canHandleSquisqMediaDrop(false, transfer)).toBe(true);
18
+ });
19
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_THEME, VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
3
+ import type { Block, Doc, TextLayer } from '@bendyline/squisq/schemas';
4
+ import { resolveBlockVisual } from '../resolveBlockVisual';
5
+
6
+ describe('resolveBlockVisual', () => {
7
+ it('materializes document-scoped custom templates in editor previews', () => {
8
+ const block: Block = {
9
+ id: 'custom',
10
+ startTime: 0,
11
+ duration: 5,
12
+ audioSegment: 0,
13
+ template: 'hero',
14
+ title: 'Editor custom template',
15
+ };
16
+ const doc: Doc = {
17
+ articleId: 'custom-preview',
18
+ duration: 5,
19
+ blocks: [block],
20
+ audio: { segments: [] },
21
+ customTemplates: [
22
+ {
23
+ name: 'hero',
24
+ label: 'Hero',
25
+ viewport: { width: 1920, height: 1080 },
26
+ layers: [
27
+ {
28
+ id: 'hero-title',
29
+ type: 'text',
30
+ position: { x: '5%', y: '10%', width: '90%' },
31
+ content: { text: '{title}', style: { fontSize: 48, color: '#000000' } },
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ };
37
+
38
+ const visual = resolveBlockVisual(doc, block, DEFAULT_THEME, VIEWPORT_PRESETS.landscape);
39
+
40
+ expect(visual).not.toBeNull();
41
+ expect((visual?.layers?.[0] as TextLayer).content.text).toBe('Editor custom template');
42
+ });
43
+ });
@@ -0,0 +1,27 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { EditorProvider } from '../EditorContext';
4
+ import { StatusBar } from '../StatusBar';
5
+
6
+ function renderStatusBar(markdown: string) {
7
+ return render(
8
+ <EditorProvider initialMarkdown={markdown} articleId="status-bar-test">
9
+ <StatusBar />
10
+ </EditorProvider>,
11
+ );
12
+ }
13
+
14
+ describe('StatusBar', () => {
15
+ it('counts nested document blocks, not only top-level roots', async () => {
16
+ renderStatusBar('# Root\n\n## Child one\n\n### Grandchild\n\n## Child two\n');
17
+
18
+ expect(await screen.findByText('4 blocks')).toBeTruthy();
19
+ });
20
+
21
+ it('uses the singular label for one block', async () => {
22
+ renderStatusBar('# Only block\n');
23
+
24
+ expect(await screen.findByText('1 block')).toBeTruthy();
25
+ expect(screen.queryByText('1 blocks')).toBeNull();
26
+ });
27
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
6
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
7
+ import { markdownToDoc } from '@bendyline/squisq/doc';
8
+ import { buildNarrationScript } from '@bendyline/squisq/narration';
9
+ import { DEFAULT_THEME } from '@bendyline/squisq/schemas';
10
+ import { TeleprompterView } from '../teleprompter/TeleprompterView';
11
+ import { stepScroll, targetOffsetFor, type TokenLineMap } from '../teleprompter/scrollModel';
12
+ import { detectFloatTiers } from '../teleprompter/floatingWindow';
13
+ import { PCM_WORKLET_SOURCE, PCM_WORKLET_NAME } from '../teleprompter/pcmWorklet';
14
+ import { vadConfigForSensitivity } from '../teleprompter/useTeleprompter';
15
+
16
+ const MD = `# First Section
17
+
18
+ One two three four five six seven eight.
19
+
20
+ ## Second Section
21
+
22
+ Nine ten eleven twelve.
23
+ `;
24
+
25
+ afterEach(() => {
26
+ cleanup();
27
+ // Prefs persist to localStorage; keep tests order-independent.
28
+ window.localStorage.removeItem('squisq:teleprompter-prefs');
29
+ });
30
+
31
+ describe('scrollModel', () => {
32
+ const lines: TokenLineMap = {
33
+ tokenTops: [0, 0, 0, 60, 60, 120, 120, 180],
34
+ tokenHeights: [60, 60, 60, 60, 60, 60, 60, 60],
35
+ };
36
+
37
+ it('places the active line at the eye-line', () => {
38
+ // Token 5 sits at top 120; eye-line = 35% of 600 = 210 → offset keeps it clamped ≥ 0.
39
+ expect(targetOffsetFor(5, lines, 600)).toBe(0);
40
+ // Smaller viewport pushes real scrolling: 120 + 30 − 0.35×200 = 80.
41
+ expect(targetOffsetFor(5, lines, 200)).toBe(80);
42
+ // Fractional positions interpolate between token tops.
43
+ expect(targetOffsetFor(4.5, lines, 200)).toBe(60 + 30 + 30 - 70);
44
+ });
45
+
46
+ it('clamps to non-negative offsets and empty maps', () => {
47
+ expect(targetOffsetFor(0, lines, 600)).toBe(0);
48
+ expect(targetOffsetFor(3, { tokenTops: [], tokenHeights: [] }, 600)).toBe(0);
49
+ });
50
+
51
+ it('stepScroll approaches the target and respects the speed clamp', () => {
52
+ // A huge jump advances by at most maxPxPerSec × dt.
53
+ expect(stepScroll(0, 100000, 16, 2600)).toBeCloseTo(2600 * 0.016, 5);
54
+ // Small distances converge and snap exactly.
55
+ let offset = 0;
56
+ for (let i = 0; i < 120; i++) offset = stepScroll(offset, 300, 16);
57
+ expect(offset).toBe(300);
58
+ // No time → no movement.
59
+ expect(stepScroll(50, 300, 0)).toBe(50);
60
+ });
61
+ });
62
+
63
+ describe('detectFloatTiers', () => {
64
+ it('reports popup + docked under jsdom (no PiP APIs)', () => {
65
+ const tiers = detectFloatTiers();
66
+ expect(tiers).toContain('popup');
67
+ expect(tiers[tiers.length - 1]).toBe('docked');
68
+ expect(tiers).not.toContain('document-pip');
69
+ expect(tiers).not.toContain('video-pip');
70
+ });
71
+
72
+ it('reports document-pip first when the API exists', () => {
73
+ const win = window as Window & { documentPictureInPicture?: unknown };
74
+ win.documentPictureInPicture = { requestWindow: () => Promise.reject(new Error('stub')) };
75
+ try {
76
+ expect(detectFloatTiers()[0]).toBe('document-pip');
77
+ } finally {
78
+ delete win.documentPictureInPicture;
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('pcmWorklet source', () => {
84
+ it('compiles under stubbed worklet globals and registers the tap', () => {
85
+ const registered: string[] = [];
86
+ const factory = new Function(
87
+ 'registerProcessor',
88
+ 'AudioWorkletProcessor',
89
+ 'currentTime',
90
+ PCM_WORKLET_SOURCE,
91
+ );
92
+ class ProcessorStub {
93
+ port = { postMessage: () => undefined };
94
+ }
95
+ factory((name: string) => registered.push(name), ProcessorStub, 0);
96
+ expect(registered).toEqual([PCM_WORKLET_NAME]);
97
+ expect(PCM_WORKLET_SOURCE).toContain('process(inputs)');
98
+ });
99
+ });
100
+
101
+ describe('vadConfigForSensitivity', () => {
102
+ it('maps 0.5 to the engine defaults and keeps hysteresis ordering', () => {
103
+ const mid = vadConfigForSensitivity(0.5);
104
+ expect(mid.enterRatio).toBeCloseTo(3.0, 5);
105
+ for (const s of [0, 0.25, 0.5, 0.75, 1]) {
106
+ const cfg = vadConfigForSensitivity(s);
107
+ expect(cfg.enterRatio!).toBeGreaterThan(cfg.exitRatio! - 1e-9);
108
+ }
109
+ // Higher sensitivity → lower (easier) enter threshold.
110
+ expect(vadConfigForSensitivity(0.9).enterRatio!).toBeLessThan(
111
+ vadConfigForSensitivity(0.1).enterRatio!,
112
+ );
113
+ });
114
+ });
115
+
116
+ describe('TeleprompterView', () => {
117
+ function renderView(markdown: string | null) {
118
+ const doc = markdown ? markdownToDoc(parseMarkdown(markdown)) : null;
119
+ render(<TeleprompterView doc={doc} theme={DEFAULT_THEME} />);
120
+ return doc;
121
+ }
122
+
123
+ it('renders one span per script token plus block markers and controls', () => {
124
+ const doc = renderView(MD);
125
+ const script = buildNarrationScript(doc!);
126
+ const spans = document.querySelectorAll('[data-token-idx]');
127
+ expect(spans.length).toBe(script.tokens.length);
128
+ expect(
129
+ screen.getByText('First Section', { selector: '.squisq-teleprompter-block-marker' }),
130
+ ).toBeTruthy();
131
+ expect(screen.getByTestId('teleprompter-controls')).toBeTruthy();
132
+ expect(screen.getByRole('button', { name: 'Start prompter' })).toBeTruthy();
133
+ });
134
+
135
+ it('mirrors the live surface into a presentation target without duplicating controls', () => {
136
+ const doc = markdownToDoc(parseMarkdown(MD));
137
+ const audienceDocument = document.implementation.createHTMLDocument('Audience');
138
+ const audienceTarget = audienceDocument.createElement('div');
139
+ audienceDocument.body.appendChild(audienceTarget);
140
+
141
+ render(
142
+ <TeleprompterView doc={doc} theme={DEFAULT_THEME} presentationTarget={audienceTarget} />,
143
+ );
144
+
145
+ expect(audienceTarget.querySelector('[aria-label="Audience presentation"]')).toBeTruthy();
146
+ expect(audienceTarget.querySelectorAll('[data-token-idx]').length).toBe(
147
+ buildNarrationScript(doc).tokens.length,
148
+ );
149
+ expect(audienceTarget.querySelector('[data-testid="teleprompter-controls"]')).toBeNull();
150
+ expect(screen.getAllByTestId('teleprompter-controls')).toHaveLength(1);
151
+ });
152
+
153
+ it('shows an empty state for an empty doc', () => {
154
+ renderView(null);
155
+ expect(screen.getByText(/Nothing to narrate yet/)).toBeTruthy();
156
+ expect(screen.queryByTestId('teleprompter-controls')).toBeNull();
157
+ });
158
+
159
+ it('mirrors the empty state into a presentation target', () => {
160
+ const audienceDocument = document.implementation.createHTMLDocument('Audience');
161
+ const audienceTarget = audienceDocument.createElement('div');
162
+ audienceDocument.body.appendChild(audienceTarget);
163
+
164
+ render(
165
+ <TeleprompterView doc={null} theme={DEFAULT_THEME} presentationTarget={audienceTarget} />,
166
+ );
167
+
168
+ expect(audienceTarget.querySelector('[aria-label="Audience presentation"]')).toBeTruthy();
169
+ expect(audienceTarget.textContent).toContain('Nothing to narrate yet');
170
+ expect(audienceTarget.querySelector('[data-testid="teleprompter-controls"]')).toBeNull();
171
+ });
172
+
173
+ it('start button enters countdown and Escape cancels back to stopped', () => {
174
+ renderView(MD);
175
+ fireEvent.click(screen.getByRole('button', { name: 'Start prompter' }));
176
+ // Default prefs use a 3 s countdown → the overlay digit appears.
177
+ expect(document.querySelector('.squisq-teleprompter-countdown-digit')?.textContent).toBe('3');
178
+ fireEvent.keyDown(screen.getByTestId('teleprompter-view'), { key: 'Escape' });
179
+ expect(document.querySelector('.squisq-teleprompter-countdown-digit')).toBeNull();
180
+ expect(screen.getByRole('button', { name: 'Start prompter' })).toBeTruthy();
181
+ });
182
+
183
+ it('mirror toggle flips the surface class', () => {
184
+ renderView(MD);
185
+ const surface = screen.getByTestId('teleprompter-surface');
186
+ expect(surface.className).not.toContain('--mirrored');
187
+ fireEvent.click(screen.getByRole('button', { name: '⇋ Mirror' }));
188
+ expect(screen.getByTestId('teleprompter-surface').className).toContain('--mirrored');
189
+ });
190
+ });
@@ -2,6 +2,15 @@ import { describe, it, expect } from 'vitest';
2
2
  import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
3
3
 
4
4
  describe('Template annotation round-trip', () => {
5
+ it('hides an empty annotation in Write view while preserving it on round-trip', () => {
6
+ const original = '## Getting Started {[]}';
7
+ const html = markdownToTiptap(original);
8
+
9
+ expect(html).toContain('data-template-empty="true"');
10
+ expect(html).not.toContain('{[]}');
11
+ expect(tiptapToMarkdown(html).trim()).toBe(original);
12
+ });
13
+
5
14
  it('extracts {[template]} into data-template on markdownToTiptap', () => {
6
15
  const html = markdownToTiptap('## Getting Started {[comparisonBar]}');
7
16
  expect(html).toContain('data-template="comparisonBar"');
@@ -16,13 +25,40 @@ describe('Template annotation round-trip', () => {
16
25
  expect(back.trim()).toBe(original);
17
26
  });
18
27
 
28
+ it('preserves a heading whose last word is also the template label', () => {
29
+ const original = '### A Famous Quote {[quote]}';
30
+ const html = markdownToTiptap(original);
31
+
32
+ expect(tiptapToMarkdown(html).trim()).toBe(original);
33
+ });
34
+
35
+ it('ignores legacy text rendered inside a template badge', () => {
36
+ const tiptapRendered =
37
+ '<h3 data-template="quote">' +
38
+ '<span class="squisq-heading-content">A Famous Quote</span>' +
39
+ '<span class="squisq-template-badge" contenteditable="false">Quote</span>' +
40
+ '</h3>';
41
+
42
+ expect(tiptapToMarkdown(tiptapRendered).trim()).toBe('### A Famous Quote {[quote]}');
43
+ });
44
+
45
+ it('round-trips param-only squiggly annotations', () => {
46
+ const original = '## Intro {[transition=fade]}';
47
+ const html = markdownToTiptap(original);
48
+ expect(html).not.toContain('data-template="transition=fade"');
49
+ expect(html).toContain('data-template-params="transition=fade"');
50
+ expect(tiptapToMarkdown(html).trim()).toBe(original);
51
+ });
52
+
19
53
  it('preserves template annotation through Tiptap-rendered HTML (with badge spans)', () => {
20
54
  // Simulates HTML that Tiptap actually renders after parse: includes the
21
55
  // squisq-heading-content + squisq-template-badge wrapper spans.
22
56
  const tiptapRendered =
23
57
  '<h2 data-template="comparisonBar">' +
24
58
  '<span class="squisq-heading-content">Getting Started</span>' +
25
- '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar" data-template-label="Comparison Bar"></span>' +
59
+ '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar">' +
60
+ '<span class="squisq-template-badge-core" data-template-label="Comparison Bar" aria-hidden="true"></span>' +
61
+ '</span>' +
26
62
  '</h2>';
27
63
  const md = tiptapToMarkdown(tiptapRendered);
28
64
  expect(md).toContain('## Getting Started');
@@ -39,7 +75,9 @@ describe('Template annotation round-trip', () => {
39
75
  const tiptapRendered =
40
76
  '<h2 data-block-attrs="transition=fade">' +
41
77
  '<span class="squisq-heading-content">Intro</span>' +
42
- '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false"></span>' +
78
+ '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false">' +
79
+ '<span class="squisq-template-badge-core" data-template-label="Block" aria-hidden="true"></span>' +
80
+ '</span>' +
43
81
  '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade · 1:30 start"></span>' +
44
82
  '</h2>';
45
83
  const md = tiptapToMarkdown(tiptapRendered).trim();
@@ -49,4 +87,13 @@ describe('Template annotation round-trip', () => {
49
87
  // The CSS-painted summary lives in a data attribute; it must not bleed.
50
88
  expect(md).not.toContain('start');
51
89
  });
90
+
91
+ it('serializes data-template-params without data-template as a squiggly annotation', () => {
92
+ const tiptapRendered =
93
+ '<h2 data-template-params="transition=fade">' +
94
+ '<span class="squisq-heading-content">Intro</span>' +
95
+ '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade"></span>' +
96
+ '</h2>';
97
+ expect(tiptapToMarkdown(tiptapRendered).trim()).toBe('## Intro {[transition=fade]}');
98
+ });
52
99
  });