@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.
- package/README.md +12 -3
- package/dist/index.d.ts +1304 -208
- package/dist/index.js +22838 -14494
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +2384 -408
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +48 -17
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +403 -270
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +276 -40
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +466 -93
- package/src/PreviewPanel.tsx +313 -77
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +34 -10
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +355 -134
- package/src/ThemeCustomizerPanel.tsx +30 -15
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +719 -238
- package/src/TransitionPicker.tsx +44 -5
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +167 -92
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +390 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +53 -15
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +38 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +312 -6
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +70 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/headingTransition.ts +85 -25
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +193 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/mediaReferences.ts +299 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +101 -25
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +11 -4
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +24 -9
- package/src/scene/layers/edgeGeometry.ts +31 -4
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +145 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +126 -28
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +10 -0
- package/src/scene/tools/SelectTool.ts +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +1566 -219
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +360 -0
- package/src/timeline/TimelineEditorWidget.tsx +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +84 -49
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -270
- package/src/diagram/useDiagramData.ts +0 -126
package/src/PreviewControls.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
if (
|
|
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
|
|
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 === '
|
|
169
|
-
if (v === '
|
|
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
|
|
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;
|
|
258
|
+
// no provider is mounted; document-scoped themes still remain available.
|
|
242
259
|
const custom = useCustomThemes();
|
|
243
|
-
const
|
|
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`
|
|
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
|
|
259
|
-
//
|
|
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
|
-
() =>
|
|
262
|
-
|
|
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
|
|
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
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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 (
|
|
477
|
-
*
|
|
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
|
|
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
|
|
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
|
|
553
|
-
const budget = available -
|
|
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
|
-
|
|
572
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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={() =>
|
|
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
|
-
<
|
|
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
|
|
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
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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
|
-
{
|
|
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)
|
|
791
|
-
*
|
|
792
|
-
*
|
|
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}
|
|
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}>
|