@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  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 +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -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,18 @@ 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;
65
+ /** Whether Squisq should synthesize and show its managed cover slide. */
66
+ activeCoverSlide: boolean;
67
+ /** Enable/disable the managed cover slide. */
68
+ setCoverSlideEnabled: (enabled: boolean) => void;
54
69
  /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
55
70
  customThemes: Theme[];
56
71
  /** Open the custom-theme designer for a theme (or null to create a new one). */
@@ -112,10 +127,15 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
112
127
  function resolveDisplayMode(value: unknown): DisplayMode | null {
113
128
  if (typeof value !== 'string') return null;
114
129
  const v = value.trim().toLowerCase();
115
- if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'page') return v;
130
+ if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
116
131
  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';
132
+ // Frontmatter uses product-facing names: Document is the plain text/HTML
133
+ // preview, Page is the styled Squisq page view. The raw DisplayMode values
134
+ // are older and remain stable for the public React API.
135
+ if (v === 'page' || v === 'paged') return 'linear';
136
+ if (v === 'document' || v === 'scroll' || v === 'html' || v === 'plain' || v === 'reader') {
137
+ return 'page';
138
+ }
119
139
  return null;
120
140
  }
121
141
 
@@ -146,11 +166,21 @@ function resolveFrontmatterTransform(value: unknown): string | null {
146
166
  return null;
147
167
  }
148
168
 
149
- function resolveFrontmatterCaptionStyle(value: unknown): CaptionStyle | null {
169
+ function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
170
+ if (typeof value !== 'string') return null;
171
+ const v = value.trim().toLowerCase();
172
+ if (v === 'off' || v === 'none' || v === 'hidden' || v === 'false' || v === 'no') return 'off';
173
+ if (v === 'standard' || v === 'cc' || v === 'captions') return 'standard';
174
+ if (v === 'social' || v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
175
+ return null;
176
+ }
177
+
178
+ function resolveFrontmatterBoolean(value: unknown): boolean | null {
179
+ if (typeof value === 'boolean') return value;
150
180
  if (typeof value !== 'string') return null;
151
181
  const v = value.trim().toLowerCase();
152
- if (v === 'standard' || v === 'social') return v;
153
- if (v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
182
+ if (v === 'true' || v === 'yes' || v === 'on' || v === 'show' || v === 'visible') return true;
183
+ if (v === 'false' || v === 'no' || v === 'off' || v === 'hide' || v === 'hidden') return false;
154
184
  return null;
155
185
  }
156
186
 
@@ -175,6 +205,7 @@ const FM_KEYS = {
175
205
  theme: { canonical: 'squisq-theme', legacy: 'theme' as const },
176
206
  transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
177
207
  captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
+ coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
178
209
  } as const;
179
210
 
180
211
  function readFrontmatterKey(
@@ -326,21 +357,45 @@ export function PreviewSettingsProvider({
326
357
  [persistFrontmatter],
327
358
  );
328
359
 
329
- // Caption stylepersisted to `squisq-captions` (legacy `caption-style` read for compat)
330
- const fmCaption = useMemo(
360
+ // Caption mode'off' | 'standard' | 'social'. Persisted to `squisq-captions`
361
+ // (legacy `caption-style` read for compat). `activeCaptionStyle` is the style
362
+ // used when enabled; `activeCaptionsEnabled` is the off/on split.
363
+ const fmCaptionMode = useMemo(
331
364
  () =>
332
- resolveFrontmatterCaptionStyle(
365
+ resolveFrontmatterCaptionMode(
333
366
  readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
334
367
  ),
335
368
  [frontmatter],
336
369
  );
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 });
370
+ const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
371
+ useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
372
+ const activeCaptionMode = selectedCaptionMode ?? fmCaptionMode ?? 'standard';
373
+ const activeCaptionsEnabled = activeCaptionMode !== 'off';
374
+ const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
375
+ const handleSetCaptionMode = useCallback(
376
+ (mode: CaptionMode) => {
377
+ setSelectedCaptionMode(mode);
378
+ persistFrontmatter({ [FM_KEYS.captions.canonical]: mode });
379
+ },
380
+ [persistFrontmatter],
381
+ );
382
+
383
+ // Managed cover slide — generated from the document startBlock. Defaults on
384
+ // for existing documents; authors can persist an explicit off switch.
385
+ const fmCoverSlide = useMemo(
386
+ () =>
387
+ resolveFrontmatterBoolean(
388
+ readFrontmatterKey(frontmatter, FM_KEYS.coverSlide.canonical, FM_KEYS.coverSlide.legacy),
389
+ ),
390
+ [frontmatter],
391
+ );
392
+ const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
393
+ useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
394
+ const activeCoverSlide = selectedCoverSlide ?? fmCoverSlide ?? true;
395
+ const handleSetCoverSlideEnabled = useCallback(
396
+ (enabled: boolean) => {
397
+ setSelectedCoverSlide(enabled);
398
+ persistFrontmatter({ [FM_KEYS.coverSlide.canonical]: enabled ? 'true' : 'false' });
344
399
  },
345
400
  [persistFrontmatter],
346
401
  );
@@ -373,7 +428,10 @@ export function PreviewSettingsProvider({
373
428
  activeTransformStyle,
374
429
  setSelectedTransformStyle: handleSetTransformStyle,
375
430
  activeCaptionStyle,
376
- setSelectedCaptionStyle: handleSetCaptionStyle,
431
+ activeCaptionsEnabled,
432
+ setCaptionMode: handleSetCaptionMode,
433
+ activeCoverSlide,
434
+ setCoverSlideEnabled: handleSetCoverSlideEnabled,
377
435
  customThemes,
378
436
  openThemeDesigner,
379
437
  deleteCustomTheme,
@@ -387,9 +445,12 @@ export function PreviewSettingsProvider({
387
445
  activeTheme,
388
446
  activeTransformStyle,
389
447
  activeCaptionStyle,
448
+ activeCaptionsEnabled,
449
+ activeCoverSlide,
390
450
  handleSetThemeId,
391
451
  handleSetTransformStyle,
392
- handleSetCaptionStyle,
452
+ handleSetCaptionMode,
453
+ handleSetCoverSlideEnabled,
393
454
  customThemes,
394
455
  openThemeDesigner,
395
456
  deleteCustomTheme,
@@ -423,18 +484,23 @@ export function ThemeDesignerDock() {
423
484
 
424
485
  // ── Dropdown options ─────────────────────────────────────────────
425
486
 
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' },
487
+ /**
488
+ * Aspect-ratio presets surfaced as the segmented {@link PreviewFormatSwitch}
489
+ * on the left of the toolbar. `w`/`h` are the glyph rectangle dimensions (in a
490
+ * 16×16 viewBox) drawn by {@link AspectIcon} to depict each ratio.
491
+ */
492
+ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h: number }[] = [
493
+ { key: 'landscape', label: '16:9', w: 13, h: 7 },
494
+ { key: 'square', label: '1:1', w: 10, h: 10 },
495
+ { key: 'portrait', label: '9:16', w: 7, h: 12 },
496
+ { key: 'standard', label: '4:3', w: 12, h: 9 },
431
497
  ];
432
498
 
433
499
  const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
434
500
  { key: 'video', label: 'Video' },
435
501
  { key: 'slideshow', label: 'Slideshow' },
436
- { key: 'linear', label: 'Document' },
437
- { key: 'page', label: 'Page' },
502
+ { key: 'linear', label: 'Page' },
503
+ { key: 'page', label: 'Document' },
438
504
  ];
439
505
 
440
506
  const TRANSFORM_STYLE_OPTIONS = [
@@ -442,10 +508,31 @@ const TRANSFORM_STYLE_OPTIONS = [
442
508
  ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
443
509
  ];
444
510
 
445
- const CAPTION_STYLE_OPTIONS: { key: CaptionStyle; label: string }[] = [
446
- { key: 'standard', label: 'Standard' },
447
- { key: 'social', label: 'Social' },
448
- ];
511
+ /**
512
+ * Left-to-right priority order for the preview controls. As the toolbar
513
+ * narrows, controls drop into the overflow menu from the END of this list
514
+ * first (Cover, then Captions, …). Display mode and aspect ratio stay inline
515
+ * the longest, but still collapse into the same menu when the toolbar is very
516
+ * constrained.
517
+ */
518
+ type ControlKey = 'mode' | 'format' | 'theme' | 'transform' | 'captions' | 'cover';
519
+ const CONTROL_KEYS: ControlKey[] = ['mode', 'format', 'theme', 'transform', 'captions', 'cover'];
520
+
521
+ const PREVIEW_POPOVER_GAP = 4;
522
+ const PREVIEW_POPOVER_MARGIN = 8;
523
+ const PREVIEW_POPOVER_FALLBACK_WIDTH = 220;
524
+
525
+ function clampPreviewPopoverLeft(
526
+ triggerRect: DOMRect,
527
+ popoverWidth: number,
528
+ viewportWidth: number,
529
+ ): number {
530
+ const maxLeft = Math.max(
531
+ PREVIEW_POPOVER_MARGIN,
532
+ viewportWidth - popoverWidth - PREVIEW_POPOVER_MARGIN,
533
+ );
534
+ return Math.min(Math.max(PREVIEW_POPOVER_MARGIN, triggerRect.right - popoverWidth), maxLeft);
535
+ }
449
536
 
450
537
  // ── Shared styles ────────────────────────────────────────────────
451
538
 
@@ -467,41 +554,115 @@ const selectStyle: React.CSSProperties = {
467
554
 
468
555
  // ── Toolbar Controls Component ───────────────────────────────────
469
556
 
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
557
  /**
485
558
  * Inline preview controls rendered in the main toolbar row.
486
- * On narrow viewports, collapses into a single settings button with a dropdown.
559
+ *
560
+ * Collapse is *progressive* (a priority-plus pattern): rather than switch the
561
+ * whole row in and out at a fixed window-width breakpoint, the controls
562
+ * measure how many of them actually fit in the width the toolbar gives them
563
+ * and keep that many inline, folding the rest — from the low-priority end of
564
+ * {@link CONTROL_KEYS} — into a single ellipsis button's popover. As
565
+ * the toolbar widens or narrows, controls migrate one at a time between the
566
+ * inline row and the menu, so the available space is always well used and the
567
+ * row never wraps onto a second line.
487
568
  */
488
569
  export function PreviewToolbarControls() {
489
570
  const s = usePreviewSettings();
490
- const isNarrow = useIsNarrow(768);
571
+ const [visibleCount, setVisibleCount] = useState(CONTROL_KEYS.length);
491
572
  const [popoverOpen, setPopoverOpen] = useState(false);
573
+ // `rootRef` (flex:1) always spans the toolbar's leftover width, so its
574
+ // clientWidth is the budget the controls have to lay out in.
575
+ const rootRef = useRef<HTMLDivElement>(null);
576
+ // Hidden probe rendering every control at natural width; the split between
577
+ // inline and overflow is computed from these per-control measurements.
578
+ const probeRef = useRef<HTMLDivElement>(null);
492
579
  const popoverRef = useRef<HTMLDivElement>(null);
580
+ const popoverTriggerRef = useRef<HTMLButtonElement>(null);
581
+ const popoverPanelRef = useRef<HTMLDivElement>(null);
582
+ const [popoverAnchor, setPopoverAnchor] = useState<{ top: number; left: number } | null>(null);
583
+
584
+ const updatePopoverPosition = useCallback(() => {
585
+ const trigger = popoverTriggerRef.current;
586
+ if (!trigger) return;
587
+ const triggerRect = trigger.getBoundingClientRect();
588
+ const measuredWidth =
589
+ popoverPanelRef.current?.getBoundingClientRect().width ?? PREVIEW_POPOVER_FALLBACK_WIDTH;
590
+ const popoverWidth = Math.min(measuredWidth, window.innerWidth - PREVIEW_POPOVER_MARGIN * 2);
591
+ setPopoverAnchor({
592
+ top: triggerRect.bottom + PREVIEW_POPOVER_GAP,
593
+ left: clampPreviewPopoverLeft(triggerRect, popoverWidth, window.innerWidth),
594
+ });
595
+ }, []);
596
+
597
+ const closePopover = useCallback(() => {
598
+ setPopoverOpen(false);
599
+ setPopoverAnchor(null);
600
+ }, []);
601
+
602
+ // Fit detection: keep as many controls inline as fit, overflow the rest.
603
+ useLayoutEffect(() => {
604
+ const root = rootRef.current;
605
+ const probe = probeRef.current;
606
+ if (!root || !probe) return;
607
+ const GAP = 6; // matches the row's flex `gap`
608
+ const LEAD_PAD = 9; // root's left padding, eaten before any control
609
+ const OVERFLOW_TRIGGER_RESERVE = 40; // width kept for the ellipsis button (+ its gap)
610
+ const SAFETY = 2;
611
+ const measure = () => {
612
+ const available = root.clientWidth - LEAD_PAD;
613
+ const widths = Array.from(probe.children).map(
614
+ (el) => (el as HTMLElement).getBoundingClientRect().width,
615
+ );
616
+ // Width of the first `n` controls laid out inline (n-1 inter-control gaps).
617
+ const rowWidth = (n: number) =>
618
+ widths.slice(0, n).reduce((sum, w) => sum + w, 0) + GAP * Math.max(0, n - 1);
619
+ // Everything fits → no overflow button needed.
620
+ if (rowWidth(widths.length) <= available) {
621
+ setVisibleCount(widths.length);
622
+ return;
623
+ }
624
+ // Otherwise reserve room for the ellipsis and fit as many as possible.
625
+ const budget = available - OVERFLOW_TRIGGER_RESERVE - GAP - SAFETY;
626
+ let count = 0;
627
+ while (count < widths.length && rowWidth(count + 1) <= budget) count++;
628
+ setVisibleCount(count);
629
+ };
630
+ const ro = new ResizeObserver(measure);
631
+ ro.observe(root);
632
+ // Observe the probe too: a control's width can change (e.g. a longer theme
633
+ // name) without the toolbar resizing, and that shifts the split.
634
+ ro.observe(probe);
635
+ measure();
636
+ return () => ro.disconnect();
637
+ }, []);
493
638
 
494
639
  // Close popover on outside click
495
640
  useEffect(() => {
496
641
  if (!popoverOpen) return;
497
642
  const handler = (e: MouseEvent) => {
498
- if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
499
- setPopoverOpen(false);
500
- }
643
+ const target = e.target as Node;
644
+ if (popoverRef.current?.contains(target)) return;
645
+ if (popoverPanelRef.current?.contains(target)) return;
646
+ closePopover();
501
647
  };
502
648
  document.addEventListener('mousedown', handler);
503
649
  return () => document.removeEventListener('mousedown', handler);
504
- }, [popoverOpen]);
650
+ }, [closePopover, popoverOpen]);
651
+
652
+ useLayoutEffect(() => {
653
+ if (!popoverOpen) return;
654
+ updatePopoverPosition();
655
+ }, [popoverOpen, updatePopoverPosition, visibleCount]);
656
+
657
+ useEffect(() => {
658
+ if (!popoverOpen) return;
659
+ window.addEventListener('resize', updatePopoverPosition);
660
+ window.addEventListener('scroll', updatePopoverPosition, true);
661
+ return () => {
662
+ window.removeEventListener('resize', updatePopoverPosition);
663
+ window.removeEventListener('scroll', updatePopoverPosition, true);
664
+ };
665
+ }, [popoverOpen, updatePopoverPosition]);
505
666
 
506
667
  // "Edit theme" pencil next to the dropdown. A custom theme opens directly in
507
668
  // the designer; a built-in seeds a NEW "Modified <name>" custom theme based
@@ -516,107 +677,276 @@ export function PreviewToolbarControls() {
516
677
  }
517
678
  }, [s]);
518
679
 
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"
680
+ // Render a single control by key. `compact` switches to the stacked
681
+ // label-over-control layout used inside the overflow popover.
682
+ const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
683
+ switch (key) {
684
+ case 'mode':
685
+ return (
686
+ <div
687
+ key="mode"
688
+ className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
689
+ >
690
+ {compact && <label style={labelStyle}>Mode:</label>}
691
+ <PreviewModeSwitch />
692
+ </div>
693
+ );
694
+ case 'format':
695
+ return (
696
+ <div
697
+ key="format"
698
+ className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
699
+ >
700
+ {compact && <label style={labelStyle}>Format:</label>}
701
+ <PreviewFormatSwitch />
702
+ </div>
703
+ );
704
+ case 'theme':
705
+ return (
706
+ <div
707
+ key="theme"
708
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
567
709
  >
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>
710
+ <label style={labelStyle}>Theme:</label>
711
+ <ThemePicker
712
+ value={s.activeThemeId}
713
+ onChange={(v) => s.setSelectedThemeId(v)}
714
+ ariaLabel="Theme"
715
+ customThemes={s.customThemes}
716
+ onCreateCustom={() => s.openThemeDesigner(null)}
717
+ onEditCustom={(id) =>
718
+ s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
719
+ }
720
+ onDeleteCustom={(id) => s.deleteCustomTheme(id)}
721
+ />
722
+ <button
723
+ type="button"
724
+ className="squisq-theme-edit-btn"
725
+ onClick={handleEditCurrentTheme}
726
+ aria-label="Edit theme"
727
+ title="Edit this theme"
728
+ >
729
+ <svg
730
+ width="14"
731
+ height="14"
732
+ viewBox="0 0 16 16"
733
+ fill="none"
734
+ stroke="currentColor"
735
+ strokeWidth="1.4"
736
+ strokeLinecap="round"
737
+ strokeLinejoin="round"
738
+ aria-hidden="true"
739
+ >
740
+ <path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
741
+ <path d="M9.5 4.5l2 2" />
742
+ </svg>
743
+ </button>
744
+ </div>
745
+ );
746
+ case 'transform':
747
+ return (
748
+ <PreviewSelect
749
+ key="transform"
750
+ label="Transform"
751
+ value={s.activeTransformStyle}
752
+ options={TRANSFORM_STYLE_OPTIONS}
753
+ onChange={(v) => s.setSelectedTransformStyle(v)}
754
+ compact={compact}
755
+ />
756
+ );
757
+ case 'captions': {
758
+ // Two independent toggles (either can be off): CC = standard captions,
759
+ // share icon = social captions. Clicking the active one turns captions
760
+ // off; clicking the other switches style (and turns captions on).
761
+ const enabled = s.activeCaptionsEnabled;
762
+ const ccActive = enabled && s.activeCaptionStyle === 'standard';
763
+ const socialActive = enabled && s.activeCaptionStyle === 'social';
764
+ return (
765
+ <div
766
+ key="captions"
767
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
768
+ >
769
+ <label style={labelStyle}>Captions:</label>
770
+ <div className="squisq-preview-seg" role="group" aria-label="Captions">
771
+ <button
772
+ type="button"
773
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${ccActive ? ' squisq-preview-seg-btn--active' : ''}`}
774
+ aria-pressed={ccActive}
775
+ aria-label="Standard captions"
776
+ title="Standard captions"
777
+ onClick={() => s.setCaptionMode(ccActive ? 'off' : 'standard')}
778
+ >
779
+ <Icon icon="fa-solid fa-closed-captioning" />
780
+ </button>
781
+ <button
782
+ type="button"
783
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${socialActive ? ' squisq-preview-seg-btn--active' : ''}`}
784
+ aria-pressed={socialActive}
785
+ aria-label="Social captions"
786
+ title="Social captions"
787
+ onClick={() => s.setCaptionMode(socialActive ? 'off' : 'social')}
788
+ >
789
+ <Icon icon="fa-solid fa-share-nodes" />
790
+ </button>
791
+ </div>
792
+ </div>
793
+ );
794
+ }
795
+ case 'cover':
796
+ return (
797
+ <div
798
+ key="cover"
799
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
800
+ >
801
+ <label style={labelStyle}>Cover:</label>
802
+ <label className="squisq-preview-checkbox">
803
+ <input
804
+ type="checkbox"
805
+ checked={s.activeCoverSlide}
806
+ onChange={(e) => s.setCoverSlideEnabled(e.target.checked)}
807
+ />
808
+ <span>Cover slide</span>
809
+ </label>
810
+ </div>
811
+ );
812
+ }
813
+ };
814
+
815
+ const hasOverflow = visibleCount < CONTROL_KEYS.length;
816
+ const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
817
+ const overflowKeys = CONTROL_KEYS.slice(visibleCount);
818
+
819
+ useEffect(() => {
820
+ if (!hasOverflow && popoverOpen) closePopover();
821
+ }, [closePopover, hasOverflow, popoverOpen]);
822
+
823
+ // The root is a flex:1 filler so it always spans the toolbar's leftover
824
+ // width (which is what the fit measurement reads).
825
+ return (
826
+ <div
827
+ className="squisq-preview-controls"
828
+ data-has-overflow={hasOverflow ? 'true' : undefined}
829
+ ref={rootRef}
830
+ >
831
+ {/* Hidden probe — every control at natural width, measured to decide the
832
+ inline/overflow split. Absolutely positioned so it never affects
833
+ layout. */}
834
+ <div className="squisq-preview-controls-probe" ref={probeRef} aria-hidden="true">
835
+ {CONTROL_KEYS.map((key) => renderControl(key, false))}
572
836
  </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
- </>
837
+
838
+ {visibleKeys.length > 0 && (
839
+ <div className="squisq-preview-controls-inline">
840
+ {visibleKeys.map((key) => renderControl(key, false))}
841
+ </div>
842
+ )}
843
+
844
+ {hasOverflow && (
845
+ <div className="squisq-preview-controls-compact" ref={popoverRef}>
846
+ <button
847
+ ref={popoverTriggerRef}
848
+ className={`squisq-toolbar-button${popoverOpen ? ' squisq-toolbar-button--active' : ''}`}
849
+ onClick={() => {
850
+ if (popoverOpen) {
851
+ closePopover();
852
+ return;
853
+ }
854
+ updatePopoverPosition();
855
+ setPopoverOpen(true);
856
+ }}
857
+ aria-label="More preview settings"
858
+ title="More preview settings"
859
+ aria-expanded={popoverOpen}
860
+ >
861
+ <Icon icon="fa-solid fa-ellipsis" />
862
+ </button>
863
+ {popoverOpen && popoverAnchor && (
864
+ <div
865
+ ref={popoverPanelRef}
866
+ className="squisq-preview-controls-popover"
867
+ style={{ top: popoverAnchor.top, left: popoverAnchor.left }}
868
+ >
869
+ {overflowKeys.map((key) => renderControl(key, true))}
870
+ </div>
871
+ )}
872
+ </div>
873
+ )}
874
+ </div>
588
875
  );
876
+ }
589
877
 
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"
878
+ /**
879
+ * Segmented display-mode switch (Video / Slideshow / Document / Page), used
880
+ * inline in the Use toolbar and inside its overflow popover. Reads and writes
881
+ * the same `activeDisplayMode` in preview settings.
882
+ */
883
+ export function PreviewModeSwitch() {
884
+ const s = usePreviewSettings();
885
+ return (
886
+ <div className="squisq-preview-seg" role="group" aria-label="Display mode">
887
+ {DISPLAY_MODE_OPTIONS.map((opt) => {
888
+ const active = s.activeDisplayMode === opt.key;
889
+ return (
890
+ <button
891
+ key={opt.key}
892
+ type="button"
893
+ className={`squisq-preview-seg-btn${active ? ' squisq-preview-seg-btn--active' : ''}`}
894
+ aria-pressed={active}
895
+ onClick={() => s.setSelectedDisplayMode(opt.key)}
609
896
  >
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
- }
897
+ {opt.label}
898
+ </button>
899
+ );
900
+ })}
901
+ </div>
902
+ );
903
+ }
618
904
 
619
- return <div className="squisq-preview-controls-inline">{controls}</div>;
905
+ /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
906
+ * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
907
+ function AspectIcon({ w, h }: { w: number; h: number }) {
908
+ return (
909
+ <svg
910
+ width="16"
911
+ height="16"
912
+ viewBox="0 0 16 16"
913
+ fill="none"
914
+ stroke="currentColor"
915
+ strokeWidth="1.4"
916
+ aria-hidden="true"
917
+ >
918
+ <rect x={(16 - w) / 2} y={(16 - h) / 2} width={w} height={h} rx="1.5" />
919
+ </svg>
920
+ );
921
+ }
922
+
923
+ /**
924
+ * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3), used inline in the
925
+ * Use toolbar and inside its overflow popover. Reads and writes the same
926
+ * `activePreset` in preview settings.
927
+ */
928
+ export function PreviewFormatSwitch() {
929
+ const s = usePreviewSettings();
930
+ return (
931
+ <div className="squisq-preview-seg" role="group" aria-label="Aspect ratio">
932
+ {FORMAT_SWITCH_OPTIONS.map((opt) => {
933
+ const active = s.activePreset === opt.key;
934
+ return (
935
+ <button
936
+ key={opt.key}
937
+ type="button"
938
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${active ? ' squisq-preview-seg-btn--active' : ''}`}
939
+ aria-pressed={active}
940
+ aria-label={opt.label}
941
+ title={opt.label}
942
+ onClick={() => s.setSelectedPreset(opt.key)}
943
+ >
944
+ <AspectIcon w={opt.w} h={opt.h} />
945
+ </button>
946
+ );
947
+ })}
948
+ </div>
949
+ );
620
950
  }
621
951
 
622
952
  function PreviewSelect({