@bendyline/squisq-editor-react 1.5.3 → 1.6.1

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 (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,132 @@
1
+ /**
2
+ * blockRange
3
+ *
4
+ * Source-text-range slicing for the block-at-a-time editing view. Splits a
5
+ * full markdown document into ordered, contiguous slices — one per
6
+ * heading-defined block plus an optional leading preamble — so a single
7
+ * block can be shown in isolation and edits spliced back into the parent.
8
+ *
9
+ * A block runs from its heading line through the character just before the
10
+ * next heading at ANY depth (or EOF). Sub-headings therefore start their own
11
+ * slices — they are NOT folded into their parent — which matches the
12
+ * "don't see child blocks" requirement and the `slicePastHeading` boundary
13
+ * in `blockSlice.ts`. Ranges are half-open `[startOffset, endOffset)` and
14
+ * line-aligned, so trailing blank lines stay with the current block and
15
+ * `spliceBlock(src, range, getBlockSlices(src)[i].text) === src` for every i.
16
+ *
17
+ * These are pure functions over a markdown string — no React, no editor
18
+ * coupling — so any host can reuse them.
19
+ */
20
+
21
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
22
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
23
+ import { frontmatterEndOffset } from './frontmatter';
24
+
25
+ /** Half-open character range into the full source: `[startOffset, endOffset)`. */
26
+ export interface BlockRange {
27
+ startOffset: number;
28
+ endOffset: number;
29
+ }
30
+
31
+ /** One block's source text plus the range it occupies in the full document. */
32
+ export interface BlockSlice {
33
+ text: string;
34
+ range: BlockRange;
35
+ }
36
+
37
+ /** Map every line start to its character offset (`lineStarts[line - 1]`). */
38
+ function computeLineStarts(source: string): number[] {
39
+ const starts = [0];
40
+ for (let i = 0; i < source.length; i++) {
41
+ if (source[i] === '\n') starts.push(i + 1);
42
+ }
43
+ return starts;
44
+ }
45
+
46
+ function makeSlice(source: string, startOffset: number, endOffset: number): BlockSlice {
47
+ return { text: source.slice(startOffset, endOffset), range: { startOffset, endOffset } };
48
+ }
49
+
50
+ /**
51
+ * Split `fullSource` into ordered block slices.
52
+ *
53
+ * - With no headings, the entire post-frontmatter body is a single slice
54
+ * (so an empty or heading-less document still shows one editable card).
55
+ * - With headings, a leading preamble slice is included only when the text
56
+ * before the first heading has non-whitespace content. Each heading then
57
+ * yields one slice spanning up to the next heading (any depth) or EOF.
58
+ *
59
+ * Frontmatter is never part of any slice — slices start at the body offset.
60
+ */
61
+ export function getBlockSlices(fullSource: string): BlockSlice[] {
62
+ const bodyStart = frontmatterEndOffset(fullSource);
63
+
64
+ let doc: MarkdownDocument;
65
+ try {
66
+ doc = parseMarkdown(fullSource);
67
+ } catch {
68
+ // Unparseable mid-edit — treat the whole body as one slice rather than
69
+ // dropping the user into an empty card.
70
+ return [makeSlice(fullSource, bodyStart, fullSource.length)];
71
+ }
72
+
73
+ const lineStarts = computeLineStarts(fullSource);
74
+ const headingOffsets: number[] = [];
75
+ for (const node of doc.children) {
76
+ if (node.type !== 'heading') continue;
77
+ const line = node.position?.start.line;
78
+ if (typeof line !== 'number') continue;
79
+ const off = lineStarts[line - 1];
80
+ if (typeof off === 'number') headingOffsets.push(off);
81
+ }
82
+
83
+ if (headingOffsets.length === 0) {
84
+ return [makeSlice(fullSource, bodyStart, fullSource.length)];
85
+ }
86
+
87
+ const slices: BlockSlice[] = [];
88
+ const firstHeading = headingOffsets[0];
89
+ if (fullSource.slice(bodyStart, firstHeading).trim().length > 0) {
90
+ slices.push(makeSlice(fullSource, bodyStart, firstHeading));
91
+ }
92
+ for (let i = 0; i < headingOffsets.length; i++) {
93
+ const start = headingOffsets[i];
94
+ const end = i + 1 < headingOffsets.length ? headingOffsets[i + 1] : fullSource.length;
95
+ slices.push(makeSlice(fullSource, start, end));
96
+ }
97
+ return slices;
98
+ }
99
+
100
+ /** Replace the text in `range` with `newText`, returning the new full source. */
101
+ export function spliceBlock(fullSource: string, range: BlockRange, newText: string): string {
102
+ return fullSource.slice(0, range.startOffset) + newText + fullSource.slice(range.endOffset);
103
+ }
104
+
105
+ /** Character offset of the start of 1-based `line` (clamps past EOF). */
106
+ export function lineToOffset(source: string, line: number): number {
107
+ const starts = computeLineStarts(source);
108
+ return starts[Math.max(0, line - 1)] ?? source.length;
109
+ }
110
+
111
+ /** 1-based line number containing `offset`. */
112
+ export function offsetToLine(source: string, offset: number): number {
113
+ const starts = computeLineStarts(source);
114
+ let lo = 0;
115
+ let hi = starts.length - 1;
116
+ let ans = 0;
117
+ while (lo <= hi) {
118
+ const mid = (lo + hi) >> 1;
119
+ if (starts[mid] <= offset) {
120
+ ans = mid;
121
+ lo = mid + 1;
122
+ } else {
123
+ hi = mid - 1;
124
+ }
125
+ }
126
+ return ans + 1;
127
+ }
128
+
129
+ /** Index of the slice whose range contains `offset`, or -1. */
130
+ export function sliceIndexAtOffset(slices: BlockSlice[], offset: number): number {
131
+ return slices.findIndex((s) => offset >= s.range.startOffset && offset < s.range.endOffset);
132
+ }
@@ -11,19 +11,40 @@
11
11
  * 4. Synthesize a dummy audio segment for timer-based playback
12
12
  */
13
13
 
14
- import { flattenBlocks, hasTemplate } from '@bendyline/squisq/doc';
15
- import { extractPlainText } from '@bendyline/squisq/markdown';
14
+ import { flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
15
+ import { extractPlainText, KNOWN_BLOCK_META_KEYS } from '@bendyline/squisq/markdown';
16
16
  import { getChildren } from '@bendyline/squisq/markdown';
17
+ import { iconMarker } from '@bendyline/squisq/icon-marker';
18
+ import type { IconFamily } from '@bendyline/squisq/icons';
17
19
  import type { Block, Doc } from '@bendyline/squisq/schemas';
18
20
  import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
19
21
 
20
22
  // ── Helpers ────────────────────────────────────────────────────────
21
23
 
24
+ /**
25
+ * Like `extractPlainText`, but preserves inline icons as encoded markers so
26
+ * template text can render them as glyphs downstream (see `iconMarker` and
27
+ * `TextLayer`). Mirrors `extractPlainText`'s value/child/list handling; the
28
+ * only addition is the `inlineIcon` interception.
29
+ */
30
+ function extractRichText(node: MarkdownNode): string {
31
+ if (node.type === 'inlineIcon') {
32
+ const icon = node as unknown as { family: IconFamily; name: string };
33
+ return iconMarker(icon.family, icon.name);
34
+ }
35
+ if ('value' in node && typeof (node as { value?: unknown }).value === 'string') {
36
+ return (node as { value: string }).value;
37
+ }
38
+ const children = getChildren(node);
39
+ const separator = node.type === 'list' || node.type === 'listItem' ? '\n' : '';
40
+ return children.map(extractRichText).join(separator);
41
+ }
42
+
22
43
  function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
23
44
  if (!contents || contents.length === 0) return '';
24
45
  const parts: string[] = [];
25
46
  for (const node of contents) {
26
- parts.push(extractPlainText(node));
47
+ parts.push(extractRichText(node));
27
48
  }
28
49
  return parts.join('\n').trim();
29
50
  }
@@ -174,13 +195,23 @@ function getTemplateDefaults(
174
195
  }
175
196
  }
176
197
 
177
- function blockToSlide(block: Block, index: number): Record<string, unknown> {
198
+ function blockToSlide(
199
+ block: Block,
200
+ index: number,
201
+ knownTemplates?: ReadonlySet<string>,
202
+ ): Record<string, unknown> {
178
203
  const headingText = block.sourceHeading
179
204
  ? extractPlainText(block.sourceHeading)
180
205
  : block.title || block.id || `Slide ${index + 1}`;
181
206
 
182
207
  const requestedTemplate = block.template || 'sectionHeader';
183
- const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
208
+ // A template is recognized if it's a built-in OR a user-defined
209
+ // template carried in the doc's `customTemplates` set. Without this,
210
+ // an annotated `{[hero]}` heading would silently fall back to
211
+ // `sectionHeader` because `hasTemplate` only knows built-ins.
212
+ const isCustomTemplate = knownTemplates?.has(requestedTemplate) ?? false;
213
+ const recognized = hasTemplate(requestedTemplate) || isCustomTemplate;
214
+ const template = recognized ? requestedTemplate : 'sectionHeader';
184
215
  const defaults = getTemplateDefaults(template, headingText, block);
185
216
 
186
217
  const {
@@ -196,6 +227,7 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
196
227
  contents: _co,
197
228
  sourceHeading: _sh,
198
229
  templateOverrides: _to,
230
+ templateData: _td,
199
231
  ...extraFields
200
232
  } = block as unknown as Record<string, unknown>;
201
233
 
@@ -204,14 +236,59 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
204
236
  template,
205
237
  duration: block.duration,
206
238
  audioSegment: 0,
207
- transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
239
+ // Respect the block's authored transition (set via the toolbar / on-canvas
240
+ // properties palette → `{…}` block attrs). Only fall back to a default fade
241
+ // for blocks past the first when the author hasn't chosen one; the first
242
+ // block has no previous slide to transition in from.
243
+ transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
208
244
  title: headingText,
245
+ // Custom templates need access to the source block's body content
246
+ // + children so their token resolver (`{content}`, `{children}`,
247
+ // `{image:N}`) substitutes against the user's prose, not just the
248
+ // heading. Built-in templates don't read these fields and risk
249
+ // surprising overlap with their typed inputs, so we only attach
250
+ // them when the slide actually maps to a custom template.
251
+ ...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
252
+ ...(isCustomTemplate && block.children ? { children: block.children } : {}),
209
253
  ...defaults,
210
254
  ...extraFields,
211
- ...block.templateOverrides,
255
+ // Structured body data (```json data fences, GFM tables for dataTable)
256
+ // carries typed values; `{[…]}` string overrides win last so an explicit
257
+ // annotation param can still pin any field.
258
+ //
259
+ // Block-meta keys (transition, startTime, duration, …) are the exception:
260
+ // they were already coerced to typed block fields above (e.g.
261
+ // `block.transition` → `{ type, duration, direction }`). Their raw string
262
+ // form also rides along in `templateData`/`templateOverrides` because the
263
+ // author wrote them inside `{[…]}`; left un-stripped, that string would
264
+ // spread back over the typed value here and clobber it — turning
265
+ // `transition=vortex` into the string `"vortex"`, which the player can't
266
+ // animate. Omit them from the content spreads so the typed fields win.
267
+ ...omitBlockMeta(block.templateData),
268
+ ...omitBlockMeta(block.templateOverrides),
212
269
  };
213
270
  }
214
271
 
272
+ /** Keys coerced to typed block fields; must not be re-applied as raw strings. */
273
+ const BLOCK_META_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_BLOCK_META_KEYS));
274
+
275
+ /** Copy of a template-param record with block-meta keys removed. */
276
+ function omitBlockMeta(
277
+ data: Record<string, unknown> | undefined,
278
+ ): Record<string, unknown> | undefined {
279
+ if (!data) return data;
280
+ let hit = false;
281
+ const out: Record<string, unknown> = {};
282
+ for (const key of Object.keys(data)) {
283
+ if (BLOCK_META_KEYS.has(key)) {
284
+ hit = true;
285
+ continue;
286
+ }
287
+ out[key] = data[key];
288
+ }
289
+ return hit ? out : data;
290
+ }
291
+
215
292
  const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
216
293
  'zoomIn',
217
294
  'zoomOut',
@@ -229,9 +306,18 @@ const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
229
306
  * audio segment.
230
307
  */
231
308
  export function buildPreviewDoc(doc: Doc): Doc {
232
- const flat = flattenBlocks(doc.blocks);
309
+ // Container templates (`diagram`, `drawing`) render their children as
310
+ // nodes/shapes, so those children must not also become preview slides.
311
+ const flat = flattenRenderableBlocks(doc.blocks);
233
312
  const allImages = collectAllDocImages(doc.blocks);
234
313
  const usedImageSrcs = new Set<string>();
314
+ // Names of user-defined templates carried by the doc — passed into
315
+ // `blockToSlide` so heading annotations like `{[hero]}` aren't
316
+ // silently downgraded to `sectionHeader` when the template doesn't
317
+ // exist in the built-in registry.
318
+ const knownTemplates = doc.customTemplates
319
+ ? new Set(doc.customTemplates.map((d) => d.name))
320
+ : undefined;
235
321
 
236
322
  const slides: Record<string, unknown>[] = [];
237
323
  let motionIndex = 0;
@@ -239,7 +325,7 @@ export function buildPreviewDoc(doc: Doc): Doc {
239
325
  for (let i = 0; i < flat.length; i++) {
240
326
  const block = flat[i];
241
327
  const blockImages = extractBlockImages(block.contents);
242
- const slide = blockToSlide(block, i);
328
+ const slide = blockToSlide(block, i, knownTemplates);
243
329
 
244
330
  if (blockImages.length > 0 && slide.template === 'sectionHeader') {
245
331
  const img = blockImages[0];
@@ -305,5 +391,8 @@ export function buildPreviewDoc(doc: Doc): Doc {
305
391
  ...(doc.captions ? { captions: doc.captions } : {}),
306
392
  ...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
307
393
  ...(doc.themeId ? { themeId: doc.themeId } : {}),
394
+ // Custom templates ride along so `useDocPlayback` can merge them
395
+ // onto the registry before expanding slides.
396
+ ...(doc.customTemplates ? { customTemplates: doc.customTemplates } : {}),
308
397
  };
309
398
  }
@@ -0,0 +1,124 @@
1
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
2
+ import { MarkdownRenderer } from '@bendyline/squisq-react';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
+ import type { CodeContext, CodeContextSection } from './types';
5
+
6
+ /**
7
+ * One context section rendered inside a Monaco view zone: a single-line
8
+ * disclosure strip, plus the full markdown body while expanded. The body is
9
+ * rendered lazily — a file with 100 collapsed sections parses 100 one-liners,
10
+ * nothing more.
11
+ */
12
+ export interface CodeContextSectionViewProps {
13
+ section: Omit<CodeContextSection, 'line'>;
14
+ expanded: boolean;
15
+ onToggle: (id: string) => void;
16
+ linkSchemes?: readonly string[] | undefined;
17
+ onLinkClick?: CodeContext['onLinkClick'] | undefined;
18
+ /** Native `#L<n>` handling: reveal that line in the editor. */
19
+ onRevealLine: (line: number) => void;
20
+ /** Reports the rendered content height so the zone can be resized to fit. */
21
+ onMeasure: (id: string, px: number) => void;
22
+ }
23
+
24
+ export function CodeContextSectionView({
25
+ section,
26
+ expanded,
27
+ onToggle,
28
+ linkSchemes,
29
+ onLinkClick,
30
+ onRevealLine,
31
+ onMeasure,
32
+ }: CodeContextSectionViewProps) {
33
+ const rootRef = useRef<HTMLDivElement | null>(null);
34
+
35
+ const stripNodes = useMemo(
36
+ () => parseMarkdown(section.summaryMarkdown).children,
37
+ [section.summaryMarkdown],
38
+ );
39
+ const bodyNodes = useMemo(
40
+ () => (expanded && section.markdown ? parseMarkdown(section.markdown).children : null),
41
+ [expanded, section.markdown],
42
+ );
43
+
44
+ // Height feedback loop: report the content's real height whenever it
45
+ // changes. Monaco display:none's offscreen zones and ResizeObserver reports
46
+ // 0×0 for those — ignore zeros so scrolled-away zones keep their height.
47
+ useEffect(() => {
48
+ const el = rootRef.current;
49
+ if (!el || typeof ResizeObserver === 'undefined') return;
50
+ const report = () => {
51
+ const h = el.offsetHeight;
52
+ if (h > 0) onMeasure(section.id, h);
53
+ };
54
+ report();
55
+ const ro = new ResizeObserver(report);
56
+ ro.observe(el);
57
+ return () => ro.disconnect();
58
+ }, [section.id, onMeasure]);
59
+
60
+ // Delegated link interception. `#L<n>` reveals natively; everything else
61
+ // goes to the host callback (returning false opts back into default
62
+ // navigation).
63
+ const handleClick = useCallback(
64
+ (e: React.MouseEvent) => {
65
+ const anchor = (e.target as HTMLElement).closest?.('a');
66
+ if (!anchor) return;
67
+ const href = anchor.getAttribute('href') ?? '';
68
+ const lineMatch = /^#L(\d+)$/.exec(href);
69
+ if (lineMatch) {
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ onRevealLine(Number(lineMatch[1]));
73
+ return;
74
+ }
75
+ if (onLinkClick) {
76
+ const handled = onLinkClick(href, { sectionId: section.id });
77
+ if (handled !== false) {
78
+ e.preventDefault();
79
+ e.stopPropagation();
80
+ }
81
+ }
82
+ },
83
+ [onLinkClick, onRevealLine, section.id],
84
+ );
85
+
86
+ // Keep Monaco's container-level mousedown handler from hijacking clicks
87
+ // and text selection inside the section.
88
+ const stopMouseDown = useCallback((e: React.MouseEvent) => {
89
+ e.stopPropagation();
90
+ }, []);
91
+
92
+ return (
93
+ // Click handler is delegated anchor interception only; keyboard users
94
+ // reach links natively and the strip itself is a real <button>.
95
+ <div
96
+ ref={rootRef}
97
+ className={`squisq-ccx-section${expanded ? ' squisq-ccx-section--expanded' : ''}`}
98
+ onClick={handleClick}
99
+ onMouseDown={stopMouseDown}
100
+ >
101
+ <button
102
+ type="button"
103
+ className="squisq-ccx-strip"
104
+ aria-expanded={expanded}
105
+ onClick={() => onToggle(section.id)}
106
+ >
107
+ <span className="squisq-ccx-chevron" aria-hidden="true">
108
+ {expanded ? '▾' : '▸'}
109
+ </span>
110
+ <span className="squisq-ccx-strip-text">
111
+ <MarkdownRenderer nodes={stripNodes} {...(linkSchemes ? { linkSchemes } : {})} />
112
+ </span>
113
+ </button>
114
+ {expanded &&
115
+ (bodyNodes ? (
116
+ <div className="squisq-ccx-body">
117
+ <MarkdownRenderer nodes={bodyNodes} {...(linkSchemes ? { linkSchemes } : {})} />
118
+ </div>
119
+ ) : (
120
+ <div className="squisq-ccx-body squisq-ccx-body--loading">Loading…</div>
121
+ ))}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,149 @@
1
+ import type { editor as MonacoEditorNs } from 'monaco-editor';
2
+ import { type ZoneSpec, diffContextSections } from './diffContextSections';
3
+
4
+ type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
5
+
6
+ /**
7
+ * Owns the Monaco view zones behind the code-context sections. React-free —
8
+ * the React layer portals content into the zone dom nodes it hands out and
9
+ * feeds measured heights back via {@link setHeight}.
10
+ *
11
+ * Zone mechanics this leans on (monaco 0.50):
12
+ * - `changeViewZones` batches adds/removes/layouts into one whitespace
13
+ * re-layout.
14
+ * - `layoutZone(id)` re-reads the zone delegate, so mutating our own
15
+ * delegate's `afterLineNumber`/`heightInPx` then calling layoutZone both
16
+ * moves and resizes without remove/re-add churn (expanded DOM survives).
17
+ * - Zone dom nodes live in `.view-zones` (absolutely positioned, offscreen
18
+ * ones display:none'd by Monaco). They sit BELOW `.view-lines` in hit-test
19
+ * order, so the stylesheet raises `.squisq-ccx-zone` with a z-index and
20
+ * `pointer-events: auto` to make section content clickable.
21
+ * - Model swaps invalidate zone ids; the owner listens to onDidChangeModel
22
+ * and rebuilds.
23
+ */
24
+ export class CodeContextZoneManager {
25
+ private readonly editor: MonacoEditor;
26
+ private entries = new Map<
27
+ string,
28
+ {
29
+ zoneId: string;
30
+ delegate: MonacoEditorNs.IViewZone;
31
+ domNode: HTMLDivElement;
32
+ spec: ZoneSpec;
33
+ }
34
+ >();
35
+ private listeners = new Set<() => void>();
36
+ private modelListener: { dispose(): void } | null = null;
37
+ private disposed = false;
38
+
39
+ /** Initial height estimate for a strip; the ResizeObserver corrects it. */
40
+ static readonly INITIAL_HEIGHT_PX = 28;
41
+
42
+ constructor(editor: MonacoEditor) {
43
+ this.editor = editor;
44
+ // A model swap (file change / external reset) invalidates every zone id.
45
+ // Drop our bookkeeping; the next sync() recreates zones against the new
46
+ // model.
47
+ this.modelListener = editor.onDidChangeModel(() => {
48
+ this.entries.clear();
49
+ this.emit();
50
+ });
51
+ }
52
+
53
+ /** Reconcile the live zones against `specs` in one changeViewZones batch. */
54
+ sync(specs: ZoneSpec[]): void {
55
+ if (this.disposed) return;
56
+ const prev = [...this.entries.values()].map((e) => e.spec);
57
+ const { add, remove, move } = diffContextSections(prev, specs);
58
+ if (add.length === 0 && remove.length === 0 && move.length === 0) return;
59
+
60
+ this.editor.changeViewZones((accessor) => {
61
+ for (const id of remove) {
62
+ const entry = this.entries.get(id);
63
+ if (!entry) continue;
64
+ accessor.removeZone(entry.zoneId);
65
+ this.entries.delete(id);
66
+ }
67
+ for (const spec of move) {
68
+ const entry = this.entries.get(spec.id);
69
+ if (!entry) continue;
70
+ entry.spec = spec;
71
+ entry.delegate.afterLineNumber = Math.max(spec.line - 1, 0);
72
+ setOrdinal(entry.delegate, spec.ordinal);
73
+ accessor.layoutZone(entry.zoneId);
74
+ }
75
+ for (const spec of add) {
76
+ const domNode = document.createElement('div');
77
+ domNode.className = 'squisq-ccx-zone';
78
+ domNode.dataset.sectionId = spec.id;
79
+ const delegate: MonacoEditorNs.IViewZone = {
80
+ afterLineNumber: Math.max(spec.line - 1, 0),
81
+ heightInPx: CodeContextZoneManager.INITIAL_HEIGHT_PX,
82
+ domNode,
83
+ suppressMouseDown: true,
84
+ };
85
+ setOrdinal(delegate, spec.ordinal);
86
+ const zoneId = accessor.addZone(delegate);
87
+ this.entries.set(spec.id, { zoneId, delegate, domNode, spec });
88
+ }
89
+ });
90
+ this.emit();
91
+ }
92
+
93
+ /** Measured-content feedback: resize the zone to fit its rendered DOM. */
94
+ setHeight(id: string, px: number): void {
95
+ if (this.disposed) return;
96
+ const entry = this.entries.get(id);
97
+ if (!entry) return;
98
+ const height = Math.max(Math.ceil(px), 1);
99
+ if (Math.abs((entry.delegate.heightInPx ?? 0) - height) < 1) return;
100
+ entry.delegate.heightInPx = height;
101
+ this.editor.changeViewZones((accessor) => {
102
+ accessor.layoutZone(entry.zoneId);
103
+ });
104
+ }
105
+
106
+ getDomNode(id: string): HTMLDivElement | undefined {
107
+ return this.entries.get(id)?.domNode;
108
+ }
109
+
110
+ liveIds(): string[] {
111
+ return [...this.entries.keys()];
112
+ }
113
+
114
+ /** Fires after sync/model-change so the React layer rebuilds its portals. */
115
+ onDidChangeZones(cb: () => void): () => void {
116
+ this.listeners.add(cb);
117
+ return () => this.listeners.delete(cb);
118
+ }
119
+
120
+ dispose(): void {
121
+ if (this.disposed) return;
122
+ this.disposed = true;
123
+ this.modelListener?.dispose();
124
+ this.modelListener = null;
125
+ if (this.entries.size > 0) {
126
+ try {
127
+ this.editor.changeViewZones((accessor) => {
128
+ for (const entry of this.entries.values()) accessor.removeZone(entry.zoneId);
129
+ });
130
+ } catch {
131
+ // Editor already disposed — its zones died with it.
132
+ }
133
+ }
134
+ this.entries.clear();
135
+ this.listeners.clear();
136
+ }
137
+
138
+ private emit(): void {
139
+ for (const cb of [...this.listeners]) cb();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * `ordinal` orders zones sharing an afterLineNumber (monaco 0.50 supports it
145
+ * but the public IViewZone typing lags behind) — hence the structural write.
146
+ */
147
+ function setOrdinal(zone: MonacoEditorNs.IViewZone, ordinal: number): void {
148
+ (zone as MonacoEditorNs.IViewZone & { ordinal?: number }).ordinal = ordinal;
149
+ }