@pixodesk/svg-animator-rn 1.0.34 → 1.0.39
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 +38 -30
- package/dist/index.cjs +117 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -62
- package/dist/index.d.ts +40 -62
- package/dist/index.js +130 -73
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/PixodeskSvgAnimator.tsx +176 -162
- package/src/PxRnErrorBoundary.tsx +5 -1
- package/src/PxRnMatrix.test.ts +2 -2
- package/src/PxRnMatrix.ts +1 -1
- package/src/PxRnPropNames.ts +2 -2
- package/src/PxRnRender.tsx +14 -15
- package/src/PxRnTracks.test.ts +16 -16
- package/src/PxRnTracks.ts +5 -5
|
@@ -3,19 +3,8 @@
|
|
|
3
3
|
* Licensed under the MIT License. See the LICENSE file in the project root for details.
|
|
4
4
|
*---------------------------------------------------------------------------------------*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
generateNewIds,
|
|
8
|
-
getAnimatorConfig,
|
|
9
|
-
getDefs,
|
|
10
|
-
materialiseAllInTree,
|
|
11
|
-
validateNodeEffects,
|
|
12
|
-
PxAnimatorEngine,
|
|
13
|
-
type FillMode,
|
|
14
|
-
type OutAction,
|
|
15
|
-
type PlaybackDirection,
|
|
16
|
-
type PxAnimatedSvgDocument,
|
|
17
|
-
type PxNode,
|
|
18
|
-
} from '@pixodesk/svg-animator-core';
|
|
6
|
+
import { clampSeekMs, createDiagnostics, createRunClock, isValidPlaybackRate, progressSpanMs, progressToTimeMs, PX_RATE_REJECTED, PxDiagnosticKind, seekCeilingMs, timeToProgress, type PxAnimatorHandle, type PxComponentCallbacks, type PxControlProps, type PxPlaybackOverrideProps, type PxDiagnostic, type PxDiagnostics,
|
|
7
|
+
reportDocumentDiagnostics, generateNewIds, getAnimatorConfig, getDefs, materializeAllInTree, resolveTrigger, validateNodeEffects, PxTimelineEngine, PxControlMode, resolveControlMode, type PxFillMode, type PxOutAction, type PxPlaybackDirection, type PxAnimatedSvgDocument, type PxTimelinePatch, type PxNode, applyAnimatorConfig, foldTimelineOverride } from '@pixodesk/svg-animator-core';
|
|
19
8
|
import React, { createElement, useEffect, useImperativeHandle, useMemo, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
20
9
|
import { Dimensions, Platform, Pressable, View } from 'react-native';
|
|
21
10
|
import Animated, {
|
|
@@ -38,99 +27,45 @@ import { openClosedTextPathTargets } from './PxRnSafety';
|
|
|
38
27
|
|
|
39
28
|
// -- Public types -----------------------------------------------------------
|
|
40
29
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
play(): void;
|
|
48
|
-
|
|
49
|
-
/** Pauses the animation at its current state. */
|
|
50
|
-
pause(): void;
|
|
51
|
-
|
|
52
|
-
/** Stops the animation and resets it to its initial state. */
|
|
53
|
-
cancel(): void;
|
|
54
|
-
|
|
55
|
-
/** Jumps to the end of the animation and holds the final state. */
|
|
56
|
-
finish(): void;
|
|
57
|
-
|
|
58
|
-
/** Changes the speed of the animation. 1 is normal, 2 is double. */
|
|
59
|
-
setPlaybackRate(rate: number): void;
|
|
60
|
-
|
|
61
|
-
/** Returns the current playback time in milliseconds. */
|
|
62
|
-
getCurrentTime(): number | null;
|
|
63
|
-
|
|
64
|
-
/** Jumps to a specific time (in milliseconds) in the animation. */
|
|
65
|
-
setCurrentTime(time: number): void;
|
|
66
|
-
}
|
|
30
|
+
/**
|
|
31
|
+
* The imperative handle `apiRef` is filled with — core's `PxAnimatorHandle` under this package's
|
|
32
|
+
* name (review §9). It no longer "mirrors" the React one by hand: the local copy had already
|
|
33
|
+
* drifted (its `setPlaybackRate` comment lost "negative plays backwards"). One definition.
|
|
34
|
+
*/
|
|
35
|
+
export type RnAnimatorApi = PxAnimatorHandle;
|
|
67
36
|
|
|
68
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The component's props. The playback override, the control props and the callbacks are core's
|
|
39
|
+
* shared shapes (review §9) — `PxPlaybackOverrideProps`, `PxControlProps` and
|
|
40
|
+
* `PxComponentCallbacks` — so React, Vue and React Native cannot drift apart. Only what differs
|
|
41
|
+
* on this platform is declared here: `onError` keeps its richer signature (the error boundary
|
|
42
|
+
* hands it a component stack), and `fallback` has no web counterpart.
|
|
43
|
+
*/
|
|
44
|
+
export interface PixodeskSvgAnimatorProps
|
|
45
|
+
extends PxPlaybackOverrideProps, PxControlProps, Omit<PxComponentCallbacks, 'onError'> {
|
|
69
46
|
|
|
70
47
|
// -- Source ---------------------------------------------------------------
|
|
71
48
|
|
|
72
49
|
/** The animation document to render. */
|
|
73
50
|
doc: PxAnimatedSvgDocument;
|
|
74
51
|
|
|
75
|
-
// -- Timing overrides -----------------------------------------------------
|
|
76
|
-
|
|
77
|
-
/** Duration of a single iteration in milliseconds. */
|
|
78
|
-
duration?: number;
|
|
79
|
-
|
|
80
|
-
/** Delay before the animation starts, in milliseconds. */
|
|
81
|
-
delay?: number;
|
|
82
|
-
|
|
83
|
-
/** Number of iterations, or 'infinite' for endless looping. */
|
|
84
|
-
iterations?: number | 'infinite';
|
|
85
|
-
|
|
86
|
-
/** Defines the element's state when the animation is not active. */
|
|
87
|
-
fill?: FillMode;
|
|
88
|
-
|
|
89
|
-
/** Playback direction. */
|
|
90
|
-
direction?: PlaybackDirection;
|
|
91
|
-
|
|
92
|
-
/** Snap back to the start state after a natural finish. */
|
|
93
|
-
resetOnFinish?: boolean;
|
|
94
|
-
|
|
95
52
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
53
|
+
* Per-instance override of the document's `timeline` — see `PxPlaybackOverrideProps`.
|
|
54
|
+
*
|
|
55
|
+
* `engine` is accepted but ignored here: React Native always uses the `native` (fully
|
|
56
|
+
* flattened) materialization, because react-native-svg has no `<use>` shadow-tree
|
|
57
|
+
* propagation.
|
|
98
58
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// -- Declarative control --------------------------------------------------
|
|
102
|
-
|
|
103
|
-
/** When true, honours the document trigger (`startOn: 'load'` plays on mount). */
|
|
104
|
-
autoplay?: boolean;
|
|
105
|
-
|
|
106
|
-
/** Starts playback unconditionally. */
|
|
107
|
-
play?: boolean;
|
|
108
|
-
|
|
109
|
-
/** Pauses current playback. */
|
|
110
|
-
pause?: boolean;
|
|
59
|
+
timeline?: PxTimelinePatch | string;
|
|
111
60
|
|
|
112
61
|
// -- Imperative control ---------------------------------------------------
|
|
113
62
|
|
|
114
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Ref populated with the imperative playback API. Filled in EVERY mode and never picks one
|
|
65
|
+
* (review §1): `autoplay` next to it still autoplays.
|
|
66
|
+
*/
|
|
115
67
|
apiRef?: React.RefObject<RnAnimatorApi | null>;
|
|
116
68
|
|
|
117
|
-
// -- Controlled (external) time -------------------------------------------
|
|
118
|
-
|
|
119
|
-
/** Seek to a fraction (0–1) of the whole timeline (duration × iterations). */
|
|
120
|
-
progress?: number;
|
|
121
|
-
|
|
122
|
-
/** Seek to a specific time in milliseconds. */
|
|
123
|
-
time?: number;
|
|
124
|
-
|
|
125
|
-
// -- Callbacks ------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
onPlay?: () => void;
|
|
128
|
-
onStop?: () => void;
|
|
129
|
-
onPause?: () => void;
|
|
130
|
-
onCancel?: () => void;
|
|
131
|
-
onFinish?: () => void;
|
|
132
|
-
|
|
133
|
-
|
|
134
69
|
// -- Failure handling -----------------------------------------------------
|
|
135
70
|
|
|
136
71
|
/**
|
|
@@ -139,7 +74,9 @@ export interface PixodeskSvgAnimatorProps {
|
|
|
139
74
|
* animation never takes down the screen around it.
|
|
140
75
|
*
|
|
141
76
|
* Only JavaScript failures reach this — a crash inside react-native-svg's
|
|
142
|
-
* native renderer bypasses JavaScript entirely.
|
|
77
|
+
* native renderer bypasses JavaScript entirely. Richer than the web's
|
|
78
|
+
* `onError(diagnostic)` because the error boundary hands it a component stack; it is
|
|
79
|
+
* adapted into the shared diagnostics channel rather than narrowed to match it.
|
|
143
80
|
*/
|
|
144
81
|
onError?: (error: Error, componentStack?: string) => void;
|
|
145
82
|
|
|
@@ -284,17 +221,11 @@ function SampledSubtree({
|
|
|
284
221
|
|
|
285
222
|
|
|
286
223
|
/** Overrides that shadow the document's own `animator` config. */
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
delay?: number;
|
|
290
|
-
iterations?: number | 'infinite';
|
|
291
|
-
fill?: FillMode;
|
|
292
|
-
direction?: PlaybackDirection;
|
|
293
|
-
resetOnFinish?: boolean;
|
|
294
|
-
}
|
|
224
|
+
/** The override subset of the props — core's shared shape, not a local copy (review §9). */
|
|
225
|
+
type ConfigOverrides = PxPlaybackOverrideProps;
|
|
295
226
|
|
|
296
227
|
interface Compiled {
|
|
297
|
-
/**
|
|
228
|
+
/** Materialized document, or null when compilation failed. */
|
|
298
229
|
doc: PxAnimatedSvgDocument | null;
|
|
299
230
|
tracks: PxCompiledTracks;
|
|
300
231
|
error: Error | null;
|
|
@@ -308,22 +239,34 @@ const EMPTY_TRACKS: PxCompiledTracks = {
|
|
|
308
239
|
};
|
|
309
240
|
|
|
310
241
|
/**
|
|
311
|
-
*
|
|
242
|
+
* Materializes + compiles a document. Extracted from the component so the
|
|
312
243
|
* whole thing sits behind one try/catch, and so it can be tested directly.
|
|
313
244
|
*/
|
|
314
|
-
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides): Compiled {
|
|
315
|
-
const {
|
|
245
|
+
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides, diag: PxDiagnostics): Compiled {
|
|
246
|
+
const { timeline, resetTimeline, duration, delay, iterations, startOn } = overrides;
|
|
316
247
|
const warnings = validateNodeEffects(doc as PxNode);
|
|
317
|
-
for (const w of warnings)
|
|
248
|
+
for (const w of warnings) diag.warn(PxDiagnosticKind.document, 'effects shape: ' + w);
|
|
249
|
+
// The whole-document boundary diagnostic — see the note in the web player's entry.
|
|
250
|
+
reportDocumentDiagnostics(doc, '[PixodeskSvgAnimator]');
|
|
251
|
+
|
|
252
|
+
// The per-instance override, applied to the WIRE document BEFORE anything reads the
|
|
253
|
+
// config — `materializeAllInTree` samples motion paths against `duration`, so a later
|
|
254
|
+
// patch would be read by none of the pipeline. Same call, same rules, on every surface.
|
|
255
|
+
const patch = foldTimelineOverride(timeline, { duration, delay, iterations, startOn });
|
|
256
|
+
if (patch !== undefined || resetTimeline) {
|
|
257
|
+
const applied = applyAnimatorConfig(doc, patch ?? {}, { resetDefaults: !!resetTimeline });
|
|
258
|
+
for (const w of applied.warnings) diag.warn(PxDiagnosticKind.usage, 'timeline override: ' + w);
|
|
259
|
+
doc = applied.doc;
|
|
260
|
+
}
|
|
318
261
|
|
|
319
|
-
// `
|
|
262
|
+
// `native` = the FULLY-FLATTENED materialization: effects + loops +
|
|
320
263
|
// sampled motion paths + animated `<use>` inlined into real `<g>`
|
|
321
264
|
// clones + orphaned defs pruned. That last part is why RN must not use
|
|
322
|
-
// the `
|
|
265
|
+
// the `js` flavor: the frame loop keeps `<use href="#animatedTarget">`
|
|
323
266
|
// live references, which only work because the DOM propagates
|
|
324
267
|
// attribute writes through `<use>` shadow trees. react-native-svg has
|
|
325
268
|
// no such live propagation, so an animated `<use>` would render frozen.
|
|
326
|
-
let prepared =
|
|
269
|
+
let prepared = materializeAllInTree(doc, PxTimelineEngine.native);
|
|
327
270
|
|
|
328
271
|
// Sidestep a react-native-svg NATIVE crash (see PxRnSafety). Guarded on
|
|
329
272
|
// the platform because the DOM renders this case correctly and the web
|
|
@@ -332,21 +275,6 @@ function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides)
|
|
|
332
275
|
prepared = openClosedTextPathTargets(prepared as PxNode) as PxAnimatedSvgDocument;
|
|
333
276
|
}
|
|
334
277
|
|
|
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
278
|
prepared = generateNewIds(prepared);
|
|
351
279
|
const tracks = compileTracks(prepared, { native: NATIVE_SVG_VIEWS });
|
|
352
280
|
return { doc: prepared, tracks, error: null };
|
|
@@ -358,38 +286,71 @@ function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides)
|
|
|
358
286
|
/**
|
|
359
287
|
* React Native component for rendering and controlling Pixodesk SVG animations.
|
|
360
288
|
*
|
|
361
|
-
* The document is
|
|
289
|
+
* The document is materialized once through the shared core pipeline (effects,
|
|
362
290
|
* loops, motion-path sampling, animated-`<use>` inlining — identical to the
|
|
363
|
-
* web
|
|
291
|
+
* web's `native` engine, NOT the frame loop, which keeps `<use>` live), compiled
|
|
292
|
+
* into densely sampled per-element tracks, and
|
|
364
293
|
* played back natively: a single reanimated progress value driven by
|
|
365
294
|
* `withTiming`/`withRepeat` on the UI thread, with per-element worklets
|
|
366
295
|
* indexing the precompiled tracks. No JS-thread frame loop.
|
|
367
296
|
*/
|
|
368
297
|
export function PixodeskSvgAnimator({
|
|
369
|
-
doc,
|
|
298
|
+
doc, timeline, resetTimeline, duration, delay, iterations, startOn,
|
|
370
299
|
// (`progress` prop aliased — the name is taken by the internal reanimated SharedValue)
|
|
371
300
|
autoplay, play, pause, apiRef, progress: progressProp, time,
|
|
372
|
-
onPlay, onStop, onPause, onCancel, onFinish, onError, fallback,
|
|
301
|
+
onPlay, onStop, onPause, onCancel, onFinish, onRemove, onError, fallback, onWarn, silent,
|
|
373
302
|
}: PixodeskSvgAnimatorProps): ReactElement | null {
|
|
374
303
|
|
|
375
304
|
// -- Compile the document (once per doc/override change) ------------------
|
|
376
305
|
|
|
306
|
+
// `timeline` is an object prop: a fresh literal every render would otherwise recompile the
|
|
307
|
+
// whole document (materialize + compile tracks), which is the expensive path. Key on its
|
|
308
|
+
// CONTENT instead — the override is small, the document is not.
|
|
309
|
+
const timelineKey = typeof timeline === 'string' ? timeline : JSON.stringify(timeline ?? null);
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The diagnostics channel, built from the CURRENT props each time (API review §5).
|
|
314
|
+
* Deliberately not hoisted into a wrapper closure: `(m, d) => onWarn?.(m, d)` would always
|
|
315
|
+
* be a function, so the channel would believe a handler exists and the console fallback
|
|
316
|
+
* would never fire for anyone who passed nothing.
|
|
317
|
+
*/
|
|
318
|
+
const makeDiag = (): PxDiagnostics => createDiagnostics({
|
|
319
|
+
onWarn,
|
|
320
|
+
// This component's public `onError` is richer — `(error, componentStack?)` — and the
|
|
321
|
+
// error boundary hands it a component stack. Adapt rather than narrow it; the ternary
|
|
322
|
+
// keeps "not given" as undefined, so the console fallback still fires.
|
|
323
|
+
onError: onError ? (d: PxDiagnostic) => onError(d.error ?? new Error(d.message)) : undefined,
|
|
324
|
+
silent,
|
|
325
|
+
}, '[PixodeskSvgAnimator]');
|
|
326
|
+
|
|
377
327
|
const compiled = useMemo((): Compiled => {
|
|
378
328
|
try {
|
|
379
329
|
return compileDocument(
|
|
380
330
|
doc,
|
|
381
|
-
{
|
|
331
|
+
{ timeline, resetTimeline, duration, delay, iterations, startOn },
|
|
332
|
+
makeDiag(),
|
|
382
333
|
);
|
|
383
334
|
} catch (e) {
|
|
384
335
|
// A malformed document must not take the host screen down with it.
|
|
385
336
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
386
|
-
|
|
337
|
+
makeDiag().warn(PxDiagnosticKind.internal, 'could not compile the document: ' + error.message);
|
|
387
338
|
return { doc: null, tracks: EMPTY_TRACKS, error };
|
|
388
339
|
}
|
|
389
|
-
|
|
340
|
+
// `timeline` is an object prop, so a fresh literal each render would recompile the whole
|
|
341
|
+
// document. Key on its CONTENT — the override is small, unlike the document.
|
|
342
|
+
}, [doc, timelineKey, resetTimeline, duration, delay, iterations, startOn]);
|
|
390
343
|
|
|
391
344
|
const tracks: PxCompiledTracks = compiled.tracks;
|
|
392
|
-
|
|
345
|
+
// The span `progress` 0–1 covers (ONE iteration when endless) — NOT the seek ceiling.
|
|
346
|
+
const totalDuration = progressSpanMs(tracks.duration, tracks.iterations);
|
|
347
|
+
// How far a seek may go: unbounded when endless (review §3).
|
|
348
|
+
const seekCeiling = seekCeilingMs(tracks.duration, tracks.iterations);
|
|
349
|
+
|
|
350
|
+
// Whole-run time lives on its own clock: `progress` below is deliberately within ONE
|
|
351
|
+
// iteration, and `withRepeat` never reports how many have elapsed, so the run time cannot
|
|
352
|
+
// be read back off it. See `createRunClock`.
|
|
353
|
+
const runClock = useMemo(() => createRunClock(seekCeiling), [seekCeiling]);
|
|
393
354
|
|
|
394
355
|
// -- Playback state -------------------------------------------------------
|
|
395
356
|
|
|
@@ -412,6 +373,8 @@ export function PixodeskSvgAnimator({
|
|
|
412
373
|
|
|
413
374
|
const notifyFinish = () => {
|
|
414
375
|
playingRef.current = false;
|
|
376
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
377
|
+
runClock.stop();
|
|
415
378
|
progress.value = restingPosition();
|
|
416
379
|
onFinish?.();
|
|
417
380
|
onStop?.();
|
|
@@ -462,13 +425,17 @@ export function PixodeskSvgAnimator({
|
|
|
462
425
|
play: () => {
|
|
463
426
|
// `startFrom` rewinds to the opposite end when the playhead is
|
|
464
427
|
// already resting at a boundary (mirrors WAAPI, where play() on a
|
|
465
|
-
// finished animation auto-rewinds)
|
|
428
|
+
// finished animation auto-rewinds) — the run clock rewinds with it,
|
|
429
|
+
// or the time would keep counting on from the old end.
|
|
430
|
+
if (Number.isFinite(seekCeiling) && runClock.now() >= seekCeiling) runClock.seek(0);
|
|
431
|
+
runClock.start(rateRef.current);
|
|
466
432
|
startFrom(progress.value);
|
|
467
433
|
onPlay?.();
|
|
468
434
|
},
|
|
469
435
|
pause: () => {
|
|
470
436
|
cancelAnimation(progress);
|
|
471
437
|
playingRef.current = false;
|
|
438
|
+
runClock.stop();
|
|
472
439
|
onPause?.();
|
|
473
440
|
onStop?.();
|
|
474
441
|
},
|
|
@@ -476,30 +443,45 @@ export function PixodeskSvgAnimator({
|
|
|
476
443
|
cancelAnimation(progress);
|
|
477
444
|
progress.value = 0;
|
|
478
445
|
playingRef.current = false;
|
|
446
|
+
runClock.seek(0);
|
|
447
|
+
runClock.stop();
|
|
479
448
|
onCancel?.();
|
|
480
449
|
onStop?.();
|
|
481
450
|
},
|
|
482
451
|
finish: () => {
|
|
483
452
|
cancelAnimation(progress);
|
|
484
453
|
playingRef.current = false;
|
|
454
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
455
|
+
runClock.stop();
|
|
485
456
|
progress.value = restingPosition();
|
|
486
457
|
onFinish?.();
|
|
487
458
|
onStop?.();
|
|
488
459
|
},
|
|
489
460
|
setPlaybackRate: (rate: number) => {
|
|
490
|
-
if (!
|
|
491
|
-
|
|
461
|
+
if (!isValidPlaybackRate(rate)) {
|
|
462
|
+
makeDiag().warn(PxDiagnosticKind.usage, PX_RATE_REJECTED);
|
|
492
463
|
return;
|
|
493
464
|
}
|
|
494
465
|
rateRef.current = rate;
|
|
495
|
-
if (playingRef.current)
|
|
466
|
+
if (playingRef.current) {
|
|
467
|
+
runClock.start(rate); // resume from the current time at the new rate
|
|
468
|
+
startFrom(progress.value);
|
|
469
|
+
}
|
|
496
470
|
},
|
|
497
|
-
|
|
471
|
+
|
|
472
|
+
// Ms from the start of the WHOLE run, like every other engine (review §3). This used
|
|
473
|
+
// to return `progress.value`, which is ms within the current iteration — so a slider
|
|
474
|
+
// built on it jumped back to 0 every time the animation repeated.
|
|
475
|
+
getCurrentTime: () => runClock.now(),
|
|
476
|
+
|
|
498
477
|
setCurrentTime: (t: number) => {
|
|
499
478
|
const wasPlaying = playingRef.current;
|
|
500
479
|
cancelAnimation(progress);
|
|
501
480
|
playingRef.current = false;
|
|
502
|
-
|
|
481
|
+
// Clamp against the SEEK ceiling, not the progress span — the old clamp capped an
|
|
482
|
+
// endless timeline at one iteration.
|
|
483
|
+
const clamped = clampSeekMs(t, seekCeiling);
|
|
484
|
+
runClock.seek(clamped);
|
|
503
485
|
const withinIteration = tracks.duration > 0
|
|
504
486
|
? (clamped % tracks.duration) || (clamped === 0 ? 0 : tracks.duration)
|
|
505
487
|
: 0;
|
|
@@ -507,6 +489,13 @@ export function PixodeskSvgAnimator({
|
|
|
507
489
|
// Seeking mid-playback continues from the new position rather than
|
|
508
490
|
// silently pausing.
|
|
509
491
|
if (wasPlaying) startFrom(withinIteration);
|
|
492
|
+
else runClock.stop();
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
getCurrentProgress: () => timeToProgress(runClock.now(), tracks.duration, tracks.iterations),
|
|
496
|
+
|
|
497
|
+
setCurrentProgress: (p: number) => {
|
|
498
|
+
api.setCurrentTime(progressToTimeMs(p, tracks.duration, tracks.iterations));
|
|
510
499
|
},
|
|
511
500
|
};
|
|
512
501
|
|
|
@@ -514,29 +503,47 @@ export function PixodeskSvgAnimator({
|
|
|
514
503
|
|
|
515
504
|
// -- Declarative control --------------------------------------------------
|
|
516
505
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
506
|
+
// The EFFECTIVE trigger, read back off the COMPILED document — so it already reflects the
|
|
507
|
+
// `timeline` override and the `startOn` shortcut, both merged in before compilation.
|
|
508
|
+
// Resolved through core's one table, so a document means the same here as on the web:
|
|
509
|
+
// no `startOn` = 'load', no `outAction` = 'continue'.
|
|
510
|
+
const trigger = resolveTrigger(compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined);
|
|
511
|
+
const effectiveStartOn = trigger.startOn;
|
|
512
|
+
const effectiveOutAction = trigger.outAction;
|
|
513
|
+
|
|
514
|
+
// ONE control-mode rule, decided in core and shared with React and Vue (API review §1/§7).
|
|
515
|
+
// This component always had the right ORDER but no name for it, and never told anyone when
|
|
516
|
+
// two tiers of props were passed together.
|
|
517
|
+
const { mode: compMode, warnings: modeWarnings } =
|
|
518
|
+
resolveControlMode({ progress: progressProp, time, play, pause, autoplay });
|
|
520
519
|
|
|
521
520
|
useEffect(() => {
|
|
522
|
-
|
|
523
|
-
|
|
521
|
+
const diag = makeDiag();
|
|
522
|
+
for (const w of modeWarnings) diag.warn(PxDiagnosticKind.usage, w);
|
|
523
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
524
|
+
}, [modeWarnings.join('|')]);
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
if (compMode === PxControlMode.fixedTime) {
|
|
528
|
+
const seekMs = time !== undefined
|
|
529
|
+
? time
|
|
530
|
+
: progressToTimeMs(progressProp ?? 0, tracks.duration, tracks.iterations);
|
|
524
531
|
api.setCurrentTime(seekMs);
|
|
525
532
|
return;
|
|
526
533
|
}
|
|
527
|
-
if (
|
|
534
|
+
if (compMode === PxControlMode.play) {
|
|
528
535
|
if (play && !pause) api.play();
|
|
529
536
|
else if (pause) api.pause();
|
|
530
|
-
else if (play === false) api.finish()
|
|
537
|
+
else if (play === false) api.pause(); // hold, not finish() — review §8, same as web
|
|
531
538
|
else api.play();
|
|
532
539
|
return;
|
|
533
540
|
}
|
|
534
541
|
// 'click' and 'scrollIntoView' start from their own handlers below.
|
|
535
|
-
if (autoplay &&
|
|
542
|
+
if (compMode === PxControlMode.autoplay && effectiveStartOn === 'load') {
|
|
536
543
|
api.play();
|
|
537
544
|
}
|
|
538
545
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
539
|
-
}, [compiled, autoplay, play, pause, progressProp, time]);
|
|
546
|
+
}, [compiled, compMode, autoplay, play, pause, progressProp, time]);
|
|
540
547
|
|
|
541
548
|
// `startOn: 'scrollIntoView'` — react-native has no IntersectionObserver, so
|
|
542
549
|
// visibility is sampled by measuring the view against the window box. The
|
|
@@ -545,8 +552,8 @@ export function PixodeskSvgAnimator({
|
|
|
545
552
|
const scrollRef = useRef<View | null>(null);
|
|
546
553
|
const inViewRef = useRef(false);
|
|
547
554
|
useEffect(() => {
|
|
548
|
-
if (!autoplay ||
|
|
549
|
-
const threshold = trigger
|
|
555
|
+
if (!autoplay || effectiveStartOn !== 'scrollIntoView') return;
|
|
556
|
+
const threshold = trigger.scrollIntoViewThreshold;
|
|
550
557
|
inViewRef.current = false;
|
|
551
558
|
|
|
552
559
|
const check = () => {
|
|
@@ -563,9 +570,9 @@ export function PixodeskSvgAnimator({
|
|
|
563
570
|
if (isIn) {
|
|
564
571
|
if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
|
|
565
572
|
api.play();
|
|
566
|
-
} else if (
|
|
567
|
-
else if (
|
|
568
|
-
else if (
|
|
573
|
+
} else if (effectiveOutAction === 'reset') api.cancel();
|
|
574
|
+
else if (effectiveOutAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
|
|
575
|
+
else if (effectiveOutAction !== 'continue') api.pause();
|
|
569
576
|
});
|
|
570
577
|
};
|
|
571
578
|
|
|
@@ -573,13 +580,16 @@ export function PixodeskSvgAnimator({
|
|
|
573
580
|
const id = setInterval(check, 200);
|
|
574
581
|
return () => clearInterval(id);
|
|
575
582
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
576
|
-
}, [compiled, autoplay,
|
|
583
|
+
}, [compiled, autoplay, effectiveStartOn, effectiveOutAction]);
|
|
577
584
|
|
|
578
|
-
// Stop cleanly on unmount / doc swap
|
|
585
|
+
// Stop cleanly on unmount / doc swap — and say so, the way the web's destroy() does (§18):
|
|
586
|
+
// both are "the animator was thrown away", so `onRemove` fires, and `onStop` with it.
|
|
579
587
|
useEffect(() => {
|
|
580
588
|
return () => {
|
|
581
589
|
cancelAnimation(progress);
|
|
582
590
|
playingRef.current = false;
|
|
591
|
+
onRemove?.();
|
|
592
|
+
onStop?.();
|
|
583
593
|
};
|
|
584
594
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
585
595
|
}, [compiled]);
|
|
@@ -647,14 +657,18 @@ export function PixodeskSvgAnimator({
|
|
|
647
657
|
// rather than propagating and unmounting the host screen.
|
|
648
658
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
649
659
|
renderErrorRef.current = error;
|
|
650
|
-
|
|
660
|
+
makeDiag().warn(PxDiagnosticKind.internal, 'could not render the document: ' + error.message);
|
|
651
661
|
return null;
|
|
652
662
|
}
|
|
653
663
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
654
664
|
}, [compiled, trackById]);
|
|
655
665
|
|
|
656
666
|
useEffect(() => {
|
|
657
|
-
|
|
667
|
+
const diag = makeDiag();
|
|
668
|
+
// `platform`: these come from the react-native-svg prop mapper — shapes this renderer
|
|
669
|
+
// cannot express, rather than anything wrong with the file.
|
|
670
|
+
for (const w of warningsRef.current) diag.warn(PxDiagnosticKind.platform, w);
|
|
671
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
658
672
|
}, [root]);
|
|
659
673
|
|
|
660
674
|
// Surface compile/render failures to the host exactly once per occurrence.
|
|
@@ -672,17 +686,17 @@ export function PixodeskSvgAnimator({
|
|
|
672
686
|
// so both are left to the host app.
|
|
673
687
|
let content: ReactElement | null = root;
|
|
674
688
|
|
|
675
|
-
if (autoplay &&
|
|
689
|
+
if (autoplay && effectiveStartOn === 'scrollIntoView' && root) {
|
|
676
690
|
// `collapsable={false}` keeps the view in the native tree so it can be measured.
|
|
677
691
|
content = <View ref={scrollRef} collapsable={false}>{root}</View>;
|
|
678
|
-
} else if (autoplay &&
|
|
692
|
+
} else if (autoplay && effectiveStartOn === 'click' && root) {
|
|
679
693
|
content = (
|
|
680
694
|
<Pressable
|
|
681
695
|
onPress={() => {
|
|
682
696
|
if (playingRef.current) {
|
|
683
|
-
if (
|
|
684
|
-
else if (
|
|
685
|
-
else if (
|
|
697
|
+
if (effectiveOutAction === 'reset') api.cancel();
|
|
698
|
+
else if (effectiveOutAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
|
|
699
|
+
else if (effectiveOutAction !== 'continue') api.pause();
|
|
686
700
|
} else {
|
|
687
701
|
if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
|
|
688
702
|
api.play();
|
|
@@ -698,7 +712,7 @@ export function PixodeskSvgAnimator({
|
|
|
698
712
|
// and commit of the tree — react-native-svg internals, reanimated failing
|
|
699
713
|
// to attach to a component that turns out not to be a host view, and so on.
|
|
700
714
|
return (
|
|
701
|
-
<PxRnErrorBoundary onError={onError} fallback={fallback}>
|
|
715
|
+
<PxRnErrorBoundary onError={onError} fallback={fallback} diag={makeDiag()}>
|
|
702
716
|
{content}
|
|
703
717
|
</PxRnErrorBoundary>
|
|
704
718
|
);
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
*---------------------------------------------------------------------------------------*/
|
|
5
5
|
|
|
6
6
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
7
|
+
import { createDiagnostics, PxDiagnosticKind, type PxDiagnostics } from '@pixodesk/svg-animator-core';
|
|
7
8
|
|
|
8
9
|
export interface PxRnErrorBoundaryProps {
|
|
9
10
|
children: ReactNode;
|
|
10
11
|
/** Rendered instead of the children once something has thrown. */
|
|
11
12
|
fallback?: (error: Error) => ReactNode;
|
|
12
13
|
onError?: (error: Error, info?: string) => void;
|
|
14
|
+
/** Where to report the failure (API review §5). Defaults to the console. */
|
|
15
|
+
diag?: PxDiagnostics;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface State {
|
|
@@ -38,7 +41,8 @@ export class PxRnErrorBoundary extends Component<PxRnErrorBoundaryProps, State>
|
|
|
38
41
|
|
|
39
42
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
43
|
this.props.onError?.(error, info?.componentStack ?? undefined);
|
|
41
|
-
|
|
44
|
+
(this.props.diag ?? createDiagnostics(undefined, '[PixodeskSvgAnimator]'))
|
|
45
|
+
.warn(PxDiagnosticKind.internal, 'render failed: ' + (error?.message ?? String(error)));
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
override componentDidUpdate(prev: PxRnErrorBoundaryProps): void {
|
package/src/PxRnMatrix.test.ts
CHANGED
|
@@ -33,7 +33,7 @@ describe('svgTransformToMatrix', () => {
|
|
|
33
33
|
expect(y).toBeCloseTo(1, 9);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it('
|
|
36
|
+
it('honors a rotation center', () => {
|
|
37
37
|
// rotate(180, 5, 0) maps (0,0) → (10,0)
|
|
38
38
|
const m = svgTransformToMatrix('rotate(180,5,0)')!;
|
|
39
39
|
expect(m[0] * 0 + m[2] * 0 + m[4]).toBeCloseTo(10, 9);
|
|
@@ -53,7 +53,7 @@ describe('toRnPropValue transform handling', () => {
|
|
|
53
53
|
|
|
54
54
|
it('leaves the transform ALONE by default, for the DOM', () => {
|
|
55
55
|
// react-native-web hands the value straight to the DOM, where an array
|
|
56
|
-
//
|
|
56
|
+
// serializes to `transform="1,0,0,1,3,4"` and the element stops moving.
|
|
57
57
|
expect(toRnPropValue('transform', 'translate(3,4)')).toBe('translate(3,4)');
|
|
58
58
|
});
|
|
59
59
|
|
package/src/PxRnMatrix.ts
CHANGED
|
@@ -54,7 +54,7 @@ function numbers(raw: string): Array<number> {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Parses an SVG transform list. Returns `undefined` when the string contains no
|
|
57
|
-
*
|
|
57
|
+
* recognizable function, so callers can leave the original value untouched
|
|
58
58
|
* rather than silently replacing it with an identity matrix.
|
|
59
59
|
*/
|
|
60
60
|
export function svgTransformToMatrix(value: string): Mat2D | undefined {
|
package/src/PxRnPropNames.ts
CHANGED
|
@@ -16,7 +16,7 @@ const ATTR_NAME_OVERRIDES: Record<string, string> = {
|
|
|
16
16
|
const DROPPED_ATTRS = new Set(['class', 'className', 'style', 'xmlns', 'xmlns:xlink', 'data-px-meta']);
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Converts one
|
|
19
|
+
* Converts one normalized wire attribute name (camelCase after core's
|
|
20
20
|
* `getNormalizedProps`, or kebab-case raw) to a react-native-svg prop name.
|
|
21
21
|
* Returns undefined for props that must be dropped.
|
|
22
22
|
*
|
|
@@ -57,7 +57,7 @@ export function toRnPropValue(
|
|
|
57
57
|
// On a device `transform` must arrive as a matrix, not a string: the
|
|
58
58
|
// string→matrix parse happens in JS during render, which reanimated's
|
|
59
59
|
// animated-props path skips entirely. See PxRnMatrix for the full why.
|
|
60
|
-
// On the web the DOM parses the string itself and an array would
|
|
60
|
+
// On the web the DOM parses the string itself and an array would serialize
|
|
61
61
|
// to a meaningless `transform="1,0,0,1,3,4"`, so it must stay a string.
|
|
62
62
|
if (native && rnPropName === 'transform' && typeof value === 'string' && tag !== 'svg') {
|
|
63
63
|
const m = svgTransformToMatrix(value);
|