@bendyline/squisq-react 1.4.2 → 2.0.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 +30 -3
- package/dist/index.d.ts +177 -28
- package/dist/index.js +1330 -611
- 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 +57 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +28 -0
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +54 -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 +138 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +138 -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 +104 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
- 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 +50 -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/ImageLayer.tsx +106 -1
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +33 -9
- 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/styles/doc-animations.css +36 -0
- package/src/types.ts +13 -13
package/src/layers/MapLayer.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Playwright screenshot contexts.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { useState, useEffect } from 'react';
|
|
15
|
+
import { useId, useState, useEffect } from 'react';
|
|
16
16
|
import type { MapLayer as MapLayerType } from '@bendyline/squisq/schemas';
|
|
17
17
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
18
18
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
@@ -30,6 +30,7 @@ interface MapLayerProps {
|
|
|
30
30
|
|
|
31
31
|
export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps) {
|
|
32
32
|
const { content, position, animation } = layer;
|
|
33
|
+
const clipId = `map-clip-${useId().replace(/:/g, '')}-${layer.id}`;
|
|
33
34
|
const [mapImageUrl, setMapImageUrl] = useState<string | null>(null);
|
|
34
35
|
const [isLoading, setIsLoading] = useState(true);
|
|
35
36
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -89,13 +90,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
|
|
|
89
90
|
return () => {
|
|
90
91
|
cancelled = true;
|
|
91
92
|
};
|
|
92
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- content properties are destructured below; center/markers/showAttribution are stable per-render
|
|
93
93
|
}, [
|
|
94
|
-
content.center
|
|
95
|
-
content.center.lng,
|
|
94
|
+
content.center,
|
|
96
95
|
content.zoom,
|
|
97
96
|
content.style,
|
|
98
97
|
content.staticSrc,
|
|
98
|
+
content.markers,
|
|
99
|
+
content.showAttribution,
|
|
99
100
|
width,
|
|
100
101
|
height,
|
|
101
102
|
basePath,
|
|
@@ -160,13 +161,13 @@ export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps
|
|
|
160
161
|
>
|
|
161
162
|
{/* Clip path for overflow handling */}
|
|
162
163
|
<defs>
|
|
163
|
-
<clipPath id={
|
|
164
|
+
<clipPath id={clipId}>
|
|
164
165
|
<rect x={finalX} y={finalY} width={width} height={height} />
|
|
165
166
|
</clipPath>
|
|
166
167
|
</defs>
|
|
167
168
|
|
|
168
169
|
{/* Map image */}
|
|
169
|
-
<g clipPath={`url(
|
|
170
|
+
<g clipPath={`url(#${clipId})`}>
|
|
170
171
|
<image
|
|
171
172
|
href={mapImageUrl}
|
|
172
173
|
x={finalX}
|
package/src/layers/PathLayer.tsx
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
* rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
|
|
18
18
|
* absolute path.
|
|
19
19
|
*
|
|
20
|
-
* End markers are configured via `startMarker`/`endMarker
|
|
21
|
-
* `
|
|
22
|
-
* `markerPath` in core so the SSR renderer and the editor agree.
|
|
20
|
+
* End markers are configured via `startMarker`/`endMarker`. Marker geometry
|
|
21
|
+
* comes from `markerPath` in core so the SSR renderer and the editor agree.
|
|
23
22
|
*/
|
|
24
23
|
|
|
24
|
+
import { useId } from 'react';
|
|
25
25
|
import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
|
|
26
26
|
import { markerPath, shapePath } from '@bendyline/squisq/doc';
|
|
27
27
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
@@ -54,33 +54,42 @@ function effectivePath(layer: PathLayerType, viewport: { width: number; height:
|
|
|
54
54
|
return derived ?? content.d;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type LegacyArrow = 'none' | 'end' | 'start' | 'both';
|
|
58
|
+
|
|
59
|
+
/** Tolerant-reader support for documents authored before endpoint markers. */
|
|
60
|
+
function readLegacyArrow(content: PathLayerType['content']): LegacyArrow | undefined {
|
|
61
|
+
return (content as PathLayerType['content'] & { arrow?: LegacyArrow }).arrow;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the effective marker for an endpoint. */
|
|
58
65
|
function effectiveMarker(
|
|
59
66
|
explicit: MarkerStyle | undefined,
|
|
60
|
-
|
|
67
|
+
legacyArrow: LegacyArrow | undefined,
|
|
61
68
|
end: 'start' | 'end',
|
|
62
69
|
): MarkerStyle {
|
|
63
70
|
if (explicit) return explicit;
|
|
64
|
-
const wants =
|
|
71
|
+
const wants = legacyArrow === 'both' || legacyArrow === end;
|
|
65
72
|
return wants ? 'arrow' : 'none';
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
|
|
69
76
|
const { content, animation, id } = layer;
|
|
77
|
+
const defsId = `${useId().replace(/:/g, '')}-${id}`;
|
|
70
78
|
const d = effectivePath(layer, viewport);
|
|
71
79
|
const stroke = content.stroke ?? '#1e293b';
|
|
72
80
|
const strokeWidth = content.strokeWidth ?? 2;
|
|
73
|
-
const { fill, def: fillDef } = resolveFill(
|
|
81
|
+
const { fill, def: fillDef } = resolveFill(defsId, content.fill ?? 'none', content.gradient);
|
|
74
82
|
// `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
|
|
75
83
|
const dash = content.borderStyle
|
|
76
84
|
? borderDashArray(content.borderStyle, strokeWidth)
|
|
77
85
|
: content.dasharray;
|
|
78
86
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
79
87
|
|
|
80
|
-
const startId = `marker-start-${
|
|
81
|
-
const endId = `marker-end-${
|
|
82
|
-
const
|
|
83
|
-
const
|
|
88
|
+
const startId = `marker-start-${defsId}`;
|
|
89
|
+
const endId = `marker-end-${defsId}`;
|
|
90
|
+
const legacyArrow = readLegacyArrow(content);
|
|
91
|
+
const start = markerPath(effectiveMarker(content.startMarker, legacyArrow, 'start'), 'start');
|
|
92
|
+
const end = markerPath(effectiveMarker(content.endMarker, legacyArrow, 'end'), 'end');
|
|
84
93
|
|
|
85
94
|
return (
|
|
86
95
|
<g
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Useful for visual accents, dividers, and background elements.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { useId } from 'react';
|
|
8
9
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
10
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
11
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
@@ -18,8 +19,17 @@ interface ShapeLayerProps {
|
|
|
18
19
|
blockTime: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Full-bleed HTML and SVG layers can be rasterized on slightly different
|
|
24
|
+
* subpixel boundaries. Extend unbordered background rectangles beyond the
|
|
25
|
+
* viewport so an image cannot peek through at the outermost pixel; the block
|
|
26
|
+
* renderer clips the combined layer stack back to the viewBox.
|
|
27
|
+
*/
|
|
28
|
+
const FULL_BLEED_OVERSCAN = 1;
|
|
29
|
+
|
|
21
30
|
export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
22
31
|
const { content, position, animation } = layer;
|
|
32
|
+
const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
23
33
|
|
|
24
34
|
// Resolve position values to pixels
|
|
25
35
|
const rawX = resolveValue(position.x, viewport.width);
|
|
@@ -32,6 +42,20 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
32
42
|
const x = rawX + anchorOffset.x;
|
|
33
43
|
const y = rawY + anchorOffset.y;
|
|
34
44
|
|
|
45
|
+
const isUnborderedFullBleedRect =
|
|
46
|
+
content.shape === 'rect' &&
|
|
47
|
+
x === 0 &&
|
|
48
|
+
y === 0 &&
|
|
49
|
+
width === viewport.width &&
|
|
50
|
+
height === viewport.height &&
|
|
51
|
+
!content.stroke &&
|
|
52
|
+
!content.borderRadius;
|
|
53
|
+
const overscan = isUnborderedFullBleedRect ? FULL_BLEED_OVERSCAN : 0;
|
|
54
|
+
const paintX = x - overscan;
|
|
55
|
+
const paintY = y - overscan;
|
|
56
|
+
const paintWidth = width + overscan * 2;
|
|
57
|
+
const paintHeight = height + overscan * 2;
|
|
58
|
+
|
|
35
59
|
// Get animation styles
|
|
36
60
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
37
61
|
|
|
@@ -49,11 +73,11 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
49
73
|
style={animStyle.style}
|
|
50
74
|
data-layer-id={layer.id}
|
|
51
75
|
>
|
|
52
|
-
<foreignObject x={
|
|
76
|
+
<foreignObject x={paintX} y={paintY} width={paintWidth} height={paintHeight}>
|
|
53
77
|
<div
|
|
54
78
|
style={{
|
|
55
|
-
width: `${
|
|
56
|
-
height: `${
|
|
79
|
+
width: `${paintWidth}px`,
|
|
80
|
+
height: `${paintHeight}px`,
|
|
57
81
|
background: fill,
|
|
58
82
|
borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
|
|
59
83
|
pointerEvents: 'none',
|
|
@@ -65,12 +89,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
65
89
|
}
|
|
66
90
|
|
|
67
91
|
const { fill: fillValue, def: fillDef } = resolveFill(
|
|
68
|
-
|
|
92
|
+
defsId,
|
|
69
93
|
fill,
|
|
70
94
|
content.gradient,
|
|
71
95
|
content.pattern,
|
|
72
96
|
);
|
|
73
|
-
const { filterAttr, def: filterDef } = resolveShapeFilter(
|
|
97
|
+
const { filterAttr, def: filterDef } = resolveShapeFilter(defsId, content.filter);
|
|
74
98
|
const dash = borderDashArray(content.borderStyle, content.strokeWidth);
|
|
75
99
|
|
|
76
100
|
// Common style props for native SVG shapes. `line` is stroke-only.
|
|
@@ -97,10 +121,10 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
97
121
|
)}
|
|
98
122
|
{content.shape === 'rect' && (
|
|
99
123
|
<rect
|
|
100
|
-
x={
|
|
101
|
-
y={
|
|
102
|
-
width={
|
|
103
|
-
height={
|
|
124
|
+
x={paintX}
|
|
125
|
+
y={paintY}
|
|
126
|
+
width={paintWidth}
|
|
127
|
+
height={paintHeight}
|
|
104
128
|
rx={content.borderRadius}
|
|
105
129
|
ry={content.borderRadius}
|
|
106
130
|
{...shapeProps}
|
package/src/layers/TextLayer.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* PDF bypasses SVG) and already used by Video/Table layers.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { useMemo, type CSSProperties } from 'react';
|
|
19
|
+
import { useId, useMemo, type CSSProperties } from 'react';
|
|
20
20
|
import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
|
|
21
21
|
import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
|
|
22
22
|
import {
|
|
@@ -165,6 +165,7 @@ function IconTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
168
|
+
const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
168
169
|
const { content, position, animation } = layer;
|
|
169
170
|
const { text, style } = content;
|
|
170
171
|
|
|
@@ -225,7 +226,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
225
226
|
};
|
|
226
227
|
|
|
227
228
|
// Add shadow filter if requested
|
|
228
|
-
const filterId = style.shadow ? `shadow-${
|
|
229
|
+
const filterId = style.shadow ? `shadow-${defsId}` : undefined;
|
|
229
230
|
|
|
230
231
|
return (
|
|
231
232
|
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
@@ -244,7 +245,7 @@ function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
244
245
|
rectangle. Without a box we fall back to a snug rect hugging the
|
|
245
246
|
text (legacy behavior for point-anchored text). */}
|
|
246
247
|
<TextBox
|
|
247
|
-
layerId={
|
|
248
|
+
layerId={defsId}
|
|
248
249
|
style={style}
|
|
249
250
|
box={
|
|
250
251
|
boxWidth != null && boxHeight != null
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a hierarchical treeview inside an SVG block via a <foreignObject>
|
|
5
|
+
* (same technique as TableLayer) — a filesystem-style outline with
|
|
6
|
+
* folder/file icons, indentation guide rails, and collapse chevrons.
|
|
7
|
+
*
|
|
8
|
+
* Interactive in the live React player: clicking a folder chevron
|
|
9
|
+
* collapses/expands it (local component state, default fully expanded).
|
|
10
|
+
* Headless frame / PDF capture renders the default expanded DOM statically,
|
|
11
|
+
* so exports are deterministic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
import type { TreeLayer as TreeLayerType, TreeLayerItem } from '@bendyline/squisq/schemas';
|
|
16
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
17
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
18
|
+
|
|
19
|
+
interface TreeLayerProps {
|
|
20
|
+
layer: TreeLayerType;
|
|
21
|
+
viewport: { width: number; height: number };
|
|
22
|
+
blockTime: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** FontAwesome class from a bare name (`folder`) or qualified (`fa-solid:folder`). */
|
|
26
|
+
function faClass(token: string | undefined, fallback: string): string {
|
|
27
|
+
const name = token && token.trim() ? token.trim() : fallback;
|
|
28
|
+
const colon = name.indexOf(':');
|
|
29
|
+
if (colon > 0) {
|
|
30
|
+
const family = name.slice(0, colon).replace(/^fa-/, '');
|
|
31
|
+
return `fa-${family} fa-${name.slice(colon + 1)}`;
|
|
32
|
+
}
|
|
33
|
+
return `fa-solid fa-${name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function TreeLayer({ layer, viewport, blockTime }: TreeLayerProps) {
|
|
37
|
+
const { content, position, animation } = layer;
|
|
38
|
+
const { items, style } = content;
|
|
39
|
+
|
|
40
|
+
const x = resolveValue(position.x, viewport.width);
|
|
41
|
+
const y = resolveValue(position.y, viewport.height);
|
|
42
|
+
const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
43
|
+
const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
|
|
44
|
+
const offset = getAnchorOffset(position.anchor, width, height);
|
|
45
|
+
const animStyle = animation ? getAnimationStyle(animation, blockTime) : {};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<foreignObject
|
|
49
|
+
x={x + offset.x}
|
|
50
|
+
y={y + offset.y}
|
|
51
|
+
width={width}
|
|
52
|
+
height={height}
|
|
53
|
+
style={animStyle}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
|
|
58
|
+
className="squisq-treelayer"
|
|
59
|
+
style={{
|
|
60
|
+
width: `${width}px`,
|
|
61
|
+
height: `${height}px`,
|
|
62
|
+
display: 'flex',
|
|
63
|
+
flexDirection: 'column',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
padding: '24px 32px',
|
|
66
|
+
boxSizing: 'border-box',
|
|
67
|
+
fontFamily: style.fontFamily ?? 'system-ui, sans-serif',
|
|
68
|
+
fontSize: `${style.fontSize}px`,
|
|
69
|
+
lineHeight: 1.7,
|
|
70
|
+
overflow: 'hidden',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<TreeList items={items} depth={0} style={style} />
|
|
74
|
+
</div>
|
|
75
|
+
</foreignObject>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TreeList({
|
|
80
|
+
items,
|
|
81
|
+
depth,
|
|
82
|
+
style,
|
|
83
|
+
}: {
|
|
84
|
+
items: TreeLayerItem[];
|
|
85
|
+
depth: number;
|
|
86
|
+
style: TreeLayerType['content']['style'];
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<ul
|
|
90
|
+
style={{
|
|
91
|
+
listStyle: 'none',
|
|
92
|
+
margin: 0,
|
|
93
|
+
padding: 0,
|
|
94
|
+
paddingLeft: depth === 0 ? 0 : `${style.indentPx}px`,
|
|
95
|
+
borderLeft: depth === 0 ? 'none' : `1px solid ${style.connectorColor}`,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{items.map((item) => (
|
|
99
|
+
<TreeRow key={item.id} item={item} style={style} />
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function TreeRow({
|
|
106
|
+
item,
|
|
107
|
+
style,
|
|
108
|
+
}: {
|
|
109
|
+
item: TreeLayerItem;
|
|
110
|
+
style: TreeLayerType['content']['style'];
|
|
111
|
+
}) {
|
|
112
|
+
const hasChildren = item.children.length > 0;
|
|
113
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
114
|
+
const isDir = item.isDir || hasChildren;
|
|
115
|
+
const iconCls = isDir
|
|
116
|
+
? faClass(style.folderIcon, collapsed ? 'folder' : 'folder-open')
|
|
117
|
+
: faClass(style.fileIcon, 'file');
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<li style={{ position: 'relative' }}>
|
|
121
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', padding: '1px 0' }}>
|
|
122
|
+
{hasChildren ? (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
|
126
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
127
|
+
style={{
|
|
128
|
+
flex: '0 0 auto',
|
|
129
|
+
width: '1em',
|
|
130
|
+
border: 'none',
|
|
131
|
+
background: 'transparent',
|
|
132
|
+
cursor: 'pointer',
|
|
133
|
+
color: style.connectorColor,
|
|
134
|
+
padding: 0,
|
|
135
|
+
fontSize: '0.8em',
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<i
|
|
139
|
+
className={`fa-solid ${collapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`}
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
</button>
|
|
143
|
+
) : (
|
|
144
|
+
<span style={{ flex: '0 0 auto', width: '1em' }} />
|
|
145
|
+
)}
|
|
146
|
+
<i
|
|
147
|
+
className={iconCls}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
style={{ flex: '0 0 auto', color: style.iconColor, width: '1.2em', textAlign: 'center' }}
|
|
150
|
+
/>
|
|
151
|
+
<span
|
|
152
|
+
style={{ color: isDir ? style.dirColor : style.rowColor, fontWeight: isDir ? 600 : 400 }}
|
|
153
|
+
>
|
|
154
|
+
{item.label}
|
|
155
|
+
</span>
|
|
156
|
+
{item.comment ? (
|
|
157
|
+
<span style={{ color: style.commentColor, fontSize: '0.85em', fontStyle: 'italic' }}>
|
|
158
|
+
{item.comment}
|
|
159
|
+
</span>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
{hasChildren && !collapsed ? (
|
|
163
|
+
<TreeList items={item.children} depth={1} style={style} />
|
|
164
|
+
) : null}
|
|
165
|
+
</li>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -26,6 +26,8 @@ import type { VideoLayer as VideoLayerType } from '@bendyline/squisq/schemas';
|
|
|
26
26
|
import { useMediaUrl } from '../hooks/MediaContext';
|
|
27
27
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
28
28
|
|
|
29
|
+
const VIDEO_SYNC_DRIFT_SECONDS = 0.2;
|
|
30
|
+
|
|
29
31
|
interface VideoLayerProps {
|
|
30
32
|
layer: VideoLayerType;
|
|
31
33
|
/** Base path for resolving relative video URLs */
|
|
@@ -100,22 +102,34 @@ export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }:
|
|
|
100
102
|
video.pause();
|
|
101
103
|
};
|
|
102
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- isPlaying is handled by the separate sync effect below
|
|
103
|
-
}, [
|
|
105
|
+
}, [src, content.clipStart, content.clipEnd]);
|
|
104
106
|
|
|
105
|
-
// Sync video play/pause with doc
|
|
107
|
+
// Sync video time + play/pause with the doc clock, honoring the startAt
|
|
108
|
+
// gate. The time correction matters when a synchronized audience player is
|
|
109
|
+
// opened partway through a block: its video must join at the main player's
|
|
110
|
+
// current frame rather than restarting from clipStart.
|
|
106
111
|
useEffect(() => {
|
|
107
112
|
const video = videoRef.current;
|
|
108
113
|
if (!video || !hasStartedRef.current) return;
|
|
109
114
|
|
|
115
|
+
const targetTime = gated
|
|
116
|
+
? content.clipStart
|
|
117
|
+
: Math.min(content.clipEnd, content.clipStart + Math.max(0, blockTime - startAt));
|
|
118
|
+
if (Math.abs(video.currentTime - targetTime) > VIDEO_SYNC_DRIFT_SECONDS) {
|
|
119
|
+
video.currentTime = targetTime;
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
// Before the clip's startAt offset, hold at the in-point.
|
|
111
123
|
if (gated) {
|
|
112
124
|
video.pause();
|
|
113
|
-
video.currentTime = content.clipStart;
|
|
114
125
|
return;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
|
-
// Don't resume if
|
|
118
|
-
if (
|
|
128
|
+
// Don't resume if the document clock has already reached the clip end.
|
|
129
|
+
if (targetTime >= content.clipEnd) {
|
|
130
|
+
video.pause();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
119
133
|
|
|
120
134
|
if (isPlaying) {
|
|
121
135
|
const playPromise = video.play();
|
|
@@ -125,7 +139,7 @@ export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }:
|
|
|
125
139
|
} else {
|
|
126
140
|
video.pause();
|
|
127
141
|
}
|
|
128
|
-
}, [isPlaying, gated, content.clipStart, content.clipEnd]);
|
|
142
|
+
}, [isPlaying, gated, blockTime, startAt, src, content.clipStart, content.clipEnd]);
|
|
129
143
|
|
|
130
144
|
return (
|
|
131
145
|
<g className="block-layer block-layer--video" data-layer-id={layer.id}>
|
package/src/standalone-entry.tsx
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* <script src="squisq-player.iife.js"></script>
|
|
13
13
|
* <div id="root"></div>
|
|
14
14
|
* <script>
|
|
15
|
-
*
|
|
15
|
+
* const root = document.getElementById('root');
|
|
16
|
+
* const handle = SquisqPlayer.mount(root, docJson, {
|
|
16
17
|
* mode: 'slideshow',
|
|
17
18
|
* images: { 'hero.jpg': 'data:image/jpeg;base64,...' }
|
|
18
19
|
* });
|
|
20
|
+
* // In render mode: const api = await handle.renderAPI;
|
|
19
21
|
* </script>
|
|
20
22
|
*/
|
|
21
23
|
|
|
@@ -23,6 +25,7 @@ import { createElement } from 'react';
|
|
|
23
25
|
import { createRoot, type Root } from 'react-dom/client';
|
|
24
26
|
import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
|
|
25
27
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
28
|
+
import type { SquisqRenderAPI } from './types';
|
|
26
29
|
import { DocPlayer } from './DocPlayer';
|
|
27
30
|
import { LinearDocView } from './LinearDocView';
|
|
28
31
|
import { MediaContext } from './hooks/MediaContext';
|
|
@@ -54,15 +57,37 @@ export interface MountOptions {
|
|
|
54
57
|
/** Auto-play on mount (only for slideshow mode, default: false) */
|
|
55
58
|
autoPlay?: boolean;
|
|
56
59
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
+
* Capture presentation arrow keys without requiring focus (default: true).
|
|
61
|
+
* Disable when mounting multiple interactive players on the same page.
|
|
62
|
+
*/
|
|
63
|
+
globalKeyboardShortcuts?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Enable render mode for headless frame capture. The instance API is
|
|
66
|
+
* available through the returned mount handle. Disables controls and
|
|
67
|
+
* auto-play.
|
|
60
68
|
*/
|
|
61
69
|
renderMode?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether to render slide transitions and per-layer animations (default: true).
|
|
72
|
+
* Timed media continues to play when disabled.
|
|
73
|
+
*/
|
|
74
|
+
animationsEnabled?: boolean;
|
|
62
75
|
/** Caption style: 'standard' or 'social'. Omit or set to undefined for no captions. */
|
|
63
76
|
captionStyle?: 'standard' | 'social';
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
/** Instance handle returned by {@link mount}. */
|
|
80
|
+
export interface SquisqPlayerHandle {
|
|
81
|
+
/** DOM element that owns this player instance. */
|
|
82
|
+
readonly element: Element;
|
|
83
|
+
/** Resolves to this instance's render API, or null when render mode is off. */
|
|
84
|
+
readonly renderAPI: Promise<SquisqRenderAPI | null>;
|
|
85
|
+
/** Current render API without waiting for effects to run. */
|
|
86
|
+
getRenderAPI(): SquisqRenderAPI | null;
|
|
87
|
+
/** Unmount this exact player instance. */
|
|
88
|
+
unmount(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
// ── CSS Injection ──────────────────────────────────────────────────
|
|
67
92
|
|
|
68
93
|
let cssInjected = false;
|
|
@@ -167,6 +192,50 @@ function rewriteAudioUrls(doc: Doc, audioMap: Record<string, string>): Doc {
|
|
|
167
192
|
|
|
168
193
|
const roots = new WeakMap<Element, Root>();
|
|
169
194
|
|
|
195
|
+
interface InternalPlayerHandle extends SquisqPlayerHandle {
|
|
196
|
+
setRenderAPI(api: SquisqRenderAPI | null): void;
|
|
197
|
+
cancel(): void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const handles = new WeakMap<Element, InternalPlayerHandle>();
|
|
201
|
+
|
|
202
|
+
function createPlayerHandle(element: Element, expectsRenderAPI: boolean): InternalPlayerHandle {
|
|
203
|
+
let currentAPI: SquisqRenderAPI | null = null;
|
|
204
|
+
let active = true;
|
|
205
|
+
let settled = false;
|
|
206
|
+
let resolveRenderAPI!: (api: SquisqRenderAPI | null) => void;
|
|
207
|
+
const renderAPI = new Promise<SquisqRenderAPI | null>((resolve) => {
|
|
208
|
+
resolveRenderAPI = resolve;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const settle = (api: SquisqRenderAPI | null) => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
resolveRenderAPI(api);
|
|
215
|
+
};
|
|
216
|
+
if (!expectsRenderAPI) settle(null);
|
|
217
|
+
|
|
218
|
+
const handle: InternalPlayerHandle = {
|
|
219
|
+
element,
|
|
220
|
+
renderAPI,
|
|
221
|
+
getRenderAPI: () => currentAPI,
|
|
222
|
+
unmount: () => {
|
|
223
|
+
if (handles.get(element) === handle) unmount(element);
|
|
224
|
+
},
|
|
225
|
+
setRenderAPI(api) {
|
|
226
|
+
if (!active) return;
|
|
227
|
+
currentAPI = api;
|
|
228
|
+
if (api) settle(api);
|
|
229
|
+
},
|
|
230
|
+
cancel() {
|
|
231
|
+
active = false;
|
|
232
|
+
currentAPI = null;
|
|
233
|
+
settle(null);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
return handle;
|
|
237
|
+
}
|
|
238
|
+
|
|
170
239
|
// ── Public API ─────────────────────────────────────────────────────
|
|
171
240
|
|
|
172
241
|
/**
|
|
@@ -176,7 +245,7 @@ const roots = new WeakMap<Element, Root>();
|
|
|
176
245
|
* @param doc - A Doc object (parsed JSON)
|
|
177
246
|
* @param options - Rendering options
|
|
178
247
|
*/
|
|
179
|
-
export function mount(element: Element, doc: Doc, options: MountOptions = {}):
|
|
248
|
+
export function mount(element: Element, doc: Doc, options: MountOptions = {}): SquisqPlayerHandle {
|
|
180
249
|
injectCss();
|
|
181
250
|
|
|
182
251
|
const {
|
|
@@ -187,7 +256,9 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
187
256
|
autoPlay = false,
|
|
188
257
|
theme,
|
|
189
258
|
renderMode = false,
|
|
259
|
+
animationsEnabled = true,
|
|
190
260
|
captionStyle,
|
|
261
|
+
globalKeyboardShortcuts = true,
|
|
191
262
|
} = options;
|
|
192
263
|
|
|
193
264
|
// Rewrite audio URLs if map provided
|
|
@@ -195,6 +266,9 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
195
266
|
|
|
196
267
|
// Build the media provider if images are provided
|
|
197
268
|
const mediaProvider = images ? createInlineMediaProvider(images, basePath) : null;
|
|
269
|
+
handles.get(element)?.cancel();
|
|
270
|
+
const handle = createPlayerHandle(element, mode === 'slideshow' && renderMode);
|
|
271
|
+
handles.set(element, handle);
|
|
198
272
|
|
|
199
273
|
let content: ReturnType<typeof createElement>;
|
|
200
274
|
|
|
@@ -203,6 +277,8 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
203
277
|
doc: finalDoc,
|
|
204
278
|
basePath,
|
|
205
279
|
theme,
|
|
280
|
+
animationsEnabled,
|
|
281
|
+
globalKeyboardShortcuts,
|
|
206
282
|
});
|
|
207
283
|
} else {
|
|
208
284
|
content = createElement(DocPlayer, {
|
|
@@ -212,9 +288,12 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
212
288
|
autoPlay: renderMode ? false : autoPlay,
|
|
213
289
|
showControls: !renderMode,
|
|
214
290
|
renderMode,
|
|
291
|
+
animationsEnabled,
|
|
215
292
|
theme,
|
|
216
293
|
captionsEnabled: !!captionStyle,
|
|
217
294
|
captionStyle: captionStyle ?? 'standard',
|
|
295
|
+
globalKeyboardShortcuts,
|
|
296
|
+
onRenderAPIReady: (api: SquisqRenderAPI | null) => handle.setRenderAPI(api),
|
|
218
297
|
});
|
|
219
298
|
}
|
|
220
299
|
|
|
@@ -230,23 +309,21 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
230
309
|
roots.set(element, root);
|
|
231
310
|
}
|
|
232
311
|
root.render(content);
|
|
312
|
+
return handle;
|
|
233
313
|
}
|
|
234
314
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
export function mountStatic(
|
|
239
|
-
element: Element,
|
|
240
|
-
doc: Doc,
|
|
241
|
-
options: Omit<MountOptions, 'mode'> = {},
|
|
242
|
-
): void {
|
|
243
|
-
mount(element, doc, { ...options, mode: 'static' });
|
|
315
|
+
/** Return the handle for the player mounted into `element`, if any. */
|
|
316
|
+
export function getHandle(element: Element): SquisqPlayerHandle | undefined {
|
|
317
|
+
return handles.get(element);
|
|
244
318
|
}
|
|
245
319
|
|
|
246
320
|
/**
|
|
247
321
|
* Unmount a previously mounted SquisqPlayer from an element.
|
|
248
322
|
*/
|
|
249
323
|
export function unmount(element: Element): void {
|
|
324
|
+
const handle = handles.get(element);
|
|
325
|
+
handle?.cancel();
|
|
326
|
+
handles.delete(element);
|
|
250
327
|
const root = roots.get(element);
|
|
251
328
|
if (root) {
|
|
252
329
|
root.unmount();
|