@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.
@@ -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
- /** Imperative playback API — mirrors ReactAnimatorApi from svg-animator-react. */
42
- export interface RnAnimatorApi {
43
- /** Returns true if the animation is currently running. */
44
- isPlaying(): boolean;
45
-
46
- /** Starts or resumes the animation. */
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
- export interface PixodeskSvgAnimatorProps {
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
- * What a second tap does when `startOn: 'click'` is active.
97
- * Defaults to the document's `trigger.outAction`, else `'pause'`.
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
- outAction?: OutAction;
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
- /** Ref populated with the imperative playback API. */
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
- interface ConfigOverrides {
288
- duration?: number;
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
- /** Materialised document, or null when compilation failed. */
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
- * Materialises + compiles a document. Extracted from the component so the
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 { duration, delay, iterations, fill, direction, resetOnFinish } = overrides;
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) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
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
- // `waapi` = the FULLY-FLATTENED materialisation: effects + loops +
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 `frames` flavour: frames keeps `<use href="#animatedTarget">`
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 = materialiseAllInTree(doc, PxAnimatorEngine.waapi);
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 materialised once through the shared core pipeline (effects,
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 frames engine), compiled into densely sampled per-element tracks, and
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, duration, delay, iterations, fill, direction, resetOnFinish, outAction: outActionProp,
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
- { duration, delay, iterations, fill, direction, resetOnFinish }
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
- console.warn('[PixodeskSvgAnimator] could not compile the document:', error.message);
337
+ makeDiag().warn(PxDiagnosticKind.internal, 'could not compile the document: ' + error.message);
387
338
  return { doc: null, tracks: EMPTY_TRACKS, error };
388
339
  }
389
- }, [doc, duration, delay, iterations, fill, direction, resetOnFinish]);
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
- const totalDuration = tracks.duration * (tracks.iterations === Infinity ? 1 : tracks.iterations);
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 (!isFinite(rate) || rate === 0) {
491
- console.warn('setPlaybackRate: rate must be finite and non-zero');
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) startFrom(progress.value);
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
- getCurrentTime: () => progress.value,
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
- const clamped = Math.max(0, Math.min(t, totalDuration));
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
- const trigger = compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined;
518
- const startOn = trigger?.startOn ?? 'load';
519
- const outAction = outActionProp ?? trigger?.outAction ?? 'pause';
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
- if (progressProp !== undefined || time !== undefined) {
523
- const seekMs = time !== undefined ? time : (progressProp ?? 0) * totalDuration;
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 (play !== undefined || pause !== undefined) {
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 && startOn === 'load') {
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 || startOn !== 'scrollIntoView') return;
549
- const threshold = trigger?.scrollIntoViewThreshold ?? 0;
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 (outAction === 'reset') api.cancel();
567
- else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
568
- else if (outAction !== 'continue') api.pause();
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, startOn, outAction]);
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
- console.warn('[PixodeskSvgAnimator] could not render the document:', error.message);
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
- for (const w of warningsRef.current) console.warn('[PixodeskSvgAnimator]', w);
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 && startOn === 'scrollIntoView' && root) {
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 && startOn === 'click' && root) {
692
+ } else if (autoplay && effectiveStartOn === 'click' && root) {
679
693
  content = (
680
694
  <Pressable
681
695
  onPress={() => {
682
696
  if (playingRef.current) {
683
- if (outAction === 'reset') api.cancel();
684
- else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
685
- else if (outAction !== 'continue') api.pause();
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
- console.warn('[PixodeskSvgAnimator] render failed:', error?.message ?? error);
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 {
@@ -33,7 +33,7 @@ describe('svgTransformToMatrix', () => {
33
33
  expect(y).toBeCloseTo(1, 9);
34
34
  });
35
35
 
36
- it('honours a rotation centre', () => {
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
- // serialises to `transform="1,0,0,1,3,4"` and the element stops moving.
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
- * recognisable function, so callers can leave the original value untouched
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 {
@@ -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 normalised wire attribute name (camelCase after core's
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 serialise
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);