morphico 0.3.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 +98 -41
- 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 +4033 -3380
- package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
- package/dist/types/clip.d.ts +6 -11
- package/dist/types/face.d.ts +27 -0
- package/dist/types/icon.d.ts +4 -9
- package/dist/types/index.d.ts +5 -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/faces.d.ts +25 -0
- 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 -4003
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,49 +97,85 @@ 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.
|
|
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
|
|
94
116
|
photo.destroy(); // the element is whole again
|
|
95
117
|
```
|
|
96
118
|
|
|
97
119
|
Shapes (based on [Lucide](https://lucide.dev)): `circle`, `ellipse`, `heart`, `sparkle`, `astroid`, `bookmark`,
|
|
98
120
|
`shield`, `moon`, `tag`, `square`, `badge`, `triangle`, `droplet`, `pentagon`, `hexagon`, `octagon`, `squircle`,
|
|
99
|
-
`star`, `rectangle-horizontal`, `
|
|
121
|
+
`star`, `rectangle-horizontal`, `message-circle`, `message-square`, `cloud`, `puzzle`, `club`. A diamond is the `square`
|
|
122
|
+
turned 45°, a portrait rectangle `rectangle-horizontal` turned 90° (`rotation`).
|
|
100
123
|
|
|
101
124
|
| Option | Default | |
|
|
102
125
|
| --- | --- | --- |
|
|
103
|
-
| `state` | `'circle'` | The shape |
|
|
104
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`) |
|
|
105
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 |
|
|
106
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 |
|
|
107
129
|
| `roundness` | `1` | Corners: `0` = sharp … `1` = the shape's own (the maximum) |
|
|
108
130
|
| `outline` | `false` | `{ width, band }`: only a band of `width` px along the edge shows; `band`: `'inside'` (default), `'centre'` or `'outside'` the edge. Not animated |
|
|
131
|
+
| `inverted` | `false` | `true`: the other way round — the shape (or band) is a hole, the rest of the element shows. Not animated |
|
|
109
132
|
| `duration`, `easing` | `400`, `'easeInOutCubic'` | As for icons; also per `to()` call |
|
|
110
133
|
|
|
111
|
-
Everything except `outline` 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(#…)`,
|
|
112
135
|
an outlined one `mask: url(#…)` (clip-path has no line width); the `<clipPath>` / `<mask>` sit in a small hidden
|
|
113
136
|
`<svg>` that `clip()` adds to the page. It follows the element's size (`ResizeObserver`). Overflow beyond the element is
|
|
114
137
|
cut off, as with any clip-path.
|
|
115
138
|
|
|
139
|
+
## A face: `face(element, init, options)`
|
|
140
|
+
|
|
141
|
+
A face of 7 soft pieces — 2 brows, 2 eyes, 2 pupils and a mouth — that morphs between feelings and blinks.
|
|
142
|
+
|
|
143
|
+
```html
|
|
144
|
+
<div id="robot" style="width: 160px; height: 160px; border-radius: 50%; background: #e4e2d6; color: #8b8e8e"></div>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { face } from 'morphico';
|
|
149
|
+
|
|
150
|
+
const robot = face(document.querySelector('#robot'), 'normal');
|
|
151
|
+
|
|
152
|
+
await robot.to('smiling'); // morph
|
|
153
|
+
robot.to('angry', { duration: 300 });
|
|
154
|
+
robot.blink(); // close and open the eyes once, on top of any expression
|
|
155
|
+
robot.destroy(); // removes the face, stops blinking
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Expressions: `normal`, `smiling`, `pleading`, `wonder`, `focused`, `fright`, `angry`, `asleep`, `sad`,
|
|
159
|
+
`surprised`, `laughing`, `tired`, `wink-left`, `wink-right`, `suspicious`, `confused`, `smug`, `irritated`,
|
|
160
|
+
`bored`, `curious`, `empathetic`, `guilty`, `worried`, `disappointed`, `ashamed`, `embarrassed`, `yearning`,
|
|
161
|
+
`triumphant`, `proud`, `excited`, `scornful`, `disgusted` (also exported as `faceExpressionNames`). Any one
|
|
162
|
+
morphs into any other. `wink-left` / `wink-right` = the eye that closes, as seen.
|
|
163
|
+
|
|
164
|
+
| Option | Default | |
|
|
165
|
+
| --- | --- | --- |
|
|
166
|
+
| `pupilColor` | `'white'` | Colour of the pupils. Brows, eyes and mouth take the CSS `color` |
|
|
167
|
+
| `blink` | `true` | Blink by itself every few seconds |
|
|
168
|
+
| `size` | `'100%'` | CSS width and height of the face (by default it fills the element) |
|
|
169
|
+
| `duration`, `easing` | `500`, `'easeInOutCubic'` | As for icons; also per `to()` call |
|
|
170
|
+
|
|
171
|
+
The face draws no background: a disc, card or anything else behind it is the element's own CSS. The pupils only show
|
|
172
|
+
inside the eyes (an eyelid covers them). With reduced motion the face changes without animation and doesn't blink.
|
|
173
|
+
|
|
116
174
|
## Options
|
|
117
175
|
|
|
118
176
|
| Option | For | Default | |
|
|
119
177
|
| --- | --- | --- | --- |
|
|
120
|
-
| `set` | both |
|
|
121
|
-
| `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 |
|
|
122
179
|
| `size` | both | `'1em'` | CSS size (icon: width and height; text: height). `1em` follows the font size |
|
|
123
180
|
| `duration` | both | `400` | Milliseconds |
|
|
124
181
|
| `easing` | both | `'easeInOutCubic'` | `'linear'`, `'easeInOutCubic'`, `'easeOutBack'`, `'easeOutElastic'`, or a function `(progress) => easedProgress` |
|
|
@@ -130,17 +187,17 @@ cut off, as with any clip-path.
|
|
|
130
187
|
| `cellWidth` | text | per set | Width of a digit cell in grid units (a digit is 16 high) |
|
|
131
188
|
| `spacing` | text | per set | Gap between cells in grid units |
|
|
132
189
|
|
|
133
|
-
`duration` and `easing` can also be passed to a single `to()`
|
|
190
|
+
`duration` and `easing` can also be passed to a single `to()` call.
|
|
134
191
|
|
|
135
192
|
## Behaviour
|
|
136
193
|
|
|
137
194
|
- **Colour** comes from the CSS `color` (`currentColor`).
|
|
138
195
|
- **Spacing** can be set in CSS too: `--morphico-spacing: 0.15em`.
|
|
139
|
-
- **Interrupting:** calling `to()`
|
|
196
|
+
- **Interrupting:** calling `to()` during a morph starts the new morph from the shape on screen.
|
|
140
197
|
The earlier promise resolves with `{ interrupted: true }`; promises never stay pending.
|
|
141
198
|
- **Reduced motion:** when the user's system asks for reduced motion, shapes change without animation.
|
|
142
199
|
- **Accessibility:** the icon / line has `role="img"` and an `aria-label` with the current value.
|
|
143
|
-
- **Server-side rendering:** importing does not touch the DOM; only `icon()` / `text()` / `clip()` do.
|
|
200
|
+
- **Server-side rendering:** importing does not touch the DOM; only `icon()` / `text()` / `clip()` / `face()` do.
|
|
144
201
|
|
|
145
202
|
## Development
|
|
146
203
|
|
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
|
+
};
|