@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  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 +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -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 +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  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 +15 -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__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -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 +382 -5
  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__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. 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,28 @@ 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';
50
+ import {
51
+ FRONTMATTER_SETTING_DEFAULTS,
52
+ FRONTMATTER_SETTING_KEYS,
53
+ omitFrontmatterDefault,
54
+ } from './frontmatterSettings';
40
55
 
41
56
  // ── Context ──────────────────────────────────────────────────────
42
57
 
@@ -81,7 +96,7 @@ export interface PreviewSettings {
81
96
  export interface ThemeDesignerConfig {
82
97
  value: Theme | null;
83
98
  onChange: (theme: Theme) => void;
84
- onSave: (theme: Theme, target: ThemeSaveTarget) => void;
99
+ onSave: (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => void;
85
100
  onClose: () => void;
86
101
  }
87
102
 
@@ -127,8 +142,9 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
127
142
  function resolveDisplayMode(value: unknown): DisplayMode | null {
128
143
  if (typeof value !== 'string') return null;
129
144
  const v = value.trim().toLowerCase();
130
- if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
145
+ if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'narrate') return v;
131
146
  if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
147
+ if (v === 'teleprompter' || v === 'prompter') return 'narrate';
132
148
  // Frontmatter uses product-facing names: Document is the plain text/HTML
133
149
  // preview, Page is the styled Squisq page view. The raw DisplayMode values
134
150
  // are older and remain stable for the public React API.
@@ -155,17 +171,6 @@ function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): strin
155
171
  return null;
156
172
  }
157
173
 
158
- const VALID_TRANSFORM_IDS = new Set(getTransformStyleSummaries().map((s) => s.id));
159
-
160
- function resolveFrontmatterTransform(value: unknown): string | null {
161
- if (typeof value !== 'string') return null;
162
- const v = value.trim().toLowerCase();
163
- if (VALID_TRANSFORM_IDS.has(v)) return v;
164
- const normalized = v.replace(/\s+/g, '-');
165
- if (VALID_TRANSFORM_IDS.has(normalized)) return normalized;
166
- return null;
167
- }
168
-
169
174
  function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
170
175
  if (typeof value !== 'string') return null;
171
176
  const v = value.trim().toLowerCase();
@@ -198,16 +203,6 @@ export interface PreviewSettingsProviderProps {
198
203
  themeOverride?: Theme | null;
199
204
  }
200
205
 
201
- /** Frontmatter keys we read/write for preview settings. The squisq-prefixed
202
- * keys are the canonical names; the legacy keys are still read so existing
203
- * documents keep working. Persistence (writes) uses only the squisq names. */
204
- const FM_KEYS = {
205
- theme: { canonical: 'squisq-theme', legacy: 'theme' as const },
206
- transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
207
- captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
- coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
209
- } as const;
210
-
211
206
  function readFrontmatterKey(
212
207
  fm: Record<string, unknown> | undefined,
213
208
  canonical: string,
@@ -223,10 +218,10 @@ export function PreviewSettingsProvider({
223
218
  themeOverride,
224
219
  }: PreviewSettingsProviderProps) {
225
220
  const frontmatter = doc?.frontmatter;
226
- const { markdownSource, setMarkdownSource } = useEditorContext();
221
+ const { markdownSource, setMarkdownSource, allowNarrate } = useEditorContext();
227
222
 
228
223
  const persistFrontmatter = useCallback(
229
- (updates: Record<string, string | null>) => {
224
+ (updates: Record<string, string | number | boolean | null | undefined>) => {
230
225
  const next = setFrontmatterValues(markdownSource, updates);
231
226
  if (next !== markdownSource) {
232
227
  setMarkdownSource(next);
@@ -245,35 +240,40 @@ export function PreviewSettingsProvider({
245
240
  const activePreset = selectedPreset ?? fmPreset ?? 'landscape';
246
241
  const activeViewport = VIEWPORT_PRESETS[activePreset];
247
242
 
248
- // Display mode
243
+ // Display mode. A frontmatter-forced `narrate` clamps back to video when
244
+ // the host disabled the mode, so hostile frontmatter can't turn it on.
249
245
  const fmMode = useMemo(() => resolveDisplayMode(frontmatter?.['display-mode']), [frontmatter]);
250
246
  const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
251
247
  useEffect(() => setSelectedDisplayMode(null), [fmMode]);
252
- const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
248
+ const requestedDisplayMode = selectedDisplayMode ?? fmMode ?? 'slideshow';
249
+ const activeDisplayMode =
250
+ requestedDisplayMode === 'narrate' && !allowNarrate ? 'video' : requestedDisplayMode;
253
251
 
254
252
  // Custom themes (doc + browser library). `useCustomThemes` returns null when
255
- // no provider is mounted; the picker then just shows built-ins.
253
+ // no provider is mounted; document-scoped themes still remain available.
256
254
  const custom = useCustomThemes();
257
- const customThemes = useMemo(() => custom?.allThemes ?? [], [custom]);
255
+ const docThemes = useMemo(
256
+ () => custom?.docThemes ?? doc?.customThemes ?? [],
257
+ [custom, doc?.customThemes],
258
+ );
259
+ const customThemes = useMemo(() => custom?.allThemes ?? docThemes, [custom, docThemes]);
258
260
  const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
259
261
 
260
- // Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
262
+ // Theme — persisted to `squisq-theme`; `themeId` / `theme` remain readable.
261
263
  const fmTheme = useMemo(
262
- () =>
263
- resolveFrontmatterTheme(
264
- readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
265
- customIds,
266
- ),
264
+ () => resolveFrontmatterTheme(readFrontmatterThemeId(frontmatter), customIds),
267
265
  [frontmatter, customIds],
268
266
  );
269
267
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
270
268
  useEffect(() => setSelectedThemeId(null), [fmTheme]);
271
- const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
272
- // Doc-scoped resolution: an inline custom theme id resolves from the doc's
273
- // own `customThemes` before built-ins no global registration needed.
269
+ const resolvedThemeId = selectedThemeId ?? fmTheme ?? FRONTMATTER_SETTING_DEFAULTS.theme;
270
+ // Doc themes precede browser-library themes in `allThemes`; choosing a
271
+ // library-only entry copies it into the document below for portable export.
274
272
  const resolvedTheme = useMemo(
275
- () => resolveThemeForDoc(doc, resolvedThemeId),
276
- [doc, resolvedThemeId],
273
+ () =>
274
+ customThemes.find((theme) => theme.id === resolvedThemeId) ??
275
+ resolveThemeForDoc(doc, resolvedThemeId),
276
+ [customThemes, doc, resolvedThemeId],
277
277
  );
278
278
 
279
279
  // In-progress theme from the designer dialog; previews live without mutating
@@ -291,9 +291,24 @@ export function PreviewSettingsProvider({
291
291
  const handleSetThemeId = useCallback(
292
292
  (id: string | null) => {
293
293
  setSelectedThemeId(id);
294
- if (id !== null) persistFrontmatter({ [FM_KEYS.theme.canonical]: id });
294
+ if (id === null) return;
295
+ const selectedCustom = customThemes.find((theme) => theme.id === id);
296
+ const alreadyDocScoped = docThemes.some((theme) => theme.id === id);
297
+ const updates: Record<string, string | null> = {
298
+ [FRONTMATTER_SETTING_KEYS.theme.canonical]: omitFrontmatterDefault(
299
+ id,
300
+ FRONTMATTER_SETTING_DEFAULTS.theme,
301
+ ),
302
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[0]]: null,
303
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[1]]: null,
304
+ };
305
+ if (selectedCustom && !alreadyDocScoped) {
306
+ updates[FRONTMATTER_CUSTOM_THEMES_KEY] =
307
+ writeCustomThemesToFrontmatter([...docThemes, selectedCustom]) ?? null;
308
+ }
309
+ persistFrontmatter(updates);
295
310
  },
296
- [persistFrontmatter],
311
+ [customThemes, docThemes, persistFrontmatter],
297
312
  );
298
313
 
299
314
  const openThemeDesigner = useCallback((theme: Theme | null) => {
@@ -305,27 +320,38 @@ export function PreviewSettingsProvider({
305
320
  setPreviewTheme(null);
306
321
  }, []);
307
322
  const handleDesignerSave = useCallback(
308
- (theme: Theme, target: ThemeSaveTarget) => {
323
+ (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => {
309
324
  if (target === 'library') {
325
+ // Imported slide layouts are doc-scoped in v1; the dialog hints at this.
310
326
  custom?.upsertLibraryTheme(theme);
311
327
  } else {
312
- // Write the theme payload AND select it in a SINGLE frontmatter update.
313
- // Two separate `setMarkdownSource` calls (upsertDocTheme + a squisq-theme
314
- // write) would each derive from the same stale source, so the second
315
- // would clobber the first and drop the custom-themes payload.
328
+ // Write the theme payload, its selection, AND any imported layout
329
+ // templates in a SINGLE frontmatter update. Separate
330
+ // `setMarkdownSource` calls would each derive from the same stale
331
+ // source, so later writes would clobber earlier ones.
316
332
  const docThemes = custom?.docThemes ?? [];
317
333
  const idx = docThemes.findIndex((t) => t.id === theme.id);
318
334
  const nextThemes =
319
335
  idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
320
- persistFrontmatter({
336
+ const updates: Record<string, string | null> = {
321
337
  [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
322
- [FM_KEYS.theme.canonical]: theme.id,
323
- });
338
+ [FRONTMATTER_SETTING_KEYS.theme.canonical]: theme.id,
339
+ };
340
+ if (extras?.templates && extras.templates.length > 0) {
341
+ const existing = doc?.customTemplates ?? [];
342
+ const merged = [
343
+ ...existing.filter((t) => !extras.templates!.some((n) => n.name === t.name)),
344
+ ...extras.templates,
345
+ ];
346
+ updates[FRONTMATTER_CUSTOM_TEMPLATES_KEY] =
347
+ writeCustomTemplatesToFrontmatter(merged) ?? null;
348
+ }
349
+ persistFrontmatter(updates);
324
350
  setSelectedThemeId(theme.id);
325
351
  }
326
352
  closeThemeDesigner();
327
353
  },
328
- [custom, persistFrontmatter, closeThemeDesigner],
354
+ [custom, doc, persistFrontmatter, closeThemeDesigner],
329
355
  );
330
356
  const deleteCustomTheme = useCallback(
331
357
  (id: string) => {
@@ -338,8 +364,12 @@ export function PreviewSettingsProvider({
338
364
  // Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
339
365
  const fmTransform = useMemo(
340
366
  () =>
341
- resolveFrontmatterTransform(
342
- readFrontmatterKey(frontmatter, FM_KEYS.transform.canonical, FM_KEYS.transform.legacy),
367
+ resolvePersistedTransformStyleId(
368
+ readFrontmatterKey(
369
+ frontmatter,
370
+ FRONTMATTER_SETTING_KEYS.transform.canonical,
371
+ FRONTMATTER_SETTING_KEYS.transform.legacy,
372
+ ),
343
373
  ),
344
374
  [frontmatter],
345
375
  );
@@ -351,7 +381,13 @@ export function PreviewSettingsProvider({
351
381
  setSelectedTransformStyle(id);
352
382
  if (id !== null) {
353
383
  // Empty string = "None" — remove the key rather than writing a blank value.
354
- persistFrontmatter({ [FM_KEYS.transform.canonical]: id === '' ? null : id });
384
+ persistFrontmatter({
385
+ [FRONTMATTER_SETTING_KEYS.transform.canonical]: omitFrontmatterDefault(
386
+ id,
387
+ FRONTMATTER_SETTING_DEFAULTS.transform,
388
+ ),
389
+ [FRONTMATTER_SETTING_KEYS.transform.legacy]: null,
390
+ });
355
391
  }
356
392
  },
357
393
  [persistFrontmatter],
@@ -363,19 +399,30 @@ export function PreviewSettingsProvider({
363
399
  const fmCaptionMode = useMemo(
364
400
  () =>
365
401
  resolveFrontmatterCaptionMode(
366
- readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
402
+ readFrontmatterKey(
403
+ frontmatter,
404
+ FRONTMATTER_SETTING_KEYS.captions.canonical,
405
+ FRONTMATTER_SETTING_KEYS.captions.legacy,
406
+ ),
367
407
  ),
368
408
  [frontmatter],
369
409
  );
370
410
  const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
371
411
  useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
372
- const activeCaptionMode = selectedCaptionMode ?? fmCaptionMode ?? 'standard';
412
+ const activeCaptionMode =
413
+ selectedCaptionMode ?? fmCaptionMode ?? FRONTMATTER_SETTING_DEFAULTS.captions;
373
414
  const activeCaptionsEnabled = activeCaptionMode !== 'off';
374
415
  const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
375
416
  const handleSetCaptionMode = useCallback(
376
417
  (mode: CaptionMode) => {
377
418
  setSelectedCaptionMode(mode);
378
- persistFrontmatter({ [FM_KEYS.captions.canonical]: mode });
419
+ persistFrontmatter({
420
+ [FRONTMATTER_SETTING_KEYS.captions.canonical]: omitFrontmatterDefault(
421
+ mode,
422
+ FRONTMATTER_SETTING_DEFAULTS.captions,
423
+ ),
424
+ [FRONTMATTER_SETTING_KEYS.captions.legacy]: null,
425
+ });
379
426
  },
380
427
  [persistFrontmatter],
381
428
  );
@@ -385,17 +432,31 @@ export function PreviewSettingsProvider({
385
432
  const fmCoverSlide = useMemo(
386
433
  () =>
387
434
  resolveFrontmatterBoolean(
388
- readFrontmatterKey(frontmatter, FM_KEYS.coverSlide.canonical, FM_KEYS.coverSlide.legacy),
435
+ readFrontmatterKey(
436
+ frontmatter,
437
+ FRONTMATTER_SETTING_KEYS.coverSlide.canonical,
438
+ FRONTMATTER_SETTING_KEYS.coverSlide.legacy,
439
+ ),
389
440
  ),
390
441
  [frontmatter],
391
442
  );
392
443
  const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
393
444
  useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
394
- const activeCoverSlide = selectedCoverSlide ?? fmCoverSlide ?? true;
445
+ const activeCoverSlide =
446
+ selectedCoverSlide ?? fmCoverSlide ?? FRONTMATTER_SETTING_DEFAULTS.coverSlide;
395
447
  const handleSetCoverSlideEnabled = useCallback(
396
448
  (enabled: boolean) => {
397
449
  setSelectedCoverSlide(enabled);
398
- persistFrontmatter({ [FM_KEYS.coverSlide.canonical]: enabled ? 'true' : 'false' });
450
+ persistFrontmatter({
451
+ // The cover is enabled by default, so only persist the non-default
452
+ // state. Pass the boolean through so YAML writes `false`, not
453
+ // the string `"false"`.
454
+ [FRONTMATTER_SETTING_KEYS.coverSlide.canonical]: omitFrontmatterDefault(
455
+ enabled,
456
+ FRONTMATTER_SETTING_DEFAULTS.coverSlide,
457
+ ),
458
+ [FRONTMATTER_SETTING_KEYS.coverSlide.legacy]: null,
459
+ });
399
460
  },
400
461
  [persistFrontmatter],
401
462
  );
@@ -496,27 +557,64 @@ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h:
496
557
  { key: 'standard', label: '4:3', w: 12, h: 9 },
497
558
  ];
498
559
 
499
- const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
500
- { key: 'video', label: 'Video' },
501
- { key: 'slideshow', label: 'Slideshow' },
502
- { key: 'linear', label: 'Page' },
503
- { key: 'page', label: 'Document' },
560
+ const DISPLAY_MODE_OPTIONS: {
561
+ key: DisplayMode;
562
+ label: string;
563
+ icon: string;
564
+ summary: string;
565
+ }[] = [
566
+ {
567
+ key: 'slideshow',
568
+ label: 'Slideshow',
569
+ icon: 'fa-solid fa-images',
570
+ summary: 'Present designed slides one at a time.',
571
+ },
572
+ {
573
+ key: 'video',
574
+ label: 'Video',
575
+ icon: 'fa-solid fa-circle-play',
576
+ summary: 'Play an automatically timed presentation.',
577
+ },
578
+ {
579
+ key: 'linear',
580
+ label: 'Page',
581
+ icon: 'fa-solid fa-window-maximize',
582
+ summary: 'Scroll through the fully designed page.',
583
+ },
584
+ {
585
+ key: 'page',
586
+ label: 'Document',
587
+ icon: 'fa-solid fa-align-left',
588
+ summary: 'Read a clean, text-first document.',
589
+ },
590
+ {
591
+ key: 'narrate',
592
+ label: 'Narrate',
593
+ icon: 'fa-solid fa-microphone-lines',
594
+ summary: 'Speak with a voice-paced teleprompter.',
595
+ },
504
596
  ];
505
597
 
598
+ export function displayModeLabel(mode: DisplayMode): string {
599
+ return DISPLAY_MODE_OPTIONS.find((option) => option.key === mode)?.label ?? 'Slideshow';
600
+ }
601
+
506
602
  const TRANSFORM_STYLE_OPTIONS = [
507
603
  { key: '', label: 'None' },
508
604
  ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
509
605
  ];
510
606
 
607
+ const SUMMARIZE_TOOLTIP =
608
+ 'Extract and summarize content for presentation with these Use modes. Your underlying content is not changed.';
609
+
511
610
  /**
512
611
  * Left-to-right priority order for the preview controls. As the toolbar
513
612
  * narrows, controls drop into the overflow menu from the END of this list
514
- * first (Cover, then Captions, …). Display mode and aspect ratio stay inline
515
- * the longest, but still collapse into the same menu when the toolbar is very
516
- * constrained.
613
+ * first (Cover, then Captions, …). Aspect ratio stays inline the longest, but
614
+ * still collapses into the same menu when the toolbar is very constrained.
517
615
  */
518
- type ControlKey = 'mode' | 'format' | 'theme' | 'transform' | 'captions' | 'cover';
519
- const CONTROL_KEYS: ControlKey[] = ['mode', 'format', 'theme', 'transform', 'captions', 'cover'];
616
+ type ControlKey = 'format' | 'theme' | 'transform' | 'captions' | 'cover';
617
+ const CONTROL_KEYS: ControlKey[] = ['format', 'theme', 'transform', 'captions', 'cover'];
520
618
 
521
619
  const PREVIEW_POPOVER_GAP = 4;
522
620
  const PREVIEW_POPOVER_MARGIN = 8;
@@ -681,16 +779,6 @@ export function PreviewToolbarControls() {
681
779
  // label-over-control layout used inside the overflow popover.
682
780
  const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
683
781
  switch (key) {
684
- case 'mode':
685
- return (
686
- <div
687
- key="mode"
688
- className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
689
- >
690
- {compact && <label style={labelStyle}>Mode:</label>}
691
- <PreviewModeSwitch />
692
- </div>
693
- );
694
782
  case 'format':
695
783
  return (
696
784
  <div
@@ -747,7 +835,8 @@ export function PreviewToolbarControls() {
747
835
  return (
748
836
  <PreviewSelect
749
837
  key="transform"
750
- label="Transform"
838
+ label="Summarize"
839
+ labelTooltip={SUMMARIZE_TOOLTIP}
751
840
  value={s.activeTransformStyle}
752
841
  options={TRANSFORM_STYLE_OPTIONS}
753
842
  onChange={(v) => s.setSelectedTransformStyle(v)}
@@ -798,7 +887,6 @@ export function PreviewToolbarControls() {
798
887
  key="cover"
799
888
  className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
800
889
  >
801
- <label style={labelStyle}>Cover:</label>
802
890
  <label className="squisq-preview-checkbox">
803
891
  <input
804
892
  type="checkbox"
@@ -876,15 +964,17 @@ export function PreviewToolbarControls() {
876
964
  }
877
965
 
878
966
  /**
879
- * Segmented display-mode switch (Video / Slideshow / Document / Page), used
880
- * inline in the Use toolbar and inside its overflow popover. Reads and writes
881
- * the same `activeDisplayMode` in preview settings.
967
+ * Segmented display-mode switch retained as a public, embeddable control.
968
+ * The editor shell uses {@link PreviewModeMenu} beside its Use tab instead.
969
+ * Narrate is hidden when the host disables `allowNarrate`.
882
970
  */
883
971
  export function PreviewModeSwitch() {
884
972
  const s = usePreviewSettings();
973
+ const { allowNarrate } = useEditorContext();
974
+ const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
885
975
  return (
886
976
  <div className="squisq-preview-seg" role="group" aria-label="Display mode">
887
- {DISPLAY_MODE_OPTIONS.map((opt) => {
977
+ {options.map((opt) => {
888
978
  const active = s.activeDisplayMode === opt.key;
889
979
  return (
890
980
  <button
@@ -902,6 +992,185 @@ export function PreviewModeSwitch() {
902
992
  );
903
993
  }
904
994
 
995
+ const USE_MODE_MENU_WIDTH = 340;
996
+ const USE_MODE_MENU_GAP = 4;
997
+ const USE_MODE_MENU_MARGIN = 8;
998
+
999
+ /**
1000
+ * Dropdown trigger rendered directly beside the Use tab. Selecting a mode
1001
+ * also enters the Use view, so the menu works from Write and Source as well
1002
+ * as from an already-active preview.
1003
+ */
1004
+ export interface PreviewModeMenuProps {
1005
+ /** Incremented by the parent to open the menu from another control. */
1006
+ openRequest?: number;
1007
+ }
1008
+
1009
+ export function PreviewModeMenu({ openRequest = 0 }: PreviewModeMenuProps) {
1010
+ const s = usePreviewSettings();
1011
+ const { allowNarrate, colorScheme, setActiveView } = useEditorContext();
1012
+ const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
1013
+ const activeLabel = displayModeLabel(s.activeDisplayMode);
1014
+ const triggerRef = useRef<HTMLButtonElement>(null);
1015
+ const menuRef = useRef<HTMLDivElement>(null);
1016
+ const itemIdPrefix = useId();
1017
+ const [open, setOpen] = useState(false);
1018
+ const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
1019
+
1020
+ const updatePosition = useCallback(() => {
1021
+ const trigger = triggerRef.current;
1022
+ if (!trigger) return;
1023
+ const rect = trigger.getBoundingClientRect();
1024
+ const menuWidth = Math.min(USE_MODE_MENU_WIDTH, window.innerWidth - USE_MODE_MENU_MARGIN * 2);
1025
+ const maxLeft = Math.max(
1026
+ USE_MODE_MENU_MARGIN,
1027
+ window.innerWidth - menuWidth - USE_MODE_MENU_MARGIN,
1028
+ );
1029
+ setAnchor({
1030
+ top: rect.bottom + USE_MODE_MENU_GAP,
1031
+ left: Math.min(Math.max(USE_MODE_MENU_MARGIN, rect.right - menuWidth), maxLeft),
1032
+ });
1033
+ }, []);
1034
+
1035
+ const closeMenu = useCallback((restoreFocus = false) => {
1036
+ setOpen(false);
1037
+ setAnchor(null);
1038
+ if (restoreFocus) triggerRef.current?.focus();
1039
+ }, []);
1040
+
1041
+ const openMenu = useCallback(() => {
1042
+ updatePosition();
1043
+ setOpen(true);
1044
+ }, [updatePosition]);
1045
+
1046
+ useEffect(() => {
1047
+ if (openRequest > 0) openMenu();
1048
+ }, [openMenu, openRequest]);
1049
+
1050
+ useEffect(() => {
1051
+ if (!open) return;
1052
+ const handlePointerDown = (event: MouseEvent) => {
1053
+ const target = event.target as Node;
1054
+ if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
1055
+ closeMenu();
1056
+ };
1057
+ const handleKeyDown = (event: KeyboardEvent) => {
1058
+ if (event.key !== 'Escape') return;
1059
+ event.preventDefault();
1060
+ closeMenu(true);
1061
+ };
1062
+ document.addEventListener('mousedown', handlePointerDown);
1063
+ document.addEventListener('keydown', handleKeyDown);
1064
+ window.addEventListener('resize', updatePosition);
1065
+ window.addEventListener('scroll', updatePosition, true);
1066
+ return () => {
1067
+ document.removeEventListener('mousedown', handlePointerDown);
1068
+ document.removeEventListener('keydown', handleKeyDown);
1069
+ window.removeEventListener('resize', updatePosition);
1070
+ window.removeEventListener('scroll', updatePosition, true);
1071
+ };
1072
+ }, [closeMenu, open, updatePosition]);
1073
+
1074
+ useLayoutEffect(() => {
1075
+ if (!open || !anchor) return;
1076
+ const selected = menuRef.current?.querySelector<HTMLButtonElement>('[aria-checked="true"]');
1077
+ const first = menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitemradio"]');
1078
+ (selected ?? first)?.focus();
1079
+ }, [anchor, open]);
1080
+
1081
+ return (
1082
+ <>
1083
+ <button
1084
+ ref={triggerRef}
1085
+ type="button"
1086
+ className={`squisq-use-mode-trigger${open ? ' squisq-use-mode-trigger--open' : ''}`}
1087
+ aria-label="Choose Use mode"
1088
+ aria-haspopup="menu"
1089
+ aria-expanded={open}
1090
+ title={`Use mode: ${activeLabel}`}
1091
+ onClick={() => (open ? closeMenu() : openMenu())}
1092
+ onKeyDown={(event) => {
1093
+ if (event.key !== 'ArrowDown') return;
1094
+ event.preventDefault();
1095
+ openMenu();
1096
+ }}
1097
+ >
1098
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
1099
+ <path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
1100
+ </svg>
1101
+ </button>
1102
+ {open &&
1103
+ anchor &&
1104
+ createPortal(
1105
+ <div
1106
+ ref={menuRef}
1107
+ className="squisq-use-mode-menu"
1108
+ data-theme={colorScheme}
1109
+ role="menu"
1110
+ aria-label="Use mode"
1111
+ style={{ top: anchor.top, left: anchor.left }}
1112
+ onKeyDown={(event) => {
1113
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
1114
+ event.preventDefault();
1115
+ const items = Array.from(
1116
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
1117
+ );
1118
+ if (items.length === 0) return;
1119
+ const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
1120
+ const nextIndex =
1121
+ event.key === 'Home'
1122
+ ? 0
1123
+ : event.key === 'End'
1124
+ ? items.length - 1
1125
+ : event.key === 'ArrowUp'
1126
+ ? (currentIndex - 1 + items.length) % items.length
1127
+ : (currentIndex + 1) % items.length;
1128
+ items[nextIndex]?.focus();
1129
+ }}
1130
+ >
1131
+ {options.map((option) => {
1132
+ const selected = option.key === s.activeDisplayMode;
1133
+ const labelId = `${itemIdPrefix}-${option.key}-label`;
1134
+ const summaryId = `${itemIdPrefix}-${option.key}-summary`;
1135
+ return (
1136
+ <button
1137
+ key={option.key}
1138
+ type="button"
1139
+ className={`squisq-use-mode-menu-item${selected ? ' squisq-use-mode-menu-item--selected' : ''}`}
1140
+ role="menuitemradio"
1141
+ aria-checked={selected}
1142
+ aria-labelledby={labelId}
1143
+ aria-describedby={summaryId}
1144
+ onClick={() => {
1145
+ s.setSelectedDisplayMode(option.key);
1146
+ setActiveView('preview');
1147
+ closeMenu(true);
1148
+ }}
1149
+ >
1150
+ <span className="squisq-use-mode-menu-icon" aria-hidden="true">
1151
+ <Icon icon={option.icon} />
1152
+ </span>
1153
+ <span className="squisq-use-mode-menu-copy">
1154
+ <span id={labelId} className="squisq-use-mode-menu-label">
1155
+ {option.label}
1156
+ </span>
1157
+ <span id={summaryId} className="squisq-use-mode-menu-summary">
1158
+ {option.summary}
1159
+ </span>
1160
+ </span>
1161
+ <span className="squisq-use-mode-menu-check" aria-hidden="true">
1162
+ {selected && <Icon icon="fa-solid fa-check" />}
1163
+ </span>
1164
+ </button>
1165
+ );
1166
+ })}
1167
+ </div>,
1168
+ document.body,
1169
+ )}
1170
+ </>
1171
+ );
1172
+ }
1173
+
905
1174
  /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
906
1175
  * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
907
1176
  function AspectIcon({ w, h }: { w: number; h: number }) {
@@ -951,12 +1220,14 @@ export function PreviewFormatSwitch() {
951
1220
 
952
1221
  function PreviewSelect({
953
1222
  label,
1223
+ labelTooltip,
954
1224
  value,
955
1225
  options,
956
1226
  onChange,
957
1227
  compact,
958
1228
  }: {
959
1229
  label: string;
1230
+ labelTooltip?: string;
960
1231
  value: string;
961
1232
  options: { key: string; label: string }[];
962
1233
  onChange: (value: string) => void;
@@ -964,7 +1235,9 @@ function PreviewSelect({
964
1235
  }) {
965
1236
  return (
966
1237
  <div className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}>
967
- <label style={labelStyle}>{label}:</label>
1238
+ <label style={labelStyle} title={labelTooltip}>
1239
+ {label}:
1240
+ </label>
968
1241
  <select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle}>
969
1242
  {options.map((o) => (
970
1243
  <option key={o.key} value={o.key}>