@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.
@@ -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 { reportDocumentDiagnostics, generateNewIds, getAnimatorConfig, getDefs, materialiseAllInTree, validateNodeEffects, PxTimelineEngine, type FillMode, type OutAction, type PlaybackDirection, type PxAnimatedSvgDocument, type PxAnimatorConfigPatch, type PxNode, type StartOn, applyAnimatorConfig, foldAnimatorConfigShortcuts } 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';
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
- /** Imperative playback API — mirrors ReactAnimatorApi from svg-animator-react. */
30
- export interface RnAnimatorApi {
31
- /** Returns true if the animation is currently running. */
32
- isPlaying(): boolean;
33
-
34
- /** Starts or resumes the animation. */
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
- 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'> {
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 `animator` config — the same shape as `animator`
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
- * `timeline.engine` is accepted but ignored here: React Native always materialises the
84
- * WAAPI-style flattening, because react-native-svg has no `<use>` shadow-tree propagation.
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
- config?: PxAnimatorConfigPatch | string;
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
- /** 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
+ */
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
- interface ConfigOverrides {
278
- config?: PxAnimatorConfigPatch | string;
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
- /** Materialised document, or null when compilation failed. */
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
- * Materialises + compiles a document. Extracted from the component so the
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 { config, resetDocDefaults, duration, delay, iterations, startOn } = overrides;
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) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
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 — `materialiseAllInTree` samples motion paths against `duration`, so a later
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 = foldAnimatorConfigShortcuts(config, { duration, delay, iterations, startOn });
315
- if (patch !== undefined || resetDocDefaults) {
316
- const applied = applyAnimatorConfig(doc, patch ?? {}, { resetDefaults: !!resetDocDefaults });
317
- for (const w of applied.warnings) console.warn('[PixodeskSvgAnimator] config override:', w);
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
- // `waapi` = the FULLY-FLATTENED materialisation: effects + loops +
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 `frames` flavour: frames keeps `<use href="#animatedTarget">`
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 = materialiseAllInTree(doc, PxTimelineEngine.native);
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 materialised once through the shared core pipeline (effects,
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 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
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, config, resetDocDefaults, duration, delay, iterations, startOn,
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
- // `config` is an object prop: a fresh literal every render would otherwise recompile the
365
- // whole document (materialise + compile tracks), which is the expensive path. Key on its
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 configKey = typeof config === 'string' ? config : JSON.stringify(config ?? null);
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
- { config, resetDocDefaults, duration, delay, iterations, startOn }
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
- console.warn('[PixodeskSvgAnimator] could not compile the document:', error.message);
337
+ makeDiag().warn(PxDiagnosticKind.internal, 'could not compile the document: ' + error.message);
380
338
  return { doc: null, tracks: EMPTY_TRACKS, error };
381
339
  }
382
- // `config` is an object prop, so a fresh literal each render would recompile the whole
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, configKey, resetDocDefaults, duration, delay, iterations, startOn]);
342
+ }, [doc, timelineKey, resetTimeline, duration, delay, iterations, startOn]);
385
343
 
386
344
  const tracks: PxCompiledTracks = compiled.tracks;
387
- 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]);
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 (!isFinite(rate) || rate === 0) {
486
- console.warn('setPlaybackRate: rate must be finite and non-zero');
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) 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
+ }
491
470
  },
492
- 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
+
493
477
  setCurrentTime: (t: number) => {
494
478
  const wasPlaying = playingRef.current;
495
479
  cancelAnimation(progress);
496
480
  playingRef.current = false;
497
- 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);
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
- // `config` override and the `startOn` shortcut, both merged in before compilation.
514
- const trigger = compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined;
515
- const effectiveStartOn = trigger?.startOn ?? 'load';
516
- const effectiveOutAction = trigger?.outAction ?? 'pause';
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
- if (progressProp !== undefined || time !== undefined) {
520
- 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);
521
531
  api.setCurrentTime(seekMs);
522
532
  return;
523
533
  }
524
- if (play !== undefined || pause !== undefined) {
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?.scrollIntoViewThreshold ?? 0;
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
- console.warn('[PixodeskSvgAnimator] could not render the document:', error.message);
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
- 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
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
- 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);
@@ -6,9 +6,8 @@
6
6
  import {
7
7
  getNormalizedProps,
8
8
  resolveStyle,
9
- sanitiseAttributeValue,
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-normalised wire props into react-native-svg props: RN prop
46
- * naming, sanitisation (same security rules as the web renderer), numeric
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 normalised = getNormalizedProps(props);
49
+ const normalized = getNormalizedProps(props);
51
50
  const out: Record<string, any> = {};
52
- for (const key of Object.keys(normalised)) {
53
- const sanitised = sanitiseAttributeValue(key, normalised[key]);
54
- if (sanitised === undefined) continue;
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(sanitised), tag);
56
+ out[rnKey] = toRnPropValue(rnKey, String(sanitized), tag);
58
57
  }
59
58
  return out;
60
59
  }
61
60
 
62
61
  /**
63
- * Renders a (materialised) PxNode tree to react-native-svg elements.
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 sanitised = sanitiseAttributeValue(rnKey, v);
105
- if (sanitised === undefined) continue;
106
- rnProps[rnKey] = toRnPropValue(rnKey, String(sanitised), tag);
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 as `text` / `textContent` attr.
111
- const textContent: string | undefined = props[TEXT_ATTR] || props[TEXT_CONTENT_ATTR];
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) {