@pixodesk/svg-animator-rn 1.0.21 → 1.0.24
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 +415 -0
- package/dist/index.cjs +512 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -7
- package/dist/index.d.ts +103 -7
- package/dist/index.js +528 -84
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/PixodeskSvgAnimator.tsx +389 -69
- package/src/PxRnErrorBoundary.tsx +57 -0
- package/src/PxRnMatrix.test.ts +97 -0
- package/src/PxRnMatrix.ts +110 -0
- package/src/PxRnPropNames.ts +21 -1
- package/src/PxRnRender.tsx +54 -8
- package/src/PxRnSafety.test.ts +118 -0
- package/src/PxRnSafety.ts +123 -0
- package/src/PxRnTracks.test.ts +56 -8
- package/src/PxRnTracks.ts +57 -5
- package/src/PxRnTypeMap.ts +72 -5
- package/src/index.ts +2 -0
|
@@ -6,17 +6,21 @@
|
|
|
6
6
|
import {
|
|
7
7
|
generateNewIds,
|
|
8
8
|
getAnimatorConfig,
|
|
9
|
+
getDefs,
|
|
9
10
|
materialiseAllInTree,
|
|
10
11
|
validateNodeEffects,
|
|
11
12
|
PxAnimatorEngine,
|
|
12
13
|
type FillMode,
|
|
14
|
+
type OutAction,
|
|
13
15
|
type PlaybackDirection,
|
|
14
16
|
type PxAnimatedSvgDocument,
|
|
15
17
|
type PxNode,
|
|
16
18
|
} from '@pixodesk/svg-animator-core';
|
|
17
|
-
import React, { useEffect, useImperativeHandle, useMemo, useRef, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
19
|
+
import React, { createElement, useEffect, useImperativeHandle, useMemo, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
20
|
+
import { Dimensions, Platform, Pressable, View } from 'react-native';
|
|
18
21
|
import Animated, {
|
|
19
22
|
cancelAnimation,
|
|
23
|
+
useAnimatedReaction,
|
|
20
24
|
Easing,
|
|
21
25
|
runOnJS,
|
|
22
26
|
useAnimatedProps,
|
|
@@ -27,7 +31,9 @@ import Animated, {
|
|
|
27
31
|
type SharedValue,
|
|
28
32
|
} from 'react-native-reanimated';
|
|
29
33
|
import { compileTracks, sampleProps, type PxCompiledTracks, type PxElementTracks } from './PxRnTracks';
|
|
30
|
-
import { renderRnNode } from './PxRnRender';
|
|
34
|
+
import { renderRnNode, type RenderRnNodeOptions } from './PxRnRender';
|
|
35
|
+
import { PxRnErrorBoundary } from './PxRnErrorBoundary';
|
|
36
|
+
import { openClosedTextPathTargets } from './PxRnSafety';
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
// -- Public types -----------------------------------------------------------
|
|
@@ -83,6 +89,15 @@ export interface PixodeskSvgAnimatorProps {
|
|
|
83
89
|
/** Playback direction. */
|
|
84
90
|
direction?: PlaybackDirection;
|
|
85
91
|
|
|
92
|
+
/** Snap back to the start state after a natural finish. */
|
|
93
|
+
resetOnFinish?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a second tap does when `startOn: 'click'` is active.
|
|
97
|
+
* Defaults to the document's `trigger.outAction`, else `'pause'`.
|
|
98
|
+
*/
|
|
99
|
+
outAction?: OutAction;
|
|
100
|
+
|
|
86
101
|
// -- Declarative control --------------------------------------------------
|
|
87
102
|
|
|
88
103
|
/** When true, honours the document trigger (`startOn: 'load'` plays on mount). */
|
|
@@ -114,11 +129,41 @@ export interface PixodeskSvgAnimatorProps {
|
|
|
114
129
|
onPause?: () => void;
|
|
115
130
|
onCancel?: () => void;
|
|
116
131
|
onFinish?: () => void;
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
// -- Failure handling -----------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Called when a document cannot be compiled or rendered. The component
|
|
138
|
+
* renders {@link fallback} instead of throwing, so a single broken
|
|
139
|
+
* animation never takes down the screen around it.
|
|
140
|
+
*
|
|
141
|
+
* Only JavaScript failures reach this — a crash inside react-native-svg's
|
|
142
|
+
* native renderer bypasses JavaScript entirely.
|
|
143
|
+
*/
|
|
144
|
+
onError?: (error: Error, componentStack?: string) => void;
|
|
145
|
+
|
|
146
|
+
/** Rendered in place of the animation after a failure. Default: nothing. */
|
|
147
|
+
fallback?: (error: Error) => ReactElement | null;
|
|
117
148
|
}
|
|
118
149
|
|
|
119
150
|
|
|
120
151
|
// -- Animated element wrapper ------------------------------------------------
|
|
121
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Whether react-native-svg is backed by NATIVE views here, rather than by the
|
|
155
|
+
* DOM through react-native-web.
|
|
156
|
+
*
|
|
157
|
+
* The two want different things from an animated `transform`: a native view
|
|
158
|
+
* declares a `matrix` prop taking six numbers, while the DOM wants a
|
|
159
|
+
* `transform` attribute holding an SVG string. Getting it wrong is silent —
|
|
160
|
+
* the value is dropped and the element simply never moves — so this single
|
|
161
|
+
* constant decides it once and feeds both the track compiler (value form) and
|
|
162
|
+
* the sampler (prop name). Everything else in the package defaults to the
|
|
163
|
+
* DOM-compatible form.
|
|
164
|
+
*/
|
|
165
|
+
const NATIVE_SVG_VIEWS = Platform.OS !== 'web';
|
|
166
|
+
|
|
122
167
|
const animatedComponentCache = new Map<ComponentType<any>, ComponentType<any>>();
|
|
123
168
|
|
|
124
169
|
function getAnimatedComponent(Component: ComponentType<any>): ComponentType<any> {
|
|
@@ -147,7 +192,10 @@ function AnimatedPxElement({
|
|
|
147
192
|
// Runs on the UI thread every frame; `sampleProps` is a trivial indexed
|
|
148
193
|
// lookup into the precompiled tracks — no interpolation logic on the hot path.
|
|
149
194
|
const animatedProps = useAnimatedProps(() => {
|
|
150
|
-
|
|
195
|
+
// On iOS/Android these values bypass react-native-svg's JS prop layer
|
|
196
|
+
// and land on the native view, which declares `matrix`, not
|
|
197
|
+
// `transform`. The web build keeps the DOM-facing `transform` name.
|
|
198
|
+
return sampleProps(tracks, progress.value, stepMs, sampleCount, NATIVE_SVG_VIEWS);
|
|
151
199
|
}, [tracks, stepMs, sampleCount]);
|
|
152
200
|
|
|
153
201
|
return (
|
|
@@ -158,6 +206,153 @@ function AnimatedPxElement({
|
|
|
158
206
|
}
|
|
159
207
|
|
|
160
208
|
|
|
209
|
+
/**
|
|
210
|
+
* react-native-svg elements whose `render()` returns `null`. They carry data for
|
|
211
|
+
* their PARENT (a `<Stop>` is read by the gradient that owns it) rather than
|
|
212
|
+
* producing a native view, so reanimated has nothing to attach to — wrapping one
|
|
213
|
+
* in `Animated.createAnimatedComponent` throws
|
|
214
|
+
* "Cannot find host instance for this component".
|
|
215
|
+
*/
|
|
216
|
+
const NON_HOST_TAGS = new Set(['stop', 'feMergeNode']);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Definition elements — they describe paint/geometry for something else rather
|
|
220
|
+
* than drawing themselves. Their animated attributes (a gradient's `y1`, a
|
|
221
|
+
* stop's `stop-color`) do not reliably flow through reanimated's animated-props
|
|
222
|
+
* path, so an animated def is rendered by re-sampling from JS instead. There
|
|
223
|
+
* are only ever a handful per document and they change slowly, so the cost is
|
|
224
|
+
* negligible — visual elements still animate entirely on the UI thread.
|
|
225
|
+
*/
|
|
226
|
+
const SAMPLED_DEF_TAGS = new Set(['linearGradient', 'radialGradient', ...NON_HOST_TAGS]);
|
|
227
|
+
|
|
228
|
+
/** True when this subtree must be driven from JS rather than the UI thread:
|
|
229
|
+
* either the node itself is an animated def, or it owns an animated non-host
|
|
230
|
+
* child (an animated `<Stop>` only re-renders via its parent gradient). */
|
|
231
|
+
function needsJsSampling(node: PxNode, trackById: Map<string, PxElementTracks>): boolean {
|
|
232
|
+
const id = (node as any).id;
|
|
233
|
+
if (SAMPLED_DEF_TAGS.has(String(node.type)) && id && trackById.has(id)) return true;
|
|
234
|
+
return (node.children ?? []).some(c => needsJsSampling(c, trackById));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Renders a subtree whose animation cannot run on the UI thread (see
|
|
239
|
+
* {@link NON_HOST_TAGS}) by re-rendering it from JS with sampled values.
|
|
240
|
+
*
|
|
241
|
+
* Changing a `<Stop>`'s props does not re-render its parent gradient, so the
|
|
242
|
+
* whole subtree is rebuilt — which is why this wraps the gradient rather than
|
|
243
|
+
* the stop. The reaction itself runs on the UI thread and only crosses to JS
|
|
244
|
+
* when the QUANTISED sample index changes, capping these few elements at
|
|
245
|
+
* ~30fps instead of a JS call every frame.
|
|
246
|
+
*/
|
|
247
|
+
function SampledSubtree({
|
|
248
|
+
node, trackById, progress, stepMs, sampleCount, renderOpts,
|
|
249
|
+
}: {
|
|
250
|
+
node: PxNode;
|
|
251
|
+
trackById: Map<string, PxElementTracks>;
|
|
252
|
+
progress: SharedValue<number>;
|
|
253
|
+
stepMs: number;
|
|
254
|
+
sampleCount: number;
|
|
255
|
+
renderOpts: RenderRnNodeOptions;
|
|
256
|
+
}) {
|
|
257
|
+
const [idx, setIdx] = useState(0);
|
|
258
|
+
|
|
259
|
+
useAnimatedReaction(
|
|
260
|
+
() => Math.floor(Math.round(progress.value / stepMs) / 2) * 2, // half-rate
|
|
261
|
+
(next, prev) => {
|
|
262
|
+
if (next !== prev) runOnJS(setIdx)(next);
|
|
263
|
+
},
|
|
264
|
+
[stepMs]
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const tMs = Math.min(Math.max(idx, 0), sampleCount - 1) * stepMs;
|
|
268
|
+
|
|
269
|
+
return renderRnNode(node, {
|
|
270
|
+
...renderOpts,
|
|
271
|
+
// Sampled values are baked in as PLAIN props — nothing reanimated-driven.
|
|
272
|
+
decorate: (n, Component, staticProps, children, key) => {
|
|
273
|
+
const id = (n as any).id;
|
|
274
|
+
const tracks = id ? trackById.get(id) : undefined;
|
|
275
|
+
if (!tracks) return undefined;
|
|
276
|
+
// Wire prop names, NOT the native ones: these go back through
|
|
277
|
+
// react-native-svg's JS prop layer, which does the renaming itself.
|
|
278
|
+
const sampled = sampleProps(tracks, tMs, stepMs, sampleCount);
|
|
279
|
+
return createElement(Component, { ...staticProps, ...sampled, key }, children);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
/** Overrides that shadow the document's own `animator` config. */
|
|
287
|
+
interface ConfigOverrides {
|
|
288
|
+
duration?: number;
|
|
289
|
+
delay?: number;
|
|
290
|
+
iterations?: number | 'infinite';
|
|
291
|
+
fill?: FillMode;
|
|
292
|
+
direction?: PlaybackDirection;
|
|
293
|
+
resetOnFinish?: boolean;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface Compiled {
|
|
297
|
+
/** Materialised document, or null when compilation failed. */
|
|
298
|
+
doc: PxAnimatedSvgDocument | null;
|
|
299
|
+
tracks: PxCompiledTracks;
|
|
300
|
+
error: Error | null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Playable-but-empty tracks, so a failed compile still satisfies every hook
|
|
304
|
+
* below it — React forbids skipping hooks on an error path. */
|
|
305
|
+
const EMPTY_TRACKS: PxCompiledTracks = {
|
|
306
|
+
duration: 1, iterations: 1, direction: 'normal', delay: 0,
|
|
307
|
+
fill: 'forwards', resetOnFinish: false, stepMs: 1, sampleCount: 2, elements: [],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Materialises + compiles a document. Extracted from the component so the
|
|
312
|
+
* whole thing sits behind one try/catch, and so it can be tested directly.
|
|
313
|
+
*/
|
|
314
|
+
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides): Compiled {
|
|
315
|
+
const { duration, delay, iterations, fill, direction, resetOnFinish } = overrides;
|
|
316
|
+
const warnings = validateNodeEffects(doc as PxNode);
|
|
317
|
+
for (const w of warnings) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
|
|
318
|
+
|
|
319
|
+
// `waapi` = the FULLY-FLATTENED materialisation: effects + loops +
|
|
320
|
+
// sampled motion paths + animated `<use>` inlined into real `<g>`
|
|
321
|
+
// clones + orphaned defs pruned. That last part is why RN must not use
|
|
322
|
+
// the `frames` flavour: frames keeps `<use href="#animatedTarget">`
|
|
323
|
+
// live references, which only work because the DOM propagates
|
|
324
|
+
// attribute writes through `<use>` shadow trees. react-native-svg has
|
|
325
|
+
// no such live propagation, so an animated `<use>` would render frozen.
|
|
326
|
+
let prepared = materialiseAllInTree(doc, PxAnimatorEngine.waapi);
|
|
327
|
+
|
|
328
|
+
// Sidestep a react-native-svg NATIVE crash (see PxRnSafety). Guarded on
|
|
329
|
+
// the platform because the DOM renders this case correctly and the web
|
|
330
|
+
// document must stay exactly as the core pipeline produced it.
|
|
331
|
+
if (NATIVE_SVG_VIEWS) {
|
|
332
|
+
prepared = openClosedTextPathTargets(prepared as PxNode) as PxAnimatedSvgDocument;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Apply prop overrides onto the animator config (mirrors the react wrapper).
|
|
336
|
+
const animator = getAnimatorConfig(prepared) || {};
|
|
337
|
+
prepared = {
|
|
338
|
+
...prepared,
|
|
339
|
+
animator: {
|
|
340
|
+
...animator,
|
|
341
|
+
duration: duration !== undefined ? duration : animator.duration,
|
|
342
|
+
delay: delay !== undefined ? delay : animator.delay,
|
|
343
|
+
iterations: iterations !== undefined ? iterations : animator.iterations,
|
|
344
|
+
fill: fill !== undefined ? fill : animator.fill,
|
|
345
|
+
direction: direction !== undefined ? direction : animator.direction,
|
|
346
|
+
resetOnFinish: resetOnFinish !== undefined ? resetOnFinish : animator.resetOnFinish,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
prepared = generateNewIds(prepared);
|
|
351
|
+
const tracks = compileTracks(prepared, { native: NATIVE_SVG_VIEWS });
|
|
352
|
+
return { doc: prepared, tracks, error: null };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
161
356
|
// -- Main component ----------------------------------------------------------
|
|
162
357
|
|
|
163
358
|
/**
|
|
@@ -171,44 +366,26 @@ function AnimatedPxElement({
|
|
|
171
366
|
* indexing the precompiled tracks. No JS-thread frame loop.
|
|
172
367
|
*/
|
|
173
368
|
export function PixodeskSvgAnimator({
|
|
174
|
-
doc, duration, delay, iterations, fill, direction,
|
|
369
|
+
doc, duration, delay, iterations, fill, direction, resetOnFinish, outAction: outActionProp,
|
|
175
370
|
autoplay, play, pause, apiRef, time, timeMs,
|
|
176
|
-
onPlay, onStop, onPause, onCancel, onFinish,
|
|
371
|
+
onPlay, onStop, onPause, onCancel, onFinish, onError, fallback,
|
|
177
372
|
}: PixodeskSvgAnimatorProps): ReactElement | null {
|
|
178
373
|
|
|
179
374
|
// -- Compile the document (once per doc/override change) ------------------
|
|
180
375
|
|
|
181
|
-
const compiled = useMemo(() => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
// Apply prop overrides onto the animator config (mirrors the react wrapper).
|
|
195
|
-
const animator = getAnimatorConfig(prepared) || {};
|
|
196
|
-
prepared = {
|
|
197
|
-
...prepared,
|
|
198
|
-
animator: {
|
|
199
|
-
...animator,
|
|
200
|
-
duration: duration !== undefined ? duration : animator.duration,
|
|
201
|
-
delay: delay !== undefined ? delay : animator.delay,
|
|
202
|
-
iterations: iterations !== undefined ? iterations : animator.iterations,
|
|
203
|
-
fill: fill !== undefined ? fill : animator.fill,
|
|
204
|
-
direction: direction !== undefined ? direction : animator.direction,
|
|
205
|
-
},
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
prepared = generateNewIds(prepared);
|
|
209
|
-
const tracks = compileTracks(prepared);
|
|
210
|
-
return { doc: prepared, tracks };
|
|
211
|
-
}, [doc, duration, delay, iterations, fill, direction]);
|
|
376
|
+
const compiled = useMemo((): Compiled => {
|
|
377
|
+
try {
|
|
378
|
+
return compileDocument(
|
|
379
|
+
doc,
|
|
380
|
+
{ duration, delay, iterations, fill, direction, resetOnFinish }
|
|
381
|
+
);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
// A malformed document must not take the host screen down with it.
|
|
384
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
385
|
+
console.warn('[PixodeskSvgAnimator] could not compile the document:', error.message);
|
|
386
|
+
return { doc: null, tracks: EMPTY_TRACKS, error };
|
|
387
|
+
}
|
|
388
|
+
}, [doc, duration, delay, iterations, fill, direction, resetOnFinish]);
|
|
212
389
|
|
|
213
390
|
const tracks: PxCompiledTracks = compiled.tracks;
|
|
214
391
|
const totalDuration = tracks.duration * (tracks.iterations === Infinity ? 1 : tracks.iterations);
|
|
@@ -221,9 +398,20 @@ export function PixodeskSvgAnimator({
|
|
|
221
398
|
const playingRef = useRef(false);
|
|
222
399
|
const rateRef = useRef(1);
|
|
223
400
|
|
|
401
|
+
/** True when the current rate plays the timeline backwards. */
|
|
402
|
+
const reversePlayback = () => rateRef.current < 0;
|
|
403
|
+
|
|
404
|
+
/** Where the playhead rests once playback ends. `resetOnFinish` snaps back
|
|
405
|
+
* to the start; otherwise `fill` decides whether the final frame is held. */
|
|
406
|
+
const restingPosition = () => {
|
|
407
|
+
if (tracks.resetOnFinish) return 0;
|
|
408
|
+
if (tracks.fill === 'none' || tracks.fill === 'backwards') return 0;
|
|
409
|
+
return reversePlayback() ? 0 : tracks.duration;
|
|
410
|
+
};
|
|
411
|
+
|
|
224
412
|
const notifyFinish = () => {
|
|
225
413
|
playingRef.current = false;
|
|
226
|
-
|
|
414
|
+
progress.value = restingPosition();
|
|
227
415
|
onFinish?.();
|
|
228
416
|
onStop?.();
|
|
229
417
|
};
|
|
@@ -231,16 +419,21 @@ export function PixodeskSvgAnimator({
|
|
|
231
419
|
const startFrom = (fromMs: number) => {
|
|
232
420
|
const dur = tracks.duration;
|
|
233
421
|
const rate = rateRef.current || 1;
|
|
234
|
-
const
|
|
422
|
+
const backwards = rate < 0;
|
|
423
|
+
const speed = Math.abs(rate);
|
|
424
|
+
// `direction` decides which end a leg runs toward; a negative playback
|
|
425
|
+
// rate flips it again (the two compose, as in the Web Animations API).
|
|
426
|
+
const directionReversed = tracks.direction === 'reverse' || tracks.direction === 'alternate-reverse';
|
|
427
|
+
const reversedStart = backwards ? !directionReversed : directionReversed;
|
|
235
428
|
const alternates = tracks.direction === 'alternate' || tracks.direction === 'alternate-reverse';
|
|
236
429
|
const from = Math.max(0, Math.min(fromMs, dur));
|
|
237
430
|
|
|
238
431
|
const legTarget = reversedStart ? 0 : dur;
|
|
239
|
-
const legRemaining = Math.abs(legTarget - from) /
|
|
432
|
+
const legRemaining = Math.abs(legTarget - from) / speed;
|
|
240
433
|
const repeats = tracks.iterations === Infinity ? -1 : tracks.iterations;
|
|
241
434
|
|
|
242
435
|
cancelAnimation(progress);
|
|
243
|
-
progress.value = reversedStart ? (from
|
|
436
|
+
progress.value = reversedStart ? (from <= 0 ? dur : from) : (from >= dur ? 0 : from);
|
|
244
437
|
|
|
245
438
|
const animation = repeats === 1
|
|
246
439
|
? withTiming(legTarget, { duration: legRemaining, easing: Easing.linear }, (finished) => {
|
|
@@ -257,7 +450,7 @@ export function PixodeskSvgAnimator({
|
|
|
257
450
|
);
|
|
258
451
|
|
|
259
452
|
progress.value = tracks.delay > 0 && from === 0
|
|
260
|
-
? withDelay(tracks.delay /
|
|
453
|
+
? withDelay(tracks.delay / speed, animation)
|
|
261
454
|
: animation;
|
|
262
455
|
|
|
263
456
|
playingRef.current = true;
|
|
@@ -266,8 +459,10 @@ export function PixodeskSvgAnimator({
|
|
|
266
459
|
const api: RnAnimatorApi = {
|
|
267
460
|
isPlaying: () => playingRef.current,
|
|
268
461
|
play: () => {
|
|
269
|
-
|
|
270
|
-
|
|
462
|
+
// `startFrom` rewinds to the opposite end when the playhead is
|
|
463
|
+
// already resting at a boundary (mirrors WAAPI, where play() on a
|
|
464
|
+
// finished animation auto-rewinds).
|
|
465
|
+
startFrom(progress.value);
|
|
271
466
|
onPlay?.();
|
|
272
467
|
},
|
|
273
468
|
pause: () => {
|
|
@@ -285,14 +480,14 @@ export function PixodeskSvgAnimator({
|
|
|
285
480
|
},
|
|
286
481
|
finish: () => {
|
|
287
482
|
cancelAnimation(progress);
|
|
288
|
-
progress.value = tracks.fill === 'none' || tracks.fill === 'backwards' ? 0 : tracks.duration;
|
|
289
483
|
playingRef.current = false;
|
|
484
|
+
progress.value = restingPosition();
|
|
290
485
|
onFinish?.();
|
|
291
486
|
onStop?.();
|
|
292
487
|
},
|
|
293
488
|
setPlaybackRate: (rate: number) => {
|
|
294
|
-
if (!isFinite(rate) || rate
|
|
295
|
-
console.warn('setPlaybackRate:
|
|
489
|
+
if (!isFinite(rate) || rate === 0) {
|
|
490
|
+
console.warn('setPlaybackRate: rate must be finite and non-zero');
|
|
296
491
|
return;
|
|
297
492
|
}
|
|
298
493
|
rateRef.current = rate;
|
|
@@ -300,10 +495,17 @@ export function PixodeskSvgAnimator({
|
|
|
300
495
|
},
|
|
301
496
|
getCurrentTime: () => progress.value,
|
|
302
497
|
setCurrentTime: (t: number) => {
|
|
498
|
+
const wasPlaying = playingRef.current;
|
|
303
499
|
cancelAnimation(progress);
|
|
304
500
|
playingRef.current = false;
|
|
305
501
|
const clamped = Math.max(0, Math.min(t, totalDuration));
|
|
306
|
-
|
|
502
|
+
const withinIteration = tracks.duration > 0
|
|
503
|
+
? (clamped % tracks.duration) || (clamped === 0 ? 0 : tracks.duration)
|
|
504
|
+
: 0;
|
|
505
|
+
progress.value = withinIteration;
|
|
506
|
+
// Seeking mid-playback continues from the new position rather than
|
|
507
|
+
// silently pausing.
|
|
508
|
+
if (wasPlaying) startFrom(withinIteration);
|
|
307
509
|
},
|
|
308
510
|
};
|
|
309
511
|
|
|
@@ -311,7 +513,9 @@ export function PixodeskSvgAnimator({
|
|
|
311
513
|
|
|
312
514
|
// -- Declarative control --------------------------------------------------
|
|
313
515
|
|
|
314
|
-
const
|
|
516
|
+
const trigger = compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined;
|
|
517
|
+
const startOn = trigger?.startOn ?? 'load';
|
|
518
|
+
const outAction = outActionProp ?? trigger?.outAction ?? 'pause';
|
|
315
519
|
|
|
316
520
|
useEffect(() => {
|
|
317
521
|
if (time !== undefined || timeMs !== undefined) {
|
|
@@ -326,12 +530,50 @@ export function PixodeskSvgAnimator({
|
|
|
326
530
|
else api.play();
|
|
327
531
|
return;
|
|
328
532
|
}
|
|
533
|
+
// 'click' and 'scrollIntoView' start from their own handlers below.
|
|
329
534
|
if (autoplay && startOn === 'load') {
|
|
330
535
|
api.play();
|
|
331
536
|
}
|
|
332
537
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
333
538
|
}, [compiled, autoplay, play, pause, time, timeMs]);
|
|
334
539
|
|
|
540
|
+
// `startOn: 'scrollIntoView'` — react-native has no IntersectionObserver, so
|
|
541
|
+
// visibility is sampled by measuring the view against the window box. The
|
|
542
|
+
// poll is cheap (a native measure every 200ms) and only runs while this
|
|
543
|
+
// trigger is active; `outAction` decides what leaving the viewport does.
|
|
544
|
+
const scrollRef = useRef<View | null>(null);
|
|
545
|
+
const inViewRef = useRef(false);
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!autoplay || startOn !== 'scrollIntoView') return;
|
|
548
|
+
const threshold = trigger?.scrollIntoViewThreshold ?? 0;
|
|
549
|
+
inViewRef.current = false;
|
|
550
|
+
|
|
551
|
+
const check = () => {
|
|
552
|
+
const node = scrollRef.current;
|
|
553
|
+
if (!node) return;
|
|
554
|
+
node.measureInWindow((_x, y, _w, h) => {
|
|
555
|
+
if (!h) return;
|
|
556
|
+
const screen = Dimensions.get('window').height;
|
|
557
|
+
const visible = Math.max(0, Math.min(y + h, screen) - Math.max(y, 0));
|
|
558
|
+
const ratio = visible / h;
|
|
559
|
+
const isIn = ratio > 0 && ratio >= threshold;
|
|
560
|
+
if (isIn === inViewRef.current) return;
|
|
561
|
+
inViewRef.current = isIn;
|
|
562
|
+
if (isIn) {
|
|
563
|
+
if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
|
|
564
|
+
api.play();
|
|
565
|
+
} else if (outAction === 'reset') api.cancel();
|
|
566
|
+
else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
|
|
567
|
+
else if (outAction !== 'continue') api.pause();
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
check();
|
|
572
|
+
const id = setInterval(check, 200);
|
|
573
|
+
return () => clearInterval(id);
|
|
574
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
575
|
+
}, [compiled, autoplay, startOn, outAction]);
|
|
576
|
+
|
|
335
577
|
// Stop cleanly on unmount / doc swap.
|
|
336
578
|
useEffect(() => {
|
|
337
579
|
return () => {
|
|
@@ -350,29 +592,63 @@ export function PixodeskSvgAnimator({
|
|
|
350
592
|
}, [tracks]);
|
|
351
593
|
|
|
352
594
|
const warningsRef = useRef<Array<string>>([]);
|
|
595
|
+
const renderErrorRef = useRef<Error | null>(null);
|
|
353
596
|
const root = useMemo(() => {
|
|
354
597
|
warningsRef.current = [];
|
|
355
|
-
|
|
598
|
+
renderErrorRef.current = null;
|
|
599
|
+
if (!compiled.doc) return null;
|
|
600
|
+
|
|
601
|
+
const renderOpts: RenderRnNodeOptions = {
|
|
356
602
|
warnings: warningsRef.current,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
603
|
+
defs: getDefs(compiled.doc),
|
|
604
|
+
};
|
|
605
|
+
try {
|
|
606
|
+
return renderRnNode(compiled.doc as PxNode, {
|
|
607
|
+
...renderOpts,
|
|
608
|
+
decorate: (node, Component, staticProps, children, key) => {
|
|
609
|
+
// An animated definition subtree (gradient / its stops) cannot be
|
|
610
|
+
// driven on the UI thread — hand the WHOLE subtree to the
|
|
611
|
+
// JS-sampled renderer and stop descending here.
|
|
612
|
+
if (needsJsSampling(node, trackById)) {
|
|
613
|
+
return (
|
|
614
|
+
<SampledSubtree
|
|
615
|
+
key={key}
|
|
616
|
+
node={node}
|
|
617
|
+
trackById={trackById}
|
|
618
|
+
progress={progress}
|
|
619
|
+
stepMs={tracks.stepMs}
|
|
620
|
+
sampleCount={tracks.sampleCount}
|
|
621
|
+
renderOpts={renderOpts}
|
|
622
|
+
/>
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const id = (node as any).id;
|
|
627
|
+
const elTracks = id ? trackById.get(id) : undefined;
|
|
628
|
+
if (!elTracks) return undefined;
|
|
629
|
+
return (
|
|
630
|
+
<AnimatedPxElement
|
|
631
|
+
key={key}
|
|
632
|
+
Component={Component}
|
|
633
|
+
staticProps={staticProps}
|
|
634
|
+
tracks={elTracks}
|
|
635
|
+
progress={progress}
|
|
636
|
+
stepMs={tracks.stepMs}
|
|
637
|
+
sampleCount={tracks.sampleCount}
|
|
638
|
+
>
|
|
639
|
+
{children}
|
|
640
|
+
</AnimatedPxElement>
|
|
641
|
+
);
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
} catch (e) {
|
|
645
|
+
// Building the element tree threw — report it and render nothing
|
|
646
|
+
// rather than propagating and unmounting the host screen.
|
|
647
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
648
|
+
renderErrorRef.current = error;
|
|
649
|
+
console.warn('[PixodeskSvgAnimator] could not render the document:', error.message);
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
376
652
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
377
653
|
}, [compiled, trackById]);
|
|
378
654
|
|
|
@@ -380,7 +656,51 @@ export function PixodeskSvgAnimator({
|
|
|
380
656
|
for (const w of warningsRef.current) console.warn('[PixodeskSvgAnimator]', w);
|
|
381
657
|
}, [root]);
|
|
382
658
|
|
|
383
|
-
|
|
659
|
+
// Surface compile/render failures to the host exactly once per occurrence.
|
|
660
|
+
const failure = compiled.error ?? renderErrorRef.current;
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
if (failure) onError?.(failure);
|
|
663
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
664
|
+
}, [failure]);
|
|
665
|
+
|
|
666
|
+
if (failure) return fallback ? fallback(failure) : null;
|
|
667
|
+
|
|
668
|
+
// `startOn: 'click'` — the touch analogue of the web player's click trigger:
|
|
669
|
+
// tap to start, tap again to apply `outAction`. Hover (`mouseOver`) has no
|
|
670
|
+
// touch equivalent and `scrollIntoView` needs the surrounding scroll view,
|
|
671
|
+
// so both are left to the host app.
|
|
672
|
+
let content: ReactElement | null = root;
|
|
673
|
+
|
|
674
|
+
if (autoplay && startOn === 'scrollIntoView' && root) {
|
|
675
|
+
// `collapsable={false}` keeps the view in the native tree so it can be measured.
|
|
676
|
+
content = <View ref={scrollRef} collapsable={false}>{root}</View>;
|
|
677
|
+
} else if (autoplay && startOn === 'click' && root) {
|
|
678
|
+
content = (
|
|
679
|
+
<Pressable
|
|
680
|
+
onPress={() => {
|
|
681
|
+
if (playingRef.current) {
|
|
682
|
+
if (outAction === 'reset') api.cancel();
|
|
683
|
+
else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
|
|
684
|
+
else if (outAction !== 'continue') api.pause();
|
|
685
|
+
} else {
|
|
686
|
+
if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
|
|
687
|
+
api.play();
|
|
688
|
+
}
|
|
689
|
+
}}
|
|
690
|
+
>
|
|
691
|
+
{root}
|
|
692
|
+
</Pressable>
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Catches what the try/catch above cannot: throws during React's own render
|
|
697
|
+
// and commit of the tree — react-native-svg internals, reanimated failing
|
|
698
|
+
// to attach to a component that turns out not to be a host view, and so on.
|
|
699
|
+
return (
|
|
700
|
+
<PxRnErrorBoundary onError={onError} fallback={fallback}>
|
|
701
|
+
{content}
|
|
702
|
+
</PxRnErrorBoundary>
|
|
703
|
+
);
|
|
384
704
|
}
|
|
385
705
|
|
|
386
706
|
export default PixodeskSvgAnimator;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*---------------------------------------------------------------------------------------
|
|
2
|
+
* Copyright (c) Pixodesk LTD.
|
|
3
|
+
* Licensed under the MIT License. See the LICENSE file in the project root for details.
|
|
4
|
+
*---------------------------------------------------------------------------------------*/
|
|
5
|
+
|
|
6
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
export interface PxRnErrorBoundaryProps {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** Rendered instead of the children once something has thrown. */
|
|
11
|
+
fallback?: (error: Error) => ReactNode;
|
|
12
|
+
onError?: (error: Error, info?: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface State {
|
|
16
|
+
error: Error | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Keeps one bad animation from taking down the screen around it.
|
|
21
|
+
*
|
|
22
|
+
* A throw anywhere in the rendered SVG tree — an unsupported prop shape, a
|
|
23
|
+
* react-native-svg internal, a reanimated attachment failure — otherwise
|
|
24
|
+
* unmounts the whole React tree above it. Here it is contained to this one
|
|
25
|
+
* animation, reported through `onError`, and replaced by `fallback`.
|
|
26
|
+
*
|
|
27
|
+
* NOTE the limit: this catches JavaScript errors only. A crash INSIDE the
|
|
28
|
+
* native renderer (see `openClosedTextPathTargets` for a real example) never
|
|
29
|
+
* reaches JavaScript and cannot be caught here — those have to be avoided
|
|
30
|
+
* rather than handled.
|
|
31
|
+
*/
|
|
32
|
+
export class PxRnErrorBoundary extends Component<PxRnErrorBoundaryProps, State> {
|
|
33
|
+
override state: State = { error: null };
|
|
34
|
+
|
|
35
|
+
static getDerivedStateFromError(error: Error): State {
|
|
36
|
+
return { error };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
|
+
this.props.onError?.(error, info?.componentStack ?? undefined);
|
|
41
|
+
console.warn('[PixodeskSvgAnimator] render failed:', error?.message ?? error);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override componentDidUpdate(prev: PxRnErrorBoundaryProps): void {
|
|
45
|
+
// A new document deserves a fresh attempt — otherwise the boundary
|
|
46
|
+
// would stay latched on the failure for the rest of its life.
|
|
47
|
+
if (this.state.error && prev.children !== this.props.children) {
|
|
48
|
+
this.setState({ error: null });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override render(): ReactNode {
|
|
53
|
+
const { error } = this.state;
|
|
54
|
+
if (error) return this.props.fallback ? this.props.fallback(error) : null;
|
|
55
|
+
return this.props.children;
|
|
56
|
+
}
|
|
57
|
+
}
|