@bendyline/squisq-react 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DocPlayer.d.ts +9 -2
- package/dist/DocPlayer.d.ts.map +1 -1
- package/dist/DocPlayer.js +33 -9
- package/dist/DocPlayer.js.map +1 -1
- package/dist/LinearDocView.d.ts +28 -2
- package/dist/LinearDocView.d.ts.map +1 -1
- package/dist/LinearDocView.js +46 -14
- package/dist/LinearDocView.js.map +1 -1
- package/dist/MarkdownRenderer.d.ts.map +1 -1
- package/dist/MarkdownRenderer.js +20 -3
- package/dist/MarkdownRenderer.js.map +1 -1
- package/dist/__tests__/LinearDocView.test.js +28 -0
- package/dist/__tests__/LinearDocView.test.js.map +1 -1
- package/dist/hooks/useAutoSurface.d.ts +11 -0
- package/dist/hooks/useAutoSurface.d.ts.map +1 -0
- package/dist/hooks/useAutoSurface.js +24 -0
- package/dist/hooks/useAutoSurface.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layers/ImageLayer.js +6 -6
- package/dist/layers/ImageLayer.js.map +1 -1
- package/dist/layers/ShapeLayer.js +2 -2
- package/dist/layers/ShapeLayer.js.map +1 -1
- package/dist/layers/TableLayer.js +2 -2
- package/dist/layers/TableLayer.js.map +1 -1
- package/dist/layers/VideoLayer.js +2 -2
- package/dist/layers/VideoLayer.js.map +1 -1
- package/dist/squisq-player.global.js +24 -6
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-entry.d.ts.map +1 -1
- package/dist/standalone-entry.js +14 -1
- package/dist/standalone-entry.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +6 -6
- package/src/DocPlayer.tsx +51 -9
- package/src/LinearDocView.tsx +73 -15
- package/src/MarkdownRenderer.tsx +43 -9
- package/src/__tests__/LinearDocView.test.tsx +35 -0
- package/src/hooks/useAutoSurface.ts +33 -0
- package/src/index.ts +1 -0
- package/src/layers/ImageLayer.tsx +6 -6
- package/src/layers/ShapeLayer.tsx +2 -2
- package/src/layers/TableLayer.tsx +2 -2
- package/src/layers/VideoLayer.tsx +2 -2
- package/src/standalone-entry.tsx +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "React component library for doc playback, block rendering, and media layers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -31,14 +31,14 @@
|
|
|
31
31
|
],
|
|
32
32
|
"exports": {
|
|
33
33
|
".": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
36
|
},
|
|
37
37
|
"./styles": "./src/styles/doc-animations.css",
|
|
38
38
|
"./standalone": "./dist/squisq-player.global.js",
|
|
39
39
|
"./standalone-source": {
|
|
40
|
-
"
|
|
41
|
-
"
|
|
40
|
+
"types": "./dist/standalone-source.d.ts",
|
|
41
|
+
"import": "./dist/standalone-source.js"
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@bendyline/squisq": "1.
|
|
55
|
+
"@bendyline/squisq": "1.3.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "18.3.28",
|
package/src/DocPlayer.tsx
CHANGED
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|
26
26
|
import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
|
|
27
27
|
import { isTemplateBlock, getCaptionAtTime } from '@bendyline/squisq/schemas';
|
|
28
|
-
import type { Theme } from '@bendyline/squisq/schemas';
|
|
28
|
+
import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
|
|
29
|
+
import { applySurface } from '@bendyline/squisq/schemas';
|
|
29
30
|
import { BlockRenderer } from './BlockRenderer';
|
|
30
31
|
import { CaptionOverlay } from './CaptionOverlay';
|
|
32
|
+
import { useAutoSurface } from './hooks/useAutoSurface';
|
|
31
33
|
import { useAudioSync } from './hooks/useAudioSync';
|
|
32
34
|
import { useDocPlayback } from './hooks/useDocPlayback';
|
|
33
35
|
import { useViewportOrientation } from './hooks/useViewportOrientation';
|
|
@@ -162,6 +164,13 @@ interface DocPlayerProps {
|
|
|
162
164
|
forceViewport?: ViewportConfig;
|
|
163
165
|
/** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
|
|
164
166
|
theme?: Theme;
|
|
167
|
+
/**
|
|
168
|
+
* Optional surface scheme (light / dark paper) overlaid on top of the
|
|
169
|
+
* theme's colors. Passed through to the underlying LinearDocView when
|
|
170
|
+
* `displayMode === 'linear'`; otherwise overlaid onto the theme that
|
|
171
|
+
* renders the player's SVG blocks.
|
|
172
|
+
*/
|
|
173
|
+
surface?: SurfaceScheme | 'auto';
|
|
165
174
|
/**
|
|
166
175
|
* Display mode for the player.
|
|
167
176
|
* - `'video'` (default) — Traditional video playback with play/pause, scrub bar, auto-advance.
|
|
@@ -197,6 +206,7 @@ export function DocPlayer({
|
|
|
197
206
|
forceViewport,
|
|
198
207
|
displayMode = 'video',
|
|
199
208
|
theme,
|
|
209
|
+
surface,
|
|
200
210
|
captionStyle = 'standard',
|
|
201
211
|
}: DocPlayerProps) {
|
|
202
212
|
const isSlideshowMode = displayMode === 'slideshow';
|
|
@@ -275,6 +285,15 @@ export function DocPlayer({
|
|
|
275
285
|
[renderMode, toggle, isPlaying, isSlideshowMode, isLinearMode],
|
|
276
286
|
);
|
|
277
287
|
|
|
288
|
+
// Resolve surface (light/dark paper) and apply it to the theme before
|
|
289
|
+
// handing off to downstream renderers. Orthogonal to the editorial theme.
|
|
290
|
+
const autoSurface = useAutoSurface(surface === 'auto');
|
|
291
|
+
const resolvedSurface = surface === 'auto' ? autoSurface : surface;
|
|
292
|
+
const effectiveTheme = useMemo(() => {
|
|
293
|
+
const base = theme ?? DEFAULT_THEME;
|
|
294
|
+
return resolvedSurface ? applySurface(base, resolvedSurface) : base;
|
|
295
|
+
}, [theme, resolvedSurface]);
|
|
296
|
+
|
|
278
297
|
// Doc playback hook - pass viewport for responsive template expansion
|
|
279
298
|
const {
|
|
280
299
|
currentBlock,
|
|
@@ -288,14 +307,14 @@ export function DocPlayer({
|
|
|
288
307
|
nextBlock: _nextBlock,
|
|
289
308
|
prevBlock: _prevBlock,
|
|
290
309
|
blocks: expandedBlocks,
|
|
291
|
-
} = useDocPlayback(script, currentTime, activeViewport, renderMode,
|
|
310
|
+
} = useDocPlayback(script, currentTime, activeViewport, renderMode, effectiveTheme);
|
|
292
311
|
|
|
293
312
|
// Expand cover block (startBlock) if present - uses active viewport
|
|
294
313
|
const coverBlock = useMemo((): Block | null => {
|
|
295
314
|
const startBlockConfig = script.startBlock as StartBlockConfig | undefined;
|
|
296
315
|
if (!startBlockConfig) return null;
|
|
297
316
|
|
|
298
|
-
const context = createTemplateContext(
|
|
317
|
+
const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
|
|
299
318
|
const layers = expandCoverBlock(startBlockConfig, context);
|
|
300
319
|
|
|
301
320
|
return {
|
|
@@ -305,7 +324,7 @@ export function DocPlayer({
|
|
|
305
324
|
audioSegment: -1,
|
|
306
325
|
layers,
|
|
307
326
|
};
|
|
308
|
-
}, [script.startBlock, activeViewport,
|
|
327
|
+
}, [script.startBlock, activeViewport, effectiveTheme]);
|
|
309
328
|
|
|
310
329
|
// Render-mode cover block control: allows Playwright to force-show the cover block
|
|
311
330
|
const [coverForced, setCoverForced] = useState(false);
|
|
@@ -314,20 +333,37 @@ export function DocPlayer({
|
|
|
314
333
|
const [coverGraceActive, setCoverGraceActive] = useState(false);
|
|
315
334
|
const coverGraceTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
316
335
|
const coverWasShowing = useRef(false);
|
|
336
|
+
// Track whether playback has ever been initiated — prevents the cover block
|
|
337
|
+
// from re-appearing when paused at currentTime === 0 (e.g., no audio source).
|
|
338
|
+
const hasPlayedOnce = useRef(false);
|
|
317
339
|
|
|
318
340
|
// Track when cover is showing at rest (before play)
|
|
319
|
-
const atRest = !!(
|
|
341
|
+
const atRest = !!(
|
|
342
|
+
coverBlock &&
|
|
343
|
+
!isPlaying &&
|
|
344
|
+
currentTime === 0 &&
|
|
345
|
+
!hasPlayedOnce.current &&
|
|
346
|
+
!renderMode &&
|
|
347
|
+
!autoPlay
|
|
348
|
+
);
|
|
320
349
|
if (atRest) coverWasShowing.current = true;
|
|
321
350
|
|
|
322
351
|
useEffect(() => {
|
|
323
352
|
if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
|
|
324
353
|
coverWasShowing.current = false;
|
|
354
|
+
hasPlayedOnce.current = true;
|
|
325
355
|
setCoverGraceActive(true);
|
|
356
|
+
// Intentionally no cleanup: if coverBlock's memoized reference changes
|
|
357
|
+
// mid-grace (e.g., due to a preview re-render), clearing the timer would
|
|
358
|
+
// leave coverGraceActive stuck at true because the effect body won't
|
|
359
|
+
// re-run (coverWasShowing.current is now false).
|
|
326
360
|
coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
|
|
327
|
-
return () => clearTimeout(coverGraceTimer.current);
|
|
328
361
|
}
|
|
329
362
|
}, [isPlaying, coverBlock, renderMode]);
|
|
330
363
|
|
|
364
|
+
// Always clear the grace timer on unmount
|
|
365
|
+
useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
|
|
366
|
+
|
|
331
367
|
// Determine if we should show the cover block
|
|
332
368
|
// Show cover when: has cover block, not playing, at time 0, not in render mode
|
|
333
369
|
// OR during the grace period after first play, OR when coverForced (render mode)
|
|
@@ -338,7 +374,7 @@ export function DocPlayer({
|
|
|
338
374
|
coverBlock &&
|
|
339
375
|
(coverForced ||
|
|
340
376
|
coverGraceActive ||
|
|
341
|
-
(!isPlaying && currentTime === 0 && !renderMode && !autoPlay));
|
|
377
|
+
(!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
|
|
342
378
|
|
|
343
379
|
// Auto-play if enabled (wait for audio to be ready)
|
|
344
380
|
// Use a ref to track if we've already auto-played to avoid repeating on every render
|
|
@@ -779,7 +815,13 @@ export function DocPlayer({
|
|
|
779
815
|
overflow: 'hidden',
|
|
780
816
|
}}
|
|
781
817
|
>
|
|
782
|
-
<LinearDocView
|
|
818
|
+
<LinearDocView
|
|
819
|
+
doc={script}
|
|
820
|
+
basePath={basePath}
|
|
821
|
+
viewport={activeViewport}
|
|
822
|
+
theme={theme}
|
|
823
|
+
surface={surface}
|
|
824
|
+
/>
|
|
783
825
|
</div>
|
|
784
826
|
);
|
|
785
827
|
}
|
|
@@ -851,7 +893,7 @@ export function DocPlayer({
|
|
|
851
893
|
enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
|
|
852
894
|
fontSize={16}
|
|
853
895
|
captionStyle={activeCaptionStyle}
|
|
854
|
-
theme={
|
|
896
|
+
theme={effectiveTheme}
|
|
855
897
|
viewport={activeViewport}
|
|
856
898
|
/>
|
|
857
899
|
)}
|
package/src/LinearDocView.tsx
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { useMemo } from 'react';
|
|
21
|
+
import { useAutoSurface } from './hooks/useAutoSurface';
|
|
21
22
|
import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
|
|
22
23
|
import type { ViewportConfig } from '@bendyline/squisq/schemas';
|
|
23
|
-
import type
|
|
24
|
+
import { applySurface, type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
|
|
24
25
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
25
26
|
import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
|
|
26
27
|
import type { RenderContext } from '@bendyline/squisq/doc';
|
|
@@ -42,8 +43,35 @@ export interface LinearDocViewProps {
|
|
|
42
43
|
className?: string;
|
|
43
44
|
/** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
|
|
44
45
|
theme?: Theme;
|
|
46
|
+
/**
|
|
47
|
+
* Optional surface scheme (light / dark paper) overlaid on top of the
|
|
48
|
+
* theme's colors. Orthogonal to `theme` — a theme picks editorial
|
|
49
|
+
* identity, a surface picks the paper. Pass `'auto'` to follow the
|
|
50
|
+
* user's OS `prefers-color-scheme`, a `SurfaceScheme` object to force a
|
|
51
|
+
* specific surface, or omit to use the theme's built-in colors.
|
|
52
|
+
*/
|
|
53
|
+
surface?: SurfaceScheme | 'auto';
|
|
54
|
+
/**
|
|
55
|
+
* Use tight padding + a wider content column. The default layout is
|
|
56
|
+
* designed for a reading surface with breathing room (720px column,
|
|
57
|
+
* 24×16px padding). Short conversational snippets like chat replies
|
|
58
|
+
* benefit from a much tighter layout. Set to `true` to render with
|
|
59
|
+
* minimal padding and no max-width cap so the content hugs its
|
|
60
|
+
* container.
|
|
61
|
+
*/
|
|
62
|
+
thinMargins?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* How images inside the doc should be sized. `'inline'` (default)
|
|
65
|
+
* flows them at natural size up to the column width; `'thumbnail'`
|
|
66
|
+
* constrains each image to a 100×100 box with aspect-preserving
|
|
67
|
+
* containment — use for chat history and other dense surfaces where
|
|
68
|
+
* full-size images would dominate the layout.
|
|
69
|
+
*/
|
|
70
|
+
imageDisplayMode?: ImageDisplayMode;
|
|
45
71
|
}
|
|
46
72
|
|
|
73
|
+
export type ImageDisplayMode = 'inline' | 'thumbnail';
|
|
74
|
+
|
|
47
75
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
48
76
|
|
|
49
77
|
/**
|
|
@@ -257,8 +285,10 @@ function getTemplateDefaults(
|
|
|
257
285
|
return { fact: headingText, explanation: bodyText || headingText };
|
|
258
286
|
case 'comparisonBar':
|
|
259
287
|
return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
|
|
260
|
-
case 'listBlock':
|
|
261
|
-
|
|
288
|
+
case 'listBlock': {
|
|
289
|
+
const items = extractListItems(contents);
|
|
290
|
+
return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
|
|
291
|
+
}
|
|
262
292
|
case 'definitionCard':
|
|
263
293
|
return { term: headingText, definition: bodyText || headingText };
|
|
264
294
|
case 'dateEvent':
|
|
@@ -292,18 +322,23 @@ export function LinearDocView({
|
|
|
292
322
|
viewport,
|
|
293
323
|
className,
|
|
294
324
|
theme,
|
|
325
|
+
surface,
|
|
326
|
+
thinMargins = false,
|
|
327
|
+
imageDisplayMode = 'inline',
|
|
295
328
|
}: LinearDocViewProps) {
|
|
296
329
|
const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
|
|
297
330
|
const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
|
|
331
|
+
const autoSurface = useAutoSurface(surface === 'auto');
|
|
332
|
+
const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
|
|
298
333
|
|
|
299
|
-
const renderContext: RenderContext = useMemo(
|
|
300
|
-
|
|
301
|
-
|
|
334
|
+
const renderContext: RenderContext = useMemo(() => {
|
|
335
|
+
const baseTheme = theme ?? DEFAULT_THEME;
|
|
336
|
+
return {
|
|
337
|
+
theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
|
|
302
338
|
viewport: activeViewport,
|
|
303
339
|
totalBlocks,
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
);
|
|
340
|
+
};
|
|
341
|
+
}, [activeViewport, totalBlocks, theme, resolvedSurface]);
|
|
307
342
|
|
|
308
343
|
const activeTheme = renderContext.theme!;
|
|
309
344
|
const bgColor = activeTheme.colors.background;
|
|
@@ -319,19 +354,28 @@ export function LinearDocView({
|
|
|
319
354
|
className={`squisq-linear ${className || ''}`}
|
|
320
355
|
style={{
|
|
321
356
|
width: '100%',
|
|
322
|
-
|
|
323
|
-
|
|
357
|
+
// Thin-margins mode is the "embedded in someone else's container"
|
|
358
|
+
// signal (chat bubble, sidebar preview). Fit to content there so
|
|
359
|
+
// the host's bubble doesn't render a tall empty box when the doc
|
|
360
|
+
// is short. Standalone mode keeps height:100% for full-viewport
|
|
361
|
+
// scrolling.
|
|
362
|
+
height: thinMargins ? 'auto' : '100%',
|
|
363
|
+
overflowY: thinMargins ? 'visible' : 'auto',
|
|
324
364
|
overflowX: 'hidden',
|
|
325
365
|
background: bgColor,
|
|
326
366
|
}}
|
|
327
367
|
>
|
|
328
368
|
<div
|
|
329
|
-
className=
|
|
369
|
+
className={`squisq-linear-content squisq-md${thinMargins ? ' squisq-linear-content--thin' : ''}${imageDisplayMode === 'thumbnail' ? ' squisq-linear-content--thumbnail-images' : ''}`}
|
|
330
370
|
style={
|
|
331
371
|
{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
372
|
+
// Thin-margins mode drops the 720px reading column + generous
|
|
373
|
+
// page padding (right for standalone docs) in favor of a tight
|
|
374
|
+
// layout that hugs its container (right for chat bubbles and
|
|
375
|
+
// sidebar previews).
|
|
376
|
+
maxWidth: thinMargins ? 'none' : '720px',
|
|
377
|
+
margin: thinMargins ? '0' : '0 auto',
|
|
378
|
+
padding: thinMargins ? '0' : '24px 16px',
|
|
335
379
|
lineHeight: lineHt,
|
|
336
380
|
fontSize: '16px',
|
|
337
381
|
fontFamily: bodyFont,
|
|
@@ -406,6 +450,20 @@ export function LinearDocView({
|
|
|
406
450
|
border-top: 1px solid var(--squisq-linear-muted);
|
|
407
451
|
margin: 1.5em 0;
|
|
408
452
|
}
|
|
453
|
+
.squisq-linear-content img {
|
|
454
|
+
max-width: 100%;
|
|
455
|
+
height: auto;
|
|
456
|
+
border-radius: 6px;
|
|
457
|
+
margin: 0.5em 0;
|
|
458
|
+
}
|
|
459
|
+
.squisq-linear-content--thumbnail-images img {
|
|
460
|
+
max-width: 100px;
|
|
461
|
+
max-height: 100px;
|
|
462
|
+
width: auto;
|
|
463
|
+
height: auto;
|
|
464
|
+
object-fit: contain;
|
|
465
|
+
display: block;
|
|
466
|
+
}
|
|
409
467
|
.squisq-linear-content strong {
|
|
410
468
|
font-weight: 700;
|
|
411
469
|
}
|
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
MarkdownTableRow,
|
|
23
23
|
MarkdownTableCell,
|
|
24
24
|
} from '@bendyline/squisq/markdown';
|
|
25
|
+
import { useMediaUrl } from './hooks/MediaContext';
|
|
25
26
|
|
|
26
27
|
// ── Props ──────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -39,8 +40,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
39
40
|
return nodes.map((node, i) => {
|
|
40
41
|
const key = `${keyPrefix}i${i}`;
|
|
41
42
|
switch (node.type) {
|
|
42
|
-
case 'text':
|
|
43
|
-
|
|
43
|
+
case 'text': {
|
|
44
|
+
// Soft breaks (newlines without two trailing spaces) become \n in
|
|
45
|
+
// text nodes. HTML collapses \n to whitespace, which loses the visual
|
|
46
|
+
// line break the author wanted, so render <br> for each newline.
|
|
47
|
+
if (!node.value.includes('\n')) {
|
|
48
|
+
return <Fragment key={key}>{node.value}</Fragment>;
|
|
49
|
+
}
|
|
50
|
+
const parts = node.value.split('\n');
|
|
51
|
+
return (
|
|
52
|
+
<Fragment key={key}>
|
|
53
|
+
{parts.map((part, j) => (
|
|
54
|
+
<Fragment key={j}>
|
|
55
|
+
{j > 0 && <br />}
|
|
56
|
+
{part}
|
|
57
|
+
</Fragment>
|
|
58
|
+
))}
|
|
59
|
+
</Fragment>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
44
62
|
|
|
45
63
|
case 'emphasis':
|
|
46
64
|
return (
|
|
@@ -86,13 +104,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
86
104
|
|
|
87
105
|
case 'image':
|
|
88
106
|
return (
|
|
89
|
-
<
|
|
90
|
-
key={key}
|
|
91
|
-
className="squisq-md-image"
|
|
92
|
-
src={node.url}
|
|
93
|
-
alt={node.alt ?? ''}
|
|
94
|
-
title={node.title ?? undefined}
|
|
95
|
-
/>
|
|
107
|
+
<MdImage key={key} src={node.url} alt={node.alt ?? ''} title={node.title ?? undefined} />
|
|
96
108
|
);
|
|
97
109
|
|
|
98
110
|
case 'break':
|
|
@@ -143,6 +155,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
143
155
|
</span>
|
|
144
156
|
);
|
|
145
157
|
|
|
158
|
+
case 'mention':
|
|
159
|
+
return (
|
|
160
|
+
<span
|
|
161
|
+
key={key}
|
|
162
|
+
className="squisq-md-mention mention"
|
|
163
|
+
data-mention="true"
|
|
164
|
+
data-kind={node.targetKind}
|
|
165
|
+
data-id={node.targetId}
|
|
166
|
+
data-label={node.displayName}
|
|
167
|
+
>
|
|
168
|
+
@{node.displayName}
|
|
169
|
+
</span>
|
|
170
|
+
);
|
|
171
|
+
|
|
146
172
|
default:
|
|
147
173
|
return null;
|
|
148
174
|
}
|
|
@@ -343,6 +369,14 @@ function renderBlocks(nodes: MarkdownBlockNode[], keyPrefix = ''): React.ReactNo
|
|
|
343
369
|
return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`));
|
|
344
370
|
}
|
|
345
371
|
|
|
372
|
+
// ── Image with MediaProvider resolution ───────────────────────────
|
|
373
|
+
|
|
374
|
+
/** Renders an <img> that resolves its src through the MediaProvider when available. */
|
|
375
|
+
function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
|
|
376
|
+
const resolved = useMediaUrl(src, '.');
|
|
377
|
+
return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
|
|
378
|
+
}
|
|
379
|
+
|
|
346
380
|
// ── Main Component ─────────────────────────────────────────────────
|
|
347
381
|
|
|
348
382
|
/**
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
5
|
+
import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
|
|
5
6
|
import type {
|
|
6
7
|
MarkdownBlockNode,
|
|
7
8
|
MarkdownInlineNode,
|
|
@@ -177,4 +178,38 @@ describe('LinearDocView', () => {
|
|
|
177
178
|
expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
|
|
178
179
|
expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
|
|
179
180
|
});
|
|
181
|
+
|
|
182
|
+
it('uses the theme background by default', () => {
|
|
183
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
184
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
185
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
186
|
+
expect(el.style.background).toBeTruthy();
|
|
187
|
+
// DEFAULT_THEME has a specific background; applying LIGHT_SURFACE below
|
|
188
|
+
// must produce a different value to prove override is working.
|
|
189
|
+
expect(el.style.background).not.toBe(LIGHT_SURFACE.background);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('light surface overlays the theme background', () => {
|
|
193
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
194
|
+
const { container } = render(
|
|
195
|
+
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={LIGHT_SURFACE} />,
|
|
196
|
+
);
|
|
197
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
198
|
+
// React inline style background may round-trip as hex or rgb; compare the
|
|
199
|
+
// colour-normalized value by mounting a plain div with the expected.
|
|
200
|
+
const probe = document.createElement('div');
|
|
201
|
+
probe.style.background = LIGHT_SURFACE.background;
|
|
202
|
+
expect(el.style.background).toBe(probe.style.background);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('dark surface overlays the theme background', () => {
|
|
206
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
207
|
+
const { container } = render(
|
|
208
|
+
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={DARK_SURFACE} />,
|
|
209
|
+
);
|
|
210
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
211
|
+
const probe = document.createElement('div');
|
|
212
|
+
probe.style.background = DARK_SURFACE.background;
|
|
213
|
+
expect(el.style.background).toBe(probe.style.background);
|
|
214
|
+
});
|
|
180
215
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
import { DARK_SURFACE, LIGHT_SURFACE, type SurfaceScheme } from '@bendyline/squisq/schemas';
|
|
3
|
+
|
|
4
|
+
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
|
5
|
+
const getServerSnapshot = () => LIGHT_SURFACE;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Live-track `prefers-color-scheme` and return a stable SurfaceScheme.
|
|
9
|
+
* `enabled: false` short-circuits to LIGHT_SURFACE (callers pass `false`
|
|
10
|
+
* when a static surface was provided so the hook never observes the
|
|
11
|
+
* media query). The `MediaQueryList` and the `subscribe`/`getSnapshot`
|
|
12
|
+
* callbacks are memoized so `useSyncExternalStore` doesn't resubscribe on
|
|
13
|
+
* every parent render.
|
|
14
|
+
*/
|
|
15
|
+
export function useAutoSurface(enabled: boolean): SurfaceScheme {
|
|
16
|
+
const mql = useMemo(
|
|
17
|
+
() => (enabled && typeof window !== 'undefined' ? window.matchMedia(DARK_QUERY) : null),
|
|
18
|
+
[enabled],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const subscribe = useCallback(
|
|
22
|
+
(cb: () => void) => {
|
|
23
|
+
if (!mql) return () => {};
|
|
24
|
+
mql.addEventListener('change', cb);
|
|
25
|
+
return () => mql.removeEventListener('change', cb);
|
|
26
|
+
},
|
|
27
|
+
[mql],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const getSnapshot = useCallback(() => (mql?.matches ? DARK_SURFACE : LIGHT_SURFACE), [mql]);
|
|
31
|
+
|
|
32
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { DocPlayerWithSidebar } from './DocPlayerWithSidebar.js';
|
|
|
11
11
|
export { DocProgressBar } from './DocProgressBar.js';
|
|
12
12
|
export { MarkdownRenderer } from './MarkdownRenderer.js';
|
|
13
13
|
export { LinearDocView } from './LinearDocView.js';
|
|
14
|
+
export type { LinearDocViewProps, ImageDisplayMode } from './LinearDocView.js';
|
|
14
15
|
|
|
15
16
|
// Layer components
|
|
16
17
|
export { ImageLayer } from './layers/ImageLayer.js';
|
|
@@ -61,8 +61,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
61
61
|
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
62
62
|
<div
|
|
63
63
|
style={{
|
|
64
|
-
width:
|
|
65
|
-
height:
|
|
64
|
+
width: `${width}px`,
|
|
65
|
+
height: `${height}px`,
|
|
66
66
|
overflow: 'hidden',
|
|
67
67
|
}}
|
|
68
68
|
>
|
|
@@ -71,8 +71,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
71
71
|
alt={content.alt || ''}
|
|
72
72
|
className={kbStyle.className}
|
|
73
73
|
style={{
|
|
74
|
-
width:
|
|
75
|
-
height:
|
|
74
|
+
width: `${width}px`,
|
|
75
|
+
height: `${height}px`,
|
|
76
76
|
objectFit: 'cover',
|
|
77
77
|
objectPosition: 'center',
|
|
78
78
|
display: 'block',
|
|
@@ -101,8 +101,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
101
101
|
src={src}
|
|
102
102
|
alt={content.alt || ''}
|
|
103
103
|
style={{
|
|
104
|
-
width:
|
|
105
|
-
height:
|
|
104
|
+
width: `${width}px`,
|
|
105
|
+
height: `${height}px`,
|
|
106
106
|
objectFit: 'cover',
|
|
107
107
|
objectPosition: 'center',
|
|
108
108
|
display: 'block',
|
|
@@ -49,8 +49,8 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
49
49
|
<foreignObject x={x} y={y} width={width} height={height}>
|
|
50
50
|
<div
|
|
51
51
|
style={{
|
|
52
|
-
width:
|
|
53
|
-
height:
|
|
52
|
+
width: `${width}px`,
|
|
53
|
+
height: `${height}px`,
|
|
54
54
|
background: fill,
|
|
55
55
|
borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
|
|
56
56
|
pointerEvents: 'none',
|
|
@@ -51,8 +51,8 @@ export function TableLayer({ layer, viewport, blockTime }: TableLayerProps) {
|
|
|
51
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
52
|
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
|
|
53
53
|
style={{
|
|
54
|
-
width:
|
|
55
|
-
height:
|
|
54
|
+
width: `${width}px`,
|
|
55
|
+
height: `${height}px`,
|
|
56
56
|
display: 'flex',
|
|
57
57
|
alignItems: 'center',
|
|
58
58
|
justifyContent: 'center',
|
|
@@ -134,8 +134,8 @@ export function VideoLayer({
|
|
|
134
134
|
data-clip-start={content.clipStart}
|
|
135
135
|
data-clip-end={content.clipEnd}
|
|
136
136
|
style={{
|
|
137
|
-
width:
|
|
138
|
-
height:
|
|
137
|
+
width: `${width}px`,
|
|
138
|
+
height: `${height}px`,
|
|
139
139
|
objectFit: content.fit || 'cover',
|
|
140
140
|
objectPosition: 'center',
|
|
141
141
|
display: 'block',
|
package/src/standalone-entry.tsx
CHANGED
|
@@ -86,10 +86,22 @@ function createInlineMediaProvider(
|
|
|
86
86
|
images: Record<string, string>,
|
|
87
87
|
basePath: string,
|
|
88
88
|
): MediaProvider {
|
|
89
|
+
// Doc image references may not exactly match image map keys
|
|
90
|
+
// (e.g., "images/hero.jpg" vs "hero.jpg"). Build a filename-keyed
|
|
91
|
+
// lookup so resolution can fall back to basename matching.
|
|
92
|
+
const byFilename: Record<string, string> = {};
|
|
93
|
+
for (const key of Object.keys(images)) {
|
|
94
|
+
const filename = key.split('/').pop()!;
|
|
95
|
+
byFilename[filename] = images[key];
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
return {
|
|
90
99
|
async resolveUrl(relativePath: string): Promise<string> {
|
|
91
100
|
if (relativePath in images) return images[relativePath];
|
|
92
|
-
|
|
101
|
+
const stripped = relativePath.replace(/^\.\//, '');
|
|
102
|
+
if (stripped !== relativePath && stripped in images) return images[stripped];
|
|
103
|
+
const filename = relativePath.split('/').pop()!;
|
|
104
|
+
if (filename in byFilename) return byFilename[filename];
|
|
93
105
|
if (
|
|
94
106
|
relativePath.startsWith('http') ||
|
|
95
107
|
relativePath.startsWith('data:') ||
|