@bendyline/squisq-react 1.1.2 → 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 +27 -8
- 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 +36 -12
- package/dist/LinearDocView.js.map +1 -1
- package/dist/MarkdownRenderer.d.ts.map +1 -1
- package/dist/MarkdownRenderer.js +12 -2
- 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/squisq-player.global.js +18 -8
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +6 -6
- package/src/DocPlayer.tsx +44 -8
- package/src/LinearDocView.tsx +63 -13
- package/src/MarkdownRenderer.tsx +33 -2
- package/src/__tests__/LinearDocView.test.tsx +35 -0
- package/src/hooks/useAutoSurface.ts +33 -0
- package/src/index.ts +1 -0
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,14 +333,25 @@ 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);
|
|
326
356
|
// Intentionally no cleanup: if coverBlock's memoized reference changes
|
|
327
357
|
// mid-grace (e.g., due to a preview re-render), clearing the timer would
|
|
@@ -344,7 +374,7 @@ export function DocPlayer({
|
|
|
344
374
|
coverBlock &&
|
|
345
375
|
(coverForced ||
|
|
346
376
|
coverGraceActive ||
|
|
347
|
-
(!isPlaying && currentTime === 0 && !renderMode && !autoPlay));
|
|
377
|
+
(!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
|
|
348
378
|
|
|
349
379
|
// Auto-play if enabled (wait for audio to be ready)
|
|
350
380
|
// Use a ref to track if we've already auto-played to avoid repeating on every render
|
|
@@ -785,7 +815,13 @@ export function DocPlayer({
|
|
|
785
815
|
overflow: 'hidden',
|
|
786
816
|
}}
|
|
787
817
|
>
|
|
788
|
-
<LinearDocView
|
|
818
|
+
<LinearDocView
|
|
819
|
+
doc={script}
|
|
820
|
+
basePath={basePath}
|
|
821
|
+
viewport={activeViewport}
|
|
822
|
+
theme={theme}
|
|
823
|
+
surface={surface}
|
|
824
|
+
/>
|
|
789
825
|
</div>
|
|
790
826
|
);
|
|
791
827
|
}
|
|
@@ -857,7 +893,7 @@ export function DocPlayer({
|
|
|
857
893
|
enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
|
|
858
894
|
fontSize={16}
|
|
859
895
|
captionStyle={activeCaptionStyle}
|
|
860
|
-
theme={
|
|
896
|
+
theme={effectiveTheme}
|
|
861
897
|
viewport={activeViewport}
|
|
862
898
|
/>
|
|
863
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
|
/**
|
|
@@ -294,18 +322,23 @@ export function LinearDocView({
|
|
|
294
322
|
viewport,
|
|
295
323
|
className,
|
|
296
324
|
theme,
|
|
325
|
+
surface,
|
|
326
|
+
thinMargins = false,
|
|
327
|
+
imageDisplayMode = 'inline',
|
|
297
328
|
}: LinearDocViewProps) {
|
|
298
329
|
const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
|
|
299
330
|
const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
|
|
331
|
+
const autoSurface = useAutoSurface(surface === 'auto');
|
|
332
|
+
const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
|
|
300
333
|
|
|
301
|
-
const renderContext: RenderContext = useMemo(
|
|
302
|
-
|
|
303
|
-
|
|
334
|
+
const renderContext: RenderContext = useMemo(() => {
|
|
335
|
+
const baseTheme = theme ?? DEFAULT_THEME;
|
|
336
|
+
return {
|
|
337
|
+
theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
|
|
304
338
|
viewport: activeViewport,
|
|
305
339
|
totalBlocks,
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
);
|
|
340
|
+
};
|
|
341
|
+
}, [activeViewport, totalBlocks, theme, resolvedSurface]);
|
|
309
342
|
|
|
310
343
|
const activeTheme = renderContext.theme!;
|
|
311
344
|
const bgColor = activeTheme.colors.background;
|
|
@@ -321,19 +354,28 @@ export function LinearDocView({
|
|
|
321
354
|
className={`squisq-linear ${className || ''}`}
|
|
322
355
|
style={{
|
|
323
356
|
width: '100%',
|
|
324
|
-
|
|
325
|
-
|
|
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',
|
|
326
364
|
overflowX: 'hidden',
|
|
327
365
|
background: bgColor,
|
|
328
366
|
}}
|
|
329
367
|
>
|
|
330
368
|
<div
|
|
331
|
-
className=
|
|
369
|
+
className={`squisq-linear-content squisq-md${thinMargins ? ' squisq-linear-content--thin' : ''}${imageDisplayMode === 'thumbnail' ? ' squisq-linear-content--thumbnail-images' : ''}`}
|
|
332
370
|
style={
|
|
333
371
|
{
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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',
|
|
337
379
|
lineHeight: lineHt,
|
|
338
380
|
fontSize: '16px',
|
|
339
381
|
fontFamily: bodyFont,
|
|
@@ -414,6 +456,14 @@ export function LinearDocView({
|
|
|
414
456
|
border-radius: 6px;
|
|
415
457
|
margin: 0.5em 0;
|
|
416
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
|
+
}
|
|
417
467
|
.squisq-linear-content strong {
|
|
418
468
|
font-weight: 700;
|
|
419
469
|
}
|
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -40,8 +40,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
40
40
|
return nodes.map((node, i) => {
|
|
41
41
|
const key = `${keyPrefix}i${i}`;
|
|
42
42
|
switch (node.type) {
|
|
43
|
-
case 'text':
|
|
44
|
-
|
|
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
|
+
}
|
|
45
62
|
|
|
46
63
|
case 'emphasis':
|
|
47
64
|
return (
|
|
@@ -138,6 +155,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
138
155
|
</span>
|
|
139
156
|
);
|
|
140
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
|
+
|
|
141
172
|
default:
|
|
142
173
|
return null;
|
|
143
174
|
}
|
|
@@ -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';
|