@bendyline/squisq-editor-react 1.6.1 → 2.0.0

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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -17,10 +17,12 @@ import {
17
17
  useState,
18
18
  useMemo,
19
19
  useEffect,
20
+ useId,
20
21
  useLayoutEffect,
21
22
  useRef,
22
23
  } from 'react';
23
24
  import type { ReactNode } from 'react';
25
+ import { createPortal } from 'react-dom';
24
26
  import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
25
27
  import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
26
28
  import { VIEWPORT_PRESETS, getThemeSummaries } from '@bendyline/squisq/schemas';
@@ -28,15 +30,23 @@ import type { Theme } from '@bendyline/squisq/schemas';
28
30
  import { ThemePicker } from './ThemePicker';
29
31
  import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
30
32
  import type { Doc } from '@bendyline/squisq/schemas';
31
- import { setFrontmatterValues } from '@bendyline/squisq/markdown';
33
+ import { readFrontmatterThemeId, setFrontmatterValues } from '@bendyline/squisq/markdown';
32
34
  import {
33
35
  resolveThemeForDoc,
34
36
  writeCustomThemesToFrontmatter,
37
+ writeCustomTemplatesToFrontmatter,
35
38
  FRONTMATTER_CUSTOM_THEMES_KEY,
39
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
36
40
  } from '@bendyline/squisq/doc';
37
41
  import { useEditorContext } from './EditorContext';
38
- import { useCustomThemes, CustomThemeDialog, type ThemeSaveTarget } from './customThemes';
42
+ import {
43
+ useCustomThemes,
44
+ CustomThemeDialog,
45
+ type ThemeSaveTarget,
46
+ type ThemeSaveExtras,
47
+ } from './customThemes';
39
48
  import { Icon } from './Icon';
49
+ import { resolvePersistedTransformStyleId } from './transformStyleId';
40
50
 
41
51
  // ── Context ──────────────────────────────────────────────────────
42
52
 
@@ -62,6 +72,10 @@ export interface PreviewSettings {
62
72
  * that style. The single entry point so the toggle buttons persist in one
63
73
  * frontmatter write. */
64
74
  setCaptionMode: (mode: CaptionMode) => void;
75
+ /** Whether Squisq should synthesize and show its managed cover slide. */
76
+ activeCoverSlide: boolean;
77
+ /** Enable/disable the managed cover slide. */
78
+ setCoverSlideEnabled: (enabled: boolean) => void;
65
79
  /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
66
80
  customThemes: Theme[];
67
81
  /** Open the custom-theme designer for a theme (or null to create a new one). */
@@ -77,7 +91,7 @@ export interface PreviewSettings {
77
91
  export interface ThemeDesignerConfig {
78
92
  value: Theme | null;
79
93
  onChange: (theme: Theme) => void;
80
- onSave: (theme: Theme, target: ThemeSaveTarget) => void;
94
+ onSave: (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => void;
81
95
  onClose: () => void;
82
96
  }
83
97
 
@@ -123,8 +137,9 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
123
137
  function resolveDisplayMode(value: unknown): DisplayMode | null {
124
138
  if (typeof value !== 'string') return null;
125
139
  const v = value.trim().toLowerCase();
126
- if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
140
+ if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'narrate') return v;
127
141
  if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
142
+ if (v === 'teleprompter' || v === 'prompter') return 'narrate';
128
143
  // Frontmatter uses product-facing names: Document is the plain text/HTML
129
144
  // preview, Page is the styled Squisq page view. The raw DisplayMode values
130
145
  // are older and remain stable for the public React API.
@@ -151,23 +166,21 @@ function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): strin
151
166
  return null;
152
167
  }
153
168
 
154
- const VALID_TRANSFORM_IDS = new Set(getTransformStyleSummaries().map((s) => s.id));
155
-
156
- function resolveFrontmatterTransform(value: unknown): string | null {
169
+ function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
157
170
  if (typeof value !== 'string') return null;
158
171
  const v = value.trim().toLowerCase();
159
- if (VALID_TRANSFORM_IDS.has(v)) return v;
160
- const normalized = v.replace(/\s+/g, '-');
161
- if (VALID_TRANSFORM_IDS.has(normalized)) return normalized;
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';
162
175
  return null;
163
176
  }
164
177
 
165
- function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
178
+ function resolveFrontmatterBoolean(value: unknown): boolean | null {
179
+ if (typeof value === 'boolean') return value;
166
180
  if (typeof value !== 'string') return null;
167
181
  const v = value.trim().toLowerCase();
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';
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;
171
184
  return null;
172
185
  }
173
186
 
@@ -189,9 +202,10 @@ export interface PreviewSettingsProviderProps {
189
202
  * keys are the canonical names; the legacy keys are still read so existing
190
203
  * documents keep working. Persistence (writes) uses only the squisq names. */
191
204
  const FM_KEYS = {
192
- theme: { canonical: 'squisq-theme', legacy: 'theme' as const },
205
+ theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
193
206
  transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
194
207
  captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
+ coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
195
209
  } as const;
196
210
 
197
211
  function readFrontmatterKey(
@@ -209,7 +223,7 @@ export function PreviewSettingsProvider({
209
223
  themeOverride,
210
224
  }: PreviewSettingsProviderProps) {
211
225
  const frontmatter = doc?.frontmatter;
212
- const { markdownSource, setMarkdownSource } = useEditorContext();
226
+ const { markdownSource, setMarkdownSource, allowNarrate } = useEditorContext();
213
227
 
214
228
  const persistFrontmatter = useCallback(
215
229
  (updates: Record<string, string | null>) => {
@@ -231,35 +245,40 @@ export function PreviewSettingsProvider({
231
245
  const activePreset = selectedPreset ?? fmPreset ?? 'landscape';
232
246
  const activeViewport = VIEWPORT_PRESETS[activePreset];
233
247
 
234
- // Display mode
248
+ // Display mode. A frontmatter-forced `narrate` clamps back to video when
249
+ // the host disabled the mode, so hostile frontmatter can't turn it on.
235
250
  const fmMode = useMemo(() => resolveDisplayMode(frontmatter?.['display-mode']), [frontmatter]);
236
251
  const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
237
252
  useEffect(() => setSelectedDisplayMode(null), [fmMode]);
238
- const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
253
+ const requestedDisplayMode = selectedDisplayMode ?? fmMode ?? 'slideshow';
254
+ const activeDisplayMode =
255
+ requestedDisplayMode === 'narrate' && !allowNarrate ? 'video' : requestedDisplayMode;
239
256
 
240
257
  // Custom themes (doc + browser library). `useCustomThemes` returns null when
241
- // no provider is mounted; the picker then just shows built-ins.
258
+ // no provider is mounted; document-scoped themes still remain available.
242
259
  const custom = useCustomThemes();
243
- const customThemes = useMemo(() => custom?.allThemes ?? [], [custom]);
260
+ const docThemes = useMemo(
261
+ () => custom?.docThemes ?? doc?.customThemes ?? [],
262
+ [custom, doc?.customThemes],
263
+ );
264
+ const customThemes = useMemo(() => custom?.allThemes ?? docThemes, [custom, docThemes]);
244
265
  const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
245
266
 
246
- // Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
267
+ // Theme — persisted to `squisq-theme`; `themeId` / `theme` remain readable.
247
268
  const fmTheme = useMemo(
248
- () =>
249
- resolveFrontmatterTheme(
250
- readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
251
- customIds,
252
- ),
269
+ () => resolveFrontmatterTheme(readFrontmatterThemeId(frontmatter), customIds),
253
270
  [frontmatter, customIds],
254
271
  );
255
272
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
256
273
  useEffect(() => setSelectedThemeId(null), [fmTheme]);
257
274
  const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
258
- // Doc-scoped resolution: an inline custom theme id resolves from the doc's
259
- // own `customThemes` before built-ins no global registration needed.
275
+ // Doc themes precede browser-library themes in `allThemes`; choosing a
276
+ // library-only entry copies it into the document below for portable export.
260
277
  const resolvedTheme = useMemo(
261
- () => resolveThemeForDoc(doc, resolvedThemeId),
262
- [doc, resolvedThemeId],
278
+ () =>
279
+ customThemes.find((theme) => theme.id === resolvedThemeId) ??
280
+ resolveThemeForDoc(doc, resolvedThemeId),
281
+ [customThemes, doc, resolvedThemeId],
263
282
  );
264
283
 
265
284
  // In-progress theme from the designer dialog; previews live without mutating
@@ -277,9 +296,21 @@ export function PreviewSettingsProvider({
277
296
  const handleSetThemeId = useCallback(
278
297
  (id: string | null) => {
279
298
  setSelectedThemeId(id);
280
- if (id !== null) persistFrontmatter({ [FM_KEYS.theme.canonical]: id });
299
+ if (id === null) return;
300
+ const selectedCustom = customThemes.find((theme) => theme.id === id);
301
+ const alreadyDocScoped = docThemes.some((theme) => theme.id === id);
302
+ const updates: Record<string, string | null> = {
303
+ [FM_KEYS.theme.canonical]: id,
304
+ [FM_KEYS.theme.legacy[0]]: null,
305
+ [FM_KEYS.theme.legacy[1]]: null,
306
+ };
307
+ if (selectedCustom && !alreadyDocScoped) {
308
+ updates[FRONTMATTER_CUSTOM_THEMES_KEY] =
309
+ writeCustomThemesToFrontmatter([...docThemes, selectedCustom]) ?? null;
310
+ }
311
+ persistFrontmatter(updates);
281
312
  },
282
- [persistFrontmatter],
313
+ [customThemes, docThemes, persistFrontmatter],
283
314
  );
284
315
 
285
316
  const openThemeDesigner = useCallback((theme: Theme | null) => {
@@ -291,27 +322,38 @@ export function PreviewSettingsProvider({
291
322
  setPreviewTheme(null);
292
323
  }, []);
293
324
  const handleDesignerSave = useCallback(
294
- (theme: Theme, target: ThemeSaveTarget) => {
325
+ (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => {
295
326
  if (target === 'library') {
327
+ // Imported slide layouts are doc-scoped in v1; the dialog hints at this.
296
328
  custom?.upsertLibraryTheme(theme);
297
329
  } 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.
330
+ // Write the theme payload, its selection, AND any imported layout
331
+ // templates in a SINGLE frontmatter update. Separate
332
+ // `setMarkdownSource` calls would each derive from the same stale
333
+ // source, so later writes would clobber earlier ones.
302
334
  const docThemes = custom?.docThemes ?? [];
303
335
  const idx = docThemes.findIndex((t) => t.id === theme.id);
304
336
  const nextThemes =
305
337
  idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
306
- persistFrontmatter({
338
+ const updates: Record<string, string | null> = {
307
339
  [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
308
340
  [FM_KEYS.theme.canonical]: theme.id,
309
- });
341
+ };
342
+ if (extras?.templates && extras.templates.length > 0) {
343
+ const existing = doc?.customTemplates ?? [];
344
+ const merged = [
345
+ ...existing.filter((t) => !extras.templates!.some((n) => n.name === t.name)),
346
+ ...extras.templates,
347
+ ];
348
+ updates[FRONTMATTER_CUSTOM_TEMPLATES_KEY] =
349
+ writeCustomTemplatesToFrontmatter(merged) ?? null;
350
+ }
351
+ persistFrontmatter(updates);
310
352
  setSelectedThemeId(theme.id);
311
353
  }
312
354
  closeThemeDesigner();
313
355
  },
314
- [custom, persistFrontmatter, closeThemeDesigner],
356
+ [custom, doc, persistFrontmatter, closeThemeDesigner],
315
357
  );
316
358
  const deleteCustomTheme = useCallback(
317
359
  (id: string) => {
@@ -324,7 +366,7 @@ export function PreviewSettingsProvider({
324
366
  // Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
325
367
  const fmTransform = useMemo(
326
368
  () =>
327
- resolveFrontmatterTransform(
369
+ resolvePersistedTransformStyleId(
328
370
  readFrontmatterKey(frontmatter, FM_KEYS.transform.canonical, FM_KEYS.transform.legacy),
329
371
  ),
330
372
  [frontmatter],
@@ -366,6 +408,26 @@ export function PreviewSettingsProvider({
366
408
  [persistFrontmatter],
367
409
  );
368
410
 
411
+ // Managed cover slide — generated from the document startBlock. Defaults on
412
+ // for existing documents; authors can persist an explicit off switch.
413
+ const fmCoverSlide = useMemo(
414
+ () =>
415
+ resolveFrontmatterBoolean(
416
+ readFrontmatterKey(frontmatter, FM_KEYS.coverSlide.canonical, FM_KEYS.coverSlide.legacy),
417
+ ),
418
+ [frontmatter],
419
+ );
420
+ const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
421
+ useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
422
+ const activeCoverSlide = selectedCoverSlide ?? fmCoverSlide ?? true;
423
+ const handleSetCoverSlideEnabled = useCallback(
424
+ (enabled: boolean) => {
425
+ setSelectedCoverSlide(enabled);
426
+ persistFrontmatter({ [FM_KEYS.coverSlide.canonical]: enabled ? 'true' : 'false' });
427
+ },
428
+ [persistFrontmatter],
429
+ );
430
+
369
431
  // Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
370
432
  // editor content row). Null when closed. setPreviewTheme is a stable setter.
371
433
  const themeDesigner = useMemo<ThemeDesignerConfig | null>(
@@ -396,6 +458,8 @@ export function PreviewSettingsProvider({
396
458
  activeCaptionStyle,
397
459
  activeCaptionsEnabled,
398
460
  setCaptionMode: handleSetCaptionMode,
461
+ activeCoverSlide,
462
+ setCoverSlideEnabled: handleSetCoverSlideEnabled,
399
463
  customThemes,
400
464
  openThemeDesigner,
401
465
  deleteCustomTheme,
@@ -410,9 +474,11 @@ export function PreviewSettingsProvider({
410
474
  activeTransformStyle,
411
475
  activeCaptionStyle,
412
476
  activeCaptionsEnabled,
477
+ activeCoverSlide,
413
478
  handleSetThemeId,
414
479
  handleSetTransformStyle,
415
480
  handleSetCaptionMode,
481
+ handleSetCoverSlideEnabled,
416
482
  customThemes,
417
483
  openThemeDesigner,
418
484
  deleteCustomTheme,
@@ -458,30 +524,80 @@ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h:
458
524
  { key: 'standard', label: '4:3', w: 12, h: 9 },
459
525
  ];
460
526
 
461
- const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
462
- { key: 'video', label: 'Video' },
463
- { key: 'slideshow', label: 'Slideshow' },
464
- { key: 'linear', label: 'Page' },
465
- { key: 'page', label: 'Document' },
527
+ const DISPLAY_MODE_OPTIONS: {
528
+ key: DisplayMode;
529
+ label: string;
530
+ icon: string;
531
+ summary: string;
532
+ }[] = [
533
+ {
534
+ key: 'slideshow',
535
+ label: 'Slideshow',
536
+ icon: 'fa-solid fa-images',
537
+ summary: 'Present designed slides one at a time.',
538
+ },
539
+ {
540
+ key: 'video',
541
+ label: 'Video',
542
+ icon: 'fa-solid fa-circle-play',
543
+ summary: 'Play an automatically timed presentation.',
544
+ },
545
+ {
546
+ key: 'linear',
547
+ label: 'Page',
548
+ icon: 'fa-solid fa-window-maximize',
549
+ summary: 'Scroll through the fully designed page.',
550
+ },
551
+ {
552
+ key: 'page',
553
+ label: 'Document',
554
+ icon: 'fa-solid fa-align-left',
555
+ summary: 'Read a clean, text-first document.',
556
+ },
557
+ {
558
+ key: 'narrate',
559
+ label: 'Narrate',
560
+ icon: 'fa-solid fa-microphone-lines',
561
+ summary: 'Speak with a voice-paced teleprompter.',
562
+ },
466
563
  ];
467
564
 
565
+ export function displayModeLabel(mode: DisplayMode): string {
566
+ return DISPLAY_MODE_OPTIONS.find((option) => option.key === mode)?.label ?? 'Slideshow';
567
+ }
568
+
468
569
  const TRANSFORM_STYLE_OPTIONS = [
469
570
  { key: '', label: 'None' },
470
571
  ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
471
572
  ];
472
573
 
574
+ const SUMMARIZE_TOOLTIP =
575
+ 'Extract and summarize content for presentation with these Use modes. Your underlying content is not changed.';
576
+
473
577
  /**
474
578
  * Left-to-right priority order for the preview controls. As the toolbar
475
579
  * 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.
580
+ * first (Cover, then Captions, …). Aspect ratio stays inline the longest, but
581
+ * still collapses into the same menu when the toolbar is very constrained.
482
582
  */
483
- type ControlKey = 'theme' | 'transform' | 'captions';
484
- const CONTROL_KEYS: ControlKey[] = ['theme', 'transform', 'captions'];
583
+ type ControlKey = 'format' | 'theme' | 'transform' | 'captions' | 'cover';
584
+ const CONTROL_KEYS: ControlKey[] = ['format', 'theme', 'transform', 'captions', 'cover'];
585
+
586
+ const PREVIEW_POPOVER_GAP = 4;
587
+ const PREVIEW_POPOVER_MARGIN = 8;
588
+ const PREVIEW_POPOVER_FALLBACK_WIDTH = 220;
589
+
590
+ function clampPreviewPopoverLeft(
591
+ triggerRect: DOMRect,
592
+ popoverWidth: number,
593
+ viewportWidth: number,
594
+ ): number {
595
+ const maxLeft = Math.max(
596
+ PREVIEW_POPOVER_MARGIN,
597
+ viewportWidth - popoverWidth - PREVIEW_POPOVER_MARGIN,
598
+ );
599
+ return Math.min(Math.max(PREVIEW_POPOVER_MARGIN, triggerRect.right - popoverWidth), maxLeft);
600
+ }
485
601
 
486
602
  // ── Shared styles ────────────────────────────────────────────────
487
603
 
@@ -510,7 +626,7 @@ const selectStyle: React.CSSProperties = {
510
626
  * whole row in and out at a fixed window-width breakpoint, the controls
511
627
  * measure how many of them actually fit in the width the toolbar gives them
512
628
  * 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
629
+ * {@link CONTROL_KEYS} — into a single ellipsis button's popover. As
514
630
  * the toolbar widens or narrows, controls migrate one at a time between the
515
631
  * inline row and the menu, so the available space is always well used and the
516
632
  * row never wraps onto a second line.
@@ -526,6 +642,27 @@ export function PreviewToolbarControls() {
526
642
  // inline and overflow is computed from these per-control measurements.
527
643
  const probeRef = useRef<HTMLDivElement>(null);
528
644
  const popoverRef = useRef<HTMLDivElement>(null);
645
+ const popoverTriggerRef = useRef<HTMLButtonElement>(null);
646
+ const popoverPanelRef = useRef<HTMLDivElement>(null);
647
+ const [popoverAnchor, setPopoverAnchor] = useState<{ top: number; left: number } | null>(null);
648
+
649
+ const updatePopoverPosition = useCallback(() => {
650
+ const trigger = popoverTriggerRef.current;
651
+ if (!trigger) return;
652
+ const triggerRect = trigger.getBoundingClientRect();
653
+ const measuredWidth =
654
+ popoverPanelRef.current?.getBoundingClientRect().width ?? PREVIEW_POPOVER_FALLBACK_WIDTH;
655
+ const popoverWidth = Math.min(measuredWidth, window.innerWidth - PREVIEW_POPOVER_MARGIN * 2);
656
+ setPopoverAnchor({
657
+ top: triggerRect.bottom + PREVIEW_POPOVER_GAP,
658
+ left: clampPreviewPopoverLeft(triggerRect, popoverWidth, window.innerWidth),
659
+ });
660
+ }, []);
661
+
662
+ const closePopover = useCallback(() => {
663
+ setPopoverOpen(false);
664
+ setPopoverAnchor(null);
665
+ }, []);
529
666
 
530
667
  // Fit detection: keep as many controls inline as fit, overflow the rest.
531
668
  useLayoutEffect(() => {
@@ -534,7 +671,7 @@ export function PreviewToolbarControls() {
534
671
  if (!root || !probe) return;
535
672
  const GAP = 6; // matches the row's flex `gap`
536
673
  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)
674
+ const OVERFLOW_TRIGGER_RESERVE = 40; // width kept for the ellipsis button (+ its gap)
538
675
  const SAFETY = 2;
539
676
  const measure = () => {
540
677
  const available = root.clientWidth - LEAD_PAD;
@@ -549,8 +686,8 @@ export function PreviewToolbarControls() {
549
686
  setVisibleCount(widths.length);
550
687
  return;
551
688
  }
552
- // Otherwise reserve room for the gear and fit as many as possible.
553
- const budget = available - GEAR_RESERVE - GAP - SAFETY;
689
+ // Otherwise reserve room for the ellipsis and fit as many as possible.
690
+ const budget = available - OVERFLOW_TRIGGER_RESERVE - GAP - SAFETY;
554
691
  let count = 0;
555
692
  while (count < widths.length && rowWidth(count + 1) <= budget) count++;
556
693
  setVisibleCount(count);
@@ -568,13 +705,29 @@ export function PreviewToolbarControls() {
568
705
  useEffect(() => {
569
706
  if (!popoverOpen) return;
570
707
  const handler = (e: MouseEvent) => {
571
- if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
572
- setPopoverOpen(false);
573
- }
708
+ const target = e.target as Node;
709
+ if (popoverRef.current?.contains(target)) return;
710
+ if (popoverPanelRef.current?.contains(target)) return;
711
+ closePopover();
574
712
  };
575
713
  document.addEventListener('mousedown', handler);
576
714
  return () => document.removeEventListener('mousedown', handler);
577
- }, [popoverOpen]);
715
+ }, [closePopover, popoverOpen]);
716
+
717
+ useLayoutEffect(() => {
718
+ if (!popoverOpen) return;
719
+ updatePopoverPosition();
720
+ }, [popoverOpen, updatePopoverPosition, visibleCount]);
721
+
722
+ useEffect(() => {
723
+ if (!popoverOpen) return;
724
+ window.addEventListener('resize', updatePopoverPosition);
725
+ window.addEventListener('scroll', updatePopoverPosition, true);
726
+ return () => {
727
+ window.removeEventListener('resize', updatePopoverPosition);
728
+ window.removeEventListener('scroll', updatePopoverPosition, true);
729
+ };
730
+ }, [popoverOpen, updatePopoverPosition]);
578
731
 
579
732
  // "Edit theme" pencil next to the dropdown. A custom theme opens directly in
580
733
  // the designer; a built-in seeds a NEW "Modified <name>" custom theme based
@@ -593,6 +746,16 @@ export function PreviewToolbarControls() {
593
746
  // label-over-control layout used inside the overflow popover.
594
747
  const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
595
748
  switch (key) {
749
+ case 'format':
750
+ return (
751
+ <div
752
+ key="format"
753
+ className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
754
+ >
755
+ {compact && <label style={labelStyle}>Format:</label>}
756
+ <PreviewFormatSwitch />
757
+ </div>
758
+ );
596
759
  case 'theme':
597
760
  return (
598
761
  <div
@@ -639,7 +802,8 @@ export function PreviewToolbarControls() {
639
802
  return (
640
803
  <PreviewSelect
641
804
  key="transform"
642
- label="Transform"
805
+ label="Summarize"
806
+ labelTooltip={SUMMARIZE_TOOLTIP}
643
807
  value={s.activeTransformStyle}
644
808
  options={TRANSFORM_STYLE_OPTIONS}
645
809
  onChange={(v) => s.setSelectedTransformStyle(v)}
@@ -684,6 +848,22 @@ export function PreviewToolbarControls() {
684
848
  </div>
685
849
  );
686
850
  }
851
+ case 'cover':
852
+ return (
853
+ <div
854
+ key="cover"
855
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
856
+ >
857
+ <label className="squisq-preview-checkbox">
858
+ <input
859
+ type="checkbox"
860
+ checked={s.activeCoverSlide}
861
+ onChange={(e) => s.setCoverSlideEnabled(e.target.checked)}
862
+ />
863
+ <span>Cover slide</span>
864
+ </label>
865
+ </div>
866
+ );
687
867
  }
688
868
  };
689
869
 
@@ -691,10 +871,18 @@ export function PreviewToolbarControls() {
691
871
  const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
692
872
  const overflowKeys = CONTROL_KEYS.slice(visibleCount);
693
873
 
874
+ useEffect(() => {
875
+ if (!hasOverflow && popoverOpen) closePopover();
876
+ }, [closePopover, hasOverflow, popoverOpen]);
877
+
694
878
  // The root is a flex:1 filler so it always spans the toolbar's leftover
695
879
  // width (which is what the fit measurement reads).
696
880
  return (
697
- <div className="squisq-preview-controls" ref={rootRef}>
881
+ <div
882
+ className="squisq-preview-controls"
883
+ data-has-overflow={hasOverflow ? 'true' : undefined}
884
+ ref={rootRef}
885
+ >
698
886
  {/* Hidden probe — every control at natural width, measured to decide the
699
887
  inline/overflow split. Absolutely positioned so it never affects
700
888
  layout. */}
@@ -702,35 +890,37 @@ export function PreviewToolbarControls() {
702
890
  {CONTROL_KEYS.map((key) => renderControl(key, false))}
703
891
  </div>
704
892
 
705
- <div className="squisq-preview-controls-inline">
706
- {visibleKeys.map((key) => renderControl(key, false))}
707
- </div>
893
+ {visibleKeys.length > 0 && (
894
+ <div className="squisq-preview-controls-inline">
895
+ {visibleKeys.map((key) => renderControl(key, false))}
896
+ </div>
897
+ )}
708
898
 
709
899
  {hasOverflow && (
710
900
  <div className="squisq-preview-controls-compact" ref={popoverRef}>
711
901
  <button
902
+ ref={popoverTriggerRef}
712
903
  className={`squisq-toolbar-button${popoverOpen ? ' squisq-toolbar-button--active' : ''}`}
713
- onClick={() => setPopoverOpen((v) => !v)}
904
+ onClick={() => {
905
+ if (popoverOpen) {
906
+ closePopover();
907
+ return;
908
+ }
909
+ updatePopoverPosition();
910
+ setPopoverOpen(true);
911
+ }}
714
912
  aria-label="More preview settings"
715
913
  title="More preview settings"
716
914
  aria-expanded={popoverOpen}
717
915
  >
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>
916
+ <Icon icon="fa-solid fa-ellipsis" />
731
917
  </button>
732
- {popoverOpen && (
733
- <div className="squisq-preview-controls-popover">
918
+ {popoverOpen && popoverAnchor && (
919
+ <div
920
+ ref={popoverPanelRef}
921
+ className="squisq-preview-controls-popover"
922
+ style={{ top: popoverAnchor.top, left: popoverAnchor.left }}
923
+ >
734
924
  {overflowKeys.map((key) => renderControl(key, true))}
735
925
  </div>
736
926
  )}
@@ -741,16 +931,17 @@ export function PreviewToolbarControls() {
741
931
  }
742
932
 
743
933
  /**
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.
934
+ * Segmented display-mode switch retained as a public, embeddable control.
935
+ * The editor shell uses {@link PreviewModeMenu} beside its Use tab instead.
936
+ * Narrate is hidden when the host disables `allowNarrate`.
748
937
  */
749
938
  export function PreviewModeSwitch() {
750
939
  const s = usePreviewSettings();
940
+ const { allowNarrate } = useEditorContext();
941
+ const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
751
942
  return (
752
943
  <div className="squisq-preview-seg" role="group" aria-label="Display mode">
753
- {DISPLAY_MODE_OPTIONS.map((opt) => {
944
+ {options.map((opt) => {
754
945
  const active = s.activeDisplayMode === opt.key;
755
946
  return (
756
947
  <button
@@ -768,6 +959,185 @@ export function PreviewModeSwitch() {
768
959
  );
769
960
  }
770
961
 
962
+ const USE_MODE_MENU_WIDTH = 340;
963
+ const USE_MODE_MENU_GAP = 4;
964
+ const USE_MODE_MENU_MARGIN = 8;
965
+
966
+ /**
967
+ * Dropdown trigger rendered directly beside the Use tab. Selecting a mode
968
+ * also enters the Use view, so the menu works from Write and Source as well
969
+ * as from an already-active preview.
970
+ */
971
+ export interface PreviewModeMenuProps {
972
+ /** Incremented by the parent to open the menu from another control. */
973
+ openRequest?: number;
974
+ }
975
+
976
+ export function PreviewModeMenu({ openRequest = 0 }: PreviewModeMenuProps) {
977
+ const s = usePreviewSettings();
978
+ const { allowNarrate, colorScheme, setActiveView } = useEditorContext();
979
+ const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
980
+ const activeLabel = displayModeLabel(s.activeDisplayMode);
981
+ const triggerRef = useRef<HTMLButtonElement>(null);
982
+ const menuRef = useRef<HTMLDivElement>(null);
983
+ const itemIdPrefix = useId();
984
+ const [open, setOpen] = useState(false);
985
+ const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
986
+
987
+ const updatePosition = useCallback(() => {
988
+ const trigger = triggerRef.current;
989
+ if (!trigger) return;
990
+ const rect = trigger.getBoundingClientRect();
991
+ const menuWidth = Math.min(USE_MODE_MENU_WIDTH, window.innerWidth - USE_MODE_MENU_MARGIN * 2);
992
+ const maxLeft = Math.max(
993
+ USE_MODE_MENU_MARGIN,
994
+ window.innerWidth - menuWidth - USE_MODE_MENU_MARGIN,
995
+ );
996
+ setAnchor({
997
+ top: rect.bottom + USE_MODE_MENU_GAP,
998
+ left: Math.min(Math.max(USE_MODE_MENU_MARGIN, rect.right - menuWidth), maxLeft),
999
+ });
1000
+ }, []);
1001
+
1002
+ const closeMenu = useCallback((restoreFocus = false) => {
1003
+ setOpen(false);
1004
+ setAnchor(null);
1005
+ if (restoreFocus) triggerRef.current?.focus();
1006
+ }, []);
1007
+
1008
+ const openMenu = useCallback(() => {
1009
+ updatePosition();
1010
+ setOpen(true);
1011
+ }, [updatePosition]);
1012
+
1013
+ useEffect(() => {
1014
+ if (openRequest > 0) openMenu();
1015
+ }, [openMenu, openRequest]);
1016
+
1017
+ useEffect(() => {
1018
+ if (!open) return;
1019
+ const handlePointerDown = (event: MouseEvent) => {
1020
+ const target = event.target as Node;
1021
+ if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
1022
+ closeMenu();
1023
+ };
1024
+ const handleKeyDown = (event: KeyboardEvent) => {
1025
+ if (event.key !== 'Escape') return;
1026
+ event.preventDefault();
1027
+ closeMenu(true);
1028
+ };
1029
+ document.addEventListener('mousedown', handlePointerDown);
1030
+ document.addEventListener('keydown', handleKeyDown);
1031
+ window.addEventListener('resize', updatePosition);
1032
+ window.addEventListener('scroll', updatePosition, true);
1033
+ return () => {
1034
+ document.removeEventListener('mousedown', handlePointerDown);
1035
+ document.removeEventListener('keydown', handleKeyDown);
1036
+ window.removeEventListener('resize', updatePosition);
1037
+ window.removeEventListener('scroll', updatePosition, true);
1038
+ };
1039
+ }, [closeMenu, open, updatePosition]);
1040
+
1041
+ useLayoutEffect(() => {
1042
+ if (!open || !anchor) return;
1043
+ const selected = menuRef.current?.querySelector<HTMLButtonElement>('[aria-checked="true"]');
1044
+ const first = menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitemradio"]');
1045
+ (selected ?? first)?.focus();
1046
+ }, [anchor, open]);
1047
+
1048
+ return (
1049
+ <>
1050
+ <button
1051
+ ref={triggerRef}
1052
+ type="button"
1053
+ className={`squisq-use-mode-trigger${open ? ' squisq-use-mode-trigger--open' : ''}`}
1054
+ aria-label="Choose Use mode"
1055
+ aria-haspopup="menu"
1056
+ aria-expanded={open}
1057
+ title={`Use mode: ${activeLabel}`}
1058
+ onClick={() => (open ? closeMenu() : openMenu())}
1059
+ onKeyDown={(event) => {
1060
+ if (event.key !== 'ArrowDown') return;
1061
+ event.preventDefault();
1062
+ openMenu();
1063
+ }}
1064
+ >
1065
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
1066
+ <path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
1067
+ </svg>
1068
+ </button>
1069
+ {open &&
1070
+ anchor &&
1071
+ createPortal(
1072
+ <div
1073
+ ref={menuRef}
1074
+ className="squisq-use-mode-menu"
1075
+ data-theme={colorScheme}
1076
+ role="menu"
1077
+ aria-label="Use mode"
1078
+ style={{ top: anchor.top, left: anchor.left }}
1079
+ onKeyDown={(event) => {
1080
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
1081
+ event.preventDefault();
1082
+ const items = Array.from(
1083
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
1084
+ );
1085
+ if (items.length === 0) return;
1086
+ const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
1087
+ const nextIndex =
1088
+ event.key === 'Home'
1089
+ ? 0
1090
+ : event.key === 'End'
1091
+ ? items.length - 1
1092
+ : event.key === 'ArrowUp'
1093
+ ? (currentIndex - 1 + items.length) % items.length
1094
+ : (currentIndex + 1) % items.length;
1095
+ items[nextIndex]?.focus();
1096
+ }}
1097
+ >
1098
+ {options.map((option) => {
1099
+ const selected = option.key === s.activeDisplayMode;
1100
+ const labelId = `${itemIdPrefix}-${option.key}-label`;
1101
+ const summaryId = `${itemIdPrefix}-${option.key}-summary`;
1102
+ return (
1103
+ <button
1104
+ key={option.key}
1105
+ type="button"
1106
+ className={`squisq-use-mode-menu-item${selected ? ' squisq-use-mode-menu-item--selected' : ''}`}
1107
+ role="menuitemradio"
1108
+ aria-checked={selected}
1109
+ aria-labelledby={labelId}
1110
+ aria-describedby={summaryId}
1111
+ onClick={() => {
1112
+ s.setSelectedDisplayMode(option.key);
1113
+ setActiveView('preview');
1114
+ closeMenu(true);
1115
+ }}
1116
+ >
1117
+ <span className="squisq-use-mode-menu-icon" aria-hidden="true">
1118
+ <Icon icon={option.icon} />
1119
+ </span>
1120
+ <span className="squisq-use-mode-menu-copy">
1121
+ <span id={labelId} className="squisq-use-mode-menu-label">
1122
+ {option.label}
1123
+ </span>
1124
+ <span id={summaryId} className="squisq-use-mode-menu-summary">
1125
+ {option.summary}
1126
+ </span>
1127
+ </span>
1128
+ <span className="squisq-use-mode-menu-check" aria-hidden="true">
1129
+ {selected && <Icon icon="fa-solid fa-check" />}
1130
+ </span>
1131
+ </button>
1132
+ );
1133
+ })}
1134
+ </div>,
1135
+ document.body,
1136
+ )}
1137
+ </>
1138
+ );
1139
+ }
1140
+
771
1141
  /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
772
1142
  * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
773
1143
  function AspectIcon({ w, h }: { w: number; h: number }) {
@@ -787,10 +1157,9 @@ function AspectIcon({ w, h }: { w: number; h: number }) {
787
1157
  }
788
1158
 
789
1159
  /**
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.
1160
+ * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3), used inline in the
1161
+ * Use toolbar and inside its overflow popover. Reads and writes the same
1162
+ * `activePreset` in preview settings.
794
1163
  */
795
1164
  export function PreviewFormatSwitch() {
796
1165
  const s = usePreviewSettings();
@@ -818,12 +1187,14 @@ export function PreviewFormatSwitch() {
818
1187
 
819
1188
  function PreviewSelect({
820
1189
  label,
1190
+ labelTooltip,
821
1191
  value,
822
1192
  options,
823
1193
  onChange,
824
1194
  compact,
825
1195
  }: {
826
1196
  label: string;
1197
+ labelTooltip?: string;
827
1198
  value: string;
828
1199
  options: { key: string; label: string }[];
829
1200
  onChange: (value: string) => void;
@@ -831,7 +1202,9 @@ function PreviewSelect({
831
1202
  }) {
832
1203
  return (
833
1204
  <div className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}>
834
- <label style={labelStyle}>{label}:</label>
1205
+ <label style={labelStyle} title={labelTooltip}>
1206
+ {label}:
1207
+ </label>
835
1208
  <select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle}>
836
1209
  {options.map((o) => (
837
1210
  <option key={o.key} value={o.key}>