@bendyline/squisq-react 1.3.2 → 1.4.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/index.d.ts +63 -7
- package/dist/index.js +1171 -666
- 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 +17 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +3 -2
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +65 -12
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +11 -197
- package/src/MarkdownRenderer.tsx +165 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +51 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/hooks/useAudioSync.ts +14 -1
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/index.ts +7 -0
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/styles/doc-animations.css +1857 -2
- package/src/utils/fillStyle.tsx +148 -0
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* - Automatic expansion of template blocks
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
16
16
|
import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
|
|
17
17
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_THEME,
|
|
20
|
+
getBlockAtTime,
|
|
21
|
+
resolveBlockTransition,
|
|
22
|
+
resolveTransitionDuration,
|
|
23
|
+
} from '@bendyline/squisq/schemas';
|
|
19
24
|
import {
|
|
20
25
|
expandDocBlocks,
|
|
21
|
-
|
|
26
|
+
flattenRenderableBlocks,
|
|
22
27
|
isTemplateBlock,
|
|
28
|
+
resolvePersistentLayers,
|
|
23
29
|
VIEWPORT_PRESETS,
|
|
24
30
|
type ViewportConfig,
|
|
25
31
|
} from '@bendyline/squisq/doc';
|
|
@@ -61,29 +67,34 @@ export function useDocPlayback(
|
|
|
61
67
|
renderMode: boolean = false,
|
|
62
68
|
theme?: Theme,
|
|
63
69
|
): PlaybackState & PlaybackActions {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
previousBlock: Block | null;
|
|
68
|
-
}>({
|
|
69
|
-
entering: false,
|
|
70
|
-
exiting: false,
|
|
71
|
-
previousBlock: null,
|
|
72
|
-
});
|
|
73
|
-
|
|
70
|
+
// `renderMode` is retained for API/signature compatibility; block transitions
|
|
71
|
+
// are now computed identically for real-time and render (export) modes.
|
|
72
|
+
void renderMode;
|
|
74
73
|
// Expand any template blocks into full blocks
|
|
75
74
|
const blocks = useMemo(() => {
|
|
76
75
|
if (!script?.blocks) {
|
|
77
76
|
return [];
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
// Flatten nested block hierarchy (markdown-derived docs have children)
|
|
79
|
+
// Flatten nested block hierarchy (markdown-derived docs have children).
|
|
80
|
+
// `flattenRenderableBlocks` skips the children of container templates
|
|
81
|
+
// (`diagram`, `drawing`) — those are consumed by the parent's render as
|
|
82
|
+
// nodes/shapes, so they must not also appear as their own slides.
|
|
81
83
|
const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
|
|
82
|
-
const flatBlocks = hasChildren ?
|
|
84
|
+
const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
|
|
83
85
|
|
|
84
86
|
// Check if any blocks are templates
|
|
85
87
|
const hasTemplates = flatBlocks.some(isTemplateBlock);
|
|
86
88
|
|
|
89
|
+
// Doc persistent layers win wholesale; docs without any inherit the
|
|
90
|
+
// theme's (see resolvePersistentLayers). Passed as a narrow object so
|
|
91
|
+
// the memo deps stay field-precise.
|
|
92
|
+
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
93
|
+
const persistentLayers = resolvePersistentLayers(
|
|
94
|
+
{ persistentLayers: script.persistentLayers },
|
|
95
|
+
resolvedTheme,
|
|
96
|
+
);
|
|
97
|
+
|
|
87
98
|
if (hasTemplates) {
|
|
88
99
|
// Extract audio segment timing for proper block synchronization
|
|
89
100
|
const audioSegments = script.audio?.segments?.map((seg) => ({
|
|
@@ -95,15 +106,31 @@ export function useDocPlayback(
|
|
|
95
106
|
const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
|
|
96
107
|
audioSegments,
|
|
97
108
|
viewport,
|
|
98
|
-
persistentLayers
|
|
109
|
+
persistentLayers,
|
|
99
110
|
theme,
|
|
111
|
+
// Custom (user-defined) templates inlined into the doc's
|
|
112
|
+
// frontmatter — see CustomTemplates.ts. Merged onto the
|
|
113
|
+
// built-in registry so blocks annotated with `{[myhero]}`
|
|
114
|
+
// resolve through the user's design.
|
|
115
|
+
customTemplates: script.customTemplates,
|
|
100
116
|
});
|
|
101
117
|
return expanded;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
// All raw blocks
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
// All raw blocks — used as-is except for the theme's default transition
|
|
121
|
+
// fallback (copies, never mutations: these blocks are caller-owned).
|
|
122
|
+
return flatBlocks.map((block, index) => {
|
|
123
|
+
const transition = resolveBlockTransition(block, resolvedTheme, index);
|
|
124
|
+
return transition !== block.transition ? { ...block, transition } : block;
|
|
125
|
+
});
|
|
126
|
+
}, [
|
|
127
|
+
script?.blocks,
|
|
128
|
+
script?.audio?.segments,
|
|
129
|
+
script?.persistentLayers,
|
|
130
|
+
script?.customTemplates,
|
|
131
|
+
viewport,
|
|
132
|
+
theme,
|
|
133
|
+
]);
|
|
107
134
|
|
|
108
135
|
// Find current block based on time
|
|
109
136
|
const currentBlock = useMemo(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
|
|
@@ -130,78 +157,38 @@ export function useDocPlayback(
|
|
|
130
157
|
return Math.min(1, currentTime / script.duration);
|
|
131
158
|
}, [script, currentTime]);
|
|
132
159
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
166
|
-
}, transitionDuration * 1000);
|
|
167
|
-
|
|
168
|
-
return () => clearTimeout(timer);
|
|
169
|
-
} else {
|
|
170
|
-
// Instant cut
|
|
171
|
-
setTransitionState({
|
|
172
|
-
entering: false,
|
|
173
|
-
exiting: false,
|
|
174
|
-
previousBlock: currentBlock,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on currentBlock?.id only; reading transitionState.previousBlock without dep to avoid infinite loop
|
|
179
|
-
}, [currentBlock?.id, renderMode]);
|
|
180
|
-
|
|
181
|
-
// Render mode: track previous block via ref and compute transition from time
|
|
182
|
-
const renderPrevBlockRef = useRef<Block | null>(null);
|
|
183
|
-
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
if (!renderMode || !currentBlock) return;
|
|
186
|
-
|
|
187
|
-
if (transitionState.previousBlock?.id !== currentBlock.id) {
|
|
188
|
-
// Block changed — remember the old block for crossfade
|
|
189
|
-
const oldPrev = transitionState.previousBlock;
|
|
190
|
-
renderPrevBlockRef.current = oldPrev;
|
|
191
|
-
// Store current block as the "last seen" for next transition
|
|
192
|
-
setTransitionState((prev) => ({
|
|
193
|
-
...prev,
|
|
194
|
-
previousBlock: currentBlock,
|
|
195
|
-
}));
|
|
196
|
-
}
|
|
197
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- same pattern: keyed on block identity change
|
|
198
|
-
}, [currentBlock?.id, renderMode]);
|
|
199
|
-
|
|
200
|
-
// In render mode, derive entering/exiting from blockTime
|
|
201
|
-
const renderTransitionDuration = currentBlock?.transition?.duration || 0;
|
|
202
|
-
const renderIsEntering =
|
|
203
|
-
renderMode && renderTransitionDuration > 0 && blockTime < renderTransitionDuration;
|
|
204
|
-
const renderIsExiting = renderIsEntering && renderPrevBlockRef.current !== null;
|
|
160
|
+
// ─── Transition tracking (synchronous — no effect lag) ──────────────
|
|
161
|
+
// `isEntering` is simply "we are within the block's entrance window"
|
|
162
|
+
// (blockTime < the transition's duration). Deriving it during render —
|
|
163
|
+
// rather than flipping it in an effect a frame AFTER the block changes —
|
|
164
|
+
// means a newly-active block renders WITH its entrance state on its very
|
|
165
|
+
// first frame. Otherwise the block paints once fully settled and then, a
|
|
166
|
+
// frame later, snaps back to the start of its entrance animation: the brief
|
|
167
|
+
// "flash then re-animate" seen between blocks. This runs identically for
|
|
168
|
+
// real-time playback and frame-seeked render (export) mode.
|
|
169
|
+
//
|
|
170
|
+
// The block we transitioned FROM (for the crossfade) is tracked with refs
|
|
171
|
+
// updated during render — the standard "previous value" pattern — so the
|
|
172
|
+
// outgoing block is known on the SAME frame the new block becomes active
|
|
173
|
+
// (an effect would lag a frame and drop the crossfade's first frames).
|
|
174
|
+
const outgoingBlockRef = useRef<Block | null>(null);
|
|
175
|
+
const activeBlockIdRef = useRef<string | null>(null);
|
|
176
|
+
const lastRenderedBlockRef = useRef<Block | null>(null);
|
|
177
|
+
if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
|
|
178
|
+
outgoingBlockRef.current = lastRenderedBlockRef.current;
|
|
179
|
+
activeBlockIdRef.current = currentBlock.id;
|
|
180
|
+
}
|
|
181
|
+
lastRenderedBlockRef.current = currentBlock;
|
|
182
|
+
|
|
183
|
+
const transitionDuration = currentBlock?.transition
|
|
184
|
+
? resolveTransitionDuration(currentBlock.transition)
|
|
185
|
+
: 0;
|
|
186
|
+
const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
|
|
187
|
+
const outgoingBlock = outgoingBlockRef.current;
|
|
188
|
+
// Only crossfade a genuinely different outgoing block (guards restarts/seeks
|
|
189
|
+
// where the "previous" resolves to the same block).
|
|
190
|
+
const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
|
|
191
|
+
const previousBlock = isExiting ? outgoingBlock : null;
|
|
205
192
|
|
|
206
193
|
// Manual navigation
|
|
207
194
|
const goToBlock = useCallback(
|
|
@@ -233,15 +220,9 @@ export function useDocPlayback(
|
|
|
233
220
|
return {
|
|
234
221
|
currentBlock,
|
|
235
222
|
currentBlockIndex,
|
|
236
|
-
previousBlock
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
: null
|
|
240
|
-
: transitionState.exiting
|
|
241
|
-
? transitionState.previousBlock
|
|
242
|
-
: null,
|
|
243
|
-
isEntering: renderMode ? renderIsEntering : transitionState.entering,
|
|
244
|
-
isExiting: renderMode ? renderIsExiting : transitionState.exiting,
|
|
223
|
+
previousBlock,
|
|
224
|
+
isEntering,
|
|
225
|
+
isExiting,
|
|
245
226
|
blockTime,
|
|
246
227
|
blockProgress,
|
|
247
228
|
docProgress,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMediaSchedule
|
|
3
|
+
*
|
|
4
|
+
* Pure follower of the playback clock for the media-clip model. Given the
|
|
5
|
+
* resolved {@link ScheduledClip}s and the current time, it returns the clips
|
|
6
|
+
* the player should mount and which of them are active right now.
|
|
7
|
+
* {@link MediaClipLayer} consumes this to drive one hidden `<audio>` /
|
|
8
|
+
* full-bleed `<video>` element per clip. (Annotation-authored clips all render
|
|
9
|
+
* at the player level; template-produced `VideoLayer`s are a separate path and
|
|
10
|
+
* are not part of the schedule.)
|
|
11
|
+
*
|
|
12
|
+
* It owns no clock: `currentTime`/`isPlaying` come from the existing
|
|
13
|
+
* `useAudioSync` provider via `DocPlayer`. With an empty schedule it returns
|
|
14
|
+
* empty lists, so documents without the new media model are unaffected.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useMemo } from 'react';
|
|
18
|
+
import type { ScheduledClip } from '@bendyline/squisq/schemas';
|
|
19
|
+
|
|
20
|
+
export interface MediaScheduleController {
|
|
21
|
+
/** Clips the player mounts (every scheduled clip). */
|
|
22
|
+
renderClips: ScheduledClip[];
|
|
23
|
+
/** Ids of clips whose [absoluteStart, absoluteEnd) contains currentTime. */
|
|
24
|
+
activeIds: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useMediaSchedule(
|
|
28
|
+
schedule: ScheduledClip[],
|
|
29
|
+
currentTime: number,
|
|
30
|
+
): MediaScheduleController {
|
|
31
|
+
const activeIds = useMemo(() => {
|
|
32
|
+
const ids = new Set<string>();
|
|
33
|
+
for (const c of schedule) {
|
|
34
|
+
if (currentTime >= c.absoluteStart && currentTime < c.absoluteEnd) ids.add(c.id);
|
|
35
|
+
}
|
|
36
|
+
return ids;
|
|
37
|
+
}, [schedule, currentTime]);
|
|
38
|
+
return { renderClips: schedule, activeIds };
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,12 +21,19 @@ export type { InlineAudioPlayerProps } from './InlineAudioPlayer.js';
|
|
|
21
21
|
export { ImageLayer } from './layers/ImageLayer.js';
|
|
22
22
|
export { TextLayer } from './layers/TextLayer.js';
|
|
23
23
|
export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
24
|
+
export { PathLayer } from './layers/PathLayer.js';
|
|
24
25
|
export { VideoLayer } from './layers/VideoLayer.js';
|
|
25
26
|
export { TableLayer } from './layers/TableLayer.js';
|
|
26
27
|
export { MapLayer } from './layers/MapLayer.js';
|
|
27
28
|
|
|
29
|
+
// Timed media clips (block.media + doc.documentMedia playback)
|
|
30
|
+
export { MediaClipLayer } from './MediaClipLayer.js';
|
|
31
|
+
export type { MediaClipLayerProps } from './MediaClipLayer.js';
|
|
32
|
+
|
|
28
33
|
// Hooks
|
|
29
34
|
export { useAudioSync } from './hooks/useAudioSync.js';
|
|
35
|
+
export { useMediaSchedule } from './hooks/useMediaSchedule.js';
|
|
36
|
+
export type { MediaScheduleController } from './hooks/useMediaSchedule.js';
|
|
30
37
|
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
31
38
|
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
32
39
|
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ImageLayer as ImageLayerType, Animation } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { cssFilterForTreatment } from '@bendyline/squisq/doc';
|
|
10
11
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
11
12
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
12
13
|
import { useMediaUrl } from '../hooks/MediaContext';
|
|
@@ -41,6 +42,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
41
42
|
// Get animation styles
|
|
42
43
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
43
44
|
|
|
45
|
+
// Theme-derived photographic grade + optional blur, as a CSS filter
|
|
46
|
+
// string (identical in the player and in headless frame capture).
|
|
47
|
+
const filter = cssFilterForTreatment(content.treatment, content.blur);
|
|
48
|
+
|
|
44
49
|
// SVG preserveAspectRatio based on fit mode
|
|
45
50
|
const preserveAspectRatio = getPreserveAspectRatio(content.fit);
|
|
46
51
|
const isCover = content.fit === 'cover';
|
|
@@ -78,6 +83,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
78
83
|
display: 'block',
|
|
79
84
|
pointerEvents: 'none',
|
|
80
85
|
transformOrigin: 'center center',
|
|
86
|
+
...(filter ? { filter } : {}),
|
|
81
87
|
...kbStyle.style,
|
|
82
88
|
}}
|
|
83
89
|
/>
|
|
@@ -107,6 +113,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
107
113
|
objectPosition: 'center',
|
|
108
114
|
display: 'block',
|
|
109
115
|
pointerEvents: 'none',
|
|
116
|
+
...(filter ? { filter } : {}),
|
|
117
|
+
// Over-scan blurred imagery so the soft edges never reveal
|
|
118
|
+
// the frame behind the layer.
|
|
119
|
+
...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
|
|
110
120
|
}}
|
|
111
121
|
/>
|
|
112
122
|
</foreignObject>
|
|
@@ -128,7 +138,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
128
138
|
width={width}
|
|
129
139
|
height={height}
|
|
130
140
|
preserveAspectRatio={preserveAspectRatio}
|
|
131
|
-
style={{ pointerEvents: 'none' }}
|
|
141
|
+
style={{ pointerEvents: 'none', ...(filter ? { filter } : {}) }}
|
|
132
142
|
/>
|
|
133
143
|
</g>
|
|
134
144
|
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders an SVG `<path>` for arbitrary curves, connectors, arrows, and the
|
|
5
|
+
* drawing template's computed shapes. Used by the diagram template for edges
|
|
6
|
+
* between nodes; usable by any template that needs a non-rect/circle/line
|
|
7
|
+
* shape.
|
|
8
|
+
*
|
|
9
|
+
* The path's `d` attribute uses absolute SVG coordinates relative to the
|
|
10
|
+
* block viewport (independent of the layer's `position` box, which is
|
|
11
|
+
* present only so animations and clipping match the other layer types).
|
|
12
|
+
*
|
|
13
|
+
* Exception: when `content.shapeKind` is set (a standard named shape like
|
|
14
|
+
* `diamond` / `star` / `arrow-right`), `d` is re-derived from the layer's
|
|
15
|
+
* `position` box resolved against the viewport. That keeps named shapes
|
|
16
|
+
* movable, resizable, and aspect-ratio-adaptive — matching how the native
|
|
17
|
+
* rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
|
|
18
|
+
* absolute path.
|
|
19
|
+
*
|
|
20
|
+
* End markers are configured via `startMarker`/`endMarker` (with the legacy
|
|
21
|
+
* `arrow` flag mapping to a filled triangle). Marker geometry comes from
|
|
22
|
+
* `markerPath` in core so the SSR renderer and the editor agree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
|
|
26
|
+
import { markerPath, shapePath } from '@bendyline/squisq/doc';
|
|
27
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
28
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
29
|
+
import { resolveFill, borderDashArray } from '../utils/fillStyle';
|
|
30
|
+
|
|
31
|
+
interface PathLayerProps {
|
|
32
|
+
layer: PathLayerType;
|
|
33
|
+
/** Viewport dimensions — used to resolve `%` positions for named shapes. */
|
|
34
|
+
viewport: { width: number; height: number };
|
|
35
|
+
/** Current time relative to block start. */
|
|
36
|
+
blockTime: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The effective `d`: a named shape (`content.shapeKind`) is re-derived
|
|
41
|
+
* from the resolved `position` box so it tracks moves/resizes and adapts
|
|
42
|
+
* to the viewport; everything else uses the stored absolute path.
|
|
43
|
+
*/
|
|
44
|
+
function effectivePath(layer: PathLayerType, viewport: { width: number; height: number }): string {
|
|
45
|
+
const { content, position } = layer;
|
|
46
|
+
if (!content.shapeKind) return content.d;
|
|
47
|
+
const w = position.width ? resolveValue(position.width, viewport.width) : 0;
|
|
48
|
+
const h = position.height ? resolveValue(position.height, viewport.height) : 0;
|
|
49
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
50
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
51
|
+
const anchor = getAnchorOffset(position.anchor, w, h);
|
|
52
|
+
const derived = shapePath(content.shapeKind, rawX + anchor.x, rawY + anchor.y, w, h);
|
|
53
|
+
// Fall back to the stored path if the kind is unknown to `shapePath`.
|
|
54
|
+
return derived ?? content.d;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the effective marker for an endpoint (explicit field, else `arrow`). */
|
|
58
|
+
function effectiveMarker(
|
|
59
|
+
explicit: MarkerStyle | undefined,
|
|
60
|
+
arrow: PathLayerType['content']['arrow'],
|
|
61
|
+
end: 'start' | 'end',
|
|
62
|
+
): MarkerStyle {
|
|
63
|
+
if (explicit) return explicit;
|
|
64
|
+
const wants = arrow === 'both' || arrow === end;
|
|
65
|
+
return wants ? 'arrow' : 'none';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
|
|
69
|
+
const { content, animation, id } = layer;
|
|
70
|
+
const d = effectivePath(layer, viewport);
|
|
71
|
+
const stroke = content.stroke ?? '#1e293b';
|
|
72
|
+
const strokeWidth = content.strokeWidth ?? 2;
|
|
73
|
+
const { fill, def: fillDef } = resolveFill(id, content.fill ?? 'none', content.gradient);
|
|
74
|
+
// `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
|
|
75
|
+
const dash = content.borderStyle
|
|
76
|
+
? borderDashArray(content.borderStyle, strokeWidth)
|
|
77
|
+
: content.dasharray;
|
|
78
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
79
|
+
|
|
80
|
+
const startId = `marker-start-${id}`;
|
|
81
|
+
const endId = `marker-end-${id}`;
|
|
82
|
+
const start = markerPath(effectiveMarker(content.startMarker, content.arrow, 'start'), 'start');
|
|
83
|
+
const end = markerPath(effectiveMarker(content.endMarker, content.arrow, 'end'), 'end');
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<g
|
|
87
|
+
className={`block-layer block-layer--path ${animStyle.className}`}
|
|
88
|
+
style={animStyle.style}
|
|
89
|
+
data-layer-id={id}
|
|
90
|
+
>
|
|
91
|
+
<defs>
|
|
92
|
+
{fillDef}
|
|
93
|
+
{end && <MarkerDef id={endId} dir="end" d={end.d} filled={end.filled} stroke={stroke} />}
|
|
94
|
+
{start && (
|
|
95
|
+
<MarkerDef id={startId} dir="start" d={start.d} filled={start.filled} stroke={stroke} />
|
|
96
|
+
)}
|
|
97
|
+
</defs>
|
|
98
|
+
<path
|
|
99
|
+
d={d}
|
|
100
|
+
stroke={stroke}
|
|
101
|
+
strokeWidth={strokeWidth}
|
|
102
|
+
fill={fill}
|
|
103
|
+
fillOpacity={content.fillOpacity}
|
|
104
|
+
strokeDasharray={dash}
|
|
105
|
+
markerStart={start ? `url(#${startId})` : undefined}
|
|
106
|
+
markerEnd={end ? `url(#${endId})` : undefined}
|
|
107
|
+
/>
|
|
108
|
+
</g>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function MarkerDef({
|
|
113
|
+
id,
|
|
114
|
+
dir,
|
|
115
|
+
d,
|
|
116
|
+
filled,
|
|
117
|
+
stroke,
|
|
118
|
+
}: {
|
|
119
|
+
id: string;
|
|
120
|
+
dir: 'start' | 'end';
|
|
121
|
+
d: string;
|
|
122
|
+
filled: boolean;
|
|
123
|
+
stroke: string;
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<marker
|
|
127
|
+
id={id}
|
|
128
|
+
viewBox="0 0 10 10"
|
|
129
|
+
refX={dir === 'end' ? 9 : 1}
|
|
130
|
+
refY={5}
|
|
131
|
+
markerWidth={4}
|
|
132
|
+
markerHeight={4}
|
|
133
|
+
orient="auto-start-reverse"
|
|
134
|
+
markerUnits="strokeWidth"
|
|
135
|
+
>
|
|
136
|
+
<path
|
|
137
|
+
d={d}
|
|
138
|
+
fill={filled ? stroke : 'none'}
|
|
139
|
+
stroke={filled ? 'none' : stroke}
|
|
140
|
+
strokeWidth={filled ? undefined : 1.5}
|
|
141
|
+
/>
|
|
142
|
+
</marker>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default PathLayer;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
9
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
10
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
11
|
+
import { resolveFill, resolveShapeFilter, borderDashArray } from '../utils/fillStyle';
|
|
11
12
|
|
|
12
13
|
interface ShapeLayerProps {
|
|
13
14
|
layer: ShapeLayerType;
|
|
@@ -34,12 +35,14 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
34
35
|
// Get animation styles
|
|
35
36
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
36
37
|
|
|
37
|
-
// Check if fill is a CSS gradient (SVG rect doesn't support CSS gradients natively)
|
|
38
38
|
const fill = content.fill || 'none';
|
|
39
|
+
// Legacy: a CSS gradient string baked into `fill` (e.g. from older docs)
|
|
40
|
+
// only works as an HTML background, so rect renders it via foreignObject.
|
|
41
|
+
// The structured `content.gradient` (preferred) is handled below for all
|
|
42
|
+
// shapes via an SVG <linearGradient>.
|
|
39
43
|
const isCSSGradient = typeof fill === 'string' && fill.includes('gradient(');
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
if (content.shape === 'rect' && isCSSGradient) {
|
|
45
|
+
if (content.shape === 'rect' && isCSSGradient && !content.gradient) {
|
|
43
46
|
return (
|
|
44
47
|
<g
|
|
45
48
|
className={`block-layer block-layer--shape ${animStyle.className}`}
|
|
@@ -61,11 +64,23 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
61
64
|
);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
const { fill: fillValue, def: fillDef } = resolveFill(
|
|
68
|
+
layer.id,
|
|
69
|
+
fill,
|
|
70
|
+
content.gradient,
|
|
71
|
+
content.pattern,
|
|
72
|
+
);
|
|
73
|
+
const { filterAttr, def: filterDef } = resolveShapeFilter(layer.id, content.filter);
|
|
74
|
+
const dash = borderDashArray(content.borderStyle, content.strokeWidth);
|
|
75
|
+
|
|
76
|
+
// Common style props for native SVG shapes. `line` is stroke-only.
|
|
65
77
|
const shapeProps = {
|
|
66
|
-
fill:
|
|
78
|
+
fill: fillValue,
|
|
79
|
+
fillOpacity: content.fillOpacity,
|
|
67
80
|
stroke: content.stroke,
|
|
68
81
|
strokeWidth: content.strokeWidth,
|
|
82
|
+
strokeDasharray: dash,
|
|
83
|
+
...(filterAttr ? { filter: filterAttr } : {}),
|
|
69
84
|
};
|
|
70
85
|
|
|
71
86
|
return (
|
|
@@ -74,6 +89,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
74
89
|
style={animStyle.style}
|
|
75
90
|
data-layer-id={layer.id}
|
|
76
91
|
>
|
|
92
|
+
{(fillDef || filterDef) && (
|
|
93
|
+
<defs>
|
|
94
|
+
{fillDef}
|
|
95
|
+
{filterDef}
|
|
96
|
+
</defs>
|
|
97
|
+
)}
|
|
77
98
|
{content.shape === 'rect' && (
|
|
78
99
|
<rect
|
|
79
100
|
x={x}
|
|
@@ -103,6 +124,7 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
103
124
|
y2={y + height}
|
|
104
125
|
stroke={content.stroke || '#ffffff'}
|
|
105
126
|
strokeWidth={content.strokeWidth || 2}
|
|
127
|
+
strokeDasharray={dash}
|
|
106
128
|
/>
|
|
107
129
|
)}
|
|
108
130
|
</g>
|