morphicons 1.2.0 → 1.4.1
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 +75 -23
- package/dist/MorphIcon.svelte +86 -0
- package/dist/dom.d.ts +3 -2
- package/dist/dom.js +4 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/react-native.d.ts +31 -0
- package/dist/react-native.js +180 -0
- package/dist/react.js +67 -28
- package/dist/{spring-BTcHiHR5.js → spring-CBynweFS.js} +7 -3
- package/dist/svelte-shared.d.ts +68 -0
- package/dist/svelte-shared.js +146 -0
- package/dist/svelte.d.ts +13 -0
- package/dist/svelte.js +1 -0
- package/dist/vue.js +47 -7
- package/package.json +64 -3
package/README.md
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
<a href="https://www.morphicons.com"><strong>morphicons.com</strong></a> — live playground
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://github.com/sponsors/guillermolg00">
|
|
18
|
+
<img src="https://img.shields.io/badge/%E2%98%95%20Buy%20me%20a%20coffee-EA4AAA" alt="Buy me a coffee — sponsor morphicons on GitHub Sponsors">
|
|
19
|
+
</a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
Universal morphing for stroke-based icons (Lucide, Tab
|
|
23
|
+
ler, Heroicons, Iconoir, or your own paths): **any icon morphs into any other** with spring physics. Rotations are never declared by hand — they emerge from the math (2D Procrustes + polar interpolation). Zero runtime dependencies.
|
|
17
24
|
|
|
18
25
|
```tsx
|
|
19
26
|
import { MorphIcon } from "morphicons/react";
|
|
@@ -36,7 +43,7 @@ The usual icon morphs either interpolate raw coordinates (shapes shrink and shea
|
|
|
36
43
|
bun add morphicons # or npm install / pnpm add
|
|
37
44
|
```
|
|
38
45
|
|
|
39
|
-
ESM only. `react` (>= 18)
|
|
46
|
+
ESM only. `react` (>= 18), `vue` (>= 3.3), `svelte` (>= 5) and `react-native` (>= 0.71) + `react-native-svg` (>= 14) are optional peers — only needed for `morphicons/react`, `morphicons/vue`, `morphicons/svelte` and `morphicons/react-native`.
|
|
40
47
|
|
|
41
48
|
## Usage
|
|
42
49
|
|
|
@@ -88,6 +95,60 @@ const morph = ref<MorphHandle | null>(null);
|
|
|
88
95
|
|
|
89
96
|
Same surface as the React binding: presentation props (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`; `class`, `style` and the rest of the `<svg>` attrs fall through), the same accessibility defaults (`aria-hidden` unless you pass `label`) and the same clean SSR — works with Nuxt out of the box: the server emits the exact static SVG and the runtime is born on hydration. The binding is a plain render function; no SFC compiler or JSX involved.
|
|
90
97
|
|
|
98
|
+
### Svelte — same three modes
|
|
99
|
+
|
|
100
|
+
```svelte
|
|
101
|
+
<script lang="ts">
|
|
102
|
+
import { MorphIcon, type MorphHandle } from "morphicons/svelte";
|
|
103
|
+
import { Menu, X, Check } from "lucide"; // data, not components
|
|
104
|
+
|
|
105
|
+
let open = $state(false);
|
|
106
|
+
let morph = $state<MorphHandle>();
|
|
107
|
+
// morph?.morphTo(Check) → animates
|
|
108
|
+
// morph?.set(X) → jumps without animating
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<!-- 1. Uncontrolled (90% of uses): change the prop and morphicons animates -->
|
|
112
|
+
<MorphIcon icon={open ? X : Menu} spring="snappy" />
|
|
113
|
+
|
|
114
|
+
<!-- 2. Controlled (gestures, scroll): explicit progress, no spring -->
|
|
115
|
+
<MorphIcon from={Menu} to={X} progress={dragProgress} />
|
|
116
|
+
|
|
117
|
+
<!-- 3. Imperative (sequences): bind:this → morphTo / set -->
|
|
118
|
+
<MorphIcon bind:this={morph} icon={Menu} />
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Same surface again: presentation props, `class`/`style`/rest-attr fall-through — fully typed via `svelte/elements`, so SVG attrs, events and ARIA autocomplete and typos fail the build, like lucide-svelte — the same accessibility defaults and the same clean SSR — works with SvelteKit out of the box. Svelte 5 (runes); the component ships as `.svelte` source and your bundler compiles it via the `svelte` export condition, like every Svelte library.
|
|
122
|
+
|
|
123
|
+
### React Native — same three modes
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { MorphIcon, type MorphHandle } from "morphicons/react-native";
|
|
127
|
+
import { Menu, X, Check } from "lucide"; // data, not components
|
|
128
|
+
|
|
129
|
+
// 1. Uncontrolled (90% of uses): change the prop and morphicons animates
|
|
130
|
+
<MorphIcon icon={open ? X : Menu} spring="snappy" />
|
|
131
|
+
|
|
132
|
+
// 2. Controlled (gestures, scroll): explicit progress, no spring
|
|
133
|
+
<MorphIcon from={Menu} to={X} progress={dragProgress} />
|
|
134
|
+
|
|
135
|
+
// 3. Imperative (sequences)
|
|
136
|
+
const ref = useRef<MorphHandle>(null);
|
|
137
|
+
<MorphIcon ref={ref} icon={Menu} />
|
|
138
|
+
ref.current?.morphTo(Check); // animates
|
|
139
|
+
ref.current?.set(X); // jumps without animating
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The DOM-free core beyond the browser: the dom driver is reused verbatim as the engine (React Native has a global `requestAnimationFrame`, and `PathEl` is structural), so the whole platform difference is a shim that forwards the per-frame `d` write to `Path.setNativeProps` of react-native-svg — outside the React render, exactly like the web mutation. Same surface as the React binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, plus the native `Svg` props: `testID`, touch handlers…), same accessibility defaults (`aria-hidden` unless you pass `label` → `role="img"` + `aria-label`). Reduced motion comes from `AccessibilityInfo` (best-effort: the query is async; a `reduceMotionChanged` subscription keeps it exact from then on). Requires Metro with package `exports` resolution — default since React Native 0.79; on older versions enable `unstable_enablePackageExports`.
|
|
143
|
+
|
|
144
|
+
### Lifecycle contract (all four bindings)
|
|
145
|
+
|
|
146
|
+
The four components share one contract, pinned by mirrored client-mount tests:
|
|
147
|
+
|
|
148
|
+
- **Lazy driver.** Mounting without any icon is fine — SSR emits `<path d="">` and the driver is born with the FIRST icon that shows up, whether a late `icon` prop (data that loads async), a late `from`/`to` pair, or an imperative `set`/`morphTo`. The first icon paints without animating; `morphTo` before the driver exists behaves as `set` (there is nothing to fly from).
|
|
149
|
+
- **Controlled wins.** While `from` AND `to` are both present, the pair owns the path: `icon` changes are ignored, no spring fires. Drop the pair and the current `icon` takes over (animated). Mixing the modes is not an error — the precedence is just explicit.
|
|
150
|
+
- **Clean re-entry.** Any exit from controlled mode (an imperative call or an icon takeover) invalidates the frozen pair, so returning to the same `from`/`to` re-bases on `from` and renders exactly like a clean mount at that `progress`.
|
|
151
|
+
|
|
91
152
|
### Vanilla (no React)
|
|
92
153
|
|
|
93
154
|
```ts
|
|
@@ -131,7 +192,7 @@ morphicons has no per-library adapters — any icon set that meets these require
|
|
|
131
192
|
const bell = fitIcon(teenySmallBell, 15); // also "0 0 15 15" or [0, 0, 15, 15]
|
|
132
193
|
```
|
|
133
194
|
|
|
134
|
-
It returns a plain `d`, so it goes anywhere an icon is accepted. Call it at module scope, not per render. Skipping it doesn't throw — Procrustes is similarity-invariant, so the grid mismatch never reads as false rotation; it lands in σ as an unwanted zoom, and the target draws outside the canvas. The React and
|
|
195
|
+
It returns a plain `d`, so it goes anywhere an icon is accepted. Call it at module scope, not per render. Skipping it doesn't throw — Procrustes is similarity-invariant, so the grid mismatch never reads as false rotation; it lands in σ as an unwanted zoom, and the target draws outside the canvas. The React, Vue and Svelte bindings default to `viewBox="0 0 24 24"`, overridable via props.
|
|
135
196
|
4. **Uniform stroke.** Cosmetic rather than structural: a consistent stroke width and round caps/joins make in-flight shapes look native. These live on the `<svg>`, not in morphicons.
|
|
136
197
|
|
|
137
198
|
Beyond the libraries in the playground, sets known to qualify on the 24×24 grid — no fitting needed: **Heroicons** (outline style), **Iconoir** (1.5px stroke), **Akar Icons**, **Untitled UI** and **Hugeicons** (stroke style). Off-grid packs work through `fitIcon`: **Teenyicons** (15×15), **Carbon** (32×32), **Heroicons solid** (20×20).
|
|
@@ -142,7 +203,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
|
|
|
142
203
|
|
|
143
204
|
```
|
|
144
205
|
┌─────────────────────────────────────────────┐
|
|
145
|
-
│ bindings react · vue
|
|
206
|
+
│ bindings react · vue · svelte · rn │ thin: ref + createMorph
|
|
146
207
|
├─────────────────────────────────────────────┤
|
|
147
208
|
│ drivers dom (setAttribute + rAF) │ singleton scheduler
|
|
148
209
|
├─────────────────────────────────────────────┤
|
|
@@ -153,7 +214,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
|
|
|
153
214
|
└─────────────────────────────────────────────┘
|
|
154
215
|
```
|
|
155
216
|
|
|
156
|
-
The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence:
|
|
217
|
+
The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: the React Native binding over `react-native-svg` is just another adapter, not a rewrite — it reuses the dom driver verbatim through a `setNativeProps` shim. One package with subpath exports (`.` core, `./dom`, `./react`, `./react-native`, `./vue`, `./svelte`), ESM only, `sideEffects: false`.
|
|
157
218
|
|
|
158
219
|
The plan is the central artifact:
|
|
159
220
|
|
|
@@ -258,7 +319,7 @@ c_k(t) = c_{A,k} + t·d_k + (σ*ᵗ·R(t·θ*) − I)·(c_{A,k} − g_A)
|
|
|
258
319
|
|
|
259
320
|
### 6. Serialization and canonical snap
|
|
260
321
|
|
|
261
|
-
In flight, each subpath is emitted as `M x y L x y …` (+ `Z` if the pair is closed↔closed; closed→open flies open, the loop opening at the cut chosen by the circular correspondence) with 2 decimals — invisible at 24px, and the frame's only allocation. On settle, the driver **snaps to the canonical `d`** of the target icon: exact fidelity at rest (real curves, not polylines), subpath count reset after duplications, and the DOM ends up identical to a static icon's. The jump is < 0.02px — imperceptible.
|
|
322
|
+
In flight, each subpath is emitted as `M x y L x y …` (+ `Z` if the pair is closed↔closed; closed→open flies open, the loop opening at the cut chosen by the circular correspondence) with 2 decimals — invisible at 24px, and the frame's only allocation. On settle, the driver **snaps to the canonical `d`** of the target icon: exact fidelity at rest (real curves, not polylines), subpath count reset after duplications, and the DOM ends up identical to a static icon's. The jump is < 0.02px — imperceptible. The canonical `d` itself is emitted with 4 decimals, and that is a correctness choice, not a size one: arc→cubic conversion goes through trig whose last ulp differs across JS engines, and SSR hydration compares the server's bytes against the browser's — quantized emission keeps them identical, where full precision leaked each engine's ulp into the markup (a Next hydration mismatch on icons with non-cardinal arcs).
|
|
262
323
|
|
|
263
324
|
### 7. The spring
|
|
264
325
|
|
|
@@ -293,8 +354,11 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
|
|
|
293
354
|
|---|---|---|
|
|
294
355
|
| `morphicons` (core) | 6.60 KB | 7 KB |
|
|
295
356
|
| `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
|
|
296
|
-
| `morphicons/react` (all, react external) | 7.
|
|
297
|
-
| `morphicons/
|
|
357
|
+
| `morphicons/react` (all, react external) | 7.89 KB | 8.5 KB |
|
|
358
|
+
| `morphicons/react-native` (all, react/rn/rnsvg external) | 8.25 KB | 9 KB |
|
|
359
|
+
| `morphicons/vue` (all, vue external) | 7.93 KB | 8.5 KB |
|
|
360
|
+
| `morphicons/svelte` TS half (all, svelte external) | 7.54 KB | 8 KB |
|
|
361
|
+
| `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.25 KB | 1.4 KB |
|
|
298
362
|
|
|
299
363
|
## Playground
|
|
300
364
|
|
|
@@ -307,27 +371,15 @@ bun run play # → http://localhost:3000
|
|
|
307
371
|
## Development
|
|
308
372
|
|
|
309
373
|
```bash
|
|
310
|
-
bun test #
|
|
311
|
-
bun run typecheck # strict ×
|
|
374
|
+
bun test # 156 tests / ~13,700 asserts
|
|
375
|
+
bun run typecheck # strict ×6: core+dom without lib DOM, playground, react, react-native, vue, svelte
|
|
312
376
|
bun run format # biome
|
|
313
|
-
bun run build # tsdown → dist/
|
|
377
|
+
bun run build # tsdown → dist/ + scripts/build-svelte.ts (svelte entry)
|
|
314
378
|
bun run size # size gates
|
|
315
379
|
```
|
|
316
380
|
|
|
317
381
|
Icons shown in the playground belong to their authors: [Lucide](https://lucide.dev) (ISC), [Feather](https://feathericons.com) (MIT), [Tabler](https://tabler.io/icons) (MIT).
|
|
318
382
|
|
|
319
|
-
## Star history
|
|
320
|
-
|
|
321
|
-
<p align="center">
|
|
322
|
-
<a href="https://star-history.com/#guillermolg00/morphicons&Date">
|
|
323
|
-
<picture>
|
|
324
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date&theme=dark">
|
|
325
|
-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date">
|
|
326
|
-
<img alt="Star history chart of morphicons" src="https://api.star-history.com/svg?repos=guillermolg00/morphicons&type=Date" width="640">
|
|
327
|
-
</picture>
|
|
328
|
-
</a>
|
|
329
|
-
</p>
|
|
330
|
-
|
|
331
383
|
---
|
|
332
384
|
|
|
333
385
|
MIT © [Guillermo](https://guillermolg.com) — made by [guillermolg.com](https://guillermolg.com)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<!-- Svelte 5 binding: MorphIcon with the three modes — uncontrolled (change
|
|
2
|
+
the `icon` prop and morphicons animates), controlled (`from`/`to`/`progress`,
|
|
3
|
+
no spring) and imperative (`bind:this` → { morphTo, set }).
|
|
4
|
+
|
|
5
|
+
Clean SSR by construction: the initial `d` is computed ONCE with the pure
|
|
6
|
+
core at init (server and client produce the same string → hydration without
|
|
7
|
+
mismatch) and stays a plain const, so Svelte never rewrites the attribute —
|
|
8
|
+
the morph mutates it externally via createMorph. Thin shell on purpose: all
|
|
9
|
+
logic lives in shared.ts (typechecked); this file is only compiled.
|
|
10
|
+
Drop-in with lucide-svelte: same presentation props. -->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { onMount, untrack } from "svelte";
|
|
13
|
+
import {
|
|
14
|
+
computeInitialD,
|
|
15
|
+
createController,
|
|
16
|
+
type IconInput,
|
|
17
|
+
type MorphIconProps,
|
|
18
|
+
type MorphOptions,
|
|
19
|
+
type SpringPreset,
|
|
20
|
+
} from "./svelte-shared.js";
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
icon,
|
|
24
|
+
from,
|
|
25
|
+
to,
|
|
26
|
+
progress,
|
|
27
|
+
spring,
|
|
28
|
+
size = 24,
|
|
29
|
+
color = "currentColor",
|
|
30
|
+
strokeWidth = 2,
|
|
31
|
+
absoluteStrokeWidth = false,
|
|
32
|
+
label,
|
|
33
|
+
...rest
|
|
34
|
+
}: MorphIconProps = $props();
|
|
35
|
+
|
|
36
|
+
// svelte-ignore state_referenced_locally — mount-time value on purpose: the
|
|
37
|
+
// initial d must stay a constant so Svelte never rewrites the attribute.
|
|
38
|
+
const initialD = computeInitialD({ icon, from, to, progress });
|
|
39
|
+
|
|
40
|
+
let pathEl: SVGPathElement | undefined = $state();
|
|
41
|
+
// svelte-ignore state_referenced_locally — init-time values on purpose: they
|
|
42
|
+
// seed the controller's watch baselines so the first $effect run is a no-op.
|
|
43
|
+
const ctrl = createController({ icon, from, to, progress });
|
|
44
|
+
|
|
45
|
+
onMount(() => {
|
|
46
|
+
if (pathEl) ctrl.mount(pathEl, { icon, from, to, progress });
|
|
47
|
+
return () => ctrl.destroy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Prop changes: one watcher owns the mode logic (controlled wins — change
|
|
51
|
+
// detection and precedence live in shared.ts).
|
|
52
|
+
$effect(() => {
|
|
53
|
+
const next = { icon, from, to, progress, spring };
|
|
54
|
+
untrack(() => ctrl.watch(next));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export function morphTo(i: IconInput, s?: SpringPreset | MorphOptions): void {
|
|
58
|
+
ctrl.morphTo(i, s ?? spring);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function set(i: IconInput): void {
|
|
62
|
+
ctrl.set(i);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sw = $derived(
|
|
66
|
+
absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
|
|
67
|
+
);
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<svg
|
|
71
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
72
|
+
width={size}
|
|
73
|
+
height={size}
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke={color}
|
|
77
|
+
stroke-width={sw}
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
role={label ? "img" : undefined}
|
|
81
|
+
aria-hidden={label ? undefined : "true"}
|
|
82
|
+
{...rest}
|
|
83
|
+
>
|
|
84
|
+
{#if label}<title>{label}</title>{/if}
|
|
85
|
+
<path bind:this={pathEl} d={initialD} />
|
|
86
|
+
</svg>
|
package/dist/dom.d.ts
CHANGED
|
@@ -27,8 +27,9 @@ interface Morph {
|
|
|
27
27
|
destroy(): void;
|
|
28
28
|
}
|
|
29
29
|
/** Canonical `d` of an icon: the input string verbatim, or the real cubics
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
|
|
31
|
+
* hydration matches, see fmtCanon in core/serialize). Exported because it
|
|
32
|
+
* is what a binding renders at SSR/rest before any runtime exists. */
|
|
32
33
|
declare function canonicalD(icon: IconInput): string;
|
|
33
34
|
/** Creates the morph instance over a `<path>` and paints the initial icon. */
|
|
34
35
|
declare function createMorph(el: PathEl, icon: IconInput): Morph;
|
package/dist/dom.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as buildPlan, c as cubicsToPathD, f as interpPolar, l as serialize, n as Spring, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-
|
|
1
|
+
import { a as buildPlan, c as cubicsToPathD, f as interpPolar, l as serialize, n as Spring, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
2
|
//#region src/dom/index.ts
|
|
3
3
|
const tickers = /* @__PURE__ */ new Set();
|
|
4
4
|
let rafId = 0;
|
|
@@ -41,8 +41,9 @@ function sampledOf(icon) {
|
|
|
41
41
|
return s;
|
|
42
42
|
}
|
|
43
43
|
/** Canonical `d` of an icon: the input string verbatim, or the real cubics
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
|
|
45
|
+
* hydration matches, see fmtCanon in core/serialize). Exported because it
|
|
46
|
+
* is what a binding renders at SSR/rest before any runtime exists. */
|
|
46
47
|
function canonicalD(icon) {
|
|
47
48
|
if (typeof icon === "string") return icon;
|
|
48
49
|
let d = canon.get(icon);
|
package/dist/index.d.ts
CHANGED
|
@@ -72,7 +72,8 @@ declare function resampleIcon(input: IconInput, N?: number): Sampled[];
|
|
|
72
72
|
/** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
|
|
73
73
|
* subpath k (closed loops in flight); without flags everything is open. */
|
|
74
74
|
declare function serialize(subs: readonly Float64Array[], closed?: readonly boolean[]): string;
|
|
75
|
-
/** Cubic subpaths → canonical `d
|
|
75
|
+
/** Cubic subpaths → canonical `d`, quantized to 4 decimals (engine-stable
|
|
76
|
+
* bytes; see fmtCanon). */
|
|
76
77
|
declare function cubicsToPathD(paths: readonly CubicPath[]): string;
|
|
77
78
|
//#endregion
|
|
78
79
|
export { type CubicPath, type IconInput, type IconNode, type IconNodeAttrs, type MorphPlan, type PlanItem, SPRING_PRESETS, type Sampled, Spring, type SpringPreset, type ViewBox, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as buildPlan, c as cubicsToPathD, d as interpLinear, f as interpPolar, i as resamplePath, l as serialize, n as Spring, o as fitIcon, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-
|
|
1
|
+
import { a as buildPlan, c as cubicsToPathD, d as interpLinear, f as interpPolar, i as resamplePath, l as serialize, n as Spring, o as fitIcon, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
2
|
export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
|
|
2
|
+
import { Morph, MorphOptions, PathEl } from "./dom.js";
|
|
3
|
+
import { SvgProps } from "react-native-svg";
|
|
4
|
+
//#region src/react-native/index.d.ts
|
|
5
|
+
/** Imperative surface exposed via ref. */
|
|
6
|
+
interface MorphHandle {
|
|
7
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
8
|
+
set(icon: IconInput): void;
|
|
9
|
+
}
|
|
10
|
+
interface MorphIconProps extends Omit<SvgProps, "from" | "to"> {
|
|
11
|
+
/** Uncontrolled mode: the current icon; changing the prop animates. */
|
|
12
|
+
icon?: IconInput;
|
|
13
|
+
/** Controlled mode: source endpoint of the pair. */
|
|
14
|
+
from?: IconInput;
|
|
15
|
+
/** Controlled mode: target endpoint of the pair. */
|
|
16
|
+
to?: IconInput;
|
|
17
|
+
/** Controlled mode: 0..1 progress of the frozen morph (no spring). */
|
|
18
|
+
progress?: number;
|
|
19
|
+
/** Physics for uncontrolled/imperative mode: preset or custom spring. */
|
|
20
|
+
spring?: SpringPreset | MorphOptions;
|
|
21
|
+
size?: number | string;
|
|
22
|
+
color?: string;
|
|
23
|
+
strokeWidth?: number | string;
|
|
24
|
+
/** Like lucide: stroke width does not scale with `size`. */
|
|
25
|
+
absoluteStrokeWidth?: boolean;
|
|
26
|
+
/** Accessibility: with label → role="img" + aria-label; without → aria-hidden. */
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
declare const MorphIcon: import("react").ForwardRefExoticComponent<MorphIconProps & import("react").RefAttributes<MorphHandle>>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIcon, MorphIconProps, type MorphOptions, type PathEl, type Sampled, type SpringPreset };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
|
+
import { canonicalD, createMorph } from "./dom.js";
|
|
3
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import { AccessibilityInfo } from "react-native";
|
|
6
|
+
import Svg, { Path } from "react-native-svg";
|
|
7
|
+
//#region src/react-native/index.tsx
|
|
8
|
+
const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
9
|
+
let reduced = false;
|
|
10
|
+
let rmInit = false;
|
|
11
|
+
function initReducedMotion() {
|
|
12
|
+
if (rmInit) return;
|
|
13
|
+
rmInit = true;
|
|
14
|
+
try {
|
|
15
|
+
AccessibilityInfo.isReduceMotionEnabled().then((value) => {
|
|
16
|
+
reduced = value;
|
|
17
|
+
}, () => {});
|
|
18
|
+
AccessibilityInfo.addEventListener("reduceMotionChanged", (value) => {
|
|
19
|
+
reduced = value;
|
|
20
|
+
});
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
/** Frozen shape of the from→to pair at t, using the pure core. At exact
|
|
24
|
+
* endpoints returns the canonical `d` (real curves, not polyline). */
|
|
25
|
+
function frozenD(from, to, t) {
|
|
26
|
+
if (t <= 0) return canonicalD(from);
|
|
27
|
+
if (t >= 1) return canonicalD(to);
|
|
28
|
+
const plan = buildPlan(resampleIcon(from), resampleIcon(to));
|
|
29
|
+
const out = allocOutputs(plan);
|
|
30
|
+
interpPolar(plan, t, out);
|
|
31
|
+
return serialize(out, plan.items.map((it) => it.closed));
|
|
32
|
+
}
|
|
33
|
+
const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
34
|
+
const { icon, from, to, progress, spring, size = 24, color = "currentColor", strokeWidth = 2, absoluteStrokeWidth, label, ...rest } = props;
|
|
35
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
36
|
+
const initialIcon = icon ?? from ?? to;
|
|
37
|
+
const [initialD] = useState(() => {
|
|
38
|
+
if (controlled) return frozenD(from, to, progress ?? 0);
|
|
39
|
+
return initialIcon !== void 0 ? canonicalD(initialIcon) : "";
|
|
40
|
+
});
|
|
41
|
+
const pathRef = useRef(null);
|
|
42
|
+
const morphRef = useRef(null);
|
|
43
|
+
const springRef = useRef(spring);
|
|
44
|
+
springRef.current = spring;
|
|
45
|
+
const prevIcon = useRef(icon);
|
|
46
|
+
const prevControlled = useRef(controlled);
|
|
47
|
+
const dead = useRef(false);
|
|
48
|
+
const based = useRef(false);
|
|
49
|
+
const pair = useRef(null);
|
|
50
|
+
/** The PathEl shim: createMorph only ever writes `d`, and Path forwards
|
|
51
|
+
* it to the native view without re-rendering. Stable identity. */
|
|
52
|
+
const el = useRef({ setAttribute: (name, value) => {
|
|
53
|
+
if (name === "d") pathRef.current?.setNativeProps({ d: value });
|
|
54
|
+
} });
|
|
55
|
+
/** morphTo that honors the OS reduce-motion setting (best-effort). */
|
|
56
|
+
const fly = (m, i, s) => {
|
|
57
|
+
if (reduced) m.set(i);
|
|
58
|
+
else m.morphTo(i, s);
|
|
59
|
+
};
|
|
60
|
+
const flyRef = useRef(fly);
|
|
61
|
+
flyRef.current = fly;
|
|
62
|
+
/** Driver birth, lazy included (#1 of the lifecycle contract): an
|
|
63
|
+
* iconless mount keeps the element and the FIRST icon to show up (prop
|
|
64
|
+
* or imperative) creates the driver, already showing it — no flight.
|
|
65
|
+
* Stable across renders: closes over refs only. */
|
|
66
|
+
const ensure = useCallback((birth) => {
|
|
67
|
+
if (morphRef.current) return morphRef.current;
|
|
68
|
+
if (dead.current || !pathRef.current) return null;
|
|
69
|
+
morphRef.current = createMorph(el.current, birth);
|
|
70
|
+
return morphRef.current;
|
|
71
|
+
}, []);
|
|
72
|
+
useIsoLayoutEffect(() => {
|
|
73
|
+
dead.current = false;
|
|
74
|
+
initReducedMotion();
|
|
75
|
+
if (pathRef.current && initialIcon !== void 0) {
|
|
76
|
+
const m = createMorph(el.current, controlled ? from : initialIcon);
|
|
77
|
+
morphRef.current = m;
|
|
78
|
+
if (controlled) {
|
|
79
|
+
pair.current = [from, to];
|
|
80
|
+
const t = progress ?? 0;
|
|
81
|
+
if (t <= 0) m.set(from);
|
|
82
|
+
else if (t >= 1) m.set(to);
|
|
83
|
+
else {
|
|
84
|
+
m.seek(to, t);
|
|
85
|
+
based.current = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return () => {
|
|
90
|
+
dead.current = true;
|
|
91
|
+
morphRef.current?.destroy();
|
|
92
|
+
morphRef.current = null;
|
|
93
|
+
based.current = false;
|
|
94
|
+
pair.current = null;
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const left = prevControlled.current && !controlled;
|
|
99
|
+
prevControlled.current = controlled;
|
|
100
|
+
const changed = icon !== prevIcon.current;
|
|
101
|
+
prevIcon.current = icon;
|
|
102
|
+
if (controlled) return;
|
|
103
|
+
if (icon === void 0 || !changed && !left) return;
|
|
104
|
+
pair.current = null;
|
|
105
|
+
based.current = false;
|
|
106
|
+
const m = morphRef.current;
|
|
107
|
+
if (m) flyRef.current(m, icon, springRef.current);
|
|
108
|
+
else ensure(icon);
|
|
109
|
+
}, [
|
|
110
|
+
icon,
|
|
111
|
+
controlled,
|
|
112
|
+
ensure
|
|
113
|
+
]);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!controlled) return;
|
|
116
|
+
const m = morphRef.current ?? ensure(from);
|
|
117
|
+
if (!m) return;
|
|
118
|
+
const t = progress ?? 0;
|
|
119
|
+
if (!pair.current || pair.current[0] !== from || pair.current[1] !== to) {
|
|
120
|
+
pair.current = [from, to];
|
|
121
|
+
based.current = false;
|
|
122
|
+
}
|
|
123
|
+
if (t <= 0) {
|
|
124
|
+
m.set(from);
|
|
125
|
+
based.current = false;
|
|
126
|
+
} else if (t >= 1) {
|
|
127
|
+
m.set(to);
|
|
128
|
+
based.current = false;
|
|
129
|
+
} else {
|
|
130
|
+
if (!based.current) {
|
|
131
|
+
m.set(from);
|
|
132
|
+
based.current = true;
|
|
133
|
+
}
|
|
134
|
+
m.seek(to, t);
|
|
135
|
+
}
|
|
136
|
+
}, [
|
|
137
|
+
controlled,
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
progress,
|
|
141
|
+
ensure
|
|
142
|
+
]);
|
|
143
|
+
useImperativeHandle(ref, () => ({
|
|
144
|
+
morphTo: (i, s) => {
|
|
145
|
+
pair.current = null;
|
|
146
|
+
based.current = false;
|
|
147
|
+
const m = morphRef.current;
|
|
148
|
+
if (m) flyRef.current(m, i, s ?? springRef.current);
|
|
149
|
+
else ensure(i);
|
|
150
|
+
},
|
|
151
|
+
set: (i) => {
|
|
152
|
+
pair.current = null;
|
|
153
|
+
based.current = false;
|
|
154
|
+
const m = morphRef.current;
|
|
155
|
+
if (m) m.set(i);
|
|
156
|
+
else ensure(i);
|
|
157
|
+
}
|
|
158
|
+
}), [ensure]);
|
|
159
|
+
const sw = absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth;
|
|
160
|
+
return /* @__PURE__ */ jsx(Svg, {
|
|
161
|
+
width: size,
|
|
162
|
+
height: size,
|
|
163
|
+
viewBox: "0 0 24 24",
|
|
164
|
+
fill: "none",
|
|
165
|
+
stroke: color,
|
|
166
|
+
strokeWidth: sw,
|
|
167
|
+
strokeLinecap: "round",
|
|
168
|
+
strokeLinejoin: "round",
|
|
169
|
+
role: label ? "img" : void 0,
|
|
170
|
+
"aria-label": label,
|
|
171
|
+
"aria-hidden": label ? void 0 : true,
|
|
172
|
+
...rest,
|
|
173
|
+
children: /* @__PURE__ */ jsx(Path, {
|
|
174
|
+
ref: pathRef,
|
|
175
|
+
d: initialD
|
|
176
|
+
})
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
//#endregion
|
|
180
|
+
export { MorphIcon };
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-
|
|
1
|
+
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
2
|
import { canonicalD, createMorph } from "./dom.js";
|
|
3
|
-
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
//#region src/react/index.tsx
|
|
6
6
|
const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
@@ -26,42 +26,68 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
26
26
|
const morphRef = useRef(null);
|
|
27
27
|
const springRef = useRef(spring);
|
|
28
28
|
springRef.current = spring;
|
|
29
|
-
const
|
|
29
|
+
const prevIcon = useRef(icon);
|
|
30
|
+
const prevControlled = useRef(controlled);
|
|
31
|
+
const dead = useRef(false);
|
|
30
32
|
const based = useRef(false);
|
|
31
33
|
const pair = useRef(null);
|
|
34
|
+
/** Driver birth, lazy included (#1 of the lifecycle contract): an
|
|
35
|
+
* iconless mount keeps the element and the FIRST icon to show up (prop
|
|
36
|
+
* or imperative) creates the driver, already showing it — no flight.
|
|
37
|
+
* Stable across renders: closes over refs only. */
|
|
38
|
+
const ensure = useCallback((birth) => {
|
|
39
|
+
if (morphRef.current) return morphRef.current;
|
|
40
|
+
const el = pathRef.current;
|
|
41
|
+
if (dead.current || !el) return null;
|
|
42
|
+
morphRef.current = createMorph(el, birth);
|
|
43
|
+
return morphRef.current;
|
|
44
|
+
}, []);
|
|
32
45
|
useIsoLayoutEffect(() => {
|
|
46
|
+
dead.current = false;
|
|
33
47
|
const el = pathRef.current;
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
if (el && initialIcon !== void 0) {
|
|
49
|
+
const m = createMorph(el, controlled ? from : initialIcon);
|
|
50
|
+
morphRef.current = m;
|
|
51
|
+
if (controlled) {
|
|
52
|
+
pair.current = [from, to];
|
|
53
|
+
const t = progress ?? 0;
|
|
54
|
+
if (t <= 0) m.set(from);
|
|
55
|
+
else if (t >= 1) m.set(to);
|
|
56
|
+
else {
|
|
57
|
+
m.seek(to, t);
|
|
58
|
+
based.current = true;
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
return () => {
|
|
48
|
-
|
|
63
|
+
dead.current = true;
|
|
64
|
+
morphRef.current?.destroy();
|
|
49
65
|
morphRef.current = null;
|
|
50
66
|
based.current = false;
|
|
51
67
|
pair.current = null;
|
|
52
68
|
};
|
|
53
69
|
}, []);
|
|
54
70
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const left = prevControlled.current && !controlled;
|
|
72
|
+
prevControlled.current = controlled;
|
|
73
|
+
const changed = icon !== prevIcon.current;
|
|
74
|
+
prevIcon.current = icon;
|
|
75
|
+
if (controlled) return;
|
|
76
|
+
if (icon === void 0 || !changed && !left) return;
|
|
77
|
+
pair.current = null;
|
|
78
|
+
based.current = false;
|
|
63
79
|
const m = morphRef.current;
|
|
64
|
-
if (
|
|
80
|
+
if (m) m.morphTo(icon, springRef.current);
|
|
81
|
+
else ensure(icon);
|
|
82
|
+
}, [
|
|
83
|
+
icon,
|
|
84
|
+
controlled,
|
|
85
|
+
ensure
|
|
86
|
+
]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!controlled) return;
|
|
89
|
+
const m = morphRef.current ?? ensure(from);
|
|
90
|
+
if (!m) return;
|
|
65
91
|
const t = progress ?? 0;
|
|
66
92
|
if (!pair.current || pair.current[0] !== from || pair.current[1] !== to) {
|
|
67
93
|
pair.current = [from, to];
|
|
@@ -84,12 +110,25 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
84
110
|
controlled,
|
|
85
111
|
from,
|
|
86
112
|
to,
|
|
87
|
-
progress
|
|
113
|
+
progress,
|
|
114
|
+
ensure
|
|
88
115
|
]);
|
|
89
116
|
useImperativeHandle(ref, () => ({
|
|
90
|
-
morphTo: (i, s) =>
|
|
91
|
-
|
|
92
|
-
|
|
117
|
+
morphTo: (i, s) => {
|
|
118
|
+
pair.current = null;
|
|
119
|
+
based.current = false;
|
|
120
|
+
const m = morphRef.current;
|
|
121
|
+
if (m) m.morphTo(i, s ?? springRef.current);
|
|
122
|
+
else ensure(i);
|
|
123
|
+
},
|
|
124
|
+
set: (i) => {
|
|
125
|
+
pair.current = null;
|
|
126
|
+
based.current = false;
|
|
127
|
+
const m = morphRef.current;
|
|
128
|
+
if (m) m.set(i);
|
|
129
|
+
else ensure(i);
|
|
130
|
+
}
|
|
131
|
+
}), [ensure]);
|
|
93
132
|
const sw = absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth;
|
|
94
133
|
return /* @__PURE__ */ jsxs("svg", {
|
|
95
134
|
xmlns: "http://www.w3.org/2000/svg",
|
|
@@ -305,12 +305,16 @@ function serialize(subs, closed) {
|
|
|
305
305
|
}
|
|
306
306
|
return d;
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
function fmtCanon(v) {
|
|
309
|
+
return String(Math.round(v * 1e4) / 1e4);
|
|
310
|
+
}
|
|
311
|
+
/** Cubic subpaths → canonical `d`, quantized to 4 decimals (engine-stable
|
|
312
|
+
* bytes; see fmtCanon). */
|
|
309
313
|
function cubicsToPathD(paths) {
|
|
310
314
|
let d = "";
|
|
311
315
|
for (const { pts, closed } of paths) {
|
|
312
|
-
d += `M${pts[0]} ${pts[1]}`;
|
|
313
|
-
for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
|
|
316
|
+
d += `M${fmtCanon(pts[0])} ${fmtCanon(pts[1])}`;
|
|
317
|
+
for (let i = 2; i < pts.length; i += 6) d += `C${fmtCanon(pts[i])} ${fmtCanon(pts[i + 1])} ${fmtCanon(pts[i + 2])} ${fmtCanon(pts[i + 3])} ${fmtCanon(pts[i + 4])} ${fmtCanon(pts[i + 5])}`;
|
|
314
318
|
if (closed) d += "Z";
|
|
315
319
|
}
|
|
316
320
|
return d;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
|
|
2
|
+
import { Morph, MorphOptions, PathEl } from "./dom.js";
|
|
3
|
+
import { SVGAttributes } from "svelte/elements";
|
|
4
|
+
//#region src/svelte/shared.d.ts
|
|
5
|
+
/** Imperative surface exposed on the component instance (`bind:this`). */
|
|
6
|
+
interface MorphHandle {
|
|
7
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
8
|
+
set(icon: IconInput): void;
|
|
9
|
+
}
|
|
10
|
+
/** Rest props fall through to the `<svg>` fully typed (class, style, data-*,
|
|
11
|
+
* events, ARIA…) via svelte/elements, like lucide-svelte — typos in an SVG
|
|
12
|
+
* attr fail the build. `from`/`to` are omitted because the SMIL animation
|
|
13
|
+
* attributes of the same name collide with the controlled-mode pair. */
|
|
14
|
+
interface MorphIconProps extends Omit<SVGAttributes<SVGSVGElement>, "from" | "to"> {
|
|
15
|
+
/** Uncontrolled mode: the current icon; changing the prop animates. */
|
|
16
|
+
icon?: IconInput;
|
|
17
|
+
/** Controlled mode: source endpoint of the pair. */
|
|
18
|
+
from?: IconInput;
|
|
19
|
+
/** Controlled mode: target endpoint of the pair. */
|
|
20
|
+
to?: IconInput;
|
|
21
|
+
/** Controlled mode: 0..1 progress of the frozen morph (no spring). */
|
|
22
|
+
progress?: number;
|
|
23
|
+
/** Physics for uncontrolled/imperative mode: preset or custom spring. */
|
|
24
|
+
spring?: SpringPreset | MorphOptions;
|
|
25
|
+
size?: number | string;
|
|
26
|
+
color?: string;
|
|
27
|
+
strokeWidth?: number | string;
|
|
28
|
+
/** Like lucide-svelte: stroke width does not scale with `size`. */
|
|
29
|
+
absoluteStrokeWidth?: boolean;
|
|
30
|
+
/** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
|
|
31
|
+
label?: string;
|
|
32
|
+
}
|
|
33
|
+
type ModeProps = Pick<MorphIconProps, "icon" | "from" | "to" | "progress">;
|
|
34
|
+
type WatchProps = ModeProps & {
|
|
35
|
+
spring?: SpringPreset | MorphOptions;
|
|
36
|
+
};
|
|
37
|
+
/** The initial d is constant for Svelte: computed once from the mount-time
|
|
38
|
+
* props (server and client produce the same string → hydration without
|
|
39
|
+
* mismatch) and from then on only the driver mutates it outside the template. */
|
|
40
|
+
declare function computeInitialD({ icon, from, to, progress }: ModeProps): string;
|
|
41
|
+
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
42
|
+
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
43
|
+
* init-time props: they seed the watch baselines so the first `$effect` run
|
|
44
|
+
* (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
|
|
45
|
+
* change detection lives HERE, under tsc — the .svelte shell only wires
|
|
46
|
+
* runes to these methods.
|
|
47
|
+
*
|
|
48
|
+
* Lifecycle contract (shared verbatim by the three bindings):
|
|
49
|
+
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
50
|
+
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
51
|
+
* driver behaves as `set` — there is nothing to fly from.
|
|
52
|
+
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
53
|
+
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
54
|
+
* path back to `icon`.
|
|
55
|
+
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
56
|
+
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
57
|
+
declare function createController({ icon, from, to, progress }: ModeProps): {
|
|
58
|
+
mount(mountEl: PathEl, { icon, from, to, progress }: ModeProps): void;
|
|
59
|
+
destroy(): void;
|
|
60
|
+
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
61
|
+
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
62
|
+
* init-time baselines). */
|
|
63
|
+
watch({ icon, from, to, progress, spring }: WatchProps): void;
|
|
64
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
65
|
+
set(icon: IconInput): void;
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIconProps, type MorphOptions, type PathEl, type Sampled, type SpringPreset, computeInitialD, createController };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
|
+
import { canonicalD, createMorph } from "./dom.js";
|
|
3
|
+
//#region src/svelte/shared.ts
|
|
4
|
+
/** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
|
|
5
|
+
* At exact endpoints returns the canonical `d` (real curves, not polyline). */
|
|
6
|
+
function frozenD(from, to, t) {
|
|
7
|
+
if (t <= 0) return canonicalD(from);
|
|
8
|
+
if (t >= 1) return canonicalD(to);
|
|
9
|
+
const plan = buildPlan(resampleIcon(from), resampleIcon(to));
|
|
10
|
+
const out = allocOutputs(plan);
|
|
11
|
+
interpPolar(plan, t, out);
|
|
12
|
+
return serialize(out, plan.items.map((it) => it.closed));
|
|
13
|
+
}
|
|
14
|
+
/** The initial d is constant for Svelte: computed once from the mount-time
|
|
15
|
+
* props (server and client produce the same string → hydration without
|
|
16
|
+
* mismatch) and from then on only the driver mutates it outside the template. */
|
|
17
|
+
function computeInitialD({ icon, from, to, progress }) {
|
|
18
|
+
if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
|
|
19
|
+
const first = icon ?? from ?? to;
|
|
20
|
+
return first !== void 0 ? canonicalD(first) : "";
|
|
21
|
+
}
|
|
22
|
+
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
23
|
+
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
24
|
+
* init-time props: they seed the watch baselines so the first `$effect` run
|
|
25
|
+
* (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
|
|
26
|
+
* change detection lives HERE, under tsc — the .svelte shell only wires
|
|
27
|
+
* runes to these methods.
|
|
28
|
+
*
|
|
29
|
+
* Lifecycle contract (shared verbatim by the three bindings):
|
|
30
|
+
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
31
|
+
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
32
|
+
* driver behaves as `set` — there is nothing to fly from.
|
|
33
|
+
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
34
|
+
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
35
|
+
* path back to `icon`.
|
|
36
|
+
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
37
|
+
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
38
|
+
function createController({ icon, from, to, progress }) {
|
|
39
|
+
let el = null;
|
|
40
|
+
let dead = false;
|
|
41
|
+
let morph = null;
|
|
42
|
+
let based = false;
|
|
43
|
+
let pair = null;
|
|
44
|
+
let prevIcon = icon;
|
|
45
|
+
let prevControlled = from !== void 0 && to !== void 0;
|
|
46
|
+
let prevFrom = from;
|
|
47
|
+
let prevTo = to;
|
|
48
|
+
let prevProgress = progress;
|
|
49
|
+
/** Driver birth, lazy included: the first icon to show up creates it. */
|
|
50
|
+
const ensure = (birth) => {
|
|
51
|
+
if (morph) return morph;
|
|
52
|
+
if (dead || !el) return null;
|
|
53
|
+
morph = createMorph(el, birth);
|
|
54
|
+
return morph;
|
|
55
|
+
};
|
|
56
|
+
/** Controlled mode: freeze the pair at `progress` via seek (no spring). */
|
|
57
|
+
const applyPair = (from, to, progress) => {
|
|
58
|
+
const m = morph;
|
|
59
|
+
if (!m) return;
|
|
60
|
+
const t = progress ?? 0;
|
|
61
|
+
if (!pair || pair[0] !== from || pair[1] !== to) {
|
|
62
|
+
pair = [from, to];
|
|
63
|
+
based = false;
|
|
64
|
+
}
|
|
65
|
+
if (t <= 0) {
|
|
66
|
+
m.set(from);
|
|
67
|
+
based = false;
|
|
68
|
+
} else if (t >= 1) {
|
|
69
|
+
m.set(to);
|
|
70
|
+
based = false;
|
|
71
|
+
} else {
|
|
72
|
+
if (!based) {
|
|
73
|
+
m.set(from);
|
|
74
|
+
based = true;
|
|
75
|
+
}
|
|
76
|
+
m.seek(to, t);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
mount(mountEl, { icon, from, to, progress }) {
|
|
81
|
+
el = mountEl;
|
|
82
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
83
|
+
const initialIcon = icon ?? from ?? to;
|
|
84
|
+
if (initialIcon === void 0) return;
|
|
85
|
+
const m = createMorph(el, controlled ? from : initialIcon);
|
|
86
|
+
morph = m;
|
|
87
|
+
if (controlled) {
|
|
88
|
+
pair = [from, to];
|
|
89
|
+
const t = progress ?? 0;
|
|
90
|
+
if (t <= 0) m.set(from);
|
|
91
|
+
else if (t >= 1) m.set(to);
|
|
92
|
+
else {
|
|
93
|
+
m.seek(to, t);
|
|
94
|
+
based = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
destroy() {
|
|
99
|
+
dead = true;
|
|
100
|
+
el = null;
|
|
101
|
+
morph?.destroy();
|
|
102
|
+
morph = null;
|
|
103
|
+
based = false;
|
|
104
|
+
pair = null;
|
|
105
|
+
},
|
|
106
|
+
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
107
|
+
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
108
|
+
* init-time baselines). */
|
|
109
|
+
watch({ icon, from, to, progress, spring }) {
|
|
110
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
111
|
+
const left = prevControlled && !controlled;
|
|
112
|
+
const iconChanged = icon !== prevIcon;
|
|
113
|
+
const pairChanged = from !== prevFrom || to !== prevTo || progress !== prevProgress;
|
|
114
|
+
prevControlled = controlled;
|
|
115
|
+
prevIcon = icon;
|
|
116
|
+
prevFrom = from;
|
|
117
|
+
prevTo = to;
|
|
118
|
+
prevProgress = progress;
|
|
119
|
+
if (controlled) {
|
|
120
|
+
if (!pairChanged) return;
|
|
121
|
+
if (!(morph ?? ensure(from))) return;
|
|
122
|
+
applyPair(from, to, progress);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (icon === void 0 || !iconChanged && !left) return;
|
|
126
|
+
pair = null;
|
|
127
|
+
based = false;
|
|
128
|
+
if (morph) morph.morphTo(icon, spring);
|
|
129
|
+
else ensure(icon);
|
|
130
|
+
},
|
|
131
|
+
morphTo(icon, spring) {
|
|
132
|
+
pair = null;
|
|
133
|
+
based = false;
|
|
134
|
+
if (morph) morph.morphTo(icon, spring);
|
|
135
|
+
else ensure(icon);
|
|
136
|
+
},
|
|
137
|
+
set(icon) {
|
|
138
|
+
pair = null;
|
|
139
|
+
based = false;
|
|
140
|
+
if (morph) morph.set(icon);
|
|
141
|
+
else ensure(icon);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
export { computeInitialD, createController };
|
package/dist/svelte.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
import type { MorphHandle, MorphIconProps } from "./svelte-shared.js";
|
|
3
|
+
export declare const MorphIcon: Component<MorphIconProps, MorphHandle>;
|
|
4
|
+
export type { MorphHandle, MorphIconProps };
|
|
5
|
+
export type {
|
|
6
|
+
IconInput,
|
|
7
|
+
IconNode,
|
|
8
|
+
Morph,
|
|
9
|
+
MorphOptions,
|
|
10
|
+
PathEl,
|
|
11
|
+
Sampled,
|
|
12
|
+
SpringPreset,
|
|
13
|
+
} from "./svelte-shared.js";
|
package/dist/svelte.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as MorphIcon } from "./MorphIcon.svelte";
|
package/dist/vue.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-
|
|
1
|
+
import { a as buildPlan, f as interpPolar, l as serialize, r as resampleIcon, u as allocOutputs } from "./spring-CBynweFS.js";
|
|
2
2
|
import { canonicalD, createMorph } from "./dom.js";
|
|
3
3
|
import { defineComponent, h, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
|
|
4
4
|
//#region src/vue/index.ts
|
|
@@ -72,8 +72,21 @@ const MorphIcon = defineComponent({
|
|
|
72
72
|
})();
|
|
73
73
|
const pathEl = shallowRef(null);
|
|
74
74
|
let morph = null;
|
|
75
|
+
let dead = false;
|
|
75
76
|
let based = false;
|
|
76
77
|
let pair = null;
|
|
78
|
+
let prevIcon = props.icon;
|
|
79
|
+
let prevControlled = props.from !== void 0 && props.to !== void 0;
|
|
80
|
+
/** Driver birth, lazy included (#1 of the lifecycle contract): an
|
|
81
|
+
* iconless mount keeps the element and the FIRST icon to show up (prop
|
|
82
|
+
* or imperative) creates the driver, already showing it — no flight. */
|
|
83
|
+
const ensure = (birth) => {
|
|
84
|
+
if (morph) return morph;
|
|
85
|
+
const el = pathEl.value;
|
|
86
|
+
if (dead || !el) return null;
|
|
87
|
+
morph = createMorph(el, birth);
|
|
88
|
+
return morph;
|
|
89
|
+
};
|
|
77
90
|
onMounted(() => {
|
|
78
91
|
const el = pathEl.value;
|
|
79
92
|
const { icon, from, to, progress } = props;
|
|
@@ -94,22 +107,39 @@ const MorphIcon = defineComponent({
|
|
|
94
107
|
}
|
|
95
108
|
});
|
|
96
109
|
onBeforeUnmount(() => {
|
|
110
|
+
dead = true;
|
|
97
111
|
morph?.destroy();
|
|
98
112
|
morph = null;
|
|
99
113
|
based = false;
|
|
100
114
|
pair = null;
|
|
101
115
|
});
|
|
102
|
-
watch(() =>
|
|
103
|
-
|
|
116
|
+
watch(() => [
|
|
117
|
+
props.icon,
|
|
118
|
+
props.from,
|
|
119
|
+
props.to
|
|
120
|
+
], () => {
|
|
121
|
+
const { icon, from, to } = props;
|
|
122
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
123
|
+
const left = prevControlled && !controlled;
|
|
124
|
+
prevControlled = controlled;
|
|
125
|
+
const changed = icon !== prevIcon;
|
|
126
|
+
prevIcon = icon;
|
|
127
|
+
if (controlled) return;
|
|
128
|
+
if (icon === void 0 || !changed && !left) return;
|
|
129
|
+
pair = null;
|
|
130
|
+
based = false;
|
|
131
|
+
if (morph) morph.morphTo(icon, props.spring);
|
|
132
|
+
else ensure(icon);
|
|
104
133
|
});
|
|
105
134
|
watch(() => [
|
|
106
135
|
props.from,
|
|
107
136
|
props.to,
|
|
108
137
|
props.progress
|
|
109
138
|
], () => {
|
|
110
|
-
const m = morph;
|
|
111
139
|
const { from, to, progress } = props;
|
|
112
|
-
if (
|
|
140
|
+
if (from === void 0 || to === void 0) return;
|
|
141
|
+
const m = morph ?? ensure(from);
|
|
142
|
+
if (!m) return;
|
|
113
143
|
const t = progress ?? 0;
|
|
114
144
|
if (!pair || pair[0] !== from || pair[1] !== to) {
|
|
115
145
|
pair = [from, to];
|
|
@@ -130,8 +160,18 @@ const MorphIcon = defineComponent({
|
|
|
130
160
|
}
|
|
131
161
|
});
|
|
132
162
|
expose({
|
|
133
|
-
morphTo: (i, s) =>
|
|
134
|
-
|
|
163
|
+
morphTo: (i, s) => {
|
|
164
|
+
pair = null;
|
|
165
|
+
based = false;
|
|
166
|
+
if (morph) morph.morphTo(i, s ?? props.spring);
|
|
167
|
+
else ensure(i);
|
|
168
|
+
},
|
|
169
|
+
set: (i) => {
|
|
170
|
+
pair = null;
|
|
171
|
+
based = false;
|
|
172
|
+
if (morph) morph.set(i);
|
|
173
|
+
else ensure(i);
|
|
174
|
+
}
|
|
135
175
|
});
|
|
136
176
|
return () => {
|
|
137
177
|
const sw = props.absoluteStrokeWidth ? Number(props.strokeWidth) * 24 / Number(props.size) : props.strokeWidth;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morphicons",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Universal morphing for stroke-based icons with spring physics — zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Guillermo (https://guillermolg.com)",
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
"shadcn",
|
|
30
30
|
"feather",
|
|
31
31
|
"react",
|
|
32
|
+
"react-native",
|
|
33
|
+
"lucide-react-native",
|
|
32
34
|
"vue",
|
|
33
35
|
"lucide-vue-next",
|
|
36
|
+
"svelte",
|
|
37
|
+
"lucide-svelte",
|
|
34
38
|
"icon-animation"
|
|
35
39
|
],
|
|
36
40
|
"type": "module",
|
|
@@ -51,19 +55,40 @@
|
|
|
51
55
|
"types": "./dist/react.d.ts",
|
|
52
56
|
"default": "./dist/react.js"
|
|
53
57
|
},
|
|
58
|
+
"./react-native": {
|
|
59
|
+
"types": "./dist/react-native.d.ts",
|
|
60
|
+
"default": "./dist/react-native.js"
|
|
61
|
+
},
|
|
54
62
|
"./vue": {
|
|
55
63
|
"types": "./dist/vue.d.ts",
|
|
56
64
|
"default": "./dist/vue.js"
|
|
65
|
+
},
|
|
66
|
+
"./svelte": {
|
|
67
|
+
"types": "./dist/svelte.d.ts",
|
|
68
|
+
"svelte": "./dist/svelte.js",
|
|
69
|
+
"default": "./dist/svelte.js"
|
|
57
70
|
}
|
|
58
71
|
},
|
|
59
72
|
"peerDependencies": {
|
|
60
73
|
"react": ">=18",
|
|
74
|
+
"react-native": ">=0.71",
|
|
75
|
+
"react-native-svg": ">=14",
|
|
76
|
+
"svelte": ">=5",
|
|
61
77
|
"vue": ">=3.3"
|
|
62
78
|
},
|
|
63
79
|
"peerDependenciesMeta": {
|
|
64
80
|
"react": {
|
|
65
81
|
"optional": true
|
|
66
82
|
},
|
|
83
|
+
"react-native": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"react-native-svg": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"svelte": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
67
92
|
"vue": {
|
|
68
93
|
"optional": true
|
|
69
94
|
}
|
|
@@ -73,11 +98,11 @@
|
|
|
73
98
|
],
|
|
74
99
|
"scripts": {
|
|
75
100
|
"test": "bun test",
|
|
76
|
-
"typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.vue.json --noEmit",
|
|
101
|
+
"typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.react-native.json --noEmit && tsc -p tsconfig.vue.json --noEmit && tsc -p tsconfig.svelte.json --noEmit",
|
|
77
102
|
"play": "bun ./playground/index.html",
|
|
78
103
|
"lint": "biome check .",
|
|
79
104
|
"format": "biome check --write .",
|
|
80
|
-
"build": "tsdown",
|
|
105
|
+
"build": "tsdown && bun scripts/build-svelte.ts",
|
|
81
106
|
"size": "size-limit",
|
|
82
107
|
"prepublishOnly": "bun run build"
|
|
83
108
|
},
|
|
@@ -106,6 +131,18 @@
|
|
|
106
131
|
"limit": "8.5 KB",
|
|
107
132
|
"gzip": true
|
|
108
133
|
},
|
|
134
|
+
{
|
|
135
|
+
"name": "core + dom + react-native (MorphIcon, react/rn/rnsvg external)",
|
|
136
|
+
"path": "dist/react-native.js",
|
|
137
|
+
"import": "*",
|
|
138
|
+
"ignore": [
|
|
139
|
+
"react",
|
|
140
|
+
"react-native",
|
|
141
|
+
"react-native-svg"
|
|
142
|
+
],
|
|
143
|
+
"limit": "9 KB",
|
|
144
|
+
"gzip": true
|
|
145
|
+
},
|
|
109
146
|
{
|
|
110
147
|
"name": "core + dom + vue (MorphIcon, vue external)",
|
|
111
148
|
"path": "dist/vue.js",
|
|
@@ -115,20 +152,44 @@
|
|
|
115
152
|
],
|
|
116
153
|
"limit": "8.5 KB",
|
|
117
154
|
"gzip": true
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "core + dom + svelte TS half (svelte external)",
|
|
158
|
+
"path": "dist/svelte-shared.js",
|
|
159
|
+
"import": "*",
|
|
160
|
+
"ignore": [
|
|
161
|
+
"svelte"
|
|
162
|
+
],
|
|
163
|
+
"limit": "8 KB",
|
|
164
|
+
"gzip": true
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"name": "svelte component (ships as source, consumer-compiled)",
|
|
168
|
+
"path": "dist/MorphIcon.svelte",
|
|
169
|
+
"disablePlugins": [
|
|
170
|
+
"@size-limit/esbuild"
|
|
171
|
+
],
|
|
172
|
+
"limit": "1.4 KB",
|
|
173
|
+
"gzip": true
|
|
118
174
|
}
|
|
119
175
|
],
|
|
120
176
|
"devDependencies": {
|
|
121
177
|
"@biomejs/biome": "^2.5.6",
|
|
178
|
+
"@happy-dom/global-registrator": "^20.11.1",
|
|
122
179
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
123
180
|
"@tabler/icons": "^3.46.0",
|
|
124
181
|
"@types/bun": "^1.3.14",
|
|
125
182
|
"@types/react": "^19.2.18",
|
|
126
183
|
"@types/react-dom": "^19.2.4",
|
|
127
184
|
"feather-icons": "^4.29.2",
|
|
185
|
+
"happy-dom": "^20.11.1",
|
|
128
186
|
"lucide": "^1.28.0",
|
|
129
187
|
"react": "^19.2.8",
|
|
130
188
|
"react-dom": "^19.2.8",
|
|
189
|
+
"react-native": "^0.86.2",
|
|
190
|
+
"react-native-svg": "^15.15.5",
|
|
131
191
|
"size-limit": "^13.0.3",
|
|
192
|
+
"svelte": "^5.56.8",
|
|
132
193
|
"tsdown": "^0.22.14",
|
|
133
194
|
"typescript": "^7.0.2",
|
|
134
195
|
"vue": "^3.5.40"
|