@pixodesk/svg-animator-rn 1.0.35 → 1.0.40

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