@bendyline/squisq-editor-react 1.1.0 → 1.2.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/dist/EditorContext.d.ts +6 -2
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +3 -1
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +11 -1
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +9 -7
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts +15 -0
- package/dist/ImageNodeView.d.ts.map +1 -0
- package/dist/ImageNodeView.js +52 -0
- package/dist/ImageNodeView.js.map +1 -0
- package/dist/PreviewControls.d.ts +41 -0
- package/dist/PreviewControls.d.ts.map +1 -0
- package/dist/PreviewControls.js +201 -0
- package/dist/PreviewControls.js.map +1 -0
- package/dist/PreviewPanel.d.ts +7 -7
- package/dist/PreviewPanel.d.ts.map +1 -1
- package/dist/PreviewPanel.js +183 -199
- package/dist/PreviewPanel.js.map +1 -1
- package/dist/Toolbar.d.ts +8 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +145 -20
- package/dist/Toolbar.js.map +1 -1
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +3 -1
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/__tests__/tiptapBridge.test.d.ts +2 -0
- package/dist/__tests__/tiptapBridge.test.d.ts.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +241 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +146 -5
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +5 -4
- package/src/EditorContext.tsx +8 -1
- package/src/EditorShell.tsx +71 -32
- package/src/ImageNodeView.tsx +70 -0
- package/src/PreviewControls.tsx +340 -0
- package/src/PreviewPanel.tsx +216 -287
- package/src/Toolbar.tsx +449 -17
- package/src/WysiwygEditor.tsx +3 -1
- package/src/__tests__/tiptapBridge.test.ts +290 -0
- package/src/index.ts +6 -0
- package/src/styles/editor.css +257 -16
- package/src/tiptapBridge.ts +164 -6
package/src/EditorContext.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
useEffect,
|
|
18
18
|
type ReactNode,
|
|
19
19
|
} from 'react';
|
|
20
|
-
import type { Doc } from '@bendyline/squisq/schemas';
|
|
20
|
+
import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
|
|
21
21
|
import type { MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
22
22
|
import { parseMarkdown, stringifyMarkdown } from '@bendyline/squisq/markdown';
|
|
23
23
|
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
@@ -74,6 +74,8 @@ export interface EditorContextValue extends EditorState, EditorActions {
|
|
|
74
74
|
tiptapEditor: TiptapEditor | null;
|
|
75
75
|
/** The live Monaco editor instance (null when Raw is not mounted) */
|
|
76
76
|
monacoEditor: MonacoEditor | null;
|
|
77
|
+
/** MediaProvider for resolving image URLs in the WYSIWYG editor */
|
|
78
|
+
mediaProvider: MediaProvider | null;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
// ─── Context ─────────────────────────────────────────────
|
|
@@ -103,6 +105,8 @@ export interface EditorProviderProps {
|
|
|
103
105
|
articleId?: string;
|
|
104
106
|
/** Color theme */
|
|
105
107
|
theme?: EditorTheme;
|
|
108
|
+
/** MediaProvider for resolving image URLs */
|
|
109
|
+
mediaProvider?: MediaProvider | null;
|
|
106
110
|
children: ReactNode;
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -115,6 +119,7 @@ export function EditorProvider({
|
|
|
115
119
|
initialView = 'raw',
|
|
116
120
|
articleId = 'untitled',
|
|
117
121
|
theme: initialTheme = 'light',
|
|
122
|
+
mediaProvider = null,
|
|
118
123
|
children,
|
|
119
124
|
}: EditorProviderProps) {
|
|
120
125
|
const [markdownSource, setMarkdownSourceRaw] = useState(initialMarkdown);
|
|
@@ -273,6 +278,7 @@ export function EditorProvider({
|
|
|
273
278
|
theme,
|
|
274
279
|
tiptapEditor,
|
|
275
280
|
monacoEditor,
|
|
281
|
+
mediaProvider,
|
|
276
282
|
setMarkdownSource,
|
|
277
283
|
setMarkdownDoc,
|
|
278
284
|
setActiveView,
|
|
@@ -292,6 +298,7 @@ export function EditorProvider({
|
|
|
292
298
|
theme,
|
|
293
299
|
tiptapEditor,
|
|
294
300
|
monacoEditor,
|
|
301
|
+
mediaProvider,
|
|
295
302
|
setMarkdownSource,
|
|
296
303
|
setMarkdownDoc,
|
|
297
304
|
setActiveView,
|
package/src/EditorShell.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { StatusBar } from './StatusBar';
|
|
|
13
13
|
import { RawEditor } from './RawEditor';
|
|
14
14
|
import { WysiwygEditor } from './WysiwygEditor';
|
|
15
15
|
import { PreviewPanel } from './PreviewPanel';
|
|
16
|
+
import { PreviewSettingsProvider, PreviewToolbarControls } from './PreviewControls';
|
|
16
17
|
import { MediaBin } from './MediaBin';
|
|
17
18
|
import { DropZoneOverlay } from './DropZoneOverlay';
|
|
18
19
|
import { useFileDrop, type DropTarget } from './hooks/useFileDrop';
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
processTextFiles,
|
|
24
25
|
} from './utils/dropUtils';
|
|
25
26
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
27
|
+
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
28
|
+
import type { ReactNode } from 'react';
|
|
26
29
|
|
|
27
30
|
export type { EditorTheme } from './EditorContext';
|
|
28
31
|
|
|
@@ -46,8 +49,16 @@ export interface EditorShellProps {
|
|
|
46
49
|
height?: string;
|
|
47
50
|
/** Optional MediaProvider for the Files panel. When set (even to null), a Files toggle appears in the toolbar. */
|
|
48
51
|
mediaProvider?: MediaProvider | null;
|
|
52
|
+
/** Optional ContentContainer for audio mapping (MP3 discovery + timing.json reading). */
|
|
53
|
+
container?: ContentContainer | null;
|
|
49
54
|
/** Show the Files toggle in the toolbar. Defaults to true when mediaProvider is passed. */
|
|
50
55
|
showFilesToggle?: boolean;
|
|
56
|
+
/** Content rendered at the left edge of the toolbar, before the view tabs. */
|
|
57
|
+
toolbarSlotLeft?: ReactNode;
|
|
58
|
+
/** Content rendered after the formatting controls (in the middle area of the toolbar). */
|
|
59
|
+
toolbarSlotAfterActions?: ReactNode;
|
|
60
|
+
/** Content rendered at the rightmost end of the toolbar, after all other elements. */
|
|
61
|
+
toolbarSlotRight?: ReactNode;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/**
|
|
@@ -64,7 +75,11 @@ export function EditorShell({
|
|
|
64
75
|
className,
|
|
65
76
|
height = '100vh',
|
|
66
77
|
mediaProvider,
|
|
78
|
+
container,
|
|
67
79
|
showFilesToggle,
|
|
80
|
+
toolbarSlotLeft,
|
|
81
|
+
toolbarSlotAfterActions,
|
|
82
|
+
toolbarSlotRight,
|
|
68
83
|
}: EditorShellProps) {
|
|
69
84
|
// Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
|
|
70
85
|
const filesToggleEnabled = showFilesToggle ?? mediaProvider !== undefined;
|
|
@@ -75,6 +90,7 @@ export function EditorShell({
|
|
|
75
90
|
initialView={initialView}
|
|
76
91
|
articleId={articleId}
|
|
77
92
|
theme={theme}
|
|
93
|
+
mediaProvider={mediaProvider}
|
|
78
94
|
>
|
|
79
95
|
<EditorShellInner
|
|
80
96
|
basePath={basePath}
|
|
@@ -82,7 +98,11 @@ export function EditorShell({
|
|
|
82
98
|
className={className}
|
|
83
99
|
height={height}
|
|
84
100
|
mediaProvider={mediaProvider ?? null}
|
|
101
|
+
container={container}
|
|
85
102
|
filesToggleEnabled={filesToggleEnabled}
|
|
103
|
+
toolbarSlotLeft={toolbarSlotLeft}
|
|
104
|
+
toolbarSlotAfterActions={toolbarSlotAfterActions}
|
|
105
|
+
toolbarSlotRight={toolbarSlotRight}
|
|
86
106
|
/>
|
|
87
107
|
</EditorProvider>
|
|
88
108
|
);
|
|
@@ -94,7 +114,11 @@ interface EditorShellInnerProps {
|
|
|
94
114
|
className?: string;
|
|
95
115
|
height: string;
|
|
96
116
|
mediaProvider: MediaProvider | null;
|
|
117
|
+
container?: ContentContainer | null;
|
|
97
118
|
filesToggleEnabled: boolean;
|
|
119
|
+
toolbarSlotLeft?: ReactNode;
|
|
120
|
+
toolbarSlotAfterActions?: ReactNode;
|
|
121
|
+
toolbarSlotRight?: ReactNode;
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
function EditorShellInner({
|
|
@@ -103,9 +127,14 @@ function EditorShellInner({
|
|
|
103
127
|
className,
|
|
104
128
|
height,
|
|
105
129
|
mediaProvider,
|
|
130
|
+
container,
|
|
106
131
|
filesToggleEnabled,
|
|
132
|
+
toolbarSlotLeft,
|
|
133
|
+
toolbarSlotAfterActions,
|
|
134
|
+
toolbarSlotRight,
|
|
107
135
|
}: EditorShellInnerProps) {
|
|
108
|
-
const { activeView, markdownSource, theme, insertAtCursor, replaceAll } = useEditorContext();
|
|
136
|
+
const { activeView, markdownSource, doc, theme, insertAtCursor, replaceAll } = useEditorContext();
|
|
137
|
+
const isPreview = activeView === 'preview';
|
|
109
138
|
const [showFiles, setShowFiles] = useState(false);
|
|
110
139
|
const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
|
|
111
140
|
const isDark = theme === 'dark';
|
|
@@ -193,41 +222,51 @@ function EditorShellInner({
|
|
|
193
222
|
}}
|
|
194
223
|
{...containerProps}
|
|
195
224
|
>
|
|
196
|
-
{
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
{activeView === 'wysiwyg' && <WysiwygEditor />}
|
|
212
|
-
{activeView === 'preview' && <PreviewPanel basePath={basePath} />}
|
|
225
|
+
<PreviewSettingsProvider doc={doc}>
|
|
226
|
+
{/* Header: Toolbar (includes view tabs + preview controls) */}
|
|
227
|
+
<div className="squisq-editor-header">
|
|
228
|
+
<Toolbar
|
|
229
|
+
showFiles={showFiles}
|
|
230
|
+
onToggleFiles={filesToggleEnabled ? handleToggleFiles : undefined}
|
|
231
|
+
slotLeft={toolbarSlotLeft}
|
|
232
|
+
slotAfterActions={
|
|
233
|
+
<>
|
|
234
|
+
{toolbarSlotAfterActions}
|
|
235
|
+
{isPreview && <PreviewToolbarControls />}
|
|
236
|
+
</>
|
|
237
|
+
}
|
|
238
|
+
slotRight={toolbarSlotRight}
|
|
239
|
+
/>
|
|
213
240
|
</div>
|
|
214
241
|
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
242
|
+
{/* Main content area */}
|
|
243
|
+
<div
|
|
244
|
+
className="squisq-editor-content"
|
|
245
|
+
style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex' }}
|
|
246
|
+
>
|
|
247
|
+
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
|
248
|
+
{activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
|
|
249
|
+
{activeView === 'wysiwyg' && <WysiwygEditor />}
|
|
250
|
+
{isPreview && <PreviewPanel basePath={basePath} container={container} />}
|
|
251
|
+
</div>
|
|
218
252
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
253
|
+
{showFiles && (
|
|
254
|
+
<MediaBin mediaProvider={mediaProvider} isDark={isDark} refreshKey={mediaRefreshKey} />
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Drop zone overlay */}
|
|
258
|
+
{isDragging && (
|
|
259
|
+
<DropZoneOverlay
|
|
260
|
+
dragContentType={dragContentType}
|
|
261
|
+
zoneProps={zoneProps}
|
|
262
|
+
hasMediaProvider={mediaProvider !== null}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
228
266
|
|
|
229
|
-
|
|
230
|
-
|
|
267
|
+
{/* Status bar */}
|
|
268
|
+
<StatusBar />
|
|
269
|
+
</PreviewSettingsProvider>
|
|
231
270
|
</div>
|
|
232
271
|
);
|
|
233
272
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageNodeView — Custom Tiptap NodeView for images.
|
|
3
|
+
*
|
|
4
|
+
* Resolves image `src` attributes through the EditorContext's MediaProvider,
|
|
5
|
+
* converting relative paths (e.g. "images/hero.jpg") to displayable blob URLs.
|
|
6
|
+
*
|
|
7
|
+
* The ProseMirror node retains the original relative path so markdown roundtrip
|
|
8
|
+
* is preserved — only the rendered DOM uses the resolved URL.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
13
|
+
import type { NodeViewProps } from '@tiptap/react';
|
|
14
|
+
import Image from '@tiptap/extension-image';
|
|
15
|
+
import { useEditorContext } from './EditorContext';
|
|
16
|
+
|
|
17
|
+
function ImageComponent({ node }: NodeViewProps) {
|
|
18
|
+
const { src, alt, title } = node.attrs as { src: string; alt: string; title: string };
|
|
19
|
+
const { mediaProvider } = useEditorContext();
|
|
20
|
+
const [resolvedSrc, setResolvedSrc] = useState(src);
|
|
21
|
+
|
|
22
|
+
const isRelative =
|
|
23
|
+
src &&
|
|
24
|
+
!src.startsWith('blob:') &&
|
|
25
|
+
!src.startsWith('http') &&
|
|
26
|
+
!src.startsWith('data:') &&
|
|
27
|
+
!src.startsWith('/');
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!mediaProvider || !isRelative) {
|
|
31
|
+
setResolvedSrc(src);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
mediaProvider.resolveUrl(src).then(
|
|
37
|
+
(resolved) => {
|
|
38
|
+
if (!cancelled) setResolvedSrc(resolved);
|
|
39
|
+
},
|
|
40
|
+
() => {
|
|
41
|
+
if (!cancelled) setResolvedSrc(src);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
};
|
|
48
|
+
}, [src, mediaProvider, isRelative]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<NodeViewWrapper as="figure" style={{ margin: '0.5em 0' }}>
|
|
52
|
+
<img
|
|
53
|
+
src={resolvedSrc}
|
|
54
|
+
alt={alt || ''}
|
|
55
|
+
title={title || undefined}
|
|
56
|
+
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
|
57
|
+
/>
|
|
58
|
+
</NodeViewWrapper>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Image extension with a custom React NodeView that resolves URLs
|
|
64
|
+
* through the EditorContext's MediaProvider.
|
|
65
|
+
*/
|
|
66
|
+
export const ImageWithMediaProvider = Image.extend({
|
|
67
|
+
addNodeView() {
|
|
68
|
+
return ReactNodeViewRenderer(ImageComponent);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
2
|
+
/**
|
|
3
|
+
* PreviewControls
|
|
4
|
+
*
|
|
5
|
+
* Shared context and inline toolbar component for preview settings
|
|
6
|
+
* (viewport format, display mode, theme, transform, caption style).
|
|
7
|
+
*
|
|
8
|
+
* The context is provided by EditorShell and consumed by both:
|
|
9
|
+
* - PreviewControls (toolbar dropdowns, rendered in the main toolbar)
|
|
10
|
+
* - PreviewPanel (the actual player, which reads the selected values)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
|
|
14
|
+
import type { ReactNode } from 'react';
|
|
15
|
+
import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
|
|
16
|
+
import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
|
|
17
|
+
import { VIEWPORT_PRESETS, getThemeSummaries, resolveTheme } from '@bendyline/squisq/schemas';
|
|
18
|
+
import type { Theme } from '@bendyline/squisq/schemas';
|
|
19
|
+
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
20
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
21
|
+
|
|
22
|
+
// ── Context ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface PreviewSettings {
|
|
25
|
+
activePreset: ViewportPreset;
|
|
26
|
+
setSelectedPreset: (preset: ViewportPreset | null) => void;
|
|
27
|
+
activeViewport: ViewportConfig;
|
|
28
|
+
activeDisplayMode: DisplayMode;
|
|
29
|
+
setSelectedDisplayMode: (mode: DisplayMode | null) => void;
|
|
30
|
+
activeThemeId: string;
|
|
31
|
+
setSelectedThemeId: (id: string | null) => void;
|
|
32
|
+
activeTheme: Theme;
|
|
33
|
+
activeTransformStyle: string;
|
|
34
|
+
setSelectedTransformStyle: (id: string | null) => void;
|
|
35
|
+
activeCaptionStyle: CaptionStyle;
|
|
36
|
+
setSelectedCaptionStyle: (style: CaptionStyle | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PreviewSettingsContext = createContext<PreviewSettings | null>(null);
|
|
40
|
+
|
|
41
|
+
export function usePreviewSettings(): PreviewSettings {
|
|
42
|
+
const ctx = useContext(PreviewSettingsContext);
|
|
43
|
+
if (!ctx) throw new Error('usePreviewSettings must be used within PreviewSettingsProvider');
|
|
44
|
+
return ctx;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Frontmatter resolvers ────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function resolveRenderAs(value: unknown): ViewportPreset | null {
|
|
50
|
+
if (typeof value !== 'string') return null;
|
|
51
|
+
const v = value.trim().toLowerCase();
|
|
52
|
+
const mapping: Record<string, ViewportPreset> = {
|
|
53
|
+
landscape: 'landscape',
|
|
54
|
+
'16:9': 'landscape',
|
|
55
|
+
widescreen: 'landscape',
|
|
56
|
+
portrait: 'portrait',
|
|
57
|
+
'9:16': 'portrait',
|
|
58
|
+
vertical: 'portrait',
|
|
59
|
+
stories: 'portrait',
|
|
60
|
+
square: 'square',
|
|
61
|
+
'1:1': 'square',
|
|
62
|
+
standard: 'standard',
|
|
63
|
+
'4:3': 'standard',
|
|
64
|
+
};
|
|
65
|
+
return mapping[v] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveDisplayMode(value: unknown): DisplayMode | null {
|
|
69
|
+
if (typeof value !== 'string') return null;
|
|
70
|
+
const v = value.trim().toLowerCase();
|
|
71
|
+
if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
|
|
72
|
+
if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
|
|
73
|
+
if (v === 'document' || v === 'scroll' || v === 'page') return 'linear';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const VALID_THEME_IDS = new Set(getThemeSummaries().map((s) => s.id));
|
|
78
|
+
|
|
79
|
+
function resolveFrontmatterTheme(value: unknown): string | null {
|
|
80
|
+
if (typeof value !== 'string') return null;
|
|
81
|
+
const v = value.trim().toLowerCase();
|
|
82
|
+
if (VALID_THEME_IDS.has(v)) return v;
|
|
83
|
+
const normalized = v.replace(/\s+/g, '-');
|
|
84
|
+
if (VALID_THEME_IDS.has(normalized)) return normalized;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const VALID_TRANSFORM_IDS = new Set(getTransformStyleSummaries().map((s) => s.id));
|
|
89
|
+
|
|
90
|
+
function resolveFrontmatterTransform(value: unknown): string | null {
|
|
91
|
+
if (typeof value !== 'string') return null;
|
|
92
|
+
const v = value.trim().toLowerCase();
|
|
93
|
+
if (VALID_TRANSFORM_IDS.has(v)) return v;
|
|
94
|
+
const normalized = v.replace(/\s+/g, '-');
|
|
95
|
+
if (VALID_TRANSFORM_IDS.has(normalized)) return normalized;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveFrontmatterCaptionStyle(value: unknown): CaptionStyle | null {
|
|
100
|
+
if (typeof value !== 'string') return null;
|
|
101
|
+
const v = value.trim().toLowerCase();
|
|
102
|
+
if (v === 'standard' || v === 'social') return v;
|
|
103
|
+
if (v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Provider ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface PreviewSettingsProviderProps {
|
|
110
|
+
doc: Doc | null;
|
|
111
|
+
children: ReactNode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function PreviewSettingsProvider({ doc, children }: PreviewSettingsProviderProps) {
|
|
115
|
+
const frontmatter = doc?.frontmatter;
|
|
116
|
+
|
|
117
|
+
// Viewport
|
|
118
|
+
const fmPreset = useMemo(
|
|
119
|
+
() => resolveRenderAs(frontmatter?.['document-render-as']),
|
|
120
|
+
[frontmatter],
|
|
121
|
+
);
|
|
122
|
+
const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
|
|
123
|
+
useEffect(() => setSelectedPreset(null), [fmPreset]);
|
|
124
|
+
const activePreset = selectedPreset ?? fmPreset ?? 'landscape';
|
|
125
|
+
const activeViewport = VIEWPORT_PRESETS[activePreset];
|
|
126
|
+
|
|
127
|
+
// Display mode
|
|
128
|
+
const fmMode = useMemo(() => resolveDisplayMode(frontmatter?.['display-mode']), [frontmatter]);
|
|
129
|
+
const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
|
|
130
|
+
useEffect(() => setSelectedDisplayMode(null), [fmMode]);
|
|
131
|
+
const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
|
|
132
|
+
|
|
133
|
+
// Theme
|
|
134
|
+
const fmTheme = useMemo(() => resolveFrontmatterTheme(frontmatter?.['theme']), [frontmatter]);
|
|
135
|
+
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
|
136
|
+
useEffect(() => setSelectedThemeId(null), [fmTheme]);
|
|
137
|
+
const activeThemeId = selectedThemeId ?? fmTheme ?? 'standard';
|
|
138
|
+
const activeTheme = useMemo(() => resolveTheme(activeThemeId), [activeThemeId]);
|
|
139
|
+
|
|
140
|
+
// Transform
|
|
141
|
+
const fmTransform = useMemo(
|
|
142
|
+
() => resolveFrontmatterTransform(frontmatter?.['transform-style']),
|
|
143
|
+
[frontmatter],
|
|
144
|
+
);
|
|
145
|
+
const [selectedTransformStyle, setSelectedTransformStyle] = useState<string | null>(null);
|
|
146
|
+
useEffect(() => setSelectedTransformStyle(null), [fmTransform]);
|
|
147
|
+
const activeTransformStyle = selectedTransformStyle ?? fmTransform ?? '';
|
|
148
|
+
|
|
149
|
+
// Caption style
|
|
150
|
+
const fmCaption = useMemo(
|
|
151
|
+
() => resolveFrontmatterCaptionStyle(frontmatter?.['caption-style']),
|
|
152
|
+
[frontmatter],
|
|
153
|
+
);
|
|
154
|
+
const [selectedCaptionStyle, setSelectedCaptionStyle] = useState<CaptionStyle | null>(null);
|
|
155
|
+
useEffect(() => setSelectedCaptionStyle(null), [fmCaption]);
|
|
156
|
+
const activeCaptionStyle = selectedCaptionStyle ?? fmCaption ?? 'standard';
|
|
157
|
+
|
|
158
|
+
const value = useMemo<PreviewSettings>(
|
|
159
|
+
() => ({
|
|
160
|
+
activePreset,
|
|
161
|
+
setSelectedPreset,
|
|
162
|
+
activeViewport,
|
|
163
|
+
activeDisplayMode,
|
|
164
|
+
setSelectedDisplayMode,
|
|
165
|
+
activeThemeId,
|
|
166
|
+
setSelectedThemeId,
|
|
167
|
+
activeTheme,
|
|
168
|
+
activeTransformStyle,
|
|
169
|
+
setSelectedTransformStyle,
|
|
170
|
+
activeCaptionStyle,
|
|
171
|
+
setSelectedCaptionStyle,
|
|
172
|
+
}),
|
|
173
|
+
[
|
|
174
|
+
activePreset,
|
|
175
|
+
activeViewport,
|
|
176
|
+
activeDisplayMode,
|
|
177
|
+
activeThemeId,
|
|
178
|
+
activeTheme,
|
|
179
|
+
activeTransformStyle,
|
|
180
|
+
activeCaptionStyle,
|
|
181
|
+
],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<PreviewSettingsContext.Provider value={value}>{children}</PreviewSettingsContext.Provider>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Dropdown options ─────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
|
|
192
|
+
{ key: 'landscape', label: '16:9' },
|
|
193
|
+
{ key: 'portrait', label: '9:16' },
|
|
194
|
+
{ key: 'square', label: '1:1' },
|
|
195
|
+
{ key: 'standard', label: '4:3' },
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
|
|
199
|
+
{ key: 'video', label: 'Video' },
|
|
200
|
+
{ key: 'slideshow', label: 'Slideshow' },
|
|
201
|
+
{ key: 'linear', label: 'Document' },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const THEME_OPTIONS = getThemeSummaries().map((s) => ({ key: s.id, label: s.name }));
|
|
205
|
+
|
|
206
|
+
const TRANSFORM_STYLE_OPTIONS = [
|
|
207
|
+
{ key: '', label: 'None' },
|
|
208
|
+
...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const CAPTION_STYLE_OPTIONS: { key: CaptionStyle; label: string }[] = [
|
|
212
|
+
{ key: 'standard', label: 'Standard' },
|
|
213
|
+
{ key: 'social', label: 'Social' },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
// ── Shared styles ────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
const labelStyle: React.CSSProperties = {
|
|
219
|
+
color: 'var(--squisq-text-muted, #6b7280)',
|
|
220
|
+
fontSize: '12px',
|
|
221
|
+
whiteSpace: 'nowrap',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const selectStyle: React.CSSProperties = {
|
|
225
|
+
padding: '2px 6px',
|
|
226
|
+
borderRadius: '4px',
|
|
227
|
+
border: '1px solid var(--squisq-border, #d1d5db)',
|
|
228
|
+
background: 'var(--squisq-input-bg, #fff)',
|
|
229
|
+
color: 'var(--squisq-text, #1f2937)',
|
|
230
|
+
fontSize: '12px',
|
|
231
|
+
cursor: 'pointer',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ── Toolbar Controls Component ───────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Inline preview controls rendered in the main toolbar row.
|
|
238
|
+
* Reads from PreviewSettingsContext.
|
|
239
|
+
*/
|
|
240
|
+
export function PreviewToolbarControls() {
|
|
241
|
+
const s = usePreviewSettings();
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div
|
|
245
|
+
style={{
|
|
246
|
+
display: 'flex',
|
|
247
|
+
alignItems: 'center',
|
|
248
|
+
gap: '6px',
|
|
249
|
+
flexWrap: 'wrap',
|
|
250
|
+
padding: '2px 0 2px 9px',
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<label style={labelStyle}>Format:</label>
|
|
254
|
+
<select
|
|
255
|
+
value={s.activePreset}
|
|
256
|
+
onChange={(e) => s.setSelectedPreset(e.target.value as ViewportPreset)}
|
|
257
|
+
style={selectStyle}
|
|
258
|
+
>
|
|
259
|
+
{VIEWPORT_OPTIONS.map((o) => (
|
|
260
|
+
<option key={o.key} value={o.key}>
|
|
261
|
+
{o.label}
|
|
262
|
+
</option>
|
|
263
|
+
))}
|
|
264
|
+
</select>
|
|
265
|
+
|
|
266
|
+
<Divider />
|
|
267
|
+
|
|
268
|
+
<label style={labelStyle}>Mode:</label>
|
|
269
|
+
<select
|
|
270
|
+
value={s.activeDisplayMode}
|
|
271
|
+
onChange={(e) => s.setSelectedDisplayMode(e.target.value as DisplayMode)}
|
|
272
|
+
style={selectStyle}
|
|
273
|
+
>
|
|
274
|
+
{DISPLAY_MODE_OPTIONS.map((o) => (
|
|
275
|
+
<option key={o.key} value={o.key}>
|
|
276
|
+
{o.label}
|
|
277
|
+
</option>
|
|
278
|
+
))}
|
|
279
|
+
</select>
|
|
280
|
+
|
|
281
|
+
<Divider />
|
|
282
|
+
|
|
283
|
+
<label style={labelStyle}>Theme:</label>
|
|
284
|
+
<select
|
|
285
|
+
value={s.activeThemeId}
|
|
286
|
+
onChange={(e) => s.setSelectedThemeId(e.target.value)}
|
|
287
|
+
style={selectStyle}
|
|
288
|
+
>
|
|
289
|
+
{THEME_OPTIONS.map((o) => (
|
|
290
|
+
<option key={o.key} value={o.key}>
|
|
291
|
+
{o.label}
|
|
292
|
+
</option>
|
|
293
|
+
))}
|
|
294
|
+
</select>
|
|
295
|
+
|
|
296
|
+
<Divider />
|
|
297
|
+
|
|
298
|
+
<label style={labelStyle}>Transform:</label>
|
|
299
|
+
<select
|
|
300
|
+
value={s.activeTransformStyle}
|
|
301
|
+
onChange={(e) => s.setSelectedTransformStyle(e.target.value)}
|
|
302
|
+
style={selectStyle}
|
|
303
|
+
>
|
|
304
|
+
{TRANSFORM_STYLE_OPTIONS.map((o) => (
|
|
305
|
+
<option key={o.key} value={o.key}>
|
|
306
|
+
{o.label}
|
|
307
|
+
</option>
|
|
308
|
+
))}
|
|
309
|
+
</select>
|
|
310
|
+
|
|
311
|
+
<Divider />
|
|
312
|
+
|
|
313
|
+
<label style={labelStyle}>Captions:</label>
|
|
314
|
+
<select
|
|
315
|
+
value={s.activeCaptionStyle}
|
|
316
|
+
onChange={(e) => s.setSelectedCaptionStyle(e.target.value as CaptionStyle)}
|
|
317
|
+
style={selectStyle}
|
|
318
|
+
>
|
|
319
|
+
{CAPTION_STYLE_OPTIONS.map((o) => (
|
|
320
|
+
<option key={o.key} value={o.key}>
|
|
321
|
+
{o.label}
|
|
322
|
+
</option>
|
|
323
|
+
))}
|
|
324
|
+
</select>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function Divider() {
|
|
330
|
+
return (
|
|
331
|
+
<span
|
|
332
|
+
style={{
|
|
333
|
+
width: '1px',
|
|
334
|
+
height: '16px',
|
|
335
|
+
background: 'var(--squisq-border, #d1d5db)',
|
|
336
|
+
margin: '0 2px',
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
}
|