@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.
Files changed (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -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, resolveTheme } from '@bendyline/squisq/schemas';
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
- setSelectedCaptionStyle: (style: CaptionStyle | null) => void;
59
+ /** Whether captions are shown at all (the 'off' arm of the tri-state). */
60
+ activeCaptionsEnabled: boolean;
61
+ /** Set the caption mode: 'off' hides captions, 'standard'/'social' enable
62
+ * that style. The single entry point so the toggle buttons persist in one
63
+ * frontmatter write. */
64
+ setCaptionMode: (mode: CaptionMode) => void;
65
+ /** 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' || v === 'page') return v;
126
+ if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
93
127
  if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
94
- if (v === 'document' || v === 'scroll') return 'linear';
95
- if (v === 'html' || v === 'plain' || v === 'reader') return 'page';
128
+ // Frontmatter uses product-facing names: Document is the plain text/HTML
129
+ // preview, Page is the styled Squisq page view. The raw DisplayMode values
130
+ // are older and remain stable for the public React API.
131
+ if (v === 'page' || v === 'paged') return 'linear';
132
+ if (v === 'document' || v === 'scroll' || v === 'html' || v === 'plain' || v === 'reader') {
133
+ return 'page';
134
+ }
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 v = value.trim().toLowerCase();
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 resolveFrontmatterCaptionStyle(value: unknown): CaptionStyle | null {
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 === 'standard' || v === 'social') return v;
125
- if (v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
168
+ if (v === 'off' || v === 'none' || v === 'hidden' || v === 'false' || v === 'no') return 'off';
169
+ if (v === 'standard' || v === 'cc' || v === 'captions') return 'standard';
170
+ if (v === 'social' || v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
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
- const resolvedTheme = useMemo(() => resolveTheme(resolvedThemeId), [resolvedThemeId]);
207
- // themeOverride wins over both dropdown selection and frontmatter
208
- const activeThemeId = themeOverride?.id ?? resolvedThemeId;
209
- const activeTheme = themeOverride ?? resolvedTheme;
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 stylepersisted to `squisq-captions` (legacy `caption-style` read for compat)
241
- const fmCaption = useMemo(
346
+ // Caption mode'off' | 'standard' | 'social'. Persisted to `squisq-captions`
347
+ // (legacy `caption-style` read for compat). `activeCaptionStyle` is the style
348
+ // used when enabled; `activeCaptionsEnabled` is the off/on split.
349
+ const fmCaptionMode = useMemo(
242
350
  () =>
243
- resolveFrontmatterCaptionStyle(
351
+ resolveFrontmatterCaptionMode(
244
352
  readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
245
353
  ),
246
354
  [frontmatter],
247
355
  );
248
- const [selectedCaptionStyle, setSelectedCaptionStyle] = useState<CaptionStyle | null>(null);
249
- useEffect(() => setSelectedCaptionStyle(null), [fmCaption]);
250
- const activeCaptionStyle = selectedCaptionStyle ?? fmCaption ?? 'standard';
251
- const handleSetCaptionStyle = useCallback(
252
- (style: CaptionStyle | null) => {
253
- setSelectedCaptionStyle(style);
254
- if (style !== null) persistFrontmatter({ [FM_KEYS.captions.canonical]: style });
356
+ const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
357
+ useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
358
+ const activeCaptionMode = selectedCaptionMode ?? fmCaptionMode ?? 'standard';
359
+ const activeCaptionsEnabled = activeCaptionMode !== 'off';
360
+ const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
361
+ const handleSetCaptionMode = useCallback(
362
+ (mode: CaptionMode) => {
363
+ setSelectedCaptionMode(mode);
364
+ persistFrontmatter({ [FM_KEYS.captions.canonical]: mode });
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
- setSelectedCaptionStyle: handleSetCaptionStyle,
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
- handleSetCaptionStyle,
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
- const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
296
- { key: 'landscape', label: '16:9' },
297
- { key: 'portrait', label: '9:16' },
298
- { key: 'square', label: '1:1' },
299
- { key: 'standard', label: '4:3' },
449
+ /**
450
+ * Aspect-ratio presets surfaced as the segmented {@link PreviewFormatSwitch}
451
+ * on the left of the toolbar. `w`/`h` are the glyph rectangle dimensions (in a
452
+ * 16×16 viewBox) drawn by {@link AspectIcon} to depict each ratio.
453
+ */
454
+ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h: number }[] = [
455
+ { key: 'landscape', label: '16:9', w: 13, h: 7 },
456
+ { key: 'square', label: '1:1', w: 10, h: 10 },
457
+ { key: 'portrait', label: '9:16', w: 7, h: 12 },
458
+ { key: 'standard', label: '4:3', w: 12, h: 9 },
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: 'Document' },
306
- { key: 'page', label: 'Page' },
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
- const CAPTION_STYLE_OPTIONS: { key: CaptionStyle; label: string }[] = [
315
- { key: 'standard', label: 'Standard' },
316
- { key: 'social', label: 'Social' },
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
- * On narrow viewports, collapses into a single settings button with a dropdown.
508
+ *
509
+ * Collapse is *progressive* (a priority-plus pattern): rather than switch the
510
+ * whole row in and out at a fixed window-width breakpoint, the controls
511
+ * measure how many of them actually fit in the width the toolbar gives them
512
+ * and keep that many inline, folding the rest — from the low-priority end of
513
+ * {@link CONTROL_KEYS} — into a single settings (gear) button's popover. As
514
+ * the toolbar widens or narrows, controls migrate one at a time between the
515
+ * inline row and the menu, so the available space is always well used and the
516
+ * row never wraps onto a second line.
356
517
  */
357
518
  export function PreviewToolbarControls() {
358
519
  const s = usePreviewSettings();
359
- const isNarrow = useIsNarrow(768);
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
- const controls = (
376
- <>
377
- <PreviewSelect
378
- label="Format"
379
- value={s.activePreset}
380
- options={VIEWPORT_OPTIONS}
381
- onChange={(v) => s.setSelectedPreset(v as ViewportPreset)}
382
- compact={isNarrow}
383
- />
384
- <PreviewSelect
385
- label="Mode"
386
- value={s.activeDisplayMode}
387
- options={DISPLAY_MODE_OPTIONS}
388
- onChange={(v) => s.setSelectedDisplayMode(v as DisplayMode)}
389
- compact={isNarrow}
390
- />
391
- <div
392
- className={`squisq-preview-control${isNarrow ? ' squisq-preview-control--compact' : ''}`}
393
- >
394
- <label style={labelStyle}>Theme:</label>
395
- <ThemePicker
396
- value={s.activeThemeId}
397
- onChange={(v) => s.setSelectedThemeId(v)}
398
- ariaLabel="Theme"
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
- <PreviewSelect
402
- label="Transform"
403
- value={s.activeTransformStyle}
404
- options={TRANSFORM_STYLE_OPTIONS}
405
- onChange={(v) => s.setSelectedTransformStyle(v)}
406
- compact={isNarrow}
407
- />
408
- <PreviewSelect
409
- label="Captions"
410
- value={s.activeCaptionStyle}
411
- options={CAPTION_STYLE_OPTIONS}
412
- onChange={(v) => s.setSelectedCaptionStyle(v as CaptionStyle)}
413
- compact={isNarrow}
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
- if (isNarrow) {
419
- return (
420
- <div className="squisq-preview-controls-compact" ref={popoverRef}>
421
- <button
422
- className="squisq-toolbar-button"
423
- onClick={() => setPopoverOpen((v) => !v)}
424
- aria-label="Preview settings"
425
- title="Preview settings"
426
- aria-expanded={popoverOpen}
427
- >
428
- <svg
429
- width="16"
430
- height="16"
431
- viewBox="0 0 16 16"
432
- fill="none"
433
- stroke="currentColor"
434
- strokeWidth="1.5"
435
- strokeLinecap="round"
436
- strokeLinejoin="round"
743
+ /**
744
+ * Segmented display-mode switch (Video / Slideshow / Document / Page) rendered
745
+ * as four connected buttons on the left of the Play toolbar — the prominent,
746
+ * one-click counterpart to the old "Mode:" dropdown. Reads and writes the same
747
+ * `activeDisplayMode` in preview settings.
748
+ */
749
+ export function PreviewModeSwitch() {
750
+ const s = usePreviewSettings();
751
+ return (
752
+ <div className="squisq-preview-seg" role="group" aria-label="Display mode">
753
+ {DISPLAY_MODE_OPTIONS.map((opt) => {
754
+ const active = s.activeDisplayMode === opt.key;
755
+ return (
756
+ <button
757
+ key={opt.key}
758
+ type="button"
759
+ className={`squisq-preview-seg-btn${active ? ' squisq-preview-seg-btn--active' : ''}`}
760
+ aria-pressed={active}
761
+ onClick={() => s.setSelectedDisplayMode(opt.key)}
437
762
  >
438
- <circle cx="8" cy="8" r="2.5" />
439
- <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" />
440
- </svg>
441
- </button>
442
- {popoverOpen && <div className="squisq-preview-controls-popover">{controls}</div>}
443
- </div>
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
- return <div className="squisq-preview-controls-inline">{controls}</div>;
789
+ /**
790
+ * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3) rendered as connected
791
+ * icon buttons on the left of the Play toolbar, next to the mode switch — the
792
+ * one-click counterpart to the old "Format:" dropdown. Reads and writes the
793
+ * same `activePreset` in preview settings.
794
+ */
795
+ export function PreviewFormatSwitch() {
796
+ const s = usePreviewSettings();
797
+ return (
798
+ <div className="squisq-preview-seg" role="group" aria-label="Aspect ratio">
799
+ {FORMAT_SWITCH_OPTIONS.map((opt) => {
800
+ const active = s.activePreset === opt.key;
801
+ return (
802
+ <button
803
+ key={opt.key}
804
+ type="button"
805
+ className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${active ? ' squisq-preview-seg-btn--active' : ''}`}
806
+ aria-pressed={active}
807
+ aria-label={opt.label}
808
+ title={opt.label}
809
+ onClick={() => s.setSelectedPreset(opt.key)}
810
+ >
811
+ <AspectIcon w={opt.w} h={opt.h} />
812
+ </button>
813
+ );
814
+ })}
815
+ </div>
816
+ );
448
817
  }
449
818
 
450
819
  function PreviewSelect({