@bendyline/squisq-editor-react 1.6.2 → 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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -16,7 +16,7 @@
16
16
  * it can fetch.
17
17
  */
18
18
 
19
- import { useEffect, useMemo, useState } from 'react';
19
+ import { useEffect, useMemo, useRef, useState } from 'react';
20
20
  import type { CSSProperties } from 'react';
21
21
  import { parseMarkdown } from '@bendyline/squisq/markdown';
22
22
  import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
@@ -68,6 +68,8 @@ export interface PlainHtmlPreviewProps {
68
68
  theme?: Theme;
69
69
  className?: string;
70
70
  style?: CSSProperties;
71
+ /** Let unmodified Up/Down arrows scroll the preview without requiring iframe focus. */
72
+ globalKeyboardShortcuts?: boolean;
71
73
  }
72
74
 
73
75
  const IFRAME_STYLE: CSSProperties = {
@@ -87,7 +89,9 @@ export function PlainHtmlPreview({
87
89
  theme,
88
90
  className,
89
91
  style,
92
+ globalKeyboardShortcuts = false,
90
93
  }: PlainHtmlPreviewProps) {
94
+ const iframeRef = useRef<HTMLIFrameElement>(null);
91
95
  const mdDoc = useMemo<MarkdownDocument>(() => parseMarkdown(markdown), [markdown]);
92
96
 
93
97
  // Resolve any relative image URLs the doc references. Blob URLs are
@@ -161,8 +165,43 @@ export function PlainHtmlPreview({
161
165
  [renderFn, mdDoc, title, mergedImages, theme, iconsCss],
162
166
  );
163
167
 
168
+ useEffect(() => {
169
+ if (!globalKeyboardShortcuts) return;
170
+ const handleKeyDown = (event: KeyboardEvent) => {
171
+ if (
172
+ event.defaultPrevented ||
173
+ event.altKey ||
174
+ event.ctrlKey ||
175
+ event.metaKey ||
176
+ event.shiftKey ||
177
+ (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
178
+ ) {
179
+ return;
180
+ }
181
+ const target = event.target instanceof Element ? event.target : null;
182
+ if (
183
+ target?.closest(
184
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], .monaco-editor',
185
+ )
186
+ ) {
187
+ return;
188
+ }
189
+ const frameWindow = iframeRef.current?.contentWindow;
190
+ if (!frameWindow) return;
191
+ event.preventDefault();
192
+ const distance = Math.max(64, Math.round(iframeRef.current?.clientHeight ?? 0) * 0.12);
193
+ frameWindow.scrollBy({
194
+ top: event.key === 'ArrowDown' ? distance : -distance,
195
+ behavior: 'smooth',
196
+ });
197
+ };
198
+ document.addEventListener('keydown', handleKeyDown);
199
+ return () => document.removeEventListener('keydown', handleKeyDown);
200
+ }, [globalKeyboardShortcuts]);
201
+
164
202
  return (
165
203
  <iframe
204
+ ref={iframeRef}
166
205
  className={className}
167
206
  data-testid="plain-html-preview"
168
207
  title={title ?? 'HTML preview'}
@@ -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
 
@@ -81,7 +91,7 @@ export interface PreviewSettings {
81
91
  export interface ThemeDesignerConfig {
82
92
  value: Theme | null;
83
93
  onChange: (theme: Theme) => void;
84
- onSave: (theme: Theme, target: ThemeSaveTarget) => void;
94
+ onSave: (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => void;
85
95
  onClose: () => void;
86
96
  }
87
97
 
@@ -127,8 +137,9 @@ function resolveRenderAs(value: unknown): ViewportPreset | null {
127
137
  function resolveDisplayMode(value: unknown): DisplayMode | null {
128
138
  if (typeof value !== 'string') return null;
129
139
  const v = value.trim().toLowerCase();
130
- if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
140
+ if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'narrate') return v;
131
141
  if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
142
+ if (v === 'teleprompter' || v === 'prompter') return 'narrate';
132
143
  // Frontmatter uses product-facing names: Document is the plain text/HTML
133
144
  // preview, Page is the styled Squisq page view. The raw DisplayMode values
134
145
  // are older and remain stable for the public React API.
@@ -155,17 +166,6 @@ function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): strin
155
166
  return null;
156
167
  }
157
168
 
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
169
  function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
170
170
  if (typeof value !== 'string') return null;
171
171
  const v = value.trim().toLowerCase();
@@ -202,7 +202,7 @@ export interface PreviewSettingsProviderProps {
202
202
  * keys are the canonical names; the legacy keys are still read so existing
203
203
  * documents keep working. Persistence (writes) uses only the squisq names. */
204
204
  const FM_KEYS = {
205
- theme: { canonical: 'squisq-theme', legacy: 'theme' as const },
205
+ theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
206
206
  transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
207
207
  captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
208
  coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
@@ -223,7 +223,7 @@ export function PreviewSettingsProvider({
223
223
  themeOverride,
224
224
  }: PreviewSettingsProviderProps) {
225
225
  const frontmatter = doc?.frontmatter;
226
- const { markdownSource, setMarkdownSource } = useEditorContext();
226
+ const { markdownSource, setMarkdownSource, allowNarrate } = useEditorContext();
227
227
 
228
228
  const persistFrontmatter = useCallback(
229
229
  (updates: Record<string, string | null>) => {
@@ -245,35 +245,40 @@ export function PreviewSettingsProvider({
245
245
  const activePreset = selectedPreset ?? fmPreset ?? 'landscape';
246
246
  const activeViewport = VIEWPORT_PRESETS[activePreset];
247
247
 
248
- // 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.
249
250
  const fmMode = useMemo(() => resolveDisplayMode(frontmatter?.['display-mode']), [frontmatter]);
250
251
  const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
251
252
  useEffect(() => setSelectedDisplayMode(null), [fmMode]);
252
- const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
253
+ const requestedDisplayMode = selectedDisplayMode ?? fmMode ?? 'slideshow';
254
+ const activeDisplayMode =
255
+ requestedDisplayMode === 'narrate' && !allowNarrate ? 'video' : requestedDisplayMode;
253
256
 
254
257
  // Custom themes (doc + browser library). `useCustomThemes` returns null when
255
- // no provider is mounted; the picker then just shows built-ins.
258
+ // no provider is mounted; document-scoped themes still remain available.
256
259
  const custom = useCustomThemes();
257
- 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]);
258
265
  const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
259
266
 
260
- // Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
267
+ // Theme — persisted to `squisq-theme`; `themeId` / `theme` remain readable.
261
268
  const fmTheme = useMemo(
262
- () =>
263
- resolveFrontmatterTheme(
264
- readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
265
- customIds,
266
- ),
269
+ () => resolveFrontmatterTheme(readFrontmatterThemeId(frontmatter), customIds),
267
270
  [frontmatter, customIds],
268
271
  );
269
272
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
270
273
  useEffect(() => setSelectedThemeId(null), [fmTheme]);
271
274
  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.
275
+ // Doc themes precede browser-library themes in `allThemes`; choosing a
276
+ // library-only entry copies it into the document below for portable export.
274
277
  const resolvedTheme = useMemo(
275
- () => resolveThemeForDoc(doc, resolvedThemeId),
276
- [doc, resolvedThemeId],
278
+ () =>
279
+ customThemes.find((theme) => theme.id === resolvedThemeId) ??
280
+ resolveThemeForDoc(doc, resolvedThemeId),
281
+ [customThemes, doc, resolvedThemeId],
277
282
  );
278
283
 
279
284
  // In-progress theme from the designer dialog; previews live without mutating
@@ -291,9 +296,21 @@ export function PreviewSettingsProvider({
291
296
  const handleSetThemeId = useCallback(
292
297
  (id: string | null) => {
293
298
  setSelectedThemeId(id);
294
- 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);
295
312
  },
296
- [persistFrontmatter],
313
+ [customThemes, docThemes, persistFrontmatter],
297
314
  );
298
315
 
299
316
  const openThemeDesigner = useCallback((theme: Theme | null) => {
@@ -305,27 +322,38 @@ export function PreviewSettingsProvider({
305
322
  setPreviewTheme(null);
306
323
  }, []);
307
324
  const handleDesignerSave = useCallback(
308
- (theme: Theme, target: ThemeSaveTarget) => {
325
+ (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => {
309
326
  if (target === 'library') {
327
+ // Imported slide layouts are doc-scoped in v1; the dialog hints at this.
310
328
  custom?.upsertLibraryTheme(theme);
311
329
  } 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.
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.
316
334
  const docThemes = custom?.docThemes ?? [];
317
335
  const idx = docThemes.findIndex((t) => t.id === theme.id);
318
336
  const nextThemes =
319
337
  idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
320
- persistFrontmatter({
338
+ const updates: Record<string, string | null> = {
321
339
  [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
322
340
  [FM_KEYS.theme.canonical]: theme.id,
323
- });
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);
324
352
  setSelectedThemeId(theme.id);
325
353
  }
326
354
  closeThemeDesigner();
327
355
  },
328
- [custom, persistFrontmatter, closeThemeDesigner],
356
+ [custom, doc, persistFrontmatter, closeThemeDesigner],
329
357
  );
330
358
  const deleteCustomTheme = useCallback(
331
359
  (id: string) => {
@@ -338,7 +366,7 @@ export function PreviewSettingsProvider({
338
366
  // Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
339
367
  const fmTransform = useMemo(
340
368
  () =>
341
- resolveFrontmatterTransform(
369
+ resolvePersistedTransformStyleId(
342
370
  readFrontmatterKey(frontmatter, FM_KEYS.transform.canonical, FM_KEYS.transform.legacy),
343
371
  ),
344
372
  [frontmatter],
@@ -496,27 +524,64 @@ const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h:
496
524
  { key: 'standard', label: '4:3', w: 12, h: 9 },
497
525
  ];
498
526
 
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' },
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
+ },
504
563
  ];
505
564
 
565
+ export function displayModeLabel(mode: DisplayMode): string {
566
+ return DISPLAY_MODE_OPTIONS.find((option) => option.key === mode)?.label ?? 'Slideshow';
567
+ }
568
+
506
569
  const TRANSFORM_STYLE_OPTIONS = [
507
570
  { key: '', label: 'None' },
508
571
  ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
509
572
  ];
510
573
 
574
+ const SUMMARIZE_TOOLTIP =
575
+ 'Extract and summarize content for presentation with these Use modes. Your underlying content is not changed.';
576
+
511
577
  /**
512
578
  * Left-to-right priority order for the preview controls. As the toolbar
513
579
  * 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.
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.
517
582
  */
518
- type ControlKey = 'mode' | 'format' | 'theme' | 'transform' | 'captions' | 'cover';
519
- const CONTROL_KEYS: ControlKey[] = ['mode', 'format', 'theme', 'transform', 'captions', 'cover'];
583
+ type ControlKey = 'format' | 'theme' | 'transform' | 'captions' | 'cover';
584
+ const CONTROL_KEYS: ControlKey[] = ['format', 'theme', 'transform', 'captions', 'cover'];
520
585
 
521
586
  const PREVIEW_POPOVER_GAP = 4;
522
587
  const PREVIEW_POPOVER_MARGIN = 8;
@@ -681,16 +746,6 @@ export function PreviewToolbarControls() {
681
746
  // label-over-control layout used inside the overflow popover.
682
747
  const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
683
748
  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
749
  case 'format':
695
750
  return (
696
751
  <div
@@ -747,7 +802,8 @@ export function PreviewToolbarControls() {
747
802
  return (
748
803
  <PreviewSelect
749
804
  key="transform"
750
- label="Transform"
805
+ label="Summarize"
806
+ labelTooltip={SUMMARIZE_TOOLTIP}
751
807
  value={s.activeTransformStyle}
752
808
  options={TRANSFORM_STYLE_OPTIONS}
753
809
  onChange={(v) => s.setSelectedTransformStyle(v)}
@@ -798,7 +854,6 @@ export function PreviewToolbarControls() {
798
854
  key="cover"
799
855
  className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
800
856
  >
801
- <label style={labelStyle}>Cover:</label>
802
857
  <label className="squisq-preview-checkbox">
803
858
  <input
804
859
  type="checkbox"
@@ -876,15 +931,17 @@ export function PreviewToolbarControls() {
876
931
  }
877
932
 
878
933
  /**
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.
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`.
882
937
  */
883
938
  export function PreviewModeSwitch() {
884
939
  const s = usePreviewSettings();
940
+ const { allowNarrate } = useEditorContext();
941
+ const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
885
942
  return (
886
943
  <div className="squisq-preview-seg" role="group" aria-label="Display mode">
887
- {DISPLAY_MODE_OPTIONS.map((opt) => {
944
+ {options.map((opt) => {
888
945
  const active = s.activeDisplayMode === opt.key;
889
946
  return (
890
947
  <button
@@ -902,6 +959,185 @@ export function PreviewModeSwitch() {
902
959
  );
903
960
  }
904
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
+
905
1141
  /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
906
1142
  * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
907
1143
  function AspectIcon({ w, h }: { w: number; h: number }) {
@@ -951,12 +1187,14 @@ export function PreviewFormatSwitch() {
951
1187
 
952
1188
  function PreviewSelect({
953
1189
  label,
1190
+ labelTooltip,
954
1191
  value,
955
1192
  options,
956
1193
  onChange,
957
1194
  compact,
958
1195
  }: {
959
1196
  label: string;
1197
+ labelTooltip?: string;
960
1198
  value: string;
961
1199
  options: { key: string; label: string }[];
962
1200
  onChange: (value: string) => void;
@@ -964,7 +1202,9 @@ function PreviewSelect({
964
1202
  }) {
965
1203
  return (
966
1204
  <div className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}>
967
- <label style={labelStyle}>{label}:</label>
1205
+ <label style={labelStyle} title={labelTooltip}>
1206
+ {label}:
1207
+ </label>
968
1208
  <select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle}>
969
1209
  {options.map((o) => (
970
1210
  <option key={o.key} value={o.key}>