@texuli/core 0.4.0 → 0.6.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 +30 -6
- 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 +340 -100
- 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/focus-scope/focus-scope.d.ts +7 -0
- package/dist/focus-scope/focus-scope.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +13 -4
- 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 +16 -3
- package/dist/url-policy/filter-image-srcset.d.ts +11 -0
- package/dist/url-policy/filter-image-srcset.js +60 -0
- package/dist/url-policy/index.d.ts +1 -0
- package/dist/url-policy/index.js +1 -0
- 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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion preferences: the reduced-motion source and the motion-level
|
|
3
|
+
* resolver (ADR-013). Framework-free; no module-scope browser access.
|
|
4
|
+
*/
|
|
5
|
+
import { REDUCED_MOTION_QUERY } from "../presence/presence.js";
|
|
6
|
+
/** Motion levels shared with `@texuli/deck`: `subtle` shortens and drops travel, `none` is instant. */
|
|
7
|
+
export const MOTION_LEVELS = ['full', 'subtle', 'none'];
|
|
8
|
+
/** `subtle` multiplies every duration and offset by this factor (the deck's value). */
|
|
9
|
+
export const SUBTLE_DURATION_SCALE = 0.6;
|
|
10
|
+
/** Longest duration or delay any motion texel plays, in milliseconds. */
|
|
11
|
+
export const MAX_MOTION_MS = 1000;
|
|
12
|
+
/** Duration of the opt-in `fade` reduced-motion policy (the deck stage's slide fade). */
|
|
13
|
+
export const REDUCED_FADE_MS = 150;
|
|
14
|
+
/**
|
|
15
|
+
* Create a reduced-motion source. It reads `(prefers-reduced-motion: reduce)`
|
|
16
|
+
* from the given window, follows its `change` events, and lets the host
|
|
17
|
+
* override it (a settings toggle, a test, a print view).
|
|
18
|
+
*/
|
|
19
|
+
export function createReducedMotionSource(options = {}) {
|
|
20
|
+
const ssrDefault = options.ssrDefault === true;
|
|
21
|
+
let override = typeof options.override === 'boolean' ? options.override : null;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
let query = null;
|
|
24
|
+
const win = options.window;
|
|
25
|
+
if (win && typeof win.matchMedia === 'function') {
|
|
26
|
+
try {
|
|
27
|
+
query = win.matchMedia(REDUCED_MOTION_QUERY);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
query = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let last = read();
|
|
34
|
+
function read() {
|
|
35
|
+
if (override !== null)
|
|
36
|
+
return override;
|
|
37
|
+
if (query)
|
|
38
|
+
return query.matches === true;
|
|
39
|
+
return ssrDefault;
|
|
40
|
+
}
|
|
41
|
+
function notify() {
|
|
42
|
+
const next = read();
|
|
43
|
+
if (next === last)
|
|
44
|
+
return;
|
|
45
|
+
last = next;
|
|
46
|
+
for (const listener of [...listeners])
|
|
47
|
+
listener(next);
|
|
48
|
+
}
|
|
49
|
+
const onChange = () => notify();
|
|
50
|
+
if (query?.addEventListener)
|
|
51
|
+
query.addEventListener('change', onChange);
|
|
52
|
+
else
|
|
53
|
+
query?.addListener?.(onChange);
|
|
54
|
+
return {
|
|
55
|
+
get: read,
|
|
56
|
+
subscribe(listener) {
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
return () => listeners.delete(listener);
|
|
59
|
+
},
|
|
60
|
+
setOverride(value) {
|
|
61
|
+
override = typeof value === 'boolean' ? value : null;
|
|
62
|
+
notify();
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
if (query?.removeEventListener)
|
|
66
|
+
query.removeEventListener('change', onChange);
|
|
67
|
+
else
|
|
68
|
+
query?.removeListener?.(onChange);
|
|
69
|
+
query = null;
|
|
70
|
+
listeners.clear();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Whether a value is one of {@link MOTION_LEVELS}. */
|
|
75
|
+
export function isMotionLevel(value) {
|
|
76
|
+
return typeof value === 'string' && MOTION_LEVELS.includes(value);
|
|
77
|
+
}
|
|
78
|
+
const RANK = { full: 2, subtle: 1, none: 0 };
|
|
79
|
+
/** The lower of two levels (`none` < `subtle` < `full`); invalid values are ignored. */
|
|
80
|
+
export function lowerMotionLevel(a, b) {
|
|
81
|
+
const left = isMotionLevel(a) ? a : 'full';
|
|
82
|
+
const right = isMotionLevel(b) ? b : 'full';
|
|
83
|
+
return RANK[left] <= RANK[right] ? left : right;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the motion level: host, then blueprint or deck, then the component
|
|
87
|
+
* default, then `full`; the viewer's preference and reduced motion only lower
|
|
88
|
+
* it. Invalid values are skipped.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveMotionLevel(input = {}) {
|
|
91
|
+
const chosen = isMotionLevel(input.host)
|
|
92
|
+
? input.host
|
|
93
|
+
: isMotionLevel(input.document)
|
|
94
|
+
? input.document
|
|
95
|
+
: isMotionLevel(input.component)
|
|
96
|
+
? input.component
|
|
97
|
+
: 'full';
|
|
98
|
+
const level = lowerMotionLevel(chosen, input.viewer);
|
|
99
|
+
return input.reducedMotion === true ? 'none' : level;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Clamp a duration or delay to [0, {@link MAX_MOTION_MS}] and scale it for the
|
|
103
|
+
* level: `subtle` × {@link SUBTLE_DURATION_SCALE}, `none` → 0. NaN and
|
|
104
|
+
* negative values are 0; Infinity is the maximum.
|
|
105
|
+
*/
|
|
106
|
+
export function motionDuration(ms, level = 'full') {
|
|
107
|
+
if (typeof ms !== 'number' || Number.isNaN(ms) || ms <= 0 || level === 'none')
|
|
108
|
+
return 0;
|
|
109
|
+
const clamped = Math.min(MAX_MOTION_MS, ms);
|
|
110
|
+
return Math.round(level === 'subtle' ? clamped * SUBTLE_DURATION_SCALE : clamped);
|
|
111
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { type PresenceSnapshot } from '../presence/presence.js';
|
|
16
|
+
import { type MotionKeyframes, type MotionOutcome, type MotionTiming } from './animate.js';
|
|
17
|
+
export type MotionPresenceOptions = MotionTiming & {
|
|
18
|
+
/** Initial intent. Default `false`. */
|
|
19
|
+
present?: boolean;
|
|
20
|
+
/** Play the enter animation for a node that is present on first attach. Default `false`. */
|
|
21
|
+
appear?: boolean;
|
|
22
|
+
/** Keep the node mounted after exit (`data-state` still closes). Default `false`. */
|
|
23
|
+
forceMount?: boolean;
|
|
24
|
+
/** Enter keyframes (hidden → shown). Default a fade in. */
|
|
25
|
+
enter?: MotionKeyframes;
|
|
26
|
+
/** Exit keyframes (shown → hidden). Default the reverse of `enter`. */
|
|
27
|
+
exit?: MotionKeyframes;
|
|
28
|
+
/** Timing overrides for the exit (merged over the shared timing). */
|
|
29
|
+
exitTiming?: MotionTiming;
|
|
30
|
+
/** Snapshot changed. */
|
|
31
|
+
onSnapshot?: (snapshot: PresenceSnapshot) => void;
|
|
32
|
+
/** The enter path began (the node should be mounted). */
|
|
33
|
+
onEnter?: (snapshot: PresenceSnapshot) => void;
|
|
34
|
+
/** The enter animation ended (or committed instantly). */
|
|
35
|
+
onEnterComplete?: (snapshot: PresenceSnapshot) => void;
|
|
36
|
+
/** The exit ended; unmount when `snapshot.mounted` is false. */
|
|
37
|
+
onExitComplete?: (snapshot: PresenceSnapshot) => void;
|
|
38
|
+
};
|
|
39
|
+
export type MotionPresence = {
|
|
40
|
+
getSnapshot: () => PresenceSnapshot;
|
|
41
|
+
/** Drive the intent; resolves when this transition settles. */
|
|
42
|
+
setPresent: (present: boolean) => Promise<MotionOutcome>;
|
|
43
|
+
/** Attach (or detach with `null`) the animated element after it mounts. */
|
|
44
|
+
setNode: (node: Element | null) => void;
|
|
45
|
+
/** The current transition's outcome (resolved `"instant"` when idle). */
|
|
46
|
+
readonly finished: Promise<MotionOutcome>;
|
|
47
|
+
/** Cancel any animation and stop; pending transitions resolve `"cancelled"`. */
|
|
48
|
+
dispose: () => void;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Create a motion presence controller.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const presence = createMotionPresence({
|
|
55
|
+
* enter: [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
|
|
56
|
+
* onExitComplete: (s) => { if (!s.mounted) unmount(); },
|
|
57
|
+
* });
|
|
58
|
+
* presence.setNode(panel);
|
|
59
|
+
* await presence.setPresent(false); // "finished", or "instant" under reduced motion
|
|
60
|
+
*/
|
|
61
|
+
export declare function createMotionPresence(options?: MotionPresenceOptions): MotionPresence;
|
|
@@ -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>;
|