@pixodesk/svg-animator-rn 1.0.21 → 1.0.22
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/README.md +415 -0
- package/dist/index.cjs +512 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -7
- package/dist/index.d.ts +103 -7
- package/dist/index.js +528 -84
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/PixodeskSvgAnimator.tsx +389 -69
- package/src/PxRnErrorBoundary.tsx +57 -0
- package/src/PxRnMatrix.test.ts +97 -0
- package/src/PxRnMatrix.ts +110 -0
- package/src/PxRnPropNames.ts +21 -1
- package/src/PxRnRender.tsx +53 -8
- package/src/PxRnSafety.test.ts +118 -0
- package/src/PxRnSafety.ts +123 -0
- package/src/PxRnTracks.test.ts +52 -4
- package/src/PxRnTracks.ts +57 -5
- package/src/PxRnTypeMap.ts +72 -5
- package/src/index.ts +2 -0
|
@@ -0,0 +1,97 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
7
|
+
import { svgTransformToMatrix } from './PxRnMatrix';
|
|
8
|
+
import { toRnPropValue } from './PxRnPropNames';
|
|
9
|
+
import { sampleProps } from './PxRnTracks';
|
|
10
|
+
|
|
11
|
+
/** Reference values cross-checked against react-native-svg's own `parse()`. */
|
|
12
|
+
describe('svgTransformToMatrix', () => {
|
|
13
|
+
it.each([
|
|
14
|
+
['translate(10,20)', [1, 0, 0, 1, 10, 20]],
|
|
15
|
+
['scale(2,3)', [2, 0, 0, 3, 0, 0]],
|
|
16
|
+
['rotate(90)', [0, 1, -1, 0, 0, 0]],
|
|
17
|
+
['skewX(45)', [1, 0, 1, 1, 0, 0]],
|
|
18
|
+
['skewY(45)', [1, 1, 0, 1, 0, 0]],
|
|
19
|
+
['matrix(1,2,3,4,5,6)', [1, 2, 3, 4, 5, 6]],
|
|
20
|
+
['translate(5,5) translate(-5,-5)', [1, 0, 0, 1, 0, 0]],
|
|
21
|
+
] as Array<[string, Array<number>]>)('%s', (input, expected) => {
|
|
22
|
+
const m = svgTransformToMatrix(input)!;
|
|
23
|
+
expect(m).toHaveLength(6);
|
|
24
|
+
m.forEach((v, i) => expect(v).toBeCloseTo(expected[i], 9));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('composes a list left-to-right (translate then rotate)', () => {
|
|
28
|
+
// translate(10,0) rotate(90) maps local (1,0) → (10,1)
|
|
29
|
+
const m = svgTransformToMatrix('translate(10,0)rotate(90)')!;
|
|
30
|
+
const x = m[0] * 1 + m[2] * 0 + m[4];
|
|
31
|
+
const y = m[1] * 1 + m[3] * 0 + m[5];
|
|
32
|
+
expect(x).toBeCloseTo(10, 9);
|
|
33
|
+
expect(y).toBeCloseTo(1, 9);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('honours a rotation centre', () => {
|
|
37
|
+
// rotate(180, 5, 0) maps (0,0) → (10,0)
|
|
38
|
+
const m = svgTransformToMatrix('rotate(180,5,0)')!;
|
|
39
|
+
expect(m[0] * 0 + m[2] * 0 + m[4]).toBeCloseTo(10, 9);
|
|
40
|
+
expect(m[1] * 0 + m[3] * 0 + m[5]).toBeCloseTo(0, 9);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns undefined when nothing parses, so the caller can pass the value through', () => {
|
|
44
|
+
expect(svgTransformToMatrix('')).toBeUndefined();
|
|
45
|
+
expect(svgTransformToMatrix('none')).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('toRnPropValue transform handling', () => {
|
|
50
|
+
it('converts a transform string to a matrix when targeting native views', () => {
|
|
51
|
+
expect(toRnPropValue('transform', 'translate(3,4)', undefined, true)).toEqual([1, 0, 0, 1, 3, 4]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('leaves the transform ALONE by default, for the DOM', () => {
|
|
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.
|
|
57
|
+
expect(toRnPropValue('transform', 'translate(3,4)')).toBe('translate(3,4)');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('leaves the ROOT <Svg> transform as a string even on native', () => {
|
|
61
|
+
// The root view uses `extractTransformSvgView`, which wants a string /
|
|
62
|
+
// RN style — a matrix would be silently dropped there.
|
|
63
|
+
expect(toRnPropValue('transform', 'translate(3,4)', 'svg', true)).toBe('translate(3,4)');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('passes non-transform props through untouched', () => {
|
|
67
|
+
expect(toRnPropValue('fill', 'rgba(1,2,3,1)')).toBe('rgba(1,2,3,1)');
|
|
68
|
+
expect(toRnPropValue('opacity', '0.5')).toBe(0.5);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('native prop naming (animated path)', () => {
|
|
73
|
+
const tracks = {
|
|
74
|
+
id: 'x',
|
|
75
|
+
props: {
|
|
76
|
+
transform: [[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 9, 9]] as Array<Array<number>>,
|
|
77
|
+
opacity: [1, 0],
|
|
78
|
+
},
|
|
79
|
+
} as any;
|
|
80
|
+
|
|
81
|
+
it('renames transform → matrix for the reanimated path', () => {
|
|
82
|
+
// The native view declares `matrix`; a `transform` prop is silently
|
|
83
|
+
// dropped there, which is exactly why animated transforms did nothing.
|
|
84
|
+
const out = sampleProps(tracks, 0, 1, 2, true);
|
|
85
|
+
expect(out.matrix).toEqual([1, 0, 0, 1, 0, 0]);
|
|
86
|
+
expect(out.transform).toBeUndefined();
|
|
87
|
+
expect(out.opacity).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps the wire name for the plain-React path', () => {
|
|
91
|
+
// Plain renders still go through react-native-svg's JS layer, which
|
|
92
|
+
// reads `transform` and derives `matrix` itself.
|
|
93
|
+
const out = sampleProps(tracks, 1, 1, 2, false);
|
|
94
|
+
expect(out.transform).toEqual([1, 0, 0, 1, 9, 9]);
|
|
95
|
+
expect(out.matrix).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* SVG transform string → 2D affine matrix, in the order react-native-svg's
|
|
8
|
+
* native side expects: `[a, b, c, d, e, f]`, i.e. SVG's own `matrix(…)` order
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* | a c e |
|
|
12
|
+
* | b d f |
|
|
13
|
+
* | 0 0 1 |
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* WHY THIS EXISTS: react-native-svg parses a `transform` STRING into this matrix
|
|
17
|
+
* in JavaScript, inside `extractTransform`, during render. Values delivered
|
|
18
|
+
* through reanimated's `animatedProps` bypass that JS step and reach the native
|
|
19
|
+
* view directly, where a raw string is meaningless — an animated transform
|
|
20
|
+
* simply does nothing. Feeding the matrix instead makes the animated and static
|
|
21
|
+
* paths agree. (On the web the DOM parses the string itself, which is why this
|
|
22
|
+
* only shows up on a device.)
|
|
23
|
+
*/
|
|
24
|
+
export type Mat2D = [number, number, number, number, number, number];
|
|
25
|
+
|
|
26
|
+
export const IDENTITY: Mat2D = [1, 0, 0, 1, 0, 0];
|
|
27
|
+
|
|
28
|
+
const DEG = Math.PI / 180;
|
|
29
|
+
|
|
30
|
+
/** `m1 · m2` — apply m2 first, then m1 (same convention as SVG's left-to-right list). */
|
|
31
|
+
function multiply(m1: Mat2D, m2: Mat2D): Mat2D {
|
|
32
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
33
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
34
|
+
return [
|
|
35
|
+
a1 * a2 + c1 * b2,
|
|
36
|
+
b1 * a2 + d1 * b2,
|
|
37
|
+
a1 * c2 + c1 * d2,
|
|
38
|
+
b1 * c2 + d1 * d2,
|
|
39
|
+
a1 * e2 + c1 * f2 + e1,
|
|
40
|
+
b1 * e2 + d1 * f2 + f1,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Splits `translate(1,2)rotate(45)` into `[['translate',[1,2]], ['rotate',[45]]]`. */
|
|
45
|
+
const FN_RE = /([a-zA-Z]+)\s*\(([^)]*)\)/g;
|
|
46
|
+
|
|
47
|
+
function numbers(raw: string): Array<number> {
|
|
48
|
+
return raw
|
|
49
|
+
.split(/[\s,]+/)
|
|
50
|
+
.filter(s => s.length > 0)
|
|
51
|
+
.map(Number)
|
|
52
|
+
.filter(n => Number.isFinite(n));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses an SVG transform list. Returns `undefined` when the string contains no
|
|
57
|
+
* recognisable function, so callers can leave the original value untouched
|
|
58
|
+
* rather than silently replacing it with an identity matrix.
|
|
59
|
+
*/
|
|
60
|
+
export function svgTransformToMatrix(value: string): Mat2D | undefined {
|
|
61
|
+
let m: Mat2D | undefined;
|
|
62
|
+
FN_RE.lastIndex = 0;
|
|
63
|
+
|
|
64
|
+
let match: RegExpExecArray | null;
|
|
65
|
+
while ((match = FN_RE.exec(value)) !== null) {
|
|
66
|
+
const fn = match[1].toLowerCase();
|
|
67
|
+
const n = numbers(match[2]);
|
|
68
|
+
let step: Mat2D | undefined;
|
|
69
|
+
|
|
70
|
+
switch (fn) {
|
|
71
|
+
case 'translate':
|
|
72
|
+
step = [1, 0, 0, 1, n[0] || 0, n[1] || 0];
|
|
73
|
+
break;
|
|
74
|
+
case 'scale': {
|
|
75
|
+
const sx = n[0] ?? 1;
|
|
76
|
+
const sy = n.length > 1 ? n[1] : sx;
|
|
77
|
+
step = [sx, 0, 0, sy, 0, 0];
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'rotate': {
|
|
81
|
+
const rad = (n[0] || 0) * DEG;
|
|
82
|
+
const cos = Math.cos(rad), sin = Math.sin(rad);
|
|
83
|
+
const rot: Mat2D = [cos, sin, -sin, cos, 0, 0];
|
|
84
|
+
if (n.length >= 3) {
|
|
85
|
+
// rotate(a, cx, cy) == translate(cx,cy) rotate(a) translate(-cx,-cy)
|
|
86
|
+
const cx = n[1], cy = n[2];
|
|
87
|
+
step = multiply(multiply([1, 0, 0, 1, cx, cy], rot), [1, 0, 0, 1, -cx, -cy]);
|
|
88
|
+
} else {
|
|
89
|
+
step = rot;
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'skewx':
|
|
94
|
+
step = [1, 0, Math.tan((n[0] || 0) * DEG), 1, 0, 0];
|
|
95
|
+
break;
|
|
96
|
+
case 'skewy':
|
|
97
|
+
step = [1, Math.tan((n[0] || 0) * DEG), 0, 1, 0, 0];
|
|
98
|
+
break;
|
|
99
|
+
case 'matrix':
|
|
100
|
+
if (n.length >= 6) step = [n[0], n[1], n[2], n[3], n[4], n[5]];
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
step = undefined; // unknown function — ignore it
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (step) m = m ? multiply(m, step) : step;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return m;
|
|
110
|
+
}
|
package/src/PxRnPropNames.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*---------------------------------------------------------------------------------------*/
|
|
5
5
|
|
|
6
6
|
import { kebabToCamelCaseWord } from '@pixodesk/svg-animator-core';
|
|
7
|
+
import { svgTransformToMatrix } from './PxRnMatrix';
|
|
7
8
|
|
|
8
9
|
/** Attribute names with a react-native-svg prop equivalent under a
|
|
9
10
|
* different name (not just a casing change). */
|
|
@@ -42,7 +43,26 @@ const LENGTH_LIST_PROPS = new Set(['strokeDasharray']);
|
|
|
42
43
|
* expects: length-list props become number arrays; numeric strings become
|
|
43
44
|
* numbers; everything else passes through.
|
|
44
45
|
*/
|
|
45
|
-
export function toRnPropValue(
|
|
46
|
+
export function toRnPropValue(
|
|
47
|
+
rnPropName: string,
|
|
48
|
+
value: string | number,
|
|
49
|
+
/** The owning element's tag. The ROOT `<Svg>` handles `transform` through a
|
|
50
|
+
* different code path (`extractTransformSvgView`, which wants a string or
|
|
51
|
+
* an RN style) — a matrix there would be dropped, so leave it alone. */
|
|
52
|
+
tag?: string,
|
|
53
|
+
/** Opt in to the NATIVE representation of a value (see below). Defaults to
|
|
54
|
+
* off, so web keeps the plain SVG/DOM form it has always been given. */
|
|
55
|
+
native = false,
|
|
56
|
+
): string | number | Array<number> {
|
|
57
|
+
// On a device `transform` must arrive as a matrix, not a string: the
|
|
58
|
+
// string→matrix parse happens in JS during render, which reanimated's
|
|
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
|
|
61
|
+
// to a meaningless `transform="1,0,0,1,3,4"`, so it must stay a string.
|
|
62
|
+
if (native && rnPropName === 'transform' && typeof value === 'string' && tag !== 'svg') {
|
|
63
|
+
const m = svgTransformToMatrix(value);
|
|
64
|
+
if (m) return m;
|
|
65
|
+
}
|
|
46
66
|
if (LENGTH_LIST_PROPS.has(rnPropName)) {
|
|
47
67
|
const parts = String(value).trim().replace(/,/g, ' ').split(/\s+/).map(Number).filter(n => Number.isFinite(n));
|
|
48
68
|
// An odd-length dasharray repeats to become even (SVG spec); rn-svg
|
package/src/PxRnRender.tsx
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
getNormalizedProps,
|
|
8
|
+
resolveStyle,
|
|
8
9
|
sanitiseAttributeValue,
|
|
9
10
|
DISALLOWED_SVG_TAGS_LOWER,
|
|
10
11
|
TEXT_ATTR,
|
|
11
12
|
TEXT_CONTENT_ATTR,
|
|
13
|
+
type PxDefs,
|
|
12
14
|
type PxNode,
|
|
13
15
|
} from '@pixodesk/svg-animator-core';
|
|
14
16
|
import { createElement, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
@@ -18,17 +20,24 @@ import { toRnPropName, toRnPropValue } from './PxRnPropNames';
|
|
|
18
20
|
export interface RenderRnNodeOptions {
|
|
19
21
|
/** Collects non-fatal issues (unsupported tags, dropped attrs). */
|
|
20
22
|
warnings?: Array<string>;
|
|
23
|
+
/** `definitions` from the document, used to resolve named `style` presets. */
|
|
24
|
+
defs?: PxDefs;
|
|
21
25
|
/**
|
|
22
26
|
* Wraps the created element for animated nodes: receives the resolved
|
|
23
27
|
* component + static props and returns the element to mount (the animator
|
|
24
28
|
* substitutes an Animated component wired to its tracks). Return undefined
|
|
25
29
|
* to keep the plain static element.
|
|
30
|
+
*
|
|
31
|
+
* `key` is handed over SEPARATELY and is deliberately absent from `props`:
|
|
32
|
+
* React 19 warns when a props object containing `key` is spread into JSX,
|
|
33
|
+
* and implementations of this hook do exactly that.
|
|
26
34
|
*/
|
|
27
35
|
decorate?: (
|
|
28
36
|
node: PxNode,
|
|
29
37
|
Component: ComponentType<any>,
|
|
30
38
|
props: Record<string, any>,
|
|
31
|
-
children: ReactNode
|
|
39
|
+
children: ReactNode,
|
|
40
|
+
key: string | number | undefined
|
|
32
41
|
) => ReactElement | undefined;
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -37,7 +46,7 @@ export interface RenderRnNodeOptions {
|
|
|
37
46
|
* naming, sanitisation (same security rules as the web renderer), numeric
|
|
38
47
|
* coercion where possible.
|
|
39
48
|
*/
|
|
40
|
-
export function toRnProps(props: Record<string, any>, warnings?: Array<string
|
|
49
|
+
export function toRnProps(props: Record<string, any>, warnings?: Array<string>, tag?: string): Record<string, any> {
|
|
41
50
|
const normalised = getNormalizedProps(props);
|
|
42
51
|
const out: Record<string, any> = {};
|
|
43
52
|
for (const key of Object.keys(normalised)) {
|
|
@@ -45,7 +54,7 @@ export function toRnProps(props: Record<string, any>, warnings?: Array<string>):
|
|
|
45
54
|
if (sanitised === undefined) continue;
|
|
46
55
|
const rnKey = toRnPropName(key);
|
|
47
56
|
if (!rnKey) continue;
|
|
48
|
-
out[rnKey] = toRnPropValue(rnKey, String(sanitised));
|
|
57
|
+
out[rnKey] = toRnPropValue(rnKey, String(sanitised), tag);
|
|
49
58
|
}
|
|
50
59
|
return out;
|
|
51
60
|
}
|
|
@@ -61,6 +70,12 @@ export function renderRnNode(node: PxNode, opts: RenderRnNodeOptions = {}, key?:
|
|
|
61
70
|
const { type, children, style, animate, meta, effects, ...props } = node as any;
|
|
62
71
|
const tag = String(type || 'g');
|
|
63
72
|
|
|
73
|
+
// `feFuncR/G/B/A` carry their SVG `type` attribute (identity/table/…) under
|
|
74
|
+
// `funcType`, because the wire format reserves `type` for the node tag.
|
|
75
|
+
// Restore it as a real prop, mirroring the web renderer.
|
|
76
|
+
const feFuncType: string | undefined = props.funcType;
|
|
77
|
+
if (feFuncType !== undefined) delete props.funcType;
|
|
78
|
+
|
|
64
79
|
if (DISALLOWED_SVG_TAGS_LOWER.has(tag.toLowerCase())) {
|
|
65
80
|
opts.warnings?.push('tag blocked (dangerous): ' + tag);
|
|
66
81
|
return null;
|
|
@@ -72,8 +87,24 @@ export function renderRnNode(node: PxNode, opts: RenderRnNodeOptions = {}, key?:
|
|
|
72
87
|
return null;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
// NB: `key` is never written into this object — see `decorate` above.
|
|
91
|
+
const rnProps = toRnProps(props, opts.warnings, tag);
|
|
92
|
+
if (feFuncType !== undefined) rnProps.type = feFuncType;
|
|
93
|
+
|
|
94
|
+
// `node.style` — a named preset from `definitions.styles`, or an inline
|
|
95
|
+
// record. react-native-svg has no CSS, so the resolved declarations are
|
|
96
|
+
// applied as ordinary props (the same names, e.g. `fill`, `strokeWidth`).
|
|
97
|
+
// Explicit attributes on the node win over the style block.
|
|
98
|
+
const resolved = resolveStyle(style, opts.defs);
|
|
99
|
+
if (resolved) {
|
|
100
|
+
for (const [k, v] of Object.entries(resolved)) {
|
|
101
|
+
const rnKey = toRnPropName(k);
|
|
102
|
+
if (!rnKey || rnKey in rnProps) continue;
|
|
103
|
+
const sanitised = sanitiseAttributeValue(rnKey, v);
|
|
104
|
+
if (sanitised === undefined) continue;
|
|
105
|
+
rnProps[rnKey] = toRnPropValue(rnKey, String(sanitised), tag);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
77
108
|
|
|
78
109
|
// Text content: wire nodes carry it as `text` / `textContent` attr.
|
|
79
110
|
const textContent: string | undefined = props[TEXT_ATTR] || props[TEXT_CONTENT_ATTR];
|
|
@@ -81,14 +112,28 @@ export function renderRnNode(node: PxNode, opts: RenderRnNodeOptions = {}, key?:
|
|
|
81
112
|
let childElements: ReactNode = undefined;
|
|
82
113
|
if (Array.isArray(children) && children.length > 0) {
|
|
83
114
|
childElements = children
|
|
84
|
-
.map((ch: PxNode, i: number) =>
|
|
115
|
+
.map((ch: PxNode, i: number) => {
|
|
116
|
+
// One malformed child should cost that child, not the whole tree.
|
|
117
|
+
try {
|
|
118
|
+
return renderRnNode(ch, opts, i);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
opts.warnings?.push('child failed to render: ' + (e instanceof Error ? e.message : String(e)));
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
})
|
|
85
124
|
.filter(Boolean);
|
|
86
125
|
} else if (textContent !== undefined) {
|
|
87
126
|
childElements = String(textContent);
|
|
88
127
|
}
|
|
89
128
|
|
|
90
|
-
const decorated = opts.decorate?.(node, Component, rnProps, childElements);
|
|
129
|
+
const decorated = opts.decorate?.(node, Component, rnProps, childElements, key);
|
|
91
130
|
if (decorated !== undefined) return decorated;
|
|
92
131
|
|
|
93
|
-
|
|
132
|
+
// `createElement` takes `key` in its config object — that path does NOT
|
|
133
|
+
// trigger React's JSX-spread warning.
|
|
134
|
+
return createElement(
|
|
135
|
+
Component,
|
|
136
|
+
key !== undefined ? { ...rnProps, key } : rnProps,
|
|
137
|
+
childElements
|
|
138
|
+
);
|
|
94
139
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
7
|
+
import type { PxNode } from '@pixodesk/svg-animator-core';
|
|
8
|
+
import { openClosedTextPathTargets } from './PxRnSafety';
|
|
9
|
+
|
|
10
|
+
/** Doc with one `<textPath>` following a closed circle-ish path. */
|
|
11
|
+
function docWithClosedTarget(): PxNode {
|
|
12
|
+
return {
|
|
13
|
+
type: 'svg',
|
|
14
|
+
children: [
|
|
15
|
+
{ type: 'defs', children: [{ type: 'path', id: 'ring', d: 'M0,0C10,0,10,10,0,10z' }] },
|
|
16
|
+
{
|
|
17
|
+
type: 'text',
|
|
18
|
+
children: [{
|
|
19
|
+
type: 'textPath', id: 'tp', href: '#ring', startOffset: 5,
|
|
20
|
+
animate: { startOffset: { keyframes: [{ time: 0, value: -20 }, { time: 1000, value: 40 }] } },
|
|
21
|
+
}],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
} as unknown as PxNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const find = (n: any, pred: (x: any) => boolean): any => {
|
|
28
|
+
if (pred(n)) return n;
|
|
29
|
+
for (const c of (n.children ?? [])) { const hit = find(c, pred); if (hit) return hit; }
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
const all = (n: any, pred: (x: any) => boolean, out: Array<any> = []): Array<any> => {
|
|
33
|
+
if (pred(n)) out.push(n);
|
|
34
|
+
for (const c of (n.children ?? [])) all(c, pred, out);
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('openClosedTextPathTargets', () => {
|
|
39
|
+
it('repoints the textPath at an OPEN copy of the closed path', () => {
|
|
40
|
+
const out: any = openClosedTextPathTargets(docWithClosedTarget());
|
|
41
|
+
const tp = find(out, (n: any) => n.type === 'textPath');
|
|
42
|
+
const newId = String(tp.href).slice(1);
|
|
43
|
+
|
|
44
|
+
expect(newId).not.toBe('ring');
|
|
45
|
+
const copy = find(out, (n: any) => n.id === newId);
|
|
46
|
+
expect(copy).toBeDefined();
|
|
47
|
+
expect(copy.d).toBe('M0,0C10,0,10,10,0,10'); // the `z` is gone
|
|
48
|
+
expect(copy.d).not.toMatch(/[zZ]/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('leaves the ORIGINAL path closed, for anything else that draws it', () => {
|
|
52
|
+
const out: any = openClosedTextPathTargets(docWithClosedTarget());
|
|
53
|
+
const original = find(out, (n: any) => n.id === 'ring');
|
|
54
|
+
expect(original.d).toBe('M0,0C10,0,10,10,0,10z');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('puts the copy in <defs> so it is never drawn', () => {
|
|
58
|
+
const out: any = openClosedTextPathTargets(docWithClosedTarget());
|
|
59
|
+
const tp = find(out, (n: any) => n.type === 'textPath');
|
|
60
|
+
const newId = String(tp.href).slice(1);
|
|
61
|
+
const defs = find(out, (n: any) => n.type === 'defs');
|
|
62
|
+
expect((defs.children ?? []).some((c: any) => c.id === newId)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('strips animation from the copy so it cannot animate twice', () => {
|
|
66
|
+
const doc: any = docWithClosedTarget();
|
|
67
|
+
doc.children[0].children[0].animate = { d: { keyframes: [] } };
|
|
68
|
+
const out: any = openClosedTextPathTargets(doc);
|
|
69
|
+
const tp = find(out, (n: any) => n.type === 'textPath');
|
|
70
|
+
const copy = find(out, (n: any) => n.id === String(tp.href).slice(1));
|
|
71
|
+
expect(copy.animate).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('makes ONE copy for several textPaths sharing a target', () => {
|
|
75
|
+
const doc: any = docWithClosedTarget();
|
|
76
|
+
doc.children[1].children.push({ type: 'textPath', id: 'tp2', href: '#ring' });
|
|
77
|
+
const out: any = openClosedTextPathTargets(doc);
|
|
78
|
+
|
|
79
|
+
const hrefs = all(out, (n: any) => n.type === 'textPath').map((n: any) => n.href);
|
|
80
|
+
expect(new Set(hrefs).size).toBe(1);
|
|
81
|
+
expect(all(out, (n: any) => n.type === 'path')).toHaveLength(2); // original + one copy
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not touch a textPath already following an OPEN path', () => {
|
|
85
|
+
const doc: any = docWithClosedTarget();
|
|
86
|
+
doc.children[0].children[0].d = 'M0,0L10,10';
|
|
87
|
+
const out: any = openClosedTextPathTargets(doc);
|
|
88
|
+
expect(find(out, (n: any) => n.type === 'textPath').href).toBe('#ring');
|
|
89
|
+
expect(all(out, (n: any) => n.type === 'path')).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns the very same object when there is no textPath at all', () => {
|
|
93
|
+
const doc = { type: 'svg', children: [{ type: 'rect' }] } as unknown as PxNode;
|
|
94
|
+
expect(openClosedTextPathTargets(doc)).toBe(doc);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reports what it did through the warnings channel', () => {
|
|
98
|
+
const warnings: Array<string> = [];
|
|
99
|
+
openClosedTextPathTargets(docWithClosedTarget(), warnings);
|
|
100
|
+
expect(warnings).toHaveLength(1);
|
|
101
|
+
expect(warnings[0]).toContain('#ring');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles xlink:href as well as href', () => {
|
|
105
|
+
const doc: any = docWithClosedTarget();
|
|
106
|
+
const tp = doc.children[1].children[0];
|
|
107
|
+
delete tp.href;
|
|
108
|
+
tp['xlink:href'] = '#ring';
|
|
109
|
+
const out: any = openClosedTextPathTargets(doc);
|
|
110
|
+
expect(find(out, (n: any) => n.type === 'textPath')['xlink:href']).not.toBe('#ring');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('ignores a dangling reference instead of throwing', () => {
|
|
114
|
+
const doc: any = docWithClosedTarget();
|
|
115
|
+
doc.children[1].children[0].href = '#nope';
|
|
116
|
+
expect(() => openClosedTextPathTargets(doc)).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { deepClone, generateUniqueId, type PxNode } from '@pixodesk/svg-animator-core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Workarounds for defects in react-native-svg's NATIVE renderer that would
|
|
10
|
+
* otherwise take the whole app down.
|
|
11
|
+
*
|
|
12
|
+
* These are applied only when the native views are in use — never on the web,
|
|
13
|
+
* where the DOM handles all of this correctly and the document must be left
|
|
14
|
+
* exactly as the core pipeline produced it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** True when a path's `d` contains a close-subpath command. */
|
|
18
|
+
function isClosedPath(d: unknown): boolean {
|
|
19
|
+
return typeof d === 'string' && /[zZ]/.test(d);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Drops close-subpath commands, turning a closed outline into an open one. */
|
|
23
|
+
function openPath(d: string): string {
|
|
24
|
+
return d.replace(/[zZ]/g, '').trimEnd();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `#foo` → `foo`. Returns undefined for anything that is not a local ref. */
|
|
28
|
+
function localRef(value: unknown): string | undefined {
|
|
29
|
+
if (typeof value !== 'string') return undefined;
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
return trimmed.startsWith('#') ? trimmed.slice(1) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function forEachNode(node: PxNode | undefined, visit: (n: PxNode, parent?: PxNode) => void, parent?: PxNode): void {
|
|
35
|
+
if (!node) return;
|
|
36
|
+
visit(node, parent);
|
|
37
|
+
for (const child of (node.children ?? [])) forEachNode(child, visit, node);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gives every `<textPath>` that follows a CLOSED path its own OPEN copy of it.
|
|
42
|
+
*
|
|
43
|
+
* WHY: react-native-svg's native text-on-path layout crashes the app —
|
|
44
|
+
* an uncatchable `NSRangeException` on iOS — for this combination.
|
|
45
|
+
* `RNSVGTSpan.mm` skips glyphs outside `[startOfRendering, endOfRendering]`,
|
|
46
|
+
* but for a closed path it sets those bounds to `startOffset … startOffset +
|
|
47
|
+
* pathLength` instead of `0 … pathLength`. Any glyph past the end of the path
|
|
48
|
+
* therefore survives the bounds check and reaches `getPosAndTan`, whose
|
|
49
|
+
* `indexOfObjectPassingTest` returns `NSNotFound` — and indexing the lengths
|
|
50
|
+
* array with `NSNotFound` throws. A non-zero `startOffset` on a closed path is
|
|
51
|
+
* all it takes, and animating `startOffset` guarantees hitting it.
|
|
52
|
+
*
|
|
53
|
+
* Opening the path restores the `0 … pathLength` bounds, so out-of-range
|
|
54
|
+
* glyphs are skipped as intended. The copy is private to the `<textPath>`, so
|
|
55
|
+
* anything else drawing the same path still gets the closed original. For a
|
|
56
|
+
* shape whose ends already meet (a circle, the usual case) the removed segment
|
|
57
|
+
* has zero length and nothing changes visually at all.
|
|
58
|
+
*
|
|
59
|
+
* Returns the document unchanged — the same object — when nothing matches.
|
|
60
|
+
*/
|
|
61
|
+
export function openClosedTextPathTargets(doc: PxNode, warnings?: Array<string>): PxNode {
|
|
62
|
+
// Cheap pre-check: the overwhelming majority of documents have no textPath.
|
|
63
|
+
let hasTextPath = false;
|
|
64
|
+
forEachNode(doc, n => { if (String(n.type) === 'textPath') hasTextPath = true; });
|
|
65
|
+
if (!hasTextPath) return doc;
|
|
66
|
+
|
|
67
|
+
const result = deepClone(doc);
|
|
68
|
+
|
|
69
|
+
const byId = new Map<string, PxNode>();
|
|
70
|
+
forEachNode(result, n => {
|
|
71
|
+
const id = (n as any).id;
|
|
72
|
+
if (typeof id === 'string') byId.set(id, n);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The copy is a geometry reference, never something to draw — so it goes
|
|
76
|
+
* in `<defs>`, alongside the paths textPath targets normally live in. */
|
|
77
|
+
let defs = (result.children ?? []).find(c => String(c.type) === 'defs');
|
|
78
|
+
const ensureDefs = (): PxNode => {
|
|
79
|
+
if (!defs) {
|
|
80
|
+
defs = { type: 'defs', children: [] } as unknown as PxNode;
|
|
81
|
+
(result.children ??= []).unshift(defs);
|
|
82
|
+
}
|
|
83
|
+
return defs;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Original path id → id of the open copy, so N textPaths share one copy. */
|
|
87
|
+
const openCopies = new Map<string, string>();
|
|
88
|
+
|
|
89
|
+
forEachNode(result, node => {
|
|
90
|
+
if (String(node.type) !== 'textPath') return;
|
|
91
|
+
|
|
92
|
+
const anyNode = node as any;
|
|
93
|
+
const attr = anyNode.href !== undefined ? 'href' : 'xlink:href';
|
|
94
|
+
const targetId = localRef(anyNode[attr]);
|
|
95
|
+
if (!targetId) return;
|
|
96
|
+
|
|
97
|
+
const target = byId.get(targetId);
|
|
98
|
+
if (!target || !isClosedPath((target as any).d)) return;
|
|
99
|
+
|
|
100
|
+
let copyId = openCopies.get(targetId);
|
|
101
|
+
if (!copyId) {
|
|
102
|
+
const copy = deepClone(target) as any;
|
|
103
|
+
copyId = 'px_open_' + generateUniqueId();
|
|
104
|
+
copy.id = copyId;
|
|
105
|
+
copy.d = openPath(String(copy.d));
|
|
106
|
+
// A copy that also carried animation would animate twice; the copy
|
|
107
|
+
// exists purely as a geometry reference for the text.
|
|
108
|
+
delete copy.animate;
|
|
109
|
+
delete copy.effects;
|
|
110
|
+
|
|
111
|
+
(ensureDefs().children ??= []).push(copy);
|
|
112
|
+
openCopies.set(targetId, copyId);
|
|
113
|
+
warnings?.push(
|
|
114
|
+
'textPath follows a closed path (#' + targetId + '); using an open copy to ' +
|
|
115
|
+
'avoid a react-native-svg native crash'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
anyNode[attr] = '#' + copyId;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|