@bendyline/squisq-editor-react 1.6.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,360 @@
1
+ import type {
2
+ Block,
3
+ CustomTemplateDefinition,
4
+ DocBlock,
5
+ MediaProvider,
6
+ Theme,
7
+ ViewportConfig,
8
+ } from '@bendyline/squisq/schemas';
9
+ import {
10
+ deriveTemplateInputs,
11
+ extractBodyPlainText,
12
+ extractBlockquoteText,
13
+ extractImages,
14
+ extractListItems,
15
+ extractTableFromContents,
16
+ materializeBlockLayers,
17
+ type MaterializeBlockLayersOptions,
18
+ } from '@bendyline/squisq/doc';
19
+ import { extractPlainText } from '@bendyline/squisq/markdown';
20
+
21
+ export interface TemplatePreviewSource {
22
+ block: Block;
23
+ theme: Theme;
24
+ viewport: ViewportConfig;
25
+ basePath?: string;
26
+ mediaProvider?: MediaProvider | null;
27
+ customTemplates?: readonly CustomTemplateDefinition[];
28
+ }
29
+
30
+ export interface TemplatePreviewResult {
31
+ visual: Block | null;
32
+ warning?: string;
33
+ }
34
+
35
+ const NUMBER_RE =
36
+ /(?:[$\u20ac\u00a3\u00a5]\s?\d+(?:[.,]\d+)*(?:\s?(?:[MBK]|million|billion|thousand))?|\d+(?:[.,]\d+)*\s?(?:%|\u2030|x|\u00d7|[MBK]|million|billion|thousand|percent|years?|days?|hours?)|\d{3,}(?:[.,]\d+)*)/i;
37
+
38
+ const DATE_RE =
39
+ /\b(?:\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4}|Q[1-4]\s+\d{4}|\d{4}s|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{4})\b/i;
40
+
41
+ const AUDIO_VIDEO_SOURCE_RE =
42
+ /\.(?:aac|flac|m4a|m4v|mkv|mov|mp3|mp4|oga|ogg|ogv|opus|wav|webm)(?:$|[?#\s"'<>),])/i;
43
+ const AUDIO_VIDEO_TAG_RE = /<\/?(?:audio|source|video)\b/i;
44
+ const AUDIO_VIDEO_MIME_RE = /^(?:audio|video)\//i;
45
+
46
+ export function resolveTemplateContentPreview(
47
+ templateName: string,
48
+ source: TemplatePreviewSource,
49
+ ): Block | null {
50
+ return resolveTemplateContentPreviewResult(templateName, source).visual;
51
+ }
52
+
53
+ export function resolveTemplateContentPreviewResult(
54
+ templateName: string,
55
+ source: TemplatePreviewSource,
56
+ ): TemplatePreviewResult {
57
+ const { block, theme, viewport } = source;
58
+ const headingText = getHeadingText(block);
59
+ const bodyText = extractBodyPlainText(block.contents);
60
+ const sameTemplate = block.template === templateName;
61
+ const existingInputs =
62
+ sameTemplate &&
63
+ ((block.templateData && Object.keys(block.templateData).length > 0) ||
64
+ (block.templateOverrides && Object.keys(block.templateOverrides).length > 0));
65
+ const { inputs, warning } = buildTemplatePreviewInputs(
66
+ templateName,
67
+ block,
68
+ headingText,
69
+ bodyText,
70
+ );
71
+
72
+ if (!inputs && !existingInputs) return { visual: null, warning };
73
+
74
+ const candidate: Block = {
75
+ ...block,
76
+ id: `template-preview-${block.id}-${templateName}`,
77
+ template: templateName,
78
+ title: headingText || block.title || templateName,
79
+ duration: 1,
80
+ audioSegment: 0,
81
+ layers: undefined,
82
+ templateData: sameTemplate ? block.templateData : undefined,
83
+ templateOverrides: sameTemplate ? block.templateOverrides : undefined,
84
+ ...(inputs ?? {}),
85
+ };
86
+
87
+ const ctx: MaterializeBlockLayersOptions = {
88
+ blockIndex: 0,
89
+ totalBlocks: 1,
90
+ theme,
91
+ viewport,
92
+ customTemplates: source.customTemplates,
93
+ };
94
+
95
+ try {
96
+ const materialized = materializeBlockLayers(candidate as unknown as DocBlock, ctx);
97
+ const { layers } = materialized;
98
+ if (layers.length === 0) return { visual: null, warning };
99
+ return {
100
+ visual: { ...candidate, layers },
101
+ warning: warning ?? materialized.diagnostic?.message,
102
+ };
103
+ } catch {
104
+ return { visual: null, warning };
105
+ }
106
+ }
107
+
108
+ function buildTemplatePreviewInputs(
109
+ templateName: string,
110
+ block: Block,
111
+ headingText: string,
112
+ bodyText: string,
113
+ ): { inputs: Record<string, unknown> | null; warning?: string } {
114
+ const contents = block.contents;
115
+ const text = [headingText, bodyText].filter(Boolean).join('\n');
116
+
117
+ switch (templateName) {
118
+ case 'title':
119
+ return {
120
+ inputs: text
121
+ ? {
122
+ title: headingText || firstLine(bodyText) || 'Untitled',
123
+ ...(headingText && bodyText ? { subtitle: bodyText } : {}),
124
+ }
125
+ : null,
126
+ };
127
+ case 'sectionHeader': {
128
+ if (!text) return { inputs: null };
129
+ const image = extractImages(contents, 1)[0];
130
+ return {
131
+ inputs: {
132
+ title: headingText || firstLine(bodyText) || 'Untitled',
133
+ ...(image
134
+ ? {
135
+ imageSrc: image.src,
136
+ imageAlt: image.alt || headingText,
137
+ }
138
+ : {}),
139
+ },
140
+ };
141
+ }
142
+ case 'statHighlight':
143
+ if (!NUMBER_RE.test(text)) {
144
+ return { inputs: null, warning: 'No stat found in this block' };
145
+ }
146
+ return {
147
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
148
+ };
149
+ case 'quote':
150
+ case 'fullBleedQuote': {
151
+ const quoteText = extractBlockquoteText(contents) || bodyText;
152
+ return {
153
+ inputs: quoteText
154
+ ? deriveTemplateInputs(templateName, headingText, contents, { placeholders: false })
155
+ : null,
156
+ };
157
+ }
158
+ case 'pullQuote':
159
+ return {
160
+ inputs: derivePullQuoteInputs(block, headingText, bodyText),
161
+ ...(extractImages(contents, 1).length === 0
162
+ ? { warning: 'No image found in this block' }
163
+ : {}),
164
+ };
165
+ case 'factCard':
166
+ case 'definitionCard':
167
+ return {
168
+ inputs:
169
+ headingText && bodyText
170
+ ? deriveTemplateInputs(templateName, headingText, contents, { placeholders: false })
171
+ : null,
172
+ };
173
+ case 'twoColumn':
174
+ return { inputs: deriveTwoColumnInputs(block, headingText, bodyText) };
175
+ case 'dateEvent':
176
+ if (!DATE_RE.test(text)) {
177
+ return { inputs: null, warning: 'No date found in this block' };
178
+ }
179
+ return {
180
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
181
+ };
182
+ case 'comparisonBar': {
183
+ const comparisonInputs = deriveComparisonInputs(block);
184
+ return {
185
+ inputs: comparisonInputs,
186
+ ...(comparisonInputs ? {} : { warning: 'No stat found in this block' }),
187
+ };
188
+ }
189
+ case 'imageWithCaption':
190
+ case 'leftFeature':
191
+ case 'rightFeature':
192
+ case 'photoGrid': {
193
+ const imageCount = extractImages(contents, templateName === 'photoGrid' ? 2 : 1).length;
194
+ const needsMoreImages = templateName === 'photoGrid' ? imageCount < 2 : imageCount === 0;
195
+ return {
196
+ inputs: needsMoreImages
197
+ ? null
198
+ : deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
199
+ ...(needsMoreImages ? { warning: 'No image found in this block' } : {}),
200
+ };
201
+ }
202
+ case 'map':
203
+ return { inputs: null };
204
+ case 'videoWithCaption':
205
+ case 'videoPullQuote':
206
+ return {
207
+ inputs: null,
208
+ ...(hasAudioVideoMedia(contents) ? {} : { warning: 'No audio/video found in this block' }),
209
+ };
210
+ case 'diagram':
211
+ case 'layout':
212
+ case 'drawing':
213
+ return {
214
+ inputs: block.children && block.children.length > 0 ? { title: headingText } : null,
215
+ };
216
+ default:
217
+ return {
218
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
219
+ };
220
+ }
221
+ }
222
+
223
+ function derivePullQuoteInputs(
224
+ block: Block,
225
+ headingText: string,
226
+ bodyText: string,
227
+ ): Record<string, unknown> | null {
228
+ const image = extractImages(block.contents, 1)[0];
229
+ const text = extractBlockquoteText(block.contents) || bodyText || headingText;
230
+ if (!image || !text) return null;
231
+ return {
232
+ text,
233
+ backgroundImage: {
234
+ src: image.src,
235
+ alt: image.alt || headingText,
236
+ },
237
+ };
238
+ }
239
+
240
+ function deriveTwoColumnInputs(
241
+ block: Block,
242
+ headingText: string,
243
+ bodyText: string,
244
+ ): Record<string, unknown> | null {
245
+ const items = extractListItems(block.contents);
246
+ if (items.length >= 2) {
247
+ return {
248
+ ...(headingText ? { header: headingText } : {}),
249
+ left: { label: trimPreviewText(items[0]) },
250
+ right: { label: trimPreviewText(items[1]) },
251
+ };
252
+ }
253
+
254
+ const chunks = bodyText
255
+ .split(/\n{2,}|\n|(?<=[.!?])\s+/)
256
+ .map((part) => part.trim())
257
+ .filter(Boolean);
258
+ if (headingText && chunks.length >= 1) {
259
+ return {
260
+ left: { label: trimPreviewText(headingText) },
261
+ right: { label: trimPreviewText(chunks[0]) },
262
+ };
263
+ }
264
+ if (chunks.length >= 2) {
265
+ return {
266
+ left: { label: trimPreviewText(chunks[0]) },
267
+ right: { label: trimPreviewText(chunks[1]) },
268
+ };
269
+ }
270
+ return null;
271
+ }
272
+
273
+ function deriveComparisonInputs(block: Block): Record<string, unknown> | null {
274
+ const table = extractTableFromContents(block.contents);
275
+ if (table) {
276
+ const values = table.rows
277
+ .map((row) => ({
278
+ label: row[0] || 'Value',
279
+ value: parseNumericValue(row.slice(1).join(' ')),
280
+ }))
281
+ .filter((row): row is { label: string; value: number } => row.value !== null);
282
+ if (values.length >= 2) {
283
+ return {
284
+ leftLabel: trimPreviewText(values[0].label, 24),
285
+ leftValue: values[0].value,
286
+ rightLabel: trimPreviewText(values[1].label, 24),
287
+ rightValue: values[1].value,
288
+ };
289
+ }
290
+ }
291
+
292
+ const bodyText = extractBodyPlainText(block.contents);
293
+ const matches = Array.from(bodyText.matchAll(new RegExp(NUMBER_RE.source, 'gi')))
294
+ .map((match) => parseNumericValue(match[0]))
295
+ .filter((value): value is number => value !== null);
296
+ if (matches.length < 2) return null;
297
+
298
+ return {
299
+ leftLabel: 'A',
300
+ leftValue: matches[0],
301
+ rightLabel: 'B',
302
+ rightValue: matches[1],
303
+ };
304
+ }
305
+
306
+ function parseNumericValue(raw: string): number | null {
307
+ const normalized = raw
308
+ .replace(/[$\u20ac\u00a3\u00a5,%\u2030]/g, '')
309
+ .replace(/,/g, '')
310
+ .trim();
311
+ const match = normalized.match(/-?\d+(?:\.\d+)?/);
312
+ if (!match) return null;
313
+ const value = Number.parseFloat(match[0]);
314
+ return Number.isFinite(value) ? value : null;
315
+ }
316
+
317
+ function hasAudioVideoMedia(value: unknown): boolean {
318
+ if (typeof value === 'string') {
319
+ return (
320
+ AUDIO_VIDEO_TAG_RE.test(value) ||
321
+ AUDIO_VIDEO_SOURCE_RE.test(value) ||
322
+ AUDIO_VIDEO_MIME_RE.test(value)
323
+ );
324
+ }
325
+ if (Array.isArray(value)) return value.some(hasAudioVideoMedia);
326
+ if (!value || typeof value !== 'object') return false;
327
+
328
+ const record = value as Record<string, unknown>;
329
+ const tagName = typeof record.tagName === 'string' ? record.tagName.toLowerCase() : '';
330
+ if (tagName === 'audio' || tagName === 'video') return true;
331
+ if (record.type === 'audio' || record.type === 'video') return true;
332
+
333
+ const attributes = record.attributes;
334
+ if (attributes && typeof attributes === 'object') {
335
+ const attrs = attributes as Record<string, unknown>;
336
+ if (hasAudioVideoMedia(attrs.src) || hasAudioVideoMedia(attrs.type)) return true;
337
+ }
338
+
339
+ return Object.values(record).some(hasAudioVideoMedia);
340
+ }
341
+
342
+ function getHeadingText(block: Block): string {
343
+ if (block.sourceHeading) return extractPlainText(block.sourceHeading).trim();
344
+ return (block.title ?? block.id ?? '').trim();
345
+ }
346
+
347
+ function firstLine(value: string): string {
348
+ return (
349
+ value
350
+ .split(/\r?\n/)
351
+ .map((line) => line.trim())
352
+ .find(Boolean) ?? ''
353
+ );
354
+ }
355
+
356
+ function trimPreviewText(value: string, max = 72): string {
357
+ const normalized = value.replace(/\s+/g, ' ').trim();
358
+ if (normalized.length <= max) return normalized;
359
+ return `${normalized.slice(0, max - 1).trimEnd()}...`;
360
+ }