@texuli/core 0.5.0 → 0.7.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 +29 -5
- package/dist/editor/document.js +8 -1
- package/dist/editor/extensions.d.ts +64 -0
- package/dist/editor/extensions.js +116 -0
- package/dist/editor/inline.d.ts +5 -3
- package/dist/editor/inline.js +24 -12
- package/dist/editor/load.d.ts +10 -0
- package/dist/editor/load.js +16 -0
- package/dist/editor/markdown.d.ts +10 -1
- package/dist/editor/markdown.js +193 -83
- package/dist/editor/model.d.ts +3 -1
- package/dist/editor/model.js +3 -1
- package/dist/editor/transforms.d.ts +1 -1
- package/dist/editor/tree.d.ts +11 -1
- package/dist/editor/tree.js +11 -2
- package/dist/editor/types.d.ts +10 -0
- package/dist/editor/types.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +5 -3
- package/dist/markup/index.d.ts +5 -0
- package/dist/markup/index.js +5 -0
- package/dist/markup/markup.d.ts +72 -0
- package/dist/markup/markup.js +233 -0
- package/dist/motion/animate.d.ts +109 -0
- package/dist/motion/animate.js +225 -0
- package/dist/motion/flip.d.ts +58 -0
- package/dist/motion/flip.js +167 -0
- package/dist/motion/index.d.ts +15 -0
- package/dist/motion/index.js +15 -0
- package/dist/motion/preferences.d.ts +68 -0
- package/dist/motion/preferences.js +111 -0
- package/dist/motion/presence.d.ts +61 -0
- package/dist/motion/presence.js +231 -0
- package/dist/motion/sequence.d.ts +70 -0
- package/dist/motion/sequence.js +159 -0
- package/dist/motion/shared.d.ts +45 -0
- package/dist/motion/shared.js +144 -0
- package/dist/motion/spring.d.ts +109 -0
- package/dist/motion/spring.js +166 -0
- package/dist/overlay/core.js +14 -3
- package/dist/zip/index.d.ts +5 -0
- package/dist/zip/index.js +5 -0
- package/dist/zip/zip.d.ts +35 -0
- package/dist/zip/zip.js +190 -0
- package/package.json +6 -2
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion presence: enter and exit animations with interruptible reversal
|
|
3
|
+
* (ADR-013), on the `@texuli/core/presence` vocabulary (`PresenceSnapshot`,
|
|
4
|
+
* `data-state`).
|
|
5
|
+
*
|
|
6
|
+
* The core presence waits for CSS `transitionend` / `animationend`; Web
|
|
7
|
+
* Animations fire neither, and an unrelated CSS transition on the node would
|
|
8
|
+
* end the exit early, so this machine drives the same snapshot from its own
|
|
9
|
+
* animations instead. Toggling mid-flight reverses the running animation from
|
|
10
|
+
* where it is (no jump) and resolves the earlier transition `"interrupted"`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing animates on the server or on hydration: the first attached node
|
|
13
|
+
* with `present: true` shows at once unless `appear` is set.
|
|
14
|
+
*/
|
|
15
|
+
import { PRESENCE_STATE_ATTR, } from "../presence/presence.js";
|
|
16
|
+
import { resolveMotion, startAnimation, } from "./animate.js";
|
|
17
|
+
const DEFAULT_ENTER = [{ opacity: 0 }, { opacity: 1 }];
|
|
18
|
+
function reverseFrames(frames) {
|
|
19
|
+
if (Array.isArray(frames)) {
|
|
20
|
+
return [...frames].reverse().map((frame) => {
|
|
21
|
+
const { offset, ...rest } = frame;
|
|
22
|
+
return typeof offset === 'number' ? { ...rest, offset: 1 - offset } : rest;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(frames)) {
|
|
27
|
+
out[key] = Array.isArray(value) ? [...value].reverse() : value;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a motion presence controller.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const presence = createMotionPresence({
|
|
36
|
+
* enter: [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
|
|
37
|
+
* onExitComplete: (s) => { if (!s.mounted) unmount(); },
|
|
38
|
+
* });
|
|
39
|
+
* presence.setNode(panel);
|
|
40
|
+
* await presence.setPresent(false); // "finished", or "instant" under reduced motion
|
|
41
|
+
*/
|
|
42
|
+
export function createMotionPresence(options = {}) {
|
|
43
|
+
const forceMount = options.forceMount === true;
|
|
44
|
+
const enterFrames = options.enter ?? DEFAULT_ENTER;
|
|
45
|
+
const exitFrames = options.exit ?? reverseFrames(enterFrames);
|
|
46
|
+
const timing = options;
|
|
47
|
+
const exitTiming = { ...options, ...options.exitTiming };
|
|
48
|
+
let present = options.present === true;
|
|
49
|
+
let mounted = present || forceMount;
|
|
50
|
+
let state = present ? 'open' : 'closed';
|
|
51
|
+
let exiting = false;
|
|
52
|
+
let node = null;
|
|
53
|
+
let pendingEnter = present && options.appear === true;
|
|
54
|
+
let animation = null;
|
|
55
|
+
let direction = null;
|
|
56
|
+
let disposed = false;
|
|
57
|
+
let settle = null;
|
|
58
|
+
let finished = Promise.resolve('instant');
|
|
59
|
+
const snapshot = () => ({ present, mounted, state, exiting });
|
|
60
|
+
const writeAttr = () => {
|
|
61
|
+
node?.setAttribute(PRESENCE_STATE_ATTR, state);
|
|
62
|
+
};
|
|
63
|
+
const emit = () => options.onSnapshot?.(snapshot());
|
|
64
|
+
function begin() {
|
|
65
|
+
settle?.('interrupted');
|
|
66
|
+
let resolve = () => { };
|
|
67
|
+
finished = new Promise((r) => {
|
|
68
|
+
resolve = r;
|
|
69
|
+
});
|
|
70
|
+
let done = false;
|
|
71
|
+
settle = (outcome) => {
|
|
72
|
+
if (done)
|
|
73
|
+
return;
|
|
74
|
+
done = true;
|
|
75
|
+
settle = null;
|
|
76
|
+
resolve(outcome);
|
|
77
|
+
};
|
|
78
|
+
return finished;
|
|
79
|
+
}
|
|
80
|
+
function dropAnimation() {
|
|
81
|
+
const current = animation;
|
|
82
|
+
animation = null;
|
|
83
|
+
direction = null;
|
|
84
|
+
if (current) {
|
|
85
|
+
current.onfinish = null;
|
|
86
|
+
try {
|
|
87
|
+
current.cancel();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Already gone.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function complete(dir, outcome) {
|
|
95
|
+
animation = null;
|
|
96
|
+
direction = null;
|
|
97
|
+
if (dir === 'exit') {
|
|
98
|
+
exiting = false;
|
|
99
|
+
state = 'closed';
|
|
100
|
+
if (!forceMount)
|
|
101
|
+
mounted = false;
|
|
102
|
+
writeAttr();
|
|
103
|
+
const s = snapshot();
|
|
104
|
+
options.onSnapshot?.(s);
|
|
105
|
+
options.onExitComplete?.(s);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
options.onEnterComplete?.(snapshot());
|
|
109
|
+
}
|
|
110
|
+
settle?.(outcome);
|
|
111
|
+
}
|
|
112
|
+
function play(dir) {
|
|
113
|
+
const target = node;
|
|
114
|
+
const plan = resolveMotion(target, dir === 'enter' ? enterFrames : exitFrames, dir === 'enter' ? timing : exitTiming);
|
|
115
|
+
const started = !plan.instant && target ? startAnimation(target, plan) : null;
|
|
116
|
+
if (!started) {
|
|
117
|
+
complete(dir, 'instant');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
animation = started;
|
|
121
|
+
direction = dir;
|
|
122
|
+
started.onfinish = () => {
|
|
123
|
+
if (animation !== started || direction === null)
|
|
124
|
+
return;
|
|
125
|
+
complete(direction, 'finished');
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Reverse the running animation in place; false when there is none to reverse. */
|
|
129
|
+
function reverseTo(dir) {
|
|
130
|
+
const current = animation;
|
|
131
|
+
if (!current || direction === dir || typeof current.reverse !== 'function')
|
|
132
|
+
return false;
|
|
133
|
+
try {
|
|
134
|
+
current.reverse();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
direction = dir;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
getSnapshot: snapshot,
|
|
144
|
+
get finished() {
|
|
145
|
+
return finished;
|
|
146
|
+
},
|
|
147
|
+
setPresent(next) {
|
|
148
|
+
if (disposed)
|
|
149
|
+
return Promise.resolve('cancelled');
|
|
150
|
+
if (next) {
|
|
151
|
+
if (present && state === 'open' && !exiting)
|
|
152
|
+
return finished;
|
|
153
|
+
present = true;
|
|
154
|
+
exiting = false;
|
|
155
|
+
mounted = true;
|
|
156
|
+
state = 'open';
|
|
157
|
+
writeAttr();
|
|
158
|
+
const result = begin();
|
|
159
|
+
emit();
|
|
160
|
+
options.onEnter?.(snapshot());
|
|
161
|
+
if (reverseTo('enter'))
|
|
162
|
+
return result;
|
|
163
|
+
dropAnimation();
|
|
164
|
+
if (node)
|
|
165
|
+
play('enter');
|
|
166
|
+
else
|
|
167
|
+
pendingEnter = true;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
present = false;
|
|
171
|
+
if (!mounted || (state === 'closed' && !exiting)) {
|
|
172
|
+
state = 'closed';
|
|
173
|
+
writeAttr();
|
|
174
|
+
return finished;
|
|
175
|
+
}
|
|
176
|
+
if (exiting)
|
|
177
|
+
return finished;
|
|
178
|
+
pendingEnter = false;
|
|
179
|
+
exiting = true;
|
|
180
|
+
state = 'closed';
|
|
181
|
+
writeAttr();
|
|
182
|
+
const result = begin();
|
|
183
|
+
emit();
|
|
184
|
+
if (reverseTo('exit'))
|
|
185
|
+
return result;
|
|
186
|
+
dropAnimation();
|
|
187
|
+
if (node)
|
|
188
|
+
play('exit');
|
|
189
|
+
else
|
|
190
|
+
complete('exit', 'instant');
|
|
191
|
+
return result;
|
|
192
|
+
},
|
|
193
|
+
setNode(next) {
|
|
194
|
+
if (disposed || next === node)
|
|
195
|
+
return;
|
|
196
|
+
if (animation) {
|
|
197
|
+
// The animated element changed: its animation cannot continue elsewhere.
|
|
198
|
+
const dir = direction ?? 'enter';
|
|
199
|
+
dropAnimation();
|
|
200
|
+
node = next;
|
|
201
|
+
writeAttr();
|
|
202
|
+
if (next)
|
|
203
|
+
play(dir);
|
|
204
|
+
else
|
|
205
|
+
complete(dir, 'instant');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
node = next;
|
|
209
|
+
writeAttr();
|
|
210
|
+
if (!next) {
|
|
211
|
+
if (exiting)
|
|
212
|
+
complete('exit', 'instant');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (pendingEnter && present) {
|
|
216
|
+
pendingEnter = false;
|
|
217
|
+
if (!settle)
|
|
218
|
+
begin();
|
|
219
|
+
play('enter');
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
dispose() {
|
|
223
|
+
if (disposed)
|
|
224
|
+
return;
|
|
225
|
+
disposed = true;
|
|
226
|
+
dropAnimation();
|
|
227
|
+
settle?.('cancelled');
|
|
228
|
+
node = null;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequences and timelines (ADR-013): declared steps played as Web Animations
|
|
3
|
+
* on one shared clock.
|
|
4
|
+
*
|
|
5
|
+
* Top-level entries run one after another; a `parallel(...)` group runs its
|
|
6
|
+
* steps together (the group lasts as long as its longest step). Each step may
|
|
7
|
+
* add an `at` offset inside its slot, which is how `stagger` spreads a list.
|
|
8
|
+
* Every animation gets the same total length (`delay` + `duration` +
|
|
9
|
+
* `endDelay`), so `seek`, `pause` and `reverse` keep the steps aligned.
|
|
10
|
+
*
|
|
11
|
+
* Under reduced motion, at level `none`, without the Web Animations API or on
|
|
12
|
+
* the server, the timeline commits at once: `finished` resolves `"instant"`
|
|
13
|
+
* and `onFinish` fires once; play, pause, seek and reverse do nothing.
|
|
14
|
+
*/
|
|
15
|
+
import { type InstantReason, type MotionKeyframes, type MotionOutcome, type MotionTiming } from './animate.js';
|
|
16
|
+
/** One animated step. */
|
|
17
|
+
export type TimelineStep = {
|
|
18
|
+
readonly target: Element | null | undefined;
|
|
19
|
+
readonly keyframes: MotionKeyframes;
|
|
20
|
+
/** Milliseconds (clamped to [0, 1000]). Default: the timeline's `duration`. */
|
|
21
|
+
readonly duration?: number;
|
|
22
|
+
readonly easing?: string;
|
|
23
|
+
/** Extra offset inside the step's slot, in ms (clamped to [0, 1000]). */
|
|
24
|
+
readonly at?: number;
|
|
25
|
+
};
|
|
26
|
+
/** A group whose steps run together. */
|
|
27
|
+
export type ParallelGroup = {
|
|
28
|
+
readonly parallel: readonly TimelineStep[];
|
|
29
|
+
};
|
|
30
|
+
export type TimelineEntry = TimelineStep | ParallelGroup;
|
|
31
|
+
export type TimelineOptions = MotionTiming & {
|
|
32
|
+
/** Start playing at once. `timeline` defaults to `false`, `sequence` to `true`. */
|
|
33
|
+
autoplay?: boolean;
|
|
34
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
35
|
+
};
|
|
36
|
+
export type Timeline = {
|
|
37
|
+
/** Total length in ms after the level is applied (0 when instant). */
|
|
38
|
+
readonly duration: number;
|
|
39
|
+
readonly finished: Promise<MotionOutcome>;
|
|
40
|
+
readonly instantReason: InstantReason | null;
|
|
41
|
+
readonly animations: readonly Animation[];
|
|
42
|
+
play: () => void;
|
|
43
|
+
pause: () => void;
|
|
44
|
+
/** Move every step to `ms` on the shared clock (clamped to [0, duration]). */
|
|
45
|
+
seek: (ms: number) => void;
|
|
46
|
+
/** Play backwards from the current time. */
|
|
47
|
+
reverse: () => void;
|
|
48
|
+
cancel: () => void;
|
|
49
|
+
finish: () => void;
|
|
50
|
+
};
|
|
51
|
+
/** Most steps one timeline animates; past it the timeline commits at once. */
|
|
52
|
+
export declare const MAX_TIMELINE_STEPS = 500;
|
|
53
|
+
/** Longest stagger spread, in ms: a long list compresses its per-item offset to fit. */
|
|
54
|
+
export declare const MAX_STAGGER_SPAN = 1000;
|
|
55
|
+
/** Group steps to run in parallel. */
|
|
56
|
+
export declare function parallel(...steps: TimelineStep[]): ParallelGroup;
|
|
57
|
+
export type StaggerOptions = {
|
|
58
|
+
/** Which item starts first. Default `"start"`. */
|
|
59
|
+
from?: 'start' | 'end' | 'center' | number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Offsets in ms for `count` items `each` ms apart, spread from `from`. The
|
|
63
|
+
* whole spread is held to {@link MAX_STAGGER_SPAN} (a long list staggers
|
|
64
|
+
* faster instead of taking longer). `count` is clamped to [0, 10000].
|
|
65
|
+
*/
|
|
66
|
+
export declare function stagger(count: number, each: number, options?: StaggerOptions): number[];
|
|
67
|
+
/** Build a timeline, paused until `play()` unless `autoplay` is true. */
|
|
68
|
+
export declare function timeline(entries: readonly TimelineEntry[], options?: TimelineOptions): Timeline;
|
|
69
|
+
/** Play steps now (a timeline that autoplays unless `autoplay: false`). */
|
|
70
|
+
export declare function sequence(entries: readonly TimelineEntry[], options?: TimelineOptions): Timeline;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequences and timelines (ADR-013): declared steps played as Web Animations
|
|
3
|
+
* on one shared clock.
|
|
4
|
+
*
|
|
5
|
+
* Top-level entries run one after another; a `parallel(...)` group runs its
|
|
6
|
+
* steps together (the group lasts as long as its longest step). Each step may
|
|
7
|
+
* add an `at` offset inside its slot, which is how `stagger` spreads a list.
|
|
8
|
+
* Every animation gets the same total length (`delay` + `duration` +
|
|
9
|
+
* `endDelay`), so `seek`, `pause` and `reverse` keep the steps aligned.
|
|
10
|
+
*
|
|
11
|
+
* Under reduced motion, at level `none`, without the Web Animations API or on
|
|
12
|
+
* the server, the timeline commits at once: `finished` resolves `"instant"`
|
|
13
|
+
* and `onFinish` fires once; play, pause, seek and reverse do nothing.
|
|
14
|
+
*/
|
|
15
|
+
import { createRun, resolveMotion, startAnimation, } from "./animate.js";
|
|
16
|
+
import { MAX_MOTION_MS, motionDuration } from "./preferences.js";
|
|
17
|
+
/** Most steps one timeline animates; past it the timeline commits at once. */
|
|
18
|
+
export const MAX_TIMELINE_STEPS = 500;
|
|
19
|
+
/** Longest stagger spread, in ms: a long list compresses its per-item offset to fit. */
|
|
20
|
+
export const MAX_STAGGER_SPAN = MAX_MOTION_MS;
|
|
21
|
+
/** Group steps to run in parallel. */
|
|
22
|
+
export function parallel(...steps) {
|
|
23
|
+
return { parallel: steps };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Offsets in ms for `count` items `each` ms apart, spread from `from`. The
|
|
27
|
+
* whole spread is held to {@link MAX_STAGGER_SPAN} (a long list staggers
|
|
28
|
+
* faster instead of taking longer). `count` is clamped to [0, 10000].
|
|
29
|
+
*/
|
|
30
|
+
export function stagger(count, each, options = {}) {
|
|
31
|
+
const n = Math.max(0, Math.min(10_000, Math.floor(Number.isFinite(count) ? count : 0)));
|
|
32
|
+
if (n === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const origin = options.from === 'end'
|
|
35
|
+
? n - 1
|
|
36
|
+
: options.from === 'center'
|
|
37
|
+
? (n - 1) / 2
|
|
38
|
+
: typeof options.from === 'number' && Number.isFinite(options.from)
|
|
39
|
+
? Math.max(0, Math.min(n - 1, Math.floor(options.from)))
|
|
40
|
+
: 0;
|
|
41
|
+
const reach = Math.max(origin, n - 1 - origin);
|
|
42
|
+
const step = Number.isFinite(each) && each > 0 ? each : 0;
|
|
43
|
+
const per = reach > 0 ? Math.min(step, MAX_STAGGER_SPAN / reach) : 0;
|
|
44
|
+
return Array.from({ length: n }, (_, i) => Math.round(Math.abs(i - origin) * per * 100) / 100);
|
|
45
|
+
}
|
|
46
|
+
function planOf(step, options) {
|
|
47
|
+
return resolveMotion(step?.target, step?.keyframes ?? [], {
|
|
48
|
+
...options,
|
|
49
|
+
duration: step?.duration ?? options.duration ?? 200,
|
|
50
|
+
easing: step?.easing ?? options.easing,
|
|
51
|
+
delay: 0,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Resolve every step, then lay the slots out with the resolved durations. */
|
|
55
|
+
function schedule(entries, options) {
|
|
56
|
+
const level = options.level ?? 'full';
|
|
57
|
+
const out = [];
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
const slot = (step, base) => {
|
|
60
|
+
const plan = planOf(step, options);
|
|
61
|
+
const at = motionDuration(step?.at ?? 0, level);
|
|
62
|
+
out.push({ step, start: base + at, plan });
|
|
63
|
+
return at + (plan.instant ? 0 : plan.duration);
|
|
64
|
+
};
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!entry)
|
|
67
|
+
continue;
|
|
68
|
+
if ('parallel' in entry) {
|
|
69
|
+
let longest = 0;
|
|
70
|
+
for (const step of entry.parallel ?? []) {
|
|
71
|
+
if (out.length >= MAX_TIMELINE_STEPS)
|
|
72
|
+
return null;
|
|
73
|
+
longest = Math.max(longest, slot(step, cursor));
|
|
74
|
+
}
|
|
75
|
+
cursor += longest;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (out.length >= MAX_TIMELINE_STEPS)
|
|
79
|
+
return null;
|
|
80
|
+
cursor += slot(entry, cursor);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function build(entries, options, autoplay) {
|
|
86
|
+
const instant = (reason) => {
|
|
87
|
+
const run = createRun([], options.onFinish, reason);
|
|
88
|
+
return {
|
|
89
|
+
duration: 0,
|
|
90
|
+
finished: run.finished,
|
|
91
|
+
instantReason: run.instantReason,
|
|
92
|
+
animations: [],
|
|
93
|
+
play() { },
|
|
94
|
+
pause() { },
|
|
95
|
+
seek() { },
|
|
96
|
+
reverse() { },
|
|
97
|
+
cancel() { },
|
|
98
|
+
finish() { },
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const scheduled = schedule(Array.isArray(entries) ? entries : [], options);
|
|
102
|
+
if (!scheduled)
|
|
103
|
+
return instant('limit');
|
|
104
|
+
const live = scheduled.filter((p) => !p.plan.instant);
|
|
105
|
+
if (live.length === 0) {
|
|
106
|
+
const first = scheduled.find((p) => p.plan.instant)?.plan;
|
|
107
|
+
return instant(first?.instant ? first.reason : 'zero-duration');
|
|
108
|
+
}
|
|
109
|
+
const total = Math.max(...live.map((p) => p.start + (p.plan.instant ? 0 : p.plan.duration)));
|
|
110
|
+
const animations = [];
|
|
111
|
+
for (const { step, start, plan } of live) {
|
|
112
|
+
if (plan.instant || !step.target)
|
|
113
|
+
continue;
|
|
114
|
+
const animation = startAnimation(step.target, { ...plan, delay: start }, { endDelay: Math.max(0, total - start - plan.duration) });
|
|
115
|
+
if (!animation)
|
|
116
|
+
continue;
|
|
117
|
+
if (!autoplay)
|
|
118
|
+
animation.pause();
|
|
119
|
+
animations.push(animation);
|
|
120
|
+
}
|
|
121
|
+
if (animations.length === 0)
|
|
122
|
+
return instant('error');
|
|
123
|
+
const run = createRun(animations, options.onFinish);
|
|
124
|
+
const each = (fn) => () => {
|
|
125
|
+
for (const a of animations) {
|
|
126
|
+
try {
|
|
127
|
+
fn(a);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// A finished or cancelled animation ignores the command.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
duration: total,
|
|
136
|
+
finished: run.finished,
|
|
137
|
+
instantReason: null,
|
|
138
|
+
animations,
|
|
139
|
+
play: each((a) => a.play()),
|
|
140
|
+
pause: each((a) => a.pause()),
|
|
141
|
+
reverse: each((a) => a.reverse()),
|
|
142
|
+
seek(ms) {
|
|
143
|
+
const t = Number.isFinite(ms) ? Math.max(0, Math.min(total, ms)) : 0;
|
|
144
|
+
each((a) => {
|
|
145
|
+
a.currentTime = t;
|
|
146
|
+
})();
|
|
147
|
+
},
|
|
148
|
+
cancel: run.cancel,
|
|
149
|
+
finish: run.finish,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Build a timeline, paused until `play()` unless `autoplay` is true. */
|
|
153
|
+
export function timeline(entries, options = {}) {
|
|
154
|
+
return build(entries, options, options.autoplay === true);
|
|
155
|
+
}
|
|
156
|
+
/** Play steps now (a timeline that autoplays unless `autoplay: false`). */
|
|
157
|
+
export function sequence(entries, options = {}) {
|
|
158
|
+
return build(entries, options, options.autoplay !== false);
|
|
159
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-element transitions (ADR-013): a keyed snapshot of one element's box
|
|
3
|
+
* before a state change, played onto the element that represents the same
|
|
4
|
+
* thing after it (a thumbnail and its lightbox, a card and its detail view).
|
|
5
|
+
*
|
|
6
|
+
* The fallback everywhere is FLIP: the new element moves and scales from the
|
|
7
|
+
* captured box; when the old element is still on the page the two cross-fade.
|
|
8
|
+
* {@link startScopedViewTransition} uses an element-scoped View Transition
|
|
9
|
+
* when the browser has one and otherwise just applies the update.
|
|
10
|
+
*/
|
|
11
|
+
import { type MotionOutcome, type MotionRun, type MotionTiming } from './animate.js';
|
|
12
|
+
export type SharedElementsOptions = MotionTiming & {
|
|
13
|
+
/** Snapshots kept at most; the oldest is dropped past it. Default 256 (clamped to [1, 4096]). */
|
|
14
|
+
maxEntries?: number;
|
|
15
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
16
|
+
};
|
|
17
|
+
export type SharedElements = {
|
|
18
|
+
/** Record where `element` is now under `key`. False when it cannot be measured (detached, SSR). */
|
|
19
|
+
capture: (key: string, element: Element) => boolean;
|
|
20
|
+
/** Animate `element` from the box captured under `key`, then forget the snapshot. */
|
|
21
|
+
play: (key: string, element: Element) => MotionRun;
|
|
22
|
+
has: (key: string) => boolean;
|
|
23
|
+
release: (key: string) => void;
|
|
24
|
+
clear: () => void;
|
|
25
|
+
/** How many snapshots are held. */
|
|
26
|
+
readonly size: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Create a registry of keyed snapshots.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const shared = createSharedElements({ duration: 250 });
|
|
33
|
+
* shared.capture('photo-3', thumbnail);
|
|
34
|
+
* openLightbox(); // the DOM changes
|
|
35
|
+
* shared.play('photo-3', lightboxImage);
|
|
36
|
+
*/
|
|
37
|
+
export declare function createSharedElements(options?: SharedElementsOptions): SharedElements;
|
|
38
|
+
export type ScopedViewTransitionOptions = Pick<MotionTiming, 'level' | 'reducedMotion'>;
|
|
39
|
+
/**
|
|
40
|
+
* Run `update` inside an element-scoped View Transition when the browser
|
|
41
|
+
* offers `element.startViewTransition`; otherwise (no support, the server,
|
|
42
|
+
* level `none`, reduced motion) apply it at once. Resolves `"finished"` after
|
|
43
|
+
* the transition, `"instant"` when the update was applied directly.
|
|
44
|
+
*/
|
|
45
|
+
export declare function startScopedViewTransition(scope: Element | null | undefined, update: () => void | Promise<void>, options?: ScopedViewTransitionOptions): Promise<MotionOutcome>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-element transitions (ADR-013): a keyed snapshot of one element's box
|
|
3
|
+
* before a state change, played onto the element that represents the same
|
|
4
|
+
* thing after it (a thumbnail and its lightbox, a card and its detail view).
|
|
5
|
+
*
|
|
6
|
+
* The fallback everywhere is FLIP: the new element moves and scales from the
|
|
7
|
+
* captured box; when the old element is still on the page the two cross-fade.
|
|
8
|
+
* {@link startScopedViewTransition} uses an element-scoped View Transition
|
|
9
|
+
* when the browser has one and otherwise just applies the update.
|
|
10
|
+
*/
|
|
11
|
+
import { createRun, resolveMotion, startAnimation, } from "./animate.js";
|
|
12
|
+
import { flipTransform } from "./flip.js";
|
|
13
|
+
import { resolveMotionLevel } from "./preferences.js";
|
|
14
|
+
function measure(element) {
|
|
15
|
+
if (!element?.isConnected || typeof element.getBoundingClientRect !== 'function')
|
|
16
|
+
return null;
|
|
17
|
+
const r = element.getBoundingClientRect();
|
|
18
|
+
const box = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
19
|
+
return Object.values(box).every(Number.isFinite) ? box : null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create a registry of keyed snapshots.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const shared = createSharedElements({ duration: 250 });
|
|
26
|
+
* shared.capture('photo-3', thumbnail);
|
|
27
|
+
* openLightbox(); // the DOM changes
|
|
28
|
+
* shared.play('photo-3', lightboxImage);
|
|
29
|
+
*/
|
|
30
|
+
export function createSharedElements(options = {}) {
|
|
31
|
+
const limit = Math.max(1, Math.min(4096, Math.floor(Number.isFinite(options.maxEntries) ? options.maxEntries : 256)));
|
|
32
|
+
const snapshots = new Map();
|
|
33
|
+
return {
|
|
34
|
+
capture(key, element) {
|
|
35
|
+
const box = element ? measure(element) : null;
|
|
36
|
+
const id = String(key);
|
|
37
|
+
snapshots.delete(id);
|
|
38
|
+
if (!box)
|
|
39
|
+
return false;
|
|
40
|
+
snapshots.set(id, { element, box });
|
|
41
|
+
while (snapshots.size > limit) {
|
|
42
|
+
const oldest = snapshots.keys().next().value;
|
|
43
|
+
if (oldest === undefined)
|
|
44
|
+
break;
|
|
45
|
+
snapshots.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
play(key, element) {
|
|
50
|
+
const id = String(key);
|
|
51
|
+
const snapshot = snapshots.get(id);
|
|
52
|
+
snapshots.delete(id);
|
|
53
|
+
const last = element ? measure(element) : null;
|
|
54
|
+
if (!snapshot || !last) {
|
|
55
|
+
return createRun([], options.onFinish, last ? 'no-properties' : 'detached');
|
|
56
|
+
}
|
|
57
|
+
const transform = flipTransform(snapshot.box, last, true);
|
|
58
|
+
const crossFade = snapshot.element !== element && snapshot.element.isConnected;
|
|
59
|
+
const toFrames = [
|
|
60
|
+
{
|
|
61
|
+
transform: transform ?? 'none',
|
|
62
|
+
transformOrigin: '0 0',
|
|
63
|
+
...(crossFade ? { opacity: 0 } : {}),
|
|
64
|
+
},
|
|
65
|
+
{ transform: 'none', transformOrigin: '0 0', ...(crossFade ? { opacity: 1 } : {}) },
|
|
66
|
+
];
|
|
67
|
+
if (!transform && !crossFade)
|
|
68
|
+
return createRun([], options.onFinish, 'no-properties');
|
|
69
|
+
// Read phase done; write phase.
|
|
70
|
+
const plans = [
|
|
71
|
+
[element, resolveMotion(element, toFrames, options)],
|
|
72
|
+
];
|
|
73
|
+
if (crossFade) {
|
|
74
|
+
// The old element travels from its box onto the new one while it fades out.
|
|
75
|
+
const travel = flipTransform(last, snapshot.box, true) ?? 'none';
|
|
76
|
+
plans.push([
|
|
77
|
+
snapshot.element,
|
|
78
|
+
resolveMotion(snapshot.element, [
|
|
79
|
+
{ transform: 'none', transformOrigin: '0 0', opacity: 1 },
|
|
80
|
+
{ transform: travel, transformOrigin: '0 0', opacity: 0 },
|
|
81
|
+
], options),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
const animations = [];
|
|
85
|
+
let reason = null;
|
|
86
|
+
for (const [target, plan] of plans) {
|
|
87
|
+
if (plan.instant) {
|
|
88
|
+
reason ??= plan.reason;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const animation = startAnimation(target, plan);
|
|
92
|
+
if (animation)
|
|
93
|
+
animations.push(animation);
|
|
94
|
+
else
|
|
95
|
+
reason ??= 'error';
|
|
96
|
+
}
|
|
97
|
+
return createRun(animations, options.onFinish, reason);
|
|
98
|
+
},
|
|
99
|
+
has: (key) => snapshots.has(String(key)),
|
|
100
|
+
release: (key) => {
|
|
101
|
+
snapshots.delete(String(key));
|
|
102
|
+
},
|
|
103
|
+
clear: () => snapshots.clear(),
|
|
104
|
+
get size() {
|
|
105
|
+
return snapshots.size;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Run `update` inside an element-scoped View Transition when the browser
|
|
111
|
+
* offers `element.startViewTransition`; otherwise (no support, the server,
|
|
112
|
+
* level `none`, reduced motion) apply it at once. Resolves `"finished"` after
|
|
113
|
+
* the transition, `"instant"` when the update was applied directly.
|
|
114
|
+
*/
|
|
115
|
+
export async function startScopedViewTransition(scope, update, options = {}) {
|
|
116
|
+
const host = scope;
|
|
117
|
+
// Same gates as any animation (server, detached, level, reduced motion);
|
|
118
|
+
// a missing Web Animations API does not matter here.
|
|
119
|
+
const probe = resolveMotion(host ?? null, [{ opacity: 1 }], {
|
|
120
|
+
level: resolveMotionLevel({ host: options.level }),
|
|
121
|
+
reducedMotion: options.reducedMotion,
|
|
122
|
+
duration: 1,
|
|
123
|
+
});
|
|
124
|
+
const usable = !probe.instant || probe.reason === 'no-waapi';
|
|
125
|
+
if (!host || !usable || typeof host.startViewTransition !== 'function') {
|
|
126
|
+
await update();
|
|
127
|
+
return 'instant';
|
|
128
|
+
}
|
|
129
|
+
let transition;
|
|
130
|
+
try {
|
|
131
|
+
transition = host.startViewTransition.call(host, update);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
await update();
|
|
135
|
+
return 'instant';
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await transition.finished;
|
|
139
|
+
return 'finished';
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return 'cancelled';
|
|
143
|
+
}
|
|
144
|
+
}
|