@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.
- package/README.md +57 -10
- package/dist/index.d.ts +476 -105
- package/dist/index.js +8703 -6741
- 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 +14617 -0
- package/package.json +15 -7
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +121 -35
- package/src/MediaBin.tsx +171 -28
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +475 -145
- package/src/PreviewPanel.tsx +17 -9
- package/src/RawEditor.tsx +10 -4
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +547 -217
- package/src/TransitionPicker.tsx +8 -1
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -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 +363 -0
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +163 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -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/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +34 -2
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/mediaReferences.ts +299 -0
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/editor.css +991 -84
- package/src/styles/index.css +1 -0
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
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,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
|
-
|
|
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'
|
|
130
|
+
if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
|
|
116
131
|
if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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 === '
|
|
153
|
-
if (v === '
|
|
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
|
|
330
|
-
|
|
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
|
-
|
|
365
|
+
resolveFrontmatterCaptionMode(
|
|
333
366
|
readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
|
|
334
367
|
),
|
|
335
368
|
[frontmatter],
|
|
336
369
|
);
|
|
337
|
-
const [
|
|
338
|
-
useEffect(() =>
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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: '
|
|
437
|
-
{ key: 'page', label: '
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
<
|
|
569
|
-
<
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
897
|
+
{opt.label}
|
|
898
|
+
</button>
|
|
899
|
+
);
|
|
900
|
+
})}
|
|
901
|
+
</div>
|
|
902
|
+
);
|
|
903
|
+
}
|
|
618
904
|
|
|
619
|
-
|
|
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({
|