@vune-ui/animation 0.1.20
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/ARCHITECTURE.md +470 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/PERFORMANCE.md +151 -0
- package/README.md +630 -0
- package/dist/index.d.ts +474 -0
- package/dist/src/canvas/index.d.ts +15 -0
- package/dist/src/canvas/index.js +67 -0
- package/dist/src/constraints/index.d.ts +33 -0
- package/dist/src/constraints/index.js +346 -0
- package/dist/src/core/bezier.js +51 -0
- package/dist/src/core/composition.js +17 -0
- package/dist/src/core/controls.js +22 -0
- package/dist/src/core/default-engine.js +20 -0
- package/dist/src/core/easing.js +58 -0
- package/dist/src/core/engine.js +1031 -0
- package/dist/src/core/frame-budget.js +30 -0
- package/dist/src/core/index.d.ts +43 -0
- package/dist/src/core/index.js +17 -0
- package/dist/src/core/js-spring-batch.js +57 -0
- package/dist/src/core/kinetics.js +140 -0
- package/dist/src/core/math.js +20 -0
- package/dist/src/core/motion-value.js +53 -0
- package/dist/src/core/planner.js +72 -0
- package/dist/src/core/specs.js +70 -0
- package/dist/src/dom/index.d.ts +41 -0
- package/dist/src/dom/index.js +364 -0
- package/dist/src/gesture/index.d.ts +66 -0
- package/dist/src/gesture/index.js +376 -0
- package/dist/src/index.js +53 -0
- package/dist/src/interpolate/color.js +223 -0
- package/dist/src/interpolate/css.d.ts +13 -0
- package/dist/src/interpolate/css.js +34 -0
- package/dist/src/interpolate/index.d.ts +13 -0
- package/dist/src/interpolate/index.js +55 -0
- package/dist/src/interpolate/transform.js +247 -0
- package/dist/src/layout/index.d.ts +56 -0
- package/dist/src/layout/index.js +485 -0
- package/dist/src/material/index.d.ts +9 -0
- package/dist/src/material/index.js +70 -0
- package/dist/src/path/index.d.ts +37 -0
- package/dist/src/path/index.js +527 -0
- package/dist/src/render/frame-batcher.js +52 -0
- package/dist/src/scroll/index.d.ts +55 -0
- package/dist/src/scroll/index.js +233 -0
- package/dist/src/timeline/index.d.ts +147 -0
- package/dist/src/timeline/index.js +849 -0
- package/dist/src/transition/index.d.ts +88 -0
- package/dist/src/transition/index.js +369 -0
- package/dist/src/wasm/index.d.ts +29 -0
- package/dist/src/wasm/index.js +8 -0
- package/dist/src/wasm/loader.js +55 -0
- package/dist/src/wasm/shared-wasm-spring-batch.js +52 -0
- package/dist/src/wasm/wasm-spring-batch.js +52 -0
- package/dist/src/webgl/index.d.ts +22 -0
- package/dist/src/webgl/index.js +94 -0
- package/dist/src/webgpu/index.d.ts +35 -0
- package/dist/src/webgpu/index.js +73 -0
- package/dist/src/webgpu/spring-batch.js +218 -0
- package/dist/src/worker/index.d.ts +17 -0
- package/dist/src/worker/index.js +1 -0
- package/dist/src/worker/shared-spring-worker.js +218 -0
- package/dist/src/worker/shared-worker.js +75 -0
- package/dist/wasm/kernel-scalar.wasm +0 -0
- package/dist/wasm/kernel-shared-scalar.wasm +0 -0
- package/dist/wasm/kernel-shared-simd.wasm +0 -0
- package/dist/wasm/kernel-simd.wasm +0 -0
- package/package.json +113 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { defaultEngine } from '../core/default-engine.js';
|
|
2
|
+
import { motionValue } from '../core/motion-value.js';
|
|
3
|
+
import { smooth } from '../core/specs.js';
|
|
4
|
+
import {
|
|
5
|
+
decomposeMatrix2D,
|
|
6
|
+
identityMatrix2D,
|
|
7
|
+
invertMatrix2D,
|
|
8
|
+
multiplyMatrix2D,
|
|
9
|
+
scaleMatrix2D,
|
|
10
|
+
translationMatrix2D,
|
|
11
|
+
} from '../interpolate/transform.js';
|
|
12
|
+
|
|
13
|
+
const activeTransitions = new WeakMap();
|
|
14
|
+
const EPSILON = 1e-6;
|
|
15
|
+
|
|
16
|
+
function asElements(elements) {
|
|
17
|
+
if (!elements) return [];
|
|
18
|
+
if (typeof elements.getBoundingClientRect === 'function') return [elements];
|
|
19
|
+
return Array.from(elements).filter((element) => element && typeof element.getBoundingClientRect === 'function');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function copyRect(rect) {
|
|
23
|
+
const left = Number(rect.left ?? rect.x ?? 0);
|
|
24
|
+
const top = Number(rect.top ?? rect.y ?? 0);
|
|
25
|
+
const width = Number(rect.width ?? Math.max(0, Number(rect.right ?? left) - left));
|
|
26
|
+
const height = Number(rect.height ?? Math.max(0, Number(rect.bottom ?? top) - top));
|
|
27
|
+
return { left, top, width, height, right: left + width, bottom: top + height };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultMeasureScroll() {
|
|
31
|
+
return {
|
|
32
|
+
x: Number(globalThis.scrollX ?? globalThis.pageXOffset ?? 0),
|
|
33
|
+
y: Number(globalThis.scrollY ?? globalThis.pageYOffset ?? 0),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function captureLayout(elements, { measureScroll = defaultMeasureScroll } = {}) {
|
|
38
|
+
const scroll = measureScroll();
|
|
39
|
+
return asElements(elements).map((element) => ({
|
|
40
|
+
element,
|
|
41
|
+
rect: copyRect(element.getBoundingClientRect()),
|
|
42
|
+
scroll: { x: Number(scroll?.x ?? 0), y: Number(scroll?.y ?? 0) },
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectionMatrixForRects(firstRect, lastRect, { includeSize = true } = {}) {
|
|
47
|
+
const first = copyRect(firstRect);
|
|
48
|
+
const last = copyRect(lastRect);
|
|
49
|
+
const sx = includeSize && Math.abs(last.width) > EPSILON ? first.width / last.width : 1;
|
|
50
|
+
const sy = includeSize && Math.abs(last.height) > EPSILON ? first.height / last.height : 1;
|
|
51
|
+
return multiplyMatrix2D(
|
|
52
|
+
translationMatrix2D(first.left, first.top),
|
|
53
|
+
multiplyMatrix2D(scaleMatrix2D(sx, sy), translationMatrix2D(-last.left, -last.top)),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matrixInElementSpace(worldMatrix, lastRect) {
|
|
58
|
+
return multiplyMatrix2D(
|
|
59
|
+
translationMatrix2D(-lastRect.left, -lastRect.top),
|
|
60
|
+
multiplyMatrix2D(worldMatrix, translationMatrix2D(lastRect.left, lastRect.top)),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nearestSelectedAncestor(element, selected) {
|
|
65
|
+
let parent = element.parentElement ?? null;
|
|
66
|
+
while (parent) {
|
|
67
|
+
if (selected.has(parent)) return parent;
|
|
68
|
+
parent = parent.parentElement ?? null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function adjustForScroll(first, currentScroll) {
|
|
74
|
+
const dx = Number(currentScroll?.x ?? 0) - first.scroll.x;
|
|
75
|
+
const dy = Number(currentScroll?.y ?? 0) - first.scroll.y;
|
|
76
|
+
return {
|
|
77
|
+
...first.rect,
|
|
78
|
+
left: first.rect.left - dx,
|
|
79
|
+
right: first.rect.right - dx,
|
|
80
|
+
top: first.rect.top - dy,
|
|
81
|
+
bottom: first.rect.bottom - dy,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clean(value) {
|
|
86
|
+
return Math.abs(value) < 1e-7 ? 0 : Math.round(value * 100000) / 100000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function matrixAtProgress(decomposed, progress, out = new Float64Array(6)) {
|
|
90
|
+
const remaining = 1 - progress;
|
|
91
|
+
const tx = decomposed.x * remaining;
|
|
92
|
+
const ty = decomposed.y * remaining;
|
|
93
|
+
const rotation = decomposed.rotateZ * remaining * Math.PI / 180;
|
|
94
|
+
const skew = decomposed.skewX * remaining * Math.PI / 180;
|
|
95
|
+
const sx = 1 + (decomposed.scaleX - 1) * remaining;
|
|
96
|
+
const sy = 1 + (decomposed.scaleY - 1) * remaining;
|
|
97
|
+
const cos = Math.cos(rotation);
|
|
98
|
+
const sin = Math.sin(rotation);
|
|
99
|
+
const tan = Math.tan(skew);
|
|
100
|
+
out[0] = cos * sx;
|
|
101
|
+
out[1] = sin * sx;
|
|
102
|
+
out[2] = (cos * tan - sin) * sy;
|
|
103
|
+
out[3] = (sin * tan + cos) * sy;
|
|
104
|
+
out[4] = tx;
|
|
105
|
+
out[5] = ty;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatMatrix(matrix) {
|
|
110
|
+
return `matrix(${clean(matrix[0])}, ${clean(matrix[1])}, ${clean(matrix[2])}, ${clean(matrix[3])}, ${clean(matrix[4])}, ${clean(matrix[5])})`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getStyleValue(element, property, fallback = '') {
|
|
114
|
+
return element.style?.[property] ?? fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setStyleValue(element, property, value) {
|
|
118
|
+
if (!element.style) return;
|
|
119
|
+
element.style[property] = value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class LayoutTransition {
|
|
123
|
+
constructor(elements, {
|
|
124
|
+
engine = defaultEngine,
|
|
125
|
+
spec = smooth(),
|
|
126
|
+
includeSize = true,
|
|
127
|
+
measureScroll = defaultMeasureScroll,
|
|
128
|
+
preserveTransform = true,
|
|
129
|
+
} = {}) {
|
|
130
|
+
this.engine = engine;
|
|
131
|
+
this.spec = spec;
|
|
132
|
+
this.includeSize = includeSize;
|
|
133
|
+
this.measureScroll = measureScroll;
|
|
134
|
+
this.preserveTransform = preserveTransform;
|
|
135
|
+
this.elements = asElements(elements);
|
|
136
|
+
this.first = captureLayout(this.elements, { measureScroll });
|
|
137
|
+
this.firstByElement = new Map(this.first.map((entry) => [entry.element, entry]));
|
|
138
|
+
this.items = [];
|
|
139
|
+
this.controls = null;
|
|
140
|
+
this.cancelled = false;
|
|
141
|
+
|
|
142
|
+
// Capture the currently rendered geometry before cancelling an older projection.
|
|
143
|
+
for (const element of this.elements) {
|
|
144
|
+
const previous = activeTransitions.get(element);
|
|
145
|
+
if (previous && previous !== this) previous.cancel();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
play() {
|
|
150
|
+
if (this.cancelled) return null;
|
|
151
|
+
const currentScroll = this.measureScroll();
|
|
152
|
+
const last = captureLayout(this.elements, { measureScroll: () => currentScroll });
|
|
153
|
+
const lastByElement = new Map(last.map((entry) => [entry.element, entry]));
|
|
154
|
+
const selected = new Set(this.elements);
|
|
155
|
+
const desiredWorld = new Map();
|
|
156
|
+
const localProjection = new Map();
|
|
157
|
+
|
|
158
|
+
// READ phase is complete before any style writes happen.
|
|
159
|
+
for (const element of this.elements) {
|
|
160
|
+
const first = this.firstByElement.get(element);
|
|
161
|
+
const lastEntry = lastByElement.get(element);
|
|
162
|
+
if (!first || !lastEntry) continue;
|
|
163
|
+
const adjustedFirst = adjustForScroll(first, currentScroll);
|
|
164
|
+
desiredWorld.set(element, projectionMatrixForRects(adjustedFirst, lastEntry.rect, { includeSize: this.includeSize }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const element of this.elements) {
|
|
168
|
+
const world = desiredWorld.get(element) ?? [...identityMatrix2D];
|
|
169
|
+
const parent = nearestSelectedAncestor(element, selected);
|
|
170
|
+
const parentWorld = parent ? desiredWorld.get(parent) ?? identityMatrix2D : identityMatrix2D;
|
|
171
|
+
const relativeWorld = multiplyMatrix2D(invertMatrix2D(parentWorld), world);
|
|
172
|
+
const lastRect = lastByElement.get(element)?.rect;
|
|
173
|
+
if (!lastRect) continue;
|
|
174
|
+
localProjection.set(element, matrixInElementSpace(relativeWorld, lastRect));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.items = this.elements.map((element) => {
|
|
178
|
+
const matrix = localProjection.get(element) ?? [...identityMatrix2D];
|
|
179
|
+
return {
|
|
180
|
+
element,
|
|
181
|
+
projection: decomposeMatrix2D(matrix),
|
|
182
|
+
originalTransform: getStyleValue(element, 'transform', ''),
|
|
183
|
+
originalTransformOrigin: getStyleValue(element, 'transformOrigin', ''),
|
|
184
|
+
originalWillChange: getStyleValue(element, 'willChange', ''),
|
|
185
|
+
matrixBuffer: new Float64Array(6),
|
|
186
|
+
baseTransformSuffix: '',
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const item of this.items) {
|
|
191
|
+
item.baseTransformSuffix = this.preserveTransform && item.originalTransform && item.originalTransform !== 'none'
|
|
192
|
+
? ` ${item.originalTransform}`
|
|
193
|
+
: '';
|
|
194
|
+
setStyleValue(item.element, 'transformOrigin', '0 0');
|
|
195
|
+
setStyleValue(item.element, 'willChange', item.originalWillChange ? `${item.originalWillChange}, transform` : 'transform');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const progress = motionValue(0);
|
|
199
|
+
this.progress = progress;
|
|
200
|
+
const unsubscribe = (progress.subscribeValue ?? progress.subscribe).call(progress, (value) => this.#commit(value));
|
|
201
|
+
this.unsubscribe = unsubscribe;
|
|
202
|
+
|
|
203
|
+
for (const item of this.items) activeTransitions.set(item.element, this);
|
|
204
|
+
this.controls = this.engine.animate(progress, 1, this.spec);
|
|
205
|
+
this.controls.finished.then(() => this.#cleanup()).catch(() => this.#cleanup());
|
|
206
|
+
return this.controls;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#commit(progress) {
|
|
210
|
+
// WRITE phase: every measurement has already happened in play().
|
|
211
|
+
for (const item of this.items) {
|
|
212
|
+
const matrix = matrixAtProgress(item.projection, progress, item.matrixBuffer);
|
|
213
|
+
setStyleValue(item.element, 'transform', `${formatMatrix(matrix)}${item.baseTransformSuffix}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#cleanup() {
|
|
218
|
+
if (this.unsubscribe) this.unsubscribe();
|
|
219
|
+
this.unsubscribe = null;
|
|
220
|
+
for (const item of this.items) {
|
|
221
|
+
setStyleValue(item.element, 'transform', item.originalTransform);
|
|
222
|
+
setStyleValue(item.element, 'transformOrigin', item.originalTransformOrigin);
|
|
223
|
+
setStyleValue(item.element, 'willChange', item.originalWillChange);
|
|
224
|
+
if (activeTransitions.get(item.element) === this) activeTransitions.delete(item.element);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
cancel() {
|
|
229
|
+
if (this.cancelled) return;
|
|
230
|
+
this.cancelled = true;
|
|
231
|
+
this.controls?.cancel();
|
|
232
|
+
this.#cleanup();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createLayoutTransition(elements, options) {
|
|
237
|
+
return new LayoutTransition(elements, options);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function animateLayout(elements, mutate, options) {
|
|
241
|
+
const transition = new LayoutTransition(elements, options);
|
|
242
|
+
if (typeof mutate === 'function') mutate();
|
|
243
|
+
const controls = transition.play();
|
|
244
|
+
return { transition, controls };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function defaultSharedKey(element) {
|
|
248
|
+
return element?.dataset?.motionKey
|
|
249
|
+
?? element?.getAttribute?.('data-motion-key')
|
|
250
|
+
?? element?.id
|
|
251
|
+
?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveKeyGetter(key) {
|
|
255
|
+
if (typeof key === 'function') return key;
|
|
256
|
+
if (typeof key === 'string') return (element) => element?.getAttribute?.(key) ?? element?.dataset?.[key] ?? element?.[key];
|
|
257
|
+
return defaultSharedKey;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class SharedLayoutSnapshot {
|
|
261
|
+
constructor(entries = []) {
|
|
262
|
+
this.entries = new Map(entries.map((entry) => [entry.key, entry]));
|
|
263
|
+
}
|
|
264
|
+
get(key) { return this.entries.get(key); }
|
|
265
|
+
has(key) { return this.entries.has(key); }
|
|
266
|
+
get size() { return this.entries.size; }
|
|
267
|
+
[Symbol.iterator]() { return this.entries[Symbol.iterator](); }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function captureSharedLayout(elements, {
|
|
271
|
+
key,
|
|
272
|
+
measureScroll = defaultMeasureScroll,
|
|
273
|
+
} = {}) {
|
|
274
|
+
const getKey = resolveKeyGetter(key);
|
|
275
|
+
const scroll = measureScroll();
|
|
276
|
+
const entries = [];
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
for (const element of asElements(elements)) {
|
|
279
|
+
const identity = getKey(element);
|
|
280
|
+
if (identity == null || identity === '') continue;
|
|
281
|
+
if (seen.has(identity)) throw new Error(`Duplicate shared-layout key: ${String(identity)}`);
|
|
282
|
+
seen.add(identity);
|
|
283
|
+
entries.push({
|
|
284
|
+
key: identity,
|
|
285
|
+
element,
|
|
286
|
+
rect: copyRect(element.getBoundingClientRect()),
|
|
287
|
+
scroll: { x: Number(scroll?.x ?? 0), y: Number(scroll?.y ?? 0) },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return new SharedLayoutSnapshot(entries);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Matched-geometry transition across different element instances. Old geometry
|
|
295
|
+
* is captured before a tree mutation; only the new/matched targets are written
|
|
296
|
+
* during playback, so the source node does not need to stay mounted.
|
|
297
|
+
*
|
|
298
|
+
* All matched targets share one progress MotionValue. A 200-item route change
|
|
299
|
+
* therefore costs one spring/timing animation rather than 200 solver entries.
|
|
300
|
+
*/
|
|
301
|
+
export class SharedLayoutTransition {
|
|
302
|
+
constructor(snapshot, elements, {
|
|
303
|
+
key,
|
|
304
|
+
engine = defaultEngine,
|
|
305
|
+
spec = smooth(),
|
|
306
|
+
includeSize = true,
|
|
307
|
+
measureScroll = defaultMeasureScroll,
|
|
308
|
+
preserveTransform = true,
|
|
309
|
+
fadeTarget = false,
|
|
310
|
+
} = {}) {
|
|
311
|
+
if (!(snapshot instanceof SharedLayoutSnapshot)) throw new TypeError('SharedLayoutTransition requires a SharedLayoutSnapshot.');
|
|
312
|
+
this.snapshot = snapshot;
|
|
313
|
+
this.elements = asElements(elements);
|
|
314
|
+
this.getKey = resolveKeyGetter(key);
|
|
315
|
+
this.engine = engine;
|
|
316
|
+
this.spec = spec;
|
|
317
|
+
this.includeSize = includeSize;
|
|
318
|
+
this.measureScroll = measureScroll;
|
|
319
|
+
this.preserveTransform = preserveTransform;
|
|
320
|
+
this.fadeTarget = fadeTarget;
|
|
321
|
+
this.items = [];
|
|
322
|
+
this.controls = null;
|
|
323
|
+
this.progress = null;
|
|
324
|
+
this.unsubscribe = null;
|
|
325
|
+
this.cancelled = false;
|
|
326
|
+
this.interruptedVisual = new Map();
|
|
327
|
+
|
|
328
|
+
// As with regular FLIP, capture the currently rendered geometry *before*
|
|
329
|
+
// cancelling an older projection. A new shared-layout transition can then
|
|
330
|
+
// continue from the visible in-flight position instead of jumping back to
|
|
331
|
+
// the underlying layout rectangle.
|
|
332
|
+
const interruptionScroll = this.measureScroll();
|
|
333
|
+
for (const element of this.elements) {
|
|
334
|
+
const previous = activeTransitions.get(element);
|
|
335
|
+
if (previous && previous !== this) {
|
|
336
|
+
const identity = this.getKey(element);
|
|
337
|
+
if (identity != null && identity !== '') {
|
|
338
|
+
this.interruptedVisual.set(identity, {
|
|
339
|
+
key: identity,
|
|
340
|
+
element,
|
|
341
|
+
rect: copyRect(element.getBoundingClientRect()),
|
|
342
|
+
scroll: { x: Number(interruptionScroll?.x ?? 0), y: Number(interruptionScroll?.y ?? 0) },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
previous.cancel();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
play() {
|
|
351
|
+
if (this.cancelled) return null;
|
|
352
|
+
const currentScroll = this.measureScroll();
|
|
353
|
+
const selected = new Set();
|
|
354
|
+
const desiredWorld = new Map();
|
|
355
|
+
const lastByElement = new Map();
|
|
356
|
+
const sourceByElement = new Map();
|
|
357
|
+
|
|
358
|
+
// READ phase: collect every target rect and desired viewport projection.
|
|
359
|
+
const seenTargetKeys = new Set();
|
|
360
|
+
for (const element of this.elements) {
|
|
361
|
+
const identity = this.getKey(element);
|
|
362
|
+
if (identity != null && identity !== '') {
|
|
363
|
+
if (seenTargetKeys.has(identity)) throw new Error(`Duplicate shared-layout target key: ${String(identity)}`);
|
|
364
|
+
seenTargetKeys.add(identity);
|
|
365
|
+
}
|
|
366
|
+
const source = identity == null ? null : (this.interruptedVisual.get(identity) ?? this.snapshot.get(identity));
|
|
367
|
+
if (!source) continue;
|
|
368
|
+
const lastRect = copyRect(element.getBoundingClientRect());
|
|
369
|
+
const adjustedFirst = adjustForScroll(source, currentScroll);
|
|
370
|
+
selected.add(element);
|
|
371
|
+
sourceByElement.set(element, source);
|
|
372
|
+
lastByElement.set(element, lastRect);
|
|
373
|
+
desiredWorld.set(element, projectionMatrixForRects(adjustedFirst, lastRect, { includeSize: this.includeSize }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Remove projection already supplied by a matched target ancestor.
|
|
377
|
+
const localProjection = new Map();
|
|
378
|
+
for (const element of selected) {
|
|
379
|
+
const parent = nearestSelectedAncestor(element, selected);
|
|
380
|
+
const parentWorld = parent ? desiredWorld.get(parent) ?? identityMatrix2D : identityMatrix2D;
|
|
381
|
+
const relativeWorld = multiplyMatrix2D(invertMatrix2D(parentWorld), desiredWorld.get(element) ?? identityMatrix2D);
|
|
382
|
+
localProjection.set(element, matrixInElementSpace(relativeWorld, lastByElement.get(element)));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.items = [];
|
|
386
|
+
for (const element of selected) {
|
|
387
|
+
const originalOpacity = getStyleValue(element, 'opacity', '');
|
|
388
|
+
const numericOpacity = originalOpacity === '' ? 1 : Number(originalOpacity);
|
|
389
|
+
this.items.push({
|
|
390
|
+
element,
|
|
391
|
+
sourceElement: sourceByElement.get(element)?.element ?? null,
|
|
392
|
+
projection: decomposeMatrix2D(localProjection.get(element) ?? identityMatrix2D),
|
|
393
|
+
originalTransform: getStyleValue(element, 'transform', ''),
|
|
394
|
+
originalTransformOrigin: getStyleValue(element, 'transformOrigin', ''),
|
|
395
|
+
originalWillChange: getStyleValue(element, 'willChange', ''),
|
|
396
|
+
originalOpacity,
|
|
397
|
+
targetOpacity: Number.isFinite(numericOpacity) ? numericOpacity : 1,
|
|
398
|
+
matrixBuffer: new Float64Array(6),
|
|
399
|
+
baseTransformSuffix: '',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (this.items.length === 0) return null;
|
|
404
|
+
for (const item of this.items) {
|
|
405
|
+
item.baseTransformSuffix = this.preserveTransform && item.originalTransform && item.originalTransform !== 'none'
|
|
406
|
+
? ` ${item.originalTransform}`
|
|
407
|
+
: '';
|
|
408
|
+
setStyleValue(item.element, 'transformOrigin', '0 0');
|
|
409
|
+
setStyleValue(item.element, 'willChange', item.originalWillChange ? `${item.originalWillChange}, transform` : 'transform');
|
|
410
|
+
}
|
|
411
|
+
const progress = motionValue(0);
|
|
412
|
+
this.progress = progress;
|
|
413
|
+
this.unsubscribe = progress.subscribeValue((value) => this.#commit(value));
|
|
414
|
+
for (const item of this.items) activeTransitions.set(item.element, this);
|
|
415
|
+
this.controls = this.engine.animate(progress, 1, this.spec);
|
|
416
|
+
this.controls.finished.then(() => this.#cleanup()).catch(() => this.#cleanup());
|
|
417
|
+
return this.controls;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#commit(progress) {
|
|
421
|
+
for (const item of this.items) {
|
|
422
|
+
const matrix = matrixAtProgress(item.projection, progress, item.matrixBuffer);
|
|
423
|
+
setStyleValue(item.element, 'transform', `${formatMatrix(matrix)}${item.baseTransformSuffix}`);
|
|
424
|
+
if (this.fadeTarget && item.sourceElement !== item.element) {
|
|
425
|
+
setStyleValue(item.element, 'opacity', String(item.targetOpacity * progress));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#cleanup() {
|
|
431
|
+
this.unsubscribe?.();
|
|
432
|
+
this.unsubscribe = null;
|
|
433
|
+
for (const item of this.items) {
|
|
434
|
+
setStyleValue(item.element, 'transform', item.originalTransform);
|
|
435
|
+
setStyleValue(item.element, 'transformOrigin', item.originalTransformOrigin);
|
|
436
|
+
setStyleValue(item.element, 'willChange', item.originalWillChange);
|
|
437
|
+
if (this.fadeTarget && item.sourceElement !== item.element) setStyleValue(item.element, 'opacity', item.originalOpacity);
|
|
438
|
+
if (activeTransitions.get(item.element) === this) activeTransitions.delete(item.element);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
cancel() {
|
|
443
|
+
if (this.cancelled) return;
|
|
444
|
+
this.cancelled = true;
|
|
445
|
+
this.controls?.cancel();
|
|
446
|
+
this.#cleanup();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function createSharedLayoutTransition(snapshot, elements, options) {
|
|
451
|
+
return new SharedLayoutTransition(snapshot, elements, options);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function animateSharedLayout(snapshot, elements, options) {
|
|
455
|
+
const transition = new SharedLayoutTransition(snapshot, elements, options);
|
|
456
|
+
const controls = transition.play();
|
|
457
|
+
return { transition, controls };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export class SharedLayoutRegistry {
|
|
461
|
+
constructor(options = {}) {
|
|
462
|
+
this.options = { ...options };
|
|
463
|
+
this.snapshot = new SharedLayoutSnapshot();
|
|
464
|
+
this.active = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
capture(elements, options = {}) {
|
|
468
|
+
this.snapshot = captureSharedLayout(elements, { ...this.options, ...options });
|
|
469
|
+
return this.snapshot;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
play(elements, options = {}) {
|
|
473
|
+
this.active?.cancel();
|
|
474
|
+
const transition = new SharedLayoutTransition(this.snapshot, elements, { ...this.options, ...options });
|
|
475
|
+
this.active = transition;
|
|
476
|
+
const controls = transition.play();
|
|
477
|
+
controls?.finished.finally(() => { if (this.active === transition) this.active = null; });
|
|
478
|
+
return { transition, controls };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
cancel() {
|
|
482
|
+
this.active?.cancel();
|
|
483
|
+
this.active = null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { formatColor, mixColor, parseColor } from '../interpolate/color.js';
|
|
2
|
+
|
|
3
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
4
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
5
|
+
|
|
6
|
+
const BASE = Object.freeze({
|
|
7
|
+
blur: 0,
|
|
8
|
+
saturation: 1,
|
|
9
|
+
brightness: 1,
|
|
10
|
+
contrast: 1,
|
|
11
|
+
tint: 'rgba(255, 255, 255, 0)',
|
|
12
|
+
tintStrength: 1,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const PRESETS = {
|
|
16
|
+
clear: BASE,
|
|
17
|
+
ultraThin: { blur: 10, saturation: 1.24, brightness: 1.04, contrast: 1.01, tint: 'rgba(255, 255, 255, 0.10)', tintStrength: 1 },
|
|
18
|
+
thin: { blur: 16, saturation: 1.30, brightness: 1.05, contrast: 1.015, tint: 'rgba(255, 255, 255, 0.13)', tintStrength: 1 },
|
|
19
|
+
regular: { blur: 24, saturation: 1.36, brightness: 1.06, contrast: 1.02, tint: 'rgba(255, 255, 255, 0.16)', tintStrength: 1 },
|
|
20
|
+
thick: { blur: 36, saturation: 1.42, brightness: 1.075, contrast: 1.025, tint: 'rgba(255, 255, 255, 0.20)', tintStrength: 1 },
|
|
21
|
+
glass: { blur: 22, saturation: 1.48, brightness: 1.09, contrast: 1.035, tint: 'rgba(255, 255, 255, 0.14)', tintStrength: 1 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const materials = Object.freeze(Object.fromEntries(
|
|
25
|
+
Object.entries(PRESETS).map(([name, value]) => [name, Object.freeze({ ...value })]),
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
export function resolveMaterial(input = 'regular') {
|
|
29
|
+
const source = typeof input === 'string' ? materials[input] : input;
|
|
30
|
+
if (!source || typeof source !== 'object') throw new TypeError(`Unknown material: ${String(input)}`);
|
|
31
|
+
const blur = Math.max(0, Number(source.blur ?? BASE.blur));
|
|
32
|
+
const saturation = Math.max(0, Number(source.saturation ?? BASE.saturation));
|
|
33
|
+
const brightness = Math.max(0, Number(source.brightness ?? BASE.brightness));
|
|
34
|
+
const contrast = Math.max(0, Number(source.contrast ?? BASE.contrast));
|
|
35
|
+
const tintStrength = clamp(Number(source.tintStrength ?? BASE.tintStrength), 0, 1);
|
|
36
|
+
const tint = parseColor(source.tint ?? BASE.tint);
|
|
37
|
+
if (![blur, saturation, brightness, contrast, tintStrength].every(Number.isFinite)) {
|
|
38
|
+
throw new TypeError('Material numeric properties must be finite numbers.');
|
|
39
|
+
}
|
|
40
|
+
return { blur, saturation, brightness, contrast, tint, tintStrength };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mixMaterial(fromInput, toInput, progress, { colorSpace = 'oklab' } = {}) {
|
|
44
|
+
const from = resolveMaterial(fromInput);
|
|
45
|
+
const to = resolveMaterial(toInput);
|
|
46
|
+
const t = clamp(Number(progress) || 0, 0, 1);
|
|
47
|
+
return {
|
|
48
|
+
blur: lerp(from.blur, to.blur, t),
|
|
49
|
+
saturation: lerp(from.saturation, to.saturation, t),
|
|
50
|
+
brightness: lerp(from.brightness, to.brightness, t),
|
|
51
|
+
contrast: lerp(from.contrast, to.contrast, t),
|
|
52
|
+
tint: mixColor(from.tint, to.tint, t, { space: colorSpace }),
|
|
53
|
+
tintStrength: lerp(from.tintStrength, to.tintStrength, t),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function interpolateMaterial(from, to, options = {}) {
|
|
58
|
+
const start = resolveMaterial(from);
|
|
59
|
+
const end = resolveMaterial(to);
|
|
60
|
+
return (progress) => mixMaterial(start, end, progress, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function materialToCss(materialInput) {
|
|
64
|
+
const material = resolveMaterial(materialInput);
|
|
65
|
+
const tint = { ...material.tint, a: material.tint.a * material.tintStrength };
|
|
66
|
+
return {
|
|
67
|
+
backdropFilter: `blur(${material.blur}px) saturate(${material.saturation}) brightness(${material.brightness}) contrast(${material.contrast})`,
|
|
68
|
+
backgroundColor: formatColor(tint),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type PathMorphOptions = {
|
|
2
|
+
align?: boolean;
|
|
3
|
+
allowReverse?: boolean;
|
|
4
|
+
precision?: number;
|
|
5
|
+
alignmentCandidates?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ParsedPathSegment = {
|
|
9
|
+
p0: { x: number; y: number };
|
|
10
|
+
p1: { x: number; y: number };
|
|
11
|
+
p2: { x: number; y: number };
|
|
12
|
+
p3: { x: number; y: number };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parsePath(path: string): {
|
|
16
|
+
subpaths: Array<{ segments: ParsedPathSegment[]; closed: boolean }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function normalizePathPair(fromPath: string, toPath: string, options?: PathMorphOptions): {
|
|
20
|
+
from: { coords: Float64Array; subpaths: Array<{ offset: number; count: number; closed: boolean }> };
|
|
21
|
+
to: { coords: Float64Array; subpaths: Array<{ offset: number; count: number; closed: boolean }> };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class PathMorpher {
|
|
25
|
+
constructor(fromPath: string, toPath: string, options?: PathMorphOptions);
|
|
26
|
+
readonly coordinateCount: number;
|
|
27
|
+
readonly segmentCount: number;
|
|
28
|
+
readonly from: Float64Array;
|
|
29
|
+
readonly to: Float64Array;
|
|
30
|
+
readonly buffer: Float64Array;
|
|
31
|
+
sampleInto(progress: number, output?: Float64Array): Float64Array;
|
|
32
|
+
format(buffer?: Float64Array): string;
|
|
33
|
+
sample(progress: number): string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createPathMorpher(fromPath: string, toPath: string, options?: PathMorphOptions): PathMorpher;
|
|
37
|
+
export function interpolatePath(fromPath: string, toPath: string, options?: PathMorphOptions): (progress: number) => string;
|