@bendyline/squisq-editor-react 0.1.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.
@@ -0,0 +1,562 @@
1
+ /**
2
+ * PreviewPanel
3
+ *
4
+ * Renders a live preview of the current markdown document as a slideshow
5
+ * using the DocPlayer component from @bendyline/squisq-react.
6
+ *
7
+ * The markdown-derived Doc (from markdownToDoc) contains hierarchical blocks
8
+ * with template names, heading text, and body content — but no audio or
9
+ * visual layers. This component bridges the gap by:
10
+ *
11
+ * 1. Flattening the block tree into a linear slide sequence
12
+ * 2. Converting each block into a TemplateBlock-compatible object
13
+ * (mapping heading text → title, templateOverrides → template fields)
14
+ * 3. Synthesizing a dummy audio segment so DocPlayer's timing works
15
+ * (the player enters fallback-timer mode when audio can't load)
16
+ * 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
17
+ */
18
+
19
+ import { useMemo, useState, useEffect } from 'react';
20
+ import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
21
+ import type { DisplayMode } from '@bendyline/squisq-react';
22
+ import { flattenBlocks } from '@bendyline/squisq/doc';
23
+ import { hasTemplate } from '@bendyline/squisq/doc';
24
+ import { extractPlainText } from '@bendyline/squisq/markdown';
25
+ import type { Block, Doc, ViewportConfig, ViewportPreset } from '@bendyline/squisq/schemas';
26
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
27
+ import { getThemeSummaries, resolveTheme } from '@bendyline/squisq/schemas';
28
+ import type { MarkdownBlockNode, MarkdownList } from '@bendyline/squisq/markdown';
29
+ import { useEditorContext } from './EditorContext';
30
+
31
+ export interface PreviewPanelProps {
32
+ /** Base path for resolving media URLs in DocPlayer */
33
+ basePath?: string;
34
+ /** Additional class name for the container */
35
+ className?: string;
36
+ }
37
+
38
+ // ── Helpers ────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Extract plain text from an array of markdown block nodes.
42
+ * Walks paragraphs, blockquotes, and list items to collect all text.
43
+ */
44
+ function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
45
+ if (!contents || contents.length === 0) return '';
46
+ const parts: string[] = [];
47
+ for (const node of contents) {
48
+ parts.push(extractPlainText(node));
49
+ }
50
+ return parts.join('\n').trim();
51
+ }
52
+
53
+ /**
54
+ * Extract list items from markdown body content.
55
+ * Returns an array of plain text strings for each list item found.
56
+ */
57
+ function extractListItems(contents: MarkdownBlockNode[] | undefined): string[] {
58
+ if (!contents) return [];
59
+ const items: string[] = [];
60
+ for (const node of contents) {
61
+ if (node.type === 'list') {
62
+ for (const item of (node as MarkdownList).children) {
63
+ const text = extractPlainText(item).trim();
64
+ if (text) items.push(text);
65
+ }
66
+ }
67
+ }
68
+ return items;
69
+ }
70
+
71
+ /**
72
+ * Provide sensible default fields for templates that require more than
73
+ * just a `title`. This prevents crashes from undefined required fields
74
+ * when the markdown annotations don't supply all template-specific values.
75
+ */
76
+ function getTemplateDefaults(
77
+ templateName: string,
78
+ headingText: string,
79
+ block: Block,
80
+ ): Record<string, unknown> {
81
+ const body = extractBodyText(block.contents);
82
+
83
+ switch (templateName) {
84
+ case 'statHighlight':
85
+ return {
86
+ stat: headingText,
87
+ description: body || headingText,
88
+ };
89
+
90
+ case 'quoteBlock':
91
+ case 'fullBleedQuote':
92
+ case 'pullQuote':
93
+ return {
94
+ quote: body || headingText,
95
+ };
96
+
97
+ case 'factCard':
98
+ return {
99
+ fact: headingText,
100
+ explanation: body || headingText,
101
+ };
102
+
103
+ case 'comparisonBar':
104
+ return {
105
+ leftLabel: 'A',
106
+ leftValue: 60,
107
+ rightLabel: 'B',
108
+ rightValue: 40,
109
+ };
110
+
111
+ case 'listBlock':
112
+ return {
113
+ items: extractListItems(block.contents) || ['Item 1', 'Item 2', 'Item 3'],
114
+ };
115
+
116
+ case 'definitionCard':
117
+ return {
118
+ term: headingText,
119
+ definition: body || headingText,
120
+ };
121
+
122
+ case 'dateEvent':
123
+ return {
124
+ date: headingText,
125
+ description: body || headingText,
126
+ };
127
+
128
+ default:
129
+ return {};
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Convert a markdown-derived Block into a TemplateBlock-compatible object.
135
+ *
136
+ * The block's heading text becomes `title` (works for sectionHeader,
137
+ * titleBlock, factCard, etc.). Any templateOverrides from annotation
138
+ * syntax `{[template key=value]}` are spread on top so template-specific
139
+ * fields (stat, quote, description, …) are available.
140
+ *
141
+ * If the requested template doesn't exist in the registry, falls back
142
+ * to `sectionHeader` to avoid "Unknown template" warnings.
143
+ */
144
+ function blockToSlide(block: Block, index: number): Record<string, unknown> {
145
+ const headingText = block.sourceHeading
146
+ ? extractPlainText(block.sourceHeading)
147
+ : block.id || `Slide ${index + 1}`;
148
+
149
+ // Validate template name — fall back to sectionHeader for unknowns
150
+ const requestedTemplate = block.template || 'sectionHeader';
151
+ const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
152
+
153
+ // Get sensible defaults for templates that need more than just `title`
154
+ const defaults = getTemplateDefaults(template, headingText, block);
155
+
156
+ return {
157
+ id: block.id,
158
+ template,
159
+ duration: block.duration,
160
+ audioSegment: 0,
161
+ transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
162
+ // Provide heading text as title — consumed by sectionHeader, titleBlock, etc.
163
+ title: headingText,
164
+ // Template-specific defaults (safe fallbacks for required fields)
165
+ ...defaults,
166
+ // Spread annotation overrides last so explicit values win
167
+ ...block.templateOverrides,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Build a player-ready Doc from the markdown-derived Doc.
173
+ *
174
+ * Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
175
+ * slide, recalculates timing, and adds a synthetic audio segment.
176
+ */
177
+ function buildPreviewDoc(doc: Doc): Doc {
178
+ const flat = flattenBlocks(doc.blocks);
179
+ const slides = flat.map(blockToSlide);
180
+
181
+ // Recalculate sequential timing
182
+ let t = 0;
183
+ for (const slide of slides) {
184
+ slide.startTime = t;
185
+ t += slide.duration as number;
186
+ }
187
+
188
+ return {
189
+ articleId: doc.articleId,
190
+ duration: t,
191
+ blocks: slides as unknown as Block[],
192
+ audio: {
193
+ // Synthetic segment — audio will fail to load and DocPlayer will use
194
+ // its fallback timer to advance currentTime via requestAnimationFrame.
195
+ segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
196
+ },
197
+ };
198
+ }
199
+
200
+ // ── Viewport helpers ───────────────────────────────────────────────
201
+
202
+ /** All viewport preset entries for the dropdown */
203
+ const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
204
+ { key: 'landscape', label: '16:9 Landscape' },
205
+ { key: 'portrait', label: '9:16 Portrait' },
206
+ { key: 'square', label: '1:1 Square' },
207
+ { key: 'standard', label: '4:3 Standard' },
208
+ ];
209
+
210
+ /**
211
+ * Resolve a `document-render-as` frontmatter value to a ViewportPreset.
212
+ * Accepts preset names ("landscape"), aspect ratio shorthand ("16:9"),
213
+ * and common aliases ("widescreen", "vertical", "stories").
214
+ */
215
+ function resolveRenderAs(value: unknown): ViewportPreset | null {
216
+ if (typeof value !== 'string') return null;
217
+ const v = value.trim().toLowerCase();
218
+ const mapping: Record<string, ViewportPreset> = {
219
+ landscape: 'landscape',
220
+ '16:9': 'landscape',
221
+ widescreen: 'landscape',
222
+ portrait: 'portrait',
223
+ '9:16': 'portrait',
224
+ vertical: 'portrait',
225
+ stories: 'portrait',
226
+ square: 'square',
227
+ '1:1': 'square',
228
+ standard: 'standard',
229
+ '4:3': 'standard',
230
+ };
231
+ return mapping[v] ?? null;
232
+ }
233
+
234
+ /** Display mode options for the dropdown */
235
+ const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
236
+ { key: 'video', label: 'Video' },
237
+ { key: 'slideshow', label: 'Slideshow' },
238
+ { key: 'linear', label: 'Document' },
239
+ ];
240
+
241
+ /** Theme options for the dropdown */
242
+ const THEME_OPTIONS = getThemeSummaries().map((s) => ({ key: s.id, label: s.name }));
243
+
244
+ /** Set of valid theme IDs for fast lookup */
245
+ const VALID_THEME_IDS = new Set(THEME_OPTIONS.map((o) => o.key));
246
+
247
+ /**
248
+ * Resolve a `theme` frontmatter value to a theme id.
249
+ * Accepts exact theme ids ('documentary', 'bold') and common aliases.
250
+ */
251
+ function resolveFrontmatterTheme(value: unknown): string | null {
252
+ if (typeof value !== 'string') return null;
253
+ const v = value.trim().toLowerCase();
254
+ if (VALID_THEME_IDS.has(v)) return v;
255
+ // Allow hyphenated/spaced aliases: "morning light" → "morning-light"
256
+ const normalized = v.replace(/\s+/g, '-');
257
+ if (VALID_THEME_IDS.has(normalized)) return normalized;
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Resolve a `display-mode` frontmatter value to a DisplayMode.
263
+ */
264
+ function resolveDisplayMode(value: unknown): DisplayMode | null {
265
+ if (typeof value !== 'string') return null;
266
+ const v = value.trim().toLowerCase();
267
+ if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
268
+ if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
269
+ if (v === 'document' || v === 'scroll' || v === 'page') return 'linear';
270
+ return null;
271
+ }
272
+
273
+ // ── Component ──────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Live preview panel that renders the current document as a slideshow.
277
+ * Uses DocPlayer from @bendyline/squisq-react for SVG block rendering
278
+ * with template expansion, transitions, and playback controls.
279
+ *
280
+ * Includes a viewport format dropdown above the player. The default
281
+ * format can be hinted via YAML frontmatter `document-render-as:`.
282
+ */
283
+ export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
284
+ const { doc, parseError, isParsing } = useEditorContext();
285
+
286
+ // Determine the frontmatter-hinted viewport preset (if any)
287
+ const frontmatterPreset = useMemo<ViewportPreset | null>(() => {
288
+ if (!doc?.frontmatter) return null;
289
+ return resolveRenderAs(doc.frontmatter['document-render-as']);
290
+ }, [doc?.frontmatter]);
291
+
292
+ // Track user-selected viewport; null means "use frontmatter or default"
293
+ const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
294
+
295
+ // When frontmatter preset changes and user hasn't explicitly chosen, sync
296
+ useEffect(() => {
297
+ setSelectedPreset(null);
298
+ }, [frontmatterPreset]);
299
+
300
+ // Active preset: explicit user choice > frontmatter hint > landscape
301
+ const activePreset: ViewportPreset = selectedPreset ?? frontmatterPreset ?? 'landscape';
302
+ const activeViewport: ViewportConfig = VIEWPORT_PRESETS[activePreset];
303
+
304
+ // ── Display mode (video vs slideshow) ──────────────────────────
305
+
306
+ // Determine the frontmatter-hinted display mode (if any)
307
+ const frontmatterDisplayMode = useMemo<DisplayMode | null>(() => {
308
+ if (!doc?.frontmatter) return null;
309
+ return resolveDisplayMode(doc.frontmatter['display-mode']);
310
+ }, [doc?.frontmatter]);
311
+
312
+ // Track user-selected display mode; null means "use frontmatter or default"
313
+ const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
314
+
315
+ // When frontmatter display mode changes and user hasn't explicitly chosen, sync
316
+ useEffect(() => {
317
+ setSelectedDisplayMode(null);
318
+ }, [frontmatterDisplayMode]);
319
+
320
+ // Active display mode: explicit user choice > frontmatter hint > video
321
+ const activeDisplayMode: DisplayMode = selectedDisplayMode ?? frontmatterDisplayMode ?? 'video';
322
+
323
+ // ── Theme selection ────────────────────────────────────────────
324
+
325
+ // Determine the frontmatter-hinted theme (if any)
326
+ const frontmatterThemeId = useMemo<string | null>(() => {
327
+ if (!doc?.frontmatter) return null;
328
+ return resolveFrontmatterTheme(doc.frontmatter['theme']);
329
+ }, [doc?.frontmatter]);
330
+
331
+ // Track user-selected theme; null means "use frontmatter or default"
332
+ const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
333
+
334
+ // When frontmatter theme changes and user hasn't explicitly chosen, sync
335
+ useEffect(() => {
336
+ setSelectedThemeId(null);
337
+ }, [frontmatterThemeId]);
338
+
339
+ // Active theme: explicit user choice > frontmatter hint > documentary
340
+ const activeThemeId = selectedThemeId ?? frontmatterThemeId ?? 'documentary';
341
+ const activeTheme = useMemo(() => resolveTheme(activeThemeId), [activeThemeId]);
342
+
343
+ // Build the player-ready Doc whenever the parsed doc changes
344
+ const previewDoc = useMemo(() => {
345
+ if (!doc || !doc.blocks.length) return null;
346
+ return buildPreviewDoc(doc);
347
+ }, [doc]);
348
+
349
+ // Status overlays for non-ready states
350
+ if (isParsing) {
351
+ return (
352
+ <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
353
+ <p>Parsing…</p>
354
+ </div>
355
+ );
356
+ }
357
+
358
+ if (parseError) {
359
+ return (
360
+ <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
361
+ <h3>Parse Error</h3>
362
+ <pre>{parseError}</pre>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ if (!previewDoc) {
368
+ return (
369
+ <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
370
+ <p>No content to preview. Start typing in the editor.</p>
371
+ </div>
372
+ );
373
+ }
374
+
375
+ return (
376
+ <div
377
+ className={`squisq-preview-container ${className || ''}`}
378
+ data-testid="preview-panel"
379
+ style={{
380
+ width: '100%',
381
+ height: '100%',
382
+ display: 'flex',
383
+ flexDirection: 'column',
384
+ overflow: 'hidden',
385
+ background: 'var(--squisq-bg, #f5f5f5)',
386
+ }}
387
+ >
388
+ {/* Viewport format selector */}
389
+ <div
390
+ className="squisq-preview-toolbar"
391
+ style={{
392
+ display: 'flex',
393
+ alignItems: 'center',
394
+ gap: '8px',
395
+ padding: '6px 12px',
396
+ borderBottom: '1px solid var(--squisq-border, #e0e0e0)',
397
+ flexShrink: 0,
398
+ fontSize: '13px',
399
+ }}
400
+ >
401
+ <label htmlFor="viewport-preset" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
402
+ Format:
403
+ </label>
404
+ <select
405
+ id="viewport-preset"
406
+ value={activePreset}
407
+ onChange={(e) => setSelectedPreset(e.target.value as ViewportPreset)}
408
+ style={{
409
+ padding: '3px 8px',
410
+ borderRadius: '4px',
411
+ border: '1px solid var(--squisq-border, #d1d5db)',
412
+ background: 'var(--squisq-input-bg, #fff)',
413
+ color: 'var(--squisq-text, #1f2937)',
414
+ fontSize: '13px',
415
+ cursor: 'pointer',
416
+ }}
417
+ >
418
+ {VIEWPORT_OPTIONS.map((opt) => (
419
+ <option key={opt.key} value={opt.key}>
420
+ {opt.label}
421
+ </option>
422
+ ))}
423
+ </select>
424
+ {frontmatterPreset && selectedPreset === null && (
425
+ <span
426
+ style={{
427
+ fontSize: '11px',
428
+ color: 'var(--squisq-text-muted, #9ca3af)',
429
+ fontStyle: 'italic',
430
+ }}
431
+ >
432
+ (from frontmatter)
433
+ </span>
434
+ )}
435
+
436
+ {/* Divider */}
437
+ <span
438
+ style={{
439
+ width: '1px',
440
+ height: '18px',
441
+ background: 'var(--squisq-border, #d1d5db)',
442
+ margin: '0 4px',
443
+ }}
444
+ />
445
+
446
+ {/* Display mode selector */}
447
+ <label htmlFor="display-mode" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
448
+ Mode:
449
+ </label>
450
+ <select
451
+ id="display-mode"
452
+ value={activeDisplayMode}
453
+ onChange={(e) => setSelectedDisplayMode(e.target.value as DisplayMode)}
454
+ style={{
455
+ padding: '3px 8px',
456
+ borderRadius: '4px',
457
+ border: '1px solid var(--squisq-border, #d1d5db)',
458
+ background: 'var(--squisq-input-bg, #fff)',
459
+ color: 'var(--squisq-text, #1f2937)',
460
+ fontSize: '13px',
461
+ cursor: 'pointer',
462
+ }}
463
+ >
464
+ {DISPLAY_MODE_OPTIONS.map((opt) => (
465
+ <option key={opt.key} value={opt.key}>
466
+ {opt.label}
467
+ </option>
468
+ ))}
469
+ </select>
470
+ {frontmatterDisplayMode && selectedDisplayMode === null && (
471
+ <span
472
+ style={{
473
+ fontSize: '11px',
474
+ color: 'var(--squisq-text-muted, #9ca3af)',
475
+ fontStyle: 'italic',
476
+ }}
477
+ >
478
+ (from frontmatter)
479
+ </span>
480
+ )}
481
+
482
+ {/* Divider */}
483
+ <span
484
+ style={{
485
+ width: '1px',
486
+ height: '18px',
487
+ background: 'var(--squisq-border, #d1d5db)',
488
+ margin: '0 4px',
489
+ }}
490
+ />
491
+
492
+ {/* Theme selector */}
493
+ <label htmlFor="theme-select" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
494
+ Theme:
495
+ </label>
496
+ <select
497
+ id="theme-select"
498
+ value={activeThemeId}
499
+ onChange={(e) => setSelectedThemeId(e.target.value)}
500
+ style={{
501
+ padding: '3px 8px',
502
+ borderRadius: '4px',
503
+ border: '1px solid var(--squisq-border, #d1d5db)',
504
+ background: 'var(--squisq-input-bg, #fff)',
505
+ color: 'var(--squisq-text, #1f2937)',
506
+ fontSize: '13px',
507
+ cursor: 'pointer',
508
+ }}
509
+ >
510
+ {THEME_OPTIONS.map((opt) => (
511
+ <option key={opt.key} value={opt.key}>
512
+ {opt.label}
513
+ </option>
514
+ ))}
515
+ </select>
516
+ {frontmatterThemeId && selectedThemeId === null && (
517
+ <span
518
+ style={{
519
+ fontSize: '11px',
520
+ color: 'var(--squisq-text-muted, #9ca3af)',
521
+ fontStyle: 'italic',
522
+ }}
523
+ >
524
+ (from frontmatter)
525
+ </span>
526
+ )}
527
+ </div>
528
+
529
+ {/* Player / Document view */}
530
+ <div
531
+ className="squisq-preview-player"
532
+ style={{
533
+ flex: 1,
534
+ display: 'flex',
535
+ alignItems: activeDisplayMode === 'linear' ? 'stretch' : 'center',
536
+ justifyContent: 'center',
537
+ overflow: 'hidden',
538
+ minHeight: 0,
539
+ }}
540
+ >
541
+ {activeDisplayMode === 'linear' ? (
542
+ <LinearDocView
543
+ doc={doc!}
544
+ basePath={basePath}
545
+ viewport={activeViewport}
546
+ theme={activeTheme}
547
+ />
548
+ ) : (
549
+ <DocPlayer
550
+ script={previewDoc}
551
+ basePath={basePath}
552
+ showControls
553
+ muted
554
+ forceViewport={activeViewport}
555
+ displayMode={activeDisplayMode}
556
+ theme={activeTheme}
557
+ />
558
+ )}
559
+ </div>
560
+ </div>
561
+ );
562
+ }