@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.
- package/LICENSE +21 -0
- package/dist/index.cjs +459 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +158 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +470 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/PixodeskSvgAnimator.tsx +386 -0
- package/src/PxRnPropNames.ts +55 -0
- package/src/PxRnRender.tsx +94 -0
- package/src/PxRnTracks.test.ts +185 -0
- package/src/PxRnTracks.ts +150 -0
- package/src/PxRnTypeMap.ts +64 -0
- package/src/index.ts +15 -0
|
@@ -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
|
+
}
|