@bendyline/squisq-editor-react 0.1.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/index.d.ts +269 -0
- package/dist/index.js +3825 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/EditorContext.tsx +251 -0
- package/src/EditorShell.tsx +139 -0
- package/src/PreviewPanel.tsx +562 -0
- package/src/RawEditor.tsx +151 -0
- package/src/StatusBar.tsx +48 -0
- package/src/TemplateAnnotation.ts +71 -0
- package/src/Toolbar.tsx +465 -0
- package/src/ViewSwitcher.tsx +46 -0
- package/src/WysiwygEditor.tsx +134 -0
- package/src/index.ts +58 -0
- package/src/styles/editor.css +594 -0
- package/src/tiptapBridge.ts +425 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreviewPanel
|
|
3
|
+
*
|
|
4
|
+
* Renders a live preview of the current markdown document as a slideshow
|
|
5
|
+
* using the DocPlayer component from @bendyline/squisq-react.
|
|
6
|
+
*
|
|
7
|
+
* The markdown-derived Doc (from markdownToDoc) contains hierarchical blocks
|
|
8
|
+
* with template names, heading text, and body content — but no audio or
|
|
9
|
+
* visual layers. This component bridges the gap by:
|
|
10
|
+
*
|
|
11
|
+
* 1. Flattening the block tree into a linear slide sequence
|
|
12
|
+
* 2. Converting each block into a TemplateBlock-compatible object
|
|
13
|
+
* (mapping heading text → title, templateOverrides → template fields)
|
|
14
|
+
* 3. Synthesizing a dummy audio segment so DocPlayer's timing works
|
|
15
|
+
* (the player enters fallback-timer mode when audio can't load)
|
|
16
|
+
* 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useMemo, useState, useEffect } from 'react';
|
|
20
|
+
import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
|
|
21
|
+
import type { DisplayMode } from '@bendyline/squisq-react';
|
|
22
|
+
import { flattenBlocks } from '@bendyline/squisq/doc';
|
|
23
|
+
import { hasTemplate } from '@bendyline/squisq/doc';
|
|
24
|
+
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
25
|
+
import type { Block, Doc, ViewportConfig, ViewportPreset } from '@bendyline/squisq/schemas';
|
|
26
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
27
|
+
import { getThemeSummaries, resolveTheme } from '@bendyline/squisq/schemas';
|
|
28
|
+
import type { MarkdownBlockNode, MarkdownList } from '@bendyline/squisq/markdown';
|
|
29
|
+
import { useEditorContext } from './EditorContext';
|
|
30
|
+
|
|
31
|
+
export interface PreviewPanelProps {
|
|
32
|
+
/** Base path for resolving media URLs in DocPlayer */
|
|
33
|
+
basePath?: string;
|
|
34
|
+
/** Additional class name for the container */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract plain text from an array of markdown block nodes.
|
|
42
|
+
* Walks paragraphs, blockquotes, and list items to collect all text.
|
|
43
|
+
*/
|
|
44
|
+
function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
|
|
45
|
+
if (!contents || contents.length === 0) return '';
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
for (const node of contents) {
|
|
48
|
+
parts.push(extractPlainText(node));
|
|
49
|
+
}
|
|
50
|
+
return parts.join('\n').trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract list items from markdown body content.
|
|
55
|
+
* Returns an array of plain text strings for each list item found.
|
|
56
|
+
*/
|
|
57
|
+
function extractListItems(contents: MarkdownBlockNode[] | undefined): string[] {
|
|
58
|
+
if (!contents) return [];
|
|
59
|
+
const items: string[] = [];
|
|
60
|
+
for (const node of contents) {
|
|
61
|
+
if (node.type === 'list') {
|
|
62
|
+
for (const item of (node as MarkdownList).children) {
|
|
63
|
+
const text = extractPlainText(item).trim();
|
|
64
|
+
if (text) items.push(text);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Provide sensible default fields for templates that require more than
|
|
73
|
+
* just a `title`. This prevents crashes from undefined required fields
|
|
74
|
+
* when the markdown annotations don't supply all template-specific values.
|
|
75
|
+
*/
|
|
76
|
+
function getTemplateDefaults(
|
|
77
|
+
templateName: string,
|
|
78
|
+
headingText: string,
|
|
79
|
+
block: Block,
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
const body = extractBodyText(block.contents);
|
|
82
|
+
|
|
83
|
+
switch (templateName) {
|
|
84
|
+
case 'statHighlight':
|
|
85
|
+
return {
|
|
86
|
+
stat: headingText,
|
|
87
|
+
description: body || headingText,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
case 'quoteBlock':
|
|
91
|
+
case 'fullBleedQuote':
|
|
92
|
+
case 'pullQuote':
|
|
93
|
+
return {
|
|
94
|
+
quote: body || headingText,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
case 'factCard':
|
|
98
|
+
return {
|
|
99
|
+
fact: headingText,
|
|
100
|
+
explanation: body || headingText,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
case 'comparisonBar':
|
|
104
|
+
return {
|
|
105
|
+
leftLabel: 'A',
|
|
106
|
+
leftValue: 60,
|
|
107
|
+
rightLabel: 'B',
|
|
108
|
+
rightValue: 40,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
case 'listBlock':
|
|
112
|
+
return {
|
|
113
|
+
items: extractListItems(block.contents) || ['Item 1', 'Item 2', 'Item 3'],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
case 'definitionCard':
|
|
117
|
+
return {
|
|
118
|
+
term: headingText,
|
|
119
|
+
definition: body || headingText,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
case 'dateEvent':
|
|
123
|
+
return {
|
|
124
|
+
date: headingText,
|
|
125
|
+
description: body || headingText,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert a markdown-derived Block into a TemplateBlock-compatible object.
|
|
135
|
+
*
|
|
136
|
+
* The block's heading text becomes `title` (works for sectionHeader,
|
|
137
|
+
* titleBlock, factCard, etc.). Any templateOverrides from annotation
|
|
138
|
+
* syntax `{[template key=value]}` are spread on top so template-specific
|
|
139
|
+
* fields (stat, quote, description, …) are available.
|
|
140
|
+
*
|
|
141
|
+
* If the requested template doesn't exist in the registry, falls back
|
|
142
|
+
* to `sectionHeader` to avoid "Unknown template" warnings.
|
|
143
|
+
*/
|
|
144
|
+
function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
145
|
+
const headingText = block.sourceHeading
|
|
146
|
+
? extractPlainText(block.sourceHeading)
|
|
147
|
+
: block.id || `Slide ${index + 1}`;
|
|
148
|
+
|
|
149
|
+
// Validate template name — fall back to sectionHeader for unknowns
|
|
150
|
+
const requestedTemplate = block.template || 'sectionHeader';
|
|
151
|
+
const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
|
|
152
|
+
|
|
153
|
+
// Get sensible defaults for templates that need more than just `title`
|
|
154
|
+
const defaults = getTemplateDefaults(template, headingText, block);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: block.id,
|
|
158
|
+
template,
|
|
159
|
+
duration: block.duration,
|
|
160
|
+
audioSegment: 0,
|
|
161
|
+
transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
|
|
162
|
+
// Provide heading text as title — consumed by sectionHeader, titleBlock, etc.
|
|
163
|
+
title: headingText,
|
|
164
|
+
// Template-specific defaults (safe fallbacks for required fields)
|
|
165
|
+
...defaults,
|
|
166
|
+
// Spread annotation overrides last so explicit values win
|
|
167
|
+
...block.templateOverrides,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build a player-ready Doc from the markdown-derived Doc.
|
|
173
|
+
*
|
|
174
|
+
* Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
|
|
175
|
+
* slide, recalculates timing, and adds a synthetic audio segment.
|
|
176
|
+
*/
|
|
177
|
+
function buildPreviewDoc(doc: Doc): Doc {
|
|
178
|
+
const flat = flattenBlocks(doc.blocks);
|
|
179
|
+
const slides = flat.map(blockToSlide);
|
|
180
|
+
|
|
181
|
+
// Recalculate sequential timing
|
|
182
|
+
let t = 0;
|
|
183
|
+
for (const slide of slides) {
|
|
184
|
+
slide.startTime = t;
|
|
185
|
+
t += slide.duration as number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
articleId: doc.articleId,
|
|
190
|
+
duration: t,
|
|
191
|
+
blocks: slides as unknown as Block[],
|
|
192
|
+
audio: {
|
|
193
|
+
// Synthetic segment — audio will fail to load and DocPlayer will use
|
|
194
|
+
// its fallback timer to advance currentTime via requestAnimationFrame.
|
|
195
|
+
segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Viewport helpers ───────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/** All viewport preset entries for the dropdown */
|
|
203
|
+
const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
|
|
204
|
+
{ key: 'landscape', label: '16:9 Landscape' },
|
|
205
|
+
{ key: 'portrait', label: '9:16 Portrait' },
|
|
206
|
+
{ key: 'square', label: '1:1 Square' },
|
|
207
|
+
{ key: 'standard', label: '4:3 Standard' },
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a `document-render-as` frontmatter value to a ViewportPreset.
|
|
212
|
+
* Accepts preset names ("landscape"), aspect ratio shorthand ("16:9"),
|
|
213
|
+
* and common aliases ("widescreen", "vertical", "stories").
|
|
214
|
+
*/
|
|
215
|
+
function resolveRenderAs(value: unknown): ViewportPreset | null {
|
|
216
|
+
if (typeof value !== 'string') return null;
|
|
217
|
+
const v = value.trim().toLowerCase();
|
|
218
|
+
const mapping: Record<string, ViewportPreset> = {
|
|
219
|
+
landscape: 'landscape',
|
|
220
|
+
'16:9': 'landscape',
|
|
221
|
+
widescreen: 'landscape',
|
|
222
|
+
portrait: 'portrait',
|
|
223
|
+
'9:16': 'portrait',
|
|
224
|
+
vertical: 'portrait',
|
|
225
|
+
stories: 'portrait',
|
|
226
|
+
square: 'square',
|
|
227
|
+
'1:1': 'square',
|
|
228
|
+
standard: 'standard',
|
|
229
|
+
'4:3': 'standard',
|
|
230
|
+
};
|
|
231
|
+
return mapping[v] ?? null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Display mode options for the dropdown */
|
|
235
|
+
const DISPLAY_MODE_OPTIONS: { key: DisplayMode; label: string }[] = [
|
|
236
|
+
{ key: 'video', label: 'Video' },
|
|
237
|
+
{ key: 'slideshow', label: 'Slideshow' },
|
|
238
|
+
{ key: 'linear', label: 'Document' },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/** Theme options for the dropdown */
|
|
242
|
+
const THEME_OPTIONS = getThemeSummaries().map((s) => ({ key: s.id, label: s.name }));
|
|
243
|
+
|
|
244
|
+
/** Set of valid theme IDs for fast lookup */
|
|
245
|
+
const VALID_THEME_IDS = new Set(THEME_OPTIONS.map((o) => o.key));
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve a `theme` frontmatter value to a theme id.
|
|
249
|
+
* Accepts exact theme ids ('documentary', 'bold') and common aliases.
|
|
250
|
+
*/
|
|
251
|
+
function resolveFrontmatterTheme(value: unknown): string | null {
|
|
252
|
+
if (typeof value !== 'string') return null;
|
|
253
|
+
const v = value.trim().toLowerCase();
|
|
254
|
+
if (VALID_THEME_IDS.has(v)) return v;
|
|
255
|
+
// Allow hyphenated/spaced aliases: "morning light" → "morning-light"
|
|
256
|
+
const normalized = v.replace(/\s+/g, '-');
|
|
257
|
+
if (VALID_THEME_IDS.has(normalized)) return normalized;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolve a `display-mode` frontmatter value to a DisplayMode.
|
|
263
|
+
*/
|
|
264
|
+
function resolveDisplayMode(value: unknown): DisplayMode | null {
|
|
265
|
+
if (typeof value !== 'string') return null;
|
|
266
|
+
const v = value.trim().toLowerCase();
|
|
267
|
+
if (v === 'video' || v === 'slideshow' || v === 'linear') return v;
|
|
268
|
+
if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
|
|
269
|
+
if (v === 'document' || v === 'scroll' || v === 'page') return 'linear';
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Live preview panel that renders the current document as a slideshow.
|
|
277
|
+
* Uses DocPlayer from @bendyline/squisq-react for SVG block rendering
|
|
278
|
+
* with template expansion, transitions, and playback controls.
|
|
279
|
+
*
|
|
280
|
+
* Includes a viewport format dropdown above the player. The default
|
|
281
|
+
* format can be hinted via YAML frontmatter `document-render-as:`.
|
|
282
|
+
*/
|
|
283
|
+
export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
|
|
284
|
+
const { doc, parseError, isParsing } = useEditorContext();
|
|
285
|
+
|
|
286
|
+
// Determine the frontmatter-hinted viewport preset (if any)
|
|
287
|
+
const frontmatterPreset = useMemo<ViewportPreset | null>(() => {
|
|
288
|
+
if (!doc?.frontmatter) return null;
|
|
289
|
+
return resolveRenderAs(doc.frontmatter['document-render-as']);
|
|
290
|
+
}, [doc?.frontmatter]);
|
|
291
|
+
|
|
292
|
+
// Track user-selected viewport; null means "use frontmatter or default"
|
|
293
|
+
const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
|
|
294
|
+
|
|
295
|
+
// When frontmatter preset changes and user hasn't explicitly chosen, sync
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
setSelectedPreset(null);
|
|
298
|
+
}, [frontmatterPreset]);
|
|
299
|
+
|
|
300
|
+
// Active preset: explicit user choice > frontmatter hint > landscape
|
|
301
|
+
const activePreset: ViewportPreset = selectedPreset ?? frontmatterPreset ?? 'landscape';
|
|
302
|
+
const activeViewport: ViewportConfig = VIEWPORT_PRESETS[activePreset];
|
|
303
|
+
|
|
304
|
+
// ── Display mode (video vs slideshow) ──────────────────────────
|
|
305
|
+
|
|
306
|
+
// Determine the frontmatter-hinted display mode (if any)
|
|
307
|
+
const frontmatterDisplayMode = useMemo<DisplayMode | null>(() => {
|
|
308
|
+
if (!doc?.frontmatter) return null;
|
|
309
|
+
return resolveDisplayMode(doc.frontmatter['display-mode']);
|
|
310
|
+
}, [doc?.frontmatter]);
|
|
311
|
+
|
|
312
|
+
// Track user-selected display mode; null means "use frontmatter or default"
|
|
313
|
+
const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
|
|
314
|
+
|
|
315
|
+
// When frontmatter display mode changes and user hasn't explicitly chosen, sync
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
setSelectedDisplayMode(null);
|
|
318
|
+
}, [frontmatterDisplayMode]);
|
|
319
|
+
|
|
320
|
+
// Active display mode: explicit user choice > frontmatter hint > video
|
|
321
|
+
const activeDisplayMode: DisplayMode = selectedDisplayMode ?? frontmatterDisplayMode ?? 'video';
|
|
322
|
+
|
|
323
|
+
// ── Theme selection ────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
// Determine the frontmatter-hinted theme (if any)
|
|
326
|
+
const frontmatterThemeId = useMemo<string | null>(() => {
|
|
327
|
+
if (!doc?.frontmatter) return null;
|
|
328
|
+
return resolveFrontmatterTheme(doc.frontmatter['theme']);
|
|
329
|
+
}, [doc?.frontmatter]);
|
|
330
|
+
|
|
331
|
+
// Track user-selected theme; null means "use frontmatter or default"
|
|
332
|
+
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
|
333
|
+
|
|
334
|
+
// When frontmatter theme changes and user hasn't explicitly chosen, sync
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
setSelectedThemeId(null);
|
|
337
|
+
}, [frontmatterThemeId]);
|
|
338
|
+
|
|
339
|
+
// Active theme: explicit user choice > frontmatter hint > documentary
|
|
340
|
+
const activeThemeId = selectedThemeId ?? frontmatterThemeId ?? 'documentary';
|
|
341
|
+
const activeTheme = useMemo(() => resolveTheme(activeThemeId), [activeThemeId]);
|
|
342
|
+
|
|
343
|
+
// Build the player-ready Doc whenever the parsed doc changes
|
|
344
|
+
const previewDoc = useMemo(() => {
|
|
345
|
+
if (!doc || !doc.blocks.length) return null;
|
|
346
|
+
return buildPreviewDoc(doc);
|
|
347
|
+
}, [doc]);
|
|
348
|
+
|
|
349
|
+
// Status overlays for non-ready states
|
|
350
|
+
if (isParsing) {
|
|
351
|
+
return (
|
|
352
|
+
<div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
|
|
353
|
+
<p>Parsing…</p>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (parseError) {
|
|
359
|
+
return (
|
|
360
|
+
<div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
|
|
361
|
+
<h3>Parse Error</h3>
|
|
362
|
+
<pre>{parseError}</pre>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!previewDoc) {
|
|
368
|
+
return (
|
|
369
|
+
<div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
|
|
370
|
+
<p>No content to preview. Start typing in the editor.</p>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div
|
|
377
|
+
className={`squisq-preview-container ${className || ''}`}
|
|
378
|
+
data-testid="preview-panel"
|
|
379
|
+
style={{
|
|
380
|
+
width: '100%',
|
|
381
|
+
height: '100%',
|
|
382
|
+
display: 'flex',
|
|
383
|
+
flexDirection: 'column',
|
|
384
|
+
overflow: 'hidden',
|
|
385
|
+
background: 'var(--squisq-bg, #f5f5f5)',
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
{/* Viewport format selector */}
|
|
389
|
+
<div
|
|
390
|
+
className="squisq-preview-toolbar"
|
|
391
|
+
style={{
|
|
392
|
+
display: 'flex',
|
|
393
|
+
alignItems: 'center',
|
|
394
|
+
gap: '8px',
|
|
395
|
+
padding: '6px 12px',
|
|
396
|
+
borderBottom: '1px solid var(--squisq-border, #e0e0e0)',
|
|
397
|
+
flexShrink: 0,
|
|
398
|
+
fontSize: '13px',
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
<label htmlFor="viewport-preset" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
|
|
402
|
+
Format:
|
|
403
|
+
</label>
|
|
404
|
+
<select
|
|
405
|
+
id="viewport-preset"
|
|
406
|
+
value={activePreset}
|
|
407
|
+
onChange={(e) => setSelectedPreset(e.target.value as ViewportPreset)}
|
|
408
|
+
style={{
|
|
409
|
+
padding: '3px 8px',
|
|
410
|
+
borderRadius: '4px',
|
|
411
|
+
border: '1px solid var(--squisq-border, #d1d5db)',
|
|
412
|
+
background: 'var(--squisq-input-bg, #fff)',
|
|
413
|
+
color: 'var(--squisq-text, #1f2937)',
|
|
414
|
+
fontSize: '13px',
|
|
415
|
+
cursor: 'pointer',
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
{VIEWPORT_OPTIONS.map((opt) => (
|
|
419
|
+
<option key={opt.key} value={opt.key}>
|
|
420
|
+
{opt.label}
|
|
421
|
+
</option>
|
|
422
|
+
))}
|
|
423
|
+
</select>
|
|
424
|
+
{frontmatterPreset && selectedPreset === null && (
|
|
425
|
+
<span
|
|
426
|
+
style={{
|
|
427
|
+
fontSize: '11px',
|
|
428
|
+
color: 'var(--squisq-text-muted, #9ca3af)',
|
|
429
|
+
fontStyle: 'italic',
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
(from frontmatter)
|
|
433
|
+
</span>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{/* Divider */}
|
|
437
|
+
<span
|
|
438
|
+
style={{
|
|
439
|
+
width: '1px',
|
|
440
|
+
height: '18px',
|
|
441
|
+
background: 'var(--squisq-border, #d1d5db)',
|
|
442
|
+
margin: '0 4px',
|
|
443
|
+
}}
|
|
444
|
+
/>
|
|
445
|
+
|
|
446
|
+
{/* Display mode selector */}
|
|
447
|
+
<label htmlFor="display-mode" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
|
|
448
|
+
Mode:
|
|
449
|
+
</label>
|
|
450
|
+
<select
|
|
451
|
+
id="display-mode"
|
|
452
|
+
value={activeDisplayMode}
|
|
453
|
+
onChange={(e) => setSelectedDisplayMode(e.target.value as DisplayMode)}
|
|
454
|
+
style={{
|
|
455
|
+
padding: '3px 8px',
|
|
456
|
+
borderRadius: '4px',
|
|
457
|
+
border: '1px solid var(--squisq-border, #d1d5db)',
|
|
458
|
+
background: 'var(--squisq-input-bg, #fff)',
|
|
459
|
+
color: 'var(--squisq-text, #1f2937)',
|
|
460
|
+
fontSize: '13px',
|
|
461
|
+
cursor: 'pointer',
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
{DISPLAY_MODE_OPTIONS.map((opt) => (
|
|
465
|
+
<option key={opt.key} value={opt.key}>
|
|
466
|
+
{opt.label}
|
|
467
|
+
</option>
|
|
468
|
+
))}
|
|
469
|
+
</select>
|
|
470
|
+
{frontmatterDisplayMode && selectedDisplayMode === null && (
|
|
471
|
+
<span
|
|
472
|
+
style={{
|
|
473
|
+
fontSize: '11px',
|
|
474
|
+
color: 'var(--squisq-text-muted, #9ca3af)',
|
|
475
|
+
fontStyle: 'italic',
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
(from frontmatter)
|
|
479
|
+
</span>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
{/* Divider */}
|
|
483
|
+
<span
|
|
484
|
+
style={{
|
|
485
|
+
width: '1px',
|
|
486
|
+
height: '18px',
|
|
487
|
+
background: 'var(--squisq-border, #d1d5db)',
|
|
488
|
+
margin: '0 4px',
|
|
489
|
+
}}
|
|
490
|
+
/>
|
|
491
|
+
|
|
492
|
+
{/* Theme selector */}
|
|
493
|
+
<label htmlFor="theme-select" style={{ color: 'var(--squisq-text-muted, #6b7280)' }}>
|
|
494
|
+
Theme:
|
|
495
|
+
</label>
|
|
496
|
+
<select
|
|
497
|
+
id="theme-select"
|
|
498
|
+
value={activeThemeId}
|
|
499
|
+
onChange={(e) => setSelectedThemeId(e.target.value)}
|
|
500
|
+
style={{
|
|
501
|
+
padding: '3px 8px',
|
|
502
|
+
borderRadius: '4px',
|
|
503
|
+
border: '1px solid var(--squisq-border, #d1d5db)',
|
|
504
|
+
background: 'var(--squisq-input-bg, #fff)',
|
|
505
|
+
color: 'var(--squisq-text, #1f2937)',
|
|
506
|
+
fontSize: '13px',
|
|
507
|
+
cursor: 'pointer',
|
|
508
|
+
}}
|
|
509
|
+
>
|
|
510
|
+
{THEME_OPTIONS.map((opt) => (
|
|
511
|
+
<option key={opt.key} value={opt.key}>
|
|
512
|
+
{opt.label}
|
|
513
|
+
</option>
|
|
514
|
+
))}
|
|
515
|
+
</select>
|
|
516
|
+
{frontmatterThemeId && selectedThemeId === null && (
|
|
517
|
+
<span
|
|
518
|
+
style={{
|
|
519
|
+
fontSize: '11px',
|
|
520
|
+
color: 'var(--squisq-text-muted, #9ca3af)',
|
|
521
|
+
fontStyle: 'italic',
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
(from frontmatter)
|
|
525
|
+
</span>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
|
|
529
|
+
{/* Player / Document view */}
|
|
530
|
+
<div
|
|
531
|
+
className="squisq-preview-player"
|
|
532
|
+
style={{
|
|
533
|
+
flex: 1,
|
|
534
|
+
display: 'flex',
|
|
535
|
+
alignItems: activeDisplayMode === 'linear' ? 'stretch' : 'center',
|
|
536
|
+
justifyContent: 'center',
|
|
537
|
+
overflow: 'hidden',
|
|
538
|
+
minHeight: 0,
|
|
539
|
+
}}
|
|
540
|
+
>
|
|
541
|
+
{activeDisplayMode === 'linear' ? (
|
|
542
|
+
<LinearDocView
|
|
543
|
+
doc={doc!}
|
|
544
|
+
basePath={basePath}
|
|
545
|
+
viewport={activeViewport}
|
|
546
|
+
theme={activeTheme}
|
|
547
|
+
/>
|
|
548
|
+
) : (
|
|
549
|
+
<DocPlayer
|
|
550
|
+
script={previewDoc}
|
|
551
|
+
basePath={basePath}
|
|
552
|
+
showControls
|
|
553
|
+
muted
|
|
554
|
+
forceViewport={activeViewport}
|
|
555
|
+
displayMode={activeDisplayMode}
|
|
556
|
+
theme={activeTheme}
|
|
557
|
+
/>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|