@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
@@ -173,6 +173,9 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
173
173
  const startTimestampRef = useRef<number | null>(null);
174
174
  const tickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
175
175
  const stopResolversRef = useRef<Array<(blob: Blob | null) => void>>([]);
176
+ const stopPromiseRef = useRef<Promise<Blob | null> | null>(null);
177
+ const requestPromiseRef = useRef<Promise<void> | null>(null);
178
+ const lifecycleRef = useRef(0);
176
179
 
177
180
  // Stable copy of options for callbacks that read them late. Re-evaluated
178
181
  // each render, but each callback closes over the ref so we don't have
@@ -222,9 +225,13 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
222
225
  }, [clearTicker]);
223
226
 
224
227
  const cancel = useCallback(() => {
228
+ lifecycleRef.current += 1;
225
229
  const rec = recorderRef.current;
226
230
  if (rec && rec.state !== 'inactive') {
227
231
  try {
232
+ rec.ondataavailable = null;
233
+ rec.onstop = null;
234
+ rec.onerror = null;
228
235
  rec.stop();
229
236
  } catch {
230
237
  // Ignore — we're tearing down anyway.
@@ -237,6 +244,8 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
237
244
  startTimestampRef.current = null;
238
245
  // Any in-flight stop() promises won't get a blob.
239
246
  stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
247
+ stopPromiseRef.current = null;
248
+ requestPromiseRef.current = null;
240
249
  setBlob(null);
241
250
  setDurationMs(0);
242
251
  setError(null);
@@ -244,66 +253,89 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
244
253
  }, [clearTicker, releaseStream]);
245
254
 
246
255
  const request = useCallback(async () => {
256
+ if (requestPromiseRef.current) return requestPromiseRef.current;
257
+ if (recorderRef.current?.stream.active) return;
247
258
  if (!supportsMediaRecorder()) {
248
259
  const err = new Error('MediaRecorder is not supported in this environment.');
249
260
  setError(err);
250
261
  setState('error');
251
262
  throw err;
252
263
  }
253
- if (state === 'recording' || state === 'stopping') {
254
- // Don't start a parallel acquisition while one is in flight.
255
- return;
256
- }
257
- setError(null);
258
- setState('requesting');
259
- try {
260
- const source = optionsRef.current.source ?? 'mic';
261
- const { stream: nextStream, dispose } = await acquireStream(source, optionsRef.current);
262
- const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
263
- const recorderOptions: MediaRecorderOptions = {};
264
- if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
265
- if (optionsRef.current.bitsPerSecond) {
266
- recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
267
- }
268
- const recorder = new MediaRecorder(nextStream, recorderOptions);
264
+ const lifecycle = ++lifecycleRef.current;
265
+ const requestPromise = (async () => {
266
+ setError(null);
267
+ setState('requesting');
268
+ let acquired: { stream: MediaStream; dispose: () => void } | null = null;
269
+ try {
270
+ const source = optionsRef.current.source ?? 'mic';
271
+ acquired = await acquireStream(source, optionsRef.current);
272
+ const { stream: nextStream, dispose } = acquired;
273
+ if (lifecycle !== lifecycleRef.current) {
274
+ nextStream.getTracks().forEach((track) => track.stop());
275
+ dispose();
276
+ return;
277
+ }
278
+ const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
279
+ const recorderOptions: MediaRecorderOptions = {};
280
+ if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
281
+ if (optionsRef.current.bitsPerSecond) {
282
+ recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
283
+ }
284
+ const recorder = new MediaRecorder(nextStream, recorderOptions);
269
285
 
270
- recorder.ondataavailable = (e) => {
271
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
272
- };
273
- recorder.onstop = () => {
274
- // The recorded MIME type is authoritative once data is in hand —
275
- // some browsers down-negotiate the format (e.g. drop codec hint).
276
- const recordedType = recorder.mimeType || resolved.mimeType || 'application/octet-stream';
277
- const finalBlob = new Blob(chunksRef.current, { type: recordedType });
278
- chunksRef.current = [];
279
- setBlob(finalBlob);
280
- setState('stopped');
281
- clearTicker();
282
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
283
- };
284
- recorder.onerror = (event) => {
285
- const detail = (event as unknown as { error?: DOMException }).error;
286
- const err = detail instanceof Error ? detail : new Error('Recorder error');
287
- setError(err);
288
- setState('error');
289
- clearTicker();
290
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
291
- };
286
+ recorder.ondataavailable = (e) => {
287
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
288
+ };
289
+ recorder.onstop = () => {
290
+ if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
291
+ // The recorded MIME type is authoritative once data is in hand —
292
+ // some browsers down-negotiate the format (e.g. drop codec hint).
293
+ const recordedType = recorder.mimeType || resolved.mimeType || 'application/octet-stream';
294
+ const finalBlob = new Blob(chunksRef.current, { type: recordedType });
295
+ chunksRef.current = [];
296
+ setBlob(finalBlob);
297
+ setState('stopped');
298
+ clearTicker();
299
+ stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
300
+ stopPromiseRef.current = null;
301
+ };
302
+ recorder.onerror = (event) => {
303
+ if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
304
+ const detail = (event as unknown as { error?: DOMException }).error;
305
+ const err = detail instanceof Error ? detail : new Error('Recorder error');
306
+ setError(err);
307
+ setState('error');
308
+ clearTicker();
309
+ stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
310
+ stopPromiseRef.current = null;
311
+ };
292
312
 
293
- recorderRef.current = recorder;
294
- disposeStreamRef.current = dispose;
295
- setStream(nextStream);
296
- setFormat(resolved);
297
- setBlob(null);
298
- setDurationMs(0);
299
- setState('ready');
300
- } catch (err: unknown) {
301
- const normalized = err instanceof Error ? err : new Error('Stream acquisition failed');
302
- setError(normalized);
303
- setState('error');
304
- throw normalized;
305
- }
306
- }, [state, clearTicker]);
313
+ recorderRef.current = recorder;
314
+ disposeStreamRef.current = dispose;
315
+ setStream(nextStream);
316
+ setFormat(resolved);
317
+ setBlob(null);
318
+ setDurationMs(0);
319
+ setState('ready');
320
+ acquired = null;
321
+ } catch (err: unknown) {
322
+ if (acquired) {
323
+ acquired.stream.getTracks().forEach((track) => track.stop());
324
+ acquired.dispose();
325
+ }
326
+ const normalized = err instanceof Error ? err : new Error('Stream acquisition failed');
327
+ if (lifecycle === lifecycleRef.current) {
328
+ setError(normalized);
329
+ setState('error');
330
+ }
331
+ throw normalized;
332
+ } finally {
333
+ if (lifecycle === lifecycleRef.current) requestPromiseRef.current = null;
334
+ }
335
+ })();
336
+ requestPromiseRef.current = requestPromise;
337
+ return requestPromise;
338
+ }, [clearTicker]);
307
339
 
308
340
  const start = useCallback(() => {
309
341
  const rec = recorderRef.current;
@@ -329,12 +361,13 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
329
361
  }, [clearTicker]);
330
362
 
331
363
  const stop = useCallback((): Promise<Blob | null> => {
364
+ if (stopPromiseRef.current) return stopPromiseRef.current;
332
365
  const rec = recorderRef.current;
333
366
  if (!rec || rec.state === 'inactive') {
334
367
  return Promise.resolve(blob);
335
368
  }
336
369
  setState('stopping');
337
- return new Promise<Blob | null>((resolve) => {
370
+ const stopPromise = new Promise<Blob | null>((resolve) => {
338
371
  stopResolversRef.current.push(resolve);
339
372
  try {
340
373
  rec.stop();
@@ -346,6 +379,11 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
346
379
  stopResolversRef.current.splice(0).forEach((r) => r(null));
347
380
  }
348
381
  });
382
+ stopPromiseRef.current = stopPromise;
383
+ void stopPromise.finally(() => {
384
+ if (stopPromiseRef.current === stopPromise) stopPromiseRef.current = null;
385
+ });
386
+ return stopPromise;
349
387
  }, [blob, clearTicker]);
350
388
 
351
389
  // Final unmount cleanup — make sure we don't leak the camera light /
@@ -360,9 +398,14 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
360
398
  useEffect(() => {
361
399
  const pendingResolvers = stopResolversRef.current;
362
400
  return () => {
401
+ lifecycleRef.current += 1;
402
+ requestPromiseRef.current = null;
363
403
  const rec = recorderRef.current;
364
404
  if (rec && rec.state !== 'inactive') {
365
405
  try {
406
+ rec.ondataavailable = null;
407
+ rec.onstop = null;
408
+ rec.onerror = null;
366
409
  rec.stop();
367
410
  } catch {
368
411
  // ignore
@@ -371,6 +414,7 @@ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMedi
371
414
  releaseStream();
372
415
  clearTicker();
373
416
  pendingResolvers.splice(0).forEach((resolve) => resolve(null));
417
+ stopPromiseRef.current = null;
374
418
  };
375
419
  }, [releaseStream, clearTicker]);
376
420
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * resolveBlockVisual — resolves a doc block into a renderable card (with
3
- * `layers`) via the shared preview pipeline (`buildPreviewDoc` `getLayers`).
3
+ * `layers`) via the shared preview pipeline and canonical materializer.
4
4
  *
5
5
  * Kept in its own module (rather than alongside `BlockThumbnail` in
6
6
  * `TimelineBlockPreview.tsx`) so that component file exports only components,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Block, DocBlock, Doc, ViewportConfig, Theme } from '@bendyline/squisq/schemas';
11
- import { getLayers, type RenderContext } from '@bendyline/squisq/doc';
11
+ import { materializeBlockLayers, type MaterializeBlockLayersOptions } from '@bendyline/squisq/doc';
12
12
  import { buildPreviewDoc } from './buildPreviewDoc';
13
13
 
14
14
  /**
@@ -24,8 +24,14 @@ export function resolveBlockVisual(
24
24
  try {
25
25
  const slide = buildPreviewDoc({ ...doc, blocks: [block] }).blocks[0] as DocBlock | undefined;
26
26
  if (!slide) return null;
27
- const ctx: RenderContext = { blockIndex: 0, totalBlocks: 1, theme, viewport };
28
- const layers = getLayers(slide, ctx);
27
+ const ctx: MaterializeBlockLayersOptions = {
28
+ blockIndex: 0,
29
+ totalBlocks: 1,
30
+ theme,
31
+ viewport,
32
+ customTemplates: doc.customTemplates,
33
+ };
34
+ const { layers } = materializeBlockLayers(slide, ctx);
29
35
  return { ...(slide as unknown as Block), layers };
30
36
  } catch {
31
37
  return null;
@@ -23,7 +23,7 @@ import {
23
23
  } from 'react';
24
24
  import type { Layer } from '@bendyline/squisq/schemas';
25
25
  import type { SceneCommand, SceneEdge } from './commands/SceneCommand';
26
- import type { SceneTool, SceneToolContext } from './tools/SceneTool';
26
+ import type { SceneInteractionState, SceneTool, SceneToolContext } from './tools/SceneTool';
27
27
  import { useScenePanZoom } from './hooks/useScenePanZoom';
28
28
  import { useSceneSelection } from './hooks/useSceneSelection';
29
29
  import { useSceneHitTest, layerBounds, type HitTestable } from './hooks/useSceneHitTest';
@@ -140,12 +140,21 @@ export function Scene(props: SceneProps) {
140
140
  // ── Pan/zoom + selection ────────────────────────────────────
141
141
  const panZoom = useScenePanZoom();
142
142
  const selection = useSceneSelection();
143
+ const setSceneSelection = selection.setSelection;
143
144
  const { hit } = useSceneHitTest();
145
+ const interaction = useRef<SceneInteractionState>({}).current;
144
146
 
145
- // Surface selection upward so a host can drive a properties panel.
146
- useEffect(() => {
147
- onSelectionChange?.(selection.selection);
148
- }, [selection.selection, onSelectionChange]);
147
+ // Surface selection in the originating interaction rather than a passive
148
+ // effect. Detached editor roots can then update their toolbar without
149
+ // forcing a flush from inside another component's lifecycle.
150
+ const setSelection = useCallback(
151
+ (ids: Iterable<string>) => {
152
+ const next = new Set(ids);
153
+ setSceneSelection(next);
154
+ onSelectionChange?.(next);
155
+ },
156
+ [setSceneSelection, onSelectionChange],
157
+ );
149
158
 
150
159
  // ── Hit-test cache ──────────────────────────────────────────
151
160
  const hitItems: HitTestable[] = useMemo(() => {
@@ -161,6 +170,7 @@ export function Scene(props: SceneProps) {
161
170
  // ── Tool context (rebuilt every render — cheap, no allocations in hot path) ──
162
171
  const ctx: SceneToolContext = useMemo(
163
172
  () => ({
173
+ interaction,
164
174
  viewport,
165
175
  transform: panZoom.transform,
166
176
  layers,
@@ -170,7 +180,7 @@ export function Scene(props: SceneProps) {
170
180
  screenToViewport: panZoom.screenToViewport,
171
181
  viewportToScreen: panZoom.viewportToScreen,
172
182
  hit: (p) => hit(p, hitItems),
173
- setSelection: selection.setSelection,
183
+ setSelection,
174
184
  dispatch: onCommand,
175
185
  setActiveTool,
176
186
  beginTextEdit: textEdit.begin,
@@ -183,16 +193,17 @@ export function Scene(props: SceneProps) {
183
193
  layers,
184
194
  edges,
185
195
  selection.selection,
186
- selection.setSelection,
196
+ setSelection,
187
197
  hitItems,
188
198
  hit,
189
199
  onCommand,
190
200
  setActiveTool,
191
201
  textEdit.begin,
202
+ interaction,
192
203
  ],
193
204
  );
194
- // Mirror ctx in a ref so keyboard handlers attached to window read the
195
- // latest selection without re-binding on every selection change.
205
+ // Mirror ctx in a ref so root keyboard handlers read the latest selection
206
+ // without re-binding on every selection change.
196
207
  const ctxRef = useRef(ctx);
197
208
  ctxRef.current = ctx;
198
209
 
@@ -220,12 +231,9 @@ export function Scene(props: SceneProps) {
220
231
  [panZoom],
221
232
  );
222
233
 
223
- // Tools keep their drag state in module-level closures (so it survives
224
- // outside React's render cycle). To make the live drag/resize preview
225
- // *visible*, the Scene needs to re-render on every pointer-move while
226
- // a gesture is in progress. We bump `dragTick` on pointer-down and on
227
- // every move thereafter — cheap (one setState per move) and only fires
228
- // while the user is actively interacting.
234
+ // Gesture state belongs to this Scene instance and lives outside React's
235
+ // render cycle. Bump `dragTick` while a pointer gesture is active so its
236
+ // live drag/resize preview is visible.
229
237
  const [, setDragTick] = useState(0);
230
238
  const pointerActive = useRef(false);
231
239
  const bumpTick = useCallback(() => setDragTick((t) => t + 1), []);
@@ -280,6 +288,27 @@ export function Scene(props: SceneProps) {
280
288
  [activeTool],
281
289
  );
282
290
 
291
+ const handlePointerCancel = useCallback(
292
+ (e: React.PointerEvent<SVGSVGElement>) => {
293
+ pointerActive.current = false;
294
+ isPanning.current = false;
295
+ panLast.current = null;
296
+ interaction.selectDrag = undefined;
297
+ interaction.connect = undefined;
298
+ (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
299
+ bumpTick();
300
+ },
301
+ [interaction, bumpTick],
302
+ );
303
+
304
+ useEffect(
305
+ () => () => {
306
+ interaction.selectDrag = undefined;
307
+ interaction.connect = undefined;
308
+ },
309
+ [interaction],
310
+ );
311
+
283
312
  const handleDoubleClick = useCallback(
284
313
  (e: React.MouseEvent<SVGSVGElement>) => {
285
314
  activeTool?.onDoubleClick?.(e, ctxRef.current);
@@ -387,8 +416,9 @@ export function Scene(props: SceneProps) {
387
416
  }, [containerSize, hitItems, panZoom]);
388
417
 
389
418
  // ── Render ──────────────────────────────────────────────────
390
- const liveOffset = activeId === 'select' ? getActiveMoveOffset() : null;
391
- const liveResize = activeId === 'select' ? getActiveResize() : null;
419
+ const liveOffset = activeId === 'select' ? getActiveMoveOffset(interaction) : null;
420
+ const liveResize = activeId === 'select' ? getActiveResize(interaction) : null;
421
+ const showSelectionHandles = activeTool?.hideSelectionHandles !== true;
392
422
 
393
423
  // Per-layer transform applied during an in-flight drag or resize so
394
424
  // the user sees the layer move/stretch in real time, not just on commit.
@@ -415,6 +445,32 @@ export function Scene(props: SceneProps) {
415
445
  return {};
416
446
  };
417
447
 
448
+ // A text box's font size is independent of its box size — a wide box exists
449
+ // precisely so a small caption can left/center/right-align within it. So
450
+ // when a text box is directly resized we reshape the box and re-align its
451
+ // text at the *unchanged* font, rather than scaling the glyphs with the box
452
+ // (the generic matrix path in `wrapperFor`, which would stretch the text and
453
+ // then snap it back to its real size on commit). Returns a clone positioned
454
+ // at the live-resize box, or null when `layer` isn't a text box being
455
+ // directly resized. Follower labels (drawing/diagram, whose font is derived
456
+ // from box height) are resized via their shape, not directly, so they never
457
+ // match here and keep the scale-with-box preview.
458
+ const liveResizedTextBox = (layer: Layer): Layer | null => {
459
+ if (!liveResize || liveResize.layerId !== layer.id || layer.type !== 'text') return null;
460
+ const b = liveResize.bounds;
461
+ return {
462
+ ...layer,
463
+ position: {
464
+ ...layer.position,
465
+ x: b.x,
466
+ y: b.y,
467
+ width: b.width,
468
+ height: b.height,
469
+ anchor: 'top-left',
470
+ },
471
+ };
472
+ };
473
+
418
474
  const handleHandlePointerDown = (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => {
419
475
  const id = selection.selection.values().next().value as string | undefined;
420
476
  if (!id) return;
@@ -426,7 +482,7 @@ export function Scene(props: SceneProps) {
426
482
  const sx = e.clientX - rect.left;
427
483
  const sy = e.clientY - rect.top;
428
484
  const startV = panZoom.screenToViewport(sx, sy);
429
- beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds });
485
+ beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds }, interaction);
430
486
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
431
487
  // Mark a gesture as active so the Scene's pointer-move handler
432
488
  // triggers re-renders for the live resize preview.
@@ -448,27 +504,47 @@ export function Scene(props: SceneProps) {
448
504
  onPointerDown={handlePointerDown}
449
505
  onPointerMove={handlePointerMove}
450
506
  onPointerUp={handlePointerUp}
507
+ onPointerCancel={handlePointerCancel}
451
508
  onDoubleClick={handleDoubleClick}
452
509
  onDragOver={onDrop ? handleDragOver : undefined}
453
510
  onDrop={onDrop ? handleDrop : undefined}
454
511
  >
455
512
  {/* Extras (e.g. diagram edges) render behind the layers so the
456
513
  cards visually clip edge endpoints. */}
457
- {renderExtras?.(ctx)}
514
+ {renderExtras && <g className="squisq-scene-extras">{renderExtras(ctx)}</g>}
458
515
  {/* Layers — the Scene wraps each in a transformable <g> so a
459
516
  selected layer can show a live drag/resize preview without
460
517
  requiring the host to know about drag state. */}
461
- {layers.map((layer) => (
462
- <g key={layer.id} data-layer-id={layer.id} {...wrapperFor(layer)}>
463
- {layerRenderer(layer, viewport)}
464
- </g>
465
- ))}
518
+ {layers.map((layer) => {
519
+ const layerClassName = `squisq-scene-layer squisq-scene-layer--${layer.type}`;
520
+ // Text boxes reshape (font-fixed) rather than scale during resize;
521
+ // everything else stretches via the group transform.
522
+ const resizedText = liveResizedTextBox(layer);
523
+ if (resizedText) {
524
+ return (
525
+ <g key={layer.id} className={layerClassName} data-layer-id={layer.id}>
526
+ {layerRenderer(resizedText, viewport)}
527
+ </g>
528
+ );
529
+ }
530
+ return (
531
+ <g
532
+ key={layer.id}
533
+ className={layerClassName}
534
+ data-layer-id={layer.id}
535
+ {...wrapperFor(layer)}
536
+ >
537
+ {layerRenderer(layer, viewport)}
538
+ </g>
539
+ );
540
+ })}
466
541
  <SceneSelection
467
542
  selection={selection.selection}
468
543
  hitItems={hitItems}
469
544
  liveOffset={liveOffset}
470
545
  liveResize={liveResize}
471
- onHandlePointerDown={handleHandlePointerDown}
546
+ onHandlePointerDown={showSelectionHandles ? handleHandlePointerDown : undefined}
547
+ showHandles={showSelectionHandles}
472
548
  />
473
549
  {activeTool?.renderOverlay?.(ctx)}
474
550
  </SceneViewport>
@@ -20,6 +20,7 @@ import { createElement } from 'react';
20
20
  import type { Node as PMNode } from '@tiptap/pm/model';
21
21
  import { parsePandocAttrTokens } from '@bendyline/squisq/markdown';
22
22
  import { SceneBlockWidget, type SceneBlockMode } from './SceneBlockWidget';
23
+ import type { SceneTextChannel } from './text/sceneTextChannel';
23
24
 
24
25
  /**
25
26
  * Stable identifier for a layout/drawing heading — used as part of the
@@ -27,24 +28,24 @@ import { SceneBlockWidget, type SceneBlockMode } from './SceneBlockWidget';
27
28
  * changes (drag/resize commits write into the heading's
28
29
  * `data-block-attrs`).
29
30
  */
30
- export function getHeadingKey(node: PMNode): string {
31
+ export function getHeadingKey(node: PMNode, position?: number): string {
31
32
  const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
32
33
  if (typeof raw === 'string' && raw.length > 0) {
33
34
  const attrs = parsePandocAttrTokens(raw);
34
- if (attrs.id) return attrs.id;
35
+ if (attrs.id) return position == null ? attrs.id : `${attrs.id}@${position}`;
35
36
  }
36
37
  let text = '';
37
38
  node.content.forEach((child) => {
38
39
  if (child.isText) text += child.text ?? '';
39
40
  });
40
- return (
41
+ const slug =
41
42
  text
42
43
  .toLowerCase()
43
44
  .replace(/[^\w\s-]/g, '')
44
45
  .replace(/[\s_]+/g, '-')
45
46
  .replace(/-+/g, '-')
46
- .replace(/^-|-$/g, '') || 'block'
47
- );
47
+ .replace(/^-|-$/g, '') || 'block';
48
+ return position == null ? slug : `${slug}@${position}`;
48
49
  }
49
50
 
50
51
  /**
@@ -63,7 +64,7 @@ export function findSceneHeadingPos(
63
64
  if (node.type.name !== 'heading') return;
64
65
  const attrs = node.attrs as { dataTemplate?: string };
65
66
  if (attrs.dataTemplate !== expectedTemplate) return;
66
- if (getHeadingKey(node) === headingKey) {
67
+ if (getHeadingKey(node, pos) === headingKey) {
67
68
  found = pos;
68
69
  return false;
69
70
  }
@@ -82,7 +83,11 @@ interface SceneRoot {
82
83
  root: Root;
83
84
  }
84
85
 
85
- function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
86
+ function buildDecorations(
87
+ state: EditorState,
88
+ editor: Editor,
89
+ textChannel?: SceneTextChannel,
90
+ ): DecorationSet {
86
91
  const decos: Decoration[] = [];
87
92
 
88
93
  state.doc.descendants((node, pos) => {
@@ -93,7 +98,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
93
98
  const parentDepth = attrs.level;
94
99
  const widgetPos = pos + node.nodeSize;
95
100
  const parentPos = pos;
96
- const headingKey = getHeadingKey(node);
101
+ const headingKey = getHeadingKey(node, pos);
97
102
  decos.push(
98
103
  Decoration.widget(
99
104
  widgetPos,
@@ -112,6 +117,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
112
117
  fallbackParentPos: parentPos,
113
118
  mode,
114
119
  host: view.dom.parentElement ?? view.dom,
120
+ textChannel,
115
121
  }),
116
122
  );
117
123
 
@@ -161,6 +167,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
161
167
  export interface SceneBlockExtensionOptions {
162
168
  /** When false, the extension is inert. */
163
169
  enabled?: boolean;
170
+ textChannel?: SceneTextChannel;
164
171
  }
165
172
 
166
173
  export const SceneBlockExtension = Extension.create<SceneBlockExtensionOptions>({
@@ -179,10 +186,10 @@ export const SceneBlockExtension = Extension.create<SceneBlockExtensionOptions>(
179
186
  new Plugin({
180
187
  key: KEY,
181
188
  state: {
182
- init: (_config, state) => buildDecorations(state, editor),
189
+ init: (_config, state) => buildDecorations(state, editor, this.options.textChannel),
183
190
  apply: (tr, oldDecos, _oldState, newState) => {
184
191
  if (!tr.docChanged) return oldDecos;
185
- return buildDecorations(newState, editor);
192
+ return buildDecorations(newState, editor, this.options.textChannel);
186
193
  },
187
194
  },
188
195
  props: {
@@ -25,10 +25,12 @@ import { shapeIdFromLayerId } from './layers/shapeLayers';
25
25
  import { ShapePalette } from './ShapePalette';
26
26
  import { ScenePropsBar } from './ScenePropsBar';
27
27
  import { SceneBlockToolbar, type SceneBlockAction, type SceneAlignment } from './SceneBlockToolbar';
28
+ import { SceneSideToolbar } from './SceneSideToolbar';
28
29
  import type { SceneTextEditConfig } from './text/sceneTextConfig';
29
30
  import { markdownToTiptap } from '../tiptapBridge';
30
31
  import { Icon } from '../Icon';
31
32
  import { DiagramMaximizedOverlay } from '../diagram/DiagramMaximizedOverlay';
33
+ import type { SceneTextChannel } from './text/sceneTextChannel';
32
34
 
33
35
  export type SceneBlockMode = 'layout' | 'drawing';
34
36
 
@@ -39,6 +41,7 @@ interface SceneBlockWidgetProps {
39
41
  mode: SceneBlockMode;
40
42
  /** Host element for portal targeting (used by maximize overlay). */
41
43
  host?: HTMLElement | null;
44
+ textChannel?: SceneTextChannel;
42
45
  }
43
46
 
44
47
  // Default viewport for layout/drawing surfaces. Matches Squisq's 16:9
@@ -58,6 +61,7 @@ export function SceneBlockWidget({
58
61
  fallbackParentPos,
59
62
  mode,
60
63
  host,
64
+ textChannel,
61
65
  }: SceneBlockWidgetProps) {
62
66
  const [parentPos, setParentPos] = useState<number>(
63
67
  () => findSceneHeadingPos(editor, headingKey, mode) ?? fallbackParentPos,
@@ -88,6 +92,7 @@ export function SceneBlockWidget({
88
92
  dispatchRef.current = adapter.dispatch;
89
93
  const textConfig = useMemo<SceneTextEditConfig>(
90
94
  () => ({
95
+ channel: textChannel,
91
96
  resolveEditableId: (id) => {
92
97
  const l = layersRef.current.find((x) => x.id === id);
93
98
  return l && l.type === 'text' ? id : null;
@@ -113,7 +118,7 @@ export function SceneBlockWidget({
113
118
  // content is stored as the child sub-block's markdown body.
114
119
  level: isDrawing ? 'inline' : 'block',
115
120
  }),
116
- [isDrawing],
121
+ [isDrawing, textChannel],
117
122
  );
118
123
 
119
124
  const [maximized, setMaximized] = useState(false);
@@ -301,15 +306,15 @@ export function SceneBlockWidget({
301
306
  );
302
307
 
303
308
  const body = <div className="squisq-scene-stage">{canvas}</div>;
304
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
305
309
 
306
310
  if (maximized) {
307
311
  return (
308
312
  <div className="squisq-scene-inline-placeholder">
309
313
  <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
314
+ {/* Maximized has a full screen for a static right column — no collapse. */}
310
315
  <div className="squisq-scene-block-max">
311
316
  {body}
312
- {sideToolbar}
317
+ <div className="squisq-scene-side-toolbar">{toolbar}</div>
313
318
  </div>
314
319
  </DiagramMaximizedOverlay>
315
320
  </div>
@@ -318,8 +323,10 @@ export function SceneBlockWidget({
318
323
 
319
324
  return (
320
325
  <div className="squisq-scene-shell">
326
+ {/* Before the canvas so the narrow-width fallback bar sits above it; the
327
+ wide-width gutter column is absolute and unaffected by DOM order. */}
328
+ <SceneSideToolbar>{toolbar}</SceneSideToolbar>
321
329
  <div className="squisq-scene-inline">{body}</div>
322
- {sideToolbar}
323
330
  </div>
324
331
  );
325
332
  }