@bendyline/squisq-editor-react 1.6.2 → 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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The teleprompter controller — single source of truth, always in the
3
+ * MAIN window (floats are render targets only).
4
+ *
5
+ * Loop-ownership rule: **the audio worklet owns time; the main window
6
+ * owns state; the visible surface owns pixels.** Voice-mode position
7
+ * advances on worklet PCM hops (immune to rAF/timer throttling while
8
+ * the browser is occluded by recording software); manual constant-rate
9
+ * mode uses rAF and is documented as best-effort under occlusion. React
10
+ * state publishes at ~15 Hz; per-hop subscribers (`subscribeTick`)
11
+ * exist for the video-PiP canvas pump.
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
16
+ import type { Doc } from '@bendyline/squisq/schemas';
17
+ import {
18
+ buildNarrationScript,
19
+ createNarrationSession,
20
+ narrationSessionStep,
21
+ reanchorSession,
22
+ DEFAULT_VAD_CONFIG,
23
+ type NarrationScript,
24
+ type NarrationSessionState,
25
+ type VadConfig,
26
+ } from '@bendyline/squisq/narration';
27
+ import { useMicAnalysis, type MicAnalysisHandle } from './useMicAnalysis';
28
+ import {
29
+ DEFAULT_TELEPROMPTER_PREFS,
30
+ type PrompterTransport,
31
+ type TeleprompterPrefs,
32
+ } from './types';
33
+
34
+ const PREFS_STORAGE_KEY = 'squisq:teleprompter-prefs';
35
+ const PUBLISH_INTERVAL_MS = 66;
36
+ /** Rough words-per-line used by keyboard nudges (line granularity without layout). */
37
+ const NUDGE_WORDS = 6;
38
+
39
+ export interface TeleprompterController {
40
+ script: NarrationScript | null;
41
+ transport: PrompterTransport;
42
+ countdownRemaining: number | null;
43
+ /** Fractional word position (published ~15 Hz). */
44
+ wordPos: number;
45
+ /** Smoothed mic level 0–1 for the meter. */
46
+ micLevel: number;
47
+ /** VAD flag for the meter tint. */
48
+ voiceActive: boolean;
49
+ mic: MicAnalysisHandle;
50
+ prefs: TeleprompterPrefs;
51
+ setPrefs: (patch: Partial<TeleprompterPrefs>) => void;
52
+ play: () => void;
53
+ pause: () => void;
54
+ restart: () => void;
55
+ /** Move the prompter by whole tokens and re-anchor voice tracking. */
56
+ nudge: (deltaTokens: number) => void;
57
+ /** Jump to an absolute token index (e.g. click a block marker). */
58
+ seekToToken: (tokenIndex: number) => void;
59
+ /** Per-analysis-tick subscription (video-PiP pump). Not throttled. */
60
+ subscribeTick: (cb: (wordPos: number) => void) => () => void;
61
+ handleKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
62
+ }
63
+
64
+ function loadPrefs(): TeleprompterPrefs {
65
+ try {
66
+ const raw = globalThis.localStorage?.getItem(PREFS_STORAGE_KEY);
67
+ if (!raw) return { ...DEFAULT_TELEPROMPTER_PREFS };
68
+ const parsed = JSON.parse(raw) as Partial<TeleprompterPrefs>;
69
+ return { ...DEFAULT_TELEPROMPTER_PREFS, ...parsed };
70
+ } catch {
71
+ return { ...DEFAULT_TELEPROMPTER_PREFS };
72
+ }
73
+ }
74
+
75
+ function savePrefs(prefs: TeleprompterPrefs): void {
76
+ try {
77
+ globalThis.localStorage?.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
78
+ } catch {
79
+ // Storage may be unavailable (private mode, embeds); prefs stay session-scoped.
80
+ }
81
+ }
82
+
83
+ /** Map the 0–1 sensitivity pref onto VAD thresholds (0.5 = engine defaults). */
84
+ export function vadConfigForSensitivity(sensitivity: number): Partial<VadConfig> {
85
+ const s = Math.min(1, Math.max(0, sensitivity));
86
+ const factor = 1.5 - s; // 0 → 1.5 (needs louder speech), 1 → 0.5 (hair trigger)
87
+ const enterRatio = Math.min(5, Math.max(2.6, DEFAULT_VAD_CONFIG.enterRatio * factor));
88
+ return {
89
+ enterRatio,
90
+ exitRatio: Math.max(
91
+ 2.0,
92
+ enterRatio * (DEFAULT_VAD_CONFIG.exitRatio / DEFAULT_VAD_CONFIG.enterRatio),
93
+ ),
94
+ };
95
+ }
96
+
97
+ export function useTeleprompter(opts: { doc: Doc | null }): TeleprompterController {
98
+ const { doc } = opts;
99
+ const script = useMemo(
100
+ () => (doc && doc.blocks.length > 0 ? buildNarrationScript(doc) : null),
101
+ [doc],
102
+ );
103
+
104
+ const [prefs, setPrefsState] = useState<TeleprompterPrefs>(loadPrefs);
105
+ const [transport, setTransport] = useState<PrompterTransport>('stopped');
106
+ const [countdownRemaining, setCountdownRemaining] = useState<number | null>(null);
107
+ const [view, setView] = useState({ wordPos: 0, micLevel: 0, voiceActive: false });
108
+
109
+ const mic = useMicAnalysis();
110
+
111
+ const scriptRef = useRef(script);
112
+ const prefsRef = useRef(prefs);
113
+ const transportRef = useRef(transport);
114
+ const wordPosRef = useRef(0);
115
+ const sessionRef = useRef<NarrationSessionState | null>(null);
116
+ const sessionKeyRef = useRef('');
117
+ const lastPublishRef = useRef(0);
118
+ const tickSubsRef = useRef<Set<(wordPos: number) => void>>(new Set());
119
+ const levelRef = useRef(0);
120
+ const voiceRef = useRef(false);
121
+ const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
122
+
123
+ scriptRef.current = script;
124
+ prefsRef.current = prefs;
125
+ transportRef.current = transport;
126
+
127
+ // Reset position when the script itself changes identity (doc edited).
128
+ useEffect(() => {
129
+ wordPosRef.current = Math.min(wordPosRef.current, script?.tokens.length ?? 0);
130
+ sessionRef.current = null;
131
+ setView((v) => ({ ...v, wordPos: wordPosRef.current }));
132
+ }, [script]);
133
+
134
+ const publish = useCallback(
135
+ (force = false) => {
136
+ const now = performance.now();
137
+ if (!force && now - lastPublishRef.current < PUBLISH_INTERVAL_MS) return;
138
+ lastPublishRef.current = now;
139
+ setView({
140
+ wordPos: wordPosRef.current,
141
+ micLevel: levelRef.current,
142
+ voiceActive: voiceRef.current,
143
+ });
144
+ },
145
+ [setView],
146
+ );
147
+
148
+ const notifyTick = useCallback(() => {
149
+ for (const cb of tickSubsRef.current) cb(wordPosRef.current);
150
+ }, []);
151
+
152
+ const clearCountdown = useCallback(() => {
153
+ if (countdownTimerRef.current !== null) {
154
+ clearInterval(countdownTimerRef.current);
155
+ countdownTimerRef.current = null;
156
+ }
157
+ setCountdownRemaining(null);
158
+ }, []);
159
+
160
+ const finish = useCallback(() => {
161
+ setTransport('finished');
162
+ transportRef.current = 'finished';
163
+ }, []);
164
+
165
+ // ── Voice drive: session stepping on worklet hops ──────────────────
166
+ useEffect(() => {
167
+ return mic.subscribeHop((pcm) => {
168
+ const currentScript = scriptRef.current;
169
+ const sampleRate = mic.sampleRate;
170
+ if (!currentScript || !sampleRate) return;
171
+
172
+ const currentPrefs = prefsRef.current;
173
+ // The session (noise floor, syllable-rate EMA, pacing state) is
174
+ // expensive learned acoustic state — recreate it ONLY when the
175
+ // sample rate or the script itself changes, not on every WPM /
176
+ // sensitivity tweak. Those prefs are applied to the session's config
177
+ // LIVE below so the slider takes effect on the next hop without
178
+ // discarding the learned floor and forcing a re-warmup.
179
+ const key = `${sampleRate}:${currentScript.tokens.length}:${currentScript.sourceText.length}`;
180
+ let session = sessionRef.current;
181
+ if (!session || sessionKeyRef.current !== key) {
182
+ session = reanchorSession(
183
+ createNarrationSession(sampleRate, currentScript, {
184
+ vad: vadConfigForSensitivity(currentPrefs.vadSensitivity),
185
+ pacing: { baseWpm: currentPrefs.baseWpm },
186
+ }),
187
+ wordPosRef.current,
188
+ );
189
+ sessionKeyRef.current = key;
190
+ } else {
191
+ session = {
192
+ ...session,
193
+ config: {
194
+ ...session.config,
195
+ vad: vadConfigForSensitivity(currentPrefs.vadSensitivity),
196
+ pacing: { baseWpm: currentPrefs.baseWpm },
197
+ },
198
+ };
199
+ }
200
+ session = narrationSessionStep(session, pcm);
201
+
202
+ levelRef.current = session.lastFrame ? Math.min(1, session.lastFrame.rms * 6) : 0;
203
+ voiceRef.current = session.vad.speaking;
204
+
205
+ if (transportRef.current === 'rolling' && prefsRef.current.voiceTracking) {
206
+ wordPosRef.current = session.pacing.wordPos;
207
+ if (wordPosRef.current >= currentScript.tokens.length) finish();
208
+ } else {
209
+ // Hold position while paused/stopped so resuming doesn't jump.
210
+ session = reanchorSession(session, wordPosRef.current);
211
+ }
212
+ sessionRef.current = session;
213
+ notifyTick();
214
+ publish();
215
+ });
216
+ }, [mic, finish, notifyTick, publish]);
217
+
218
+ // ── Manual drive: constant rate when voice tracking is off/unavailable ──
219
+ useEffect(() => {
220
+ if (transport !== 'rolling') return;
221
+ let raf = 0;
222
+ let last = performance.now();
223
+ const loop = (now: number) => {
224
+ const dt = Math.min(0.25, (now - last) / 1000);
225
+ last = now;
226
+ const currentScript = scriptRef.current;
227
+ const manual = !prefsRef.current.voiceTracking || mic.status !== 'live';
228
+ if (currentScript && manual) {
229
+ wordPosRef.current = Math.min(
230
+ currentScript.tokens.length,
231
+ wordPosRef.current + (prefsRef.current.baseWpm / 60) * dt,
232
+ );
233
+ if (wordPosRef.current >= currentScript.tokens.length) {
234
+ finish();
235
+ publish(true);
236
+ return;
237
+ }
238
+ notifyTick();
239
+ }
240
+ publish();
241
+ raf = requestAnimationFrame(loop);
242
+ };
243
+ raf = requestAnimationFrame(loop);
244
+ return () => cancelAnimationFrame(raf);
245
+ }, [transport, mic.status, finish, notifyTick, publish]);
246
+
247
+ // ── Transport ───────────────────────────────────────────────────────
248
+ const beginRolling = useCallback(() => {
249
+ clearCountdown();
250
+ if (sessionRef.current) {
251
+ sessionRef.current = reanchorSession(sessionRef.current, wordPosRef.current);
252
+ }
253
+ setTransport('rolling');
254
+ transportRef.current = 'rolling';
255
+ }, [clearCountdown]);
256
+
257
+ const play = useCallback(() => {
258
+ if (transportRef.current === 'rolling' || transportRef.current === 'countdown') return;
259
+ if (transportRef.current === 'finished') {
260
+ wordPosRef.current = 0;
261
+ publish(true);
262
+ }
263
+ if (prefsRef.current.voiceTracking && mic.status === 'idle') {
264
+ // Lazy mic start on the user's explicit gesture; denial degrades to
265
+ // manual mode (the manual rAF drive checks mic.status each frame).
266
+ void mic.start(prefsRef.current.micDeviceId);
267
+ }
268
+ const countdown = prefsRef.current.countdownSec;
269
+ if (countdown > 0) {
270
+ setTransport('countdown');
271
+ transportRef.current = 'countdown';
272
+ setCountdownRemaining(countdown);
273
+ let remaining = countdown;
274
+ countdownTimerRef.current = setInterval(() => {
275
+ remaining -= 1;
276
+ if (remaining <= 0) beginRolling();
277
+ else setCountdownRemaining(remaining);
278
+ }, 1000);
279
+ } else {
280
+ beginRolling();
281
+ }
282
+ }, [beginRolling, mic, publish]);
283
+
284
+ const pause = useCallback(() => {
285
+ if (transportRef.current === 'countdown') {
286
+ clearCountdown();
287
+ setTransport('stopped');
288
+ transportRef.current = 'stopped';
289
+ return;
290
+ }
291
+ if (transportRef.current !== 'rolling') return;
292
+ setTransport('paused');
293
+ transportRef.current = 'paused';
294
+ }, [clearCountdown]);
295
+
296
+ const restart = useCallback(() => {
297
+ clearCountdown();
298
+ wordPosRef.current = 0;
299
+ if (sessionRef.current) sessionRef.current = reanchorSession(sessionRef.current, 0);
300
+ setTransport('stopped');
301
+ transportRef.current = 'stopped';
302
+ publish(true);
303
+ }, [clearCountdown, publish]);
304
+
305
+ const seekToToken = useCallback(
306
+ (tokenIndex: number) => {
307
+ const currentScript = scriptRef.current;
308
+ if (!currentScript) return;
309
+ wordPosRef.current = Math.min(Math.max(0, tokenIndex), currentScript.tokens.length);
310
+ if (sessionRef.current) {
311
+ sessionRef.current = reanchorSession(sessionRef.current, wordPosRef.current);
312
+ }
313
+ if (transportRef.current === 'finished') {
314
+ setTransport('paused');
315
+ transportRef.current = 'paused';
316
+ }
317
+ notifyTick();
318
+ publish(true);
319
+ },
320
+ [notifyTick, publish],
321
+ );
322
+
323
+ const nudge = useCallback(
324
+ (deltaTokens: number) => seekToToken(Math.round(wordPosRef.current + deltaTokens)),
325
+ [seekToToken],
326
+ );
327
+
328
+ const setPrefs = useCallback(
329
+ (patch: Partial<TeleprompterPrefs>) => {
330
+ setPrefsState((prev) => {
331
+ const next = { ...prev, ...patch };
332
+ savePrefs(next);
333
+ // Switching mic device while live restarts capture on the new device.
334
+ if (
335
+ patch.micDeviceId !== undefined &&
336
+ patch.micDeviceId !== prev.micDeviceId &&
337
+ mic.status === 'live'
338
+ ) {
339
+ void mic.start(patch.micDeviceId);
340
+ }
341
+ return next;
342
+ });
343
+ },
344
+ [mic],
345
+ );
346
+
347
+ const subscribeTick = useCallback((cb: (wordPos: number) => void) => {
348
+ tickSubsRef.current.add(cb);
349
+ return () => {
350
+ tickSubsRef.current.delete(cb);
351
+ };
352
+ }, []);
353
+
354
+ const handleKeyDown = useCallback(
355
+ (event: ReactKeyboardEvent<HTMLElement>) => {
356
+ const target = event.target as HTMLElement | null;
357
+ if (target?.closest('input, textarea, select, button, a, [contenteditable="true"]')) return;
358
+ switch (event.key) {
359
+ case ' ':
360
+ event.preventDefault();
361
+ if (transportRef.current === 'rolling' || transportRef.current === 'countdown') pause();
362
+ else play();
363
+ break;
364
+ case 'ArrowUp':
365
+ case 'ArrowLeft':
366
+ event.preventDefault();
367
+ nudge(-NUDGE_WORDS);
368
+ break;
369
+ case 'ArrowDown':
370
+ case 'ArrowRight':
371
+ event.preventDefault();
372
+ nudge(NUDGE_WORDS);
373
+ break;
374
+ case '[':
375
+ event.preventDefault();
376
+ setPrefs({ baseWpm: Math.max(80, prefsRef.current.baseWpm - 10) });
377
+ break;
378
+ case ']':
379
+ event.preventDefault();
380
+ setPrefs({ baseWpm: Math.min(260, prefsRef.current.baseWpm + 10) });
381
+ break;
382
+ case 'm':
383
+ case 'M':
384
+ event.preventDefault();
385
+ setPrefs({ mirrored: !prefsRef.current.mirrored });
386
+ break;
387
+ case 'Escape':
388
+ if (transportRef.current === 'countdown' || transportRef.current === 'rolling') {
389
+ event.preventDefault();
390
+ pause();
391
+ }
392
+ break;
393
+ default:
394
+ break;
395
+ }
396
+ },
397
+ [nudge, pause, play, setPrefs],
398
+ );
399
+
400
+ // Countdown timer cleanup on unmount.
401
+ useEffect(() => clearCountdown, [clearCountdown]);
402
+
403
+ return {
404
+ script,
405
+ transport,
406
+ countdownRemaining,
407
+ wordPos: view.wordPos,
408
+ micLevel: view.micLevel,
409
+ voiceActive: view.voiceActive,
410
+ mic,
411
+ prefs,
412
+ setPrefs,
413
+ play,
414
+ pause,
415
+ restart,
416
+ nudge,
417
+ seekToToken,
418
+ subscribeTick,
419
+ handleKeyDown,
420
+ };
421
+ }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Block,
3
+ CustomTemplateDefinition,
3
4
  DocBlock,
4
5
  MediaProvider,
5
6
  Theme,
@@ -12,8 +13,8 @@ import {
12
13
  extractImages,
13
14
  extractListItems,
14
15
  extractTableFromContents,
15
- getLayers,
16
- type RenderContext,
16
+ materializeBlockLayers,
17
+ type MaterializeBlockLayersOptions,
17
18
  } from '@bendyline/squisq/doc';
18
19
  import { extractPlainText } from '@bendyline/squisq/markdown';
19
20
 
@@ -23,6 +24,7 @@ export interface TemplatePreviewSource {
23
24
  viewport: ViewportConfig;
24
25
  basePath?: string;
25
26
  mediaProvider?: MediaProvider | null;
27
+ customTemplates?: readonly CustomTemplateDefinition[];
26
28
  }
27
29
 
28
30
  export interface TemplatePreviewResult {
@@ -82,17 +84,22 @@ export function resolveTemplateContentPreviewResult(
82
84
  ...(inputs ?? {}),
83
85
  };
84
86
 
85
- const ctx: RenderContext = {
87
+ const ctx: MaterializeBlockLayersOptions = {
86
88
  blockIndex: 0,
87
89
  totalBlocks: 1,
88
90
  theme,
89
91
  viewport,
92
+ customTemplates: source.customTemplates,
90
93
  };
91
94
 
92
95
  try {
93
- const layers = getLayers(candidate as unknown as DocBlock, ctx);
96
+ const materialized = materializeBlockLayers(candidate as unknown as DocBlock, ctx);
97
+ const { layers } = materialized;
94
98
  if (layers.length === 0) return { visual: null, warning };
95
- return { visual: { ...candidate, layers } };
99
+ return {
100
+ visual: { ...candidate, layers },
101
+ warning: warning ?? materialized.diagnostic?.message,
102
+ };
96
103
  } catch {
97
104
  return { visual: null, warning };
98
105
  }