morphico 0.4.0 → 0.5.0
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 +62 -45
- package/dist/esm/all.js +50 -0
- package/dist/esm/clip.js +283 -0
- package/dist/esm/engine.js +25 -0
- package/dist/esm/face.js +153 -0
- package/dist/esm/geometry.js +459 -0
- package/dist/esm/icon.js +41 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/morph.js +107 -0
- package/dist/esm/roundness.js +37 -0
- package/dist/esm/sets/battery.js +87 -0
- package/dist/esm/sets/bell.js +101 -0
- package/dist/esm/sets/characters.js +43 -0
- package/dist/esm/sets/check-mark.js +74 -0
- package/dist/esm/sets/checkbox.js +91 -0
- package/dist/esm/sets/chevron.js +70 -0
- package/dist/esm/sets/condensed.js +137 -0
- package/dist/esm/sets/copy.js +87 -0
- package/dist/esm/sets/eye.js +105 -0
- package/dist/esm/sets/faces.js +482 -0
- package/dist/esm/sets/folder.js +112 -0
- package/dist/esm/sets/handwritten.js +136 -0
- package/dist/esm/sets/lock.js +75 -0
- package/dist/esm/sets/lucide.js +66 -0
- package/dist/esm/sets/media.js +109 -0
- package/dist/esm/sets/menu.js +84 -0
- package/dist/esm/sets/mic.js +102 -0
- package/dist/esm/sets/radio.js +84 -0
- package/dist/esm/sets/round.js +244 -0
- package/dist/esm/sets/shapes.js +455 -0
- package/dist/esm/sets/slim.js +148 -0
- package/dist/esm/sets/square.js +149 -0
- package/dist/esm/sets/theme.js +107 -0
- package/dist/esm/sets/toggle.js +73 -0
- package/dist/esm/sets/volume.js +89 -0
- package/dist/esm/sets/weather.js +691 -0
- package/dist/esm/sets/wifi.js +68 -0
- package/dist/esm/svg.js +31 -0
- package/dist/esm/text.js +88 -0
- package/dist/esm/types.js +3 -0
- package/dist/morphico.js +3764 -3750
- package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
- package/dist/types/clip.d.ts +4 -11
- package/dist/types/face.d.ts +2 -6
- package/dist/types/icon.d.ts +4 -9
- package/dist/types/index.d.ts +1 -3
- package/dist/types/roundness.d.ts +8 -0
- package/dist/types/sets/battery.d.ts +1 -1
- package/dist/types/sets/bell.d.ts +1 -1
- package/dist/types/sets/checkbox.d.ts +1 -1
- package/dist/types/sets/chevron.d.ts +1 -1
- package/dist/types/sets/condensed.d.ts +1 -1
- package/dist/types/sets/copy.d.ts +1 -1
- package/dist/types/sets/eye.d.ts +1 -1
- package/dist/types/sets/folder.d.ts +1 -1
- package/dist/types/sets/handwritten.d.ts +1 -1
- package/dist/types/sets/lock.d.ts +1 -1
- package/dist/types/sets/media.d.ts +1 -1
- package/dist/types/sets/menu.d.ts +1 -1
- package/dist/types/sets/mic.d.ts +1 -1
- package/dist/types/sets/radio.d.ts +1 -1
- package/dist/types/sets/round.d.ts +1 -1
- package/dist/types/sets/slim.d.ts +1 -1
- package/dist/types/sets/square.d.ts +1 -1
- package/dist/types/sets/theme.d.ts +1 -1
- package/dist/types/sets/toggle.d.ts +1 -1
- package/dist/types/sets/volume.d.ts +1 -1
- package/dist/types/sets/weather.d.ts +1 -1
- package/dist/types/sets/wifi.d.ts +1 -1
- package/dist/types/text.d.ts +8 -13
- package/package.json +95 -3
- package/dist/morphico.mjs +0 -4642
package/README.md
CHANGED
|
@@ -11,64 +11,85 @@ npm install morphico
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
|
-
import { icon, text } from 'morphico';
|
|
14
|
+
import { icon, text, clip, face } from 'morphico';
|
|
15
|
+
import { digitsRound } from 'morphico/sets/round'; // every set is its own import
|
|
16
|
+
import { iconsWeather } from 'morphico/sets/weather';
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
Every set is a separate file, so a bundler (Vite, webpack, esbuild, Rollup …) only ships the sets and functions a
|
|
20
|
+
page uses — e.g. `icon()` with one icon set is about 13–21 KB gzipped, everything about 63 KB. `clip()` and `face()`
|
|
21
|
+
bring their own shapes / expressions. To pick sets by name at run time, `import { iconSets } from 'morphico/all'`
|
|
22
|
+
(`iconSets.weather` …; this imports every set).
|
|
23
|
+
|
|
24
|
+
Or from a CDN, as a classic script with the global `morphico` — one file with everything, sets by name in
|
|
25
|
+
`morphico.iconSets`:
|
|
18
26
|
|
|
19
27
|
```html
|
|
20
28
|
<script src="https://cdn.jsdelivr.net/npm/morphico"></script>
|
|
21
29
|
<script>
|
|
22
|
-
const { icon, text, clip } = morphico;
|
|
30
|
+
const { icon, text, clip, face, iconSets } = morphico;
|
|
31
|
+
icon(document.querySelector('#weather'), 'sunny', { set: iconSets.weather });
|
|
23
32
|
</script>
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
Every layer is created the same way — `layer(element, init, options)`: the element to draw into, what it shows
|
|
36
|
+
first, and the settings — and changes with `to()`, which returns a promise. `destroy()` removes it.
|
|
37
|
+
|
|
38
|
+
## One icon: `icon(element, init, options)`
|
|
27
39
|
|
|
28
40
|
```ts
|
|
29
|
-
|
|
41
|
+
import { icon } from 'morphico';
|
|
42
|
+
import { digitsRound } from 'morphico/sets/round';
|
|
43
|
+
|
|
44
|
+
const digit = icon(document.querySelector('#digit'), '4', { set: digitsRound });
|
|
30
45
|
|
|
31
46
|
await digit.to('7'); // the promise resolves when the morph ends
|
|
32
47
|
digit.to('8', { onEnd: (result) => {} }); // or pass a callback
|
|
33
48
|
digit.destroy(); // stops and removes it (e.g. when a component unmounts)
|
|
34
49
|
```
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
Digit sets: `digitsRound` (`morphico/sets/round`), `digitsSquare` (`/square`), `digitsCondensed` (`/condensed`),
|
|
52
|
+
`digitsSlim` (`/slim`), `digitsHandwritten` (`/handwritten`). Their states: `'0'`…`'9'`, `'-'` `'/'` `'.'` `','` `':'`, and `' '` (empty: nothing drawn, shapes grow
|
|
37
53
|
out of it and fade). A digit morphing into `-` `/` `.` `,` is pressed flat onto it; into `:` it splits into the two dots.
|
|
38
54
|
|
|
39
55
|
Icon sets (based on [Lucide](https://lucide.dev)):
|
|
40
56
|
|
|
41
|
-
|
|
|
57
|
+
| Import from `morphico/sets/…` | States |
|
|
42
58
|
| --- | --- |
|
|
43
|
-
| `media` | `play`, `pause`, `stop` |
|
|
44
|
-
| `menu` | `menu` (☰), `close` (✕), `back` (←) |
|
|
45
|
-
| `checkbox` | `unchecked` (☐), `checked` (☑), `checked-big` (✓ breaking out of the box) |
|
|
46
|
-
| `radio` | `unchecked` (○), `checked` (✓ in a circle), `checked-big` (✓ breaking out of the circle) |
|
|
47
|
-
| `folder` | `closed`, `open` |
|
|
48
|
-
| `theme` | `light` (sun), `dark` (moon) |
|
|
49
|
-
| `weather` | `sunny`, `frosty`, `partly-cloudy`, `showers`, `cloudy`, `thunder`, `rain`, `snow`, `drizzle` |
|
|
50
|
-
| `eye` | `visible`, `hidden` (slashed) |
|
|
51
|
-
| `volume` | `muted` (✕), `low` (no waves), `medium`, `high` |
|
|
52
|
-
| `toggle` | `plus`, `minus`, `close` (✕), `check` |
|
|
53
|
-
| `lock` | `locked`, `unlocked` |
|
|
54
|
-
| `bell` | `on`, `off` (slashed) |
|
|
55
|
-
| `mic` | `on`, `off` (slashed) |
|
|
56
|
-
| `chevron` | `up`, `right`, `down`, `left` |
|
|
57
|
-
| `copy` | `copy`, `copied` (✓) |
|
|
58
|
-
| `wifi` | `none`, `low`, `medium`, `high` |
|
|
59
|
-
| `battery` | `empty`, `low`, `medium`, `full` |
|
|
59
|
+
| `media` → `iconsMedia` | `play`, `pause`, `stop` |
|
|
60
|
+
| `menu` → `iconsMenu` | `menu` (☰), `close` (✕), `back` (←) |
|
|
61
|
+
| `checkbox` → `iconsCheckbox` | `unchecked` (☐), `checked` (☑), `checked-big` (✓ breaking out of the box) |
|
|
62
|
+
| `radio` → `iconsRadio` | `unchecked` (○), `checked` (✓ in a circle), `checked-big` (✓ breaking out of the circle) |
|
|
63
|
+
| `folder` → `iconsFolder` | `closed`, `open` |
|
|
64
|
+
| `theme` → `iconsTheme` | `light` (sun), `dark` (moon) |
|
|
65
|
+
| `weather` → `iconsWeather` | `sunny`, `frosty`, `partly-cloudy`, `showers`, `cloudy`, `thunder`, `rain`, `snow`, `drizzle` |
|
|
66
|
+
| `eye` → `iconsEye` | `visible`, `hidden` (slashed) |
|
|
67
|
+
| `volume` → `iconsVolume` | `muted` (✕), `low` (no waves), `medium`, `high` |
|
|
68
|
+
| `toggle` → `iconsToggle` | `plus`, `minus`, `close` (✕), `check` |
|
|
69
|
+
| `lock` → `iconsLock` | `locked`, `unlocked` |
|
|
70
|
+
| `bell` → `iconsBell` | `on`, `off` (slashed) |
|
|
71
|
+
| `mic` → `iconsMic` | `on`, `off` (slashed) |
|
|
72
|
+
| `chevron` → `iconsChevron` | `up`, `right`, `down`, `left` |
|
|
73
|
+
| `copy` → `iconsCopy` | `copy`, `copied` (✓) |
|
|
74
|
+
| `wifi` → `iconsWifi` | `none`, `low`, `medium`, `high` |
|
|
75
|
+
| `battery` → `iconsBattery` | `empty`, `low`, `medium`, `full` |
|
|
60
76
|
|
|
61
77
|
```ts
|
|
62
|
-
|
|
78
|
+
import { iconsMedia } from 'morphico/sets/media';
|
|
79
|
+
|
|
80
|
+
const player = icon(button, 'play', { set: iconsMedia });
|
|
63
81
|
player.to('pause');
|
|
64
82
|
```
|
|
65
83
|
|
|
66
|
-
## A line of characters: `text(element, options)`
|
|
84
|
+
## A line of characters: `text(element, init, options)`
|
|
67
85
|
|
|
68
86
|
```ts
|
|
69
|
-
|
|
87
|
+
import { text } from 'morphico';
|
|
88
|
+
import { digitsRound } from 'morphico/sets/round';
|
|
89
|
+
|
|
90
|
+
const clock = text(document.querySelector('#clock'), '04:59', { set: digitsRound });
|
|
70
91
|
|
|
71
|
-
clock.
|
|
92
|
+
await clock.to('05:00'); // only the digits that changed morph
|
|
72
93
|
clock.destroy();
|
|
73
94
|
```
|
|
74
95
|
|
|
@@ -76,22 +97,22 @@ clock.destroy();
|
|
|
76
97
|
**Any other character is removed** (with the regex `/[^0-9 :.,\/-]/g`): `'12:09 AM'` shows `12:09 `.
|
|
77
98
|
- Cells: digits, space and `-` have one width, `:` `.` `,` half of it, `/` 0.7 of it. A change morphs when the length
|
|
78
99
|
and every cell's width stay the same (`5` ↔ `-`, `.` ↔ `,`); otherwise the line is rebuilt without animation
|
|
79
|
-
(`5` ↔ `:`). Pad counters with spaces to keep them morphing: `counter.
|
|
100
|
+
(`5` ↔ `:`). Pad counters with spaces to keep them morphing: `counter.to(String(count).padStart(3, ' '))`.
|
|
80
101
|
- Digits sit on the text baseline, and every cell has the same width, so clocks don't jiggle.
|
|
81
102
|
|
|
82
|
-
## A cut-out: `clip(element, options)`
|
|
103
|
+
## A cut-out: `clip(element, init, options)`
|
|
83
104
|
|
|
84
105
|
Cuts any element (an `<img>`, a `<div>`, a `<main>` …) out by a shape, and morphs, moves, resizes and turns it.
|
|
85
106
|
|
|
86
107
|
```ts
|
|
87
|
-
const photo = clip(document.querySelector('#photo'),
|
|
108
|
+
const photo = clip(document.querySelector('#photo'), 'star', { x: 50, y: 50, size: '80%' });
|
|
88
109
|
|
|
89
110
|
await photo.to('heart'); // morph
|
|
90
111
|
photo.to('shield', { rotation: 360, x: 30, size: '120px' }); // morph + turn + move together
|
|
91
112
|
photo.to({ x: 70, y: '150px' }); // move only, the shape stays
|
|
92
|
-
photo.
|
|
93
|
-
photo.
|
|
94
|
-
photo.
|
|
113
|
+
photo.to({ rotation: 15, duration: 0 }); // jump, no animation (e.g. while dragging a slider)
|
|
114
|
+
photo.to({ outline: { width: 16 } }); // only a 16 px band along the edge shows
|
|
115
|
+
photo.to({ inverted: true }); // the other way round: the shape is a hole
|
|
95
116
|
photo.destroy(); // the element is whole again
|
|
96
117
|
```
|
|
97
118
|
|
|
@@ -102,7 +123,6 @@ turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`
|
|
|
102
123
|
|
|
103
124
|
| Option | Default | |
|
|
104
125
|
| --- | --- | --- |
|
|
105
|
-
| `state` | `'circle'` | The shape |
|
|
106
126
|
| `x`, `y` | `50` | The shape's **centre**. A number is %, or a string `'40%'` / `'120px'` (% of the element's width for `x`, height for `y`) |
|
|
107
127
|
| `size` | fits | The side of the shape's square (% of the element's width, or px). The shape keeps its own proportions inside that square. Default: the largest square that fits the element |
|
|
108
128
|
| `rotation` | `0` | Degrees, clockwise, **absolute**: `rotation: 360` ends one full turn from 0. The shape turns rigidly, also in a wide or tall element |
|
|
@@ -111,12 +131,12 @@ turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`
|
|
|
111
131
|
| `inverted` | `false` | `true`: the other way round — the shape (or band) is a hole, the rest of the element shows. Not animated |
|
|
112
132
|
| `duration`, `easing` | `400`, `'easeInOutCubic'` | As for icons; also per `to()` call |
|
|
113
133
|
|
|
114
|
-
Everything except `outline` and `inverted` animates together in `to()
|
|
134
|
+
Everything except `outline` and `inverted` (they switch at once) animates together in `to()`; `duration: 0` jumps. How it works: a filled shape uses CSS `clip-path: url(#…)`,
|
|
115
135
|
an outlined one `mask: url(#…)` (clip-path has no line width); the `<clipPath>` / `<mask>` sit in a small hidden
|
|
116
136
|
`<svg>` that `clip()` adds to the page. It follows the element's size (`ResizeObserver`). Overflow beyond the element is
|
|
117
137
|
cut off, as with any clip-path.
|
|
118
138
|
|
|
119
|
-
## A face: `face(element, options)`
|
|
139
|
+
## A face: `face(element, init, options)`
|
|
120
140
|
|
|
121
141
|
A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morphs between feelings and blinks.
|
|
122
142
|
|
|
@@ -127,12 +147,11 @@ A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morph
|
|
|
127
147
|
```ts
|
|
128
148
|
import { face } from 'morphico';
|
|
129
149
|
|
|
130
|
-
const robot = face(document.querySelector('#robot'),
|
|
150
|
+
const robot = face(document.querySelector('#robot'), 'normal');
|
|
131
151
|
|
|
132
152
|
await robot.to('smiling'); // morph
|
|
133
|
-
robot.to('angry', { duration: 300
|
|
153
|
+
robot.to('angry', { duration: 300 });
|
|
134
154
|
robot.blink(); // close and open the eyes once, on top of any expression
|
|
135
|
-
robot.state; // 'angry'
|
|
136
155
|
robot.destroy(); // removes the face, stops blinking
|
|
137
156
|
```
|
|
138
157
|
|
|
@@ -144,7 +163,6 @@ morphs into any other. `wink-left` / `wink-right` = the eye that closes, as seen
|
|
|
144
163
|
|
|
145
164
|
| Option | Default | |
|
|
146
165
|
| --- | --- | --- |
|
|
147
|
-
| `state` | `'normal'` | The expression shown first |
|
|
148
166
|
| `pupilColor` | `'white'` | Colour of the pupils. Brows, eyes and mouth take the CSS `color` |
|
|
149
167
|
| `blink` | `true` | Blink by itself every few seconds |
|
|
150
168
|
| `size` | `'100%'` | CSS width and height of the face (by default it fills the element) |
|
|
@@ -157,8 +175,7 @@ inside the eyes (an eyelid covers them). With reduced motion the face changes wi
|
|
|
157
175
|
|
|
158
176
|
| Option | For | Default | |
|
|
159
177
|
| --- | --- | --- | --- |
|
|
160
|
-
| `set` | both |
|
|
161
|
-
| `state` / `value` | icon / text | empty | What is shown first |
|
|
178
|
+
| `set` | both | **required** | A set object: a digit set (`digitsRound` …) for `text()`, any set for `icon()` — see above |
|
|
162
179
|
| `size` | both | `'1em'` | CSS size (icon: width and height; text: height). `1em` follows the font size |
|
|
163
180
|
| `duration` | both | `400` | Milliseconds |
|
|
164
181
|
| `easing` | both | `'easeInOutCubic'` | `'linear'`, `'easeInOutCubic'`, `'easeOutBack'`, `'easeOutElastic'`, or a function `(progress) => easedProgress` |
|
|
@@ -170,13 +187,13 @@ inside the eyes (an eyelid covers them). With reduced motion the face changes wi
|
|
|
170
187
|
| `cellWidth` | text | per set | Width of a digit cell in grid units (a digit is 16 high) |
|
|
171
188
|
| `spacing` | text | per set | Gap between cells in grid units |
|
|
172
189
|
|
|
173
|
-
`duration` and `easing` can also be passed to a single `to()`
|
|
190
|
+
`duration` and `easing` can also be passed to a single `to()` call.
|
|
174
191
|
|
|
175
192
|
## Behaviour
|
|
176
193
|
|
|
177
194
|
- **Colour** comes from the CSS `color` (`currentColor`).
|
|
178
195
|
- **Spacing** can be set in CSS too: `--morphico-spacing: 0.15em`.
|
|
179
|
-
- **Interrupting:** calling `to()`
|
|
196
|
+
- **Interrupting:** calling `to()` during a morph starts the new morph from the shape on screen.
|
|
180
197
|
The earlier promise resolves with `{ interrupted: true }`; promises never stay pending.
|
|
181
198
|
- **Reduced motion:** when the user's system asks for reduced motion, shapes change without animation.
|
|
182
199
|
- **Accessibility:** the icon / line has `role="img"` and an `aria-label` with the current value.
|
package/dist/esm/all.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
// Every built-in set by name — `import { iconSets } from 'morphico/all'` (and `morphico.iconSets` in the classic script).
|
|
3
|
+
// Imports every set: for pages that pick sets at run time. Others import single sets (`morphico/sets/<name>`), so a
|
|
4
|
+
// bundler only keeps those.
|
|
5
|
+
import { iconsCheckbox } from './sets/checkbox.js';
|
|
6
|
+
import { digitsCondensed } from './sets/condensed.js';
|
|
7
|
+
import { iconsFolder } from './sets/folder.js';
|
|
8
|
+
import { digitsHandwritten } from './sets/handwritten.js';
|
|
9
|
+
import { iconsMedia } from './sets/media.js';
|
|
10
|
+
import { iconsRadio } from './sets/radio.js';
|
|
11
|
+
import { iconsMenu } from './sets/menu.js';
|
|
12
|
+
import { digitsRound } from './sets/round.js';
|
|
13
|
+
import { digitsSlim } from './sets/slim.js';
|
|
14
|
+
import { digitsSquare } from './sets/square.js';
|
|
15
|
+
import { iconsTheme } from './sets/theme.js';
|
|
16
|
+
import { iconsWeather } from './sets/weather.js';
|
|
17
|
+
import { iconsEye } from './sets/eye.js';
|
|
18
|
+
import { iconsVolume } from './sets/volume.js';
|
|
19
|
+
import { iconsToggle } from './sets/toggle.js';
|
|
20
|
+
import { iconsLock } from './sets/lock.js';
|
|
21
|
+
import { iconsBell } from './sets/bell.js';
|
|
22
|
+
import { iconsMic } from './sets/mic.js';
|
|
23
|
+
import { iconsChevron } from './sets/chevron.js';
|
|
24
|
+
import { iconsCopy } from './sets/copy.js';
|
|
25
|
+
import { iconsWifi } from './sets/wifi.js';
|
|
26
|
+
import { iconsBattery } from './sets/battery.js';
|
|
27
|
+
export const iconSets = {
|
|
28
|
+
round: digitsRound,
|
|
29
|
+
square: digitsSquare,
|
|
30
|
+
condensed: digitsCondensed,
|
|
31
|
+
slim: digitsSlim,
|
|
32
|
+
handwritten: digitsHandwritten,
|
|
33
|
+
media: iconsMedia,
|
|
34
|
+
menu: iconsMenu,
|
|
35
|
+
checkbox: iconsCheckbox,
|
|
36
|
+
radio: iconsRadio,
|
|
37
|
+
folder: iconsFolder,
|
|
38
|
+
theme: iconsTheme,
|
|
39
|
+
weather: iconsWeather,
|
|
40
|
+
eye: iconsEye,
|
|
41
|
+
volume: iconsVolume,
|
|
42
|
+
toggle: iconsToggle,
|
|
43
|
+
lock: iconsLock,
|
|
44
|
+
bell: iconsBell,
|
|
45
|
+
mic: iconsMic,
|
|
46
|
+
chevron: iconsChevron,
|
|
47
|
+
copy: iconsCopy,
|
|
48
|
+
wifi: iconsWifi,
|
|
49
|
+
battery: iconsBattery,
|
|
50
|
+
};
|
package/dist/esm/clip.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
// Layer 3: an element cut out by a morphing shape (a star, a heart, a speech bubble …), e.g. a photo or a card.
|
|
3
|
+
// The shape is square (1:1), placed by its centre (x, y) and size in % or px of the element, can be turned and
|
|
4
|
+
// outlined (only a band along its edge shows). Filled → CSS `clip-path: url(#id)`; outlined → `mask: url(#id)`
|
|
5
|
+
// (clip-path ignores line width). The <clipPath> / <mask> live in one hidden <svg> per clip.
|
|
6
|
+
import { animate, easings } from './engine.js';
|
|
7
|
+
import { interpolate, pieceLengthOf, shapeForPair, toPathData } from './geometry.js';
|
|
8
|
+
import { checkedInit, resolveIconSet } from './roundness.js';
|
|
9
|
+
import { clampToUnit, DEFAULT_DURATION, prefersReducedMotion, shapesOf } from './morph.js';
|
|
10
|
+
import { clipShapes } from './sets/shapes.js';
|
|
11
|
+
import { createSvgElement } from './svg.js';
|
|
12
|
+
const GRID_SIZE = 24;
|
|
13
|
+
const GRID_CENTER = 12;
|
|
14
|
+
const DEGREE = Math.PI / 180;
|
|
15
|
+
/** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
|
|
16
|
+
const FAR = 100000;
|
|
17
|
+
/** In objectBoundingBox units (1 = the element's width / height): far around the element. */
|
|
18
|
+
const INVERTED_SURROUNDING = 'M-1000 -1000H1001V1001H-1000Z';
|
|
19
|
+
let clipCount = 0;
|
|
20
|
+
const lengthToPixels = (length, reference, name) => {
|
|
21
|
+
if (typeof length === 'number' && Number.isFinite(length))
|
|
22
|
+
return (length / 100) * reference;
|
|
23
|
+
const match = typeof length === 'string' ? /^\s*(-?[\d.]+(?:e[-+]?\d+)?)\s*(px|%)\s*$/i.exec(length) : null;
|
|
24
|
+
if (!match)
|
|
25
|
+
throw new Error(`morphico: ${name} "${length}" is not a length — use a number (= %), '40%' or '120px'`);
|
|
26
|
+
const value = Number(match[1]);
|
|
27
|
+
return match[2] === 'px' ? value : (value / 100) * reference;
|
|
28
|
+
};
|
|
29
|
+
const checkedOutline = (outline) => {
|
|
30
|
+
if (!outline)
|
|
31
|
+
return false;
|
|
32
|
+
if (!(outline.width > 0))
|
|
33
|
+
throw new Error('morphico: outline.width must be a number of px above 0');
|
|
34
|
+
if (outline.band && !['inside', 'centre', 'outside'].includes(outline.band)) {
|
|
35
|
+
throw new Error(`morphico: outline.band "${outline.band}" — use 'inside', 'centre' or 'outside'`);
|
|
36
|
+
}
|
|
37
|
+
return outline;
|
|
38
|
+
};
|
|
39
|
+
/** Cuts `element` out by a morphing shape, `init` first. */
|
|
40
|
+
export const clip = (element, init, options = {}) => {
|
|
41
|
+
const { set = clipShapes, x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, inverted = false, ...defaultMorphOptions } = options;
|
|
42
|
+
const checkedState = (shapeSet, state) => {
|
|
43
|
+
if (!(state in shapeSet.strokes)) {
|
|
44
|
+
throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
|
|
45
|
+
}
|
|
46
|
+
return state;
|
|
47
|
+
};
|
|
48
|
+
for (const [name, length] of [['x', x], ['y', y], ['size', size]]) {
|
|
49
|
+
if (length !== undefined)
|
|
50
|
+
lengthToPixels(length, 1, name);
|
|
51
|
+
}
|
|
52
|
+
/** Where the change goes (or what shows when nothing moves). */
|
|
53
|
+
const target = {
|
|
54
|
+
state: checkedState(set, checkedInit(init, 'clip')), set: resolveIconSet(set, roundness), roundness,
|
|
55
|
+
x, y, size, rotation, outline: checkedOutline(outline), inverted,
|
|
56
|
+
};
|
|
57
|
+
const pieceLength = pieceLengthOf(set);
|
|
58
|
+
let frame = null; // null = resting at the target
|
|
59
|
+
let running = null;
|
|
60
|
+
let elementWidth = 0, elementHeight = 0;
|
|
61
|
+
// ---- the hidden <svg> with the <clipPath> (filled) and the <mask> (outlined) ----
|
|
62
|
+
const id = `morphico-clip-${++clipCount}`;
|
|
63
|
+
const svg = createSvgElement('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
|
|
64
|
+
svg.style.position = 'absolute';
|
|
65
|
+
svg.style.pointerEvents = 'none';
|
|
66
|
+
const clipPathElement = svg.appendChild(createSvgElement('clipPath', { id: `${id}-shape`, clipPathUnits: 'objectBoundingBox' }));
|
|
67
|
+
const clipShape = clipPathElement.appendChild(createSvgElement('path'));
|
|
68
|
+
const farRegion = { maskUnits: 'userSpaceOnUse', x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR };
|
|
69
|
+
const maskElement = svg.appendChild(createSvgElement('mask', { id: `${id}-band`, ...farRegion }));
|
|
70
|
+
/** Every path drawn in element pixels for the outlined band (the band itself and its helpers). */
|
|
71
|
+
let bandPaths = [];
|
|
72
|
+
const buildBand = () => {
|
|
73
|
+
for (const helper of svg.querySelectorAll('.morphico-band-helper'))
|
|
74
|
+
helper.remove();
|
|
75
|
+
maskElement.replaceChildren();
|
|
76
|
+
bandPaths = [];
|
|
77
|
+
clipShape.setAttribute('clip-rule', target.inverted ? 'evenodd' : 'nonzero');
|
|
78
|
+
if (!target.outline)
|
|
79
|
+
return;
|
|
80
|
+
const { width, band = 'inside' } = target.outline;
|
|
81
|
+
// inverted: everything shows (white), the band is the hole (black)
|
|
82
|
+
const bandColor = target.inverted ? 'black' : 'white';
|
|
83
|
+
if (target.inverted) {
|
|
84
|
+
maskElement.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }));
|
|
85
|
+
}
|
|
86
|
+
const pixelPath = (attributes) => {
|
|
87
|
+
const path = createSvgElement('path', attributes);
|
|
88
|
+
bandPaths.push(path);
|
|
89
|
+
return path;
|
|
90
|
+
};
|
|
91
|
+
// inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
|
|
92
|
+
const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: bandColor, 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
|
|
93
|
+
if (band === 'centre') {
|
|
94
|
+
maskElement.append(edgeLine(width));
|
|
95
|
+
}
|
|
96
|
+
else if (band === 'inside') {
|
|
97
|
+
const insideClip = createSvgElement('clipPath', { id: `${id}-inside`, class: 'morphico-band-helper' });
|
|
98
|
+
insideClip.append(pixelPath({}));
|
|
99
|
+
svg.append(insideClip);
|
|
100
|
+
const line = edgeLine(2 * width);
|
|
101
|
+
line.setAttribute('clip-path', `url(#${id}-inside)`);
|
|
102
|
+
maskElement.append(line);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// painting black over the inside would only work for luminance masks; a mask cut out of the line works for both
|
|
106
|
+
const outsideMask = createSvgElement('mask', { id: `${id}-outside`, class: 'morphico-band-helper', ...farRegion });
|
|
107
|
+
outsideMask.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }), pixelPath({ fill: 'black' }));
|
|
108
|
+
svg.append(outsideMask);
|
|
109
|
+
const line = edgeLine(2 * width);
|
|
110
|
+
line.setAttribute('mask', `url(#${id}-outside)`);
|
|
111
|
+
maskElement.append(line);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const ownClipPath = element.style.clipPath;
|
|
115
|
+
const ownMask = element.style.mask;
|
|
116
|
+
const applyStyle = () => {
|
|
117
|
+
const mask = target.outline ? `url(#${id}-band)` : ownMask;
|
|
118
|
+
element.style.clipPath = target.outline ? ownClipPath : `url(#${id}-shape)`;
|
|
119
|
+
element.style.mask = mask;
|
|
120
|
+
element.style.setProperty('-webkit-mask', mask);
|
|
121
|
+
};
|
|
122
|
+
// ---- placement and drawing ----
|
|
123
|
+
const restingPlacement = () => ({
|
|
124
|
+
centerX: lengthToPixels(target.x, elementWidth, 'x'),
|
|
125
|
+
centerY: lengthToPixels(target.y, elementHeight, 'y'),
|
|
126
|
+
side: target.size === undefined ? Math.min(elementWidth, elementHeight) : lengthToPixels(target.size, elementWidth, 'size'),
|
|
127
|
+
});
|
|
128
|
+
const restingFrame = () => ({ shape: shapesOf(target.set)[target.state], rotation: target.rotation, placement: restingPlacement() });
|
|
129
|
+
/** The shape in element pixels: scaled to its square, turned around the square's centre, moved into place. */
|
|
130
|
+
const toElementPixels = ({ shape, rotation: angle, placement: { centerX, centerY, side } }) => {
|
|
131
|
+
const scale = side / GRID_SIZE;
|
|
132
|
+
const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
|
|
133
|
+
const pixels = [];
|
|
134
|
+
for (let index = 0; index < shape.length; index += 2) {
|
|
135
|
+
const offsetX = (shape[index] - GRID_CENTER) * scale, offsetY = (shape[index + 1] - GRID_CENTER) * scale;
|
|
136
|
+
pixels.push(centerX + offsetX * cos - offsetY * sin, centerY + offsetX * sin + offsetY * cos);
|
|
137
|
+
}
|
|
138
|
+
return pixels;
|
|
139
|
+
};
|
|
140
|
+
const render = () => {
|
|
141
|
+
if (!elementWidth || !elementHeight)
|
|
142
|
+
return;
|
|
143
|
+
const pixels = toElementPixels(frame ?? restingFrame());
|
|
144
|
+
if (target.outline) {
|
|
145
|
+
const pathData = toPathData(pixels, pieceLength);
|
|
146
|
+
for (const path of bandPaths)
|
|
147
|
+
path.setAttribute('d', pathData);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// objectBoundingBox: 0…1 of the element's width / height
|
|
151
|
+
const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
|
|
152
|
+
// inverted: a rectangle far around the element with the shape as a hole in it (clip-rule evenodd)
|
|
153
|
+
const surrounding = target.inverted ? INVERTED_SURROUNDING : '';
|
|
154
|
+
clipShape.setAttribute('d', surrounding + toPathData(boxUnits, pieceLength));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const measure = (width, height) => {
|
|
158
|
+
elementWidth = width;
|
|
159
|
+
elementHeight = height;
|
|
160
|
+
render();
|
|
161
|
+
};
|
|
162
|
+
// ---- changes ----
|
|
163
|
+
/** Ends the running change and resolves everyone waiting for it. */
|
|
164
|
+
const finish = (interrupted) => {
|
|
165
|
+
if (!running)
|
|
166
|
+
return;
|
|
167
|
+
const { stopAnimation, resolvers } = running;
|
|
168
|
+
running = null;
|
|
169
|
+
stopAnimation();
|
|
170
|
+
for (const resolve of resolvers)
|
|
171
|
+
resolve({ state: target.state, interrupted });
|
|
172
|
+
};
|
|
173
|
+
/** Updates the target (checked first, so a wrong value throws right away, not in an animation frame). */
|
|
174
|
+
const applyChanges = (changes) => {
|
|
175
|
+
for (const name of ['x', 'y', 'size']) {
|
|
176
|
+
if (changes[name] !== undefined)
|
|
177
|
+
lengthToPixels(changes[name], 1, name);
|
|
178
|
+
}
|
|
179
|
+
if (changes.state !== undefined)
|
|
180
|
+
target.state = checkedState(target.set, changes.state);
|
|
181
|
+
if (changes.roundness !== undefined && changes.roundness !== target.roundness) {
|
|
182
|
+
target.roundness = changes.roundness;
|
|
183
|
+
target.set = resolveIconSet(set, changes.roundness);
|
|
184
|
+
}
|
|
185
|
+
if (changes.x !== undefined)
|
|
186
|
+
target.x = changes.x;
|
|
187
|
+
if (changes.y !== undefined)
|
|
188
|
+
target.y = changes.y;
|
|
189
|
+
if (changes.size !== undefined)
|
|
190
|
+
target.size = changes.size;
|
|
191
|
+
if (changes.rotation !== undefined)
|
|
192
|
+
target.rotation = changes.rotation;
|
|
193
|
+
if (changes.outline !== undefined || changes.inverted !== undefined) {
|
|
194
|
+
if (changes.outline !== undefined)
|
|
195
|
+
target.outline = checkedOutline(changes.outline);
|
|
196
|
+
if (changes.inverted !== undefined)
|
|
197
|
+
target.inverted = changes.inverted;
|
|
198
|
+
buildBand();
|
|
199
|
+
applyStyle();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const change = (changes, morphOptions) => {
|
|
203
|
+
const settled = !running;
|
|
204
|
+
finish(true);
|
|
205
|
+
const previous = { state: target.state, set: target.set };
|
|
206
|
+
const from = frame ?? restingFrame();
|
|
207
|
+
applyChanges(changes);
|
|
208
|
+
const { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = morphOptions;
|
|
209
|
+
if (duration <= 0 || prefersReducedMotion() || !elementWidth || !elementHeight) {
|
|
210
|
+
frame = null;
|
|
211
|
+
render();
|
|
212
|
+
return Promise.resolve({ state: target.state, interrupted: false });
|
|
213
|
+
}
|
|
214
|
+
// settled: both sides use the pair's layouts; interrupted: start from the shape on screen
|
|
215
|
+
const fromShape = settled
|
|
216
|
+
? shapeForPair(previous.set, shapesOf(previous.set), previous.state, target.state) : from.shape;
|
|
217
|
+
const toShape = shapeForPair(target.set, shapesOf(target.set), target.state, previous.state);
|
|
218
|
+
const to = restingPlacement();
|
|
219
|
+
const ease = typeof easing === 'function' ? easing : easings[easing];
|
|
220
|
+
const startTime = performance.now();
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const stopAnimation = animate((frameTime) => {
|
|
223
|
+
const progress = clampToUnit((frameTime - startTime) / duration);
|
|
224
|
+
if (progress >= 1) {
|
|
225
|
+
frame = null;
|
|
226
|
+
render();
|
|
227
|
+
finish(false);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const easedProgress = ease(progress);
|
|
231
|
+
const between = (start, end) => start + (end - start) * easedProgress;
|
|
232
|
+
frame = {
|
|
233
|
+
shape: interpolate(fromShape, toShape, easedProgress),
|
|
234
|
+
rotation: between(from.rotation, target.rotation),
|
|
235
|
+
placement: {
|
|
236
|
+
centerX: between(from.placement.centerX, to.centerX),
|
|
237
|
+
centerY: between(from.placement.centerY, to.centerY),
|
|
238
|
+
side: between(from.placement.side, to.side),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
render();
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
running = { stopAnimation, resolvers: [resolve] };
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
// ---- start ----
|
|
248
|
+
const root = element.getRootNode();
|
|
249
|
+
// inside a shadow root, url(#id) only finds ids of that root
|
|
250
|
+
(root instanceof ShadowRoot ? root : document.body).append(svg);
|
|
251
|
+
buildBand();
|
|
252
|
+
applyStyle();
|
|
253
|
+
const box = element.getBoundingClientRect();
|
|
254
|
+
measure(box.width, box.height);
|
|
255
|
+
const resizeObserver = typeof ResizeObserver === 'function'
|
|
256
|
+
? new ResizeObserver(([entry]) => {
|
|
257
|
+
const borderBox = entry.borderBoxSize?.[0];
|
|
258
|
+
if (borderBox)
|
|
259
|
+
measure(borderBox.inlineSize, borderBox.blockSize);
|
|
260
|
+
else
|
|
261
|
+
measure(entry.contentRect.width, entry.contentRect.height);
|
|
262
|
+
})
|
|
263
|
+
: null;
|
|
264
|
+
resizeObserver?.observe(element);
|
|
265
|
+
return {
|
|
266
|
+
to(stateOrChanges, options = {}) {
|
|
267
|
+
const { onEnd, duration, easing, ...changes } = typeof stateOrChanges === 'string'
|
|
268
|
+
? { ...options, state: stateOrChanges } : stateOrChanges;
|
|
269
|
+
const result = change(changes, { ...defaultMorphOptions, ...(duration !== undefined && { duration }), ...(easing && { easing }) });
|
|
270
|
+
if (onEnd)
|
|
271
|
+
result.then(onEnd);
|
|
272
|
+
return result;
|
|
273
|
+
},
|
|
274
|
+
destroy() {
|
|
275
|
+
finish(true);
|
|
276
|
+
resizeObserver?.disconnect();
|
|
277
|
+
svg.remove();
|
|
278
|
+
element.style.clipPath = ownClipPath;
|
|
279
|
+
element.style.mask = ownMask;
|
|
280
|
+
element.style.setProperty('-webkit-mask', ownMask);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
// Animation engine: easings and one shared requestAnimationFrame loop for every running morph.
|
|
3
|
+
// The loop only runs while something moves; nothing touches the DOM at import time (SSR-safe).
|
|
4
|
+
export const easings = {
|
|
5
|
+
linear: (progress) => progress,
|
|
6
|
+
easeInOutCubic: (progress) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2),
|
|
7
|
+
easeOutBack: (progress) => 1 + 2.70158 * (progress - 1) ** 3 + 1.70158 * (progress - 1) ** 2,
|
|
8
|
+
easeOutElastic: (progress) => (progress === 0 || progress === 1
|
|
9
|
+
? progress
|
|
10
|
+
: 2 ** (-10 * progress) * Math.sin((progress * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1),
|
|
11
|
+
};
|
|
12
|
+
const frameCallbacks = new Set();
|
|
13
|
+
let animationFrameId = 0;
|
|
14
|
+
const runFrame = (frameTime) => {
|
|
15
|
+
for (const callback of frameCallbacks)
|
|
16
|
+
if (!callback(frameTime))
|
|
17
|
+
frameCallbacks.delete(callback);
|
|
18
|
+
animationFrameId = frameCallbacks.size ? requestAnimationFrame(runFrame) : 0;
|
|
19
|
+
};
|
|
20
|
+
/** Adds a callback to the shared loop. Returns a function that removes it again. */
|
|
21
|
+
export const animate = (onFrame) => {
|
|
22
|
+
frameCallbacks.add(onFrame);
|
|
23
|
+
animationFrameId ||= requestAnimationFrame(runFrame);
|
|
24
|
+
return () => { frameCallbacks.delete(onFrame); };
|
|
25
|
+
};
|