@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
@@ -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,8 @@ 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;
392
421
  const showSelectionHandles = activeTool?.hideSelectionHandles !== true;
393
422
 
394
423
  // Per-layer transform applied during an in-flight drag or resize so
@@ -453,7 +482,7 @@ export function Scene(props: SceneProps) {
453
482
  const sx = e.clientX - rect.left;
454
483
  const sy = e.clientY - rect.top;
455
484
  const startV = panZoom.screenToViewport(sx, sy);
456
- beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds });
485
+ beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds }, interaction);
457
486
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
458
487
  // Mark a gesture as active so the Scene's pointer-move handler
459
488
  // triggers re-renders for the live resize preview.
@@ -475,6 +504,7 @@ export function Scene(props: SceneProps) {
475
504
  onPointerDown={handlePointerDown}
476
505
  onPointerMove={handlePointerMove}
477
506
  onPointerUp={handlePointerUp}
507
+ onPointerCancel={handlePointerCancel}
478
508
  onDoubleClick={handleDoubleClick}
479
509
  onDragOver={onDrop ? handleDragOver : undefined}
480
510
  onDrop={onDrop ? handleDrop : undefined}
@@ -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: {
@@ -30,6 +30,7 @@ import type { SceneTextEditConfig } from './text/sceneTextConfig';
30
30
  import { markdownToTiptap } from '../tiptapBridge';
31
31
  import { Icon } from '../Icon';
32
32
  import { DiagramMaximizedOverlay } from '../diagram/DiagramMaximizedOverlay';
33
+ import type { SceneTextChannel } from './text/sceneTextChannel';
33
34
 
34
35
  export type SceneBlockMode = 'layout' | 'drawing';
35
36
 
@@ -40,6 +41,7 @@ interface SceneBlockWidgetProps {
40
41
  mode: SceneBlockMode;
41
42
  /** Host element for portal targeting (used by maximize overlay). */
42
43
  host?: HTMLElement | null;
44
+ textChannel?: SceneTextChannel;
43
45
  }
44
46
 
45
47
  // Default viewport for layout/drawing surfaces. Matches Squisq's 16:9
@@ -59,6 +61,7 @@ export function SceneBlockWidget({
59
61
  fallbackParentPos,
60
62
  mode,
61
63
  host,
64
+ textChannel,
62
65
  }: SceneBlockWidgetProps) {
63
66
  const [parentPos, setParentPos] = useState<number>(
64
67
  () => findSceneHeadingPos(editor, headingKey, mode) ?? fallbackParentPos,
@@ -89,6 +92,7 @@ export function SceneBlockWidget({
89
92
  dispatchRef.current = adapter.dispatch;
90
93
  const textConfig = useMemo<SceneTextEditConfig>(
91
94
  () => ({
95
+ channel: textChannel,
92
96
  resolveEditableId: (id) => {
93
97
  const l = layersRef.current.find((x) => x.id === id);
94
98
  return l && l.type === 'text' ? id : null;
@@ -114,7 +118,7 @@ export function SceneBlockWidget({
114
118
  // content is stored as the child sub-block's markdown body.
115
119
  level: isDrawing ? 'inline' : 'block',
116
120
  }),
117
- [isDrawing],
121
+ [isDrawing, textChannel],
118
122
  );
119
123
 
120
124
  const [maximized, setMaximized] = useState(false);
@@ -11,7 +11,7 @@
11
11
  * canvas grid would.
12
12
  */
13
13
 
14
- import { forwardRef, type ReactNode, type WheelEvent } from 'react';
14
+ import { forwardRef, useId, type ReactNode, type WheelEvent } from 'react';
15
15
  import type { SceneTransform } from './hooks/useScenePanZoom';
16
16
 
17
17
  interface SceneViewportProps {
@@ -24,6 +24,7 @@ interface SceneViewportProps {
24
24
  onPointerDown?: (e: React.PointerEvent<SVGSVGElement>) => void;
25
25
  onPointerMove?: (e: React.PointerEvent<SVGSVGElement>) => void;
26
26
  onPointerUp?: (e: React.PointerEvent<SVGSVGElement>) => void;
27
+ onPointerCancel?: (e: React.PointerEvent<SVGSVGElement>) => void;
27
28
  onDoubleClick?: (e: React.MouseEvent<SVGSVGElement>) => void;
28
29
  /** HTML5 drag-and-drop handlers — used for palette drag-to-place. */
29
30
  onDragOver?: (e: React.DragEvent<SVGSVGElement>) => void;
@@ -43,6 +44,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
43
44
  onPointerDown,
44
45
  onPointerMove,
45
46
  onPointerUp,
47
+ onPointerCancel,
46
48
  onDoubleClick,
47
49
  onDragOver,
48
50
  onDrop,
@@ -52,6 +54,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
52
54
  ref,
53
55
  ) {
54
56
  const t = transform;
57
+ const gridId = `squisq-scene-dot-grid-${useId().replace(/:/g, '')}`;
55
58
  const groupTransform = `translate(${t.tx} ${t.ty}) scale(${t.scale})`;
56
59
 
57
60
  return (
@@ -70,6 +73,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
70
73
  onPointerDown={onPointerDown}
71
74
  onPointerMove={onPointerMove}
72
75
  onPointerUp={onPointerUp}
76
+ onPointerCancel={onPointerCancel}
73
77
  onDoubleClick={onDoubleClick}
74
78
  onDragOver={onDragOver}
75
79
  onDrop={onDrop}
@@ -85,7 +89,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
85
89
  >
86
90
  <defs>
87
91
  <pattern
88
- id="squisq-scene-dot-grid"
92
+ id={gridId}
89
93
  x={t.tx}
90
94
  y={t.ty}
91
95
  width={24 * t.scale}
@@ -95,7 +99,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
95
99
  <circle cx={1} cy={1} r={1} className="squisq-scene-dot" />
96
100
  </pattern>
97
101
  </defs>
98
- <rect width="100%" height="100%" fill="url(#squisq-scene-dot-grid)" />
102
+ <rect width="100%" height="100%" fill={`url(#${gridId})`} />
99
103
  <g transform={groupTransform}>{children}</g>
100
104
  </svg>
101
105
  );
@@ -8,7 +8,7 @@
8
8
  * gets drawn.
9
9
  */
10
10
 
11
- import { useEffect, useMemo, useRef, useState } from 'react';
11
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
12
12
  import { shapePath } from '@bendyline/squisq/doc';
13
13
 
14
14
  interface ShapePaletteProps {
@@ -152,8 +152,23 @@ export function ShapePalette({
152
152
  /** A 40×30 SVG thumbnail of a shape, inside a 48×38 button viewport. */
153
153
  function ShapeThumb({ kind }: { kind: string }) {
154
154
  const d = shapePath(kind, 4, 4, 40, 30);
155
+ const arrowId = `squisq-shape-thumb-arrow-${useId().replace(/:/g, '')}`;
155
156
  return (
156
157
  <svg viewBox="0 0 48 38" width={36} height={28} aria-hidden="true">
158
+ {kind === 'arrow' ? (
159
+ <defs>
160
+ <marker
161
+ id={arrowId}
162
+ viewBox="0 0 10 10"
163
+ refX={9}
164
+ refY={5}
165
+ markerWidth={4}
166
+ markerHeight={4}
167
+ >
168
+ <path d="M 0 0 L 10 5 L 0 10 z" className="squisq-shape-thumb-line" />
169
+ </marker>
170
+ </defs>
171
+ ) : null}
157
172
  {d ? (
158
173
  <path d={d} className="squisq-shape-thumb" />
159
174
  ) : kind === 'circle' ? (
@@ -167,7 +182,7 @@ function ShapeThumb({ kind }: { kind: string }) {
167
182
  x2={44}
168
183
  y2={4}
169
184
  className="squisq-shape-thumb-line"
170
- markerEnd="url(#squisq-edge-arrow-end)"
185
+ markerEnd={`url(#${arrowId})`}
171
186
  />
172
187
  ) : kind === 'text' ? (
173
188
  <text x={24} y={26} textAnchor="middle" className="squisq-shape-thumb-text">