@bendyline/squisq-editor-react 1.6.0 → 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 (37) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +401 -76
  3. package/dist/index.js +1334 -930
  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/EditorContext.tsx +22 -16
  12. package/src/EditorShell.tsx +68 -27
  13. package/src/OutlinePanel.tsx +26 -4
  14. package/src/PreviewControls.tsx +338 -141
  15. package/src/PreviewPanel.tsx +15 -9
  16. package/src/RawEditor.tsx +10 -4
  17. package/src/Toolbar.tsx +21 -11
  18. package/src/VersionHistoryPanel.tsx +2 -2
  19. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  20. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  21. package/src/__tests__/diffContextSections.test.ts +39 -0
  22. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  23. package/src/__tests__/editorShellProps.test.tsx +96 -0
  24. package/src/__tests__/previewControls.test.tsx +70 -0
  25. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  26. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  27. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  28. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  29. package/src/codeContext/CodeContextZones.tsx +121 -0
  30. package/src/codeContext/diffContextSections.ts +38 -0
  31. package/src/codeContext/types.ts +75 -0
  32. package/src/index.ts +32 -1
  33. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  34. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  35. package/src/styles/code-context.css +155 -0
  36. package/src/styles/editor.css +149 -3
  37. package/src/styles/index.css +1 -0
@@ -17,6 +17,7 @@ import {
17
17
  useState,
18
18
  useMemo,
19
19
  useEffect,
20
+ useLayoutEffect,
20
21
  useRef,
21
22
  } from 'react';
22
23
  import type { ReactNode } from 'react';
@@ -35,9 +36,13 @@ import {
35
36
  } from '@bendyline/squisq/doc';
36
37
  import { useEditorContext } from './EditorContext';
37
38
  import { useCustomThemes, CustomThemeDialog, type ThemeSaveTarget } from './customThemes';
39
+ import { Icon } from './Icon';
38
40
 
39
41
  // ── Context ──────────────────────────────────────────────────────
40
42
 
43
+ /** Caption selection: off, or one of the two enabled styles. */
44
+ export type CaptionMode = 'off' | CaptionStyle;
45
+
41
46
  export interface PreviewSettings {
42
47
  activePreset: ViewportPreset;
43
48
  setSelectedPreset: (preset: ViewportPreset | null) => void;
@@ -49,8 +54,14 @@ export interface PreviewSettings {
49
54
  activeTheme: Theme;
50
55
  activeTransformStyle: string;
51
56
  setSelectedTransformStyle: (id: string | null) => void;
57
+ /** The caption style used when captions are enabled. */
52
58
  activeCaptionStyle: CaptionStyle;
53
- setSelectedCaptionStyle: (style: CaptionStyle | null) => void;
59
+ /** Whether captions are shown at all (the 'off' arm of the tri-state). */
60
+ activeCaptionsEnabled: boolean;
61
+ /** Set the caption mode: 'off' hides captions, 'standard'/'social' enable
62
+ * that style. The single entry point so the toggle buttons persist in one
63
+ * frontmatter write. */
64
+ setCaptionMode: (mode: CaptionMode) => void;
54
65
  /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
55
66
  customThemes: Theme[];
56
67
  /** Open the custom-theme designer for a theme (or null to create a new one). */
@@ -112,10 +123,15 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
112
123
  function resolveDisplayMode(value: unknown): DisplayMode | null {
113
124
  if (typeof value !== 'string') return null;
114
125
  const v = value.trim().toLowerCase();
115
- if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'page') return v;
126
+ if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
116
127
  if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
117
- if (v === 'document' || v === 'scroll') return 'linear';
118
- if (v === 'html' || v === 'plain' || v === 'reader') return 'page';
128
+ // Frontmatter uses product-facing names: Document is the plain text/HTML
129
+ // preview, Page is the styled Squisq page view. The raw DisplayMode values
130
+ // are older and remain stable for the public React API.
131
+ if (v === 'page' || v === 'paged') return 'linear';
132
+ if (v === 'document' || v === 'scroll' || v === 'html' || v === 'plain' || v === 'reader') {
133
+ return 'page';
134
+ }
119
135
  return null;
120
136
  }
121
137
 
@@ -146,11 +162,12 @@ function resolveFrontmatterTransform(value: unknown): string | null {
146
162
  return null;
147
163
  }
148
164
 
149
- function resolveFrontmatterCaptionStyle(value: unknown): CaptionStyle | null {
165
+ function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
150
166
  if (typeof value !== 'string') return null;
151
167
  const v = value.trim().toLowerCase();
152
- if (v === 'standard' || v === 'social') return v;
153
- if (v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
168
+ if (v === 'off' || v === 'none' || v === 'hidden' || v === 'false' || v === 'no') return 'off';
169
+ if (v === 'standard' || v === 'cc' || v === 'captions') return 'standard';
170
+ if (v === 'social' || v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
154
171
  return null;
155
172
  }
156
173
 
@@ -326,21 +343,25 @@ export function PreviewSettingsProvider({
326
343
  [persistFrontmatter],
327
344
  );
328
345
 
329
- // Caption stylepersisted to `squisq-captions` (legacy `caption-style` read for compat)
330
- const fmCaption = useMemo(
346
+ // Caption mode'off' | 'standard' | 'social'. Persisted to `squisq-captions`
347
+ // (legacy `caption-style` read for compat). `activeCaptionStyle` is the style
348
+ // used when enabled; `activeCaptionsEnabled` is the off/on split.
349
+ const fmCaptionMode = useMemo(
331
350
  () =>
332
- resolveFrontmatterCaptionStyle(
351
+ resolveFrontmatterCaptionMode(
333
352
  readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
334
353
  ),
335
354
  [frontmatter],
336
355
  );
337
- const [selectedCaptionStyle, setSelectedCaptionStyle] = useState<CaptionStyle | null>(null);
338
- useEffect(() => setSelectedCaptionStyle(null), [fmCaption]);
339
- const activeCaptionStyle = selectedCaptionStyle ?? fmCaption ?? 'standard';
340
- const handleSetCaptionStyle = useCallback(
341
- (style: CaptionStyle | null) => {
342
- setSelectedCaptionStyle(style);
343
- if (style !== null) persistFrontmatter({ [FM_KEYS.captions.canonical]: style });
356
+ const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
357
+ useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
358
+ const activeCaptionMode = selectedCaptionMode ?? fmCaptionMode ?? 'standard';
359
+ const activeCaptionsEnabled = activeCaptionMode !== 'off';
360
+ const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
361
+ const handleSetCaptionMode = useCallback(
362
+ (mode: CaptionMode) => {
363
+ setSelectedCaptionMode(mode);
364
+ persistFrontmatter({ [FM_KEYS.captions.canonical]: mode });
344
365
  },
345
366
  [persistFrontmatter],
346
367
  );
@@ -373,7 +394,8 @@ export function PreviewSettingsProvider({
373
394
  activeTransformStyle,
374
395
  setSelectedTransformStyle: handleSetTransformStyle,
375
396
  activeCaptionStyle,
376
- setSelectedCaptionStyle: handleSetCaptionStyle,
397
+ activeCaptionsEnabled,
398
+ setCaptionMode: handleSetCaptionMode,
377
399
  customThemes,
378
400
  openThemeDesigner,
379
401
  deleteCustomTheme,
@@ -387,9 +409,10 @@ export function PreviewSettingsProvider({
387
409
  activeTheme,
388
410
  activeTransformStyle,
389
411
  activeCaptionStyle,
412
+ activeCaptionsEnabled,
390
413
  handleSetThemeId,
391
414
  handleSetTransformStyle,
392
- handleSetCaptionStyle,
415
+ handleSetCaptionMode,
393
416
  customThemes,
394
417
  openThemeDesigner,
395
418
  deleteCustomTheme,
@@ -423,18 +446,23 @@ export function ThemeDesignerDock() {
423
446
 
424
447
  // ── Dropdown options ─────────────────────────────────────────────
425
448
 
426
- const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
427
- { key: 'landscape', label: '16:9' },
428
- { key: 'portrait', label: '9:16' },
429
- { key: 'square', label: '1:1' },
430
- { key: 'standard', label: '4:3' },
449
+ /**
450
+ * Aspect-ratio presets surfaced as the segmented {@link PreviewFormatSwitch}
451
+ * on the left of the toolbar. `w`/`h` are the glyph rectangle dimensions (in a
452
+ * 16×16 viewBox) drawn by {@link AspectIcon} to depict each ratio.
453
+ */
454
+ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h: number }[] = [
455
+ { key: 'landscape', label: '16:9', w: 13, h: 7 },
456
+ { key: 'square', label: '1:1', w: 10, h: 10 },
457
+ { key: 'portrait', label: '9:16', w: 7, h: 12 },
458
+ { key: 'standard', label: '4:3', w: 12, h: 9 },
431
459
  ];
432
460
 
433
461
  const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
434
462
  { key: 'video', label: 'Video' },
435
463
  { key: 'slideshow', label: 'Slideshow' },
436
- { key: 'linear', label: 'Document' },
437
- { key: 'page', label: 'Page' },
464
+ { key: 'linear', label: 'Page' },
465
+ { key: 'page', label: 'Document' },
438
466
  ];
439
467
 
440
468
  const TRANSFORM_STYLE_OPTIONS = [
@@ -442,10 +470,18 @@ const TRANSFORM_STYLE_OPTIONS = [
442
470
  ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
443
471
  ];
444
472
 
445
- const CAPTION_STYLE_OPTIONS: { key: CaptionStyle; label: string }[] = [
446
- { key: 'standard', label: 'Standard' },
447
- { key: 'social', label: 'Social' },
448
- ];
473
+ /**
474
+ * Left-to-right priority order for the preview controls. As the toolbar
475
+ * narrows, controls drop into the overflow menu from the END of this list
476
+ * first (Captions, then Transform, …), so the higher-priority control (Theme)
477
+ * stays inline the longest.
478
+ *
479
+ * Display mode and aspect ratio are not here — they're surfaced separately as
480
+ * the segmented {@link PreviewModeSwitch} / {@link PreviewFormatSwitch} on the
481
+ * left of the toolbar.
482
+ */
483
+ type ControlKey = 'theme' | 'transform' | 'captions';
484
+ const CONTROL_KEYS: ControlKey[] = ['theme', 'transform', 'captions'];
449
485
 
450
486
  // ── Shared styles ────────────────────────────────────────────────
451
487
 
@@ -467,30 +503,67 @@ const selectStyle: React.CSSProperties = {
467
503
 
468
504
  // ── Toolbar Controls Component ───────────────────────────────────
469
505
 
470
- /** Hook to track whether the viewport is narrow. */
471
- function useIsNarrow(breakpoint = 600): boolean {
472
- const [narrow, setNarrow] = useState(
473
- () => typeof window !== 'undefined' && window.innerWidth <= breakpoint,
474
- );
475
- useEffect(() => {
476
- const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
477
- const handler = (e: MediaQueryListEvent) => setNarrow(e.matches);
478
- mq.addEventListener('change', handler);
479
- return () => mq.removeEventListener('change', handler);
480
- }, [breakpoint]);
481
- return narrow;
482
- }
483
-
484
506
  /**
485
507
  * Inline preview controls rendered in the main toolbar row.
486
- * On narrow viewports, collapses into a single settings button with a dropdown.
508
+ *
509
+ * Collapse is *progressive* (a priority-plus pattern): rather than switch the
510
+ * whole row in and out at a fixed window-width breakpoint, the controls
511
+ * measure how many of them actually fit in the width the toolbar gives them
512
+ * and keep that many inline, folding the rest — from the low-priority end of
513
+ * {@link CONTROL_KEYS} — into a single settings (gear) button's popover. As
514
+ * the toolbar widens or narrows, controls migrate one at a time between the
515
+ * inline row and the menu, so the available space is always well used and the
516
+ * row never wraps onto a second line.
487
517
  */
488
518
  export function PreviewToolbarControls() {
489
519
  const s = usePreviewSettings();
490
- const isNarrow = useIsNarrow(768);
520
+ const [visibleCount, setVisibleCount] = useState(CONTROL_KEYS.length);
491
521
  const [popoverOpen, setPopoverOpen] = useState(false);
522
+ // `rootRef` (flex:1) always spans the toolbar's leftover width, so its
523
+ // clientWidth is the budget the controls have to lay out in.
524
+ const rootRef = useRef<HTMLDivElement>(null);
525
+ // Hidden probe rendering every control at natural width; the split between
526
+ // inline and overflow is computed from these per-control measurements.
527
+ const probeRef = useRef<HTMLDivElement>(null);
492
528
  const popoverRef = useRef<HTMLDivElement>(null);
493
529
 
530
+ // Fit detection: keep as many controls inline as fit, overflow the rest.
531
+ useLayoutEffect(() => {
532
+ const root = rootRef.current;
533
+ const probe = probeRef.current;
534
+ if (!root || !probe) return;
535
+ const GAP = 6; // matches the row's flex `gap`
536
+ const LEAD_PAD = 9; // root's left padding, eaten before any control
537
+ const GEAR_RESERVE = 40; // width kept for the overflow gear button (+ its gap)
538
+ const SAFETY = 2;
539
+ const measure = () => {
540
+ const available = root.clientWidth - LEAD_PAD;
541
+ const widths = Array.from(probe.children).map(
542
+ (el) => (el as HTMLElement).getBoundingClientRect().width,
543
+ );
544
+ // Width of the first `n` controls laid out inline (n-1 inter-control gaps).
545
+ const rowWidth = (n: number) =>
546
+ widths.slice(0, n).reduce((sum, w) => sum + w, 0) + GAP * Math.max(0, n - 1);
547
+ // Everything fits → no overflow button needed.
548
+ if (rowWidth(widths.length) <= available) {
549
+ setVisibleCount(widths.length);
550
+ return;
551
+ }
552
+ // Otherwise reserve room for the gear and fit as many as possible.
553
+ const budget = available - GEAR_RESERVE - GAP - SAFETY;
554
+ let count = 0;
555
+ while (count < widths.length && rowWidth(count + 1) <= budget) count++;
556
+ setVisibleCount(count);
557
+ };
558
+ const ro = new ResizeObserver(measure);
559
+ ro.observe(root);
560
+ // Observe the probe too: a control's width can change (e.g. a longer theme
561
+ // name) without the toolbar resizing, and that shifts the split.
562
+ ro.observe(probe);
563
+ measure();
564
+ return () => ro.disconnect();
565
+ }, []);
566
+
494
567
  // Close popover on outside click
495
568
  useEffect(() => {
496
569
  if (!popoverOpen) return;
@@ -516,107 +589,231 @@ export function PreviewToolbarControls() {
516
589
  }
517
590
  }, [s]);
518
591
 
519
- const controls = (
520
- <>
521
- <PreviewSelect
522
- label="Format"
523
- value={s.activePreset}
524
- options={VIEWPORT_OPTIONS}
525
- onChange={(v) => s.setSelectedPreset(v as ViewportPreset)}
526
- compact={isNarrow}
527
- />
528
- <PreviewSelect
529
- label="Mode"
530
- value={s.activeDisplayMode}
531
- options={DISPLAY_MODE_OPTIONS}
532
- onChange={(v) => s.setSelectedDisplayMode(v as DisplayMode)}
533
- compact={isNarrow}
534
- />
535
- <div
536
- className={`squisq-preview-control${isNarrow ? ' squisq-preview-control--compact' : ''}`}
537
- >
538
- <label style={labelStyle}>Theme:</label>
539
- <ThemePicker
540
- value={s.activeThemeId}
541
- onChange={(v) => s.setSelectedThemeId(v)}
542
- ariaLabel="Theme"
543
- customThemes={s.customThemes}
544
- onCreateCustom={() => s.openThemeDesigner(null)}
545
- onEditCustom={(id) =>
546
- s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
547
- }
548
- onDeleteCustom={(id) => s.deleteCustomTheme(id)}
549
- />
550
- <button
551
- type="button"
552
- className="squisq-theme-edit-btn"
553
- onClick={handleEditCurrentTheme}
554
- aria-label="Edit theme"
555
- title="Edit this theme"
556
- >
557
- <svg
558
- width="14"
559
- height="14"
560
- viewBox="0 0 16 16"
561
- fill="none"
562
- stroke="currentColor"
563
- strokeWidth="1.4"
564
- strokeLinecap="round"
565
- strokeLinejoin="round"
566
- aria-hidden="true"
592
+ // Render a single control by key. `compact` switches to the stacked
593
+ // label-over-control layout used inside the overflow popover.
594
+ const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
595
+ switch (key) {
596
+ case 'theme':
597
+ return (
598
+ <div
599
+ key="theme"
600
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
601
+ >
602
+ <label style={labelStyle}>Theme:</label>
603
+ <ThemePicker
604
+ value={s.activeThemeId}
605
+ onChange={(v) => s.setSelectedThemeId(v)}
606
+ ariaLabel="Theme"
607
+ customThemes={s.customThemes}
608
+ onCreateCustom={() => s.openThemeDesigner(null)}
609
+ onEditCustom={(id) =>
610
+ s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
611
+ }
612
+ onDeleteCustom={(id) => s.deleteCustomTheme(id)}
613
+ />
614
+ <button
615
+ type="button"
616
+ className="squisq-theme-edit-btn"
617
+ onClick={handleEditCurrentTheme}
618
+ aria-label="Edit theme"
619
+ title="Edit this theme"
620
+ >
621
+ <svg
622
+ width="14"
623
+ height="14"
624
+ viewBox="0 0 16 16"
625
+ fill="none"
626
+ stroke="currentColor"
627
+ strokeWidth="1.4"
628
+ strokeLinecap="round"
629
+ strokeLinejoin="round"
630
+ aria-hidden="true"
631
+ >
632
+ <path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
633
+ <path d="M9.5 4.5l2 2" />
634
+ </svg>
635
+ </button>
636
+ </div>
637
+ );
638
+ case 'transform':
639
+ return (
640
+ <PreviewSelect
641
+ key="transform"
642
+ label="Transform"
643
+ value={s.activeTransformStyle}
644
+ options={TRANSFORM_STYLE_OPTIONS}
645
+ onChange={(v) => s.setSelectedTransformStyle(v)}
646
+ compact={compact}
647
+ />
648
+ );
649
+ case 'captions': {
650
+ // Two independent toggles (either can be off): CC = standard captions,
651
+ // share icon = social captions. Clicking the active one turns captions
652
+ // off; clicking the other switches style (and turns captions on).
653
+ const enabled = s.activeCaptionsEnabled;
654
+ const ccActive = enabled && s.activeCaptionStyle === 'standard';
655
+ const socialActive = enabled && s.activeCaptionStyle === 'social';
656
+ return (
657
+ <div
658
+ key="captions"
659
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
567
660
  >
568
- <path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
569
- <path d="M9.5 4.5l2 2" />
570
- </svg>
571
- </button>
661
+ <label style={labelStyle}>Captions:</label>
662
+ <div className="squisq-preview-seg" role="group" aria-label="Captions">
663
+ <button
664
+ type="button"
665
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${ccActive ? ' squisq-preview-seg-btn--active' : ''}`}
666
+ aria-pressed={ccActive}
667
+ aria-label="Standard captions"
668
+ title="Standard captions"
669
+ onClick={() => s.setCaptionMode(ccActive ? 'off' : 'standard')}
670
+ >
671
+ <Icon icon="fa-solid fa-closed-captioning" />
672
+ </button>
673
+ <button
674
+ type="button"
675
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${socialActive ? ' squisq-preview-seg-btn--active' : ''}`}
676
+ aria-pressed={socialActive}
677
+ aria-label="Social captions"
678
+ title="Social captions"
679
+ onClick={() => s.setCaptionMode(socialActive ? 'off' : 'social')}
680
+ >
681
+ <Icon icon="fa-solid fa-share-nodes" />
682
+ </button>
683
+ </div>
684
+ </div>
685
+ );
686
+ }
687
+ }
688
+ };
689
+
690
+ const hasOverflow = visibleCount < CONTROL_KEYS.length;
691
+ const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
692
+ const overflowKeys = CONTROL_KEYS.slice(visibleCount);
693
+
694
+ // The root is a flex:1 filler so it always spans the toolbar's leftover
695
+ // width (which is what the fit measurement reads).
696
+ return (
697
+ <div className="squisq-preview-controls" ref={rootRef}>
698
+ {/* Hidden probe — every control at natural width, measured to decide the
699
+ inline/overflow split. Absolutely positioned so it never affects
700
+ layout. */}
701
+ <div className="squisq-preview-controls-probe" ref={probeRef} aria-hidden="true">
702
+ {CONTROL_KEYS.map((key) => renderControl(key, false))}
572
703
  </div>
573
- <PreviewSelect
574
- label="Transform"
575
- value={s.activeTransformStyle}
576
- options={TRANSFORM_STYLE_OPTIONS}
577
- onChange={(v) => s.setSelectedTransformStyle(v)}
578
- compact={isNarrow}
579
- />
580
- <PreviewSelect
581
- label="Captions"
582
- value={s.activeCaptionStyle}
583
- options={CAPTION_STYLE_OPTIONS}
584
- onChange={(v) => s.setSelectedCaptionStyle(v as CaptionStyle)}
585
- compact={isNarrow}
586
- />
587
- </>
704
+
705
+ <div className="squisq-preview-controls-inline">
706
+ {visibleKeys.map((key) => renderControl(key, false))}
707
+ </div>
708
+
709
+ {hasOverflow && (
710
+ <div className="squisq-preview-controls-compact" ref={popoverRef}>
711
+ <button
712
+ className={`squisq-toolbar-button${popoverOpen ? ' squisq-toolbar-button--active' : ''}`}
713
+ onClick={() => setPopoverOpen((v) => !v)}
714
+ aria-label="More preview settings"
715
+ title="More preview settings"
716
+ aria-expanded={popoverOpen}
717
+ >
718
+ <svg
719
+ width="16"
720
+ height="16"
721
+ viewBox="0 0 16 16"
722
+ fill="none"
723
+ stroke="currentColor"
724
+ strokeWidth="1.5"
725
+ strokeLinecap="round"
726
+ strokeLinejoin="round"
727
+ >
728
+ <circle cx="8" cy="8" r="2.5" />
729
+ <path d="M13.5 8a5.5 5.5 0 01-.4 1.8l1.2 1.2-1.6 1.6-1.2-1.2A5.5 5.5 0 018 13.5a5.5 5.5 0 01-3.5-1.3L3.3 13.4 1.7 11.8l1.2-1.2A5.5 5.5 0 012.5 8c0-.6.1-1.2.4-1.8L1.7 5 3.3 3.4l1.2 1.2A5.5 5.5 0 018 2.5c1.3 0 2.5.5 3.5 1.3l1.2-1.2 1.6 1.6-1.2 1.2c.3.6.4 1.2.4 1.6z" />
730
+ </svg>
731
+ </button>
732
+ {popoverOpen && (
733
+ <div className="squisq-preview-controls-popover">
734
+ {overflowKeys.map((key) => renderControl(key, true))}
735
+ </div>
736
+ )}
737
+ </div>
738
+ )}
739
+ </div>
588
740
  );
741
+ }
589
742
 
590
- if (isNarrow) {
591
- return (
592
- <div className="squisq-preview-controls-compact" ref={popoverRef}>
593
- <button
594
- className="squisq-toolbar-button"
595
- onClick={() => setPopoverOpen((v) => !v)}
596
- aria-label="Preview settings"
597
- title="Preview settings"
598
- aria-expanded={popoverOpen}
599
- >
600
- <svg
601
- width="16"
602
- height="16"
603
- viewBox="0 0 16 16"
604
- fill="none"
605
- stroke="currentColor"
606
- strokeWidth="1.5"
607
- strokeLinecap="round"
608
- strokeLinejoin="round"
743
+ /**
744
+ * Segmented display-mode switch (Video / Slideshow / Document / Page) rendered
745
+ * as four connected buttons on the left of the Play toolbar — the prominent,
746
+ * one-click counterpart to the old "Mode:" dropdown. Reads and writes the same
747
+ * `activeDisplayMode` in preview settings.
748
+ */
749
+ export function PreviewModeSwitch() {
750
+ const s = usePreviewSettings();
751
+ return (
752
+ <div className="squisq-preview-seg" role="group" aria-label="Display mode">
753
+ {DISPLAY_MODE_OPTIONS.map((opt) => {
754
+ const active = s.activeDisplayMode === opt.key;
755
+ return (
756
+ <button
757
+ key={opt.key}
758
+ type="button"
759
+ className={`squisq-preview-seg-btn${active ? ' squisq-preview-seg-btn--active' : ''}`}
760
+ aria-pressed={active}
761
+ onClick={() => s.setSelectedDisplayMode(opt.key)}
609
762
  >
610
- <circle cx="8" cy="8" r="2.5" />
611
- <path d="M13.5 8a5.5 5.5 0 01-.4 1.8l1.2 1.2-1.6 1.6-1.2-1.2A5.5 5.5 0 018 13.5a5.5 5.5 0 01-3.5-1.3L3.3 13.4 1.7 11.8l1.2-1.2A5.5 5.5 0 012.5 8c0-.6.1-1.2.4-1.8L1.7 5 3.3 3.4l1.2 1.2A5.5 5.5 0 018 2.5c1.3 0 2.5.5 3.5 1.3l1.2-1.2 1.6 1.6-1.2 1.2c.3.6.4 1.2.4 1.6z" />
612
- </svg>
613
- </button>
614
- {popoverOpen && <div className="squisq-preview-controls-popover">{controls}</div>}
615
- </div>
616
- );
617
- }
763
+ {opt.label}
764
+ </button>
765
+ );
766
+ })}
767
+ </div>
768
+ );
769
+ }
770
+
771
+ /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
772
+ * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
773
+ function AspectIcon({ w, h }: { w: number; h: number }) {
774
+ return (
775
+ <svg
776
+ width="16"
777
+ height="16"
778
+ viewBox="0 0 16 16"
779
+ fill="none"
780
+ stroke="currentColor"
781
+ strokeWidth="1.4"
782
+ aria-hidden="true"
783
+ >
784
+ <rect x={(16 - w) / 2} y={(16 - h) / 2} width={w} height={h} rx="1.5" />
785
+ </svg>
786
+ );
787
+ }
618
788
 
619
- return <div className="squisq-preview-controls-inline">{controls}</div>;
789
+ /**
790
+ * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3) rendered as connected
791
+ * icon buttons on the left of the Play toolbar, next to the mode switch — the
792
+ * one-click counterpart to the old "Format:" dropdown. Reads and writes the
793
+ * same `activePreset` in preview settings.
794
+ */
795
+ export function PreviewFormatSwitch() {
796
+ const s = usePreviewSettings();
797
+ return (
798
+ <div className="squisq-preview-seg" role="group" aria-label="Aspect ratio">
799
+ {FORMAT_SWITCH_OPTIONS.map((opt) => {
800
+ const active = s.activePreset === opt.key;
801
+ return (
802
+ <button
803
+ key={opt.key}
804
+ type="button"
805
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${active ? ' squisq-preview-seg-btn--active' : ''}`}
806
+ aria-pressed={active}
807
+ aria-label={opt.label}
808
+ title={opt.label}
809
+ onClick={() => s.setSelectedPreset(opt.key)}
810
+ >
811
+ <AspectIcon w={opt.w} h={opt.h} />
812
+ </button>
813
+ );
814
+ })}
815
+ </div>
816
+ );
620
817
  }
621
818
 
622
819
  function PreviewSelect({