@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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate: the Web Animations wrapper every motion texel plays through
|
|
3
|
+
* (ADR-013). It clamps timing, applies the motion level and reduced motion,
|
|
4
|
+
* and commits the end state at once (no frames, `finished` resolves
|
|
5
|
+
* `"instant"`) whenever motion cannot or must not run: on the server, without
|
|
6
|
+
* the Web Animations API, on a detached element, at level `none`, under
|
|
7
|
+
* reduced motion, or with a zero duration.
|
|
8
|
+
*
|
|
9
|
+
* Animations use `fill: "none"` (`"backwards"` only while a delay holds the
|
|
10
|
+
* first frame), so no inline style outlives them; the final state belongs in
|
|
11
|
+
* classes, attributes or props.
|
|
12
|
+
*/
|
|
13
|
+
import { type MotionLevel, type ReducedMotionPolicy, type ReducedMotionSource } from './preferences.js';
|
|
14
|
+
import { type SpringParams } from './spring.js';
|
|
15
|
+
/** Keyframes in either Web Animations form. */
|
|
16
|
+
export type MotionKeyframes = Keyframe[] | PropertyIndexedKeyframes;
|
|
17
|
+
/** How a motion run ended. */
|
|
18
|
+
export type MotionOutcome =
|
|
19
|
+
/** Every animation played to its end. */
|
|
20
|
+
'finished'
|
|
21
|
+
/** The end state was committed without frames (see {@link InstantReason}). */
|
|
22
|
+
| 'instant'
|
|
23
|
+
/** `cancel()` stopped it. */
|
|
24
|
+
| 'cancelled'
|
|
25
|
+
/** A newer run took over (a reversal, a new measure, a new transition). */
|
|
26
|
+
| 'interrupted';
|
|
27
|
+
/** Why a run committed instantly. */
|
|
28
|
+
export type InstantReason = 'server' | 'detached' | 'level-none' | 'reduced-motion' | 'no-properties' | 'zero-duration' | 'no-waapi' | 'error' | 'limit';
|
|
29
|
+
/** Reduced motion: a fixed value, a live source, or omitted (read the element's window). */
|
|
30
|
+
export type ReducedMotionInput = boolean | ReducedMotionSource | null | undefined;
|
|
31
|
+
/** Timing and policy shared by every motion texel. */
|
|
32
|
+
export type MotionTiming = {
|
|
33
|
+
/** Milliseconds, clamped to [0, 1000]; NaN or negative is 0 (instant). Default 200. */
|
|
34
|
+
duration?: number;
|
|
35
|
+
/** Milliseconds before the animation starts, clamped like `duration`. Default 0. */
|
|
36
|
+
delay?: number;
|
|
37
|
+
/** A CSS easing. Default `cubic-bezier(0.4, 0, 0.2, 1)` (the `standard` token). */
|
|
38
|
+
easing?: string;
|
|
39
|
+
/** Play on a spring instead: the duration is its settle time and the easing its `linear()` curve. */
|
|
40
|
+
spring?: Partial<SpringParams> & {
|
|
41
|
+
velocity?: number;
|
|
42
|
+
};
|
|
43
|
+
/** The already-resolved motion level (see `resolveMotionLevel`). Default `full`. */
|
|
44
|
+
level?: MotionLevel;
|
|
45
|
+
/** Reduced motion; omitted reads `prefers-reduced-motion` from the element's window. */
|
|
46
|
+
reducedMotion?: ReducedMotionInput;
|
|
47
|
+
/** `instant` (default) commits the end state; `fade` keeps an opacity-only 150 ms fade. */
|
|
48
|
+
reducedMotionPolicy?: ReducedMotionPolicy;
|
|
49
|
+
};
|
|
50
|
+
/** One running (or already settled) motion. */
|
|
51
|
+
export type MotionRun = {
|
|
52
|
+
/** Resolves once with the outcome; never rejects. */
|
|
53
|
+
readonly finished: Promise<MotionOutcome>;
|
|
54
|
+
/** The live animations (empty when instant). */
|
|
55
|
+
readonly animations: readonly Animation[];
|
|
56
|
+
/** Why it committed instantly, or `null` when it animates. */
|
|
57
|
+
readonly instantReason: InstantReason | null;
|
|
58
|
+
/** Stop now; no style remains. Resolves `finished` with `"cancelled"`. */
|
|
59
|
+
cancel: () => void;
|
|
60
|
+
/** Jump to the end. Resolves `finished` with `"finished"`. */
|
|
61
|
+
finish: () => void;
|
|
62
|
+
};
|
|
63
|
+
export type AnimateOptions = MotionTiming & {
|
|
64
|
+
/** Called once with the outcome, before `finished` resolves. */
|
|
65
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
66
|
+
};
|
|
67
|
+
/** Default duration (the `base` token) and easing (the `standard` token). */
|
|
68
|
+
export declare const DEFAULT_MOTION_DURATION = 200;
|
|
69
|
+
export declare const DEFAULT_MOTION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
|
70
|
+
/** Resolved, clamped plan for one animation. */
|
|
71
|
+
export type ResolvedMotion = {
|
|
72
|
+
readonly instant: true;
|
|
73
|
+
readonly reason: InstantReason;
|
|
74
|
+
} | {
|
|
75
|
+
readonly instant: false;
|
|
76
|
+
readonly keyframes: MotionKeyframes;
|
|
77
|
+
readonly duration: number;
|
|
78
|
+
readonly delay: number;
|
|
79
|
+
readonly easing: string;
|
|
80
|
+
};
|
|
81
|
+
/** Keyframes without movement or scale (what `subtle` plays). `null` when nothing is left. */
|
|
82
|
+
export declare function withoutTravel(keyframes: MotionKeyframes): MotionKeyframes | null;
|
|
83
|
+
/**
|
|
84
|
+
* Resolve keyframes and timing for one element: the level, reduced motion,
|
|
85
|
+
* clamping and the environment checks, without starting anything.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveMotion(element: Element | null | undefined, keyframes: MotionKeyframes, timing?: MotionTiming): ResolvedMotion;
|
|
88
|
+
/** Start a resolved plan; `null` when the browser refuses it (an invalid easing, say). */
|
|
89
|
+
export declare function startAnimation(element: Element, plan: Extract<ResolvedMotion, {
|
|
90
|
+
instant: false;
|
|
91
|
+
}>, extra?: {
|
|
92
|
+
endDelay?: number;
|
|
93
|
+
}): Animation | null;
|
|
94
|
+
/**
|
|
95
|
+
* Settle a set of animations as one run. With no animations the run is
|
|
96
|
+
* instant: `onFinish` fires synchronously and `finished` resolves `"instant"`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createRun(animations: readonly Animation[], onFinish?: (outcome: MotionOutcome) => void, instantReason?: InstantReason | null): MotionRun & {
|
|
99
|
+
interrupt: () => void;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Animate one element through the Web Animations API with the motion level
|
|
103
|
+
* and reduced motion applied.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const run = animate(card, [{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
|
|
107
|
+
* await run.finished; // "finished", or "instant" under reduced motion
|
|
108
|
+
*/
|
|
109
|
+
export declare function animate(element: Element | null | undefined, keyframes: MotionKeyframes, options?: AnimateOptions): MotionRun;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate: the Web Animations wrapper every motion texel plays through
|
|
3
|
+
* (ADR-013). It clamps timing, applies the motion level and reduced motion,
|
|
4
|
+
* and commits the end state at once (no frames, `finished` resolves
|
|
5
|
+
* `"instant"`) whenever motion cannot or must not run: on the server, without
|
|
6
|
+
* the Web Animations API, on a detached element, at level `none`, under
|
|
7
|
+
* reduced motion, or with a zero duration.
|
|
8
|
+
*
|
|
9
|
+
* Animations use `fill: "none"` (`"backwards"` only while a delay holds the
|
|
10
|
+
* first frame), so no inline style outlives them; the final state belongs in
|
|
11
|
+
* classes, attributes or props.
|
|
12
|
+
*/
|
|
13
|
+
import { getOwnerWindow } from "../env/index.js";
|
|
14
|
+
import { prefersReducedMotion } from "../presence/presence.js";
|
|
15
|
+
import { motionDuration, REDUCED_FADE_MS, } from "./preferences.js";
|
|
16
|
+
import { springEasing } from "./spring.js";
|
|
17
|
+
/** Default duration (the `base` token) and easing (the `standard` token). */
|
|
18
|
+
export const DEFAULT_MOTION_DURATION = 200;
|
|
19
|
+
export const DEFAULT_MOTION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
|
20
|
+
const SPRING_FALLBACK_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
|
21
|
+
/** Properties that move or scale: dropped at level `subtle`. */
|
|
22
|
+
const TRAVEL = new Set([
|
|
23
|
+
'transform',
|
|
24
|
+
'transformOrigin',
|
|
25
|
+
'translate',
|
|
26
|
+
'scale',
|
|
27
|
+
'rotate',
|
|
28
|
+
'offsetDistance',
|
|
29
|
+
]);
|
|
30
|
+
const META = new Set(['offset', 'easing', 'composite']);
|
|
31
|
+
function filterKeyframes(keyframes, keep) {
|
|
32
|
+
let any = false;
|
|
33
|
+
if (Array.isArray(keyframes)) {
|
|
34
|
+
const out = keyframes.map((frame) => {
|
|
35
|
+
const next = {};
|
|
36
|
+
for (const [key, value] of Object.entries(frame ?? {})) {
|
|
37
|
+
if (META.has(key) || keep(key)) {
|
|
38
|
+
next[key] = value;
|
|
39
|
+
if (!META.has(key))
|
|
40
|
+
any = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
45
|
+
return any ? out : null;
|
|
46
|
+
}
|
|
47
|
+
if (!keyframes || typeof keyframes !== 'object')
|
|
48
|
+
return null;
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [key, value] of Object.entries(keyframes)) {
|
|
51
|
+
if (META.has(key) || keep(key)) {
|
|
52
|
+
out[key] = value;
|
|
53
|
+
if (!META.has(key))
|
|
54
|
+
any = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return any ? out : null;
|
|
58
|
+
}
|
|
59
|
+
/** Keyframes without movement or scale (what `subtle` plays). `null` when nothing is left. */
|
|
60
|
+
export function withoutTravel(keyframes) {
|
|
61
|
+
return filterKeyframes(keyframes, (property) => !TRAVEL.has(property));
|
|
62
|
+
}
|
|
63
|
+
function opacityOnly(keyframes) {
|
|
64
|
+
return filterKeyframes(keyframes, (property) => property === 'opacity');
|
|
65
|
+
}
|
|
66
|
+
function readReduced(input, win) {
|
|
67
|
+
if (typeof input === 'boolean')
|
|
68
|
+
return input;
|
|
69
|
+
if (input && typeof input.get === 'function')
|
|
70
|
+
return input.get() === true;
|
|
71
|
+
return prefersReducedMotion(win ?? null, false);
|
|
72
|
+
}
|
|
73
|
+
function supportsLinear(win) {
|
|
74
|
+
try {
|
|
75
|
+
const css = win?.CSS;
|
|
76
|
+
return css?.supports?.('animation-timing-function', 'linear(0, 1)') === true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve keyframes and timing for one element: the level, reduced motion,
|
|
84
|
+
* clamping and the environment checks, without starting anything.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveMotion(element, keyframes, timing = {}) {
|
|
87
|
+
const win = element ? getOwnerWindow(element) : undefined;
|
|
88
|
+
if (!element || !win)
|
|
89
|
+
return { instant: true, reason: 'server' };
|
|
90
|
+
if (!element.isConnected)
|
|
91
|
+
return { instant: true, reason: 'detached' };
|
|
92
|
+
const level = timing.level ?? 'full';
|
|
93
|
+
if (level === 'none')
|
|
94
|
+
return { instant: true, reason: 'level-none' };
|
|
95
|
+
let frames = keyframes;
|
|
96
|
+
let duration;
|
|
97
|
+
let delay;
|
|
98
|
+
let easing = typeof timing.easing === 'string' && timing.easing ? timing.easing : DEFAULT_MOTION_EASING;
|
|
99
|
+
if (readReduced(timing.reducedMotion, win)) {
|
|
100
|
+
if (timing.reducedMotionPolicy !== 'fade')
|
|
101
|
+
return { instant: true, reason: 'reduced-motion' };
|
|
102
|
+
frames = opacityOnly(keyframes);
|
|
103
|
+
duration = REDUCED_FADE_MS;
|
|
104
|
+
delay = 0;
|
|
105
|
+
easing = 'linear';
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (level === 'subtle')
|
|
109
|
+
frames = withoutTravel(keyframes);
|
|
110
|
+
if (timing.spring) {
|
|
111
|
+
const spring = springEasing(timing.spring, { velocity: timing.spring.velocity ?? 0 });
|
|
112
|
+
duration = motionDuration(spring.durationMs, level);
|
|
113
|
+
easing = supportsLinear(win) ? spring.easing : SPRING_FALLBACK_EASING;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
duration = motionDuration(timing.duration ?? DEFAULT_MOTION_DURATION, level);
|
|
117
|
+
}
|
|
118
|
+
delay = motionDuration(timing.delay ?? 0, level);
|
|
119
|
+
}
|
|
120
|
+
if (!frames)
|
|
121
|
+
return { instant: true, reason: 'no-properties' };
|
|
122
|
+
if (duration <= 0)
|
|
123
|
+
return { instant: true, reason: 'zero-duration' };
|
|
124
|
+
if (typeof element.animate !== 'function') {
|
|
125
|
+
return { instant: true, reason: 'no-waapi' };
|
|
126
|
+
}
|
|
127
|
+
return { instant: false, keyframes: frames, duration, delay, easing };
|
|
128
|
+
}
|
|
129
|
+
/** Start a resolved plan; `null` when the browser refuses it (an invalid easing, say). */
|
|
130
|
+
export function startAnimation(element, plan, extra = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const animation = element.animate(plan.keyframes, {
|
|
133
|
+
duration: plan.duration,
|
|
134
|
+
delay: plan.delay,
|
|
135
|
+
endDelay: extra.endDelay ?? 0,
|
|
136
|
+
easing: plan.easing,
|
|
137
|
+
fill: plan.delay > 0 ? 'backwards' : 'none',
|
|
138
|
+
});
|
|
139
|
+
// A cancelled animation rejects `finished`; never leave that unhandled.
|
|
140
|
+
animation?.finished?.catch(() => { });
|
|
141
|
+
return animation ?? null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Settle a set of animations as one run. With no animations the run is
|
|
149
|
+
* instant: `onFinish` fires synchronously and `finished` resolves `"instant"`.
|
|
150
|
+
*/
|
|
151
|
+
export function createRun(animations, onFinish, instantReason = null) {
|
|
152
|
+
let settled = false;
|
|
153
|
+
let resolve = () => { };
|
|
154
|
+
const finished = new Promise((r) => {
|
|
155
|
+
resolve = r;
|
|
156
|
+
});
|
|
157
|
+
const settle = (outcome) => {
|
|
158
|
+
if (settled)
|
|
159
|
+
return;
|
|
160
|
+
settled = true;
|
|
161
|
+
onFinish?.(outcome);
|
|
162
|
+
resolve(outcome);
|
|
163
|
+
};
|
|
164
|
+
const stopAll = (outcome) => {
|
|
165
|
+
settle(outcome);
|
|
166
|
+
for (const animation of animations) {
|
|
167
|
+
try {
|
|
168
|
+
animation.cancel();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// A cancelled animation stays cancelled.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
if (animations.length === 0) {
|
|
176
|
+
settle('instant');
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
let remaining = animations.length;
|
|
180
|
+
for (const animation of animations) {
|
|
181
|
+
animation.finished.then(() => {
|
|
182
|
+
remaining -= 1;
|
|
183
|
+
if (remaining === 0)
|
|
184
|
+
settle('finished');
|
|
185
|
+
}, () => settle('cancelled'));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
finished,
|
|
190
|
+
animations,
|
|
191
|
+
instantReason: animations.length === 0 ? (instantReason ?? 'zero-duration') : null,
|
|
192
|
+
cancel: () => stopAll('cancelled'),
|
|
193
|
+
interrupt: () => stopAll('interrupted'),
|
|
194
|
+
finish() {
|
|
195
|
+
if (settled)
|
|
196
|
+
return;
|
|
197
|
+
for (const animation of animations) {
|
|
198
|
+
try {
|
|
199
|
+
animation.finish();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// An animation that cannot finish is cancelled below.
|
|
203
|
+
animation.cancel();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
settle('finished');
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Animate one element through the Web Animations API with the motion level
|
|
212
|
+
* and reduced motion applied.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const run = animate(card, [{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
|
|
216
|
+
* await run.finished; // "finished", or "instant" under reduced motion
|
|
217
|
+
*/
|
|
218
|
+
export function animate(element, keyframes, options = {}) {
|
|
219
|
+
const plan = resolveMotion(element, keyframes, options);
|
|
220
|
+
if (plan.instant || !element) {
|
|
221
|
+
return createRun([], options.onFinish, plan.instant ? plan.reason : 'server');
|
|
222
|
+
}
|
|
223
|
+
const animation = startAnimation(element, plan);
|
|
224
|
+
return createRun(animation ? [animation] : [], options.onFinish, animation ? null : 'error');
|
|
225
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLIP layout animation (ADR-013): measure (First), let the DOM change
|
|
3
|
+
* (Last), Invert with a transform, Play it back to identity.
|
|
4
|
+
*
|
|
5
|
+
* Reads and writes are batched: `measure` and `play` read every rectangle
|
|
6
|
+
* before they cancel or start any animation, so a list of any length lays out
|
|
7
|
+
* once per phase. A `measure` during a running play reads the in-flight
|
|
8
|
+
* (visual) positions, then interrupts the old run, so a re-reorder continues
|
|
9
|
+
* from where items are on screen.
|
|
10
|
+
*
|
|
11
|
+
* Deltas are measured in physical pixels, so reorders are correct in either
|
|
12
|
+
* direction; insert offsets are logical (`inline-start` follows `dir`).
|
|
13
|
+
* At level `subtle` moved items snap (no travel) and inserts only fade;
|
|
14
|
+
* at `none` or under reduced motion everything commits at once.
|
|
15
|
+
*/
|
|
16
|
+
import { type MotionKeyframes, type MotionOutcome, type MotionRun, type MotionTiming } from './animate.js';
|
|
17
|
+
/** Items keyed by a stable id (a `Map` works). */
|
|
18
|
+
export type FlipItems = Iterable<readonly [string, Element]>;
|
|
19
|
+
/** Where an inserted item comes from: a fade, or a short slide from a logical side. */
|
|
20
|
+
export type FlipEnter = false | 'fade' | 'inline-start' | 'inline-end' | 'block-start' | 'block-end' | MotionKeyframes;
|
|
21
|
+
export type FlipOptions = MotionTiming & {
|
|
22
|
+
/** Animate size changes with a scale as well. Default `true`. */
|
|
23
|
+
scale?: boolean;
|
|
24
|
+
/** Inserted items (keys `measure` did not see). Default `"fade"`. */
|
|
25
|
+
enter?: FlipEnter;
|
|
26
|
+
/** Slide distance for a logical `enter`, in px (clamped to [0, 64]). Default 8. */
|
|
27
|
+
enterDistance?: number;
|
|
28
|
+
/** Above this many animated items the change commits at once. Default 500. */
|
|
29
|
+
maxItems?: number;
|
|
30
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
31
|
+
};
|
|
32
|
+
export type FlipController = {
|
|
33
|
+
/** First: record where every item is now (interrupts a running play). */
|
|
34
|
+
measure: (items: FlipItems) => void;
|
|
35
|
+
/** Last, Invert, Play: animate each item from its measured box to its current one. */
|
|
36
|
+
play: (items: FlipItems) => MotionRun;
|
|
37
|
+
/** Cancel the running play. */
|
|
38
|
+
cancel: () => void;
|
|
39
|
+
};
|
|
40
|
+
type Box = {
|
|
41
|
+
readonly left: number;
|
|
42
|
+
readonly top: number;
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
};
|
|
46
|
+
/** The inverted transform from `first` to `last` (`null` when nothing moved). */
|
|
47
|
+
export declare function flipTransform(first: Box, last: Box, scale?: boolean): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Create a FLIP controller for one list (or any set of keyed elements).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const flip = createFlip({ duration: 200 });
|
|
53
|
+
* flip.measure(itemsByKey);
|
|
54
|
+
* reorder(); // the DOM changes
|
|
55
|
+
* await flip.play(itemsByKey).finished;
|
|
56
|
+
*/
|
|
57
|
+
export declare function createFlip(options?: FlipOptions): FlipController;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLIP layout animation (ADR-013): measure (First), let the DOM change
|
|
3
|
+
* (Last), Invert with a transform, Play it back to identity.
|
|
4
|
+
*
|
|
5
|
+
* Reads and writes are batched: `measure` and `play` read every rectangle
|
|
6
|
+
* before they cancel or start any animation, so a list of any length lays out
|
|
7
|
+
* once per phase. A `measure` during a running play reads the in-flight
|
|
8
|
+
* (visual) positions, then interrupts the old run, so a re-reorder continues
|
|
9
|
+
* from where items are on screen.
|
|
10
|
+
*
|
|
11
|
+
* Deltas are measured in physical pixels, so reorders are correct in either
|
|
12
|
+
* direction; insert offsets are logical (`inline-start` follows `dir`).
|
|
13
|
+
* At level `subtle` moved items snap (no travel) and inserts only fade;
|
|
14
|
+
* at `none` or under reduced motion everything commits at once.
|
|
15
|
+
*/
|
|
16
|
+
import { getOwnerWindow } from "../env/index.js";
|
|
17
|
+
import { createRun, resolveMotion, startAnimation, } from "./animate.js";
|
|
18
|
+
const MAX_DELTA = 100_000;
|
|
19
|
+
function boxOf(element) {
|
|
20
|
+
if (!element.isConnected || typeof element.getBoundingClientRect !== 'function')
|
|
21
|
+
return null;
|
|
22
|
+
const r = element.getBoundingClientRect();
|
|
23
|
+
const box = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
24
|
+
return Number.isFinite(box.left) &&
|
|
25
|
+
Number.isFinite(box.top) &&
|
|
26
|
+
Number.isFinite(box.width) &&
|
|
27
|
+
Number.isFinite(box.height)
|
|
28
|
+
? box
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
function num(value) {
|
|
32
|
+
const clamped = Math.max(-MAX_DELTA, Math.min(MAX_DELTA, value));
|
|
33
|
+
const r = Math.round(clamped * 1000) / 1000;
|
|
34
|
+
return String(Object.is(r, -0) ? 0 : r);
|
|
35
|
+
}
|
|
36
|
+
/** The inverted transform from `first` to `last` (`null` when nothing moved). */
|
|
37
|
+
export function flipTransform(first, last, scale = true) {
|
|
38
|
+
const dx = first.left - last.left;
|
|
39
|
+
const dy = first.top - last.top;
|
|
40
|
+
const sx = scale && last.width > 0 && first.width > 0 ? first.width / last.width : 1;
|
|
41
|
+
const sy = scale && last.height > 0 && first.height > 0 ? first.height / last.height : 1;
|
|
42
|
+
if (Math.abs(dx) < 0.5 &&
|
|
43
|
+
Math.abs(dy) < 0.5 &&
|
|
44
|
+
Math.abs(sx - 1) < 0.001 &&
|
|
45
|
+
Math.abs(sy - 1) < 0.001) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const move = `translate(${num(dx)}px, ${num(dy)}px)`;
|
|
49
|
+
return sx === 1 && sy === 1 ? move : `${move} scale(${num(sx)}, ${num(sy)})`;
|
|
50
|
+
}
|
|
51
|
+
function isRtl(element) {
|
|
52
|
+
const win = getOwnerWindow(element);
|
|
53
|
+
try {
|
|
54
|
+
return win?.getComputedStyle(element).direction === 'rtl';
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function enterFrames(enter, distance, rtl) {
|
|
61
|
+
if (enter === false)
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof enter === 'object')
|
|
64
|
+
return enter;
|
|
65
|
+
if (enter === 'fade')
|
|
66
|
+
return [{ opacity: 0 }, { opacity: 1 }];
|
|
67
|
+
const sign = enter === 'inline-start' || enter === 'block-start' ? -1 : 1;
|
|
68
|
+
const inline = enter.startsWith('inline');
|
|
69
|
+
const px = num(sign * distance * (inline && rtl ? -1 : 1));
|
|
70
|
+
const from = inline ? `translate(${px}px, 0px)` : `translate(0px, ${px}px)`;
|
|
71
|
+
return [
|
|
72
|
+
{ opacity: 0, transform: from },
|
|
73
|
+
{ opacity: 1, transform: 'none' },
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a FLIP controller for one list (or any set of keyed elements).
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const flip = createFlip({ duration: 200 });
|
|
81
|
+
* flip.measure(itemsByKey);
|
|
82
|
+
* reorder(); // the DOM changes
|
|
83
|
+
* await flip.play(itemsByKey).finished;
|
|
84
|
+
*/
|
|
85
|
+
export function createFlip(options = {}) {
|
|
86
|
+
const scale = options.scale !== false;
|
|
87
|
+
const enter = options.enter ?? 'fade';
|
|
88
|
+
const distance = Math.max(0, Math.min(64, Number.isFinite(options.enterDistance) ? options.enterDistance : 8));
|
|
89
|
+
const maxItems = Math.max(0, Math.floor(Number.isFinite(options.maxItems) ? options.maxItems : 500));
|
|
90
|
+
let first = new Map();
|
|
91
|
+
let running = null;
|
|
92
|
+
function interrupt() {
|
|
93
|
+
running?.interrupt();
|
|
94
|
+
running = null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
measure(items) {
|
|
98
|
+
// Read phase: every box (in-flight transforms included) before any write.
|
|
99
|
+
const next = new Map();
|
|
100
|
+
for (const [key, element] of items) {
|
|
101
|
+
const box = element ? boxOf(element) : null;
|
|
102
|
+
if (box)
|
|
103
|
+
next.set(String(key), box);
|
|
104
|
+
}
|
|
105
|
+
first = next;
|
|
106
|
+
// Write phase: stop the old run so `play` reads the new layout untransformed.
|
|
107
|
+
interrupt();
|
|
108
|
+
},
|
|
109
|
+
play(items) {
|
|
110
|
+
interrupt();
|
|
111
|
+
const before = first;
|
|
112
|
+
first = new Map();
|
|
113
|
+
const jobs = [];
|
|
114
|
+
let rtl = null;
|
|
115
|
+
for (const [rawKey, element] of items) {
|
|
116
|
+
if (!element)
|
|
117
|
+
continue;
|
|
118
|
+
const key = String(rawKey);
|
|
119
|
+
const last = boxOf(element);
|
|
120
|
+
if (!last)
|
|
121
|
+
continue;
|
|
122
|
+
const from = before.get(key);
|
|
123
|
+
let frames;
|
|
124
|
+
if (from) {
|
|
125
|
+
const transform = flipTransform(from, last, scale);
|
|
126
|
+
frames = transform
|
|
127
|
+
? [
|
|
128
|
+
{ transform, transformOrigin: '0 0' },
|
|
129
|
+
{ transform: 'none', transformOrigin: '0 0' },
|
|
130
|
+
]
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
if (rtl === null && typeof enter === 'string' && enter.startsWith('inline'))
|
|
135
|
+
rtl = isRtl(element);
|
|
136
|
+
frames = enterFrames(enter, distance, rtl === true);
|
|
137
|
+
}
|
|
138
|
+
if (frames)
|
|
139
|
+
jobs.push({ element, frames, plan: resolveMotion(element, frames, options) });
|
|
140
|
+
}
|
|
141
|
+
if (jobs.length > maxItems) {
|
|
142
|
+
return createRun([], options.onFinish, 'limit');
|
|
143
|
+
}
|
|
144
|
+
// Write phase.
|
|
145
|
+
const animations = [];
|
|
146
|
+
let reason = null;
|
|
147
|
+
for (const job of jobs) {
|
|
148
|
+
if (job.plan.instant) {
|
|
149
|
+
reason ??= job.plan.reason;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const animation = startAnimation(job.element, job.plan);
|
|
153
|
+
if (animation)
|
|
154
|
+
animations.push(animation);
|
|
155
|
+
else
|
|
156
|
+
reason ??= 'error';
|
|
157
|
+
}
|
|
158
|
+
const run = createRun(animations, options.onFinish, reason ?? 'zero-duration');
|
|
159
|
+
running = animations.length > 0 ? run : null;
|
|
160
|
+
return run;
|
|
161
|
+
},
|
|
162
|
+
cancel() {
|
|
163
|
+
running?.cancel();
|
|
164
|
+
running = null;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/motion: the motion runtime (ADR-013). Web Animations with an
|
|
3
|
+
* owned spring solver, reduced motion instant by construction, and the
|
|
4
|
+
* `full` / `subtle` / `none` motion levels shared with the deck.
|
|
5
|
+
*
|
|
6
|
+
* Not re-exported from the `@texuli/core` root barrel: a component that does
|
|
7
|
+
* not animate pays nothing for it.
|
|
8
|
+
*/
|
|
9
|
+
export { type AnimateOptions, animate, createRun, DEFAULT_MOTION_DURATION, DEFAULT_MOTION_EASING, type InstantReason, type MotionKeyframes, type MotionOutcome, type MotionRun, type MotionTiming, type ReducedMotionInput, type ResolvedMotion, resolveMotion, startAnimation, withoutTravel, } from './animate.js';
|
|
10
|
+
export { createFlip, type FlipController, type FlipEnter, type FlipItems, type FlipOptions, flipTransform, } from './flip.js';
|
|
11
|
+
export { createReducedMotionSource, isMotionLevel, lowerMotionLevel, MAX_MOTION_MS, MOTION_LEVELS, type MotionLevel, type MotionLevelInput, motionDuration, REDUCED_FADE_MS, type ReducedMotionPolicy, type ReducedMotionSource, type ReducedMotionSourceOptions, resolveMotionLevel, SUBTLE_DURATION_SCALE, } from './preferences.js';
|
|
12
|
+
export { createMotionPresence, type MotionPresence, type MotionPresenceOptions, } from './presence.js';
|
|
13
|
+
export { MAX_STAGGER_SPAN, MAX_TIMELINE_STEPS, type ParallelGroup, parallel, type StaggerOptions, sequence, stagger, type Timeline, type TimelineEntry, type TimelineOptions, type TimelineStep, timeline, } from './sequence.js';
|
|
14
|
+
export { createSharedElements, type ScopedViewTransitionOptions, type SharedElements, type SharedElementsOptions, startScopedViewTransition, } from './shared.js';
|
|
15
|
+
export * from './spring.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/motion: the motion runtime (ADR-013). Web Animations with an
|
|
3
|
+
* owned spring solver, reduced motion instant by construction, and the
|
|
4
|
+
* `full` / `subtle` / `none` motion levels shared with the deck.
|
|
5
|
+
*
|
|
6
|
+
* Not re-exported from the `@texuli/core` root barrel: a component that does
|
|
7
|
+
* not animate pays nothing for it.
|
|
8
|
+
*/
|
|
9
|
+
export { animate, createRun, DEFAULT_MOTION_DURATION, DEFAULT_MOTION_EASING, resolveMotion, startAnimation, withoutTravel, } from "./animate.js";
|
|
10
|
+
export { createFlip, flipTransform, } from "./flip.js";
|
|
11
|
+
export { createReducedMotionSource, isMotionLevel, lowerMotionLevel, MAX_MOTION_MS, MOTION_LEVELS, motionDuration, REDUCED_FADE_MS, resolveMotionLevel, SUBTLE_DURATION_SCALE, } from "./preferences.js";
|
|
12
|
+
export { createMotionPresence, } from "./presence.js";
|
|
13
|
+
export { MAX_STAGGER_SPAN, MAX_TIMELINE_STEPS, parallel, sequence, stagger, timeline, } from "./sequence.js";
|
|
14
|
+
export { createSharedElements, startScopedViewTransition, } from "./shared.js";
|
|
15
|
+
export * from "./spring.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
/** Motion levels shared with `@texuli/deck`: `subtle` shortens and drops travel, `none` is instant. */
|
|
6
|
+
export declare const MOTION_LEVELS: readonly ["full", "subtle", "none"];
|
|
7
|
+
export type MotionLevel = (typeof MOTION_LEVELS)[number];
|
|
8
|
+
/** `subtle` multiplies every duration and offset by this factor (the deck's value). */
|
|
9
|
+
export declare const SUBTLE_DURATION_SCALE = 0.6;
|
|
10
|
+
/** Longest duration or delay any motion texel plays, in milliseconds. */
|
|
11
|
+
export declare const MAX_MOTION_MS = 1000;
|
|
12
|
+
/** Duration of the opt-in `fade` reduced-motion policy (the deck stage's slide fade). */
|
|
13
|
+
export declare const REDUCED_FADE_MS = 150;
|
|
14
|
+
/** What reduced motion does: commit the end state at once (default), or keep an opacity-only fade. */
|
|
15
|
+
export type ReducedMotionPolicy = 'instant' | 'fade';
|
|
16
|
+
/** A live view of `prefers-reduced-motion` with a host override. */
|
|
17
|
+
export type ReducedMotionSource = {
|
|
18
|
+
/** Current value: the override when set, else the media query, else the SSR default. */
|
|
19
|
+
get: () => boolean;
|
|
20
|
+
/** Called with the new value whenever it changes; returns an unsubscribe function. */
|
|
21
|
+
subscribe: (listener: (reduced: boolean) => void) => () => void;
|
|
22
|
+
/** Force reduced motion on or off; `null` returns to the media query. */
|
|
23
|
+
setOverride: (value: boolean | null) => void;
|
|
24
|
+
/** Stop listening to the media query and drop every listener. */
|
|
25
|
+
dispose: () => void;
|
|
26
|
+
};
|
|
27
|
+
export type ReducedMotionSourceOptions = {
|
|
28
|
+
/** Window to read `matchMedia` from. Omit on the server. */
|
|
29
|
+
window?: Pick<Window, 'matchMedia'> | null;
|
|
30
|
+
/** Initial host override (`null` or omitted reads the media query). */
|
|
31
|
+
override?: boolean | null;
|
|
32
|
+
/** Value without a window. Default `false` (texels do not run on the server anyway). */
|
|
33
|
+
ssrDefault?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Create a reduced-motion source. It reads `(prefers-reduced-motion: reduce)`
|
|
37
|
+
* from the given window, follows its `change` events, and lets the host
|
|
38
|
+
* override it (a settings toggle, a test, a print view).
|
|
39
|
+
*/
|
|
40
|
+
export declare function createReducedMotionSource(options?: ReducedMotionSourceOptions): ReducedMotionSource;
|
|
41
|
+
/** Whether a value is one of {@link MOTION_LEVELS}. */
|
|
42
|
+
export declare function isMotionLevel(value: unknown): value is MotionLevel;
|
|
43
|
+
/** The lower of two levels (`none` < `subtle` < `full`); invalid values are ignored. */
|
|
44
|
+
export declare function lowerMotionLevel(a: unknown, b: unknown): MotionLevel;
|
|
45
|
+
export type MotionLevelInput = {
|
|
46
|
+
/** The host's level (an app setting); wins over everything below it. */
|
|
47
|
+
host?: unknown;
|
|
48
|
+
/** The blueprint's or deck's level. */
|
|
49
|
+
document?: unknown;
|
|
50
|
+
/** The component default. */
|
|
51
|
+
component?: unknown;
|
|
52
|
+
/** The viewer's preference: can only lower the result. */
|
|
53
|
+
viewer?: unknown;
|
|
54
|
+
/** Reduced motion lowers the result to `none` (the texels commit the end state at once). */
|
|
55
|
+
reducedMotion?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the motion level: host, then blueprint or deck, then the component
|
|
59
|
+
* default, then `full`; the viewer's preference and reduced motion only lower
|
|
60
|
+
* it. Invalid values are skipped.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveMotionLevel(input?: MotionLevelInput): MotionLevel;
|
|
63
|
+
/**
|
|
64
|
+
* Clamp a duration or delay to [0, {@link MAX_MOTION_MS}] and scale it for the
|
|
65
|
+
* level: `subtle` × {@link SUBTLE_DURATION_SCALE}, `none` → 0. NaN and
|
|
66
|
+
* negative values are 0; Infinity is the maximum.
|
|
67
|
+
*/
|
|
68
|
+
export declare function motionDuration(ms: unknown, level?: MotionLevel): number;
|