@onlynative/inertia-svg 0.0.1-alpha.3

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,340 @@
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import { Path, type PathProps } from 'react-native-svg'
3
+ import Animated, {
4
+ useAnimatedProps,
5
+ useSharedValue,
6
+ type SharedValue,
7
+ } from 'react-native-reanimated'
8
+ import {
9
+ resolveTransition,
10
+ useShouldReduceMotion,
11
+ type TransitionConfig,
12
+ } from '@onlynative/inertia'
13
+ import {
14
+ diffTemplate,
15
+ flattenParams,
16
+ parsePathD,
17
+ serializePath,
18
+ templateOf,
19
+ type PathTemplate,
20
+ } from './path'
21
+ import type {
22
+ PathAnimate,
23
+ PathPerPropertyTransition,
24
+ PathTransition,
25
+ } from './types'
26
+
27
+ const AnimatedPath = Animated.createAnimatedComponent(Path)
28
+
29
+ const NO_ANIMATION: TransitionConfig = { type: 'no-animation' }
30
+
31
+ function pickTransition(
32
+ per: PathTransition | undefined,
33
+ key: keyof PathPerPropertyTransition,
34
+ ): TransitionConfig | undefined {
35
+ if (!per) return undefined
36
+ if ('type' in per) return per as TransitionConfig
37
+ return (per as PathPerPropertyTransition)[key]
38
+ }
39
+
40
+ export interface MotionPathProps extends Omit<
41
+ PathProps,
42
+ | 'd'
43
+ | 'fill'
44
+ | 'stroke'
45
+ | 'strokeWidth'
46
+ | 'strokeOpacity'
47
+ | 'fillOpacity'
48
+ | 'opacity'
49
+ | 'strokeDashoffset'
50
+ > {
51
+ /**
52
+ * Initial path data. **The command sequence is locked at first render** —
53
+ * every target `d` passed via `animate` / `initial` must produce the same
54
+ * command letters in the same order after implicit-repeat expansion. To
55
+ * morph between structurally different paths, remount with a new `key`.
56
+ */
57
+ d: string
58
+ fill?: string
59
+ stroke?: string
60
+ strokeWidth?: number
61
+ strokeOpacity?: number
62
+ fillOpacity?: number
63
+ opacity?: number
64
+ strokeDashoffset?: number
65
+ /**
66
+ * Initial frame override. When present, the component mounts displaying
67
+ * these values, then animates to `animate` on the next effect. Pass `false`
68
+ * to skip the initial-mount animation entirely.
69
+ */
70
+ initial?: PathAnimate | false
71
+ /** Target animation state. */
72
+ animate?: PathAnimate
73
+ /**
74
+ * Transition config — either a single `TransitionConfig` applied to every
75
+ * animated dimension, or a per-property map. Per-property entries win over
76
+ * the top-level transition.
77
+ */
78
+ transition?: PathTransition
79
+ }
80
+
81
+ /**
82
+ * Animatable `<Path>` from `react-native-svg`. Wraps `Path` with declarative
83
+ * `initial` / `animate` / `transition` props.
84
+ *
85
+ * Animatable dimensions:
86
+ * - `d` — path morph via element-wise scalar interpolation. Source and target
87
+ * must share the same command sequence (e.g. both `M L L L Z`).
88
+ * - `fill`, `stroke` — color strings, interpolated via Reanimated's native
89
+ * color animation.
90
+ * - `strokeWidth`, `strokeOpacity`, `fillOpacity`, `opacity`,
91
+ * `strokeDashoffset` — numeric, spring or timing-driven.
92
+ *
93
+ * Example:
94
+ * ```tsx
95
+ * <Svg viewBox="0 0 100 100">
96
+ * <MotionPath
97
+ * d="M 50 20 L 80 80 L 20 80 Z"
98
+ * animate={{ d: "M 50 80 L 80 20 L 20 20 Z", fill: '#7c3aed' }}
99
+ * transition={{ type: 'spring', tension: 140, friction: 12 }}
100
+ * fill="#0ea5e9"
101
+ * />
102
+ * </Svg>
103
+ * ```
104
+ */
105
+ export function MotionPath(props: MotionPathProps) {
106
+ const {
107
+ d,
108
+ fill,
109
+ stroke,
110
+ strokeWidth,
111
+ strokeOpacity,
112
+ fillOpacity,
113
+ opacity,
114
+ strokeDashoffset,
115
+ initial,
116
+ animate,
117
+ transition,
118
+ ...rest
119
+ } = props
120
+
121
+ // Parse + freeze the source template at mount. The number of scalar params
122
+ // is locked here so the shared-value array allocated below has a stable
123
+ // length across renders.
124
+ const sourceRef = useRef<{
125
+ template: PathTemplate
126
+ params: number[]
127
+ } | null>(null)
128
+ if (sourceRef.current === null) {
129
+ const segments = parsePathD(d)
130
+ sourceRef.current = {
131
+ template: templateOf(segments),
132
+ params: flattenParams(segments),
133
+ }
134
+ }
135
+ const template = sourceRef.current.template
136
+
137
+ if (__DEV__) {
138
+ // Re-parse the current `d` prop and verify the template hasn't shifted.
139
+ // Catches the easy mistake of swapping a star for a hexagon without
140
+ // remounting via `key`.
141
+ const segments = parsePathD(d)
142
+ const live = templateOf(segments)
143
+ const err = diffTemplate(template, live)
144
+ if (err) {
145
+ throw new Error(
146
+ `[inertia-svg] d prop template changed after mount: ${err}\n` +
147
+ `If you need to swap to a structurally different path, remount with key={...}.`,
148
+ )
149
+ }
150
+ }
151
+
152
+ // `initial: false` → start at the animate target (no mount animation).
153
+ // `initial: {...}` → explicit seed values.
154
+ // `initial: undefined` → seed from the static props.
155
+ const seedSource = initial === false ? animate : (initial ?? undefined)
156
+
157
+ // Seed the path params. If `initial.d` is provided, parse it and verify
158
+ // it's template-compatible before seeding.
159
+ const seedParams: number[] = useMemo(() => {
160
+ if (!seedSource?.d) return sourceRef.current!.params
161
+ const segs = parsePathD(seedSource.d)
162
+ const t = templateOf(segs)
163
+ const err = diffTemplate(template, t)
164
+ if (err) {
165
+ if (__DEV__) {
166
+ throw new Error(`[inertia-svg] initial.d template mismatch: ${err}`)
167
+ }
168
+ return sourceRef.current!.params
169
+ }
170
+ return flattenParams(segs)
171
+ // template is stable for the component's lifetime; seedSource is the
172
+ // only meaningful input. We intentionally ignore `template` in deps.
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ }, [seedSource?.d])
175
+
176
+ // Loop-of-hooks per scalar param — safe because `template.size` is locked
177
+ // at mount via the source ref above.
178
+ const paramSvs: SharedValue<number>[] = []
179
+ for (let i = 0; i < template.size; i++) {
180
+ // eslint-disable-next-line react-hooks/rules-of-hooks
181
+ paramSvs.push(useSharedValue<number>(seedParams[i] ?? 0))
182
+ }
183
+
184
+ // Scalar property SVs. Strings (`fill`, `stroke`) use color seeds so
185
+ // Reanimated recognizes them as colors from frame 1.
186
+ const fillSv = useSharedValue<string>(
187
+ seedSource?.fill ?? fill ?? 'transparent',
188
+ )
189
+ const strokeSv = useSharedValue<string>(
190
+ seedSource?.stroke ?? stroke ?? 'transparent',
191
+ )
192
+ const strokeWidthSv = useSharedValue<number>(
193
+ seedSource?.strokeWidth ?? strokeWidth ?? 1,
194
+ )
195
+ const strokeOpacitySv = useSharedValue<number>(
196
+ seedSource?.strokeOpacity ?? strokeOpacity ?? 1,
197
+ )
198
+ const fillOpacitySv = useSharedValue<number>(
199
+ seedSource?.fillOpacity ?? fillOpacity ?? 1,
200
+ )
201
+ const opacitySv = useSharedValue<number>(seedSource?.opacity ?? opacity ?? 1)
202
+ const strokeDashoffsetSv = useSharedValue<number>(
203
+ seedSource?.strokeDashoffset ?? strokeDashoffset ?? 0,
204
+ )
205
+
206
+ const reduce = useShouldReduceMotion()
207
+
208
+ // Serialize scalar targets into stable keys so effects re-run on value
209
+ // change, not on every parent re-render (a fresh `animate` literal each
210
+ // render is the common case).
211
+ const animateD = animate?.d
212
+ const animateFill = animate?.fill
213
+ const animateStroke = animate?.stroke
214
+ const animateStrokeWidth = animate?.strokeWidth
215
+ const animateStrokeOpacity = animate?.strokeOpacity
216
+ const animateFillOpacity = animate?.fillOpacity
217
+ const animateOpacity = animate?.opacity
218
+ const animateStrokeDashoffset = animate?.strokeDashoffset
219
+
220
+ useEffect(() => {
221
+ if (animateD === undefined) return
222
+ const segments = parsePathD(animateD)
223
+ const t = templateOf(segments)
224
+ const err = diffTemplate(template, t)
225
+ if (err) {
226
+ if (__DEV__) {
227
+ throw new Error(`[inertia-svg] animate.d template mismatch: ${err}`)
228
+ }
229
+ return
230
+ }
231
+ const target = flattenParams(segments)
232
+ const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'd')
233
+ for (let i = 0; i < paramSvs.length; i++) {
234
+ paramSvs[i]!.value = resolveTransition(cfg, target[i] ?? 0) as number
235
+ }
236
+ // paramSvs / template are stable across renders by the locks above.
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [animateD, reduce, transition])
239
+
240
+ useEffect(() => {
241
+ if (animateFill === undefined) return
242
+ const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'fill')
243
+ fillSv.value = resolveTransition(cfg, animateFill) as string
244
+ // eslint-disable-next-line react-hooks/exhaustive-deps
245
+ }, [animateFill, reduce, transition])
246
+
247
+ useEffect(() => {
248
+ if (animateStroke === undefined) return
249
+ const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'stroke')
250
+ strokeSv.value = resolveTransition(cfg, animateStroke) as string
251
+ // eslint-disable-next-line react-hooks/exhaustive-deps
252
+ }, [animateStroke, reduce, transition])
253
+
254
+ useEffect(() => {
255
+ if (animateStrokeWidth === undefined) return
256
+ const cfg = reduce
257
+ ? NO_ANIMATION
258
+ : pickTransition(transition, 'strokeWidth')
259
+ strokeWidthSv.value = resolveTransition(cfg, animateStrokeWidth) as number
260
+ // eslint-disable-next-line react-hooks/exhaustive-deps
261
+ }, [animateStrokeWidth, reduce, transition])
262
+
263
+ useEffect(() => {
264
+ if (animateStrokeOpacity === undefined) return
265
+ const cfg = reduce
266
+ ? NO_ANIMATION
267
+ : pickTransition(transition, 'strokeOpacity')
268
+ strokeOpacitySv.value = resolveTransition(
269
+ cfg,
270
+ animateStrokeOpacity,
271
+ ) as number
272
+ // eslint-disable-next-line react-hooks/exhaustive-deps
273
+ }, [animateStrokeOpacity, reduce, transition])
274
+
275
+ useEffect(() => {
276
+ if (animateFillOpacity === undefined) return
277
+ const cfg = reduce
278
+ ? NO_ANIMATION
279
+ : pickTransition(transition, 'fillOpacity')
280
+ fillOpacitySv.value = resolveTransition(cfg, animateFillOpacity) as number
281
+ // eslint-disable-next-line react-hooks/exhaustive-deps
282
+ }, [animateFillOpacity, reduce, transition])
283
+
284
+ useEffect(() => {
285
+ if (animateOpacity === undefined) return
286
+ const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'opacity')
287
+ opacitySv.value = resolveTransition(cfg, animateOpacity) as number
288
+ // eslint-disable-next-line react-hooks/exhaustive-deps
289
+ }, [animateOpacity, reduce, transition])
290
+
291
+ useEffect(() => {
292
+ if (animateStrokeDashoffset === undefined) return
293
+ const cfg = reduce
294
+ ? NO_ANIMATION
295
+ : pickTransition(transition, 'strokeDashoffset')
296
+ strokeDashoffsetSv.value = resolveTransition(
297
+ cfg,
298
+ animateStrokeDashoffset,
299
+ ) as number
300
+ // eslint-disable-next-line react-hooks/exhaustive-deps
301
+ }, [animateStrokeDashoffset, reduce, transition])
302
+
303
+ const animatedProps = useAnimatedProps(() => {
304
+ 'worklet'
305
+ const params = new Array<number>(paramSvs.length)
306
+ for (let i = 0; i < paramSvs.length; i++) params[i] = paramSvs[i]!.value
307
+ return {
308
+ d: serializePath(template, params),
309
+ fill: fillSv.value,
310
+ stroke: strokeSv.value,
311
+ strokeWidth: strokeWidthSv.value,
312
+ strokeOpacity: strokeOpacitySv.value,
313
+ fillOpacity: fillOpacitySv.value,
314
+ opacity: opacitySv.value,
315
+ strokeDashoffset: strokeDashoffsetSv.value,
316
+ }
317
+ })
318
+
319
+ return (
320
+ <AnimatedPath
321
+ // `animatedProps` overrides every animated key each frame; the static
322
+ // props below are the first-render seeds so the path renders before the
323
+ // first effect tick. The cast sheds Reanimated's strict-prop constraint
324
+ // that the worklet's return type can't express — the runtime shape is
325
+ // the same.
326
+ animatedProps={animatedProps as never}
327
+ d={d}
328
+ fill={fill}
329
+ stroke={stroke}
330
+ strokeWidth={strokeWidth}
331
+ strokeOpacity={strokeOpacity}
332
+ fillOpacity={fillOpacity}
333
+ opacity={opacity}
334
+ strokeDashoffset={strokeDashoffset}
335
+ {...rest}
336
+ />
337
+ )
338
+ }
339
+
340
+ declare const __DEV__: boolean
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `@onlynative/inertia-svg` — animatable SVG primitives for
3
+ * `@onlynative/inertia`.
4
+ *
5
+ * v0.2 surface:
6
+ * - `MotionPath` / `MotionSvg.Path` — animatable `<Path>` over
7
+ * `react-native-svg`. Supports path morphing on the `d` attribute (source
8
+ * and target must share the same command sequence) plus animatable
9
+ * `fill`, `stroke`, `strokeWidth`, `strokeOpacity`, `fillOpacity`,
10
+ * `opacity`, and `strokeDashoffset` with the same `initial` /
11
+ * `animate` / `transition` shape as the core `Motion.*` primitives.
12
+ *
13
+ * Additional shape primitives (`Circle`, `Rect`, `Line`, `Ellipse`) land in
14
+ * a follow-up once the path morphing API is validated. Path normalization
15
+ * (resampling between structurally different paths) is out of scope for
16
+ * v0.2 — use structurally-compatible source/target paths and remount with
17
+ * `key={...}` to switch shape.
18
+ */
19
+ export { MotionPath } from './MotionPath'
20
+ export type { MotionPathProps } from './MotionPath'
21
+ export type {
22
+ PathAnimate,
23
+ PathPerPropertyTransition,
24
+ PathStateShape,
25
+ PathTransition,
26
+ } from './types'
27
+
28
+ export {
29
+ parsePathD,
30
+ templateOf,
31
+ diffTemplate,
32
+ flattenParams,
33
+ serializePath,
34
+ type PathSegment,
35
+ type PathTemplate,
36
+ } from './path'
37
+
38
+ import { MotionPath } from './MotionPath'
39
+
40
+ /**
41
+ * Namespace bundling every animatable SVG primitive. Use `MotionSvg.Path` for
42
+ * autocomplete-friendly grouping or import `MotionPath` directly — both
43
+ * point at the same component.
44
+ */
45
+ export const MotionSvg = {
46
+ Path: MotionPath,
47
+ } as const
package/src/path.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * SVG path-string utilities used by `MotionSvg.Path`. Everything here runs on
3
+ * the JS thread — paths are tokenized into a normalized command list at mount
4
+ * and when `animate.d` changes; the worklet only ever consumes flat number
5
+ * arrays + a frozen command template.
6
+ *
7
+ * Path morphing in v0.2 requires **structural compatibility**: the source and
8
+ * every target `d` must produce the same command sequence (same command
9
+ * letters, in the same order, after implicit-repeat expansion). Element-wise
10
+ * numeric interpolation is the entire morphing model — we do not resample
11
+ * paths or insert/remove commands. Same-shape morphs (e.g. a heart breathing,
12
+ * a chevron flipping, a check mark tracing in) are the supported use case.
13
+ */
14
+
15
+ /** Arg count per SVG path command. `Z`/`z` close the subpath and take none. */
16
+ const CMD_ARGS: Readonly<Record<string, number>> = {
17
+ M: 2,
18
+ m: 2,
19
+ L: 2,
20
+ l: 2,
21
+ H: 1,
22
+ h: 1,
23
+ V: 1,
24
+ v: 1,
25
+ C: 6,
26
+ c: 6,
27
+ S: 4,
28
+ s: 4,
29
+ Q: 4,
30
+ q: 4,
31
+ T: 2,
32
+ t: 2,
33
+ A: 7,
34
+ a: 7,
35
+ Z: 0,
36
+ z: 0,
37
+ }
38
+
39
+ /**
40
+ * After an explicit `M`/`m` the SVG spec says additional coordinate pairs are
41
+ * implicit `L`/`l` commands. Every other command repeats itself.
42
+ */
43
+ const CMD_REPEAT: Readonly<Record<string, string>> = {
44
+ M: 'L',
45
+ m: 'l',
46
+ }
47
+
48
+ /**
49
+ * A single normalized path command after implicit-repeat expansion. The cmd
50
+ * letter is preserved (absolute vs relative — case is meaningful to the SVG
51
+ * renderer). `args` always has exactly `CMD_ARGS[cmd]` entries.
52
+ */
53
+ export interface PathSegment {
54
+ cmd: string
55
+ args: number[]
56
+ }
57
+
58
+ const isDigit = (c: string): boolean => c >= '0' && c <= '9'
59
+
60
+ /**
61
+ * Tokenize a path `d` string into a stream of (command-letter | number)
62
+ * tokens. Handles SVG's "compact" number forms — adjacent numbers separated
63
+ * only by sign (`1-2`) or decimal point (`.5.6`) — so author-written paths
64
+ * with mixed spacing all parse to the same tokens.
65
+ */
66
+ function tokenize(d: string): Array<string | number> {
67
+ const out: Array<string | number> = []
68
+ const len = d.length
69
+ let i = 0
70
+ while (i < len) {
71
+ const c = d[i]!
72
+ // SVG path whitespace + comma separators.
73
+ if (c === ' ' || c === ',' || c === '\t' || c === '\n' || c === '\r') {
74
+ i++
75
+ continue
76
+ }
77
+ // Command letter — any ASCII letter not adjacent to a number context.
78
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
79
+ if (!(c in CMD_ARGS)) {
80
+ throw new Error(
81
+ `[inertia-svg] unknown path command '${c}' at position ${i}`,
82
+ )
83
+ }
84
+ out.push(c)
85
+ i++
86
+ continue
87
+ }
88
+ // Number. Reaches here for digits, `.`, `+`, `-`.
89
+ const start = i
90
+ let hasDigit = false
91
+ let hasDot = false
92
+ if (c === '+' || c === '-') i++
93
+ while (i < len) {
94
+ const ch = d[i]!
95
+ if (isDigit(ch)) {
96
+ hasDigit = true
97
+ i++
98
+ } else if (ch === '.' && !hasDot) {
99
+ hasDot = true
100
+ i++
101
+ } else {
102
+ break
103
+ }
104
+ }
105
+ if (hasDigit && (d[i] === 'e' || d[i] === 'E')) {
106
+ i++
107
+ if (d[i] === '+' || d[i] === '-') i++
108
+ while (i < len && isDigit(d[i]!)) i++
109
+ }
110
+ if (!hasDigit) {
111
+ throw new Error(
112
+ `[inertia-svg] expected number at position ${start} in path '${d}', got '${c}'`,
113
+ )
114
+ }
115
+ out.push(Number(d.substring(start, i)))
116
+ }
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * Parse a path `d` string into a flat list of normalized segments. Implicit
122
+ * repeats are expanded — `M 0 0 10 10 20 20` becomes three segments
123
+ * (`M 0 0`, `L 10 10`, `L 20 20`) so the segment list can be compared and
124
+ * interpolated 1:1 against another path.
125
+ */
126
+ export function parsePathD(d: string): PathSegment[] {
127
+ const tokens = tokenize(d)
128
+ const segments: PathSegment[] = []
129
+ let i = 0
130
+ while (i < tokens.length) {
131
+ const t = tokens[i]
132
+ if (typeof t !== 'string') {
133
+ throw new Error(
134
+ `[inertia-svg] expected command letter at token ${i}, got number ${t} — paths must start with a command`,
135
+ )
136
+ }
137
+ const cmd = t
138
+ const argCount = CMD_ARGS[cmd]!
139
+ i++
140
+ if (argCount === 0) {
141
+ segments.push({ cmd, args: [] })
142
+ continue
143
+ }
144
+ // First explicit batch for this command.
145
+ const first: number[] = []
146
+ for (let j = 0; j < argCount; j++) {
147
+ const v = tokens[i++]
148
+ if (typeof v !== 'number') {
149
+ throw new Error(
150
+ `[inertia-svg] command '${cmd}' expected ${argCount} numbers, got '${v}' at token ${i - 1}`,
151
+ )
152
+ }
153
+ first.push(v)
154
+ }
155
+ segments.push({ cmd, args: first })
156
+ // Repeated batches consume numbers up to the next command letter, applying
157
+ // the implicit-repeat command (M → L, m → l, everything else → itself).
158
+ // `argCount === 0` is handled above with an early continue, so the loop
159
+ // body here always makes forward progress.
160
+ const repeatCmd = CMD_REPEAT[cmd] ?? cmd
161
+ while (i < tokens.length && typeof tokens[i] === 'number') {
162
+ const batch: number[] = []
163
+ for (let j = 0; j < argCount; j++) {
164
+ const v = tokens[i++]
165
+ if (typeof v !== 'number') {
166
+ throw new Error(
167
+ `[inertia-svg] command '${cmd}' (implicit repeat as '${repeatCmd}') expected ${argCount} numbers`,
168
+ )
169
+ }
170
+ batch.push(v)
171
+ }
172
+ segments.push({ cmd: repeatCmd, args: batch })
173
+ }
174
+ }
175
+ return segments
176
+ }
177
+
178
+ /**
179
+ * The frozen "shape" of a path — just command letters and arg widths. Two
180
+ * paths are morphable iff their templates are equal.
181
+ */
182
+ export interface PathTemplate {
183
+ cmds: ReadonlyArray<string>
184
+ /** Flat width per segment, indexed parallel to `cmds`. */
185
+ widths: ReadonlyArray<number>
186
+ /** Total scalar count across all segments — `widths.reduce((a,b)=>a+b,0)`. */
187
+ size: number
188
+ }
189
+
190
+ export function templateOf(segments: ReadonlyArray<PathSegment>): PathTemplate {
191
+ const cmds = segments.map((s) => s.cmd)
192
+ const widths = segments.map((s) => s.args.length)
193
+ let size = 0
194
+ for (let i = 0; i < widths.length; i++) size += widths[i]!
195
+ return { cmds, widths, size }
196
+ }
197
+
198
+ /** Flatten a parsed segment list into a single number array (length === size). */
199
+ export function flattenParams(segments: ReadonlyArray<PathSegment>): number[] {
200
+ const out: number[] = []
201
+ for (let i = 0; i < segments.length; i++) {
202
+ const args = segments[i]!.args
203
+ for (let j = 0; j < args.length; j++) out.push(args[j]!)
204
+ }
205
+ return out
206
+ }
207
+
208
+ /**
209
+ * Verify a target template matches the source. Returns `null` on match or a
210
+ * descriptive error string on mismatch — callers throw in `__DEV__` and
211
+ * silently snap to the target in production.
212
+ */
213
+ export function diffTemplate(
214
+ source: PathTemplate,
215
+ target: PathTemplate,
216
+ ): string | null {
217
+ if (source.cmds.length !== target.cmds.length) {
218
+ return `command count differs: source has ${source.cmds.length} segments, target has ${target.cmds.length}. Paths must produce the same command sequence after implicit-repeat expansion.`
219
+ }
220
+ for (let i = 0; i < source.cmds.length; i++) {
221
+ if (source.cmds[i] !== target.cmds[i]) {
222
+ return `command at segment ${i} differs: source '${source.cmds[i]}' vs target '${target.cmds[i]}'. Command letters (including case — absolute vs relative) must match.`
223
+ }
224
+ }
225
+ return null
226
+ }
227
+
228
+ /**
229
+ * Build a path `d` string from a template + flat param array. Runs inside the
230
+ * worklet on the UI thread, so it must not capture any JS-thread closures or
231
+ * use Array.prototype helpers that allocate intermediates the Hermes runtime
232
+ * boxes into JS objects. Manual loops + `+=` string concat keep the worklet
233
+ * cheap.
234
+ *
235
+ * MUST be a worklet — call sites in `MotionPath` wrap it with `'worklet'` via
236
+ * `useAnimatedProps`.
237
+ */
238
+ export function serializePath(
239
+ template: PathTemplate,
240
+ params: ReadonlyArray<number>,
241
+ ): string {
242
+ 'worklet'
243
+ let out = ''
244
+ let p = 0
245
+ for (let i = 0; i < template.cmds.length; i++) {
246
+ out += template.cmds[i]
247
+ const w = template.widths[i]!
248
+ for (let j = 0; j < w; j++) {
249
+ out += ' '
250
+ out += params[p++]
251
+ }
252
+ }
253
+ return out
254
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { TransitionConfig } from '@onlynative/inertia'
2
+
3
+ /**
4
+ * Animatable target snapshot for a `MotionSvg.Path`. Every field is optional
5
+ * — include only the dimensions you want to animate; the rest fall back to
6
+ * the static props on the component.
7
+ *
8
+ * `d` morphs the path geometry. The target path **must produce the same
9
+ * command sequence** as the source after implicit-repeat expansion (same
10
+ * letters in the same order, e.g. `M L L L Z`). Element-wise numeric
11
+ * interpolation is the morphing model — see `parsePathD` for the
12
+ * normalization rules.
13
+ */
14
+ export interface PathAnimate {
15
+ d?: string
16
+ fill?: string
17
+ stroke?: string
18
+ strokeWidth?: number
19
+ strokeOpacity?: number
20
+ fillOpacity?: number
21
+ opacity?: number
22
+ strokeDashoffset?: number
23
+ }
24
+
25
+ /** The animatable dimensions of a `MotionSvg.Path`. */
26
+ export type PathStateShape = {
27
+ [K in keyof Required<PathAnimate>]: PathAnimate[K]
28
+ }
29
+
30
+ /**
31
+ * Per-property transition map. Top-level entries on `transition` apply to all
32
+ * properties unless overridden by a per-key entry here.
33
+ */
34
+ export type PathPerPropertyTransition = {
35
+ [K in keyof PathStateShape]?: TransitionConfig
36
+ }
37
+
38
+ /**
39
+ * Transition shape accepted by `MotionSvg.Path`. Either a single top-level
40
+ * transition applied to every animated dimension, or a per-property map.
41
+ */
42
+ export type PathTransition = TransitionConfig | PathPerPropertyTransition