@bendyline/squisq-react 1.4.2 → 2.0.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/README.md +30 -3
- package/dist/index.d.ts +174 -27
- package/dist/index.js +1244 -603
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +54 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +53 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +135 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +79 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +91 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +3 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +4 -2
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/types.ts +13 -13
|
@@ -36,6 +36,8 @@ interface DocPlayerWithSidebarProps {
|
|
|
36
36
|
onTimeUpdate?: (time: number) => void;
|
|
37
37
|
/** Optional audio controller (if not provided, uses default HTML5 audio) */
|
|
38
38
|
audioController?: AudioController;
|
|
39
|
+
/** Whether to render slide transitions and per-layer animations (default: true). */
|
|
40
|
+
animationsEnabled?: boolean;
|
|
39
41
|
muted?: boolean;
|
|
40
42
|
captionsEnabled?: boolean;
|
|
41
43
|
isFullscreen?: boolean;
|
|
@@ -75,6 +77,7 @@ export function DocPlayerWithSidebar({
|
|
|
75
77
|
onEnded,
|
|
76
78
|
onTimeUpdate,
|
|
77
79
|
audioController,
|
|
80
|
+
animationsEnabled = true,
|
|
78
81
|
muted,
|
|
79
82
|
captionsEnabled,
|
|
80
83
|
isFullscreen,
|
|
@@ -132,6 +135,7 @@ export function DocPlayerWithSidebar({
|
|
|
132
135
|
onEnded={onEnded}
|
|
133
136
|
onTimeUpdate={onTimeUpdate}
|
|
134
137
|
audioController={audioController}
|
|
138
|
+
animationsEnabled={animationsEnabled}
|
|
135
139
|
muted={muted}
|
|
136
140
|
captionsEnabled={captionsEnabled}
|
|
137
141
|
showControls={isFullscreen}
|
package/src/DocProgressBar.tsx
CHANGED
|
@@ -63,6 +63,32 @@ export function DocProgressBar({
|
|
|
63
63
|
setHoverPosition(null);
|
|
64
64
|
}, []);
|
|
65
65
|
|
|
66
|
+
const handleProgressKeyDown = useCallback(
|
|
67
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
68
|
+
let next: number | null = null;
|
|
69
|
+
switch (e.key) {
|
|
70
|
+
case 'ArrowLeft':
|
|
71
|
+
case 'ArrowDown':
|
|
72
|
+
next = state.currentTime - 5;
|
|
73
|
+
break;
|
|
74
|
+
case 'ArrowRight':
|
|
75
|
+
case 'ArrowUp':
|
|
76
|
+
next = state.currentTime + 5;
|
|
77
|
+
break;
|
|
78
|
+
case 'Home':
|
|
79
|
+
next = 0;
|
|
80
|
+
break;
|
|
81
|
+
case 'End':
|
|
82
|
+
next = state.totalDuration;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (next == null) return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
actions.seekTo(Math.max(0, Math.min(state.totalDuration, next)));
|
|
88
|
+
},
|
|
89
|
+
[actions, state.currentTime, state.totalDuration],
|
|
90
|
+
);
|
|
91
|
+
|
|
66
92
|
const getBlockAtTimeLocal = useCallback(
|
|
67
93
|
(time: number): { block: Block; index: number } | null => {
|
|
68
94
|
for (let i = expandedBlocks.length - 1; i >= 0; i--) {
|
|
@@ -79,6 +105,8 @@ export function DocProgressBar({
|
|
|
79
105
|
return (
|
|
80
106
|
<div
|
|
81
107
|
ref={progressBarRef}
|
|
108
|
+
role="group"
|
|
109
|
+
aria-label="Playback timeline"
|
|
82
110
|
style={{
|
|
83
111
|
flex: 1,
|
|
84
112
|
height: '24px',
|
|
@@ -98,6 +126,14 @@ export function DocProgressBar({
|
|
|
98
126
|
>
|
|
99
127
|
{/* Track background */}
|
|
100
128
|
<div
|
|
129
|
+
role="slider"
|
|
130
|
+
tabIndex={0}
|
|
131
|
+
aria-label="Playback position"
|
|
132
|
+
aria-valuemin={0}
|
|
133
|
+
aria-valuemax={state.totalDuration}
|
|
134
|
+
aria-valuenow={Math.max(0, Math.min(state.totalDuration, state.currentTime))}
|
|
135
|
+
aria-valuetext={`${formatTime(state.currentTime)} of ${formatTime(state.totalDuration)}`}
|
|
136
|
+
onKeyDown={handleProgressKeyDown}
|
|
101
137
|
style={{
|
|
102
138
|
position: 'absolute',
|
|
103
139
|
left: 0,
|
|
@@ -132,7 +168,8 @@ export function DocProgressBar({
|
|
|
132
168
|
|
|
133
169
|
{/* Block markers (dots) */}
|
|
134
170
|
{blockMarkers.map((marker, i) => (
|
|
135
|
-
<
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
136
173
|
key={`${marker.block.id}-${i}`}
|
|
137
174
|
style={{
|
|
138
175
|
position: 'absolute',
|
|
@@ -144,11 +181,13 @@ export function DocProgressBar({
|
|
|
144
181
|
background:
|
|
145
182
|
marker.index === state.currentBlockIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
|
146
183
|
border: '2px solid #5b9bd5',
|
|
184
|
+
padding: 0,
|
|
147
185
|
cursor: 'pointer',
|
|
148
186
|
zIndex: 2,
|
|
149
187
|
transition: 'transform 0.15s, background 0.15s',
|
|
150
188
|
}}
|
|
151
189
|
title={marker.title}
|
|
190
|
+
aria-label={`Seek to ${marker.title}`}
|
|
152
191
|
onClick={(e) => {
|
|
153
192
|
e.stopPropagation();
|
|
154
193
|
actions.seekTo(marker.block.startTime);
|
package/src/LinearDocView.tsx
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* - Headings from the block hierarchy rendered as HTML headings
|
|
14
14
|
* - Body content rendered via MarkdownRenderer
|
|
15
15
|
* - Template-annotated sections show an SVG card (BlockRenderer)
|
|
16
|
-
* using `
|
|
16
|
+
* using `materializeBlockLayers()` for on-demand layer computation
|
|
17
17
|
* - Blocks are rendered recursively to preserve the heading hierarchy
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { useMemo } from 'react';
|
|
20
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
21
21
|
import { useAutoSurface } from './hooks/useAutoSurface';
|
|
22
22
|
import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
|
|
23
23
|
import type { ViewportConfig } from '@bendyline/squisq/schemas';
|
|
@@ -29,13 +29,13 @@ import {
|
|
|
29
29
|
} from '@bendyline/squisq/schemas';
|
|
30
30
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
31
31
|
import {
|
|
32
|
-
|
|
33
|
-
hasTemplate,
|
|
32
|
+
materializeBlockLayers,
|
|
34
33
|
markdownToDoc,
|
|
35
34
|
DEFAULT_THEME,
|
|
36
35
|
deriveTemplateInputs,
|
|
36
|
+
isTemplateBlock,
|
|
37
37
|
} from '@bendyline/squisq/doc';
|
|
38
|
-
import type {
|
|
38
|
+
import type { MaterializeBlockLayersOptions } from '@bendyline/squisq/doc';
|
|
39
39
|
import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
|
|
40
40
|
import { BlockRenderer } from './BlockRenderer';
|
|
41
41
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
|
@@ -62,6 +62,8 @@ export interface LinearDocViewProps {
|
|
|
62
62
|
className?: string;
|
|
63
63
|
/** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
|
|
64
64
|
theme?: Theme;
|
|
65
|
+
/** Whether inline visual cards render their layer animations (default: true). */
|
|
66
|
+
animationsEnabled?: boolean;
|
|
65
67
|
/**
|
|
66
68
|
* Optional surface scheme (light / dark paper) overlaid on top of the
|
|
67
69
|
* theme's colors. Orthogonal to `theme` — a theme picks editorial
|
|
@@ -87,43 +89,35 @@ export interface LinearDocViewProps {
|
|
|
87
89
|
* full-size images would dominate the layout.
|
|
88
90
|
*/
|
|
89
91
|
imageDisplayMode?: ImageDisplayMode;
|
|
92
|
+
/**
|
|
93
|
+
* Let unmodified Up/Down arrows scroll this view even when it does not
|
|
94
|
+
* currently hold focus. Intended for a primary document preview.
|
|
95
|
+
*/
|
|
96
|
+
globalKeyboardShortcuts?: boolean;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
export type ImageDisplayMode = 'inline' | 'thumbnail';
|
|
93
100
|
|
|
94
101
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
95
102
|
|
|
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
|
-
|
|
100
103
|
/**
|
|
101
104
|
* Determine whether a block has a template annotation that should be
|
|
102
|
-
* rendered as a visual SVG card.
|
|
103
|
-
*
|
|
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.
|
|
105
|
+
* rendered as a visual SVG card. Unknown templates remain annotated so the
|
|
106
|
+
* materializer can return a visible fallback and structured diagnostic.
|
|
109
107
|
*/
|
|
110
108
|
function isAnnotatedBlock(block: Block): boolean {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
return true;
|
|
109
|
+
return (
|
|
110
|
+
!!block.sourceHeading?.templateAnnotation?.template ||
|
|
111
|
+
(!block.sourceHeading && isTemplateBlock(block as DocBlock))
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function visualTemplateName(block: Block): string | undefined {
|
|
116
|
+
return block.sourceHeading?.templateAnnotation?.template ?? block.template;
|
|
123
117
|
}
|
|
124
118
|
|
|
125
119
|
/**
|
|
126
|
-
* Count total blocks in a hierarchy
|
|
120
|
+
* Count total blocks in a hierarchy for the materialization context.
|
|
127
121
|
*/
|
|
128
122
|
function countAll(blocks: Block[]): number {
|
|
129
123
|
let count = 0;
|
|
@@ -140,55 +134,75 @@ interface BlockSectionProps {
|
|
|
140
134
|
block: Block;
|
|
141
135
|
basePath: string;
|
|
142
136
|
viewport: ViewportConfig;
|
|
143
|
-
renderContext:
|
|
137
|
+
renderContext: MaterializeBlockLayersOptions;
|
|
144
138
|
blockIndex: number;
|
|
139
|
+
blockIndices: ReadonlyMap<Block, number>;
|
|
140
|
+
animationsEnabled: boolean;
|
|
145
141
|
}
|
|
146
142
|
|
|
147
143
|
/**
|
|
148
144
|
* Render a single block section: heading + body content or SVG card.
|
|
149
145
|
* Recurses into children to render the full heading tree.
|
|
150
146
|
*/
|
|
151
|
-
function BlockSection({
|
|
147
|
+
function BlockSection({
|
|
148
|
+
block,
|
|
149
|
+
basePath,
|
|
150
|
+
viewport,
|
|
151
|
+
renderContext,
|
|
152
|
+
blockIndex,
|
|
153
|
+
blockIndices,
|
|
154
|
+
animationsEnabled,
|
|
155
|
+
}: BlockSectionProps) {
|
|
152
156
|
const isAnnotated = isAnnotatedBlock(block);
|
|
153
157
|
|
|
154
158
|
// For annotated blocks, compute layers and build a Block with them
|
|
155
159
|
const visualBlock = useMemo(() => {
|
|
156
160
|
if (!isAnnotated) return null;
|
|
157
161
|
|
|
158
|
-
const annotation = block.sourceHeading
|
|
159
|
-
const
|
|
162
|
+
const annotation = block.sourceHeading?.templateAnnotation;
|
|
163
|
+
const templateName = visualTemplateName(block) ?? 'sectionHeader';
|
|
160
164
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
165
|
+
// Authored Markdown blocks derive their typed template inputs from the
|
|
166
|
+
// heading/body. Transform-generated blocks already ARE typed template
|
|
167
|
+
// inputs, so materialize them directly instead of looking for authoring
|
|
168
|
+
// nodes they intentionally do not carry.
|
|
169
|
+
const templateBlock: Record<string, unknown> = annotation
|
|
170
|
+
? (() => {
|
|
171
|
+
const headingText = extractPlainText(block.sourceHeading!);
|
|
172
|
+
return {
|
|
173
|
+
id: block.id,
|
|
174
|
+
template: templateName,
|
|
175
|
+
startTime: 0,
|
|
176
|
+
duration: 1,
|
|
177
|
+
audioSegment: 0,
|
|
178
|
+
title: headingText,
|
|
179
|
+
contents: block.contents,
|
|
180
|
+
children: block.children,
|
|
181
|
+
...(deriveTemplateInputs(templateName, headingText, block.contents, {
|
|
182
|
+
placeholders: true,
|
|
183
|
+
}) ?? {}),
|
|
184
|
+
...annotation.params,
|
|
185
|
+
...block.templateOverrides,
|
|
186
|
+
};
|
|
187
|
+
})()
|
|
188
|
+
: {
|
|
189
|
+
...block,
|
|
190
|
+
startTime: block.startTime ?? 0,
|
|
191
|
+
duration: block.duration ?? 1,
|
|
192
|
+
audioSegment: block.audioSegment ?? 0,
|
|
193
|
+
template: templateName,
|
|
194
|
+
};
|
|
180
195
|
|
|
181
|
-
|
|
182
|
-
const ctx: RenderContext = {
|
|
196
|
+
const ctx: MaterializeBlockLayersOptions = {
|
|
183
197
|
...renderContext,
|
|
184
198
|
blockIndex,
|
|
185
199
|
};
|
|
186
|
-
const layers =
|
|
200
|
+
const { layers } = materializeBlockLayers(templateBlock as unknown as DocBlock, ctx);
|
|
187
201
|
|
|
188
202
|
return {
|
|
189
203
|
...block,
|
|
190
204
|
layers,
|
|
191
|
-
template:
|
|
205
|
+
template: templateName,
|
|
192
206
|
} as Block;
|
|
193
207
|
}, [block, isAnnotated, renderContext, blockIndex]);
|
|
194
208
|
|
|
@@ -196,7 +210,8 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
196
210
|
<div
|
|
197
211
|
className="squisq-linear-section"
|
|
198
212
|
data-block-id={block.id}
|
|
199
|
-
data-
|
|
213
|
+
data-block-index={blockIndex}
|
|
214
|
+
data-template={isAnnotated ? visualTemplateName(block) : undefined}
|
|
200
215
|
>
|
|
201
216
|
{/* Render the heading (if present — preamble has no sourceHeading) */}
|
|
202
217
|
{block.sourceHeading && !isAnnotated && <MarkdownRenderer nodes={[block.sourceHeading]} />}
|
|
@@ -224,6 +239,7 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
224
239
|
blockTime={0}
|
|
225
240
|
basePath={basePath}
|
|
226
241
|
viewport={viewport}
|
|
242
|
+
animationsEnabled={animationsEnabled}
|
|
227
243
|
/>
|
|
228
244
|
</div>
|
|
229
245
|
</div>
|
|
@@ -244,7 +260,9 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
244
260
|
basePath={basePath}
|
|
245
261
|
viewport={viewport}
|
|
246
262
|
renderContext={renderContext}
|
|
247
|
-
blockIndex={blockIndex + i + 1}
|
|
263
|
+
blockIndex={blockIndices.get(child) ?? blockIndex + i + 1}
|
|
264
|
+
blockIndices={blockIndices}
|
|
265
|
+
animationsEnabled={animationsEnabled}
|
|
248
266
|
/>
|
|
249
267
|
))}
|
|
250
268
|
</div>
|
|
@@ -275,9 +293,12 @@ export function LinearDocView({
|
|
|
275
293
|
className,
|
|
276
294
|
theme,
|
|
277
295
|
surface,
|
|
296
|
+
animationsEnabled = true,
|
|
278
297
|
thinMargins = false,
|
|
279
298
|
imageDisplayMode = 'inline',
|
|
299
|
+
globalKeyboardShortcuts = false,
|
|
280
300
|
}: LinearDocViewProps) {
|
|
301
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
281
302
|
const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
|
|
282
303
|
|
|
283
304
|
// Parse markdown into a Doc only when no explicit doc is supplied.
|
|
@@ -291,10 +312,22 @@ export function LinearDocView({
|
|
|
291
312
|
() => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
|
|
292
313
|
[resolvedDoc],
|
|
293
314
|
);
|
|
315
|
+
const blockIndices = useMemo(() => {
|
|
316
|
+
const indices = new Map<Block, number>();
|
|
317
|
+
let index = 0;
|
|
318
|
+
const visit = (blocks: Block[]) => {
|
|
319
|
+
for (const block of blocks) {
|
|
320
|
+
indices.set(block, index++);
|
|
321
|
+
if (block.children) visit(block.children);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
if (resolvedDoc) visit(resolvedDoc.blocks);
|
|
325
|
+
return indices;
|
|
326
|
+
}, [resolvedDoc]);
|
|
294
327
|
const autoSurface = useAutoSurface(surface === 'auto');
|
|
295
328
|
const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
|
|
296
329
|
|
|
297
|
-
const renderContext:
|
|
330
|
+
const renderContext: MaterializeBlockLayersOptions = useMemo(() => {
|
|
298
331
|
const baseTheme = theme ?? DEFAULT_THEME;
|
|
299
332
|
const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
|
|
300
333
|
return {
|
|
@@ -304,15 +337,52 @@ export function LinearDocView({
|
|
|
304
337
|
// Theme atmosphere (vignette/grain/gradient persistent layers) shows
|
|
305
338
|
// on the inline template cards so they match the player's look.
|
|
306
339
|
persistentLayers: effectiveTheme.persistentLayers,
|
|
340
|
+
customTemplates: resolvedDoc?.customTemplates,
|
|
307
341
|
};
|
|
308
|
-
}, [activeViewport, totalBlocks, theme, resolvedSurface]);
|
|
342
|
+
}, [activeViewport, resolvedDoc?.customTemplates, totalBlocks, theme, resolvedSurface]);
|
|
309
343
|
|
|
310
344
|
const activeTheme = renderContext.theme!;
|
|
311
345
|
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
if (!globalKeyboardShortcuts) return;
|
|
348
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
349
|
+
if (
|
|
350
|
+
event.defaultPrevented ||
|
|
351
|
+
event.altKey ||
|
|
352
|
+
event.ctrlKey ||
|
|
353
|
+
event.metaKey ||
|
|
354
|
+
event.shiftKey ||
|
|
355
|
+
(event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
|
|
356
|
+
) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
360
|
+
if (
|
|
361
|
+
target?.closest(
|
|
362
|
+
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], .monaco-editor',
|
|
363
|
+
)
|
|
364
|
+
) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const scroller = scrollRef.current;
|
|
368
|
+
if (!scroller) return;
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
const distance = Math.max(64, Math.round(scroller.clientHeight * 0.12));
|
|
371
|
+
scroller.scrollBy({
|
|
372
|
+
top: event.key === 'ArrowDown' ? distance : -distance,
|
|
373
|
+
behavior: 'smooth',
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
377
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
378
|
+
}, [globalKeyboardShortcuts]);
|
|
379
|
+
|
|
312
380
|
// Nothing to render — keep an empty (but classed) container so hosts can
|
|
313
381
|
// still target/measure the view.
|
|
314
382
|
if (!resolvedDoc) {
|
|
315
|
-
return
|
|
383
|
+
return (
|
|
384
|
+
<div ref={scrollRef} className={`squisq-linear squisq-linear--empty ${className || ''}`} />
|
|
385
|
+
);
|
|
316
386
|
}
|
|
317
387
|
|
|
318
388
|
const bgColor = activeTheme.colors.background;
|
|
@@ -325,6 +395,7 @@ export function LinearDocView({
|
|
|
325
395
|
|
|
326
396
|
return (
|
|
327
397
|
<div
|
|
398
|
+
ref={scrollRef}
|
|
328
399
|
className={`squisq-linear ${className || ''}`}
|
|
329
400
|
style={{
|
|
330
401
|
width: '100%',
|
|
@@ -484,7 +555,9 @@ export function LinearDocView({
|
|
|
484
555
|
basePath={basePath}
|
|
485
556
|
viewport={activeViewport}
|
|
486
557
|
renderContext={renderContext}
|
|
487
|
-
blockIndex={i}
|
|
558
|
+
blockIndex={blockIndices.get(block) ?? i}
|
|
559
|
+
blockIndices={blockIndices}
|
|
560
|
+
animationsEnabled={animationsEnabled}
|
|
488
561
|
/>
|
|
489
562
|
))}
|
|
490
563
|
</div>
|
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -158,27 +158,12 @@ function renderInline(
|
|
|
158
158
|
|
|
159
159
|
case 'htmlInline':
|
|
160
160
|
if (ctx.htmlPolicy === 'strip') return null;
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
ctx.htmlPolicy === 'trusted' &&
|
|
165
|
-
!containsMediaTag(node.htmlChildren) &&
|
|
166
|
-
!containsDangerousTag(node.htmlChildren) &&
|
|
167
|
-
!hasDangerousRawHtml(node.rawHtml)
|
|
168
|
-
) {
|
|
169
|
-
return (
|
|
170
|
-
<span
|
|
171
|
-
key={key}
|
|
172
|
-
className="squisq-md-html-inline"
|
|
173
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
174
|
-
/>
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
// Otherwise reconstruct the subtree as React so <video>/<audio>
|
|
178
|
-
// go through MediaContext-aware player components.
|
|
161
|
+
// Reconstruct every subtree as React. Besides routing media through
|
|
162
|
+
// MediaContext, this keeps event-handler attributes and unsafe URLs
|
|
163
|
+
// out of the DOM even when the caller selected `trusted` structure.
|
|
179
164
|
return (
|
|
180
165
|
<span key={key} className="squisq-md-html-inline">
|
|
181
|
-
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h
|
|
166
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
|
|
182
167
|
</span>
|
|
183
168
|
);
|
|
184
169
|
|
|
@@ -292,27 +277,11 @@ function renderBlock(
|
|
|
292
277
|
|
|
293
278
|
case 'htmlBlock':
|
|
294
279
|
if (ctx.htmlPolicy === 'strip') return null;
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
if (
|
|
298
|
-
ctx.htmlPolicy === 'trusted' &&
|
|
299
|
-
!containsMediaTag(node.htmlChildren) &&
|
|
300
|
-
!containsDangerousTag(node.htmlChildren) &&
|
|
301
|
-
!hasDangerousRawHtml(node.rawHtml)
|
|
302
|
-
) {
|
|
303
|
-
return (
|
|
304
|
-
<div
|
|
305
|
-
key={key}
|
|
306
|
-
className="squisq-md-html-block"
|
|
307
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
308
|
-
/>
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
// Otherwise reconstruct subtree as React so <video>/<audio>
|
|
312
|
-
// route through the player components and resolve via MediaContext.
|
|
280
|
+
// The structural walker is deliberately the only rendering path: raw
|
|
281
|
+
// HTML strings never bypass the tag, attribute, and URL policy below.
|
|
313
282
|
return (
|
|
314
283
|
<div key={key} className="squisq-md-html-block">
|
|
315
|
-
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h
|
|
284
|
+
{renderHtmlNodes(resolveHtmlNodes(node.htmlChildren, ctx.htmlPolicy), `${key}h`, ctx)}
|
|
316
285
|
</div>
|
|
317
286
|
);
|
|
318
287
|
|
|
@@ -467,25 +436,13 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
|
|
|
467
436
|
|
|
468
437
|
// ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
|
|
469
438
|
|
|
470
|
-
/**
|
|
471
|
-
* for a React component. Cheap recursive scan — lets us keep the
|
|
472
|
-
* `dangerouslySetInnerHTML` fast path for everything else. */
|
|
439
|
+
/** Apply the caller's structural HTML policy before React reconstruction. */
|
|
473
440
|
function resolveHtmlNodes(nodes: HtmlNode[], htmlPolicy: HtmlPolicy): HtmlNode[] {
|
|
474
441
|
if (htmlPolicy === 'strip') return [];
|
|
475
442
|
if (htmlPolicy === 'trusted') return nodes;
|
|
476
443
|
return sanitizeHtmlNodes(nodes);
|
|
477
444
|
}
|
|
478
445
|
|
|
479
|
-
function containsMediaTag(nodes: HtmlNode[]): boolean {
|
|
480
|
-
for (const node of nodes) {
|
|
481
|
-
if (node.type !== 'htmlElement') continue;
|
|
482
|
-
const tagName = node.tagName.toLowerCase();
|
|
483
|
-
if (tagName === 'video' || tagName === 'audio') return true;
|
|
484
|
-
if (containsMediaTag(node.children)) return true;
|
|
485
|
-
}
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
446
|
/**
|
|
490
447
|
* Tags that can escape their container and affect the whole host
|
|
491
448
|
* document — global styling, script execution, external/resource loads,
|
|
@@ -510,41 +467,9 @@ const DANGEROUS_HTML_TAGS = new Set([
|
|
|
510
467
|
'title',
|
|
511
468
|
]);
|
|
512
469
|
|
|
513
|
-
/** True when the subtree contains any host-affecting tag (see
|
|
514
|
-
* {@link DANGEROUS_HTML_TAGS}). Mirrors {@link containsMediaTag}: keeps
|
|
515
|
-
* such content off the verbatim `dangerouslySetInnerHTML` fast path so
|
|
516
|
-
* it routes through the React reconstruction, which drops the tag. */
|
|
517
|
-
function containsDangerousTag(nodes: HtmlNode[]): boolean {
|
|
518
|
-
for (const node of nodes) {
|
|
519
|
-
if (node.type !== 'htmlElement') continue;
|
|
520
|
-
if (DANGEROUS_HTML_TAGS.has(node.tagName.toLowerCase())) return true;
|
|
521
|
-
if (containsDangerousTag(node.children)) return true;
|
|
522
|
-
}
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Raw-string backstop for {@link DANGEROUS_HTML_TAGS}. The structural
|
|
528
|
-
* {@link containsDangerousTag} check covers the normal case, but a block
|
|
529
|
-
* parsed with `parseHtml: false` carries an empty `htmlChildren` while
|
|
530
|
-
* `rawHtml` still holds the markup — so the verbatim fast path scans the
|
|
531
|
-
* raw string too, guaranteeing a `<style>`/`<script>` can never be
|
|
532
|
-
* injected into the host document by that path no matter how the node
|
|
533
|
-
* was produced. The `\b` keeps `<styled-thing>` from matching `<style>`.
|
|
534
|
-
*/
|
|
535
|
-
const DANGEROUS_RAW_HTML_RE =
|
|
536
|
-
/<\s*\/?\s*(?:base|embed|iframe|link|meta|object|script|style|title)\b/i;
|
|
537
|
-
|
|
538
|
-
function hasDangerousRawHtml(rawHtml: string): boolean {
|
|
539
|
-
return DANGEROUS_RAW_HTML_RE.test(rawHtml);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
470
|
/** A pragmatic shortlist of HTML attributes the raw-HTML walker
|
|
543
471
|
* passes through to React when reconstructing a non-media element.
|
|
544
|
-
* Anything outside this list is silently dropped
|
|
545
|
-
* fast path means most authors will never hit this code, so we
|
|
546
|
-
* keep the surface narrow to avoid React warnings about unknown
|
|
547
|
-
* attributes. */
|
|
472
|
+
* Anything outside this list is silently dropped. */
|
|
548
473
|
const PASSTHROUGH_ATTRS: Record<string, string> = {
|
|
549
474
|
// common
|
|
550
475
|
class: 'className',
|
|
@@ -564,7 +489,10 @@ const PASSTHROUGH_ATTRS: Record<string, string> = {
|
|
|
564
489
|
rel: 'rel',
|
|
565
490
|
};
|
|
566
491
|
|
|
567
|
-
function reactPropsFromAttrs(
|
|
492
|
+
function reactPropsFromAttrs(
|
|
493
|
+
attrs: Record<string, string>,
|
|
494
|
+
ctx: RenderCtx,
|
|
495
|
+
): Record<string, unknown> {
|
|
568
496
|
const out: Record<string, unknown> = {};
|
|
569
497
|
for (const [name, value] of Object.entries(attrs)) {
|
|
570
498
|
const propName = PASSTHROUGH_ATTRS[name];
|
|
@@ -576,26 +504,36 @@ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unkn
|
|
|
576
504
|
out['data-style'] = value;
|
|
577
505
|
continue;
|
|
578
506
|
}
|
|
507
|
+
if (propName === 'href') {
|
|
508
|
+
const href = sanitizeUrl(value, 'link', { extraLinkSchemes: ctx.linkSchemes });
|
|
509
|
+
if (href) out.href = href;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (propName === 'src') {
|
|
513
|
+
const src = sanitizeUrl(value, 'media');
|
|
514
|
+
if (src) out.src = src;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
579
517
|
out[propName] = value;
|
|
580
518
|
}
|
|
581
519
|
return out;
|
|
582
520
|
}
|
|
583
521
|
|
|
584
|
-
function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
522
|
+
function renderHtmlElement(el: HtmlElement, key: string, ctx: RenderCtx): React.ReactNode {
|
|
585
523
|
const tagName = el.tagName.toLowerCase();
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
// is gated by containsDangerousTag, so trusted content carrying these
|
|
589
|
-
// tags lands here — drop the tag and keep the rest of the subtree.
|
|
524
|
+
// Never reconstruct a host-affecting element (e.g. a <style> that would
|
|
525
|
+
// leak globally), whatever the policy.
|
|
590
526
|
if (DANGEROUS_HTML_TAGS.has(tagName)) return null;
|
|
591
527
|
if (tagName === 'video') {
|
|
528
|
+
const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
|
|
529
|
+
const poster = sanitizeUrl(el.attributes.poster ?? '', 'media') ?? undefined;
|
|
592
530
|
return (
|
|
593
531
|
<InlineVideoPlayer
|
|
594
532
|
key={key}
|
|
595
|
-
src={
|
|
533
|
+
src={src}
|
|
596
534
|
width={el.attributes.width}
|
|
597
535
|
height={el.attributes.height}
|
|
598
|
-
poster={
|
|
536
|
+
poster={poster}
|
|
599
537
|
// The `controls` attribute is a boolean — present means true,
|
|
600
538
|
// even if its value is an empty string.
|
|
601
539
|
controls={'controls' in el.attributes}
|
|
@@ -610,10 +548,11 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
610
548
|
);
|
|
611
549
|
}
|
|
612
550
|
if (tagName === 'audio') {
|
|
551
|
+
const src = sanitizeUrl(el.attributes.src ?? '', 'media') ?? '';
|
|
613
552
|
return (
|
|
614
553
|
<InlineAudioPlayer
|
|
615
554
|
key={key}
|
|
616
|
-
src={
|
|
555
|
+
src={src}
|
|
617
556
|
controls={'controls' in el.attributes}
|
|
618
557
|
preload={
|
|
619
558
|
el.attributes.preload === 'none' ||
|
|
@@ -627,23 +566,27 @@ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
|
627
566
|
}
|
|
628
567
|
|
|
629
568
|
const Tag = tagName as keyof JSX.IntrinsicElements;
|
|
630
|
-
const props = reactPropsFromAttrs(el.attributes);
|
|
569
|
+
const props = reactPropsFromAttrs(el.attributes, ctx);
|
|
631
570
|
if (el.selfClosing) {
|
|
632
571
|
return <Tag key={key} {...props} />;
|
|
633
572
|
}
|
|
634
573
|
return (
|
|
635
574
|
<Tag key={key} {...props}>
|
|
636
|
-
{renderHtmlNodes(el.children, `${key}c
|
|
575
|
+
{renderHtmlNodes(el.children, `${key}c`, ctx)}
|
|
637
576
|
</Tag>
|
|
638
577
|
);
|
|
639
578
|
}
|
|
640
579
|
|
|
641
|
-
function renderHtmlNodes(
|
|
580
|
+
function renderHtmlNodes(
|
|
581
|
+
nodes: HtmlNode[],
|
|
582
|
+
keyPrefix: string,
|
|
583
|
+
ctx: RenderCtx = DEFAULT_CTX,
|
|
584
|
+
): React.ReactNode[] {
|
|
642
585
|
return nodes.map((node, i) => {
|
|
643
586
|
const key = `${keyPrefix}${i}`;
|
|
644
587
|
switch (node.type) {
|
|
645
588
|
case 'htmlElement':
|
|
646
|
-
return renderHtmlElement(node, key);
|
|
589
|
+
return renderHtmlElement(node, key, ctx);
|
|
647
590
|
case 'htmlText':
|
|
648
591
|
return <Fragment key={key}>{node.value}</Fragment>;
|
|
649
592
|
case 'htmlComment':
|