@bendyline/squisq-editor-react 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DropZoneOverlay.d.ts +24 -0
- package/dist/DropZoneOverlay.d.ts.map +1 -0
- package/dist/DropZoneOverlay.js +53 -0
- package/dist/DropZoneOverlay.js.map +1 -0
- package/dist/EditorContext.d.ts +10 -2
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +49 -1
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +16 -1
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +55 -8
- 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/MediaBin.d.ts +18 -0
- package/dist/MediaBin.d.ts.map +1 -0
- package/dist/MediaBin.js +141 -0
- package/dist/MediaBin.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 +12 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +4 -12
- 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/hooks/useFileDrop.d.ts +41 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -0
- package/dist/hooks/useFileDrop.js +167 -0
- package/dist/hooks/useFileDrop.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +4 -5
- package/dist/tiptapBridge.js.map +1 -1
- package/dist/utils/dropUtils.d.ts +36 -0
- package/dist/utils/dropUtils.d.ts.map +1 -0
- package/dist/utils/dropUtils.js +71 -0
- package/dist/utils/dropUtils.js.map +1 -0
- package/package.json +5 -3
- package/src/DropZoneOverlay.tsx +137 -0
- package/src/EditorContext.tsx +64 -1
- package/src/EditorShell.tsx +153 -20
- package/src/ImageNodeView.tsx +70 -0
- package/src/MediaBin.tsx +223 -0
- package/src/PreviewControls.tsx +340 -0
- package/src/PreviewPanel.tsx +216 -287
- package/src/Toolbar.tsx +40 -3
- package/src/WysiwygEditor.tsx +3 -1
- package/src/hooks/useFileDrop.ts +226 -0
- package/src/index.ts +29 -0
- package/src/styles/editor.css +349 -8
- package/src/tiptapBridge.ts +5 -6
- package/src/utils/dropUtils.ts +88 -0
package/src/MediaBin.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaBin
|
|
3
|
+
*
|
|
4
|
+
* Toggleable side panel that displays files associated with the current
|
|
5
|
+
* content. Shows image thumbnails, icons for other types, file sizes,
|
|
6
|
+
* and provides an upload button to add new media.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
10
|
+
import type { MediaProvider, MediaEntry } from '@bendyline/squisq/schemas';
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// Types
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
export interface MediaBinProps {
|
|
17
|
+
/** The active MediaProvider (null when no media context is available) */
|
|
18
|
+
mediaProvider: MediaProvider | null;
|
|
19
|
+
/** Whether the editor is in dark mode */
|
|
20
|
+
isDark: boolean;
|
|
21
|
+
/** Incremented externally to signal a re-scan of the media list */
|
|
22
|
+
refreshKey?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// Helpers
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
function formatSize(bytes: number): string {
|
|
30
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
31
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
32
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function iconForMime(mimeType: string): string {
|
|
36
|
+
if (mimeType.startsWith('image/')) return '\u{1F5BC}';
|
|
37
|
+
if (mimeType.startsWith('audio/')) return '\u{1F50A}';
|
|
38
|
+
if (mimeType.startsWith('video/')) return '\u{1F3AC}';
|
|
39
|
+
if (mimeType.includes('json')) return '{ }';
|
|
40
|
+
if (mimeType.includes('xml') || mimeType.includes('ssml')) return '\u{2329}/\u{232A}';
|
|
41
|
+
return '\u{1F4C4}';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isImageMime(mimeType: string): boolean {
|
|
45
|
+
return mimeType.startsWith('image/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================
|
|
49
|
+
// Component
|
|
50
|
+
// ============================================
|
|
51
|
+
|
|
52
|
+
export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
|
|
53
|
+
const [entries, setEntries] = useState<MediaEntry[]>([]);
|
|
54
|
+
const [thumbUrls, setThumbUrls] = useState<Record<string, string>>({});
|
|
55
|
+
const [loading, setLoading] = useState(false);
|
|
56
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
|
|
58
|
+
// Scan media entries whenever the provider changes or refreshKey bumps
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!mediaProvider) {
|
|
61
|
+
setEntries([]);
|
|
62
|
+
setThumbUrls({});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cancelled = false;
|
|
67
|
+
|
|
68
|
+
async function scan() {
|
|
69
|
+
setLoading(true);
|
|
70
|
+
try {
|
|
71
|
+
const list = await mediaProvider!.listMedia();
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
|
|
74
|
+
list.sort((a, b) => {
|
|
75
|
+
const aImg = isImageMime(a.mimeType) ? 0 : 1;
|
|
76
|
+
const bImg = isImageMime(b.mimeType) ? 0 : 1;
|
|
77
|
+
if (aImg !== bImg) return aImg - bImg;
|
|
78
|
+
return a.name.localeCompare(b.name);
|
|
79
|
+
});
|
|
80
|
+
setEntries(list);
|
|
81
|
+
|
|
82
|
+
const urls: Record<string, string> = {};
|
|
83
|
+
for (const entry of list) {
|
|
84
|
+
if (isImageMime(entry.mimeType)) {
|
|
85
|
+
try {
|
|
86
|
+
urls[entry.name] = await mediaProvider!.resolveUrl(entry.name);
|
|
87
|
+
} catch {
|
|
88
|
+
// skip failed resolve
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!cancelled) setThumbUrls(urls);
|
|
93
|
+
} finally {
|
|
94
|
+
if (!cancelled) setLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
scan();
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [mediaProvider, refreshKey]);
|
|
103
|
+
|
|
104
|
+
// ---- Upload ----
|
|
105
|
+
|
|
106
|
+
const handleUploadClick = useCallback(() => {
|
|
107
|
+
fileInputRef.current?.click();
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const handleFileChange = useCallback(
|
|
111
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
112
|
+
const files = e.target.files;
|
|
113
|
+
if (!files || !mediaProvider) return;
|
|
114
|
+
|
|
115
|
+
setLoading(true);
|
|
116
|
+
try {
|
|
117
|
+
for (let i = 0; i < files.length; i++) {
|
|
118
|
+
const file = files[i];
|
|
119
|
+
const buffer = await file.arrayBuffer();
|
|
120
|
+
const mimeType = file.type || 'application/octet-stream';
|
|
121
|
+
await mediaProvider.addMedia(file.name, buffer, mimeType);
|
|
122
|
+
}
|
|
123
|
+
// Re-scan
|
|
124
|
+
const list = await mediaProvider.listMedia();
|
|
125
|
+
list.sort((a, b) => {
|
|
126
|
+
const aImg = isImageMime(a.mimeType) ? 0 : 1;
|
|
127
|
+
const bImg = isImageMime(b.mimeType) ? 0 : 1;
|
|
128
|
+
if (aImg !== bImg) return aImg - bImg;
|
|
129
|
+
return a.name.localeCompare(b.name);
|
|
130
|
+
});
|
|
131
|
+
setEntries(list);
|
|
132
|
+
|
|
133
|
+
const urls: Record<string, string> = {};
|
|
134
|
+
for (const entry of list) {
|
|
135
|
+
if (isImageMime(entry.mimeType)) {
|
|
136
|
+
try {
|
|
137
|
+
urls[entry.name] = await mediaProvider.resolveUrl(entry.name);
|
|
138
|
+
} catch {
|
|
139
|
+
// skip
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
setThumbUrls(urls);
|
|
144
|
+
} finally {
|
|
145
|
+
setLoading(false);
|
|
146
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[mediaProvider],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={`squisq-media-bin${isDark ? ' squisq-media-bin--dark' : ''}`}>
|
|
154
|
+
{/* Header */}
|
|
155
|
+
<div className="squisq-media-bin-header">
|
|
156
|
+
<span className="squisq-media-bin-title">
|
|
157
|
+
Files {entries.length > 0 && `(${entries.length})`}
|
|
158
|
+
</span>
|
|
159
|
+
|
|
160
|
+
<button
|
|
161
|
+
className="squisq-media-bin-upload"
|
|
162
|
+
onClick={handleUploadClick}
|
|
163
|
+
disabled={!mediaProvider || loading}
|
|
164
|
+
title={
|
|
165
|
+
mediaProvider ? 'Upload files' : 'Load a content zip or select a storage slot first'
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
+ Upload
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* File list */}
|
|
173
|
+
<div className="squisq-media-bin-list">
|
|
174
|
+
{!mediaProvider && (
|
|
175
|
+
<div className="squisq-media-bin-empty">
|
|
176
|
+
No media context.
|
|
177
|
+
<br />
|
|
178
|
+
Load a content zip or select a storage slot.
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{mediaProvider && entries.length === 0 && !loading && (
|
|
183
|
+
<div className="squisq-media-bin-empty">No files yet.</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{entries.map((entry) => {
|
|
187
|
+
const thumb = thumbUrls[entry.name];
|
|
188
|
+
const basename = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
key={entry.name}
|
|
193
|
+
className="squisq-media-bin-item"
|
|
194
|
+
title={`${entry.name}\n${entry.mimeType}\n${formatSize(entry.size)}`}
|
|
195
|
+
>
|
|
196
|
+
{/* Thumbnail or icon */}
|
|
197
|
+
{thumb ? (
|
|
198
|
+
<img src={thumb} alt={basename} className="squisq-media-bin-thumb" />
|
|
199
|
+
) : (
|
|
200
|
+
<span className="squisq-media-bin-icon">{iconForMime(entry.mimeType)}</span>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Name + size */}
|
|
204
|
+
<div className="squisq-media-bin-meta">
|
|
205
|
+
<div className="squisq-media-bin-name">{basename}</div>
|
|
206
|
+
<div className="squisq-media-bin-size">{formatSize(entry.size)}</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Hidden file input */}
|
|
214
|
+
<input
|
|
215
|
+
ref={fileInputRef}
|
|
216
|
+
type="file"
|
|
217
|
+
multiple
|
|
218
|
+
style={{ display: 'none' }}
|
|
219
|
+
onChange={handleFileChange}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -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',
|
|
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
|
+
}
|