@bendyline/squisq-editor-react 1.5.3 → 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 +1114 -52
- package/dist/index.js +18446 -8050
- 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/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- 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/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
package/src/PreviewControls.tsx
CHANGED
|
@@ -17,21 +17,32 @@ 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';
|
|
23
24
|
import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
|
|
24
25
|
import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
|
|
25
|
-
import { VIEWPORT_PRESETS, getThemeSummaries
|
|
26
|
+
import { VIEWPORT_PRESETS, getThemeSummaries } from '@bendyline/squisq/schemas';
|
|
26
27
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
27
28
|
import { ThemePicker } from './ThemePicker';
|
|
28
29
|
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
29
30
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
30
31
|
import { setFrontmatterValues } from '@bendyline/squisq/markdown';
|
|
32
|
+
import {
|
|
33
|
+
resolveThemeForDoc,
|
|
34
|
+
writeCustomThemesToFrontmatter,
|
|
35
|
+
FRONTMATTER_CUSTOM_THEMES_KEY,
|
|
36
|
+
} from '@bendyline/squisq/doc';
|
|
31
37
|
import { useEditorContext } from './EditorContext';
|
|
38
|
+
import { useCustomThemes, CustomThemeDialog, type ThemeSaveTarget } from './customThemes';
|
|
39
|
+
import { Icon } from './Icon';
|
|
32
40
|
|
|
33
41
|
// ── Context ──────────────────────────────────────────────────────
|
|
34
42
|
|
|
43
|
+
/** Caption selection: off, or one of the two enabled styles. */
|
|
44
|
+
export type CaptionMode = 'off' | CaptionStyle;
|
|
45
|
+
|
|
35
46
|
export interface PreviewSettings {
|
|
36
47
|
activePreset: ViewportPreset;
|
|
37
48
|
setSelectedPreset: (preset: ViewportPreset | null) => void;
|
|
@@ -43,8 +54,31 @@ export interface PreviewSettings {
|
|
|
43
54
|
activeTheme: Theme;
|
|
44
55
|
activeTransformStyle: string;
|
|
45
56
|
setSelectedTransformStyle: (id: string | null) => void;
|
|
57
|
+
/** The caption style used when captions are enabled. */
|
|
46
58
|
activeCaptionStyle: CaptionStyle;
|
|
47
|
-
|
|
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
|
+
/** User-authored themes (doc + browser library) for the picker's "Custom" group. */
|
|
66
|
+
customThemes: Theme[];
|
|
67
|
+
/** Open the custom-theme designer for a theme (or null to create a new one). */
|
|
68
|
+
openThemeDesigner: (theme: Theme | null) => void;
|
|
69
|
+
/** Remove a custom theme from the doc and the library. */
|
|
70
|
+
deleteCustomTheme: (id: string) => void;
|
|
71
|
+
/** Config for the docked theme designer, or null when closed. Rendered by
|
|
72
|
+
* `<ThemeDesignerDock>` in the editor's content row. */
|
|
73
|
+
themeDesigner: ThemeDesignerConfig | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Everything `<ThemeDesignerDock>` needs to render the designer pane. */
|
|
77
|
+
export interface ThemeDesignerConfig {
|
|
78
|
+
value: Theme | null;
|
|
79
|
+
onChange: (theme: Theme) => void;
|
|
80
|
+
onSave: (theme: Theme, target: ThemeSaveTarget) => void;
|
|
81
|
+
onClose: () => void;
|
|
48
82
|
}
|
|
49
83
|
|
|
50
84
|
const PreviewSettingsContext = createContext<PreviewSettings | null>(null);
|
|
@@ -89,21 +123,31 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
|
|
|
89
123
|
function resolveDisplayMode(value: unknown): DisplayMode | null {
|
|
90
124
|
if (typeof value !== 'string') return null;
|
|
91
125
|
const v = value.trim().toLowerCase();
|
|
92
|
-
if (v === 'video' || v === 'slideshow' || v === 'linear'
|
|
126
|
+
if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
|
|
93
127
|
if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
135
|
return null;
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
const VALID_THEME_IDS = new Set(getThemeSummaries().map((s) => s.id));
|
|
100
139
|
|
|
101
|
-
function resolveFrontmatterTheme(value: unknown): string | null {
|
|
140
|
+
function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): string | null {
|
|
102
141
|
if (typeof value !== 'string') return null;
|
|
103
|
-
const
|
|
142
|
+
const raw = value.trim();
|
|
143
|
+
const v = raw.toLowerCase();
|
|
104
144
|
if (VALID_THEME_IDS.has(v)) return v;
|
|
105
145
|
const normalized = v.replace(/\s+/g, '-');
|
|
106
146
|
if (VALID_THEME_IDS.has(normalized)) return normalized;
|
|
147
|
+
// Custom theme ids are doc-defined slugs (lowercase from the customizer).
|
|
148
|
+
// Admit them so an inline theme selection isn't rejected back to 'standard'.
|
|
149
|
+
if (customIds?.has(raw)) return raw;
|
|
150
|
+
if (customIds?.has(v)) return v;
|
|
107
151
|
return null;
|
|
108
152
|
}
|
|
109
153
|
|
|
@@ -118,11 +162,12 @@ function resolveFrontmatterTransform(value: unknown): string | null {
|
|
|
118
162
|
return null;
|
|
119
163
|
}
|
|
120
164
|
|
|
121
|
-
function
|
|
165
|
+
function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
|
|
122
166
|
if (typeof value !== 'string') return null;
|
|
123
167
|
const v = value.trim().toLowerCase();
|
|
124
|
-
if (v === '
|
|
125
|
-
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';
|
|
126
171
|
return null;
|
|
127
172
|
}
|
|
128
173
|
|
|
@@ -192,21 +237,43 @@ export function PreviewSettingsProvider({
|
|
|
192
237
|
useEffect(() => setSelectedDisplayMode(null), [fmMode]);
|
|
193
238
|
const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
|
|
194
239
|
|
|
240
|
+
// Custom themes (doc + browser library). `useCustomThemes` returns null when
|
|
241
|
+
// no provider is mounted; the picker then just shows built-ins.
|
|
242
|
+
const custom = useCustomThemes();
|
|
243
|
+
const customThemes = useMemo(() => custom?.allThemes ?? [], [custom]);
|
|
244
|
+
const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
|
|
245
|
+
|
|
195
246
|
// Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
|
|
196
247
|
const fmTheme = useMemo(
|
|
197
248
|
() =>
|
|
198
249
|
resolveFrontmatterTheme(
|
|
199
250
|
readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
|
|
251
|
+
customIds,
|
|
200
252
|
),
|
|
201
|
-
[frontmatter],
|
|
253
|
+
[frontmatter, customIds],
|
|
202
254
|
);
|
|
203
255
|
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
|
204
256
|
useEffect(() => setSelectedThemeId(null), [fmTheme]);
|
|
205
257
|
const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
const
|
|
209
|
-
|
|
258
|
+
// Doc-scoped resolution: an inline custom theme id resolves from the doc's
|
|
259
|
+
// own `customThemes` before built-ins — no global registration needed.
|
|
260
|
+
const resolvedTheme = useMemo(
|
|
261
|
+
() => resolveThemeForDoc(doc, resolvedThemeId),
|
|
262
|
+
[doc, resolvedThemeId],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// In-progress theme from the designer dialog; previews live without mutating
|
|
266
|
+
// the doc until the user saves.
|
|
267
|
+
const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
|
|
268
|
+
const [designer, setDesigner] = useState<{ open: boolean; editing: Theme | null }>({
|
|
269
|
+
open: false,
|
|
270
|
+
editing: null,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Precedence: an active designer preview > external themeOverride > the
|
|
274
|
+
// dropdown/frontmatter selection.
|
|
275
|
+
const activeThemeId = previewTheme?.id ?? themeOverride?.id ?? resolvedThemeId;
|
|
276
|
+
const activeTheme = previewTheme ?? themeOverride ?? resolvedTheme;
|
|
210
277
|
const handleSetThemeId = useCallback(
|
|
211
278
|
(id: string | null) => {
|
|
212
279
|
setSelectedThemeId(id);
|
|
@@ -215,6 +282,45 @@ export function PreviewSettingsProvider({
|
|
|
215
282
|
[persistFrontmatter],
|
|
216
283
|
);
|
|
217
284
|
|
|
285
|
+
const openThemeDesigner = useCallback((theme: Theme | null) => {
|
|
286
|
+
setDesigner({ open: true, editing: theme });
|
|
287
|
+
setPreviewTheme(theme);
|
|
288
|
+
}, []);
|
|
289
|
+
const closeThemeDesigner = useCallback(() => {
|
|
290
|
+
setDesigner({ open: false, editing: null });
|
|
291
|
+
setPreviewTheme(null);
|
|
292
|
+
}, []);
|
|
293
|
+
const handleDesignerSave = useCallback(
|
|
294
|
+
(theme: Theme, target: ThemeSaveTarget) => {
|
|
295
|
+
if (target === 'library') {
|
|
296
|
+
custom?.upsertLibraryTheme(theme);
|
|
297
|
+
} else {
|
|
298
|
+
// Write the theme payload AND select it in a SINGLE frontmatter update.
|
|
299
|
+
// Two separate `setMarkdownSource` calls (upsertDocTheme + a squisq-theme
|
|
300
|
+
// write) would each derive from the same stale source, so the second
|
|
301
|
+
// would clobber the first and drop the custom-themes payload.
|
|
302
|
+
const docThemes = custom?.docThemes ?? [];
|
|
303
|
+
const idx = docThemes.findIndex((t) => t.id === theme.id);
|
|
304
|
+
const nextThemes =
|
|
305
|
+
idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
|
|
306
|
+
persistFrontmatter({
|
|
307
|
+
[FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
|
|
308
|
+
[FM_KEYS.theme.canonical]: theme.id,
|
|
309
|
+
});
|
|
310
|
+
setSelectedThemeId(theme.id);
|
|
311
|
+
}
|
|
312
|
+
closeThemeDesigner();
|
|
313
|
+
},
|
|
314
|
+
[custom, persistFrontmatter, closeThemeDesigner],
|
|
315
|
+
);
|
|
316
|
+
const deleteCustomTheme = useCallback(
|
|
317
|
+
(id: string) => {
|
|
318
|
+
custom?.removeDocTheme(id);
|
|
319
|
+
custom?.removeLibraryTheme(id);
|
|
320
|
+
},
|
|
321
|
+
[custom],
|
|
322
|
+
);
|
|
323
|
+
|
|
218
324
|
// Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
|
|
219
325
|
const fmTransform = useMemo(
|
|
220
326
|
() =>
|
|
@@ -237,25 +343,44 @@ export function PreviewSettingsProvider({
|
|
|
237
343
|
[persistFrontmatter],
|
|
238
344
|
);
|
|
239
345
|
|
|
240
|
-
// Caption
|
|
241
|
-
|
|
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(
|
|
242
350
|
() =>
|
|
243
|
-
|
|
351
|
+
resolveFrontmatterCaptionMode(
|
|
244
352
|
readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
|
|
245
353
|
),
|
|
246
354
|
[frontmatter],
|
|
247
355
|
);
|
|
248
|
-
const [
|
|
249
|
-
useEffect(() =>
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 });
|
|
255
365
|
},
|
|
256
366
|
[persistFrontmatter],
|
|
257
367
|
);
|
|
258
368
|
|
|
369
|
+
// Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
|
|
370
|
+
// editor content row). Null when closed. setPreviewTheme is a stable setter.
|
|
371
|
+
const themeDesigner = useMemo<ThemeDesignerConfig | null>(
|
|
372
|
+
() =>
|
|
373
|
+
designer.open
|
|
374
|
+
? {
|
|
375
|
+
value: designer.editing,
|
|
376
|
+
onChange: setPreviewTheme,
|
|
377
|
+
onSave: handleDesignerSave,
|
|
378
|
+
onClose: closeThemeDesigner,
|
|
379
|
+
}
|
|
380
|
+
: null,
|
|
381
|
+
[designer.open, designer.editing, handleDesignerSave, closeThemeDesigner],
|
|
382
|
+
);
|
|
383
|
+
|
|
259
384
|
const value = useMemo<PreviewSettings>(
|
|
260
385
|
() => ({
|
|
261
386
|
activePreset,
|
|
@@ -269,7 +394,12 @@ export function PreviewSettingsProvider({
|
|
|
269
394
|
activeTransformStyle,
|
|
270
395
|
setSelectedTransformStyle: handleSetTransformStyle,
|
|
271
396
|
activeCaptionStyle,
|
|
272
|
-
|
|
397
|
+
activeCaptionsEnabled,
|
|
398
|
+
setCaptionMode: handleSetCaptionMode,
|
|
399
|
+
customThemes,
|
|
400
|
+
openThemeDesigner,
|
|
401
|
+
deleteCustomTheme,
|
|
402
|
+
themeDesigner,
|
|
273
403
|
}),
|
|
274
404
|
[
|
|
275
405
|
activePreset,
|
|
@@ -279,9 +409,14 @@ export function PreviewSettingsProvider({
|
|
|
279
409
|
activeTheme,
|
|
280
410
|
activeTransformStyle,
|
|
281
411
|
activeCaptionStyle,
|
|
412
|
+
activeCaptionsEnabled,
|
|
282
413
|
handleSetThemeId,
|
|
283
414
|
handleSetTransformStyle,
|
|
284
|
-
|
|
415
|
+
handleSetCaptionMode,
|
|
416
|
+
customThemes,
|
|
417
|
+
openThemeDesigner,
|
|
418
|
+
deleteCustomTheme,
|
|
419
|
+
themeDesigner,
|
|
285
420
|
],
|
|
286
421
|
);
|
|
287
422
|
|
|
@@ -290,20 +425,44 @@ export function PreviewSettingsProvider({
|
|
|
290
425
|
);
|
|
291
426
|
}
|
|
292
427
|
|
|
428
|
+
/**
|
|
429
|
+
* ThemeDesignerDock — renders the docked custom-theme designer pane when open.
|
|
430
|
+
* Placed as a flex sibling of the preview in `EditorShell`'s content row so the
|
|
431
|
+
* preview reflows narrower beside it. Renders nothing when the designer is
|
|
432
|
+
* closed. Must be mounted inside a `PreviewSettingsProvider`.
|
|
433
|
+
*/
|
|
434
|
+
export function ThemeDesignerDock() {
|
|
435
|
+
const { themeDesigner } = usePreviewSettings();
|
|
436
|
+
if (!themeDesigner) return null;
|
|
437
|
+
return (
|
|
438
|
+
<CustomThemeDialog
|
|
439
|
+
value={themeDesigner.value}
|
|
440
|
+
onChange={themeDesigner.onChange}
|
|
441
|
+
onSave={themeDesigner.onSave}
|
|
442
|
+
onClose={themeDesigner.onClose}
|
|
443
|
+
/>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
293
447
|
// ── Dropdown options ─────────────────────────────────────────────
|
|
294
448
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 },
|
|
300
459
|
];
|
|
301
460
|
|
|
302
461
|
const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
|
|
303
462
|
{ key: 'video', label: 'Video' },
|
|
304
463
|
{ key: 'slideshow', label: 'Slideshow' },
|
|
305
|
-
{ key: 'linear', label: '
|
|
306
|
-
{ key: 'page', label: '
|
|
464
|
+
{ key: 'linear', label: 'Page' },
|
|
465
|
+
{ key: 'page', label: 'Document' },
|
|
307
466
|
];
|
|
308
467
|
|
|
309
468
|
const TRANSFORM_STYLE_OPTIONS = [
|
|
@@ -311,10 +470,18 @@ const TRANSFORM_STYLE_OPTIONS = [
|
|
|
311
470
|
...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
|
|
312
471
|
];
|
|
313
472
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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'];
|
|
318
485
|
|
|
319
486
|
// ── Shared styles ────────────────────────────────────────────────
|
|
320
487
|
|
|
@@ -336,30 +503,67 @@ const selectStyle: React.CSSProperties = {
|
|
|
336
503
|
|
|
337
504
|
// ── Toolbar Controls Component ───────────────────────────────────
|
|
338
505
|
|
|
339
|
-
/** Hook to track whether the viewport is narrow. */
|
|
340
|
-
function useIsNarrow(breakpoint = 600): boolean {
|
|
341
|
-
const [narrow, setNarrow] = useState(
|
|
342
|
-
() => typeof window !== 'undefined' && window.innerWidth <= breakpoint,
|
|
343
|
-
);
|
|
344
|
-
useEffect(() => {
|
|
345
|
-
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
|
346
|
-
const handler = (e: MediaQueryListEvent) => setNarrow(e.matches);
|
|
347
|
-
mq.addEventListener('change', handler);
|
|
348
|
-
return () => mq.removeEventListener('change', handler);
|
|
349
|
-
}, [breakpoint]);
|
|
350
|
-
return narrow;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
506
|
/**
|
|
354
507
|
* Inline preview controls rendered in the main toolbar row.
|
|
355
|
-
*
|
|
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.
|
|
356
517
|
*/
|
|
357
518
|
export function PreviewToolbarControls() {
|
|
358
519
|
const s = usePreviewSettings();
|
|
359
|
-
const
|
|
520
|
+
const [visibleCount, setVisibleCount] = useState(CONTROL_KEYS.length);
|
|
360
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);
|
|
361
528
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
362
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
|
+
|
|
363
567
|
// Close popover on outside click
|
|
364
568
|
useEffect(() => {
|
|
365
569
|
if (!popoverOpen) return;
|
|
@@ -372,79 +576,244 @@ export function PreviewToolbarControls() {
|
|
|
372
576
|
return () => document.removeEventListener('mousedown', handler);
|
|
373
577
|
}, [popoverOpen]);
|
|
374
578
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
579
|
+
// "Edit theme" pencil next to the dropdown. A custom theme opens directly in
|
|
580
|
+
// the designer; a built-in seeds a NEW "Modified <name>" custom theme based
|
|
581
|
+
// on it — Cancel leaves the current theme untouched, Save engages the custom.
|
|
582
|
+
const handleEditCurrentTheme = useCallback(() => {
|
|
583
|
+
const existing = s.customThemes.find((t) => t.id === s.activeThemeId);
|
|
584
|
+
if (existing) {
|
|
585
|
+
s.openThemeDesigner(existing);
|
|
586
|
+
} else {
|
|
587
|
+
const base = s.activeTheme;
|
|
588
|
+
s.openThemeDesigner({ ...base, name: `Modified ${base.name}`, basedOn: base.id });
|
|
589
|
+
}
|
|
590
|
+
}, [s]);
|
|
591
|
+
|
|
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' : ''}`}
|
|
660
|
+
>
|
|
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))}
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
<div className="squisq-preview-controls-inline">
|
|
706
|
+
{visibleKeys.map((key) => renderControl(key, false))}
|
|
400
707
|
</div>
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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>
|
|
416
740
|
);
|
|
741
|
+
}
|
|
417
742
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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)}
|
|
437
762
|
>
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
788
|
|
|
447
|
-
|
|
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
|
+
);
|
|
448
817
|
}
|
|
449
818
|
|
|
450
819
|
function PreviewSelect({
|