@pixodesk/svg-animator-rn 1.0.21

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.
@@ -0,0 +1,386 @@
1
+ /*---------------------------------------------------------------------------------------
2
+ * Copyright (c) Pixodesk LTD.
3
+ * Licensed under the MIT License. See the LICENSE file in the project root for details.
4
+ *---------------------------------------------------------------------------------------*/
5
+
6
+ import {
7
+ generateNewIds,
8
+ getAnimatorConfig,
9
+ materialiseAllInTree,
10
+ validateNodeEffects,
11
+ PxAnimatorEngine,
12
+ type FillMode,
13
+ type PlaybackDirection,
14
+ type PxAnimatedSvgDocument,
15
+ type PxNode,
16
+ } from '@pixodesk/svg-animator-core';
17
+ import React, { useEffect, useImperativeHandle, useMemo, useRef, type ComponentType, type ReactElement, type ReactNode } from 'react';
18
+ import Animated, {
19
+ cancelAnimation,
20
+ Easing,
21
+ runOnJS,
22
+ useAnimatedProps,
23
+ useSharedValue,
24
+ withDelay,
25
+ withRepeat,
26
+ withTiming,
27
+ type SharedValue,
28
+ } from 'react-native-reanimated';
29
+ import { compileTracks, sampleProps, type PxCompiledTracks, type PxElementTracks } from './PxRnTracks';
30
+ import { renderRnNode } from './PxRnRender';
31
+
32
+
33
+ // -- Public types -----------------------------------------------------------
34
+
35
+ /** Imperative playback API — mirrors ReactAnimatorApi from svg-animator-react. */
36
+ export interface RnAnimatorApi {
37
+ /** Returns true if the animation is currently running. */
38
+ isPlaying(): boolean;
39
+
40
+ /** Starts or resumes the animation. */
41
+ play(): void;
42
+
43
+ /** Pauses the animation at its current state. */
44
+ pause(): void;
45
+
46
+ /** Stops the animation and resets it to its initial state. */
47
+ cancel(): void;
48
+
49
+ /** Jumps to the end of the animation and holds the final state. */
50
+ finish(): void;
51
+
52
+ /** Changes the speed of the animation. 1 is normal, 2 is double. */
53
+ setPlaybackRate(rate: number): void;
54
+
55
+ /** Returns the current playback time in milliseconds. */
56
+ getCurrentTime(): number | null;
57
+
58
+ /** Jumps to a specific time (in milliseconds) in the animation. */
59
+ setCurrentTime(time: number): void;
60
+ }
61
+
62
+ export interface PixodeskSvgAnimatorProps {
63
+
64
+ // -- Source ---------------------------------------------------------------
65
+
66
+ /** The animation document to render. */
67
+ doc: PxAnimatedSvgDocument;
68
+
69
+ // -- Timing overrides -----------------------------------------------------
70
+
71
+ /** Duration of a single iteration in milliseconds. */
72
+ duration?: number;
73
+
74
+ /** Delay before the animation starts, in milliseconds. */
75
+ delay?: number;
76
+
77
+ /** Number of iterations, or 'infinite' for endless looping. */
78
+ iterations?: number | 'infinite';
79
+
80
+ /** Defines the element's state when the animation is not active. */
81
+ fill?: FillMode;
82
+
83
+ /** Playback direction. */
84
+ direction?: PlaybackDirection;
85
+
86
+ // -- Declarative control --------------------------------------------------
87
+
88
+ /** When true, honours the document trigger (`startOn: 'load'` plays on mount). */
89
+ autoplay?: boolean;
90
+
91
+ /** Starts playback unconditionally. */
92
+ play?: boolean;
93
+
94
+ /** Pauses current playback. */
95
+ pause?: boolean;
96
+
97
+ // -- Imperative control ---------------------------------------------------
98
+
99
+ /** Ref populated with the imperative playback API. */
100
+ apiRef?: React.RefObject<RnAnimatorApi | null>;
101
+
102
+ // -- Controlled (external) time -------------------------------------------
103
+
104
+ /** Seek to a fraction (0–1) of the whole timeline (duration × iterations). */
105
+ time?: number;
106
+
107
+ /** Seek to a specific time in milliseconds. */
108
+ timeMs?: number;
109
+
110
+ // -- Callbacks ------------------------------------------------------------
111
+
112
+ onPlay?: () => void;
113
+ onStop?: () => void;
114
+ onPause?: () => void;
115
+ onCancel?: () => void;
116
+ onFinish?: () => void;
117
+ }
118
+
119
+
120
+ // -- Animated element wrapper ------------------------------------------------
121
+
122
+ const animatedComponentCache = new Map<ComponentType<any>, ComponentType<any>>();
123
+
124
+ function getAnimatedComponent(Component: ComponentType<any>): ComponentType<any> {
125
+ let cached = animatedComponentCache.get(Component);
126
+ if (!cached) {
127
+ cached = Animated.createAnimatedComponent(Component as any);
128
+ animatedComponentCache.set(Component, cached);
129
+ }
130
+ return cached;
131
+ }
132
+
133
+ /** One animated element: static props + UI-thread sampled animated props. */
134
+ function AnimatedPxElement({
135
+ Component, staticProps, children, tracks, progress, stepMs, sampleCount,
136
+ }: {
137
+ Component: ComponentType<any>;
138
+ staticProps: Record<string, any>;
139
+ children: ReactNode;
140
+ tracks: PxElementTracks;
141
+ progress: SharedValue<number>;
142
+ stepMs: number;
143
+ sampleCount: number;
144
+ }) {
145
+ const AnimatedComponent = useMemo(() => getAnimatedComponent(Component), [Component]);
146
+
147
+ // Runs on the UI thread every frame; `sampleProps` is a trivial indexed
148
+ // lookup into the precompiled tracks — no interpolation logic on the hot path.
149
+ const animatedProps = useAnimatedProps(() => {
150
+ return sampleProps(tracks, progress.value, stepMs, sampleCount);
151
+ }, [tracks, stepMs, sampleCount]);
152
+
153
+ return (
154
+ <AnimatedComponent {...staticProps} animatedProps={animatedProps}>
155
+ {children}
156
+ </AnimatedComponent>
157
+ );
158
+ }
159
+
160
+
161
+ // -- Main component ----------------------------------------------------------
162
+
163
+ /**
164
+ * React Native component for rendering and controlling Pixodesk SVG animations.
165
+ *
166
+ * The document is materialised once through the shared core pipeline (effects,
167
+ * loops, motion-path sampling, animated-`<use>` inlining — identical to the
168
+ * web frames engine), compiled into densely sampled per-element tracks, and
169
+ * played back natively: a single reanimated progress value driven by
170
+ * `withTiming`/`withRepeat` on the UI thread, with per-element worklets
171
+ * indexing the precompiled tracks. No JS-thread frame loop.
172
+ */
173
+ export function PixodeskSvgAnimator({
174
+ doc, duration, delay, iterations, fill, direction,
175
+ autoplay, play, pause, apiRef, time, timeMs,
176
+ onPlay, onStop, onPause, onCancel, onFinish,
177
+ }: PixodeskSvgAnimatorProps): ReactElement | null {
178
+
179
+ // -- Compile the document (once per doc/override change) ------------------
180
+
181
+ const compiled = useMemo(() => {
182
+ const warnings = validateNodeEffects(doc as PxNode);
183
+ for (const w of warnings) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
184
+
185
+ // `webapi` = the FULLY-FLATTENED materialisation: effects + loops +
186
+ // sampled motion paths + animated `<use>` inlined into real `<g>`
187
+ // clones + orphaned defs pruned. That last part is why RN must not use
188
+ // the `frames` flavour: frames keeps `<use href="#animatedTarget">`
189
+ // live references, which only work because the DOM propagates
190
+ // attribute writes through `<use>` shadow trees. react-native-svg has
191
+ // no such live propagation, so an animated `<use>` would render frozen.
192
+ let prepared = materialiseAllInTree(doc, PxAnimatorEngine.webapi);
193
+
194
+ // Apply prop overrides onto the animator config (mirrors the react wrapper).
195
+ const animator = getAnimatorConfig(prepared) || {};
196
+ prepared = {
197
+ ...prepared,
198
+ animator: {
199
+ ...animator,
200
+ duration: duration !== undefined ? duration : animator.duration,
201
+ delay: delay !== undefined ? delay : animator.delay,
202
+ iterations: iterations !== undefined ? iterations : animator.iterations,
203
+ fill: fill !== undefined ? fill : animator.fill,
204
+ direction: direction !== undefined ? direction : animator.direction,
205
+ },
206
+ };
207
+
208
+ prepared = generateNewIds(prepared);
209
+ const tracks = compileTracks(prepared);
210
+ return { doc: prepared, tracks };
211
+ }, [doc, duration, delay, iterations, fill, direction]);
212
+
213
+ const tracks: PxCompiledTracks = compiled.tracks;
214
+ const totalDuration = tracks.duration * (tracks.iterations === Infinity ? 1 : tracks.iterations);
215
+
216
+ // -- Playback state -------------------------------------------------------
217
+
218
+ // Progress in ms within ONE iteration; iteration repetition/alternation is
219
+ // expressed through withRepeat, so worklets only ever see [0, duration].
220
+ const progress = useSharedValue(0);
221
+ const playingRef = useRef(false);
222
+ const rateRef = useRef(1);
223
+
224
+ const notifyFinish = () => {
225
+ playingRef.current = false;
226
+ if (tracks.fill === 'none' || tracks.fill === 'backwards') progress.value = 0;
227
+ onFinish?.();
228
+ onStop?.();
229
+ };
230
+
231
+ const startFrom = (fromMs: number) => {
232
+ const dur = tracks.duration;
233
+ const rate = rateRef.current || 1;
234
+ const reversedStart = tracks.direction === 'reverse' || tracks.direction === 'alternate-reverse';
235
+ const alternates = tracks.direction === 'alternate' || tracks.direction === 'alternate-reverse';
236
+ const from = Math.max(0, Math.min(fromMs, dur));
237
+
238
+ const legTarget = reversedStart ? 0 : dur;
239
+ const legRemaining = Math.abs(legTarget - from) / rate;
240
+ const repeats = tracks.iterations === Infinity ? -1 : tracks.iterations;
241
+
242
+ cancelAnimation(progress);
243
+ progress.value = reversedStart ? (from === 0 ? dur : from) : from;
244
+
245
+ const animation = repeats === 1
246
+ ? withTiming(legTarget, { duration: legRemaining, easing: Easing.linear }, (finished) => {
247
+ 'worklet';
248
+ if (finished) runOnJS(notifyFinish)();
249
+ })
250
+ : withRepeat(
251
+ withTiming(legTarget, { duration: legRemaining, easing: Easing.linear }),
252
+ repeats, alternates,
253
+ (finished) => {
254
+ 'worklet';
255
+ if (finished) runOnJS(notifyFinish)();
256
+ }
257
+ );
258
+
259
+ progress.value = tracks.delay > 0 && from === 0
260
+ ? withDelay(tracks.delay / rate, animation)
261
+ : animation;
262
+
263
+ playingRef.current = true;
264
+ };
265
+
266
+ const api: RnAnimatorApi = {
267
+ isPlaying: () => playingRef.current,
268
+ play: () => {
269
+ const from = playingRef.current ? progress.value : progress.value >= tracks.duration ? 0 : progress.value;
270
+ startFrom(from);
271
+ onPlay?.();
272
+ },
273
+ pause: () => {
274
+ cancelAnimation(progress);
275
+ playingRef.current = false;
276
+ onPause?.();
277
+ onStop?.();
278
+ },
279
+ cancel: () => {
280
+ cancelAnimation(progress);
281
+ progress.value = 0;
282
+ playingRef.current = false;
283
+ onCancel?.();
284
+ onStop?.();
285
+ },
286
+ finish: () => {
287
+ cancelAnimation(progress);
288
+ progress.value = tracks.fill === 'none' || tracks.fill === 'backwards' ? 0 : tracks.duration;
289
+ playingRef.current = false;
290
+ onFinish?.();
291
+ onStop?.();
292
+ },
293
+ setPlaybackRate: (rate: number) => {
294
+ if (!isFinite(rate) || rate <= 0) {
295
+ console.warn('setPlaybackRate: only finite positive rates are supported in the RN player (reverse is on the feature-gap list)');
296
+ return;
297
+ }
298
+ rateRef.current = rate;
299
+ if (playingRef.current) startFrom(progress.value);
300
+ },
301
+ getCurrentTime: () => progress.value,
302
+ setCurrentTime: (t: number) => {
303
+ cancelAnimation(progress);
304
+ playingRef.current = false;
305
+ const clamped = Math.max(0, Math.min(t, totalDuration));
306
+ progress.value = tracks.duration > 0 ? clamped % tracks.duration || (clamped === 0 ? 0 : tracks.duration) : 0;
307
+ },
308
+ };
309
+
310
+ useImperativeHandle(apiRef, () => api, [compiled]);
311
+
312
+ // -- Declarative control --------------------------------------------------
313
+
314
+ const startOn = getAnimatorConfig(compiled.doc)?.trigger?.startOn ?? 'load';
315
+
316
+ useEffect(() => {
317
+ if (time !== undefined || timeMs !== undefined) {
318
+ const seekMs = timeMs !== undefined ? timeMs : (time ?? 0) * totalDuration;
319
+ api.setCurrentTime(seekMs);
320
+ return;
321
+ }
322
+ if (play !== undefined || pause !== undefined) {
323
+ if (play && !pause) api.play();
324
+ else if (pause) api.pause();
325
+ else if (play === false) api.finish();
326
+ else api.play();
327
+ return;
328
+ }
329
+ if (autoplay && startOn === 'load') {
330
+ api.play();
331
+ }
332
+ // eslint-disable-next-line react-hooks/exhaustive-deps
333
+ }, [compiled, autoplay, play, pause, time, timeMs]);
334
+
335
+ // Stop cleanly on unmount / doc swap.
336
+ useEffect(() => {
337
+ return () => {
338
+ cancelAnimation(progress);
339
+ playingRef.current = false;
340
+ };
341
+ // eslint-disable-next-line react-hooks/exhaustive-deps
342
+ }, [compiled]);
343
+
344
+ // -- Render ---------------------------------------------------------------
345
+
346
+ const trackById = useMemo(() => {
347
+ const map = new Map<string, PxElementTracks>();
348
+ for (const el of tracks.elements) map.set(el.id, el);
349
+ return map;
350
+ }, [tracks]);
351
+
352
+ const warningsRef = useRef<Array<string>>([]);
353
+ const root = useMemo(() => {
354
+ warningsRef.current = [];
355
+ return renderRnNode(compiled.doc as PxNode, {
356
+ warnings: warningsRef.current,
357
+ decorate: (node, Component, staticProps, children) => {
358
+ const id = (node as any).id;
359
+ const elTracks = id ? trackById.get(id) : undefined;
360
+ if (!elTracks) return undefined;
361
+ return (
362
+ <AnimatedPxElement
363
+ key={staticProps.key}
364
+ Component={Component}
365
+ staticProps={staticProps}
366
+ tracks={elTracks}
367
+ progress={progress}
368
+ stepMs={tracks.stepMs}
369
+ sampleCount={tracks.sampleCount}
370
+ >
371
+ {children}
372
+ </AnimatedPxElement>
373
+ );
374
+ },
375
+ });
376
+ // eslint-disable-next-line react-hooks/exhaustive-deps
377
+ }, [compiled, trackById]);
378
+
379
+ useEffect(() => {
380
+ for (const w of warningsRef.current) console.warn('[PixodeskSvgAnimator]', w);
381
+ }, [root]);
382
+
383
+ return root;
384
+ }
385
+
386
+ export default PixodeskSvgAnimator;
@@ -0,0 +1,55 @@
1
+ /*---------------------------------------------------------------------------------------
2
+ * Copyright (c) Pixodesk LTD.
3
+ * Licensed under the MIT License. See the LICENSE file in the project root for details.
4
+ *---------------------------------------------------------------------------------------*/
5
+
6
+ import { kebabToCamelCaseWord } from '@pixodesk/svg-animator-core';
7
+
8
+ /** Attribute names with a react-native-svg prop equivalent under a
9
+ * different name (not just a casing change). */
10
+ const ATTR_NAME_OVERRIDES: Record<string, string> = {
11
+ 'xlink:href': 'href',
12
+ };
13
+
14
+ /** Props react-native-svg does not understand / must not receive. */
15
+ const DROPPED_ATTRS = new Set(['class', 'className', 'style', 'xmlns', 'xmlns:xlink', 'data-px-meta']);
16
+
17
+ /**
18
+ * Converts one normalised wire attribute name (camelCase after core's
19
+ * `getNormalizedProps`, or kebab-case raw) to a react-native-svg prop name.
20
+ * Returns undefined for props that must be dropped.
21
+ *
22
+ * Pure (no react-native-svg import) so the track compiler and its tests
23
+ * don't need a React Native environment.
24
+ */
25
+ export function toRnPropName(attrName: string): string | undefined {
26
+ if (DROPPED_ATTRS.has(attrName)) return undefined;
27
+ const override = ATTR_NAME_OVERRIDES[attrName];
28
+ if (override) return override;
29
+ // react-native-svg uses camelCase props (strokeWidth, fillOpacity, …);
30
+ // core's kebabToCamelCaseWord is a no-op for already-camelCase input.
31
+ return kebabToCamelCaseWord(attrName);
32
+ }
33
+
34
+ /** Props whose value is a LIST of lengths. react-native-svg's native side
35
+ * expects a number array here (its JS `extractLengthList` splits strings, but
36
+ * values delivered through reanimated's animated-props path bypass that JS
37
+ * extraction and reach the native view directly). */
38
+ const LENGTH_LIST_PROPS = new Set(['strokeDasharray']);
39
+
40
+ /**
41
+ * Converts one already-renamed prop value into the shape react-native-svg
42
+ * expects: length-list props become number arrays; numeric strings become
43
+ * numbers; everything else passes through.
44
+ */
45
+ export function toRnPropValue(rnPropName: string, value: string | number): string | number | Array<number> {
46
+ if (LENGTH_LIST_PROPS.has(rnPropName)) {
47
+ const parts = String(value).trim().replace(/,/g, ' ').split(/\s+/).map(Number).filter(n => Number.isFinite(n));
48
+ // An odd-length dasharray repeats to become even (SVG spec); rn-svg
49
+ // does this itself for the static path — mirror it so both paths agree.
50
+ return parts.length % 2 === 1 ? parts.concat(parts) : parts;
51
+ }
52
+ if (typeof value === 'number') return value;
53
+ const num = +value;
54
+ return Number.isFinite(num) && String(num) === value ? num : value;
55
+ }
@@ -0,0 +1,94 @@
1
+ /*---------------------------------------------------------------------------------------
2
+ * Copyright (c) Pixodesk LTD.
3
+ * Licensed under the MIT License. See the LICENSE file in the project root for details.
4
+ *---------------------------------------------------------------------------------------*/
5
+
6
+ import {
7
+ getNormalizedProps,
8
+ sanitiseAttributeValue,
9
+ DISALLOWED_SVG_TAGS_LOWER,
10
+ TEXT_ATTR,
11
+ TEXT_CONTENT_ATTR,
12
+ type PxNode,
13
+ } from '@pixodesk/svg-animator-core';
14
+ import { createElement, type ComponentType, type ReactElement, type ReactNode } from 'react';
15
+ import { RN_SVG_COMPONENTS } from './PxRnTypeMap';
16
+ import { toRnPropName, toRnPropValue } from './PxRnPropNames';
17
+
18
+ export interface RenderRnNodeOptions {
19
+ /** Collects non-fatal issues (unsupported tags, dropped attrs). */
20
+ warnings?: Array<string>;
21
+ /**
22
+ * Wraps the created element for animated nodes: receives the resolved
23
+ * component + static props and returns the element to mount (the animator
24
+ * substitutes an Animated component wired to its tracks). Return undefined
25
+ * to keep the plain static element.
26
+ */
27
+ decorate?: (
28
+ node: PxNode,
29
+ Component: ComponentType<any>,
30
+ props: Record<string, any>,
31
+ children: ReactNode
32
+ ) => ReactElement | undefined;
33
+ }
34
+
35
+ /**
36
+ * Converts core-normalised wire props into react-native-svg props: RN prop
37
+ * naming, sanitisation (same security rules as the web renderer), numeric
38
+ * coercion where possible.
39
+ */
40
+ export function toRnProps(props: Record<string, any>, warnings?: Array<string>): Record<string, any> {
41
+ const normalised = getNormalizedProps(props);
42
+ const out: Record<string, any> = {};
43
+ for (const key of Object.keys(normalised)) {
44
+ const sanitised = sanitiseAttributeValue(key, normalised[key]);
45
+ if (sanitised === undefined) continue;
46
+ const rnKey = toRnPropName(key);
47
+ if (!rnKey) continue;
48
+ out[rnKey] = toRnPropValue(rnKey, String(sanitised));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Renders a (materialised) PxNode tree to react-native-svg elements.
55
+ * Mirrors the web `renderNode` contract: unsupported/dangerous tags are
56
+ * skipped with a warning, never a crash.
57
+ */
58
+ export function renderRnNode(node: PxNode, opts: RenderRnNodeOptions = {}, key?: string | number): ReactElement | null {
59
+ if (!node) return null;
60
+
61
+ const { type, children, style, animate, meta, effects, ...props } = node as any;
62
+ const tag = String(type || 'g');
63
+
64
+ if (DISALLOWED_SVG_TAGS_LOWER.has(tag.toLowerCase())) {
65
+ opts.warnings?.push('tag blocked (dangerous): ' + tag);
66
+ return null;
67
+ }
68
+
69
+ const Component = RN_SVG_COMPONENTS[tag];
70
+ if (!Component) {
71
+ opts.warnings?.push('tag not supported in react-native-svg mapping: ' + tag);
72
+ return null;
73
+ }
74
+
75
+ const rnProps = toRnProps(props, opts.warnings);
76
+ if (key !== undefined) rnProps.key = key;
77
+
78
+ // Text content: wire nodes carry it as `text` / `textContent` attr.
79
+ const textContent: string | undefined = props[TEXT_ATTR] || props[TEXT_CONTENT_ATTR];
80
+
81
+ let childElements: ReactNode = undefined;
82
+ if (Array.isArray(children) && children.length > 0) {
83
+ childElements = children
84
+ .map((ch: PxNode, i: number) => renderRnNode(ch, opts, i))
85
+ .filter(Boolean);
86
+ } else if (textContent !== undefined) {
87
+ childElements = String(textContent);
88
+ }
89
+
90
+ const decorated = opts.decorate?.(node, Component, rnProps, childElements);
91
+ if (decorated !== undefined) return decorated;
92
+
93
+ return createElement(Component, rnProps, childElements);
94
+ }