@bendyline/squisq-react 1.3.2 → 1.4.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/README.md +57 -23
- package/dist/index.d.ts +131 -26
- package/dist/index.js +1475 -755
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +49 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
|
@@ -20,19 +20,22 @@
|
|
|
20
20
|
|
|
21
21
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
22
22
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
23
|
-
import type { ViewportConfig } from '@bendyline/squisq/schemas';
|
|
24
|
-
import type {
|
|
23
|
+
import type { ViewportConfig, Theme } from '@bendyline/squisq/schemas';
|
|
24
|
+
import type { AudioController } from './hooks/AudioController';
|
|
25
25
|
import { DocPlayer } from './DocPlayer';
|
|
26
26
|
import { DocControlsSidebar } from './DocControlsSidebar';
|
|
27
27
|
import type { PlaybackState, PlaybackActions } from './types';
|
|
28
28
|
|
|
29
29
|
interface DocPlayerWithSidebarProps {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** The Doc to play */
|
|
31
|
+
doc: Doc;
|
|
32
|
+
/** Base path for resolving media URLs (default: `'.'`) */
|
|
33
|
+
basePath?: string;
|
|
32
34
|
autoPlay?: boolean;
|
|
33
35
|
onEnded?: () => void;
|
|
34
36
|
onTimeUpdate?: (time: number) => void;
|
|
35
|
-
|
|
37
|
+
/** Optional audio controller (if not provided, uses default HTML5 audio) */
|
|
38
|
+
audioController?: AudioController;
|
|
36
39
|
muted?: boolean;
|
|
37
40
|
captionsEnabled?: boolean;
|
|
38
41
|
isFullscreen?: boolean;
|
|
@@ -41,6 +44,13 @@ interface DocPlayerWithSidebarProps {
|
|
|
41
44
|
forceViewport?: ViewportConfig;
|
|
42
45
|
/** Called when playing state changes */
|
|
43
46
|
onPlayingChange?: (isPlaying: boolean) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Theme for rendering. Forwarded to the inner DocPlayer so the sidebar
|
|
49
|
+
* (portrait) layout matches the default (landscape) layout — without it the
|
|
50
|
+
* inner player falls back to DEFAULT_THEME, whose dark text is unreadable
|
|
51
|
+
* over a hero cover image.
|
|
52
|
+
*/
|
|
53
|
+
theme?: Theme;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const DEFAULT_STATE: PlaybackState = {
|
|
@@ -59,18 +69,19 @@ const DEFAULT_STATE: PlaybackState = {
|
|
|
59
69
|
};
|
|
60
70
|
|
|
61
71
|
export function DocPlayerWithSidebar({
|
|
62
|
-
|
|
72
|
+
doc,
|
|
63
73
|
basePath,
|
|
64
74
|
autoPlay = false,
|
|
65
75
|
onEnded,
|
|
66
76
|
onTimeUpdate,
|
|
67
|
-
|
|
77
|
+
audioController,
|
|
68
78
|
muted,
|
|
69
79
|
captionsEnabled,
|
|
70
80
|
isFullscreen,
|
|
71
81
|
onFullscreenToggle,
|
|
72
82
|
forceViewport,
|
|
73
83
|
onPlayingChange,
|
|
84
|
+
theme,
|
|
74
85
|
}: DocPlayerWithSidebarProps) {
|
|
75
86
|
// Store playback state in a ref to avoid triggering re-renders from DocPlayer callbacks
|
|
76
87
|
const stateRef = useRef<PlaybackState>(DEFAULT_STATE);
|
|
@@ -114,12 +125,13 @@ export function DocPlayerWithSidebar({
|
|
|
114
125
|
<div className="doc-player-sidebar-layout">
|
|
115
126
|
<div className="doc-player-sidebar-layout__video">
|
|
116
127
|
<DocPlayer
|
|
117
|
-
|
|
128
|
+
doc={doc}
|
|
129
|
+
theme={theme}
|
|
118
130
|
basePath={basePath}
|
|
119
131
|
autoPlay={autoPlay}
|
|
120
132
|
onEnded={onEnded}
|
|
121
133
|
onTimeUpdate={onTimeUpdate}
|
|
122
|
-
|
|
134
|
+
audioController={audioController}
|
|
123
135
|
muted={muted}
|
|
124
136
|
captionsEnabled={captionsEnabled}
|
|
125
137
|
showControls={isFullscreen}
|
package/src/DocProgressBar.tsx
CHANGED
|
@@ -41,6 +41,15 @@ export function DocProgressBar({
|
|
|
41
41
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
42
42
|
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
|
|
43
43
|
|
|
44
|
+
// Fill position tracks the same timeline as the clock readout and the block
|
|
45
|
+
// marker dots — elapsed playback time over the audio `totalDuration`. (We
|
|
46
|
+
// deliberately don't use `state.docProgress`, which is keyed to the doc's
|
|
47
|
+
// estimated `script.duration`; when that diverges from the actual playback
|
|
48
|
+
// length — e.g. the editor preview's reading-time estimate — the fill would
|
|
49
|
+
// desync from the clock and the dots.)
|
|
50
|
+
const playProgress =
|
|
51
|
+
state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
|
|
52
|
+
|
|
44
53
|
const handleProgressHover = useCallback((e: React.MouseEvent) => {
|
|
45
54
|
const bar = progressBarRef.current;
|
|
46
55
|
if (!bar) return;
|
|
@@ -99,16 +108,25 @@ export function DocProgressBar({
|
|
|
99
108
|
}}
|
|
100
109
|
/>
|
|
101
110
|
|
|
102
|
-
{/* Progress fill
|
|
111
|
+
{/* Progress fill.
|
|
112
|
+
No CSS `width` transition: the fill is driven by frequent JS updates
|
|
113
|
+
(the synthetic fallback timer runs ~60fps; real audio fires
|
|
114
|
+
`timeupdate` several times/sec), so it's already smooth. A CSS
|
|
115
|
+
transition here actively breaks playback — after a discontinuous
|
|
116
|
+
backward seek, the per-frame inline-width updates on resume keep
|
|
117
|
+
interrupting the in-flight transition before it advances, so the
|
|
118
|
+
*rendered* width latches at the seek position even though the inline
|
|
119
|
+
style (and the clock) keep climbing. The result is a frozen-looking
|
|
120
|
+
bar while time advances. Update width instantly instead. */}
|
|
103
121
|
<div
|
|
122
|
+
data-testid="doc-progress-fill"
|
|
104
123
|
style={{
|
|
105
124
|
position: 'absolute',
|
|
106
125
|
left: 0,
|
|
107
|
-
width: `${
|
|
126
|
+
width: `${playProgress * 100}%`,
|
|
108
127
|
height: '6px',
|
|
109
128
|
background: '#5b9bd5',
|
|
110
129
|
borderRadius: '3px',
|
|
111
|
-
transition: 'width 0.1s',
|
|
112
130
|
}}
|
|
113
131
|
/>
|
|
114
132
|
|
package/src/LinearDocView.tsx
CHANGED
|
@@ -28,18 +28,32 @@ import {
|
|
|
28
28
|
type Theme,
|
|
29
29
|
} from '@bendyline/squisq/schemas';
|
|
30
30
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
getLayers,
|
|
33
|
+
hasTemplate,
|
|
34
|
+
markdownToDoc,
|
|
35
|
+
DEFAULT_THEME,
|
|
36
|
+
deriveTemplateInputs,
|
|
37
|
+
} from '@bendyline/squisq/doc';
|
|
32
38
|
import type { RenderContext } from '@bendyline/squisq/doc';
|
|
33
|
-
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
34
|
-
import type { MarkdownBlockNode, MarkdownList, MarkdownTable } from '@bendyline/squisq/markdown';
|
|
39
|
+
import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
|
|
35
40
|
import { BlockRenderer } from './BlockRenderer';
|
|
36
41
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
|
37
42
|
|
|
38
43
|
// ── Props ──────────────────────────────────────────────────────────
|
|
39
44
|
|
|
40
45
|
export interface LinearDocViewProps {
|
|
41
|
-
/**
|
|
42
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The Doc to render. Wins over `markdown` when both are provided.
|
|
48
|
+
* When neither `doc` nor `markdown` is given, an empty container renders.
|
|
49
|
+
*/
|
|
50
|
+
doc?: Doc;
|
|
51
|
+
/**
|
|
52
|
+
* Markdown source to render. When `doc` is absent, the markdown is parsed
|
|
53
|
+
* and converted to a Doc via `markdownToDoc(parseMarkdown(markdown))`.
|
|
54
|
+
* Ignored when `doc` is provided.
|
|
55
|
+
*/
|
|
56
|
+
markdown?: string;
|
|
43
57
|
/** Base path for resolving media URLs (images, etc.) */
|
|
44
58
|
basePath?: string;
|
|
45
59
|
/** Viewport config for SVG card rendering (default: landscape) */
|
|
@@ -79,16 +93,33 @@ export type ImageDisplayMode = 'inline' | 'thumbnail';
|
|
|
79
93
|
|
|
80
94
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
81
95
|
|
|
96
|
+
// Unknown template names we've already warned about (module-level so each
|
|
97
|
+
// name warns at most once per page, not once per render).
|
|
98
|
+
const warnedUnknownTemplates = new Set<string>();
|
|
99
|
+
|
|
82
100
|
/**
|
|
83
101
|
* Determine whether a block has a template annotation that should be
|
|
84
102
|
* rendered as a visual SVG card. A block is "annotated" when:
|
|
85
103
|
* 1. Its sourceHeading has a templateAnnotation, AND
|
|
86
104
|
* 2. The annotated template exists in the registry
|
|
105
|
+
*
|
|
106
|
+
* Blocks annotated with a template that is NOT in the registry fall back
|
|
107
|
+
* to plain markdown rendering, with a one-shot dev-visible warning per
|
|
108
|
+
* unknown template name.
|
|
87
109
|
*/
|
|
88
110
|
function isAnnotatedBlock(block: Block): boolean {
|
|
89
111
|
const annotation = block.sourceHeading?.templateAnnotation;
|
|
90
|
-
if (!annotation) return false;
|
|
91
|
-
|
|
112
|
+
if (!annotation?.template) return false;
|
|
113
|
+
if (!hasTemplate(annotation.template)) {
|
|
114
|
+
if (!warnedUnknownTemplates.has(annotation.template)) {
|
|
115
|
+
warnedUnknownTemplates.add(annotation.template);
|
|
116
|
+
console.warn(
|
|
117
|
+
`[squisq] Unknown template "${annotation.template}" — rendering the block as plain markdown.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
/**
|
|
@@ -126,7 +157,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
126
157
|
|
|
127
158
|
const annotation = block.sourceHeading!.templateAnnotation!;
|
|
128
159
|
const headingText = extractPlainText(block.sourceHeading!);
|
|
129
|
-
const bodyText = extractBodyPlainText(block.contents);
|
|
130
160
|
|
|
131
161
|
// Build a TemplateBlock-compatible object
|
|
132
162
|
const templateBlock: Record<string, unknown> = {
|
|
@@ -136,12 +166,14 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
136
166
|
duration: 1,
|
|
137
167
|
audioSegment: 0,
|
|
138
168
|
title: headingText,
|
|
139
|
-
...
|
|
169
|
+
...(deriveTemplateInputs(
|
|
140
170
|
annotation.template ?? 'sectionHeader',
|
|
141
171
|
headingText,
|
|
142
|
-
bodyText,
|
|
143
172
|
block.contents,
|
|
144
|
-
|
|
173
|
+
{
|
|
174
|
+
placeholders: true,
|
|
175
|
+
},
|
|
176
|
+
) ?? {}),
|
|
145
177
|
...annotation.params,
|
|
146
178
|
...block.templateOverrides,
|
|
147
179
|
};
|
|
@@ -221,198 +253,6 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
221
253
|
);
|
|
222
254
|
}
|
|
223
255
|
|
|
224
|
-
// ── Template Defaults (mirrored from PreviewPanel) ─────────────────
|
|
225
|
-
|
|
226
|
-
/** Extract plain text from block contents. */
|
|
227
|
-
function extractBodyPlainText(contents?: MarkdownBlockNode[]): string {
|
|
228
|
-
if (!contents || contents.length === 0) return '';
|
|
229
|
-
return contents
|
|
230
|
-
.map((n) => extractPlainText(n))
|
|
231
|
-
.join('\n')
|
|
232
|
-
.trim();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Extract list items as plain text. */
|
|
236
|
-
function extractListItems(contents?: MarkdownBlockNode[]): string[] {
|
|
237
|
-
if (!contents) return [];
|
|
238
|
-
const items: string[] = [];
|
|
239
|
-
for (const node of contents) {
|
|
240
|
-
if (node.type === 'list') {
|
|
241
|
-
for (const item of (node as MarkdownList).children) {
|
|
242
|
-
const text = extractPlainText(item).trim();
|
|
243
|
-
if (text) items.push(text);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return items;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** First image discovered in a block's body, with any explicit
|
|
251
|
-
* dimensions captured (set by `<img width>` / `<img height>` in raw
|
|
252
|
-
* HTML — markdown shorthand has no syntax for dimensions). */
|
|
253
|
-
interface FirstImage {
|
|
254
|
-
src: string;
|
|
255
|
-
alt: string;
|
|
256
|
-
width?: number;
|
|
257
|
-
height?: number;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Find the first image referenced anywhere in block contents — both
|
|
262
|
-
* markdown shorthand `` (type `image`) and raw HTML `<img>`
|
|
263
|
-
* tags (type `htmlBlock`/`htmlInline`). The WYSIWYG editor emits the
|
|
264
|
-
* HTML form whenever a user resizes an image (markdown shorthand has no
|
|
265
|
-
* width syntax), so missing that path silently breaks every resized
|
|
266
|
-
* image in the linear view.
|
|
267
|
-
*/
|
|
268
|
-
function extractFirstImage(contents: MarkdownBlockNode[] | undefined): FirstImage | null {
|
|
269
|
-
if (!contents || contents.length === 0) return null;
|
|
270
|
-
|
|
271
|
-
function fromHtml(nodes: unknown[]): FirstImage | null {
|
|
272
|
-
for (const node of nodes) {
|
|
273
|
-
if (!node || typeof node !== 'object') continue;
|
|
274
|
-
const n = node as Record<string, unknown>;
|
|
275
|
-
if (n.type === 'htmlElement' && n.tagName === 'img') {
|
|
276
|
-
const attrs = n.attributes as Record<string, string> | undefined;
|
|
277
|
-
if (attrs && typeof attrs.src === 'string' && attrs.src) {
|
|
278
|
-
return {
|
|
279
|
-
src: attrs.src,
|
|
280
|
-
alt: typeof attrs.alt === 'string' ? attrs.alt : '',
|
|
281
|
-
width: parseDim(attrs.width),
|
|
282
|
-
height: parseDim(attrs.height),
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (Array.isArray(n.children)) {
|
|
287
|
-
const found = fromHtml(n.children);
|
|
288
|
-
if (found) return found;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function walk(node: unknown): FirstImage | null {
|
|
295
|
-
if (!node || typeof node !== 'object') return null;
|
|
296
|
-
const n = node as Record<string, unknown>;
|
|
297
|
-
if (n.type === 'image' && typeof n.url === 'string' && n.url) {
|
|
298
|
-
return { src: n.url, alt: typeof n.alt === 'string' ? n.alt : '' };
|
|
299
|
-
}
|
|
300
|
-
if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
|
|
301
|
-
const found = fromHtml(n.htmlChildren);
|
|
302
|
-
if (found) return found;
|
|
303
|
-
}
|
|
304
|
-
if (Array.isArray(n.children)) {
|
|
305
|
-
for (const child of n.children) {
|
|
306
|
-
const found = walk(child);
|
|
307
|
-
if (found) return found;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
for (const node of contents) {
|
|
314
|
-
const found = walk(node);
|
|
315
|
-
if (found) return found;
|
|
316
|
-
}
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Parse a `width`/`height` HTML attribute to a positive number. */
|
|
321
|
-
function parseDim(raw: string | undefined): number | undefined {
|
|
322
|
-
if (raw === undefined) return undefined;
|
|
323
|
-
const n = parseFloat(raw);
|
|
324
|
-
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** Extract table data (headers, rows, alignment) from block contents. */
|
|
328
|
-
function extractTableData(contents?: MarkdownBlockNode[]): {
|
|
329
|
-
headers: string[];
|
|
330
|
-
rows: string[][];
|
|
331
|
-
align?: (('left' | 'right' | 'center') | null)[];
|
|
332
|
-
} | null {
|
|
333
|
-
if (!contents) return null;
|
|
334
|
-
for (const node of contents) {
|
|
335
|
-
if (node.type === 'table') {
|
|
336
|
-
const table = node as MarkdownTable;
|
|
337
|
-
const [headerRow, ...bodyRows] = table.children;
|
|
338
|
-
if (!headerRow) return null;
|
|
339
|
-
const headers = headerRow.children.map((cell) => extractPlainText(cell).trim());
|
|
340
|
-
const rows = bodyRows.map((row) => row.children.map((cell) => extractPlainText(cell).trim()));
|
|
341
|
-
return { headers, rows, align: table.align };
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Provide sensible default fields for templates that require more than
|
|
349
|
-
* just a `title`. Prevents crashes from undefined required fields.
|
|
350
|
-
*/
|
|
351
|
-
function getTemplateDefaults(
|
|
352
|
-
templateName: string,
|
|
353
|
-
headingText: string,
|
|
354
|
-
bodyText: string,
|
|
355
|
-
contents?: MarkdownBlockNode[],
|
|
356
|
-
): Record<string, unknown> {
|
|
357
|
-
switch (templateName) {
|
|
358
|
-
case 'statHighlight':
|
|
359
|
-
return { stat: headingText, description: bodyText || headingText };
|
|
360
|
-
case 'quote':
|
|
361
|
-
case 'fullBleedQuote':
|
|
362
|
-
case 'pullQuote':
|
|
363
|
-
return { quote: bodyText || headingText };
|
|
364
|
-
case 'factCard':
|
|
365
|
-
return { fact: headingText, explanation: bodyText || headingText };
|
|
366
|
-
case 'comparisonBar':
|
|
367
|
-
return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
|
|
368
|
-
case 'list': {
|
|
369
|
-
const items = extractListItems(contents);
|
|
370
|
-
return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
|
|
371
|
-
}
|
|
372
|
-
case 'definitionCard':
|
|
373
|
-
return { term: headingText, definition: bodyText || headingText };
|
|
374
|
-
case 'dateEvent':
|
|
375
|
-
return { date: headingText, description: bodyText || headingText };
|
|
376
|
-
case 'dataTable': {
|
|
377
|
-
const tableData = extractTableData(contents);
|
|
378
|
-
return tableData ?? { headers: ['Column'], rows: [['Data']] };
|
|
379
|
-
}
|
|
380
|
-
case 'imageWithCaption': {
|
|
381
|
-
// The template requires `imageSrc` — without it the image layer
|
|
382
|
-
// renders a broken `<img src=undefined>`. Pull the first image
|
|
383
|
-
// out of the block's body (markdown shorthand or HTML <img>) and
|
|
384
|
-
// use the heading as a caption fallback so a bare `# Title
|
|
385
|
-
// [Image with Caption]` block renders something sensible.
|
|
386
|
-
const img = extractFirstImage(contents);
|
|
387
|
-
if (!img) return { caption: headingText };
|
|
388
|
-
return {
|
|
389
|
-
imageSrc: img.src,
|
|
390
|
-
imageAlt: img.alt || headingText,
|
|
391
|
-
caption: headingText,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
case 'leftFeature':
|
|
395
|
-
case 'rightFeature': {
|
|
396
|
-
// Feature blocks pair an image with the heading + the first
|
|
397
|
-
// paragraph of body text. Both inputs come from the section's
|
|
398
|
-
// contents — the image via the same scan used by imageWithCaption,
|
|
399
|
-
// the body text via the plain-text extractor that's already in
|
|
400
|
-
// scope here.
|
|
401
|
-
const img = extractFirstImage(contents);
|
|
402
|
-
return {
|
|
403
|
-
imageSrc: img?.src ?? '',
|
|
404
|
-
imageAlt: img?.alt || headingText,
|
|
405
|
-
imageWidth: img?.width,
|
|
406
|
-
imageHeight: img?.height,
|
|
407
|
-
title: headingText,
|
|
408
|
-
body: bodyText,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
default:
|
|
412
|
-
return {};
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
256
|
// ── Main Component ─────────────────────────────────────────────────
|
|
417
257
|
|
|
418
258
|
/**
|
|
@@ -429,6 +269,7 @@ function getTemplateDefaults(
|
|
|
429
269
|
*/
|
|
430
270
|
export function LinearDocView({
|
|
431
271
|
doc,
|
|
272
|
+
markdown,
|
|
432
273
|
basePath = '/',
|
|
433
274
|
viewport,
|
|
434
275
|
className,
|
|
@@ -438,20 +279,42 @@ export function LinearDocView({
|
|
|
438
279
|
imageDisplayMode = 'inline',
|
|
439
280
|
}: LinearDocViewProps) {
|
|
440
281
|
const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
|
|
441
|
-
|
|
282
|
+
|
|
283
|
+
// Parse markdown into a Doc only when no explicit doc is supplied.
|
|
284
|
+
const markdownDoc = useMemo(
|
|
285
|
+
() => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
|
|
286
|
+
[doc, markdown],
|
|
287
|
+
);
|
|
288
|
+
const resolvedDoc = doc ?? markdownDoc;
|
|
289
|
+
|
|
290
|
+
const totalBlocks = useMemo(
|
|
291
|
+
() => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
|
|
292
|
+
[resolvedDoc],
|
|
293
|
+
);
|
|
442
294
|
const autoSurface = useAutoSurface(surface === 'auto');
|
|
443
295
|
const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
|
|
444
296
|
|
|
445
297
|
const renderContext: RenderContext = useMemo(() => {
|
|
446
298
|
const baseTheme = theme ?? DEFAULT_THEME;
|
|
299
|
+
const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
|
|
447
300
|
return {
|
|
448
|
-
theme:
|
|
301
|
+
theme: effectiveTheme,
|
|
449
302
|
viewport: activeViewport,
|
|
450
303
|
totalBlocks,
|
|
304
|
+
// Theme atmosphere (vignette/grain/gradient persistent layers) shows
|
|
305
|
+
// on the inline template cards so they match the player's look.
|
|
306
|
+
persistentLayers: effectiveTheme.persistentLayers,
|
|
451
307
|
};
|
|
452
308
|
}, [activeViewport, totalBlocks, theme, resolvedSurface]);
|
|
453
309
|
|
|
454
310
|
const activeTheme = renderContext.theme!;
|
|
311
|
+
|
|
312
|
+
// Nothing to render — keep an empty (but classed) container so hosts can
|
|
313
|
+
// still target/measure the view.
|
|
314
|
+
if (!resolvedDoc) {
|
|
315
|
+
return <div className={`squisq-linear squisq-linear--empty ${className || ''}`} />;
|
|
316
|
+
}
|
|
317
|
+
|
|
455
318
|
const bgColor = activeTheme.colors.background;
|
|
456
319
|
const textColor = activeTheme.colors.text;
|
|
457
320
|
const mutedColor = activeTheme.colors.textMuted;
|
|
@@ -614,7 +477,7 @@ export function LinearDocView({
|
|
|
614
477
|
background: color-mix(in srgb, var(--squisq-linear-primary) 8%, transparent);
|
|
615
478
|
}
|
|
616
479
|
`}</style>
|
|
617
|
-
{
|
|
480
|
+
{resolvedDoc.blocks.map((block, i) => (
|
|
618
481
|
<BlockSection
|
|
619
482
|
key={block.id}
|
|
620
483
|
block={block}
|