@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.
- package/README.md +57 -10
- package/dist/index.d.ts +401 -76
- package/dist/index.js +1334 -930
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +68 -27
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +338 -141
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +10 -4
- package/src/Toolbar.tsx +21 -11
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +17 -0
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/index.ts +32 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- package/src/styles/code-context.css +155 -0
- package/src/styles/editor.css +149 -3
- package/src/styles/index.css +1 -0
package/src/PreviewControls.tsx
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
126
|
+
if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
|
|
116
127
|
if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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 === '
|
|
153
|
-
if (v === '
|
|
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
|
|
330
|
-
|
|
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
|
-
|
|
351
|
+
resolveFrontmatterCaptionMode(
|
|
333
352
|
readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
|
|
334
353
|
),
|
|
335
354
|
[frontmatter],
|
|
336
355
|
);
|
|
337
|
-
const [
|
|
338
|
-
useEffect(() =>
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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: '
|
|
437
|
-
{ key: 'page', label: '
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
<
|
|
569
|
-
<
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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({
|