@bendyline/squisq-editor-react 1.1.0 → 1.2.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 (50) hide show
  1. package/dist/EditorContext.d.ts +6 -2
  2. package/dist/EditorContext.d.ts.map +1 -1
  3. package/dist/EditorContext.js +3 -1
  4. package/dist/EditorContext.js.map +1 -1
  5. package/dist/EditorShell.d.ts +11 -1
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +9 -7
  8. package/dist/EditorShell.js.map +1 -1
  9. package/dist/ImageNodeView.d.ts +15 -0
  10. package/dist/ImageNodeView.d.ts.map +1 -0
  11. package/dist/ImageNodeView.js +52 -0
  12. package/dist/ImageNodeView.js.map +1 -0
  13. package/dist/PreviewControls.d.ts +41 -0
  14. package/dist/PreviewControls.d.ts.map +1 -0
  15. package/dist/PreviewControls.js +201 -0
  16. package/dist/PreviewControls.js.map +1 -0
  17. package/dist/PreviewPanel.d.ts +7 -7
  18. package/dist/PreviewPanel.d.ts.map +1 -1
  19. package/dist/PreviewPanel.js +183 -199
  20. package/dist/PreviewPanel.js.map +1 -1
  21. package/dist/Toolbar.d.ts +8 -1
  22. package/dist/Toolbar.d.ts.map +1 -1
  23. package/dist/Toolbar.js +145 -20
  24. package/dist/Toolbar.js.map +1 -1
  25. package/dist/WysiwygEditor.d.ts.map +1 -1
  26. package/dist/WysiwygEditor.js +3 -1
  27. package/dist/WysiwygEditor.js.map +1 -1
  28. package/dist/__tests__/tiptapBridge.test.d.ts +2 -0
  29. package/dist/__tests__/tiptapBridge.test.d.ts.map +1 -0
  30. package/dist/__tests__/tiptapBridge.test.js +241 -0
  31. package/dist/__tests__/tiptapBridge.test.js.map +1 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/tiptapBridge.d.ts.map +1 -1
  37. package/dist/tiptapBridge.js +146 -5
  38. package/dist/tiptapBridge.js.map +1 -1
  39. package/package.json +5 -4
  40. package/src/EditorContext.tsx +8 -1
  41. package/src/EditorShell.tsx +71 -32
  42. package/src/ImageNodeView.tsx +70 -0
  43. package/src/PreviewControls.tsx +340 -0
  44. package/src/PreviewPanel.tsx +216 -287
  45. package/src/Toolbar.tsx +449 -17
  46. package/src/WysiwygEditor.tsx +3 -1
  47. package/src/__tests__/tiptapBridge.test.ts +290 -0
  48. package/src/index.ts +6 -0
  49. package/src/styles/editor.css +257 -16
  50. package/src/tiptapBridge.ts +164 -6
@@ -16,23 +16,27 @@
16
16
  * 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
17
17
  */
18
18
 
19
- import { useMemo, useState, useEffect } from 'react';
19
+ import { useState, useEffect } from 'react';
20
20
  import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
21
- import type { DisplayMode } from '@bendyline/squisq-react';
22
21
  import { flattenBlocks } from '@bendyline/squisq/doc';
23
22
  import { hasTemplate } from '@bendyline/squisq/doc';
24
23
  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';
24
+ import type { Block, Doc } from '@bendyline/squisq/schemas';
25
+ import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
26
+ import { getChildren } from '@bendyline/squisq/markdown';
27
+ import { applyTransform } from '@bendyline/squisq/transform';
28
+ import { resolveAudioMapping } from '@bendyline/squisq/doc';
29
+ import type { ContentContainer } from '@bendyline/squisq/storage';
29
30
  import { useEditorContext } from './EditorContext';
31
+ import { usePreviewSettings } from './PreviewControls';
30
32
 
31
33
  export interface PreviewPanelProps {
32
34
  /** Base path for resolving media URLs in DocPlayer */
33
35
  basePath?: string;
34
36
  /** Additional class name for the container */
35
37
  className?: string;
38
+ /** Optional ContentContainer for audio mapping (MP3 discovery + timing.json) */
39
+ container?: ContentContainer | null;
36
40
  }
37
41
 
38
42
  // ── Helpers ────────────────────────────────────────────────────────
@@ -50,6 +54,60 @@ function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
50
54
  return parts.join('\n').trim();
51
55
  }
52
56
 
57
+ /**
58
+ * Extract images from a block's markdown contents.
59
+ * Walks the node tree recursively to find all MarkdownImage nodes.
60
+ */
61
+ function extractBlockImages(
62
+ contents: MarkdownBlockNode[] | undefined,
63
+ ): Array<{ src: string; alt: string }> {
64
+ if (!contents || contents.length === 0) return [];
65
+ const images: Array<{ src: string; alt: string }> = [];
66
+
67
+ function walk(node: MarkdownNode): void {
68
+ if ('type' in node && node.type === 'image' && 'url' in node) {
69
+ const img = node as { url: string; alt?: string };
70
+ if (img.url) {
71
+ images.push({ src: img.url, alt: img.alt ?? '' });
72
+ }
73
+ }
74
+ for (const child of getChildren(node)) {
75
+ walk(child);
76
+ }
77
+ }
78
+
79
+ for (const node of contents) {
80
+ walk(node);
81
+ }
82
+ return images;
83
+ }
84
+
85
+ /**
86
+ * Collect all unique images from an entire Doc's block tree.
87
+ * Walks nested children to find every image across all blocks.
88
+ */
89
+ function collectAllDocImages(blocks: Block[]): Array<{ src: string; alt: string }> {
90
+ const seen = new Set<string>();
91
+ const images: Array<{ src: string; alt: string }> = [];
92
+
93
+ function walkBlocks(blockList: Block[]): void {
94
+ for (const block of blockList) {
95
+ for (const img of extractBlockImages(block.contents)) {
96
+ if (!seen.has(img.src)) {
97
+ seen.add(img.src);
98
+ images.push(img);
99
+ }
100
+ }
101
+ if (block.children) {
102
+ walkBlocks(block.children);
103
+ }
104
+ }
105
+ }
106
+
107
+ walkBlocks(blocks);
108
+ return images;
109
+ }
110
+
53
111
  /**
54
112
  * Extract list items from markdown body content.
55
113
  * Returns an array of plain text strings for each list item found.
@@ -144,7 +202,7 @@ function getTemplateDefaults(
144
202
  function blockToSlide(block: Block, index: number): Record<string, unknown> {
145
203
  const headingText = block.sourceHeading
146
204
  ? extractPlainText(block.sourceHeading)
147
- : block.id || `Slide ${index + 1}`;
205
+ : block.title || block.id || `Slide ${index + 1}`;
148
206
 
149
207
  // Validate template name — fall back to sectionHeader for unknowns
150
208
  const requestedTemplate = block.template || 'sectionHeader';
@@ -153,6 +211,27 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
153
211
  // Get sensible defaults for templates that need more than just `title`
154
212
  const defaults = getTemplateDefaults(template, headingText, block);
155
213
 
214
+ // Spread the block itself to pick up any template-specific fields
215
+ // placed directly on the block by applyTransform (e.g. stat, description,
216
+ // quote, colorScheme). These are not in templateOverrides — they live
217
+ // on the block object because the transform produces hybrid Block+Template
218
+ // objects via the timing allocator.
219
+ const {
220
+ id: _id,
221
+ startTime: _st,
222
+ duration: _d,
223
+ audioSegment: _as,
224
+ layers: _l,
225
+ transition: _tr,
226
+ template: _t,
227
+ title: _ti,
228
+ children: _c,
229
+ contents: _co,
230
+ sourceHeading: _sh,
231
+ templateOverrides: _to,
232
+ ...extraFields
233
+ } = block as unknown as Record<string, unknown>;
234
+
156
235
  return {
157
236
  id: block.id,
158
237
  template,
@@ -163,20 +242,101 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
163
242
  title: headingText,
164
243
  // Template-specific defaults (safe fallbacks for required fields)
165
244
  ...defaults,
245
+ // Template-specific fields from transform (stat, description, quote, etc.)
246
+ ...extraFields,
166
247
  // Spread annotation overrides last so explicit values win
167
248
  ...block.templateOverrides,
168
249
  };
169
250
  }
170
251
 
252
+ /** Ambient motions to rotate on image slides. */
253
+ const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
254
+ 'zoomIn',
255
+ 'zoomOut',
256
+ 'panLeft',
257
+ 'panRight',
258
+ ];
259
+
171
260
  /**
172
261
  * Build a player-ready Doc from the markdown-derived Doc.
173
262
  *
174
263
  * Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
175
264
  * slide, recalculates timing, and adds a synthetic audio segment.
265
+ *
266
+ * Images found in the markdown are used in two ways:
267
+ * 1. Per-block: if a block has images, its first image becomes the background
268
+ * (via imageWithCaption template) or an accent image on text templates.
269
+ * 2. Global: remaining images are interleaved as standalone image slides
270
+ * for visual variety.
176
271
  */
177
272
  function buildPreviewDoc(doc: Doc): Doc {
178
273
  const flat = flattenBlocks(doc.blocks);
179
- const slides = flat.map(blockToSlide);
274
+
275
+ // Collect all images from the doc for global interleaving
276
+ const allImages = collectAllDocImages(doc.blocks);
277
+
278
+ // Track which images are used per-block so we can interleave the rest
279
+ const usedImageSrcs = new Set<string>();
280
+
281
+ // First pass: convert blocks to slides, using per-block images
282
+ const slides: Record<string, unknown>[] = [];
283
+ let motionIndex = 0;
284
+
285
+ for (let i = 0; i < flat.length; i++) {
286
+ const block = flat[i];
287
+ const blockImages = extractBlockImages(block.contents);
288
+ const slide = blockToSlide(block, i);
289
+
290
+ // If the block has images and is using the default sectionHeader template,
291
+ // upgrade it to imageWithCaption so the image becomes the slide background.
292
+ if (blockImages.length > 0 && slide.template === 'sectionHeader') {
293
+ const img = blockImages[0];
294
+ usedImageSrcs.add(img.src);
295
+ slide.template = 'imageWithCaption';
296
+ slide.imageSrc = img.src;
297
+ slide.imageAlt = img.alt;
298
+ slide.caption = slide.title as string;
299
+ slide.captionPosition = 'bottom';
300
+ slide.ambientMotion = IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length];
301
+ } else if (blockImages.length > 0) {
302
+ // For other templates, add the first image as an accent
303
+ const img = blockImages[0];
304
+ usedImageSrcs.add(img.src);
305
+ if (!slide.accentImage) {
306
+ slide.accentImage = {
307
+ src: img.src,
308
+ alt: img.alt,
309
+ position: 'left-strip',
310
+ ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
311
+ };
312
+ }
313
+ }
314
+
315
+ slides.push(slide);
316
+ }
317
+
318
+ // Second pass: interleave unused images as standalone imageWithCaption slides.
319
+ // Spread them evenly through the sequence for visual variety.
320
+ const unusedImages = allImages.filter((img) => !usedImageSrcs.has(img.src));
321
+ if (unusedImages.length > 0 && slides.length > 0) {
322
+ const interval = Math.max(2, Math.floor(slides.length / (unusedImages.length + 1)));
323
+ let insertOffset = 0;
324
+ for (let imgIdx = 0; imgIdx < unusedImages.length; imgIdx++) {
325
+ const insertAt = Math.min((imgIdx + 1) * interval + insertOffset, slides.length);
326
+ const img = unusedImages[imgIdx];
327
+ slides.splice(insertAt, 0, {
328
+ id: `img-interleave-${imgIdx}`,
329
+ template: 'imageWithCaption',
330
+ duration: 5,
331
+ audioSegment: 0,
332
+ imageSrc: img.src,
333
+ imageAlt: img.alt,
334
+ ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
335
+ transition: { type: 'fade', duration: 0.5 },
336
+ });
337
+ insertOffset++;
338
+ }
339
+ }
180
340
 
181
341
  // Recalculate sequential timing
182
342
  let t = 0;
@@ -194,157 +354,66 @@ function buildPreviewDoc(doc: Doc): Doc {
194
354
  // its fallback timer to advance currentTime via requestAnimationFrame.
195
355
  segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
196
356
  },
357
+ ...(doc.captions ? { captions: doc.captions } : {}),
197
358
  };
198
359
  }
199
360
 
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
361
  // ── Component ──────────────────────────────────────────────────────
274
362
 
275
363
  /**
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:`.
364
+ * Live preview panel that renders the current document as a slideshow
365
+ * or document view. Controls (viewport, mode, theme, transform, captions)
366
+ * are rendered in the main toolbar via PreviewToolbarControls.
282
367
  */
283
- export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
368
+ export function PreviewPanel({ basePath = '/', className, container }: PreviewPanelProps) {
284
369
  const { doc, parseError, isParsing } = useEditorContext();
370
+ const {
371
+ activeViewport,
372
+ activeDisplayMode,
373
+ activeTheme,
374
+ activeTransformStyle,
375
+ activeCaptionStyle,
376
+ } = usePreviewSettings();
377
+
378
+ // Build the player-ready Doc whenever the parsed doc changes.
379
+ // Transform runs on the ORIGINAL doc (which has block.contents with
380
+ // markdown body text) so the content extractor can analyze it.
381
+ // Then buildPreviewDoc converts the result for DocPlayer.
382
+ //
383
+ // Audio mapping is async (reads container files), so we use a two-phase
384
+ // approach: first build the base doc synchronously, then resolve audio
385
+ // in an effect and update the state.
386
+ const [previewDoc, setPreviewDoc] = useState<Doc | null>(null);
285
387
 
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
388
  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);
389
+ if (!doc || !doc.blocks.length) {
390
+ setPreviewDoc(null);
391
+ return;
392
+ }
333
393
 
334
- // When frontmatter theme changes and user hasn't explicitly chosen, sync
335
- useEffect(() => {
336
- setSelectedThemeId(null);
337
- }, [frontmatterThemeId]);
394
+ let sourceDoc = doc;
395
+ if (activeTransformStyle) {
396
+ const result = applyTransform(doc, activeTransformStyle);
397
+ sourceDoc = result.doc;
398
+ }
338
399
 
339
- // Active theme: explicit user choice > frontmatter hint > documentary
340
- const activeThemeId = selectedThemeId ?? frontmatterThemeId ?? 'documentary';
341
- const activeTheme = useMemo(() => resolveTheme(activeThemeId), [activeThemeId]);
400
+ // If we have a container, try to resolve audio mapping before building preview
401
+ if (container) {
402
+ let cancelled = false;
403
+ resolveAudioMapping(sourceDoc, container).then((audioDoc) => {
404
+ if (!cancelled) {
405
+ setPreviewDoc(buildPreviewDoc(audioDoc));
406
+ }
407
+ });
408
+ // Set an immediate preview without audio while mapping resolves
409
+ setPreviewDoc(buildPreviewDoc(sourceDoc));
410
+ return () => {
411
+ cancelled = true;
412
+ };
413
+ }
342
414
 
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]);
415
+ setPreviewDoc(buildPreviewDoc(sourceDoc));
416
+ }, [doc, activeTransformStyle, container]);
348
417
 
349
418
  // Status overlays for non-ready states
350
419
  if (isParsing) {
@@ -385,147 +454,6 @@ export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
385
454
  background: 'var(--squisq-bg, #f5f5f5)',
386
455
  }}
387
456
  >
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
457
  {/* Player / Document view */}
530
458
  <div
531
459
  className="squisq-preview-player"
@@ -554,6 +482,7 @@ export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
554
482
  forceViewport={activeViewport}
555
483
  displayMode={activeDisplayMode}
556
484
  theme={activeTheme}
485
+ captionStyle={activeCaptionStyle}
557
486
  />
558
487
  )}
559
488
  </div>