libraries-dev-svelte 0.1.0 → 0.3.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 +74 -4
- package/dist/bot-avatars/BotAvatar.svelte +67 -11
- package/dist/bot-avatars/ticker.d.ts +1 -0
- package/dist/bot-avatars/ticker.js +6 -2
- package/dist/bot-avatars/types.d.ts +4 -0
- package/dist/gooey-radial-menu/GooeyRadialMenu.svelte +84 -79
- package/dist/gooey-radial-menu/shadow.d.ts +12 -0
- package/dist/gooey-radial-menu/shadow.js +12 -0
- package/dist/gooey-radial-menu/types.d.ts +21 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,23 +13,93 @@ Svelte 5 ports of the Libraries.dev Bot Avatars and Gooey radial plus menu. The
|
|
|
13
13
|
<BotAvatar type="clover" {state} size={64} />
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
The port retains all 18 shapes; idle, working and sleeping animation states; eyes and optional state-aware mouth; pointer following and click jumps; `plastic`, `crisp`, `smooth` and `flat` shading; lighting, whirl and jump controls; reduced-motion and paused poses; seed synchronization; accessible labels; and the canvas drawing exports. Canvas properties and native click handlers pass through.
|
|
16
|
+
The port retains all 18 shapes; idle, working and sleeping animation states; eyes and optional state-aware mouth; pointer following and click jumps; `plastic`, `crisp`, `smooth` and `flat` shading; lighting, whirl and jump controls; reduced-motion and paused poses; seed synchronization; accessible labels; and the canvas drawing exports. Canvas properties and native click handlers pass through. It updates at 30 fps with a maximum device pixel ratio of 1.5 by default to reduce continuous drawing and backing-store memory on high-density screens; set `fps={60}` and `maxDpr={2}` when maximum smoothness and resolution matter more.
|
|
17
17
|
|
|
18
18
|
Types, presets, palette, shape paths, simulation, renderer, and plastic-material helpers are exported from the package entry point. See `packages/bot-avatars/README.md` in the source clone for the complete original prop descriptions.
|
|
19
19
|
|
|
20
20
|
## Gooey radial menu
|
|
21
21
|
|
|
22
|
+
```svelte
|
|
23
|
+
<script lang="ts">
|
|
24
|
+
import { GooeyRadialMenu, gooeyShadowPresets } from 'libraries-dev-svelte';
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<GooeyRadialMenu bare blur={6} contrast={18} shadow={gooeyShadowPresets.light.soft} />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This ports the source `PlusMenu.tsx` demo and the liquid-gooey behavior it uses: filtered merged silhouettes and shadows, synchronized CSS transform transitions for each button and its SVG blob, stagger and easing controls, close anticipation, theme-aware fill, and the full tuning panel. The normal playground exposes duration and stagger; add `?pro` on `/gooey` for the full controls, copy-values output, and reset.
|
|
31
|
+
|
|
32
|
+
### Shadow styles
|
|
33
|
+
|
|
34
|
+
`shadow` styles one silhouette behind the menu. When the buttons join, their shadow joins into one continuous outline; it is not a separate shadow on each button. The presets are split by surface theme:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
gooeyShadowPresets.light.soft
|
|
38
|
+
gooeyShadowPresets.light.floating
|
|
39
|
+
gooeyShadowPresets.dark.soft
|
|
40
|
+
gooeyShadowPresets.dark.floating
|
|
41
|
+
gooeyShadowPresets.none
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You can also pass CSS `box-shadow` layers directly. Layer order is horizontal offset, vertical offset, blur, optional spread, then color. Separate layers with commas; add `inset` for an inner ring or highlight. Lengths must be numbers in pixels (`px`) or unitless values.
|
|
45
|
+
|
|
46
|
+
```svelte
|
|
47
|
+
<GooeyRadialMenu
|
|
48
|
+
bare
|
|
49
|
+
shadow="0 0 0 1px rgba(0, 0, 0, .06), 0 4px 12px rgba(0, 0, 0, .12)"
|
|
50
|
+
/>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Props: `blur` (6), `contrast` (18), `fill` (`var(--modal-bg, #fff)`), `shadow` (empty), `items` (optional custom satellite items), `onAction` (optional callback; receives the selected item's `action` string), `pro` (false), and `bare` (false). `bare` renders only the menu stage and is the recommended mode when embedding it in an application; without it, the tuning panel is included. Selecting a satellite calls `onAction` when supplied and closes the menu. The host can set `--modal-bg`, `--btn-text`, `--stage-bg`, `--accent`, and `--hover-veil` to match its theme.
|
|
54
|
+
|
|
55
|
+
### Custom items
|
|
56
|
+
|
|
57
|
+
**Note for package users:** `GooeyRadialMenu` no longer ships fixed demo items. By default it still renders the original `New file` / `Add image` / `New folder` demo, but you can pass your own via the `items` prop. Each item is `{ label, action, x, y, iconSnippet? }`, where `x`/`y` are pixel offsets from the main button (positive `y` is down) and `action` is any string reported back through `onAction`. Keep offsets within the stage (the default stage is 200×140 with the main button centered) so satellites stay inside the goo filter. Icons are snippets, so any markup works — including an icon library. If `iconSnippet` is omitted, the built-in `icon` key (`'file' | 'image' | 'folder'`) is used; when neither is set, the item renders unlabeled.
|
|
58
|
+
|
|
22
59
|
```svelte
|
|
23
60
|
<script lang="ts">
|
|
24
61
|
import { GooeyRadialMenu } from 'libraries-dev-svelte';
|
|
62
|
+
|
|
63
|
+
const items = [
|
|
64
|
+
{ label: 'Reply', action: 'reply', x: -54, y: -34 },
|
|
65
|
+
{ label: 'Share', action: 'share', x: 0, y: -64 },
|
|
66
|
+
{ label: 'Delete', action: 'delete', x: 54, y: -34 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function handleAction(action: string) {
|
|
70
|
+
if (action === 'delete') confirmDelete();
|
|
71
|
+
}
|
|
25
72
|
</script>
|
|
26
73
|
|
|
27
|
-
<GooeyRadialMenu
|
|
74
|
+
<GooeyRadialMenu bare {items} onAction={handleAction} />
|
|
28
75
|
```
|
|
29
76
|
|
|
30
|
-
|
|
77
|
+
With custom icons:
|
|
31
78
|
|
|
32
|
-
|
|
79
|
+
```svelte
|
|
80
|
+
<script lang="ts">
|
|
81
|
+
import { GooeyRadialMenu } from 'libraries-dev-svelte';
|
|
82
|
+
import { Camera, Send, Trash } from 'reicon-svelte';
|
|
83
|
+
|
|
84
|
+
const items = [
|
|
85
|
+
{ label: 'Camera', action: 'camera', x: -54, y: -34 },
|
|
86
|
+
{ label: 'Send', action: 'send', x: 0, y: -64 },
|
|
87
|
+
{ label: 'Delete', action: 'delete', x: 54, y: -34 },
|
|
88
|
+
];
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<GooeyRadialMenu
|
|
92
|
+
bare
|
|
93
|
+
{items}
|
|
94
|
+
onAction={(action) => console.log(action)}
|
|
95
|
+
>
|
|
96
|
+
{#snippet iconSnippet(item)}
|
|
97
|
+
{#if item.action === 'camera'}<Camera size={16} weight="Outline" />{/if}
|
|
98
|
+
{#if item.action === 'send'}<Send size={16} weight="Outline" />{/if}
|
|
99
|
+
{#if item.action === 'delete'}<Trash size={16} weight="Outline" />{/if}
|
|
100
|
+
{/snippet}
|
|
101
|
+
</GooeyRadialMenu>
|
|
102
|
+
```
|
|
33
103
|
|
|
34
104
|
## Demo routes
|
|
35
105
|
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
face,
|
|
16
16
|
state: avatarState = 'default',
|
|
17
17
|
size = 64,
|
|
18
|
+
fps = 30,
|
|
19
|
+
maxDpr = 1.5,
|
|
18
20
|
color,
|
|
19
21
|
ink,
|
|
20
22
|
brightness = 1,
|
|
@@ -69,6 +71,8 @@
|
|
|
69
71
|
let sim: Sim | null = null;
|
|
70
72
|
let cfg: DrawConfig | null = null;
|
|
71
73
|
let cssSize = 0;
|
|
74
|
+
let drawContext: CanvasRenderingContext2D | null = null;
|
|
75
|
+
let bounds: DOMRect | null = null;
|
|
72
76
|
let instanceSeed = 0;
|
|
73
77
|
let speedValue = 1;
|
|
74
78
|
let interactiveValue = true;
|
|
@@ -117,14 +121,18 @@
|
|
|
117
121
|
/* A hidden ancestor measures 0: keep the last size rather than wiping the backing store. */
|
|
118
122
|
const px = el.clientWidth / OVERSCAN || cssSize || (typeof size === 'number' ? size : 64);
|
|
119
123
|
if (!px) return;
|
|
120
|
-
const dpr = Math.min(
|
|
124
|
+
const dpr = Math.min(
|
|
125
|
+
2,
|
|
126
|
+
clamp(maxDpr, 1, 2),
|
|
127
|
+
(typeof devicePixelRatio === 'number' && devicePixelRatio) || 1,
|
|
128
|
+
);
|
|
121
129
|
const wanted = Math.round(px * OVERSCAN * dpr);
|
|
122
130
|
if (el.width !== wanted || el.height !== wanted) {
|
|
123
131
|
el.width = wanted;
|
|
124
132
|
el.height = wanted;
|
|
125
133
|
}
|
|
126
134
|
cssSize = px;
|
|
127
|
-
const ctx = el.getContext('2d');
|
|
135
|
+
const ctx = drawContext ?? (drawContext = el.getContext('2d'));
|
|
128
136
|
if (!ctx) return;
|
|
129
137
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
130
138
|
current.dpr = dpr;
|
|
@@ -164,6 +172,34 @@
|
|
|
164
172
|
};
|
|
165
173
|
media?.addEventListener?.('change', updateMotion);
|
|
166
174
|
|
|
175
|
+
const el = canvas;
|
|
176
|
+
if (el) {
|
|
177
|
+
const measure = () => {
|
|
178
|
+
const measuredSize = el.clientWidth / OVERSCAN;
|
|
179
|
+
if (measuredSize > 0) cssSize = measuredSize;
|
|
180
|
+
bounds = el.getBoundingClientRect();
|
|
181
|
+
paint();
|
|
182
|
+
};
|
|
183
|
+
const updateBounds = () => {
|
|
184
|
+
bounds = el.getBoundingClientRect();
|
|
185
|
+
};
|
|
186
|
+
const observer = typeof ResizeObserver === 'function'
|
|
187
|
+
? new ResizeObserver(measure)
|
|
188
|
+
: null;
|
|
189
|
+
observer?.observe(el);
|
|
190
|
+
window.addEventListener('scroll', updateBounds, { capture: true, passive: true });
|
|
191
|
+
window.addEventListener('resize', measure, { passive: true });
|
|
192
|
+
mounted = true;
|
|
193
|
+
measure();
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
media?.removeEventListener?.('change', updateMotion);
|
|
197
|
+
observer?.disconnect();
|
|
198
|
+
window.removeEventListener('scroll', updateBounds, true);
|
|
199
|
+
window.removeEventListener('resize', measure);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
167
203
|
mounted = true;
|
|
168
204
|
|
|
169
205
|
return () => {
|
|
@@ -228,7 +264,11 @@
|
|
|
228
264
|
$effect(() => {
|
|
229
265
|
if (!mounted || shadingMode !== 'plastic' || typeof Path2D === 'undefined') return;
|
|
230
266
|
const path = bodyPath(SHAPE_PATHS[type] ?? SHAPE_PATHS.clover);
|
|
231
|
-
const dpr = Math.min(
|
|
267
|
+
const dpr = Math.min(
|
|
268
|
+
2,
|
|
269
|
+
clamp(maxDpr, 1, 2),
|
|
270
|
+
(typeof devicePixelRatio === 'number' && devicePixelRatio) || 1,
|
|
271
|
+
);
|
|
232
272
|
const devicePx = (typeof size === 'number' ? size : 64) * dpr;
|
|
233
273
|
const runtime = globalThis as typeof globalThis & {
|
|
234
274
|
requestIdleCallback?: (callback: () => void) => number;
|
|
@@ -247,20 +287,37 @@
|
|
|
247
287
|
const el = canvas;
|
|
248
288
|
const current = sim;
|
|
249
289
|
if (!mounted || !el || !current || frozen || motionReduced) return;
|
|
250
|
-
let onScreen = true;
|
|
251
290
|
let unsubscribe: (() => void) | null = null;
|
|
252
291
|
const reach = 3;
|
|
292
|
+
const frameInterval = 1 / clamp(fps, 10, 60);
|
|
293
|
+
let frameElapsed = 0;
|
|
294
|
+
let lastPointerVersion = -1;
|
|
253
295
|
const tick = (dt: number) => {
|
|
296
|
+
frameElapsed += dt;
|
|
297
|
+
if (frameElapsed < frameInterval) return;
|
|
298
|
+
const frameDt = frameElapsed;
|
|
299
|
+
frameElapsed = 0;
|
|
300
|
+
|
|
254
301
|
if (interactiveValue && !Number.isNaN(pointer.x)) {
|
|
255
|
-
|
|
302
|
+
if (pointer.version !== lastPointerVersion) {
|
|
303
|
+
bounds = el.getBoundingClientRect();
|
|
304
|
+
lastPointerVersion = pointer.version;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (interactiveValue && !Number.isNaN(pointer.x) && bounds) {
|
|
309
|
+
const rect = bounds;
|
|
256
310
|
const box = rect.width / OVERSCAN || 1;
|
|
257
311
|
const dx = (pointer.x - (rect.left + rect.width / 2)) / box;
|
|
258
312
|
const dy = (pointer.y - (rect.top + rect.height / 2 + RISE * box)) / box;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
313
|
+
const distanceSquared = dx * dx + dy * dy;
|
|
314
|
+
if (distanceSquared < 1) current.setPointer(dx, dy, 1);
|
|
315
|
+
else if (distanceSquared < reach * reach) {
|
|
316
|
+
const distance = Math.sqrt(distanceSquared);
|
|
317
|
+
current.setPointer(dx / distance, dy / distance, 1 - (distance - 1) / (reach - 1));
|
|
318
|
+
} else current.setPointer(0, 0, 0);
|
|
262
319
|
} else current.setPointer(0, 0, 0);
|
|
263
|
-
current.update(
|
|
320
|
+
current.update(frameDt * speedValue);
|
|
264
321
|
paint();
|
|
265
322
|
};
|
|
266
323
|
const run = () => {
|
|
@@ -273,8 +330,7 @@
|
|
|
273
330
|
let observer: IntersectionObserver | null = null;
|
|
274
331
|
if (typeof IntersectionObserver === 'function') {
|
|
275
332
|
observer = new IntersectionObserver((entries) => {
|
|
276
|
-
|
|
277
|
-
if (onScreen) run();
|
|
333
|
+
if (entries[0]?.isIntersecting ?? true) run();
|
|
278
334
|
else stop();
|
|
279
335
|
});
|
|
280
336
|
observer.observe(el);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
the seconds since their last tick; the loop stops while the tab is
|
|
3
3
|
hidden and while nobody is subscribed. */
|
|
4
4
|
/** The last pointer position on the page, NaN while it is away. */
|
|
5
|
-
export const pointer = { x: NaN, y: NaN };
|
|
5
|
+
export const pointer = { x: NaN, y: NaN, version: 0 };
|
|
6
6
|
const subs = new Set();
|
|
7
7
|
let raf = 0;
|
|
8
8
|
let last = 0;
|
|
@@ -25,7 +25,11 @@ function wire() {
|
|
|
25
25
|
if (wired || typeof document === 'undefined')
|
|
26
26
|
return;
|
|
27
27
|
wired = true;
|
|
28
|
-
document.addEventListener('pointermove', (e) => {
|
|
28
|
+
document.addEventListener('pointermove', (e) => {
|
|
29
|
+
pointer.x = e.clientX;
|
|
30
|
+
pointer.y = e.clientY;
|
|
31
|
+
pointer.version++;
|
|
32
|
+
}, { passive: true });
|
|
29
33
|
document.addEventListener('pointerleave', () => { pointer.x = NaN; pointer.y = NaN; });
|
|
30
34
|
window.addEventListener('blur', () => { pointer.x = NaN; pointer.y = NaN; });
|
|
31
35
|
document.addEventListener('visibilitychange', () => {
|
|
@@ -64,6 +64,10 @@ export interface BotAvatarProps extends Omit<HTMLCanvasAttributes, 'color' | 'st
|
|
|
64
64
|
saturation?: number;
|
|
65
65
|
/** Multiplier on every animation's speed. Default `1`. */
|
|
66
66
|
speed?: number;
|
|
67
|
+
/** Maximum animation updates per second, clamped to 10–60. Default `30`. */
|
|
68
|
+
fps?: number;
|
|
69
|
+
/** Maximum backing-store device pixel ratio, clamped to 1–2. Default `1.5`. */
|
|
70
|
+
maxDpr?: number;
|
|
67
71
|
/** Freeze every animation on its current frame. */
|
|
68
72
|
paused?: boolean;
|
|
69
73
|
/**
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onDestroy, onMount } from 'svelte';
|
|
3
|
-
import { easingFunction } from './motion';
|
|
4
3
|
import { parseShadow, type ShadowLayer } from './shadow';
|
|
5
|
-
import type { GooeyMenuSettings, GooeyRadialMenuProps } from './types';
|
|
4
|
+
import type { GooeyMenuAction, GooeyMenuItem, GooeyMenuSettings, GooeyRadialMenuProps } from './types';
|
|
6
5
|
|
|
7
6
|
let {
|
|
8
7
|
blur = 6,
|
|
9
8
|
contrast = 18,
|
|
10
9
|
fill = 'var(--modal-bg, #fff)',
|
|
11
10
|
shadow = '',
|
|
11
|
+
items,
|
|
12
|
+
onAction,
|
|
13
|
+
iconSnippet,
|
|
12
14
|
pro = false,
|
|
13
15
|
bare = false,
|
|
14
16
|
}: GooeyRadialMenuProps & { fill?: string } = $props();
|
|
15
17
|
|
|
16
18
|
const instanceId = $props.id();
|
|
17
19
|
const filterId = `gooey-${instanceId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
18
|
-
const
|
|
19
|
-
{ label: 'New file', x: -54, y: -34, icon: 'file' },
|
|
20
|
-
{ label: 'Add image', x: 0, y: -64, icon: 'image' },
|
|
21
|
-
{ label: 'New folder', x: 54, y: -34, icon: 'folder' },
|
|
22
|
-
]
|
|
20
|
+
const demoItems: GooeyMenuItem[] = [
|
|
21
|
+
{ label: 'New file', action: 'new-file', x: -54, y: -34, icon: 'file' },
|
|
22
|
+
{ label: 'Add image', action: 'add-image', x: 0, y: -64, icon: 'image' },
|
|
23
|
+
{ label: 'New folder', action: 'new-folder', x: 54, y: -34, icon: 'folder' },
|
|
24
|
+
];
|
|
25
|
+
const satellites = $derived(items ?? demoItems);
|
|
23
26
|
const defaults: GooeyMenuSettings = {
|
|
24
27
|
openDur: 550,
|
|
25
28
|
openEase: 'Bouncy',
|
|
@@ -41,8 +44,7 @@
|
|
|
41
44
|
Linear: 'linear',
|
|
42
45
|
};
|
|
43
46
|
const easeNames = Object.keys(eases);
|
|
44
|
-
const allItems = [...satellites, { label: 'Menu', x: 0, y: 0, icon: 'main' as const }];
|
|
45
|
-
const initialBoxes: Array<BlobBox | null> = allItems.map(() => null);
|
|
47
|
+
const allItems = $derived([...satellites, { label: 'Menu', x: 0, y: 0, icon: 'main' as const }]);
|
|
46
48
|
|
|
47
49
|
interface BlobBox {
|
|
48
50
|
x: number;
|
|
@@ -58,11 +60,11 @@
|
|
|
58
60
|
let menuSettings = $state<GooeyMenuSettings>({ ...defaults });
|
|
59
61
|
let copied = $state(false);
|
|
60
62
|
let reducedMotion = $state(false);
|
|
61
|
-
let mounted = $state(false);
|
|
62
63
|
let groupElement: HTMLDivElement;
|
|
63
64
|
let itemWrappers: Array<HTMLDivElement | undefined> = [];
|
|
64
|
-
let
|
|
65
|
-
let
|
|
65
|
+
let satelliteButtons: Array<HTMLButtonElement | undefined> = [];
|
|
66
|
+
let mainButton: HTMLButtonElement;
|
|
67
|
+
let itemBoxes = $state<Array<BlobBox | null>>([]);
|
|
66
68
|
let groupSize = $state({ w: 0, h: 0 });
|
|
67
69
|
|
|
68
70
|
const shadows = $derived(parseShadow(shadow));
|
|
@@ -104,10 +106,10 @@
|
|
|
104
106
|
},
|
|
105
107
|
);
|
|
106
108
|
|
|
107
|
-
let positions = allItems.map(() => ({ x: 0, y: 0 }));
|
|
108
|
-
let frameIds = allItems.map(() => 0);
|
|
109
109
|
let anticipationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
110
110
|
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
+
let anticipationFrame = 0;
|
|
112
|
+
let focusFrame = 0;
|
|
111
113
|
|
|
112
114
|
function parseCorner(value: string, size: number): number {
|
|
113
115
|
const first = value.split(' ')[0];
|
|
@@ -165,67 +167,46 @@
|
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
function
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
positions[index] = { x, y };
|
|
170
|
+
function transformFor(index: number) {
|
|
171
|
+
const item = allItems[index];
|
|
172
|
+
if (!menuOpen || !item || (!item.x && !item.y)) return 'none';
|
|
173
|
+
return `translate(${item.x * menuSettings.spread}px, ${item.y * menuSettings.spread}px)`;
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
function animateItem(index: number, x: number, y: number, duration: number, ease: string, delay: number) {
|
|
176
|
-
if (frameIds[index]) cancelAnimationFrame(frameIds[index]);
|
|
177
|
-
const from = positions[index];
|
|
178
|
-
if (duration <= 0 || (from.x === x && from.y === y)) {
|
|
179
|
-
writeTransform(index, x, y);
|
|
180
|
-
frameIds[index] = 0;
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const startPosition = { ...from };
|
|
185
|
-
const evaluate = easingFunction(ease);
|
|
186
|
-
const start = performance.now() + delay;
|
|
187
|
-
const tick = (now: number) => {
|
|
188
|
-
const progress = Math.min(1, Math.max(0, (now - start) / duration));
|
|
189
|
-
const eased = evaluate(progress);
|
|
190
|
-
writeTransform(
|
|
191
|
-
index,
|
|
192
|
-
startPosition.x + (x - startPosition.x) * eased,
|
|
193
|
-
startPosition.y + (y - startPosition.y) * eased,
|
|
194
|
-
);
|
|
195
|
-
if (progress < 1) frameIds[index] = requestAnimationFrame(tick);
|
|
196
|
-
else frameIds[index] = 0;
|
|
197
|
-
};
|
|
198
|
-
frameIds[index] = requestAnimationFrame(tick);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
$effect(() => {
|
|
202
|
-
const open = menuOpen;
|
|
203
|
-
const duration = reducedMotion ? 0 : phase.duration;
|
|
204
|
-
const ease = phase.ease;
|
|
205
|
-
const stagger = phase.stagger;
|
|
206
|
-
const spread = menuSettings.spread;
|
|
207
|
-
if (!mounted) return;
|
|
208
|
-
satellites.forEach((satellite, index) => {
|
|
209
|
-
animateItem(
|
|
210
|
-
index,
|
|
211
|
-
open ? satellite.x * spread : 0,
|
|
212
|
-
open ? satellite.y * spread : 0,
|
|
213
|
-
duration,
|
|
214
|
-
ease,
|
|
215
|
-
index * stagger,
|
|
216
|
-
);
|
|
217
|
-
});
|
|
218
|
-
animateItem(allItems.length - 1, 0, 0, duration, ease, 0);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
176
|
function toggle() {
|
|
177
|
+
const opening = !menuOpen;
|
|
222
178
|
if (menuOpen && menuSettings.anticipDist > 0) {
|
|
223
179
|
if (anticipationTimer) clearTimeout(anticipationTimer);
|
|
180
|
+
if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
|
|
224
181
|
anticipating = false;
|
|
225
|
-
requestAnimationFrame(() =>
|
|
182
|
+
anticipationFrame = requestAnimationFrame(() => {
|
|
183
|
+
anticipationFrame = 0;
|
|
184
|
+
anticipating = true;
|
|
185
|
+
});
|
|
226
186
|
anticipationTimer = setTimeout(() => (anticipating = false), menuSettings.anticipDur);
|
|
227
187
|
}
|
|
228
|
-
menuOpen =
|
|
188
|
+
menuOpen = opening;
|
|
189
|
+
if (focusFrame) cancelAnimationFrame(focusFrame);
|
|
190
|
+
if (opening) {
|
|
191
|
+
focusFrame = requestAnimationFrame(() => {
|
|
192
|
+
focusFrame = 0;
|
|
193
|
+
if (menuOpen) satelliteButtons[0]?.focus();
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
mainButton?.focus();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function activateSatellite(action: GooeyMenuAction) {
|
|
201
|
+
toggle();
|
|
202
|
+
onAction?.(action);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleEscape(event: KeyboardEvent) {
|
|
206
|
+
if (event.key === 'Escape' && menuOpen) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
toggle();
|
|
209
|
+
}
|
|
229
210
|
}
|
|
230
211
|
|
|
231
212
|
function updateSetting<K extends keyof GooeyMenuSettings>(key: K, value: GooeyMenuSettings[K]) {
|
|
@@ -260,8 +241,6 @@
|
|
|
260
241
|
reducedMotion = motionQuery.matches;
|
|
261
242
|
const updateMotionPreference = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
|
|
262
243
|
motionQuery.addEventListener('change', updateMotionPreference);
|
|
263
|
-
mounted = true;
|
|
264
|
-
|
|
265
244
|
return () => {
|
|
266
245
|
observer.disconnect();
|
|
267
246
|
motionQuery.removeEventListener('change', updateMotionPreference);
|
|
@@ -271,7 +250,8 @@
|
|
|
271
250
|
onDestroy(() => {
|
|
272
251
|
if (anticipationTimer) clearTimeout(anticipationTimer);
|
|
273
252
|
if (copyTimer) clearTimeout(copyTimer);
|
|
274
|
-
|
|
253
|
+
if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
|
|
254
|
+
if (focusFrame) cancelAnimationFrame(focusFrame);
|
|
275
255
|
});
|
|
276
256
|
</script>
|
|
277
257
|
|
|
@@ -279,13 +259,13 @@
|
|
|
279
259
|
<div
|
|
280
260
|
class={`pm${menuOpen ? ' pm-open' : ''}${anticipating ? ' pm-anticipating' : ''}`}
|
|
281
261
|
bind:this={groupElement}
|
|
282
|
-
style={`--pm-anticip:${menuSettings.anticipDist}px;--pm-anticip-dur:${menuSettings.anticipDur}ms;--pm-icon-dur:${menuSettings.iconDur}ms;position:relative;isolation:isolate;`}
|
|
262
|
+
style={`--pm-anticip:${menuSettings.anticipDist}px;--pm-anticip-dur:${menuSettings.anticipDur}ms;--pm-icon-dur:${menuSettings.iconDur}ms;--pm-transition-duration:${reducedMotion ? 0 : phase.duration}ms;--pm-transition-ease:${phase.ease};position:relative;isolation:isolate;`}
|
|
283
263
|
>
|
|
284
264
|
<svg
|
|
285
265
|
aria-hidden="true"
|
|
286
266
|
focusable="false"
|
|
287
267
|
data-gooey-svg=""
|
|
288
|
-
style={`position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:-1
|
|
268
|
+
style={`position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:-1;${cssShadowFilter ? `filter:${cssShadowFilter};` : ''}`}
|
|
289
269
|
>
|
|
290
270
|
<defs>
|
|
291
271
|
<filter
|
|
@@ -409,11 +389,10 @@
|
|
|
409
389
|
{/if}
|
|
410
390
|
</filter>
|
|
411
391
|
</defs>
|
|
412
|
-
<g id={`${filterId}-sil`} filter={`url(#${filterId})`} style={`fill:${fill};`}>
|
|
392
|
+
<g id={`${filterId}-sil`} class="pm-blobs" filter={`url(#${filterId})`} style={`fill:${fill};`}>
|
|
413
393
|
{#each itemBoxes as box, index (index)}
|
|
414
394
|
{#if box}
|
|
415
395
|
<rect
|
|
416
|
-
bind:this={blobElements[index]}
|
|
417
396
|
x={box.x}
|
|
418
397
|
y={box.y}
|
|
419
398
|
width={box.w}
|
|
@@ -421,7 +400,7 @@
|
|
|
421
400
|
rx={box.r[0] === box.r[1] && box.r[1] === box.r[2] && box.r[2] === box.r[3]
|
|
422
401
|
? Math.max(0, Math.min(box.r[0], Math.min(box.w, box.h) / 2))
|
|
423
402
|
: undefined}
|
|
424
|
-
style={
|
|
403
|
+
style={`--pm-transition-delay:${reducedMotion || index >= satellites.length ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}
|
|
425
404
|
/>
|
|
426
405
|
{/if}
|
|
427
406
|
{/each}
|
|
@@ -429,13 +408,16 @@
|
|
|
429
408
|
</svg>
|
|
430
409
|
|
|
431
410
|
{#each satellites as satellite, index (satellite.label)}
|
|
432
|
-
<div bind:this={itemWrappers[index]} class="pm-slot" style=
|
|
411
|
+
<div bind:this={itemWrappers[index]} class="pm-slot" style={`--pm-transition-delay:${reducedMotion ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}>
|
|
433
412
|
<button
|
|
434
413
|
type="button"
|
|
435
414
|
class="pm-btn pm-sat"
|
|
436
415
|
aria-label={satellite.label}
|
|
437
416
|
tabindex={menuOpen ? 0 : -1}
|
|
438
|
-
|
|
417
|
+
inert={!menuOpen}
|
|
418
|
+
bind:this={satelliteButtons[index]}
|
|
419
|
+
onclick={() => activateSatellite(satellite.action)}
|
|
420
|
+
onkeydown={handleEscape}
|
|
439
421
|
>
|
|
440
422
|
<span
|
|
441
423
|
class="pm-sat-icon"
|
|
@@ -443,7 +425,9 @@
|
|
|
443
425
|
? `${menuSettings.iconDelay + index * phase.stagger}ms`
|
|
444
426
|
: '0ms'}
|
|
445
427
|
>
|
|
446
|
-
{#if
|
|
428
|
+
{#if iconSnippet}
|
|
429
|
+
{@render iconSnippet(satellite)}
|
|
430
|
+
{:else if satellite.icon === 'file'}
|
|
447
431
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
|
448
432
|
<path d="M9 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8a1.5 1.5 0 0 0 1.5-1.5V6z" />
|
|
449
433
|
<path d="M9 1.5V6h4.5" />
|
|
@@ -464,13 +448,15 @@
|
|
|
464
448
|
</div>
|
|
465
449
|
{/each}
|
|
466
450
|
|
|
467
|
-
<div bind:this={itemWrappers[satellites.length]} class="pm-slot" style=
|
|
451
|
+
<div bind:this={itemWrappers[satellites.length]} class="pm-slot" style={`transform:${transformFor(satellites.length)};`}>
|
|
468
452
|
<button
|
|
453
|
+
bind:this={mainButton}
|
|
469
454
|
type="button"
|
|
470
455
|
class="pm-btn pm-main"
|
|
471
456
|
aria-expanded={menuOpen}
|
|
472
457
|
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
|
473
458
|
onclick={toggle}
|
|
459
|
+
onkeydown={handleEscape}
|
|
474
460
|
>
|
|
475
461
|
<span class="pm-plus">
|
|
476
462
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
|
|
@@ -597,9 +583,20 @@
|
|
|
597
583
|
position: absolute;
|
|
598
584
|
left: 80px;
|
|
599
585
|
top: 80px;
|
|
586
|
+
display: inline-block;
|
|
587
|
+
transition: transform var(--pm-transition-duration, 0ms)
|
|
588
|
+
var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.pm-blobs rect {
|
|
592
|
+
transform-box: fill-box;
|
|
593
|
+
transform-origin: center;
|
|
594
|
+
transition: transform var(--pm-transition-duration, 0ms)
|
|
595
|
+
var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
|
|
600
596
|
}
|
|
601
597
|
|
|
602
598
|
.pm-btn {
|
|
599
|
+
position: relative;
|
|
603
600
|
width: 40px;
|
|
604
601
|
height: 40px;
|
|
605
602
|
border: 0;
|
|
@@ -611,6 +608,14 @@
|
|
|
611
608
|
cursor: pointer;
|
|
612
609
|
color: var(--btn-text, #17181c);
|
|
613
610
|
-webkit-tap-highlight-color: transparent;
|
|
611
|
+
touch-action: manipulation;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.pm-btn::before {
|
|
615
|
+
content: '';
|
|
616
|
+
position: absolute;
|
|
617
|
+
inset: -2px;
|
|
618
|
+
border-radius: inherit;
|
|
614
619
|
}
|
|
615
620
|
|
|
616
621
|
.pm-btn:focus-visible {
|
|
@@ -7,4 +7,16 @@ export interface ShadowLayer {
|
|
|
7
7
|
color: string;
|
|
8
8
|
inset: boolean;
|
|
9
9
|
}
|
|
10
|
+
/** Ready-to-use box-shadow values for the Gooey menu's merged liquid silhouette. */
|
|
11
|
+
export declare const gooeyShadowPresets: {
|
|
12
|
+
readonly light: {
|
|
13
|
+
readonly soft: "0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.05), 0 4px 42px rgba(0, 0, 0, 0.06)";
|
|
14
|
+
readonly floating: "0 2px 6px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.18)";
|
|
15
|
+
};
|
|
16
|
+
readonly dark: {
|
|
17
|
+
readonly soft: "0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 1px 0 rgba(255, 255, 255, 0.06) inset";
|
|
18
|
+
readonly floating: "0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.55)";
|
|
19
|
+
};
|
|
20
|
+
readonly none: "";
|
|
21
|
+
};
|
|
10
22
|
export declare function parseShadow(input?: string | null): ShadowLayer[];
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/** Ready-to-use box-shadow values for the Gooey menu's merged liquid silhouette. */
|
|
2
|
+
export const gooeyShadowPresets = {
|
|
3
|
+
light: {
|
|
4
|
+
soft: '0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.05), 0 4px 42px rgba(0, 0, 0, 0.06)',
|
|
5
|
+
floating: '0 2px 6px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.18)',
|
|
6
|
+
},
|
|
7
|
+
dark: {
|
|
8
|
+
soft: '0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 1px 0 rgba(255, 255, 255, 0.06) inset',
|
|
9
|
+
floating: '0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.55)',
|
|
10
|
+
},
|
|
11
|
+
none: '',
|
|
12
|
+
};
|
|
1
13
|
function splitTop(value, separator) {
|
|
2
14
|
const parts = [];
|
|
3
15
|
let depth = 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Inputs accepted by the
|
|
1
|
+
/** Inputs accepted by the Gooey radial-menu component. */
|
|
2
2
|
export interface GooeyRadialMenuProps {
|
|
3
3
|
/** Goo filter blur sigma in px. */
|
|
4
4
|
blur?: number;
|
|
@@ -6,6 +6,12 @@ export interface GooeyRadialMenuProps {
|
|
|
6
6
|
contrast?: number;
|
|
7
7
|
/** CSS box-shadow layers drawn on the merged liquid silhouette. */
|
|
8
8
|
shadow?: string;
|
|
9
|
+
/** Custom satellite items. Defaults to the original demo items. */
|
|
10
|
+
items?: GooeyMenuItem[];
|
|
11
|
+
/** Called when an item is selected. The menu closes after activation. */
|
|
12
|
+
onAction?: (action: GooeyMenuAction) => void;
|
|
13
|
+
/** Snippet rendering each item's icon; receives the item. Falls back to the built-in demo icons. */
|
|
14
|
+
iconSnippet?: Snippet<[GooeyMenuItem]>;
|
|
9
15
|
/** Surface color for the merged liquid. Defaults to the host theme's modal color. */
|
|
10
16
|
fill?: string;
|
|
11
17
|
/** Show all raw tuning controls, export, and reset controls. */
|
|
@@ -13,6 +19,20 @@ export interface GooeyRadialMenuProps {
|
|
|
13
19
|
/** Render only the menu stage for embedding in a host layout. */
|
|
14
20
|
bare?: boolean;
|
|
15
21
|
}
|
|
22
|
+
/** A satellite item rendered around the main button. */
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
export interface GooeyMenuItem {
|
|
25
|
+
/** Accessible label (also the `aria-label`). */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Stable identifier reported to `onAction`. */
|
|
28
|
+
action: string;
|
|
29
|
+
/** Offset in px from the main button (positive y is down). */
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
/** Built-in demo icon key: 'file' | 'image' | 'folder'. Used when the component's `iconSnippet` prop is not set. */
|
|
33
|
+
icon?: 'file' | 'image' | 'folder';
|
|
34
|
+
}
|
|
35
|
+
export type GooeyMenuAction = string;
|
|
16
36
|
export interface GooeyMenuSettings {
|
|
17
37
|
openDur: number;
|
|
18
38
|
openEase: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { default as BotAvatar } from './bot-avatars/BotAvatar.svelte';
|
|
2
2
|
export { default } from './bot-avatars/BotAvatar.svelte';
|
|
3
3
|
export { default as GooeyRadialMenu } from './gooey-radial-menu/GooeyRadialMenu.svelte';
|
|
4
|
-
export
|
|
4
|
+
export { gooeyShadowPresets } from './gooey-radial-menu/shadow';
|
|
5
|
+
export type { GooeyMenuAction, GooeyMenuItem, GooeyMenuSettings, GooeyRadialMenuProps } from './gooey-radial-menu/types';
|
|
5
6
|
export { botAvatarPresets, botAvatarPalette, botAvatarTypes, botAvatarFaces, botAvatarStates, } from './bot-avatars/presets';
|
|
6
7
|
export { SHAPE_PATHS as botAvatarShapes, SHAPE_PARTS as botAvatarParts } from './bot-avatars/shapes';
|
|
7
8
|
export { autoInk, luminance, parseColor, shade } from './bot-avatars/color';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as BotAvatar } from './bot-avatars/BotAvatar.svelte';
|
|
2
2
|
export { default } from './bot-avatars/BotAvatar.svelte';
|
|
3
3
|
export { default as GooeyRadialMenu } from './gooey-radial-menu/GooeyRadialMenu.svelte';
|
|
4
|
+
export { gooeyShadowPresets } from './gooey-radial-menu/shadow';
|
|
4
5
|
export { botAvatarPresets, botAvatarPalette, botAvatarTypes, botAvatarFaces, botAvatarStates, } from './bot-avatars/presets';
|
|
5
6
|
export { SHAPE_PATHS as botAvatarShapes, SHAPE_PARTS as botAvatarParts } from './bot-avatars/shapes';
|
|
6
7
|
export { autoInk, luminance, parseColor, shade } from './bot-avatars/color';
|