@bendyline/squisq-editor-react 1.5.3 → 1.6.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 (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16875 -6883
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +169 -13
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +2 -14
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +8 -1
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +23 -1
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -13,6 +13,11 @@
13
13
 
14
14
  import { templateLabel } from './TemplatePicker';
15
15
  import { resolveIcon } from '@bendyline/squisq/icons';
16
+ import {
17
+ matchTrailingTemplateAnnotation,
18
+ matchTrailingPandocAttr,
19
+ tokenizeAttrTokens,
20
+ } from '@bendyline/squisq/markdown';
16
21
 
17
22
  // Hoisted regex patterns for inline markdown ↔ HTML conversion
18
23
  const RE_BOLD_STAR = /\*\*(.+?)\*\*/g;
@@ -194,20 +199,49 @@ export function markdownToTiptap(markdown: string): string {
194
199
  let text = headingMatch[2];
195
200
  let attrs = '';
196
201
 
197
- // Extract {[template key=value …]} annotation. Trailing `[\s\]\}]*`
198
- // tolerates accidental doubled `]}` that users type while learning
199
- // the syntax must stay in sync with TEMPLATE_ANNOTATION_RE in
200
- // packages/core/src/markdown/convert.ts.
201
- const annotMatch = text.match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
202
- if (annotMatch) {
203
- text = text.slice(0, annotMatch.index!).trimEnd();
204
- const tokens = annotMatch[1].trim().split(/\s+/);
205
- attrs = ` data-template="${escapeHtml(tokens[0])}"`;
202
+ // Peel off trailing brace-blocks in any order: the squisq-native
203
+ // `{[template ]}` annotation and the Pandoc `{#id .class key=value}`
204
+ // attribute block may both appear at the end of the heading line.
205
+ // Loop until neither matches. The matchers are imported from core so
206
+ // this stays in sync with packages/core/src/markdown/convert.ts.
207
+ let templateInner: string | null = null;
208
+ let pandocInner: string | null = null;
209
+ for (let pass = 0; pass < 4; pass++) {
210
+ let matched = false;
211
+ if (templateInner == null) {
212
+ const m = matchTrailingTemplateAnnotation(text);
213
+ if (m) {
214
+ templateInner = m.inner.trim();
215
+ text = text.slice(0, m.index).trimEnd();
216
+ matched = true;
217
+ }
218
+ }
219
+ if (pandocInner == null) {
220
+ const m = matchTrailingPandocAttr(text);
221
+ if (m) {
222
+ pandocInner = m.inner.trim();
223
+ text = text.slice(0, m.index).trimEnd();
224
+ matched = true;
225
+ }
226
+ }
227
+ if (!matched) break;
228
+ }
229
+
230
+ if (templateInner != null) {
231
+ // Quote-aware tokenization (shared with the core parser) keeps a
232
+ // quoted value like caption="Beach at sunset" as one token. The
233
+ // tokens are stored raw — quotes included — so tiptapToMarkdown
234
+ // can re-join them into the annotation verbatim.
235
+ const tokens = tokenizeAttrTokens(templateInner);
236
+ attrs += ` data-template="${escapeHtml(tokens[0] ?? '')}"`;
206
237
  const params = tokens.slice(1).filter((t) => t.includes('='));
207
238
  if (params.length > 0) {
208
239
  attrs += ` data-template-params="${escapeHtml(params.join(' '))}"`;
209
240
  }
210
241
  }
242
+ if (pandocInner != null) {
243
+ attrs += ` data-block-attrs="${escapeHtml(pandocInner)}"`;
244
+ }
211
245
 
212
246
  outputBlocks.push(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
213
247
  continue;
@@ -227,17 +261,20 @@ export function markdownToTiptap(markdown: string): string {
227
261
  continue;
228
262
  }
229
263
 
230
- // Task list item
231
- const taskMatch = line.match(/^[-*+]\s+\[([xX ])\]\s+(.+)$/);
264
+ // Task list item. Text is optional so freshly-typed, still-empty tasks
265
+ // survive a round-trip. The checkbox itself is drawn by TaskItem's node
266
+ // view from the data-checked attribute, so we only emit the text content
267
+ // (in a paragraph, matching Tiptap's own node structure).
268
+ const taskMatch = line.match(/^[-*+]\s+\[([xX ])\]\s*(.*)$/);
232
269
  if (taskMatch) {
233
270
  if (!inList || listType !== 'task') {
234
271
  flushList();
235
272
  inList = true;
236
273
  listType = 'task';
237
274
  }
238
- const checked = taskMatch[1].toLowerCase() === 'x' ? ' data-checked="true"' : '';
275
+ const checkedAttr = taskMatch[1].toLowerCase() === 'x' ? ' data-checked="true"' : '';
239
276
  listItems.push(
240
- `<li data-type="taskItem"${checked}><label><input type="checkbox"${checked ? ' checked' : ''}>${inlineToHtml(taskMatch[2])}</label></li>`,
277
+ `<li data-type="taskItem"${checkedAttr}><p>${inlineToHtml(taskMatch[2])}</p></li>`,
241
278
  );
242
279
  continue;
243
280
  }
@@ -354,19 +391,26 @@ export function tiptapToMarkdown(html: string): string {
354
391
  const attrs = headingMatch[2];
355
392
  let text = htmlToInline(headingMatch[3]);
356
393
 
357
- // Re-inject template annotation from data attributes
394
+ // Re-inject heading annotations from data attributes. Canonical
395
+ // emit order: Pandoc `{#…}` first, then squisq `{[…]}` template
396
+ // annotation (matches blockToMdast in core/markdown/convert.ts).
397
+ const blockAttrsMatch = attrs.match(/data-block-attrs="([^"]*)"/);
358
398
  const tmplMatch = attrs.match(/data-template="([^"]+)"/);
359
399
  if (tmplMatch) {
360
- let annotation = tmplMatch[1];
361
- // Defensive: an earlier broken build briefly rendered the
362
- // template label as a real text node inside the badge, which
363
- // bled the label into the heading's textContent. Strip a
364
- // trailing copy of the template's label so existing documents
365
- // self-heal on save.
366
- const label = templateLabel(annotation);
400
+ // Strip an accidental trailing copy of the template label that an
401
+ // earlier broken build briefly rendered as real text inside the
402
+ // badge. Existing documents self-heal on save.
403
+ const label = templateLabel(tmplMatch[1]);
367
404
  if (label && text.endsWith(label)) {
368
405
  text = text.slice(0, -label.length).trimEnd();
369
406
  }
407
+ }
408
+ if (blockAttrsMatch) {
409
+ const inner = unescapeHtml(blockAttrsMatch[1]);
410
+ text += ` {${inner}}`;
411
+ }
412
+ if (tmplMatch) {
413
+ let annotation = tmplMatch[1];
370
414
  const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
371
415
  if (paramsMatch) {
372
416
  annotation += ' ' + unescapeHtml(paramsMatch[1]);
@@ -484,14 +528,16 @@ export function tiptapToMarkdown(html: string): string {
484
528
  // Task list
485
529
  const taskListMatch = remaining.match(/^<ul[^>]*data-type="taskList"[^>]*>(.*?)<\/ul>/s);
486
530
  if (taskListMatch) {
487
- const items = taskListMatch[1].matchAll(
488
- /<li[^>]*data-type="taskItem"[^>]*(data-checked="true")?[^>]*>.*?<\/li>/gs,
489
- );
531
+ const items = taskListMatch[1].matchAll(/<li[^>]*data-type="taskItem"[^>]*>.*?<\/li>/gs);
490
532
  for (const item of items) {
491
- const checked = item[0].includes('data-checked="true"') || item[0].includes('checked');
492
- const textMatch = item[0].match(/<label>.*?<\/label>|<p>(.*?)<\/p>/s);
493
- const text = textMatch ? htmlToInline(textMatch[0].replace(/<[^>]+>/g, '')) : '';
494
- lines.push(`- [${checked ? 'x' : ' '}] ${text}`);
533
+ // Read checked state from the attribute value only — a bare
534
+ // includes('checked') matches the substring in data-checked="false".
535
+ const checked = /data-checked="true"/.test(item[0]);
536
+ // Drop the checkbox chrome (<label>…</label>) before reading text;
537
+ // Tiptap puts the task text in the trailing content node, not the label.
538
+ const body = item[0].replace(/<label\b[^>]*>.*?<\/label>/s, '');
539
+ const text = htmlToInline(body.replace(/<[^>]+>/g, '').trim());
540
+ lines.push(`- [${checked ? 'x' : ' '}] ${text}`.trimEnd());
495
541
  }
496
542
  lines.push('');
497
543
  remaining = remaining.slice(taskListMatch[0].length);
@@ -605,12 +651,28 @@ export function tiptapToMarkdown(html: string): string {
605
651
  function renderListItem(prefix: string, html: string): string[] {
606
652
  const indent = ' '.repeat(prefix.length);
607
653
 
654
+ // Pull out any block-level media nested in the item (e.g. a recording
655
+ // dropped onto a list bullet, or a clip dragged into one). The inline
656
+ // paragraph walk below ignores `<video>` / `<audio>` tags, so without
657
+ // this they'd be silently dropped on serialize and lost from the
658
+ // markdown source. We collect them and re-emit each as an indented
659
+ // continuation line so they stay inside the list item and round-trip
660
+ // through the markdown parser's block-media handler.
661
+ const media: string[] = [];
662
+ const htmlWithoutMedia = html.replace(
663
+ /<(video|audio)\b([^>]*)>(?:[\s\S]*?<\/\1>)?/gi,
664
+ (_full, tag: string, attrs: string) => {
665
+ media.push(serializeMediaTag(tag.toLowerCase() === 'video' ? 'video' : 'audio', attrs ?? ''));
666
+ return '';
667
+ },
668
+ );
669
+
608
670
  // Split on </p><p> to detect paragraph breaks within the item
609
- const paragraphs = html
671
+ const paragraphs = htmlWithoutMedia
610
672
  .split(/<\/p>\s*<p[^>]*>/i)
611
673
  .map((p) => p.replace(/^<p[^>]*>/i, '').replace(/<\/p>\s*$/i, ''));
612
674
 
613
- const result: string[] = [];
675
+ const textLines: string[] = [];
614
676
  paragraphs.forEach((paragraph, pIdx) => {
615
677
  const inline = htmlToInline(paragraph).trim();
616
678
  if (!inline) return;
@@ -619,16 +681,35 @@ function renderListItem(prefix: string, html: string): string[] {
619
681
  const subLines = inline.split('\n');
620
682
  subLines.forEach((sub, sIdx) => {
621
683
  if (pIdx === 0 && sIdx === 0) {
622
- result.push(prefix + sub);
684
+ textLines.push(prefix + sub);
623
685
  } else {
624
686
  // Blank line separator between paragraphs (sIdx === 0 means new paragraph)
625
- if (sIdx === 0) result.push('');
626
- result.push(indent + sub);
687
+ if (sIdx === 0) textLines.push('');
688
+ textLines.push(indent + sub);
627
689
  }
628
690
  });
629
691
  });
630
692
 
631
- return result.length > 0 ? result : [prefix];
693
+ if (textLines.length === 0 && media.length === 0) return [prefix];
694
+
695
+ // Text first (its first line carries the bullet marker), then each media
696
+ // tag as its own indented continuation block. If the item is media-only,
697
+ // the first tag takes the bullet so the item isn't emitted empty.
698
+ const result: string[] = [];
699
+ if (textLines.length > 0) {
700
+ result.push(...textLines);
701
+ for (const tag of media) {
702
+ result.push('');
703
+ result.push(indent + tag);
704
+ }
705
+ } else {
706
+ result.push(prefix + media[0]);
707
+ for (const tag of media.slice(1)) {
708
+ result.push('');
709
+ result.push(indent + tag);
710
+ }
711
+ }
712
+ return result;
632
713
  }
633
714
 
634
715
  // ─── Table helpers ───────────────────────────────────────
@@ -0,0 +1,13 @@
1
+ const EDGE_PADDING_PX = 8;
2
+
3
+ export function clampTooltipLeft(
4
+ anchorX: number,
5
+ tooltipWidth: number,
6
+ viewportWidth: number,
7
+ edgePadding = EDGE_PADDING_PX,
8
+ ): number {
9
+ const minLeft = edgePadding;
10
+ const maxLeft = Math.max(minLeft, viewportWidth - edgePadding - tooltipWidth);
11
+ const centeredLeft = anchorX - tooltipWidth / 2;
12
+ return Math.min(maxLeft, Math.max(minLeft, centeredLeft));
13
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * transitionCatalog
3
+ *
4
+ * Editor-facing, curated presentation of the block transition vocabulary.
5
+ *
6
+ * Core's `TRANSITION_TYPES` (packages/core/src/schemas/Transitions.ts) lists
7
+ * ~80 names, including legacy aliases and near-duplicate spellings. This
8
+ * catalog hand-picks the distinct, useful transitions, gives each a friendly
9
+ * label, and groups them for the toolbar's transition flyout — the same
10
+ * "core holds the truth, the editor holds the presentation" split the block
11
+ * `TemplatePicker` uses.
12
+ *
13
+ * Every `value` here MUST be a real `TransitionType`; `transitionCatalog.test.ts`
14
+ * enforces that so a renamed/removed core transition fails the build instead
15
+ * of silently producing an invalid `transition=` annotation. The catalog is
16
+ * intentionally NOT exhaustive — aliases and redundant spellings are omitted.
17
+ */
18
+
19
+ /**
20
+ * How a transition takes a direction, driving which direction sub-control
21
+ * the picker shows. Mirrors core's `getTransitionVisualClass` dispatch:
22
+ * - `lrud`: left / right / up / down (push, wipe, cover, uncover, reveal, pan)
23
+ * - `axis`: horizontal / vertical (split, blinds)
24
+ * Entries without a model take no `transitionDirection`.
25
+ */
26
+ export type DirectionModel = 'lrud' | 'axis';
27
+
28
+ export interface TransitionCatalogEntry {
29
+ /** Canonical `transition=` value — must be a core `TransitionType`. */
30
+ value: string;
31
+ /** Friendly label shown in the flyout and trigger. */
32
+ label: string;
33
+ /** Direction model, when the transition is directional. */
34
+ direction?: DirectionModel;
35
+ }
36
+
37
+ export interface TransitionGroup {
38
+ title: string;
39
+ entries: TransitionCatalogEntry[];
40
+ }
41
+
42
+ export const TRANSITION_GROUPS: readonly TransitionGroup[] = [
43
+ {
44
+ title: 'Basic',
45
+ entries: [
46
+ { value: 'fade', label: 'Fade' },
47
+ { value: 'dissolve', label: 'Dissolve' },
48
+ { value: 'flash', label: 'Flash' },
49
+ { value: 'morph', label: 'Morph' },
50
+ ],
51
+ },
52
+ {
53
+ title: 'Slide & Push',
54
+ entries: [
55
+ { value: 'push', label: 'Push', direction: 'lrud' },
56
+ { value: 'cover', label: 'Cover', direction: 'lrud' },
57
+ { value: 'uncover', label: 'Uncover', direction: 'lrud' },
58
+ { value: 'pan', label: 'Pan', direction: 'lrud' },
59
+ // Conveyor renders as a fixed-direction slide (no distinct 3D motion),
60
+ // so it lives with the slides rather than "3D & Motion".
61
+ { value: 'conveyor', label: 'Conveyor' },
62
+ ],
63
+ },
64
+ {
65
+ title: 'Reveal & Wipe',
66
+ entries: [
67
+ { value: 'wipe', label: 'Wipe', direction: 'lrud' },
68
+ { value: 'reveal', label: 'Reveal', direction: 'lrud' },
69
+ { value: 'split', label: 'Split', direction: 'axis' },
70
+ // Blinds takes no `direction`: its horizontal/vertical web animations
71
+ // are identical (both map to the generic textured reveal), so a
72
+ // direction control here would be a visual no-op. Split, by contrast,
73
+ // has genuinely distinct horizontal/vertical keyframes.
74
+ { value: 'blinds', label: 'Blinds' },
75
+ { value: 'wedge', label: 'Wedge' },
76
+ { value: 'wheel', label: 'Wheel' },
77
+ { value: 'circle', label: 'Circle' },
78
+ { value: 'diamond', label: 'Diamond' },
79
+ { value: 'checkerboard', label: 'Checkerboard' },
80
+ ],
81
+ },
82
+ {
83
+ title: '3D & Motion',
84
+ entries: [
85
+ { value: 'zoom', label: 'Zoom' },
86
+ { value: 'cube', label: 'Cube' },
87
+ { value: 'flip', label: 'Flip' },
88
+ { value: 'rotate', label: 'Rotate' },
89
+ { value: 'doors', label: 'Doors' },
90
+ { value: 'box', label: 'Box' },
91
+ { value: 'gallery', label: 'Gallery' },
92
+ { value: 'pageCurl', label: 'Page Curl' },
93
+ { value: 'origami', label: 'Origami' },
94
+ { value: 'ferrisWheel', label: 'Ferris Wheel' },
95
+ { value: 'orbit', label: 'Orbit' },
96
+ { value: 'flyThrough', label: 'Fly Through' },
97
+ ],
98
+ },
99
+ {
100
+ title: 'Effects',
101
+ entries: [
102
+ { value: 'ripple', label: 'Ripple' },
103
+ { value: 'honeycomb', label: 'Honeycomb' },
104
+ { value: 'glitter', label: 'Glitter' },
105
+ { value: 'vortex', label: 'Vortex' },
106
+ { value: 'shred', label: 'Shred' },
107
+ { value: 'fracture', label: 'Fracture' },
108
+ { value: 'crush', label: 'Crush' },
109
+ { value: 'prestige', label: 'Prestige' },
110
+ { value: 'curtains', label: 'Curtains' },
111
+ { value: 'drape', label: 'Drape' },
112
+ { value: 'wind', label: 'Wind' },
113
+ { value: 'fallOver', label: 'Fall Over' },
114
+ { value: 'peelOff', label: 'Peel Off' },
115
+ { value: 'airplane', label: 'Airplane' },
116
+ ],
117
+ },
118
+ ];
119
+
120
+ /** Flat list of every catalog entry, in group order. */
121
+ export const TRANSITION_ENTRIES: readonly TransitionCatalogEntry[] = TRANSITION_GROUPS.flatMap(
122
+ (g) => g.entries,
123
+ );
124
+
125
+ const ENTRY_BY_VALUE = new Map(TRANSITION_ENTRIES.map((e) => [e.value, e]));
126
+
127
+ /** Direction option lists keyed by model. */
128
+ export const DIRECTION_OPTIONS: Record<
129
+ DirectionModel,
130
+ readonly { value: string; label: string }[]
131
+ > = {
132
+ lrud: [
133
+ { value: 'left', label: 'Left' },
134
+ { value: 'right', label: 'Right' },
135
+ { value: 'up', label: 'Up' },
136
+ { value: 'down', label: 'Down' },
137
+ ],
138
+ axis: [
139
+ { value: 'horizontal', label: 'Horizontal' },
140
+ { value: 'vertical', label: 'Vertical' },
141
+ ],
142
+ };
143
+
144
+ /** Look up a catalog entry by its `transition=` value. */
145
+ export function findTransitionEntry(value: string): TransitionCatalogEntry | undefined {
146
+ return ENTRY_BY_VALUE.get(value);
147
+ }
148
+
149
+ /**
150
+ * Human label for a transition value. Returns 'None' for the empty value and
151
+ * falls back to a camelCase-humanized form for any valid-but-uncurated type
152
+ * (e.g. a hand-typed alias) so the trigger still reads sensibly.
153
+ */
154
+ export function transitionLabel(value: string): string {
155
+ if (!value) return 'None';
156
+ const entry = ENTRY_BY_VALUE.get(value);
157
+ if (entry) return entry.label;
158
+ return value.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase());
159
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Monaco ships `editor.main.js` (the full bundle with language contributions)
3
+ * but only publishes `.d.ts` for `editor.api`. We import the `.main.js`
4
+ * subpath at runtime to register all languages, then cast through
5
+ * `as unknown as typeof import('monaco-editor')` at the call site. This
6
+ * ambient declaration makes the subpath import resolve without a
7
+ * `@ts-expect-error` (which doesn't survive Prettier's formatting choice
8
+ * for the surrounding parenthesized expression — see useMonacoLoader.ts).
9
+ */
10
+ declare module 'monaco-editor/esm/vs/editor/editor.main.js';
@@ -0,0 +1,153 @@
1
+ /**
2
+ * useBlockNavigator
3
+ *
4
+ * Standalone hook powering the block-at-a-time editing view. Given a
5
+ * `(source, setSource)` pair it slices the markdown into blocks (via
6
+ * `blockRange.ts`), tracks which block is active, and exposes a derived
7
+ * **content channel** (`editorSource` / `setEditorSource`) plus navigation.
8
+ *
9
+ * It depends only on its arguments — no `EditorContext`, no `EditorShell` —
10
+ * so any host (an embedded single-block editor, a chat composer, a review
11
+ * surface) can drive a block-at-a-time UI by calling it directly.
12
+ *
13
+ * When `enabled` is false the channel is an identity passthrough: the editors
14
+ * see and write the full source exactly as before.
15
+ */
16
+
17
+ import { useCallback, useMemo, useState } from 'react';
18
+ import {
19
+ getBlockSlices,
20
+ spliceBlock,
21
+ lineToOffset,
22
+ offsetToLine,
23
+ sliceIndexAtOffset,
24
+ type BlockSlice,
25
+ } from './blockRange';
26
+
27
+ export interface BlockNavigator {
28
+ /** What the bound editor should show: the active slice (block mode) or the full source. */
29
+ editorSource: string;
30
+ /** What the bound editor writes through: splices back into the full source (block mode). */
31
+ setEditorSource: (s: string) => void;
32
+ /** Number of navigable blocks in the current source. */
33
+ blockCount: number;
34
+ /** Index of the active block (clamped into range). */
35
+ activeBlockKey: number;
36
+ /** Jump to a block by index (clamped). */
37
+ goToBlock: (key: number) => void;
38
+ /**
39
+ * Select the block that owns a given 1-based source line — used by the
40
+ * outline, which knows a heading's source line but not its slice index
41
+ * (slice order includes the optional preamble, so it needn't match
42
+ * `flattenBlocks` order).
43
+ */
44
+ goToBlockByLine: (line: number) => void;
45
+ /** 1-based source line where the active block begins (for outline highlight). */
46
+ activeBlockStartLine: number | null;
47
+ /** Move to the previous block (no-op at the start). */
48
+ prevBlock: () => void;
49
+ /** Move to the next block (no-op at the end). */
50
+ nextBlock: () => void;
51
+ /** Insert a new heading-defined block after the active one and move to it. */
52
+ addBlock: () => void;
53
+ }
54
+
55
+ export interface UseBlockNavigatorOptions {
56
+ /**
57
+ * When false (the default), the channel passes through to the full source
58
+ * and navigation is inert. Hosts flip this on to enter block-at-a-time mode.
59
+ */
60
+ enabled?: boolean;
61
+ }
62
+
63
+ /** Heading inserted by {@link BlockNavigator.addBlock}. */
64
+ const NEW_BLOCK_MARKDOWN = '## New section\n\n';
65
+
66
+ export function useBlockNavigator(
67
+ source: string,
68
+ setSource: (s: string) => void,
69
+ opts?: UseBlockNavigatorOptions,
70
+ ): BlockNavigator {
71
+ const enabled = opts?.enabled ?? false;
72
+
73
+ // Slices are derived straight from the live `source`, not from any
74
+ // debounced parse, so they're always consistent with what we splice into.
75
+ const slices = useMemo<BlockSlice[]>(() => getBlockSlices(source), [source]);
76
+ const blockCount = slices.length;
77
+
78
+ const [rawKey, setRawKey] = useState(0);
79
+ // Effective key is always in range even when the source shrank under us —
80
+ // the state can lag a re-slice for a render, but derivations never read a
81
+ // stale/out-of-bounds slice.
82
+ const activeBlockKey = blockCount === 0 ? 0 : Math.min(Math.max(rawKey, 0), blockCount - 1);
83
+ const active = slices[activeBlockKey] ?? null;
84
+
85
+ const goToBlock = useCallback((key: number) => {
86
+ setRawKey(key);
87
+ }, []);
88
+ const prevBlock = useCallback(() => {
89
+ setRawKey(activeBlockKey - 1);
90
+ }, [activeBlockKey]);
91
+ const nextBlock = useCallback(() => {
92
+ setRawKey(activeBlockKey + 1);
93
+ }, [activeBlockKey]);
94
+ const goToBlockByLine = useCallback(
95
+ (line: number) => {
96
+ const offset = lineToOffset(source, line);
97
+ const idx = sliceIndexAtOffset(slices, offset);
98
+ if (idx >= 0) setRawKey(idx);
99
+ },
100
+ [source, slices],
101
+ );
102
+
103
+ const activeBlockStartLine = active ? offsetToLine(source, active.range.startOffset) : null;
104
+
105
+ const editorSource = enabled && active ? active.text : source;
106
+
107
+ const setEditorSource = useCallback(
108
+ (next: string) => {
109
+ if (!enabled || !active) {
110
+ setSource(next);
111
+ return;
112
+ }
113
+ // Keep a blank line before the following block's heading so the splice
114
+ // can't glue the edited block onto the next one (the editor may emit a
115
+ // slice with no trailing newline).
116
+ const hasFollowing = active.range.endOffset < source.length;
117
+ const normalized = hasFollowing ? next.replace(/\s*$/, '') + '\n\n' : next;
118
+ setSource(spliceBlock(source, active.range, normalized));
119
+ },
120
+ [enabled, active, source, setSource],
121
+ );
122
+
123
+ const addBlock = useCallback(() => {
124
+ if (!active) {
125
+ // Empty / heading-less doc — append a heading and select it.
126
+ const base = source.replace(/\s*$/, '');
127
+ setSource((base ? base + '\n\n' : '') + NEW_BLOCK_MARKDOWN);
128
+ setRawKey(blockCount); // becomes the new last slice after re-slice
129
+ return;
130
+ }
131
+ const insertAt = active.range.endOffset;
132
+ const before = source.slice(0, insertAt);
133
+ const after = source.slice(insertAt);
134
+ const lead =
135
+ before === '' || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
136
+ setSource(before + lead + NEW_BLOCK_MARKDOWN + after);
137
+ // The new block sits immediately after the active one.
138
+ setRawKey(activeBlockKey + 1);
139
+ }, [active, source, setSource, activeBlockKey, blockCount]);
140
+
141
+ return {
142
+ editorSource,
143
+ setEditorSource,
144
+ blockCount,
145
+ activeBlockKey,
146
+ goToBlock,
147
+ goToBlockByLine,
148
+ activeBlockStartLine,
149
+ prevBlock,
150
+ nextBlock,
151
+ addBlock,
152
+ };
153
+ }
@@ -59,8 +59,30 @@ export function useMonacoLoader(): UseMonacoLoaderResult {
59
59
  // care about the manifest's entry fields. Works identically
60
60
  // in Vite dev / build, vitest's transform pipeline, and any
61
61
  // bundler-using downstream consumer.
62
+ //
63
+ // Use `editor.main.js`, NOT `editor.api.js`. The `api` entry is
64
+ // the bare standalone API with zero language contributions —
65
+ // RawEditor's `defaultLanguage="typescript"` then mounts with
66
+ // no tokenizer registered, and every file renders as
67
+ // undifferentiated foreground text (the regression that surfaced
68
+ // when a `.ts` file in the chat workspace previewer showed up
69
+ // with no syntax coloring). `editor.main.js` re-exports the API
70
+ // and additionally pulls in `basic-languages/monaco.contribution`
71
+ // (TM grammars for ~70 languages) plus the four rich language
72
+ // services (css / html / json / typescript). This is what makes
73
+ // `defaultLanguage` actually do anything. The cost is the language
74
+ // bundle — but since we load it lazily on first EditorShell mount,
75
+ // it stays out of the resolver graph for consumers that only
76
+ // import types or `JsonEditor` from this package.
77
+ // `editor.main.js` ships without a sibling `.d.ts` (only
78
+ // `editor.api.d.ts` is published), so the subpath import has no
79
+ // resolvable declaration. The `declare module` shim in
80
+ // `src/types/monaco-shims.d.ts` makes it import as `any`; the
81
+ // `as unknown as` cast below pins the surface to the full namespace.
82
+ // At runtime `main` re-exports the entire `editor.api` surface
83
+ // alongside the language contributions, so the cast is sound.
62
84
  monacoPromise = (
63
- import('monaco-editor/esm/vs/editor/editor.api.js') as unknown as Promise<
85
+ import('monaco-editor/esm/vs/editor/editor.main.js') as unknown as Promise<
64
86
  typeof import('monaco-editor')
65
87
  >
66
88
  ).then((m) => {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * useTimelineClock
3
+ *
4
+ * A minimal real-time playback clock for the timeline view — a
5
+ * `requestAnimationFrame` loop that advances `currentTime` at wall-clock speed
6
+ * between `play()` and `pause()`, clamped to `[0, total]`. No audio element;
7
+ * media playback is driven separately (the timeline feeds `currentTime` to
8
+ * `MediaClipLayer`). Mirrors the fallback timer in `useAudioSync`.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react';
12
+
13
+ export interface TimelineClock {
14
+ /** Seconds from the start of the timeline. */
15
+ currentTime: number;
16
+ isPlaying: boolean;
17
+ /** Start playing; restarts from 0 when already at the end. */
18
+ play: () => void;
19
+ pause: () => void;
20
+ toggle: () => void;
21
+ /** Jump to a time (clamped to `[0, total]`). */
22
+ seek: (t: number) => void;
23
+ }
24
+
25
+ /** Advance `prev` by `dt` seconds, clamped to `[0, total]`. Pure. */
26
+ export function advanceTime(prev: number, dt: number, total: number): number {
27
+ if (total <= 0) return 0;
28
+ return Math.min(total, Math.max(0, prev + dt));
29
+ }
30
+
31
+ export function useTimelineClock(total: number): TimelineClock {
32
+ const [currentTime, setCurrentTime] = useState(0);
33
+ const [isPlaying, setIsPlaying] = useState(false);
34
+ const rafRef = useRef<number | null>(null);
35
+ const lastRef = useRef(0);
36
+
37
+ // Keep currentTime within the (possibly shrinking) timeline.
38
+ useEffect(() => {
39
+ setCurrentTime((t) => Math.min(t, Math.max(0, total)));
40
+ }, [total]);
41
+
42
+ useEffect(() => {
43
+ if (!isPlaying) return;
44
+ lastRef.current = performance.now();
45
+ const tick = (now: number) => {
46
+ const dt = (now - lastRef.current) / 1000;
47
+ lastRef.current = now;
48
+ setCurrentTime((prev) => {
49
+ const next = advanceTime(prev, dt, total);
50
+ if (next >= total) {
51
+ setIsPlaying(false);
52
+ return total;
53
+ }
54
+ return next;
55
+ });
56
+ rafRef.current = requestAnimationFrame(tick);
57
+ };
58
+ rafRef.current = requestAnimationFrame(tick);
59
+ return () => {
60
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
61
+ };
62
+ }, [isPlaying, total]);
63
+
64
+ const play = useCallback(() => {
65
+ setCurrentTime((t) => (total > 0 && t >= total ? 0 : t));
66
+ setIsPlaying(true);
67
+ }, [total]);
68
+ const pause = useCallback(() => setIsPlaying(false), []);
69
+ const toggle = useCallback(() => setIsPlaying((p) => !p), []);
70
+ const seek = useCallback(
71
+ (t: number) => setCurrentTime(Math.min(Math.max(0, t), Math.max(0, total))),
72
+ [total],
73
+ );
74
+
75
+ return { currentTime, isPlaying, play, pause, toggle, seek };
76
+ }