@impractical-ai/motion 0.1.7 → 0.1.9

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.
@@ -1,803 +0,0 @@
1
- // ─── Actions — cursor, click, drag, scroll, and UI state primitives ────────
2
- //
3
- // The interaction vocabulary that every scene used to hand-roll: cursors that
4
- // travel and click, buttons that squash, containers that scroll, UI state that
5
- // changes on the timeline. One call per action, one Tweak row per gesture,
6
- // perceptually-tuned defaults where scenes used to carry magic numbers.
7
- //
8
- // The cursor is a STATEFUL ACTOR, not a per-call sprite. The kit tracks where
9
- // every cursor is and whether it is on screen, so continuation is the default
10
- // and entry/exit are explicit moments:
11
- //
12
- // cursor.click('[data-new]') // enters from the nearest edge,
13
- // type('[data-input]', 'hello') // travels, morphs, clicks
14
- // cursor.click('[data-send]') // continues FROM WHERE IT WAS
15
- // cursor.exit() // leaves via the nearest edge
16
- //
17
- // Icons live in the engine with REGISTERED HOTSPOTS (where the tip actually
18
- // is), so click math can never drift the way per-scene hand-rolled rigs did.
19
- // Every action defaults to `ease-in-out-cubic` and takes a `duration`; the two
20
- // deliberate exceptions are typing (stepped by definition, see typeInto) and
21
- // `ui.set` (instant — a cut is hard).
22
- //
23
- // Everything obeys the kit conventions: factory, every tween through `deps.tl`,
24
- // `(target, opts?, position?)`, returns `{ duration }`, deterministic — no
25
- // clocks, no randomness; the idle "human" drift is a fixed sine.
26
-
27
- import gsap from 'gsap'
28
- import { createWarner, layoutRect as sharedLayoutRect, visualRect as sharedVisualRect, resolveTarget } from './kits'
29
-
30
- type Rect = { left: number; top: number; width: number; height: number }
31
-
32
- export type ActionsDeps = {
33
- tl: gsap.core.Timeline
34
- stage: HTMLElement
35
- config: { width: number; height: number }
36
- }
37
-
38
- /** Built-in cursor icons. `collab` is the Figma-style colored arrow + name
39
- * chip for multi-user scenes. Custom art passes `{ svg, hotspot }` — the
40
- * hotspot (0..1 of the icon box, where the tip sits) is part of the contract,
41
- * so brand cursors cannot reopen the misaligned-click bug. */
42
- export type CursorIconName =
43
- | 'arrow'
44
- | 'pointer'
45
- | 'ibeam'
46
- | 'grab'
47
- | 'grabbing'
48
- | 'crosshair'
49
- | 'collab'
50
- export type CursorIcon = CursorIconName | { svg: string; hotspot: { x: number; y: number } }
51
-
52
- export type CursorActorOpts = {
53
- /** Icon set. Default 'arrow'; named collaborators usually want 'collab'. */
54
- icon?: CursorIcon
55
- /** Tint for the collab arrow and label chip. */
56
- color?: string
57
- /** Figma-style name tag riding the cursor. */
58
- label?: string
59
- /** Icon box size in scene px. Default 56 (at 1080p) — marketing videos
60
- * oversize the cursor so the action reads at a glance. */
61
- size?: number
62
- /** 'still' (default) or 'human' — sub-pixel drift while parked between
63
- * actions, so a resting cursor never reads as a freeze-frame. */
64
- idle?: 'still' | 'human'
65
- /** Auto-morph arrow → pointer over the last stretch of a click travel.
66
- * Default true. */
67
- morph?: boolean
68
- }
69
-
70
- export type CursorMoveOpts = {
71
- /** Travel time in seconds. Default 0.8. */
72
- duration?: number
73
- /** Default 'ease-in-out-cubic' — every action eases in-out. */
74
- ease?: string
75
- /** Where on the target to land, as fractions of its box. Default
76
- * `{ x: 0.5, y: 0.52 }` — the corpus's observed natural click point. */
77
- anchor?: { x?: number; y?: number }
78
- /** Extra scene-px nudge after the anchor. */
79
- offset?: { x?: number; y?: number }
80
- /** First-action entrance: 'edge' (default — slides in from the nearest
81
- * frame edge) or 'fade' (materializes just short of the target, for tight
82
- * close-ups where the edge is far away). Ignored while already visible. */
83
- enter?: 'edge' | 'fade'
84
- }
85
-
86
- /** What the cursor does after the action — the continuity contract.
87
- * 'stay' (default) parks it where it is so the NEXT action continues from
88
- * there; 'exit' leaves via the nearest edge; 'park' steps aside just below
89
- * the target (clears it for typing or a reveal); a selector travels to that
90
- * element and parks there. */
91
- export type CursorThen = 'stay' | 'exit' | 'park' | string
92
-
93
- export type CursorClickOpts = CursorMoveOpts & {
94
- /** Target squash scale. 'auto' (default) derives from the element's size —
95
- * big surfaces squash less. A number (e.g. 0.9) is used verbatim. */
96
- press?: number | 'auto'
97
- /** Squash time each way. Default 0.09. */
98
- pressDuration?: number
99
- then?: CursorThen
100
- }
101
-
102
- export type CursorDragOpts = CursorMoveOpts & {
103
- /** Subtle scale lift on the payload while it is held. Default true. */
104
- lift?: boolean
105
- then?: CursorThen
106
- }
107
-
108
- export type CursorExitOpts = {
109
- duration?: number
110
- ease?: string
111
- /** 'edge' (default) or 'fade'. */
112
- via?: 'edge' | 'fade'
113
- }
114
-
115
- export type CursorActor = {
116
- /** Move to an element (or continue there from the last position). */
117
- to: (
118
- target: string | Element,
119
- opts?: CursorMoveOpts,
120
- position?: gsap.Position
121
- ) => { duration: number }
122
- /** Travel + press: squashes the cursor AND the target, ordered so the
123
- * click visibly precedes any result the direction schedules after it. */
124
- click: (
125
- target: string | Element,
126
- opts?: CursorClickOpts,
127
- position?: gsap.Position
128
- ) => { duration: number }
129
- /** Grab a payload and carry it: cursor and payload move as ONE tween pair
130
- * with identical timing, so they cannot desync. `to` is an element/selector
131
- * (payload centers on it) or a scene-px delta `{ x, y }`. */
132
- drag: (
133
- payload: string | Element,
134
- to: string | Element | { x: number; y: number },
135
- opts?: CursorDragOpts,
136
- position?: gsap.Position
137
- ) => { duration: number }
138
- /** Leave the frame. The next action re-enters fresh. */
139
- exit: (opts?: CursorExitOpts, position?: gsap.Position) => { duration: number }
140
- /** Update the collab name chip on the timeline. */
141
- label: (text: string, position?: gsap.Position) => void
142
- }
143
-
144
- export type CursorKit = CursorActor & {
145
- /** Declare a named cursor — an independent actor with its own icon, color,
146
- * label chip, and continuity state. Two actors animate concurrently just by
147
- * overlapping their positions. */
148
- actor: (name: string, opts?: CursorActorOpts) => CursorActor
149
- }
150
-
151
- export type PressOpts = {
152
- /** Squash scale; 'auto' (default) derives from element size. */
153
- amount?: number | 'auto'
154
- /** Each way. Default 0.09. */
155
- duration?: number
156
- ease?: string
157
- }
158
-
159
- export type ScrollOpts = {
160
- duration?: number
161
- ease?: string
162
- /** Where the target settles in the container: 0 top, 0.5 center (default),
163
- * 1 bottom. */
164
- align?: number
165
- }
166
-
167
- export type UiChange = {
168
- /** Replace the element's text content. */
169
- text?: string
170
- /** Set an input's value. */
171
- value?: string
172
- /** Swap an image/video source. */
173
- src?: string
174
- /** Set attributes verbatim. */
175
- attrs?: Record<string, string>
176
- /** Add/remove classes (resolved against the authored class list). */
177
- classes?: { add?: string[]; remove?: string[] }
178
- /** Brief brightness pulse after the change, so the state change reads as an
179
- * event rather than a glitch. */
180
- flash?: boolean
181
- }
182
-
183
- export type ActionsKit = {
184
- cursor: CursorKit
185
- /** Squash without a cursor — keyboard sends, programmatic actions. */
186
- press: (
187
- target: string | Element,
188
- opts?: PressOpts,
189
- position?: gsap.Position
190
- ) => { duration: number }
191
- /** Animated scroll of a container until `to` (element, selector, or an
192
- * absolute scrollTop) is in view. `duration: 0` sets it instantly for
193
- * initial state. */
194
- scroll: (
195
- container: string | Element,
196
- to: string | Element | number,
197
- opts?: ScrollOpts,
198
- position?: gsap.Position
199
- ) => { duration: number }
200
- /** Declarative hard UI state change on the timeline (seek-reversible).
201
- * Instant by design — a cut is hard; `flash` adds the one animated accent. */
202
- ui: {
203
- set: (
204
- target: string | Element,
205
- change: UiChange,
206
- position?: gsap.Position
207
- ) => { duration: number }
208
- }
209
- }
210
-
211
- // ─── icon library ─────────────────────────────────────────────────────────
212
- // All 32x32 viewBox. White fill + ink outline reads on any background; the
213
- // collab arrow takes the actor's color. Hotspots are FRACTIONS of the icon
214
- // box marking where the functional tip sits.
215
-
216
- const OUTLINE = 'stroke="#1a1a1f" stroke-width="1.6" stroke-linejoin="round"'
217
-
218
- const ICONS: Record<CursorIconName, { svg: (color: string) => string; hotspot: { x: number; y: number } }> = {
219
- arrow: {
220
- svg: () =>
221
- `<svg viewBox="0 0 32 32"><path d="M8 3 L8 24.5 L13.4 19.6 L16.9 27.2 L20.1 25.7 L16.6 18.3 L23.8 17.9 Z" fill="#fff" ${OUTLINE}/></svg>`,
222
- hotspot: { x: 0.25, y: 0.09 },
223
- },
224
- pointer: {
225
- svg: () =>
226
- `<svg viewBox="0 0 32 32"><path d="M12.2 3.6 a1.7 1.7 0 0 1 3.4 0 V13.6 h1.1 V8.2 a1.7 1.7 0 0 1 3.4 0 v6 h1.1 V10.4 a1.7 1.7 0 0 1 3.4 0 v9.2 c0 4.6-3.1 8.2-7.7 8.2 h-1.2 c-3 0-5.2-1.6-6.6-4.1 l-3-5.3 c-0.6-1.1 0.7-2.3 1.8-1.6 l3.3 2 V3.6 z" fill="#fff" ${OUTLINE}/></svg>`,
227
- hotspot: { x: 0.43, y: 0.06 },
228
- },
229
- ibeam: {
230
- svg: () =>
231
- `<svg viewBox="0 0 32 32"><path d="M11 4 h4 v1.8 h3 V4 h4 M16 5.8 V26.2 M11 28 h4 v-1.8 h3 V28 h4 M13 4.9 h6 M13 27.1 h6" fill="none" stroke="#fff" stroke-width="4"/><path d="M11 4 h10 M11 28 h10 M16 5 V27" fill="none" stroke="#1a1a1f" stroke-width="1.8"/></svg>`,
232
- hotspot: { x: 0.5, y: 0.5 },
233
- },
234
- grab: {
235
- svg: () =>
236
- `<svg viewBox="0 0 32 32"><path d="M9.5 15 v-3 a1.7 1.7 0 0 1 3.4 0 v-2 a1.7 1.7 0 0 1 3.4 0 v-0.6 a1.7 1.7 0 0 1 3.4 0 v1.6 a1.7 1.7 0 0 1 3.4 0 v7.5 c0 4.4-3 7.5-7.4 7.5 h-0.8 c-2.9 0-4.9-1.5-6.3-3.9 l-1.7-3 c-0.6-1.1 0.6-2.3 1.7-1.7 z" fill="#fff" ${OUTLINE}/></svg>`,
237
- hotspot: { x: 0.5, y: 0.45 },
238
- },
239
- grabbing: {
240
- svg: () =>
241
- `<svg viewBox="0 0 32 32"><path d="M9.5 17 v-1.5 a1.7 1.7 0 0 1 3.4 0 v-1 a1.7 1.7 0 0 1 3.4 0 v-0.4 a1.7 1.7 0 0 1 3.4 0 v0.8 a1.7 1.7 0 0 1 3.4 0 v5.6 c0 4.2-3 7-7.4 7 h-0.8 c-2.9 0-4.9-1.4-6.3-3.7 l-1.4-2.4 c-0.6-1.1 0.6-2.3 1.7-1.7 z" fill="#fff" ${OUTLINE}/></svg>`,
242
- hotspot: { x: 0.5, y: 0.5 },
243
- },
244
- crosshair: {
245
- svg: () =>
246
- `<svg viewBox="0 0 32 32"><path d="M16 5 V27 M5 16 H27" fill="none" stroke="#fff" stroke-width="4"/><path d="M16 5 V27 M5 16 H27" fill="none" stroke="#1a1a1f" stroke-width="1.6"/></svg>`,
247
- hotspot: { x: 0.5, y: 0.5 },
248
- },
249
- collab: {
250
- svg: (color) =>
251
- `<svg viewBox="0 0 32 32"><path d="M8 3 L8 24.5 L13.4 19.6 L16.9 27.2 L20.1 25.7 L16.6 18.3 L23.8 17.9 Z" fill="${color}" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>`,
252
- hotspot: { x: 0.25, y: 0.09 },
253
- },
254
- }
255
-
256
- /** The icon layers a built-in actor carries, so morphs are opacity crossfades
257
- * between pre-rendered siblings (deterministic and seek-reversible). */
258
- const MORPH_SET: CursorIconName[] = ['arrow', 'pointer', 'grab', 'grabbing']
259
-
260
- const DEFAULTS = {
261
- size: 56,
262
- travel: 0.8,
263
- ease: 'ease-in-out-cubic',
264
- anchor: { x: 0.5, y: 0.52 },
265
- pressDuration: 0.09,
266
- exitDuration: 0.5,
267
- enterFade: 0.18,
268
- morphDuration: 0.12,
269
- }
270
-
271
- /** Target squash from its smallest dimension: a 48px chip squashes to 0.86,
272
- * a 400px+ surface barely moves (0.97). This is the one press parameter the
273
- * corpus varied LEGITIMATELY by element size — so it is a derivation, not a
274
- * constant. */
275
- export function autoPressAmount(rect: { width: number; height: number }): number {
276
- const d = Math.min(rect.width, rect.height)
277
- const t = Math.min(1, Math.max(0, (d - 48) / 352))
278
- return Math.round((0.86 + t * 0.11) * 1000) / 1000
279
- }
280
-
281
- /** Entry point just outside the nearest frame edge to `p`, so a cursor's
282
- * first travel reads as "someone's hand arrived", not a teleport. */
283
- export function nearestEdgeStart(
284
- p: { x: number; y: number },
285
- frame: { width: number; height: number },
286
- margin: number
287
- ): { x: number; y: number } {
288
- const candidates = [
289
- { d: p.x, x: -margin, y: p.y },
290
- { d: frame.width - p.x, x: frame.width + margin, y: p.y },
291
- { d: p.y, x: p.x, y: -margin },
292
- { d: frame.height - p.y, x: p.x, y: frame.height + margin },
293
- ]
294
- candidates.sort((a, b) => a.d - b.d)
295
- return { x: candidates[0].x, y: candidates[0].y }
296
- }
297
-
298
- // ─── the kit ──────────────────────────────────────────────────────────────
299
-
300
- type ActorState = {
301
- el: HTMLElement
302
- layers: Partial<Record<string, HTMLElement>>
303
- chip: HTMLElement | null
304
- hotspot: { x: number; y: number }
305
- size: number
306
- idle: 'still' | 'human'
307
- morph: boolean
308
- baseIcon: string
309
- activeIcon: string
310
- /** Scene-px position of the HOTSPOT after the last action. Null = off. */
311
- pos: { x: number; y: number } | null
312
- visible: boolean
313
- /** Timeline second at which this actor's last action ends. */
314
- clock: number | null
315
- }
316
-
317
- export function createActionsKit(deps: ActionsDeps): ActionsKit {
318
- const { tl, stage, config } = deps
319
- const warn = createWarner('actions')
320
- let actorCount = 0
321
- let labelCount = 0
322
-
323
- /** Rect in authored-layout scene coords — see kits.ts `layoutRect`. */
324
- const layoutRect = (el: HTMLElement): Rect => sharedLayoutRect(stage, el)
325
-
326
- /** Rect where the element sits RIGHT NOW (authored transforms included) —
327
- * see kits.ts `visualRect`. Aim points resolve through this at action time. */
328
- const visualRect = (el: HTMLElement): Rect => sharedVisualRect(stage, el)
329
-
330
- /** Resolve any gsap position expression to absolute seconds via a label. */
331
- const resolvePosition = (position: gsap.Position | undefined, fallback: number): number => {
332
- if (position === undefined) return fallback
333
- if (typeof position === 'number') return position
334
- const name = `__action-${labelCount++}`
335
- tl.addLabel(name, position)
336
- return tl.labels[name] ?? fallback
337
- }
338
-
339
- const anchorPoint = (
340
- target: string | Element,
341
- opts: CursorMoveOpts,
342
- kind: string
343
- ): { el: HTMLElement; rect: Rect; x: number; y: number; resolve: () => { x: number; y: number } } => {
344
- const el = resolveTarget(stage, target, kind)
345
- const rect = layoutRect(el)
346
- const ax = opts.anchor?.x ?? DEFAULTS.anchor.x
347
- const ay = opts.anchor?.y ?? DEFAULTS.anchor.y
348
- const point = (r: Rect) => ({
349
- x: r.left + r.width * ax + (opts.offset?.x ?? 0),
350
- y: r.top + r.height * ay + (opts.offset?.y ?? 0),
351
- })
352
- const estimate = point(rect)
353
- return {
354
- el,
355
- rect,
356
- x: estimate.x,
357
- y: estimate.y,
358
- // Resolved when the travel tween first renders, not when the direction
359
- // builds: a container the direction scales mid-timeline (a shell zoom)
360
- // has moved the target by then, and only the at-render rect knows where.
361
- resolve: () => point(visualRect(el)),
362
- }
363
- }
364
-
365
- const squash = (
366
- el: HTMLElement,
367
- amount: number,
368
- each: number,
369
- at: number,
370
- origin = '50% 50%'
371
- ) => {
372
- tl.to(
373
- el,
374
- {
375
- scale: amount,
376
- duration: each,
377
- repeat: 1,
378
- yoyo: true,
379
- ease: 'ease-in-out-quad',
380
- transformOrigin: origin,
381
- },
382
- at
383
- )
384
- }
385
-
386
- function buildActor(name: string, opts: CursorActorOpts): CursorActor {
387
- const size = opts.size ?? DEFAULTS.size
388
- const color = opts.color ?? '#5865F2'
389
- const iconOpt = opts.icon ?? 'arrow'
390
- const custom = typeof iconOpt === 'object' ? iconOpt : null
391
- const baseIcon = custom ? 'custom' : (iconOpt as string)
392
- const hotspot = custom ? custom.hotspot : ICONS[iconOpt as CursorIconName].hotspot
393
-
394
- const el = document.createElement('div')
395
- el.setAttribute(`data-cursor-${name}`, '')
396
- el.style.cssText = `position:absolute;left:0;top:0;width:${size}px;height:${size}px;pointer-events:none;z-index:${9990 + actorCount};filter:drop-shadow(0 1.5px 3px rgba(0,0,0,0.35));`
397
-
398
- const layers: Partial<Record<string, HTMLElement>> = {}
399
- const addLayer = (key: string, svg: string, visible: boolean) => {
400
- const layer = document.createElement('div')
401
- layer.style.cssText = `position:absolute;inset:0;opacity:${visible ? 1 : 0};`
402
- layer.innerHTML = svg
403
- el.appendChild(layer)
404
- layers[key] = layer
405
- }
406
- if (custom) {
407
- addLayer('custom', custom.svg, true)
408
- } else if (MORPH_SET.includes(baseIcon as CursorIconName)) {
409
- // The morphing set is pre-rendered as stacked siblings so every morph is
410
- // an opacity crossfade — deterministic and seek-reversible.
411
- for (const key of MORPH_SET) addLayer(key, ICONS[key].svg(color), key === baseIcon)
412
- } else {
413
- addLayer(baseIcon, ICONS[baseIcon as CursorIconName].svg(color), true)
414
- }
415
-
416
- let chip: HTMLElement | null = null
417
- if (opts.label) {
418
- chip = document.createElement('div')
419
- chip.textContent = opts.label
420
- chip.style.cssText = `position:absolute;left:${size * 0.62}px;top:${size * 0.72}px;background:${color};color:#fff;font:600 ${Math.round(size * 0.34)}px/1.25 system-ui,sans-serif;padding:2px 8px;border-radius:999px;white-space:nowrap;box-shadow:0 1px 3px rgba(0,0,0,0.25);`
421
- el.appendChild(chip)
422
- }
423
-
424
- gsap.set(el, { autoAlpha: 0, x: -size * 3, y: -size * 3, force3D: false })
425
- stage.appendChild(el)
426
-
427
- const state: ActorState = {
428
- el,
429
- layers,
430
- chip,
431
- hotspot,
432
- size,
433
- idle: opts.idle ?? 'still',
434
- morph: opts.morph ?? true,
435
- baseIcon,
436
- activeIcon: baseIcon,
437
- pos: null,
438
- visible: false,
439
- clock: null,
440
- }
441
- actorCount += 1
442
-
443
- /** Wrapper x/y from a hotspot scene point. */
444
- const place = (p: { x: number; y: number }) => ({
445
- x: p.x - state.hotspot.x * state.size,
446
- y: p.y - state.hotspot.y * state.size,
447
- })
448
-
449
- const morphTo = (icon: string, at: number) => {
450
- if (!state.morph || icon === state.activeIcon) return
451
- const fromLayer = state.layers[state.activeIcon]
452
- const toLayer = state.layers[icon]
453
- if (!fromLayer || !toLayer) return
454
- tl.to(fromLayer, { opacity: 0, duration: DEFAULTS.morphDuration, ease: 'ease-in-out-quad' }, at)
455
- tl.to(toLayer, { opacity: 1, duration: DEFAULTS.morphDuration, ease: 'ease-in-out-quad' }, at)
456
- state.activeIcon = icon
457
- }
458
-
459
- /** Common start-of-action bookkeeping: resolve the start second, warn on
460
- * mid-travel overlap, and fill a long park with the idle drift. */
461
- const begin = (position: gsap.Position | undefined): number => {
462
- const start = resolvePosition(position, state.clock ?? tl.duration())
463
- if (state.clock !== null && start < state.clock - 1e-6) {
464
- warn(
465
- `overlap-${name}`,
466
- `cursor "${name}" is scheduled at ${start.toFixed(2)}s while still mid-action ` +
467
- `until ${state.clock.toFixed(2)}s — author each cursor's actions in order.`
468
- )
469
- }
470
- if (
471
- state.idle === 'human' &&
472
- state.visible &&
473
- state.clock !== null &&
474
- start - state.clock > 0.4
475
- ) {
476
- const gap = start - state.clock
477
- tl.to(
478
- el,
479
- { x: '+=2.4', y: '-=1.8', duration: gap / 2, repeat: 1, yoyo: true, ease: 'sine.inOut' },
480
- state.clock
481
- )
482
- }
483
- return start
484
- }
485
-
486
- /** The label chip rides along during travel and fades just after arrival —
487
- * parked on target it occludes exactly the thing the cursor points at. */
488
- const chipRide = (start: number, duration: number) => {
489
- if (!chip) return
490
- tl.set(chip, { autoAlpha: 1 }, start)
491
- tl.to(chip, { autoAlpha: 0, duration: 0.18, ease: 'ease-in-quad' }, start + duration + 0.15)
492
- }
493
-
494
- /** Travel the hotspot to scene point `p`. Returns total travel duration. */
495
- const travelTo = (
496
- p: { x: number; y: number },
497
- opts: CursorMoveOpts,
498
- start: number,
499
- resolve?: () => { x: number; y: number }
500
- ): number => {
501
- const duration = opts.duration ?? DEFAULTS.travel
502
- const ease = opts.ease ?? DEFAULTS.ease
503
- const dest = place(p)
504
- // Destination resolves when the tween first renders: by then every
505
- // earlier tween (container zooms included) has applied, so the aim is
506
- // taken from where the target actually sits, not its resting layout.
507
- const destX = resolve ? () => place(resolve()).x : dest.x
508
- const destY = resolve ? () => place(resolve()).y : dest.y
509
- if (!state.visible) {
510
- const enter = opts.enter ?? 'edge'
511
- const from =
512
- enter === 'fade'
513
- ? { x: dest.x - state.size * 0.7, y: dest.y - state.size * 0.5 }
514
- : place(nearestEdgeStart(p, config, state.size * 1.5))
515
- tl.fromTo(
516
- el,
517
- { autoAlpha: 0 },
518
- { autoAlpha: 1, duration: DEFAULTS.enterFade, ease: 'ease-out-quad' },
519
- start
520
- )
521
- tl.fromTo(el, { x: from.x, y: from.y }, { x: destX, y: destY, duration, ease }, start)
522
- } else {
523
- tl.to(el, { x: destX, y: destY, duration, ease }, start)
524
- }
525
- chipRide(start, duration)
526
- state.pos = p
527
- state.visible = true
528
- return duration
529
- }
530
-
531
- const doExit = (opts: CursorExitOpts, start: number): number => {
532
- if (!state.visible || !state.pos) {
533
- warn(`exit-${name}`, `cursor "${name}" exit was skipped — it is not on screen.`)
534
- return 0
535
- }
536
- const duration = opts.duration ?? DEFAULTS.exitDuration
537
- const ease = opts.ease ?? DEFAULTS.ease
538
- if ((opts.via ?? 'edge') === 'edge') {
539
- const out = place(nearestEdgeStart(state.pos, config, state.size * 1.5))
540
- tl.to(el, { x: out.x, y: out.y, duration, ease }, start)
541
- }
542
- tl.to(
543
- el,
544
- { autoAlpha: 0, duration: Math.min(0.15, duration), ease: 'ease-in-quad' },
545
- start + duration - Math.min(0.15, duration)
546
- )
547
- morphTo(state.baseIcon, start + duration)
548
- state.visible = false
549
- state.pos = null
550
- state.clock = start + duration
551
- return duration
552
- }
553
-
554
- const handleThen = (then: CursorThen | undefined, at: number): number => {
555
- if (!then || then === 'stay') return 0
556
- if (then === 'exit') return 0.25 + doExit({}, at + 0.25)
557
- if (then === 'park') {
558
- const p = { x: state.pos!.x + 56, y: state.pos!.y + 46 }
559
- const dest = place(p)
560
- tl.to(el, { x: dest.x, y: dest.y, duration: 0.35, ease: DEFAULTS.ease }, at + 0.2)
561
- state.pos = p
562
- return 0.55
563
- }
564
- // A selector: travel there and park.
565
- const point = anchorPoint(then, {}, 'cursor.then')
566
- tl.to(
567
- el,
568
- {
569
- x: () => place(point.resolve()).x,
570
- y: () => place(point.resolve()).y,
571
- duration: 0.6,
572
- ease: DEFAULTS.ease,
573
- },
574
- at + 0.3
575
- )
576
- state.pos = { x: point.x, y: point.y }
577
- return 0.9
578
- }
579
-
580
- const actor: CursorActor = {
581
- to: (target, opts = {}, position) => {
582
- const start = begin(position)
583
- const point = anchorPoint(target, opts, 'cursor.to')
584
- const duration = travelTo(point, opts, start, point.resolve)
585
- state.clock = start + duration
586
- return { duration }
587
- },
588
-
589
- click: (target, opts = {}, position) => {
590
- const start = begin(position)
591
- const point = anchorPoint(target, opts, 'cursor.click')
592
- const travel = travelTo(point, opts, start, point.resolve)
593
- // Arrow melts into the pointer hand over the last stretch of travel —
594
- // the detail that makes a click read as intentional.
595
- if (state.baseIcon === 'arrow') morphTo('pointer', start + travel * 0.85)
596
- const each = opts.pressDuration ?? DEFAULTS.pressDuration
597
- const arrive = start + travel
598
- squash(
599
- el,
600
- 0.9,
601
- each,
602
- arrive,
603
- `${state.hotspot.x * 100}% ${state.hotspot.y * 100}%`
604
- )
605
- const amount =
606
- typeof opts.press === 'number' ? opts.press : autoPressAmount(point.rect)
607
- squash(point.el, amount, each, arrive)
608
- if (state.baseIcon === 'arrow') morphTo('arrow', arrive + each * 2 + 0.3)
609
- state.clock = arrive + each * 2
610
- const tail = handleThen(opts.then, state.clock)
611
- state.clock += tail
612
- return { duration: travel + each * 2 + tail }
613
- },
614
-
615
- drag: (payload, to, opts = {}, position) => {
616
- const start = begin(position)
617
- const payloadEl = resolveTarget(stage, payload, 'cursor.drag')
618
- const payloadRect = layoutRect(payloadEl)
619
- const gax = opts.anchor?.x ?? 0.5
620
- const gay = opts.anchor?.y ?? 0.5
621
- const grabResolve = () => {
622
- const r = visualRect(payloadEl)
623
- return { x: r.left + r.width * gax, y: r.top + r.height * gay }
624
- }
625
- const grabPoint = {
626
- el: payloadEl,
627
- rect: payloadRect,
628
- x: payloadRect.left + payloadRect.width * gax,
629
- y: payloadRect.top + payloadRect.height * gay,
630
- }
631
- const approach = travelTo(
632
- { x: grabPoint.x, y: grabPoint.y },
633
- { ...opts, duration: 0.6 },
634
- start,
635
- grabResolve
636
- )
637
- const grabAt = start + approach
638
- morphTo('grab', grabAt - 0.1)
639
- morphTo('grabbing', grabAt + 0.08)
640
-
641
- let delta: { x: number; y: number }
642
- if (typeof to === 'object' && 'x' in to && !('nodeType' in (to as object))) {
643
- delta = { x: (to as { x: number }).x, y: (to as { y: number }).y }
644
- } else {
645
- const dest = resolveTarget(stage, to as string | Element, 'cursor.drag destination')
646
- const destRect = layoutRect(dest)
647
- delta = {
648
- x: destRect.left + destRect.width / 2 - (payloadRect.left + payloadRect.width / 2),
649
- y: destRect.top + destRect.height / 2 - (payloadRect.top + payloadRect.height / 2),
650
- }
651
- }
652
-
653
- const carry = opts.duration ?? 1.0
654
- const ease = opts.ease ?? DEFAULTS.ease
655
- const holdBeat = 0.16
656
- const carryAt = grabAt + holdBeat
657
- if (opts.lift !== false) {
658
- tl.to(payloadEl, { scale: 1.04, duration: holdBeat, ease: 'ease-out-quad' }, grabAt)
659
- tl.to(payloadEl, { scale: 1, duration: 0.2, ease: 'ease-out-quad' }, carryAt + carry)
660
- }
661
- // One delta, two targets, identical timing — the pair cannot desync.
662
- // The payload's delta is layout-space: payload and destination share a
663
- // container, so any ancestor scale applies to both equally and the
664
- // authored delta is exactly the transform the payload needs. The CURSOR
665
- // lives in stage space, so its version of the same delta must be
666
- // multiplied by whatever the container's ancestors scale it to on
667
- // screen — measured at carry time, after any shell zoom has applied.
668
- // This tween is inserted BEFORE the payload's, so its function values
669
- // resolve while the payload still sits at the grab point.
670
- const carryDest = () => {
671
- const grab = grabResolve()
672
- const scale = payloadRect.width ? visualRect(payloadEl).width / payloadRect.width : 1
673
- return place({ x: grab.x + delta.x * scale, y: grab.y + delta.y * scale })
674
- }
675
- tl.to(el, { x: () => carryDest().x, y: () => carryDest().y, duration: carry, ease }, carryAt)
676
- tl.to(payloadEl, { x: `+=${delta.x}`, y: `+=${delta.y}`, duration: carry, ease }, carryAt)
677
- morphTo('grab', carryAt + carry + 0.02)
678
- morphTo('arrow', carryAt + carry + 0.24)
679
- state.pos = { x: state.pos!.x + delta.x, y: state.pos!.y + delta.y }
680
- state.clock = carryAt + carry + 0.2
681
- const tail = handleThen(opts.then, state.clock)
682
- state.clock += tail
683
- return { duration: state.clock - start }
684
- },
685
-
686
- exit: (opts = {}, position) => {
687
- const start = begin(position)
688
- return { duration: doExit(opts, start) }
689
- },
690
-
691
- label: (text, position) => {
692
- if (!chip) {
693
- warn(`chip-${name}`, `cursor "${name}" has no label chip — declare it with a label.`)
694
- return
695
- }
696
- const at = typeof position === 'number' ? position : (state.clock ?? tl.duration())
697
- tl.set(chip, { textContent: text, autoAlpha: 1 }, at)
698
- tl.to(chip, { autoAlpha: 0, duration: 0.18, ease: 'ease-in-quad' }, at + 1.2)
699
- },
700
- }
701
- return actor
702
- }
703
-
704
- const actors = new Map<string, CursorActor>()
705
- const defaultActor = () => {
706
- let actor = actors.get('__default')
707
- if (!actor) {
708
- actor = buildActor('main', {})
709
- actors.set('__default', actor)
710
- }
711
- return actor
712
- }
713
-
714
- const cursor: CursorKit = {
715
- to: (target, opts, position) => defaultActor().to(target, opts, position),
716
- click: (target, opts, position) => defaultActor().click(target, opts, position),
717
- drag: (payload, to, opts, position) => defaultActor().drag(payload, to, opts, position),
718
- exit: (opts, position) => defaultActor().exit(opts, position),
719
- label: (text, position) => defaultActor().label(text, position),
720
- actor: (name, opts = {}) => {
721
- let actor = actors.get(name)
722
- if (!actor) {
723
- actor = buildActor(name, opts)
724
- actors.set(name, actor)
725
- }
726
- return actor
727
- },
728
- }
729
-
730
- const press: ActionsKit['press'] = (target, opts = {}, position) => {
731
- const el = resolveTarget(stage, target, 'press')
732
- const rect = layoutRect(el)
733
- const each = opts.duration ?? DEFAULTS.pressDuration
734
- const amount = typeof opts.amount === 'number' ? opts.amount : autoPressAmount(rect)
735
- const at = resolvePosition(position, tl.duration())
736
- tl.to(
737
- el,
738
- {
739
- scale: amount,
740
- duration: each,
741
- repeat: 1,
742
- yoyo: true,
743
- ease: opts.ease ?? 'ease-in-out-quad',
744
- transformOrigin: '50% 50%',
745
- },
746
- at
747
- )
748
- return { duration: each * 2 }
749
- }
750
-
751
- const scroll: ActionsKit['scroll'] = (container, to, opts = {}, position) => {
752
- const el = resolveTarget(stage, container, 'scroll')
753
- let desired: number
754
- if (typeof to === 'number') {
755
- desired = to
756
- } else {
757
- const targetEl = resolveTarget(stage, to, 'scroll target')
758
- const crect = layoutRect(el)
759
- const trect = layoutRect(targetEl)
760
- const align = opts.align ?? 0.5
761
- const clientH = el.clientHeight
762
- desired = el.scrollTop + (trect.top - crect.top) - (clientH - trect.height) * align
763
- }
764
- desired = Math.max(0, Math.min(desired, el.scrollHeight - el.clientHeight))
765
- const duration = opts.duration ?? 0.7
766
- const at = resolvePosition(position, tl.duration())
767
- if (duration <= 0) {
768
- tl.set(el, { scrollTop: desired }, at)
769
- return { duration: 0 }
770
- }
771
- tl.to(el, { scrollTop: desired, duration, ease: opts.ease ?? DEFAULTS.ease }, at)
772
- return { duration }
773
- }
774
-
775
- const uiSet: ActionsKit['ui']['set'] = (target, change, position) => {
776
- const el = resolveTarget(stage, target, 'ui.set')
777
- const at = resolvePosition(position, tl.duration())
778
- const vars: gsap.TweenVars = {}
779
- if (change.text !== undefined) vars.textContent = change.text
780
- if (change.value !== undefined) (vars as Record<string, unknown>).value = change.value
781
- if (change.src !== undefined) (vars as Record<string, unknown>).src = change.src
782
- if (change.attrs) vars.attr = change.attrs
783
- if (change.classes) {
784
- const current = new Set((el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean))
785
- for (const cls of change.classes.add ?? []) current.add(cls)
786
- for (const cls of change.classes.remove ?? []) current.delete(cls)
787
- ;(vars as Record<string, unknown>).className = Array.from(current).join(' ')
788
- }
789
- if (Object.keys(vars).length) tl.set(el, vars, at)
790
- if (change.flash) {
791
- tl.fromTo(
792
- el,
793
- { filter: 'brightness(1)' },
794
- { filter: 'brightness(1.35)', duration: 0.12, repeat: 1, yoyo: true, ease: 'ease-in-out-quad' },
795
- at
796
- )
797
- return { duration: 0.24 }
798
- }
799
- return { duration: 0 }
800
- }
801
-
802
- return { cursor, press, scroll, ui: { set: uiSet } }
803
- }