@pixodesk/svg-animator-rn 1.0.35 → 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 +31 -21
- package/dist/index.cjs +104 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -64
- package/dist/index.d.ts +39 -64
- package/dist/index.js +121 -53
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/PixodeskSvgAnimator.tsx +153 -136
- 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 +12 -12
- package/src/PxRnTracks.ts +4 -4
|
@@ -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, 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';
|
|
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,101 +27,45 @@ import { openClosedTextPathTargets } from './PxRnSafety';
|
|
|
26
27
|
|
|
27
28
|
// -- Public types -----------------------------------------------------------
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
play(): void;
|
|
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
|
+
*/
|
|
35
|
+
export type RnAnimatorApi = PxAnimatorHandle;
|
|
55
36
|
|
|
56
|
-
|
|
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'> {
|
|
57
46
|
|
|
58
47
|
// -- Source ---------------------------------------------------------------
|
|
59
48
|
|
|
60
49
|
/** The animation document to render. */
|
|
61
50
|
doc: PxAnimatedSvgDocument;
|
|
62
51
|
|
|
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
52
|
/**
|
|
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.
|
|
53
|
+
* Per-instance override of the document's `timeline` — see `PxPlaybackOverrideProps`.
|
|
82
54
|
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
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.
|
|
85
58
|
*/
|
|
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;
|
|
59
|
+
timeline?: PxTimelinePatch | string;
|
|
101
60
|
|
|
102
61
|
// -- Imperative control ---------------------------------------------------
|
|
103
62
|
|
|
104
|
-
/**
|
|
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
|
+
*/
|
|
105
67
|
apiRef?: React.RefObject<RnAnimatorApi | null>;
|
|
106
68
|
|
|
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
69
|
// -- Failure handling -----------------------------------------------------
|
|
125
70
|
|
|
126
71
|
/**
|
|
@@ -129,7 +74,9 @@ export interface PixodeskSvgAnimatorProps {
|
|
|
129
74
|
* animation never takes down the screen around it.
|
|
130
75
|
*
|
|
131
76
|
* Only JavaScript failures reach this — a crash inside react-native-svg's
|
|
132
|
-
* 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.
|
|
133
80
|
*/
|
|
134
81
|
onError?: (error: Error, componentStack?: string) => void;
|
|
135
82
|
|
|
@@ -274,17 +221,11 @@ function SampledSubtree({
|
|
|
274
221
|
|
|
275
222
|
|
|
276
223
|
/** 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
|
-
}
|
|
224
|
+
/** The override subset of the props — core's shared shape, not a local copy (review §9). */
|
|
225
|
+
type ConfigOverrides = PxPlaybackOverrideProps;
|
|
285
226
|
|
|
286
227
|
interface Compiled {
|
|
287
|
-
/**
|
|
228
|
+
/** Materialized document, or null when compilation failed. */
|
|
288
229
|
doc: PxAnimatedSvgDocument | null;
|
|
289
230
|
tracks: PxCompiledTracks;
|
|
290
231
|
error: Error | null;
|
|
@@ -298,34 +239,34 @@ const EMPTY_TRACKS: PxCompiledTracks = {
|
|
|
298
239
|
};
|
|
299
240
|
|
|
300
241
|
/**
|
|
301
|
-
*
|
|
242
|
+
* Materializes + compiles a document. Extracted from the component so the
|
|
302
243
|
* whole thing sits behind one try/catch, and so it can be tested directly.
|
|
303
244
|
*/
|
|
304
|
-
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides): Compiled {
|
|
305
|
-
const {
|
|
245
|
+
function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides, diag: PxDiagnostics): Compiled {
|
|
246
|
+
const { timeline, resetTimeline, duration, delay, iterations, startOn } = overrides;
|
|
306
247
|
const warnings = validateNodeEffects(doc as PxNode);
|
|
307
|
-
for (const w of warnings)
|
|
248
|
+
for (const w of warnings) diag.warn(PxDiagnosticKind.document, 'effects shape: ' + w);
|
|
308
249
|
// The whole-document boundary diagnostic — see the note in the web player's entry.
|
|
309
250
|
reportDocumentDiagnostics(doc, '[PixodeskSvgAnimator]');
|
|
310
251
|
|
|
311
252
|
// The per-instance override, applied to the WIRE document BEFORE anything reads the
|
|
312
|
-
// config — `
|
|
253
|
+
// config — `materializeAllInTree` samples motion paths against `duration`, so a later
|
|
313
254
|
// 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)
|
|
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);
|
|
318
259
|
doc = applied.doc;
|
|
319
260
|
}
|
|
320
261
|
|
|
321
|
-
// `
|
|
262
|
+
// `native` = the FULLY-FLATTENED materialization: effects + loops +
|
|
322
263
|
// sampled motion paths + animated `<use>` inlined into real `<g>`
|
|
323
264
|
// clones + orphaned defs pruned. That last part is why RN must not use
|
|
324
|
-
// the `
|
|
265
|
+
// the `js` flavor: the frame loop keeps `<use href="#animatedTarget">`
|
|
325
266
|
// live references, which only work because the DOM propagates
|
|
326
267
|
// attribute writes through `<use>` shadow trees. react-native-svg has
|
|
327
268
|
// no such live propagation, so an animated `<use>` would render frozen.
|
|
328
|
-
let prepared =
|
|
269
|
+
let prepared = materializeAllInTree(doc, PxTimelineEngine.native);
|
|
329
270
|
|
|
330
271
|
// Sidestep a react-native-svg NATIVE crash (see PxRnSafety). Guarded on
|
|
331
272
|
// the platform because the DOM renders this case correctly and the web
|
|
@@ -345,46 +286,71 @@ function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides)
|
|
|
345
286
|
/**
|
|
346
287
|
* React Native component for rendering and controlling Pixodesk SVG animations.
|
|
347
288
|
*
|
|
348
|
-
* The document is
|
|
289
|
+
* The document is materialized once through the shared core pipeline (effects,
|
|
349
290
|
* loops, motion-path sampling, animated-`<use>` inlining — identical to the
|
|
350
|
-
* web
|
|
291
|
+
* web's `native` engine, NOT the frame loop, which keeps `<use>` live), compiled
|
|
292
|
+
* into densely sampled per-element tracks, and
|
|
351
293
|
* played back natively: a single reanimated progress value driven by
|
|
352
294
|
* `withTiming`/`withRepeat` on the UI thread, with per-element worklets
|
|
353
295
|
* indexing the precompiled tracks. No JS-thread frame loop.
|
|
354
296
|
*/
|
|
355
297
|
export function PixodeskSvgAnimator({
|
|
356
|
-
doc,
|
|
298
|
+
doc, timeline, resetTimeline, duration, delay, iterations, startOn,
|
|
357
299
|
// (`progress` prop aliased — the name is taken by the internal reanimated SharedValue)
|
|
358
300
|
autoplay, play, pause, apiRef, progress: progressProp, time,
|
|
359
|
-
onPlay, onStop, onPause, onCancel, onFinish, onError, fallback,
|
|
301
|
+
onPlay, onStop, onPause, onCancel, onFinish, onRemove, onError, fallback, onWarn, silent,
|
|
360
302
|
}: PixodeskSvgAnimatorProps): ReactElement | null {
|
|
361
303
|
|
|
362
304
|
// -- Compile the document (once per doc/override change) ------------------
|
|
363
305
|
|
|
364
|
-
// `
|
|
365
|
-
// whole document (
|
|
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
|
|
366
308
|
// CONTENT instead — the override is small, the document is not.
|
|
367
|
-
const
|
|
309
|
+
const timelineKey = typeof timeline === 'string' ? timeline : JSON.stringify(timeline ?? null);
|
|
310
|
+
|
|
368
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]');
|
|
369
326
|
|
|
370
327
|
const compiled = useMemo((): Compiled => {
|
|
371
328
|
try {
|
|
372
329
|
return compileDocument(
|
|
373
330
|
doc,
|
|
374
|
-
{
|
|
331
|
+
{ timeline, resetTimeline, duration, delay, iterations, startOn },
|
|
332
|
+
makeDiag(),
|
|
375
333
|
);
|
|
376
334
|
} catch (e) {
|
|
377
335
|
// A malformed document must not take the host screen down with it.
|
|
378
336
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
379
|
-
|
|
337
|
+
makeDiag().warn(PxDiagnosticKind.internal, 'could not compile the document: ' + error.message);
|
|
380
338
|
return { doc: null, tracks: EMPTY_TRACKS, error };
|
|
381
339
|
}
|
|
382
|
-
// `
|
|
340
|
+
// `timeline` is an object prop, so a fresh literal each render would recompile the whole
|
|
383
341
|
// document. Key on its CONTENT — the override is small, unlike the document.
|
|
384
|
-
}, [doc,
|
|
342
|
+
}, [doc, timelineKey, resetTimeline, duration, delay, iterations, startOn]);
|
|
385
343
|
|
|
386
344
|
const tracks: PxCompiledTracks = compiled.tracks;
|
|
387
|
-
|
|
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]);
|
|
388
354
|
|
|
389
355
|
// -- Playback state -------------------------------------------------------
|
|
390
356
|
|
|
@@ -407,6 +373,8 @@ export function PixodeskSvgAnimator({
|
|
|
407
373
|
|
|
408
374
|
const notifyFinish = () => {
|
|
409
375
|
playingRef.current = false;
|
|
376
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
377
|
+
runClock.stop();
|
|
410
378
|
progress.value = restingPosition();
|
|
411
379
|
onFinish?.();
|
|
412
380
|
onStop?.();
|
|
@@ -457,13 +425,17 @@ export function PixodeskSvgAnimator({
|
|
|
457
425
|
play: () => {
|
|
458
426
|
// `startFrom` rewinds to the opposite end when the playhead is
|
|
459
427
|
// already resting at a boundary (mirrors WAAPI, where play() on a
|
|
460
|
-
// 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);
|
|
461
432
|
startFrom(progress.value);
|
|
462
433
|
onPlay?.();
|
|
463
434
|
},
|
|
464
435
|
pause: () => {
|
|
465
436
|
cancelAnimation(progress);
|
|
466
437
|
playingRef.current = false;
|
|
438
|
+
runClock.stop();
|
|
467
439
|
onPause?.();
|
|
468
440
|
onStop?.();
|
|
469
441
|
},
|
|
@@ -471,30 +443,45 @@ export function PixodeskSvgAnimator({
|
|
|
471
443
|
cancelAnimation(progress);
|
|
472
444
|
progress.value = 0;
|
|
473
445
|
playingRef.current = false;
|
|
446
|
+
runClock.seek(0);
|
|
447
|
+
runClock.stop();
|
|
474
448
|
onCancel?.();
|
|
475
449
|
onStop?.();
|
|
476
450
|
},
|
|
477
451
|
finish: () => {
|
|
478
452
|
cancelAnimation(progress);
|
|
479
453
|
playingRef.current = false;
|
|
454
|
+
runClock.seek(Number.isFinite(seekCeiling) ? seekCeiling : tracks.duration);
|
|
455
|
+
runClock.stop();
|
|
480
456
|
progress.value = restingPosition();
|
|
481
457
|
onFinish?.();
|
|
482
458
|
onStop?.();
|
|
483
459
|
},
|
|
484
460
|
setPlaybackRate: (rate: number) => {
|
|
485
|
-
if (!
|
|
486
|
-
|
|
461
|
+
if (!isValidPlaybackRate(rate)) {
|
|
462
|
+
makeDiag().warn(PxDiagnosticKind.usage, PX_RATE_REJECTED);
|
|
487
463
|
return;
|
|
488
464
|
}
|
|
489
465
|
rateRef.current = rate;
|
|
490
|
-
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
|
+
}
|
|
491
470
|
},
|
|
492
|
-
|
|
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
|
+
|
|
493
477
|
setCurrentTime: (t: number) => {
|
|
494
478
|
const wasPlaying = playingRef.current;
|
|
495
479
|
cancelAnimation(progress);
|
|
496
480
|
playingRef.current = false;
|
|
497
|
-
|
|
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);
|
|
498
485
|
const withinIteration = tracks.duration > 0
|
|
499
486
|
? (clamped % tracks.duration) || (clamped === 0 ? 0 : tracks.duration)
|
|
500
487
|
: 0;
|
|
@@ -502,6 +489,13 @@ export function PixodeskSvgAnimator({
|
|
|
502
489
|
// Seeking mid-playback continues from the new position rather than
|
|
503
490
|
// silently pausing.
|
|
504
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));
|
|
505
499
|
},
|
|
506
500
|
};
|
|
507
501
|
|
|
@@ -510,30 +504,46 @@ export function PixodeskSvgAnimator({
|
|
|
510
504
|
// -- Declarative control --------------------------------------------------
|
|
511
505
|
|
|
512
506
|
// The EFFECTIVE trigger, read back off the COMPILED document — so it already reflects the
|
|
513
|
-
// `
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
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 });
|
|
517
519
|
|
|
518
520
|
useEffect(() => {
|
|
519
|
-
|
|
520
|
-
|
|
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);
|
|
521
531
|
api.setCurrentTime(seekMs);
|
|
522
532
|
return;
|
|
523
533
|
}
|
|
524
|
-
if (
|
|
534
|
+
if (compMode === PxControlMode.play) {
|
|
525
535
|
if (play && !pause) api.play();
|
|
526
536
|
else if (pause) api.pause();
|
|
527
|
-
else if (play === false) api.finish()
|
|
537
|
+
else if (play === false) api.pause(); // hold, not finish() — review §8, same as web
|
|
528
538
|
else api.play();
|
|
529
539
|
return;
|
|
530
540
|
}
|
|
531
541
|
// 'click' and 'scrollIntoView' start from their own handlers below.
|
|
532
|
-
if (autoplay && effectiveStartOn === 'load') {
|
|
542
|
+
if (compMode === PxControlMode.autoplay && effectiveStartOn === 'load') {
|
|
533
543
|
api.play();
|
|
534
544
|
}
|
|
535
545
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
536
|
-
}, [compiled, autoplay, play, pause, progressProp, time]);
|
|
546
|
+
}, [compiled, compMode, autoplay, play, pause, progressProp, time]);
|
|
537
547
|
|
|
538
548
|
// `startOn: 'scrollIntoView'` — react-native has no IntersectionObserver, so
|
|
539
549
|
// visibility is sampled by measuring the view against the window box. The
|
|
@@ -543,7 +553,7 @@ export function PixodeskSvgAnimator({
|
|
|
543
553
|
const inViewRef = useRef(false);
|
|
544
554
|
useEffect(() => {
|
|
545
555
|
if (!autoplay || effectiveStartOn !== 'scrollIntoView') return;
|
|
546
|
-
const threshold = trigger
|
|
556
|
+
const threshold = trigger.scrollIntoViewThreshold;
|
|
547
557
|
inViewRef.current = false;
|
|
548
558
|
|
|
549
559
|
const check = () => {
|
|
@@ -572,11 +582,14 @@ export function PixodeskSvgAnimator({
|
|
|
572
582
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
573
583
|
}, [compiled, autoplay, effectiveStartOn, effectiveOutAction]);
|
|
574
584
|
|
|
575
|
-
// 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.
|
|
576
587
|
useEffect(() => {
|
|
577
588
|
return () => {
|
|
578
589
|
cancelAnimation(progress);
|
|
579
590
|
playingRef.current = false;
|
|
591
|
+
onRemove?.();
|
|
592
|
+
onStop?.();
|
|
580
593
|
};
|
|
581
594
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
582
595
|
}, [compiled]);
|
|
@@ -644,14 +657,18 @@ export function PixodeskSvgAnimator({
|
|
|
644
657
|
// rather than propagating and unmounting the host screen.
|
|
645
658
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
646
659
|
renderErrorRef.current = error;
|
|
647
|
-
|
|
660
|
+
makeDiag().warn(PxDiagnosticKind.internal, 'could not render the document: ' + error.message);
|
|
648
661
|
return null;
|
|
649
662
|
}
|
|
650
663
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
651
664
|
}, [compiled, trackById]);
|
|
652
665
|
|
|
653
666
|
useEffect(() => {
|
|
654
|
-
|
|
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
|
|
655
672
|
}, [root]);
|
|
656
673
|
|
|
657
674
|
// Surface compile/render failures to the host exactly once per occurrence.
|
|
@@ -695,7 +712,7 @@ export function PixodeskSvgAnimator({
|
|
|
695
712
|
// and commit of the tree — react-native-svg internals, reanimated failing
|
|
696
713
|
// to attach to a component that turns out not to be a host view, and so on.
|
|
697
714
|
return (
|
|
698
|
-
<PxRnErrorBoundary onError={onError} fallback={fallback}>
|
|
715
|
+
<PxRnErrorBoundary onError={onError} fallback={fallback} diag={makeDiag()}>
|
|
699
716
|
{content}
|
|
700
717
|
</PxRnErrorBoundary>
|
|
701
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);
|
package/src/PxRnRender.tsx
CHANGED
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
import {
|
|
7
7
|
getNormalizedProps,
|
|
8
8
|
resolveStyle,
|
|
9
|
-
|
|
9
|
+
sanitizeAttributeValue,
|
|
10
10
|
DISALLOWED_SVG_TAGS_LOWER,
|
|
11
|
-
TEXT_ATTR,
|
|
12
11
|
TEXT_CONTENT_ATTR,
|
|
13
12
|
type PxDefs,
|
|
14
13
|
type PxNode,
|
|
@@ -42,25 +41,25 @@ export interface RenderRnNodeOptions {
|
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
|
-
* Converts core-
|
|
46
|
-
* naming,
|
|
44
|
+
* Converts core-normalized wire props into react-native-svg props: RN prop
|
|
45
|
+
* naming, sanitization (same security rules as the web renderer), numeric
|
|
47
46
|
* coercion where possible.
|
|
48
47
|
*/
|
|
49
48
|
export function toRnProps(props: Record<string, any>, warnings?: Array<string>, tag?: string): Record<string, any> {
|
|
50
|
-
const
|
|
49
|
+
const normalized = getNormalizedProps(props);
|
|
51
50
|
const out: Record<string, any> = {};
|
|
52
|
-
for (const key of Object.keys(
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
51
|
+
for (const key of Object.keys(normalized)) {
|
|
52
|
+
const sanitized = sanitizeAttributeValue(key, normalized[key]);
|
|
53
|
+
if (sanitized === undefined) continue;
|
|
55
54
|
const rnKey = toRnPropName(key);
|
|
56
55
|
if (!rnKey) continue;
|
|
57
|
-
out[rnKey] = toRnPropValue(rnKey, String(
|
|
56
|
+
out[rnKey] = toRnPropValue(rnKey, String(sanitized), tag);
|
|
58
57
|
}
|
|
59
58
|
return out;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
/**
|
|
63
|
-
* Renders a (
|
|
62
|
+
* Renders a (materialized) PxNode tree to react-native-svg elements.
|
|
64
63
|
* Mirrors the web `renderNode` contract: unsupported/dangerous tags are
|
|
65
64
|
* skipped with a warning, never a crash.
|
|
66
65
|
*/
|
|
@@ -101,14 +100,14 @@ export function renderRnNode(node: PxNode, opts: RenderRnNodeOptions = {}, key?:
|
|
|
101
100
|
for (const [k, v] of Object.entries(resolved)) {
|
|
102
101
|
const rnKey = toRnPropName(k);
|
|
103
102
|
if (!rnKey || rnKey in rnProps) continue;
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
rnProps[rnKey] = toRnPropValue(rnKey, String(
|
|
103
|
+
const sanitized = sanitizeAttributeValue(rnKey, v);
|
|
104
|
+
if (sanitized === undefined) continue;
|
|
105
|
+
rnProps[rnKey] = toRnPropValue(rnKey, String(sanitized), tag);
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
// Text content: wire nodes carry it
|
|
111
|
-
const textContent: string | undefined = props[
|
|
109
|
+
// Text content: wire nodes carry it in `textContent` — the one key.
|
|
110
|
+
const textContent: string | undefined = props[TEXT_CONTENT_ATTR];
|
|
112
111
|
|
|
113
112
|
let childElements: ReactNode = undefined;
|
|
114
113
|
if (Array.isArray(children) && children.length > 0) {
|