@bendyline/squisq-editor-react 1.1.0 → 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/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 +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/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 +4 -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 +23 -6
- package/src/WysiwygEditor.tsx +3 -1
- package/src/index.ts +6 -0
- package/src/styles/editor.css +31 -8
- package/src/tiptapBridge.ts +5 -6
package/src/PreviewPanel.tsx
CHANGED
|
@@ -16,23 +16,27 @@
|
|
|
16
16
|
* 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { useState, useEffect } from 'react';
|
|
20
20
|
import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
|
|
21
|
-
import type { DisplayMode } from '@bendyline/squisq-react';
|
|
22
21
|
import { flattenBlocks } from '@bendyline/squisq/doc';
|
|
23
22
|
import { hasTemplate } from '@bendyline/squisq/doc';
|
|
24
23
|
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
25
|
-
import type { Block, Doc
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import
|
|
24
|
+
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
25
|
+
import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
|
|
26
|
+
import { getChildren } from '@bendyline/squisq/markdown';
|
|
27
|
+
import { applyTransform } from '@bendyline/squisq/transform';
|
|
28
|
+
import { resolveAudioMapping } from '@bendyline/squisq/doc';
|
|
29
|
+
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
29
30
|
import { useEditorContext } from './EditorContext';
|
|
31
|
+
import { usePreviewSettings } from './PreviewControls';
|
|
30
32
|
|
|
31
33
|
export interface PreviewPanelProps {
|
|
32
34
|
/** Base path for resolving media URLs in DocPlayer */
|
|
33
35
|
basePath?: string;
|
|
34
36
|
/** Additional class name for the container */
|
|
35
37
|
className?: string;
|
|
38
|
+
/** Optional ContentContainer for audio mapping (MP3 discovery + timing.json) */
|
|
39
|
+
container?: ContentContainer | null;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
@@ -50,6 +54,60 @@ function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
|
|
|
50
54
|
return parts.join('\n').trim();
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Extract images from a block's markdown contents.
|
|
59
|
+
* Walks the node tree recursively to find all MarkdownImage nodes.
|
|
60
|
+
*/
|
|
61
|
+
function extractBlockImages(
|
|
62
|
+
contents: MarkdownBlockNode[] | undefined,
|
|
63
|
+
): Array<{ src: string; alt: string }> {
|
|
64
|
+
if (!contents || contents.length === 0) return [];
|
|
65
|
+
const images: Array<{ src: string; alt: string }> = [];
|
|
66
|
+
|
|
67
|
+
function walk(node: MarkdownNode): void {
|
|
68
|
+
if ('type' in node && node.type === 'image' && 'url' in node) {
|
|
69
|
+
const img = node as { url: string; alt?: string };
|
|
70
|
+
if (img.url) {
|
|
71
|
+
images.push({ src: img.url, alt: img.alt ?? '' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const child of getChildren(node)) {
|
|
75
|
+
walk(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const node of contents) {
|
|
80
|
+
walk(node);
|
|
81
|
+
}
|
|
82
|
+
return images;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Collect all unique images from an entire Doc's block tree.
|
|
87
|
+
* Walks nested children to find every image across all blocks.
|
|
88
|
+
*/
|
|
89
|
+
function collectAllDocImages(blocks: Block[]): Array<{ src: string; alt: string }> {
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
const images: Array<{ src: string; alt: string }> = [];
|
|
92
|
+
|
|
93
|
+
function walkBlocks(blockList: Block[]): void {
|
|
94
|
+
for (const block of blockList) {
|
|
95
|
+
for (const img of extractBlockImages(block.contents)) {
|
|
96
|
+
if (!seen.has(img.src)) {
|
|
97
|
+
seen.add(img.src);
|
|
98
|
+
images.push(img);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (block.children) {
|
|
102
|
+
walkBlocks(block.children);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
walkBlocks(blocks);
|
|
108
|
+
return images;
|
|
109
|
+
}
|
|
110
|
+
|
|
53
111
|
/**
|
|
54
112
|
* Extract list items from markdown body content.
|
|
55
113
|
* Returns an array of plain text strings for each list item found.
|
|
@@ -144,7 +202,7 @@ function getTemplateDefaults(
|
|
|
144
202
|
function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
145
203
|
const headingText = block.sourceHeading
|
|
146
204
|
? extractPlainText(block.sourceHeading)
|
|
147
|
-
: block.id || `Slide ${index + 1}`;
|
|
205
|
+
: block.title || block.id || `Slide ${index + 1}`;
|
|
148
206
|
|
|
149
207
|
// Validate template name — fall back to sectionHeader for unknowns
|
|
150
208
|
const requestedTemplate = block.template || 'sectionHeader';
|
|
@@ -153,6 +211,27 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
153
211
|
// Get sensible defaults for templates that need more than just `title`
|
|
154
212
|
const defaults = getTemplateDefaults(template, headingText, block);
|
|
155
213
|
|
|
214
|
+
// Spread the block itself to pick up any template-specific fields
|
|
215
|
+
// placed directly on the block by applyTransform (e.g. stat, description,
|
|
216
|
+
// quote, colorScheme). These are not in templateOverrides — they live
|
|
217
|
+
// on the block object because the transform produces hybrid Block+Template
|
|
218
|
+
// objects via the timing allocator.
|
|
219
|
+
const {
|
|
220
|
+
id: _id,
|
|
221
|
+
startTime: _st,
|
|
222
|
+
duration: _d,
|
|
223
|
+
audioSegment: _as,
|
|
224
|
+
layers: _l,
|
|
225
|
+
transition: _tr,
|
|
226
|
+
template: _t,
|
|
227
|
+
title: _ti,
|
|
228
|
+
children: _c,
|
|
229
|
+
contents: _co,
|
|
230
|
+
sourceHeading: _sh,
|
|
231
|
+
templateOverrides: _to,
|
|
232
|
+
...extraFields
|
|
233
|
+
} = block as unknown as Record<string, unknown>;
|
|
234
|
+
|
|
156
235
|
return {
|
|
157
236
|
id: block.id,
|
|
158
237
|
template,
|
|
@@ -163,20 +242,101 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
163
242
|
title: headingText,
|
|
164
243
|
// Template-specific defaults (safe fallbacks for required fields)
|
|
165
244
|
...defaults,
|
|
245
|
+
// Template-specific fields from transform (stat, description, quote, etc.)
|
|
246
|
+
...extraFields,
|
|
166
247
|
// Spread annotation overrides last so explicit values win
|
|
167
248
|
...block.templateOverrides,
|
|
168
249
|
};
|
|
169
250
|
}
|
|
170
251
|
|
|
252
|
+
/** Ambient motions to rotate on image slides. */
|
|
253
|
+
const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
254
|
+
'zoomIn',
|
|
255
|
+
'zoomOut',
|
|
256
|
+
'panLeft',
|
|
257
|
+
'panRight',
|
|
258
|
+
];
|
|
259
|
+
|
|
171
260
|
/**
|
|
172
261
|
* Build a player-ready Doc from the markdown-derived Doc.
|
|
173
262
|
*
|
|
174
263
|
* Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
|
|
175
264
|
* slide, recalculates timing, and adds a synthetic audio segment.
|
|
265
|
+
*
|
|
266
|
+
* Images found in the markdown are used in two ways:
|
|
267
|
+
* 1. Per-block: if a block has images, its first image becomes the background
|
|
268
|
+
* (via imageWithCaption template) or an accent image on text templates.
|
|
269
|
+
* 2. Global: remaining images are interleaved as standalone image slides
|
|
270
|
+
* for visual variety.
|
|
176
271
|
*/
|
|
177
272
|
function buildPreviewDoc(doc: Doc): Doc {
|
|
178
273
|
const flat = flattenBlocks(doc.blocks);
|
|
179
|
-
|
|
274
|
+
|
|
275
|
+
// Collect all images from the doc for global interleaving
|
|
276
|
+
const allImages = collectAllDocImages(doc.blocks);
|
|
277
|
+
|
|
278
|
+
// Track which images are used per-block so we can interleave the rest
|
|
279
|
+
const usedImageSrcs = new Set<string>();
|
|
280
|
+
|
|
281
|
+
// First pass: convert blocks to slides, using per-block images
|
|
282
|
+
const slides: Record<string, unknown>[] = [];
|
|
283
|
+
let motionIndex = 0;
|
|
284
|
+
|
|
285
|
+
for (let i = 0; i < flat.length; i++) {
|
|
286
|
+
const block = flat[i];
|
|
287
|
+
const blockImages = extractBlockImages(block.contents);
|
|
288
|
+
const slide = blockToSlide(block, i);
|
|
289
|
+
|
|
290
|
+
// If the block has images and is using the default sectionHeader template,
|
|
291
|
+
// upgrade it to imageWithCaption so the image becomes the slide background.
|
|
292
|
+
if (blockImages.length > 0 && slide.template === 'sectionHeader') {
|
|
293
|
+
const img = blockImages[0];
|
|
294
|
+
usedImageSrcs.add(img.src);
|
|
295
|
+
slide.template = 'imageWithCaption';
|
|
296
|
+
slide.imageSrc = img.src;
|
|
297
|
+
slide.imageAlt = img.alt;
|
|
298
|
+
slide.caption = slide.title as string;
|
|
299
|
+
slide.captionPosition = 'bottom';
|
|
300
|
+
slide.ambientMotion = IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length];
|
|
301
|
+
} else if (blockImages.length > 0) {
|
|
302
|
+
// For other templates, add the first image as an accent
|
|
303
|
+
const img = blockImages[0];
|
|
304
|
+
usedImageSrcs.add(img.src);
|
|
305
|
+
if (!slide.accentImage) {
|
|
306
|
+
slide.accentImage = {
|
|
307
|
+
src: img.src,
|
|
308
|
+
alt: img.alt,
|
|
309
|
+
position: 'left-strip',
|
|
310
|
+
ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
slides.push(slide);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Second pass: interleave unused images as standalone imageWithCaption slides.
|
|
319
|
+
// Spread them evenly through the sequence for visual variety.
|
|
320
|
+
const unusedImages = allImages.filter((img) => !usedImageSrcs.has(img.src));
|
|
321
|
+
if (unusedImages.length > 0 && slides.length > 0) {
|
|
322
|
+
const interval = Math.max(2, Math.floor(slides.length / (unusedImages.length + 1)));
|
|
323
|
+
let insertOffset = 0;
|
|
324
|
+
for (let imgIdx = 0; imgIdx < unusedImages.length; imgIdx++) {
|
|
325
|
+
const insertAt = Math.min((imgIdx + 1) * interval + insertOffset, slides.length);
|
|
326
|
+
const img = unusedImages[imgIdx];
|
|
327
|
+
slides.splice(insertAt, 0, {
|
|
328
|
+
id: `img-interleave-${imgIdx}`,
|
|
329
|
+
template: 'imageWithCaption',
|
|
330
|
+
duration: 5,
|
|
331
|
+
audioSegment: 0,
|
|
332
|
+
imageSrc: img.src,
|
|
333
|
+
imageAlt: img.alt,
|
|
334
|
+
ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
|
|
335
|
+
transition: { type: 'fade', duration: 0.5 },
|
|
336
|
+
});
|
|
337
|
+
insertOffset++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
180
340
|
|
|
181
341
|
// Recalculate sequential timing
|
|
182
342
|
let t = 0;
|
|
@@ -194,157 +354,66 @@ function buildPreviewDoc(doc: Doc): Doc {
|
|
|
194
354
|
// its fallback timer to advance currentTime via requestAnimationFrame.
|
|
195
355
|
segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
|
|
196
356
|
},
|
|
357
|
+
...(doc.captions ? { captions: doc.captions } : {}),
|
|
197
358
|
};
|
|
198
359
|
}
|
|
199
360
|
|
|
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
361
|
// ── Component ──────────────────────────────────────────────────────
|
|
274
362
|
|
|
275
363
|
/**
|
|
276
|
-
* Live preview panel that renders the current document as a slideshow
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* Includes a viewport format dropdown above the player. The default
|
|
281
|
-
* format can be hinted via YAML frontmatter `document-render-as:`.
|
|
364
|
+
* Live preview panel that renders the current document as a slideshow
|
|
365
|
+
* or document view. Controls (viewport, mode, theme, transform, captions)
|
|
366
|
+
* are rendered in the main toolbar via PreviewToolbarControls.
|
|
282
367
|
*/
|
|
283
|
-
export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
|
|
368
|
+
export function PreviewPanel({ basePath = '/', className, container }: PreviewPanelProps) {
|
|
284
369
|
const { doc, parseError, isParsing } = useEditorContext();
|
|
370
|
+
const {
|
|
371
|
+
activeViewport,
|
|
372
|
+
activeDisplayMode,
|
|
373
|
+
activeTheme,
|
|
374
|
+
activeTransformStyle,
|
|
375
|
+
activeCaptionStyle,
|
|
376
|
+
} = usePreviewSettings();
|
|
377
|
+
|
|
378
|
+
// Build the player-ready Doc whenever the parsed doc changes.
|
|
379
|
+
// Transform runs on the ORIGINAL doc (which has block.contents with
|
|
380
|
+
// markdown body text) so the content extractor can analyze it.
|
|
381
|
+
// Then buildPreviewDoc converts the result for DocPlayer.
|
|
382
|
+
//
|
|
383
|
+
// Audio mapping is async (reads container files), so we use a two-phase
|
|
384
|
+
// approach: first build the base doc synchronously, then resolve audio
|
|
385
|
+
// in an effect and update the state.
|
|
386
|
+
const [previewDoc, setPreviewDoc] = useState<Doc | null>(null);
|
|
285
387
|
|
|
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
388
|
useEffect(() => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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);
|
|
389
|
+
if (!doc || !doc.blocks.length) {
|
|
390
|
+
setPreviewDoc(null);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
333
393
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
394
|
+
let sourceDoc = doc;
|
|
395
|
+
if (activeTransformStyle) {
|
|
396
|
+
const result = applyTransform(doc, activeTransformStyle);
|
|
397
|
+
sourceDoc = result.doc;
|
|
398
|
+
}
|
|
338
399
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
400
|
+
// If we have a container, try to resolve audio mapping before building preview
|
|
401
|
+
if (container) {
|
|
402
|
+
let cancelled = false;
|
|
403
|
+
resolveAudioMapping(sourceDoc, container).then((audioDoc) => {
|
|
404
|
+
if (!cancelled) {
|
|
405
|
+
setPreviewDoc(buildPreviewDoc(audioDoc));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
// Set an immediate preview without audio while mapping resolves
|
|
409
|
+
setPreviewDoc(buildPreviewDoc(sourceDoc));
|
|
410
|
+
return () => {
|
|
411
|
+
cancelled = true;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
342
414
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (!doc || !doc.blocks.length) return null;
|
|
346
|
-
return buildPreviewDoc(doc);
|
|
347
|
-
}, [doc]);
|
|
415
|
+
setPreviewDoc(buildPreviewDoc(sourceDoc));
|
|
416
|
+
}, [doc, activeTransformStyle, container]);
|
|
348
417
|
|
|
349
418
|
// Status overlays for non-ready states
|
|
350
419
|
if (isParsing) {
|
|
@@ -385,147 +454,6 @@ export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
|
|
|
385
454
|
background: 'var(--squisq-bg, #f5f5f5)',
|
|
386
455
|
}}
|
|
387
456
|
>
|
|
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
457
|
{/* Player / Document view */}
|
|
530
458
|
<div
|
|
531
459
|
className="squisq-preview-player"
|
|
@@ -554,6 +482,7 @@ export function PreviewPanel({ basePath = '/', className }: PreviewPanelProps) {
|
|
|
554
482
|
forceViewport={activeViewport}
|
|
555
483
|
displayMode={activeDisplayMode}
|
|
556
484
|
theme={activeTheme}
|
|
485
|
+
captionStyle={activeCaptionStyle}
|
|
557
486
|
/>
|
|
558
487
|
)}
|
|
559
488
|
</div>
|
package/src/Toolbar.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Hidden in Preview mode.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
10
11
|
import { useCallback, useEffect, useReducer } from 'react';
|
|
11
12
|
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
12
13
|
import { useEditorContext, type EditorView } from './EditorContext';
|
|
@@ -15,7 +16,7 @@ import { getAvailableTemplates } from '@bendyline/squisq/doc';
|
|
|
15
16
|
const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
|
|
16
17
|
{ id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
|
|
17
18
|
{ id: 'raw', label: 'Raw', shortcut: '⌘2' },
|
|
18
|
-
{ id: 'preview', label: '
|
|
19
|
+
{ id: 'preview', label: 'Play', shortcut: '⌘3' },
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
export interface ToolbarProps {
|
|
@@ -25,6 +26,12 @@ export interface ToolbarProps {
|
|
|
25
26
|
showFiles?: boolean;
|
|
26
27
|
/** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
|
|
27
28
|
onToggleFiles?: () => void;
|
|
29
|
+
/** Content rendered at the left edge of the toolbar, before the view tabs. */
|
|
30
|
+
slotLeft?: ReactNode;
|
|
31
|
+
/** Content rendered after the formatting controls (in the middle area). */
|
|
32
|
+
slotAfterActions?: ReactNode;
|
|
33
|
+
/** Content rendered at the rightmost end of the toolbar, after all other elements. */
|
|
34
|
+
slotRight?: ReactNode;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
interface ToolbarButton {
|
|
@@ -117,7 +124,14 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
|
|
|
117
124
|
* - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
|
|
118
125
|
* - Raw: appends markdown syntax to the source
|
|
119
126
|
*/
|
|
120
|
-
export function Toolbar({
|
|
127
|
+
export function Toolbar({
|
|
128
|
+
className,
|
|
129
|
+
showFiles,
|
|
130
|
+
onToggleFiles,
|
|
131
|
+
slotLeft,
|
|
132
|
+
slotAfterActions,
|
|
133
|
+
slotRight,
|
|
134
|
+
}: ToolbarProps) {
|
|
121
135
|
const {
|
|
122
136
|
activeView,
|
|
123
137
|
setActiveView,
|
|
@@ -399,6 +413,8 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
|
|
|
399
413
|
role="toolbar"
|
|
400
414
|
aria-label="Formatting toolbar"
|
|
401
415
|
>
|
|
416
|
+
{/* Left slot — before view tabs */}
|
|
417
|
+
{slotLeft}
|
|
402
418
|
{/* View tabs */}
|
|
403
419
|
<div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
|
|
404
420
|
{VIEWS.map((view) => (
|
|
@@ -415,7 +431,6 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
|
|
|
415
431
|
</button>
|
|
416
432
|
))}
|
|
417
433
|
</div>
|
|
418
|
-
|
|
419
434
|
{/* Formatting buttons — hidden in preview mode */}
|
|
420
435
|
{!isPreview && (
|
|
421
436
|
<div className="squisq-toolbar-actions">
|
|
@@ -469,10 +484,10 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
|
|
|
469
484
|
)}
|
|
470
485
|
</div>
|
|
471
486
|
)}
|
|
472
|
-
|
|
487
|
+
{/* After-actions slot — after formatting controls */}
|
|
488
|
+
{slotAfterActions}
|
|
473
489
|
{/* Spacer pushes right-side buttons to the end */}
|
|
474
|
-
|
|
475
|
-
|
|
490
|
+
<div style={{ flex: 1 }} />
|
|
476
491
|
{/* Files toggle — visible when callback is provided */}
|
|
477
492
|
{onToggleFiles && (
|
|
478
493
|
<button
|
|
@@ -485,6 +500,8 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
|
|
|
485
500
|
{'\u{1F4CE}'}
|
|
486
501
|
</button>
|
|
487
502
|
)}
|
|
503
|
+
{/* Right slot — rightmost end of toolbar */}
|
|
504
|
+
{slotRight}
|
|
488
505
|
</div>
|
|
489
506
|
);
|
|
490
507
|
}
|