@pixodesk/svg-animator-rn 1.0.35 → 1.0.40
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 +47 -55
- package/dist/index.cjs +113 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -237
- package/dist/index.d.ts +31 -237
- package/dist/index.js +100 -81
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/PixodeskSvgAnimator.tsx +158 -154
- package/src/PxRnErrorBoundary.tsx +13 -4
- package/src/PxRnMatrix.test.ts +2 -2
- package/src/PxRnMatrix.ts +1 -1
- package/src/PxRnPropNames.ts +4 -3
- package/src/PxRnRender.tsx +21 -27
- package/src/PxRnSafety.ts +3 -1
- package/src/PxRnTracks.test.ts +12 -12
- package/src/PxRnTracks.ts +10 -12
- package/src/PxRnTypeMap.ts +1 -0
- package/src/index.ts +0 -9
|
@@ -3,7 +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 {
|
|
6
|
+
import { clampSeekMs, createRunClock, isValidPlaybackRate, progressSpanMs, progressToTimeMs, PX_RATE_REJECTED, PxDiagnosticKind, seekCeilingMs, timeToProgress, type PxAnimatorHandle, type PxAnimatorCallbacks, type PxControlProps, type PxPlaybackOverrideProps, type PxDiagnostics, 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';
|
|
7
|
+
import { createDiagnostics, reportDocumentDiagnostics } from '@pixodesk/svg-animator-core/internal';
|
|
7
8
|
import React, { createElement, useEffect, useImperativeHandle, useMemo, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
8
9
|
import { Dimensions, Platform, Pressable, View } from 'react-native';
|
|
9
10
|
import Animated, {
|
|
@@ -26,112 +27,53 @@ import { openClosedTextPathTargets } from './PxRnSafety';
|
|
|
26
27
|
|
|
27
28
|
// -- Public types -----------------------------------------------------------
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Pauses the animation at its current state. */
|
|
38
|
-
pause(): void;
|
|
39
|
-
|
|
40
|
-
/** Stops the animation and resets it to its initial state. */
|
|
41
|
-
cancel(): void;
|
|
42
|
-
|
|
43
|
-
/** Jumps to the end of the animation and holds the final state. */
|
|
44
|
-
finish(): void;
|
|
45
|
-
|
|
46
|
-
/** Changes the speed of the animation. 1 is normal, 2 is double. */
|
|
47
|
-
setPlaybackRate(rate: number): void;
|
|
48
|
-
|
|
49
|
-
/** Returns the current playback time in milliseconds. */
|
|
50
|
-
getCurrentTime(): number | null;
|
|
51
|
-
|
|
52
|
-
/** Jumps to a specific time (in milliseconds) in the animation. */
|
|
53
|
-
setCurrentTime(time: number): void;
|
|
54
|
-
}
|
|
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
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export type RnAnimatorApi = PxAnimatorHandle;
|
|
55
37
|
|
|
56
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The component's props. The playback override, the control props and the callbacks are core's
|
|
40
|
+
* shared shapes (review §9) — `PxPlaybackOverrideProps`, `PxControlProps` and
|
|
41
|
+
* `PxAnimatorCallbacks` — so React, Vue and React Native cannot drift apart. Only what differs
|
|
42
|
+
* on this platform is declared here: `fallback`, which has no web counterpart.
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
export interface PixodeskSvgAnimatorProps
|
|
46
|
+
extends PxPlaybackOverrideProps, PxControlProps, PxAnimatorCallbacks {
|
|
57
47
|
|
|
58
48
|
// -- Source ---------------------------------------------------------------
|
|
59
49
|
|
|
60
50
|
/** The animation document to render. */
|
|
61
51
|
doc: PxAnimatedSvgDocument;
|
|
62
52
|
|
|
63
|
-
// -- Timing overrides -----------------------------------------------------
|
|
64
|
-
|
|
65
|
-
/** Duration of a single iteration in milliseconds. */
|
|
66
|
-
duration?: number;
|
|
67
|
-
|
|
68
|
-
/** Delay before the animation starts, in milliseconds. */
|
|
69
|
-
delay?: number;
|
|
70
|
-
|
|
71
|
-
/** Number of iterations, or 'infinite' for endless looping. */
|
|
72
|
-
iterations?: number | 'infinite';
|
|
73
|
-
|
|
74
|
-
/** Shortcut for `config.timeline.trigger.startOn`. */
|
|
75
|
-
startOn?: StartOn;
|
|
76
|
-
|
|
77
53
|
/**
|
|
78
|
-
* Per-instance override of the document's `
|
|
79
|
-
* in SCHEMA.md, deep-merged over what the document says; `null` at a slot deletes it.
|
|
80
|
-
* Replaces the former flat `fill` / `direction` / `resetOnFinish` / `outAction` props, so
|
|
81
|
-
* every surface takes one vocabulary. Also accepts a JSON string.
|
|
54
|
+
* Per-instance override of the document's `timeline` — see `PxPlaybackOverrideProps`.
|
|
82
55
|
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
56
|
+
* `engine` is accepted but ignored here: React Native always uses the `native` (fully
|
|
57
|
+
* flattened) materialization, because react-native-svg has no `<use>` shadow-tree
|
|
58
|
+
* propagation.
|
|
85
59
|
*/
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
/** Start from the player's defaults instead of the document's playback settings. */
|
|
89
|
-
resetDocDefaults?: boolean;
|
|
90
|
-
|
|
91
|
-
// -- Declarative control --------------------------------------------------
|
|
92
|
-
|
|
93
|
-
/** When true, honours the document trigger (`startOn: 'load'` plays on mount). */
|
|
94
|
-
autoplay?: boolean;
|
|
95
|
-
|
|
96
|
-
/** Starts playback unconditionally. */
|
|
97
|
-
play?: boolean;
|
|
98
|
-
|
|
99
|
-
/** Pauses current playback. */
|
|
100
|
-
pause?: boolean;
|
|
60
|
+
timeline?: PxTimelinePatch | string;
|
|
101
61
|
|
|
102
62
|
// -- Imperative control ---------------------------------------------------
|
|
103
63
|
|
|
104
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Ref populated with the imperative playback API. Filled in EVERY mode and never picks one
|
|
66
|
+
* (review §1): `autoplay` next to it still autoplays.
|
|
67
|
+
*/
|
|
105
68
|
apiRef?: React.RefObject<RnAnimatorApi | null>;
|
|
106
69
|
|
|
107
|
-
// -- Controlled (external) time -------------------------------------------
|
|
108
|
-
|
|
109
|
-
/** Seek to a fraction (0–1) of the whole timeline (duration × iterations). */
|
|
110
|
-
progress?: number;
|
|
111
|
-
|
|
112
|
-
/** Seek to a specific time in milliseconds. */
|
|
113
|
-
time?: number;
|
|
114
|
-
|
|
115
|
-
// -- Callbacks ------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
onPlay?: () => void;
|
|
118
|
-
onStop?: () => void;
|
|
119
|
-
onPause?: () => void;
|
|
120
|
-
onCancel?: () => void;
|
|
121
|
-
onFinish?: () => void;
|
|
122
|
-
|
|
123
|
-
|
|
124
70
|
// -- Failure handling -----------------------------------------------------
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
* Only JavaScript failures reach this — a crash inside react-native-svg's
|
|
132
|
-
* native renderer bypasses JavaScript entirely.
|
|
133
|
-
*/
|
|
134
|
-
onError?: (error: Error, componentStack?: string) => void;
|
|
71
|
+
// A document that cannot be compiled or rendered is reported through `onError` — the same
|
|
72
|
+
// `(diagnostic) => void` as every other player (review §25.1), with `diagnostic.error` the
|
|
73
|
+
// thrown Error and, when the error boundary caught it, `diagnostic.detail.componentStack`.
|
|
74
|
+
// The component renders `fallback` instead of throwing, so one broken animation never
|
|
75
|
+
// takes down the screen around it. Only JavaScript failures reach this — a crash inside
|
|
76
|
+
// react-native-svg's native renderer bypasses JavaScript entirely.
|
|
135
77
|
|
|
136
78
|
/** Rendered in place of the animation after a failure. Default: nothing. */
|
|
137
79
|
fallback?: (error: Error) => ReactElement | null;
|
|
@@ -274,17 +216,11 @@ function SampledSubtree({
|
|
|
274
216
|
|
|
275
217
|
|
|
276
218
|
/** Overrides that shadow the document's own `animator` config. */
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
resetDocDefaults?: boolean;
|
|
280
|
-
duration?: number;
|
|
281
|
-
delay?: number;
|
|
282
|
-
iterations?: number | 'infinite';
|
|
283
|
-
startOn?: StartOn;
|
|
284
|
-
}
|
|
219
|
+
/** The override subset of the props — core's shared shape, not a local copy (review §9). */
|
|
220
|
+
type ConfigOverrides = PxPlaybackOverrideProps;
|
|
285
221
|
|
|
286
222
|
interface Compiled {
|
|
287
|
-
/**
|
|
223
|
+
/** Materialized document, or null when compilation failed. */
|
|
288
224
|
doc: PxAnimatedSvgDocument | null;
|
|
289
225
|
tracks: PxCompiledTracks;
|
|
290
226
|
error: Error | null;
|
|
@@ -298,34 +234,34 @@ const EMPTY_TRACKS: PxCompiledTracks = {
|
|
|
298
234
|
};
|
|
299
235
|
|
|
300
236
|
/**
|
|
301
|
-
*
|
|
237
|
+
* Materializes + compiles a document. Extracted from the component so the
|
|
302
238
|
* whole thing sits behind one try/catch, and so it can be tested directly.
|
|
303
239
|
*/
|
|
304
|
-
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides): Compiled {
|
|
305
|
-
const {
|
|
240
|
+
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides, diag: PxDiagnostics): Compiled {
|
|
241
|
+
const { timeline, resetTimeline, duration, delay, iterations, startOn } = overrides;
|
|
306
242
|
const warnings = validateNodeEffects(doc as PxNode);
|
|
307
|
-
for (const w of warnings)
|
|
243
|
+
for (const w of warnings) diag.warn(PxDiagnosticKind.document, 'effects shape: ' + w);
|
|
308
244
|
// The whole-document boundary diagnostic — see the note in the web player's entry.
|
|
309
245
|
reportDocumentDiagnostics(doc, '[PixodeskSvgAnimator]');
|
|
310
246
|
|
|
311
247
|
// The per-instance override, applied to the WIRE document BEFORE anything reads the
|
|
312
|
-
// config — `
|
|
248
|
+
// config — `materializeAllInTree` samples motion paths against `duration`, so a later
|
|
313
249
|
// patch would be read by none of the pipeline. Same call, same rules, on every surface.
|
|
314
|
-
const patch =
|
|
315
|
-
if (patch !== undefined ||
|
|
316
|
-
const applied = applyAnimatorConfig(doc, patch ?? {}, { resetDefaults: !!
|
|
317
|
-
for (const w of applied.warnings)
|
|
250
|
+
const patch = foldTimelineOverride(timeline, { duration, delay, iterations, startOn });
|
|
251
|
+
if (patch !== undefined || resetTimeline) {
|
|
252
|
+
const applied = applyAnimatorConfig(doc, patch ?? {}, { resetDefaults: !!resetTimeline });
|
|
253
|
+
for (const w of applied.warnings) diag.warn(PxDiagnosticKind.usage, 'timeline override: ' + w);
|
|
318
254
|
doc = applied.doc;
|
|
319
255
|
}
|
|
320
256
|
|
|
321
|
-
// `
|
|
257
|
+
// `native` = the FULLY-FLATTENED materialization: effects + loops +
|
|
322
258
|
// sampled motion paths + animated `<use>` inlined into real `<g>`
|
|
323
259
|
// clones + orphaned defs pruned. That last part is why RN must not use
|
|
324
|
-
// the `
|
|
260
|
+
// the `js` flavor: the frame loop keeps `<use href="#animatedTarget">`
|
|
325
261
|
// live references, which only work because the DOM propagates
|
|
326
262
|
// attribute writes through `<use>` shadow trees. react-native-svg has
|
|
327
263
|
// no such live propagation, so an animated `<use>` would render frozen.
|
|
328
|
-
let prepared =
|
|
264
|
+
let prepared = materializeAllInTree(doc, PxTimelineEngine.native);
|
|
329
265
|
|
|
330
266
|
// Sidestep a react-native-svg NATIVE crash (see PxRnSafety). Guarded on
|
|
331
267
|
// the platform because the DOM renders this case correctly and the web
|
|
@@ -345,46 +281,67 @@ function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides)
|
|
|
345
281
|
/**
|
|
346
282
|
* React Native component for rendering and controlling Pixodesk SVG animations.
|
|
347
283
|
*
|
|
348
|
-
* The document is
|
|
284
|
+
* The document is materialized once through the shared core pipeline (effects,
|
|
349
285
|
* loops, motion-path sampling, animated-`<use>` inlining — identical to the
|
|
350
|
-
* web
|
|
286
|
+
* web's `native` engine, NOT the frame loop, which keeps `<use>` live), compiled
|
|
287
|
+
* into densely sampled per-element tracks, and
|
|
351
288
|
* played back natively: a single reanimated progress value driven by
|
|
352
289
|
* `withTiming`/`withRepeat` on the UI thread, with per-element worklets
|
|
353
290
|
* indexing the precompiled tracks. No JS-thread frame loop.
|
|
291
|
+
* @public
|
|
354
292
|
*/
|
|
355
293
|
export function PixodeskSvgAnimator({
|
|
356
|
-
doc,
|
|
294
|
+
doc, timeline, resetTimeline, duration, delay, iterations, startOn,
|
|
357
295
|
// (`progress` prop aliased — the name is taken by the internal reanimated SharedValue)
|
|
358
296
|
autoplay, play, pause, apiRef, progress: progressProp, time,
|
|
359
|
-
onPlay, onStop, onPause, onCancel, onFinish, onError, fallback,
|
|
297
|
+
onPlay, onStop, onPause, onCancel, onFinish, onRemove, onError, fallback, onWarn, muteWarn, muteError,
|
|
360
298
|
}: PixodeskSvgAnimatorProps): ReactElement | null {
|
|
361
299
|
|
|
362
300
|
// -- Compile the document (once per doc/override change) ------------------
|
|
363
301
|
|
|
364
|
-
// `
|
|
365
|
-
// whole document (
|
|
302
|
+
// `timeline` is an object prop: a fresh literal every render would otherwise recompile the
|
|
303
|
+
// whole document (materialize + compile tracks), which is the expensive path. Key on its
|
|
366
304
|
// CONTENT instead — the override is small, the document is not.
|
|
367
|
-
const
|
|
305
|
+
const timelineKey = typeof timeline === 'string' ? timeline : JSON.stringify(timeline ?? null);
|
|
368
306
|
|
|
369
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The diagnostics channel, built from the CURRENT props each time (API review §5).
|
|
310
|
+
* Deliberately not hoisted into a wrapper closure: `(m, d) => onWarn?.(m, d)` would always
|
|
311
|
+
* be a function, so the channel would believe a handler exists and the console fallback
|
|
312
|
+
* would never fire for anyone who passed nothing.
|
|
313
|
+
*/
|
|
314
|
+
const makeDiag = (): PxDiagnostics => createDiagnostics(
|
|
315
|
+
{ onWarn, onError, muteWarn, muteError }, '[PixodeskSvgAnimator]');
|
|
316
|
+
|
|
370
317
|
const compiled = useMemo((): Compiled => {
|
|
371
318
|
try {
|
|
372
319
|
return compileDocument(
|
|
373
320
|
doc,
|
|
374
|
-
{
|
|
321
|
+
{ timeline, resetTimeline, duration, delay, iterations, startOn },
|
|
322
|
+
makeDiag(),
|
|
375
323
|
);
|
|
376
324
|
} catch (e) {
|
|
377
|
-
// A malformed document must not take the host screen down with it.
|
|
325
|
+
// A malformed document must not take the host screen down with it. This instance
|
|
326
|
+
// will not play — an ERROR, reported once, here (review §25.1).
|
|
378
327
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
379
|
-
|
|
328
|
+
makeDiag().error(PxDiagnosticKind.internal, error, { phase: 'compile' });
|
|
380
329
|
return { doc: null, tracks: EMPTY_TRACKS, error };
|
|
381
330
|
}
|
|
382
|
-
// `
|
|
331
|
+
// `timeline` is an object prop, so a fresh literal each render would recompile the whole
|
|
383
332
|
// document. Key on its CONTENT — the override is small, unlike the document.
|
|
384
|
-
}, [doc,
|
|
333
|
+
}, [doc, timelineKey, resetTimeline, duration, delay, iterations, startOn]);
|
|
385
334
|
|
|
386
335
|
const tracks: PxCompiledTracks = compiled.tracks;
|
|
387
|
-
|
|
336
|
+
// The span `progress` 0–1 covers (ONE iteration when endless) — NOT the seek ceiling.
|
|
337
|
+
const totalDuration = progressSpanMs(tracks.duration, tracks.iterations);
|
|
338
|
+
// How far a seek may go: unbounded when endless (review §3).
|
|
339
|
+
const seekCeiling = seekCeilingMs(tracks.duration, tracks.iterations);
|
|
340
|
+
|
|
341
|
+
// Whole-run time lives on its own clock: `progress` below is deliberately within ONE
|
|
342
|
+
// iteration, and `withRepeat` never reports how many have elapsed, so the run time cannot
|
|
343
|
+
// be read back off it. See `createRunClock`.
|
|
344
|
+
const runClock = useMemo(() => createRunClock(seekCeiling), [seekCeiling]);
|
|
388
345
|
|
|
389
346
|
// -- Playback state -------------------------------------------------------
|
|
390
347
|
|
|
@@ -407,6 +364,8 @@ export function PixodeskSvgAnimator({
|
|
|
407
364
|
|
|
408
365
|
const notifyFinish = () => {
|
|
409
366
|
playingRef.current = false;
|
|
367
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
368
|
+
runClock.stop();
|
|
410
369
|
progress.value = restingPosition();
|
|
411
370
|
onFinish?.();
|
|
412
371
|
onStop?.();
|
|
@@ -457,13 +416,17 @@ export function PixodeskSvgAnimator({
|
|
|
457
416
|
play: () => {
|
|
458
417
|
// `startFrom` rewinds to the opposite end when the playhead is
|
|
459
418
|
// already resting at a boundary (mirrors WAAPI, where play() on a
|
|
460
|
-
// finished animation auto-rewinds)
|
|
419
|
+
// finished animation auto-rewinds) — the run clock rewinds with it,
|
|
420
|
+
// or the time would keep counting on from the old end.
|
|
421
|
+
if (Number.isFinite(seekCeiling) && runClock.now() >= seekCeiling) runClock.seek(0);
|
|
422
|
+
runClock.start(rateRef.current);
|
|
461
423
|
startFrom(progress.value);
|
|
462
424
|
onPlay?.();
|
|
463
425
|
},
|
|
464
426
|
pause: () => {
|
|
465
427
|
cancelAnimation(progress);
|
|
466
428
|
playingRef.current = false;
|
|
429
|
+
runClock.stop();
|
|
467
430
|
onPause?.();
|
|
468
431
|
onStop?.();
|
|
469
432
|
},
|
|
@@ -471,30 +434,45 @@ export function PixodeskSvgAnimator({
|
|
|
471
434
|
cancelAnimation(progress);
|
|
472
435
|
progress.value = 0;
|
|
473
436
|
playingRef.current = false;
|
|
437
|
+
runClock.seek(0);
|
|
438
|
+
runClock.stop();
|
|
474
439
|
onCancel?.();
|
|
475
440
|
onStop?.();
|
|
476
441
|
},
|
|
477
442
|
finish: () => {
|
|
478
443
|
cancelAnimation(progress);
|
|
479
444
|
playingRef.current = false;
|
|
445
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
446
|
+
runClock.stop();
|
|
480
447
|
progress.value = restingPosition();
|
|
481
448
|
onFinish?.();
|
|
482
449
|
onStop?.();
|
|
483
450
|
},
|
|
484
451
|
setPlaybackRate: (rate: number) => {
|
|
485
|
-
if (!
|
|
486
|
-
|
|
452
|
+
if (!isValidPlaybackRate(rate)) {
|
|
453
|
+
makeDiag().warn(PxDiagnosticKind.usage, PX_RATE_REJECTED);
|
|
487
454
|
return;
|
|
488
455
|
}
|
|
489
456
|
rateRef.current = rate;
|
|
490
|
-
if (playingRef.current)
|
|
457
|
+
if (playingRef.current) {
|
|
458
|
+
runClock.start(rate); // resume from the current time at the new rate
|
|
459
|
+
startFrom(progress.value);
|
|
460
|
+
}
|
|
491
461
|
},
|
|
492
|
-
|
|
462
|
+
|
|
463
|
+
// Ms from the start of the WHOLE run, like every other engine (review §3). This used
|
|
464
|
+
// to return `progress.value`, which is ms within the current iteration — so a slider
|
|
465
|
+
// built on it jumped back to 0 every time the animation repeated.
|
|
466
|
+
getCurrentTime: () => runClock.now(),
|
|
467
|
+
|
|
493
468
|
setCurrentTime: (t: number) => {
|
|
494
469
|
const wasPlaying = playingRef.current;
|
|
495
470
|
cancelAnimation(progress);
|
|
496
471
|
playingRef.current = false;
|
|
497
|
-
|
|
472
|
+
// Clamp against the SEEK ceiling, not the progress span — the old clamp capped an
|
|
473
|
+
// endless timeline at one iteration.
|
|
474
|
+
const clamped = clampSeekMs(t, seekCeiling);
|
|
475
|
+
runClock.seek(clamped);
|
|
498
476
|
const withinIteration = tracks.duration > 0
|
|
499
477
|
? (clamped % tracks.duration) || (clamped === 0 ? 0 : tracks.duration)
|
|
500
478
|
: 0;
|
|
@@ -502,6 +480,13 @@ export function PixodeskSvgAnimator({
|
|
|
502
480
|
// Seeking mid-playback continues from the new position rather than
|
|
503
481
|
// silently pausing.
|
|
504
482
|
if (wasPlaying) startFrom(withinIteration);
|
|
483
|
+
else runClock.stop();
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
getCurrentProgress: () => timeToProgress(runClock.now(), tracks.duration, tracks.iterations),
|
|
487
|
+
|
|
488
|
+
setCurrentProgress: (p: number) => {
|
|
489
|
+
api.setCurrentTime(progressToTimeMs(p, tracks.duration, tracks.iterations));
|
|
505
490
|
},
|
|
506
491
|
};
|
|
507
492
|
|
|
@@ -510,30 +495,46 @@ export function PixodeskSvgAnimator({
|
|
|
510
495
|
// -- Declarative control --------------------------------------------------
|
|
511
496
|
|
|
512
497
|
// The EFFECTIVE trigger, read back off the COMPILED document — so it already reflects the
|
|
513
|
-
// `
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
498
|
+
// `timeline` override and the `startOn` shortcut, both merged in before compilation.
|
|
499
|
+
// Resolved through core's one table, so a document means the same here as on the web:
|
|
500
|
+
// no `startOn` = 'load', no `outAction` = 'continue'.
|
|
501
|
+
const trigger = resolveTrigger(compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined);
|
|
502
|
+
const effectiveStartOn = trigger.startOn;
|
|
503
|
+
const effectiveOutAction = trigger.outAction;
|
|
504
|
+
|
|
505
|
+
// ONE control-mode rule, decided in core and shared with React and Vue (API review §1/§7).
|
|
506
|
+
// This component always had the right ORDER but no name for it, and never told anyone when
|
|
507
|
+
// two tiers of props were passed together.
|
|
508
|
+
const { mode: compMode, warnings: modeWarnings } =
|
|
509
|
+
resolveControlMode({ progress: progressProp, time, play, pause, autoplay });
|
|
517
510
|
|
|
518
511
|
useEffect(() => {
|
|
519
|
-
|
|
520
|
-
|
|
512
|
+
const diag = makeDiag();
|
|
513
|
+
for (const w of modeWarnings) diag.warn(PxDiagnosticKind.usage, w);
|
|
514
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
515
|
+
}, [modeWarnings.join('|')]);
|
|
516
|
+
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
if (compMode === PxControlMode.fixedTime) {
|
|
519
|
+
const seekMs = time !== undefined
|
|
520
|
+
? time
|
|
521
|
+
: progressToTimeMs(progressProp ?? 0, tracks.duration, tracks.iterations);
|
|
521
522
|
api.setCurrentTime(seekMs);
|
|
522
523
|
return;
|
|
523
524
|
}
|
|
524
|
-
if (
|
|
525
|
+
if (compMode === PxControlMode.play) {
|
|
525
526
|
if (play && !pause) api.play();
|
|
526
527
|
else if (pause) api.pause();
|
|
527
|
-
else if (play === false) api.finish()
|
|
528
|
+
else if (play === false) api.pause(); // hold, not finish() — review §8, same as web
|
|
528
529
|
else api.play();
|
|
529
530
|
return;
|
|
530
531
|
}
|
|
531
532
|
// 'click' and 'scrollIntoView' start from their own handlers below.
|
|
532
|
-
if (autoplay && effectiveStartOn === 'load') {
|
|
533
|
+
if (compMode === PxControlMode.autoplay && effectiveStartOn === 'load') {
|
|
533
534
|
api.play();
|
|
534
535
|
}
|
|
535
536
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
536
|
-
}, [compiled, autoplay, play, pause, progressProp, time]);
|
|
537
|
+
}, [compiled, compMode, autoplay, play, pause, progressProp, time]);
|
|
537
538
|
|
|
538
539
|
// `startOn: 'scrollIntoView'` — react-native has no IntersectionObserver, so
|
|
539
540
|
// visibility is sampled by measuring the view against the window box. The
|
|
@@ -543,7 +544,7 @@ export function PixodeskSvgAnimator({
|
|
|
543
544
|
const inViewRef = useRef(false);
|
|
544
545
|
useEffect(() => {
|
|
545
546
|
if (!autoplay || effectiveStartOn !== 'scrollIntoView') return;
|
|
546
|
-
const threshold = trigger
|
|
547
|
+
const threshold = trigger.scrollIntoViewThreshold;
|
|
547
548
|
inViewRef.current = false;
|
|
548
549
|
|
|
549
550
|
const check = () => {
|
|
@@ -572,11 +573,14 @@ export function PixodeskSvgAnimator({
|
|
|
572
573
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
573
574
|
}, [compiled, autoplay, effectiveStartOn, effectiveOutAction]);
|
|
574
575
|
|
|
575
|
-
// Stop cleanly on unmount / doc swap
|
|
576
|
+
// Stop cleanly on unmount / doc swap — and say so, the way the web's destroy() does (§18):
|
|
577
|
+
// both are "the animator was thrown away", so `onRemove` fires, and `onStop` with it.
|
|
576
578
|
useEffect(() => {
|
|
577
579
|
return () => {
|
|
578
580
|
cancelAnimation(progress);
|
|
579
581
|
playingRef.current = false;
|
|
582
|
+
onRemove?.();
|
|
583
|
+
onStop?.();
|
|
580
584
|
};
|
|
581
585
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
582
586
|
}, [compiled]);
|
|
@@ -640,27 +644,27 @@ export function PixodeskSvgAnimator({
|
|
|
640
644
|
},
|
|
641
645
|
});
|
|
642
646
|
} catch (e) {
|
|
643
|
-
// Building the element tree threw — report it
|
|
644
|
-
//
|
|
647
|
+
// Building the element tree threw — this instance will not play: report it once
|
|
648
|
+
// (an ERROR, review §25.1) and render nothing rather than unmount the host screen.
|
|
645
649
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
646
650
|
renderErrorRef.current = error;
|
|
647
|
-
|
|
651
|
+
makeDiag().error(PxDiagnosticKind.internal, error, { phase: 'render' });
|
|
648
652
|
return null;
|
|
649
653
|
}
|
|
650
654
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
651
655
|
}, [compiled, trackById]);
|
|
652
656
|
|
|
653
657
|
useEffect(() => {
|
|
654
|
-
|
|
658
|
+
const diag = makeDiag();
|
|
659
|
+
// `platform`: these come from the react-native-svg prop mapper — shapes this renderer
|
|
660
|
+
// cannot express, rather than anything wrong with the file.
|
|
661
|
+
for (const w of warningsRef.current) diag.warn(PxDiagnosticKind.platform, w);
|
|
662
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
655
663
|
}, [root]);
|
|
656
664
|
|
|
657
|
-
//
|
|
665
|
+
// A compile/render failure was reported where it was caught (once, as an error); here it
|
|
666
|
+
// only decides what to show.
|
|
658
667
|
const failure = compiled.error ?? renderErrorRef.current;
|
|
659
|
-
useEffect(() => {
|
|
660
|
-
if (failure) onError?.(failure);
|
|
661
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
662
|
-
}, [failure]);
|
|
663
|
-
|
|
664
668
|
if (failure) return fallback ? fallback(failure) : null;
|
|
665
669
|
|
|
666
670
|
// `startOn: 'click'` — the touch analogue of the web player's click trigger:
|
|
@@ -695,7 +699,7 @@ export function PixodeskSvgAnimator({
|
|
|
695
699
|
// and commit of the tree — react-native-svg internals, reanimated failing
|
|
696
700
|
// to attach to a component that turns out not to be a host view, and so on.
|
|
697
701
|
return (
|
|
698
|
-
<PxRnErrorBoundary
|
|
702
|
+
<PxRnErrorBoundary fallback={fallback} diag={makeDiag()}>
|
|
699
703
|
{content}
|
|
700
704
|
</PxRnErrorBoundary>
|
|
701
705
|
);
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
*---------------------------------------------------------------------------------------*/
|
|
5
5
|
|
|
6
6
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
7
|
+
import { PxDiagnosticKind, type PxDiagnostics } from '@pixodesk/svg-animator-core';
|
|
8
|
+
import { createDiagnostics } from '@pixodesk/svg-animator-core/internal';
|
|
7
9
|
|
|
10
|
+
/** @public @advanced */
|
|
8
11
|
export interface PxRnErrorBoundaryProps {
|
|
9
12
|
children: ReactNode;
|
|
10
13
|
/** Rendered instead of the children once something has thrown. */
|
|
11
14
|
fallback?: (error: Error) => ReactNode;
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Where the failure is reported (API review §5, §25.1): `error(internal, …)` with
|
|
17
|
+
* `{ componentStack }` as the detail — so it reaches `onError` like every other failure.
|
|
18
|
+
* Defaults to the console.
|
|
19
|
+
*/
|
|
20
|
+
diag?: PxDiagnostics;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
interface State {
|
|
@@ -22,12 +30,13 @@ interface State {
|
|
|
22
30
|
* A throw anywhere in the rendered SVG tree — an unsupported prop shape, a
|
|
23
31
|
* react-native-svg internal, a reanimated attachment failure — otherwise
|
|
24
32
|
* unmounts the whole React tree above it. Here it is contained to this one
|
|
25
|
-
* animation, reported through
|
|
33
|
+
* animation, reported through the diagnostics channel, and replaced by `fallback`.
|
|
26
34
|
*
|
|
27
35
|
* NOTE the limit: this catches JavaScript errors only. A crash INSIDE the
|
|
28
36
|
* native renderer (see `openClosedTextPathTargets` for a real example) never
|
|
29
37
|
* reaches JavaScript and cannot be caught here — those have to be avoided
|
|
30
38
|
* rather than handled.
|
|
39
|
+
* @public @advanced
|
|
31
40
|
*/
|
|
32
41
|
export class PxRnErrorBoundary extends Component<PxRnErrorBoundaryProps, State> {
|
|
33
42
|
override state: State = { error: null };
|
|
@@ -37,8 +46,8 @@ export class PxRnErrorBoundary extends Component<PxRnErrorBoundaryProps, State>
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
|
-
this.props.
|
|
41
|
-
|
|
49
|
+
(this.props.diag ?? createDiagnostics(undefined, '[PixodeskSvgAnimator]'))
|
|
50
|
+
.error(PxDiagnosticKind.internal, error, { componentStack: info?.componentStack ?? undefined });
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Licensed under the MIT License. See the LICENSE file in the project root for details.
|
|
4
4
|
*---------------------------------------------------------------------------------------*/
|
|
5
5
|
|
|
6
|
-
import { kebabToCamelCaseWord } from '@pixodesk/svg-animator-core';
|
|
6
|
+
import { kebabToCamelCaseWord } from '@pixodesk/svg-animator-core/internal';
|
|
7
7
|
import { svgTransformToMatrix } from './PxRnMatrix';
|
|
8
8
|
|
|
9
9
|
/** Attribute names with a react-native-svg prop equivalent under a
|
|
@@ -16,12 +16,13 @@ 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
|
*
|
|
23
23
|
* Pure (no react-native-svg import) so the track compiler and its tests
|
|
24
24
|
* don't need a React Native environment.
|
|
25
|
+
* @internal
|
|
25
26
|
*/
|
|
26
27
|
export function toRnPropName(attrName: string): string | undefined {
|
|
27
28
|
if (DROPPED_ATTRS.has(attrName)) return undefined;
|
|
@@ -57,7 +58,7 @@ export function toRnPropValue(
|
|
|
57
58
|
// On a device `transform` must arrive as a matrix, not a string: the
|
|
58
59
|
// string→matrix parse happens in JS during render, which reanimated's
|
|
59
60
|
// 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
|
|
61
|
+
// On the web the DOM parses the string itself and an array would serialize
|
|
61
62
|
// to a meaningless `transform="1,0,0,1,3,4"`, so it must stay a string.
|
|
62
63
|
if (native && rnPropName === 'transform' && typeof value === 'string' && tag !== 'svg') {
|
|
63
64
|
const m = svgTransformToMatrix(value);
|