react-morphcard 0.1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.js +1575 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1575 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { flushSync } from "react-dom";
|
|
4
|
+
//#region src/geometry.ts
|
|
5
|
+
function box(left, top, width, height) {
|
|
6
|
+
return {
|
|
7
|
+
left,
|
|
8
|
+
top,
|
|
9
|
+
width,
|
|
10
|
+
height
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function toBox(rect) {
|
|
14
|
+
return {
|
|
15
|
+
left: rect.left,
|
|
16
|
+
top: rect.top,
|
|
17
|
+
width: rect.width,
|
|
18
|
+
height: rect.height
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Screen pixels per CSS pixel of an element: its rendered width over its
|
|
23
|
+
* layout width. Not 1 when an ancestor is scaled (a preview frame shrunk to
|
|
24
|
+
* fit, a zoomed canvas). Falls back to 1 when either size is unusable.
|
|
25
|
+
*/
|
|
26
|
+
function unitOf(rendered, layout) {
|
|
27
|
+
if (!(rendered > 0) || !(layout > 0)) return 1;
|
|
28
|
+
const unit = rendered / layout;
|
|
29
|
+
return Math.abs(unit - 1) < .001 ? 1 : unit;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A box measured on screen, in the CSS pixels of a scaled subtree. Clip
|
|
33
|
+
* insets and transforms are written in those pixels, so every measured box
|
|
34
|
+
* goes through this before it becomes a keyframe.
|
|
35
|
+
*/
|
|
36
|
+
function toLocal(b, unit) {
|
|
37
|
+
if (unit === 1) return b;
|
|
38
|
+
return {
|
|
39
|
+
left: b.left / unit,
|
|
40
|
+
top: b.top / unit,
|
|
41
|
+
width: b.width / unit,
|
|
42
|
+
height: b.height / unit
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function area(b) {
|
|
46
|
+
return b ? Math.max(0, b.width) * Math.max(0, b.height) : 0;
|
|
47
|
+
}
|
|
48
|
+
/** The overlap of two boxes, or null when they do not overlap. */
|
|
49
|
+
function intersect(a, b) {
|
|
50
|
+
const left = Math.max(a.left, b.left);
|
|
51
|
+
const top = Math.max(a.top, b.top);
|
|
52
|
+
const right = Math.min(a.left + a.width, b.left + b.width);
|
|
53
|
+
const bottom = Math.min(a.top + a.height, b.top + b.height);
|
|
54
|
+
if (right <= left || bottom <= top) return null;
|
|
55
|
+
return {
|
|
56
|
+
left,
|
|
57
|
+
top,
|
|
58
|
+
width: right - left,
|
|
59
|
+
height: bottom - top
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Share of `rect` that is inside `visible` (0 to 1). Empty boxes count as hidden. */
|
|
63
|
+
function visibleRatio(rect, visible) {
|
|
64
|
+
const whole = area(rect);
|
|
65
|
+
if (whole <= 0 || !visible) return 0;
|
|
66
|
+
return area(intersect(rect, visible)) / whole;
|
|
67
|
+
}
|
|
68
|
+
/** Below this share of their area on screen, elements do not take part in a flight. */
|
|
69
|
+
const MIN_VISIBLE = .5;
|
|
70
|
+
function isOnScreen(rect, visible, min = MIN_VISIBLE) {
|
|
71
|
+
return visibleRatio(rect, visible) >= min;
|
|
72
|
+
}
|
|
73
|
+
const NO_SHIFT = {
|
|
74
|
+
x: 0,
|
|
75
|
+
y: 0
|
|
76
|
+
};
|
|
77
|
+
function moveBox(b, shift) {
|
|
78
|
+
return shift.x === 0 && shift.y === 0 ? b : box(b.left + shift.x, b.top + shift.y, b.width, b.height);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* How far to move `outer` so that it covers `inner`, moving it as little as
|
|
82
|
+
* possible. When `inner` is larger on an axis, `outer` ends up inside it.
|
|
83
|
+
* Used for a sheet smaller than the screen (a centred panel) that must start
|
|
84
|
+
* over a card it does not cover.
|
|
85
|
+
*/
|
|
86
|
+
function coverShift(inner, outer) {
|
|
87
|
+
const axis = (a0, a1, b0, b1) => {
|
|
88
|
+
if (a0 >= b0 && a1 <= b1) return 0;
|
|
89
|
+
if (a1 - a0 <= b1 - b0) return a0 < b0 ? a0 - b0 : a1 - b1;
|
|
90
|
+
if (b0 >= a0 && b1 <= a1) return 0;
|
|
91
|
+
return b0 < a0 ? a0 - b0 : a1 - b1;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
x: axis(inner.left, inner.left + inner.width, outer.left, outer.left + outer.width),
|
|
95
|
+
y: axis(inner.top, inner.top + inner.height, outer.top, outer.top + outer.height)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function px(n) {
|
|
99
|
+
return `${Math.round(n * 100) / 100}px`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A clip-path that shows only `rect` of an element whose border box is
|
|
103
|
+
* `container`. The rect is clamped to the container, so a card that sticks
|
|
104
|
+
* out of the surface never produces negative insets.
|
|
105
|
+
*/
|
|
106
|
+
function insetClip(rect, container, radius) {
|
|
107
|
+
const top = Math.max(0, rect.top - container.top);
|
|
108
|
+
const left = Math.max(0, rect.left - container.left);
|
|
109
|
+
const right = Math.max(0, container.left + container.width - (rect.left + rect.width));
|
|
110
|
+
const bottom = Math.max(0, container.top + container.height - (rect.top + rect.height));
|
|
111
|
+
return `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)} round ${px(radius)})`;
|
|
112
|
+
}
|
|
113
|
+
function fullClip(radius) {
|
|
114
|
+
return `inset(0px 0px 0px 0px round ${px(radius)})`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* First length of a computed border-radius ("16px", "16px 8px"). Percentages
|
|
118
|
+
* resolve against `width`; anything else counts as square.
|
|
119
|
+
*/
|
|
120
|
+
function parseRadius(value, width = 0) {
|
|
121
|
+
const text = value?.trim() ?? "";
|
|
122
|
+
const pxMatch = /^(\d*\.?\d+)px/.exec(text);
|
|
123
|
+
if (pxMatch) return Number(pxMatch[1]);
|
|
124
|
+
const pctMatch = /^(\d*\.?\d+)%/.exec(text);
|
|
125
|
+
if (pctMatch) return Number(pctMatch[1]) / 100 * width;
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
/** Lines of text in a box, given its computed line-height and font-size. */
|
|
129
|
+
function lineCount(height, lineHeight, fontSize) {
|
|
130
|
+
const parsed = Number.parseFloat(lineHeight);
|
|
131
|
+
const lh = Number.isFinite(parsed) && parsed > 0 ? lineHeight.endsWith("px") ? parsed : parsed * fontSize : fontSize * 1.2;
|
|
132
|
+
if (lh <= 0) return 1;
|
|
133
|
+
return Math.max(1, Math.round(height / lh));
|
|
134
|
+
}
|
|
135
|
+
function planPair(input) {
|
|
136
|
+
const { src, dst } = input;
|
|
137
|
+
if (input.mode === "box") {
|
|
138
|
+
const scale = dst.width > 0 ? src.width / dst.width : 1;
|
|
139
|
+
return {
|
|
140
|
+
scale,
|
|
141
|
+
crossfade: dst.height <= 0 || Math.abs(src.height / dst.height - scale) > .04
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const srcFont = input.srcFont ?? 16;
|
|
145
|
+
const dstFont = input.dstFont ?? 16;
|
|
146
|
+
const scale = dstFont > 0 ? srcFont / dstFont : 1;
|
|
147
|
+
return {
|
|
148
|
+
scale,
|
|
149
|
+
crossfade: Boolean(input.srcClipped) || (input.srcLines ?? 1) !== (input.dstLines ?? 1) || dst.width <= 0 || dst.height <= 0 || Math.abs(src.width / dst.width - scale) > .04 || Math.abs(src.height / dst.height - scale) > .04
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Transform (origin 0 0) that draws an element laid out at `at` over `over`, scaled by `scale`. */
|
|
153
|
+
function placeOver(at, over, scale) {
|
|
154
|
+
return `translate(${px(over.left - at.left)}, ${px(over.top - at.top)}) scale(${Math.round(scale * 1e4) / 1e4})`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Frames for a picture that changes shape on the way: a square thumbnail
|
|
158
|
+
* into a wide banner, a cropped poster into a tall video. The small picture
|
|
159
|
+
* is treated as a crop of the large one, the way `object-fit: cover` crops
|
|
160
|
+
* it. A rectangle moves from the card to the sheet; the large copy covers it
|
|
161
|
+
* and both copies are cut to it with a clip-path. The small copy is scaled so
|
|
162
|
+
* that it shows the same part of the picture at the same size and place as
|
|
163
|
+
* the large copy under it. So while the two copies swap, the reader sees one
|
|
164
|
+
* picture whose frame changes shape: never two pictures of different sizes,
|
|
165
|
+
* never a zoom into a crop.
|
|
166
|
+
*
|
|
167
|
+
* The rectangle moves linearly; the effect's easing shapes it in time. The
|
|
168
|
+
* frames are sampled because a clip-path inside a changing scale is not linear.
|
|
169
|
+
*/
|
|
170
|
+
function fillFrames(input) {
|
|
171
|
+
const { big, small, from, to } = input;
|
|
172
|
+
const [ax, ay] = input.align ?? [.5, .5];
|
|
173
|
+
const [r0, r1] = input.radius ?? [0, 0];
|
|
174
|
+
const steps = input.steps ?? 16;
|
|
175
|
+
const out = {
|
|
176
|
+
big: [],
|
|
177
|
+
small: []
|
|
178
|
+
};
|
|
179
|
+
if (!(big.width > 0 && big.height > 0 && small.width > 0 && small.height > 0)) return out;
|
|
180
|
+
const k = Math.max(small.width / big.width, small.height / big.height);
|
|
181
|
+
const cropX = ax * (big.width * k - small.width);
|
|
182
|
+
const cropY = ay * (big.height * k - small.height);
|
|
183
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
184
|
+
const frame = (at, left, top, s, rect, radius, offset) => {
|
|
185
|
+
const c = (n) => px(Math.max(0, n) / s);
|
|
186
|
+
const inset = [
|
|
187
|
+
c(rect.top - top),
|
|
188
|
+
c(left + at.width * s - (rect.left + rect.width)),
|
|
189
|
+
c(top + at.height * s - (rect.top + rect.height)),
|
|
190
|
+
c(rect.left - left)
|
|
191
|
+
];
|
|
192
|
+
return {
|
|
193
|
+
offset,
|
|
194
|
+
transform: `translate(${px(left - at.left)}, ${px(top - at.top)}) scale(${Math.round(s * 1e4) / 1e4})`,
|
|
195
|
+
transformOrigin: "0 0",
|
|
196
|
+
clipPath: `inset(${inset.join(" ")} round ${px(radius / s)})`
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
for (let i = 0; i <= steps; i++) {
|
|
200
|
+
const t = i / steps;
|
|
201
|
+
const offset = Math.round(t * 1e4) / 1e4;
|
|
202
|
+
const rect = box(lerp(from.left, to.left, t), lerp(from.top, to.top, t), lerp(from.width, to.width, t), lerp(from.height, to.height, t));
|
|
203
|
+
const radius = lerp(r0, r1, t);
|
|
204
|
+
const sb = Math.max(rect.width / big.width, rect.height / big.height);
|
|
205
|
+
const bl = rect.left + ax * (rect.width - big.width * sb);
|
|
206
|
+
const bt = rect.top + ay * (rect.height - big.height * sb);
|
|
207
|
+
out.big.push(frame(big, bl, bt, sb, rect, radius, offset));
|
|
208
|
+
if (input.crop === false) {
|
|
209
|
+
const s2 = Math.max(rect.width / small.width, rect.height / small.height);
|
|
210
|
+
const sl = rect.left + ax * (rect.width - small.width * s2);
|
|
211
|
+
const st = rect.top + ay * (rect.height - small.height * s2);
|
|
212
|
+
out.small.push(frame(small, sl, st, s2, rect, radius, offset));
|
|
213
|
+
} else {
|
|
214
|
+
const ss = sb / k;
|
|
215
|
+
out.small.push(frame(small, bl + cropX * ss, bt + cropY * ss, ss, rect, radius, offset));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Transform origin for scaling the background back: the horizontal centre of
|
|
222
|
+
* its visible part and 30% down it, so a long scrolled page shrinks around
|
|
223
|
+
* what the reader sees, not around the middle of the whole document.
|
|
224
|
+
*/
|
|
225
|
+
function backgroundOrigin(element, visible) {
|
|
226
|
+
const v = visible ?? element;
|
|
227
|
+
const x = v.left + v.width / 2 - element.left;
|
|
228
|
+
const y = v.top + v.height * .3 - element.top;
|
|
229
|
+
return `${px(x)} ${px(y)}`;
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/dom.ts
|
|
233
|
+
/**
|
|
234
|
+
* DOM helpers: measuring what is on screen, finding marked elements, and
|
|
235
|
+
* undoing every attribute or inline style the library sets.
|
|
236
|
+
*/
|
|
237
|
+
const SHARED_ATTR = "data-morph";
|
|
238
|
+
const GHOST_ATTR = "data-morph-ghost";
|
|
239
|
+
const STAGGER_ATTR = "data-morph-stagger";
|
|
240
|
+
const DOCK_ATTR = "data-morph-dock";
|
|
241
|
+
const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
242
|
+
function viewportBox() {
|
|
243
|
+
return {
|
|
244
|
+
left: 0,
|
|
245
|
+
top: 0,
|
|
246
|
+
width: document.documentElement.clientWidth || window.innerWidth,
|
|
247
|
+
height: window.innerHeight
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The part of the viewport where `el` can be seen: the viewport cut down by
|
|
252
|
+
* every ancestor that clips its content (overflow other than visible).
|
|
253
|
+
* Returns null when an ancestor clips everything away.
|
|
254
|
+
*/
|
|
255
|
+
function visibleBoxOf(el) {
|
|
256
|
+
let visible = viewportBox();
|
|
257
|
+
for (let node = el.parentElement; node && visible; node = node.parentElement) {
|
|
258
|
+
if (node === document.body || node === document.documentElement) break;
|
|
259
|
+
const style = getComputedStyle(node);
|
|
260
|
+
if (style.display === "contents") continue;
|
|
261
|
+
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
|
262
|
+
const rect = node.getBoundingClientRect();
|
|
263
|
+
const scaleX = unitOf(rect.width, node.offsetWidth);
|
|
264
|
+
const scaleY = unitOf(rect.height, node.offsetHeight);
|
|
265
|
+
visible = intersect(visible, {
|
|
266
|
+
left: rect.left + node.clientLeft * scaleX,
|
|
267
|
+
top: rect.top + node.clientTop * scaleY,
|
|
268
|
+
width: node.clientWidth * scaleX,
|
|
269
|
+
height: node.clientHeight * scaleY
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (style.position === "fixed") break;
|
|
273
|
+
}
|
|
274
|
+
return visible;
|
|
275
|
+
}
|
|
276
|
+
function rectOf(el) {
|
|
277
|
+
return toBox(el.getBoundingClientRect());
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* True when a descendant is position: fixed. A transform on the ancestor
|
|
281
|
+
* would become the containing block of that descendant and drag it along.
|
|
282
|
+
*/
|
|
283
|
+
function hasFixedDescendant(el) {
|
|
284
|
+
const all = el.getElementsByTagName("*");
|
|
285
|
+
for (let i = 0; i < all.length; i++) {
|
|
286
|
+
const node = all[i];
|
|
287
|
+
if (node && getComputedStyle(node).position === "fixed") return true;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
/** The text is cut off: an ellipsis, a line clamp or hidden overflow. */
|
|
292
|
+
function isClipped(el, style = getComputedStyle(el)) {
|
|
293
|
+
if (style.overflowX === "visible" && style.overflowY === "visible") return false;
|
|
294
|
+
if (el.clientWidth === 0) return false;
|
|
295
|
+
return el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
|
|
296
|
+
}
|
|
297
|
+
function textLines(el, height, style = getComputedStyle(el)) {
|
|
298
|
+
const fontSize = Number.parseFloat(style.fontSize) || 16;
|
|
299
|
+
const inner = height - (Number.parseFloat(style.paddingTop) || 0) - (Number.parseFloat(style.paddingBottom) || 0) - (Number.parseFloat(style.borderTopWidth) || 0) - (Number.parseFloat(style.borderBottomWidth) || 0);
|
|
300
|
+
return lineCount(Math.max(0, inner), style.lineHeight, fontSize);
|
|
301
|
+
}
|
|
302
|
+
/** Index once per measurement; duplicate keys keep the first non-ghost element. */
|
|
303
|
+
function sharedElements(root) {
|
|
304
|
+
const elements = /* @__PURE__ */ new Map();
|
|
305
|
+
for (const el of root.querySelectorAll(`[${SHARED_ATTR}]`)) {
|
|
306
|
+
if (el.closest(`[data-morph-ghost]`)) continue;
|
|
307
|
+
const key = el.getAttribute(SHARED_ATTR);
|
|
308
|
+
if (key && !elements.has(key)) elements.set(key, el);
|
|
309
|
+
}
|
|
310
|
+
return elements;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Splits the sheet into the parts that move differently. Walks down only
|
|
314
|
+
* into elements that contain shared, stagger or dock markers, so each
|
|
315
|
+
* returned element is the largest block that can fade as a whole.
|
|
316
|
+
*/
|
|
317
|
+
function collectContent(sheet) {
|
|
318
|
+
const out = {
|
|
319
|
+
stagger: [],
|
|
320
|
+
plain: [],
|
|
321
|
+
docks: []
|
|
322
|
+
};
|
|
323
|
+
const marked = `[${SHARED_ATTR}], [${STAGGER_ATTR}], [${DOCK_ATTR}]`;
|
|
324
|
+
const walk = (parent) => {
|
|
325
|
+
for (const child of Array.from(parent.children)) {
|
|
326
|
+
if (!(child instanceof HTMLElement) && !(child instanceof SVGElement)) continue;
|
|
327
|
+
if (child.hasAttribute("data-morph-ghost") || child.hasAttribute("data-morph")) continue;
|
|
328
|
+
if (child.hasAttribute(DOCK_ATTR)) out.docks.push(child);
|
|
329
|
+
else if (child.hasAttribute(STAGGER_ATTR) && !child.querySelector(`[data-morph]`)) out.stagger.push(child);
|
|
330
|
+
else if (child.querySelector(marked)) walk(child);
|
|
331
|
+
else out.plain.push(child);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
walk(sheet);
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/** Largest blocks of a card copy that contain no shared element. */
|
|
338
|
+
function collectRest(root) {
|
|
339
|
+
const out = [];
|
|
340
|
+
const walk = (parent) => {
|
|
341
|
+
for (const child of Array.from(parent.children)) {
|
|
342
|
+
if (child.hasAttribute("data-morph")) continue;
|
|
343
|
+
if (child.querySelector(`[data-morph]`)) walk(child);
|
|
344
|
+
else out.push(child);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
walk(root);
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
function focusTargetIn(root) {
|
|
351
|
+
if (root.matches("[data-morph-focus]")) return root;
|
|
352
|
+
const marked = root.querySelector("[data-morph-focus]");
|
|
353
|
+
if (marked) return marked;
|
|
354
|
+
if (root.matches("a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])")) return root;
|
|
355
|
+
const first = root.querySelector(FOCUSABLE);
|
|
356
|
+
if (first) return first;
|
|
357
|
+
return root.tabIndex >= 0 ? root : null;
|
|
358
|
+
}
|
|
359
|
+
/** Nearest ancestor that scrolls vertically, or the window. */
|
|
360
|
+
function scrollerOf(el) {
|
|
361
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
362
|
+
if (node === document.body || node === document.documentElement) break;
|
|
363
|
+
const overflow = getComputedStyle(node).overflowY;
|
|
364
|
+
if ((overflow === "auto" || overflow === "scroll" || overflow === "overlay") && node.scrollHeight > node.clientHeight) return node;
|
|
365
|
+
}
|
|
366
|
+
return window;
|
|
367
|
+
}
|
|
368
|
+
function saveScroll(target) {
|
|
369
|
+
if (target instanceof Window) return {
|
|
370
|
+
target,
|
|
371
|
+
top: window.scrollY,
|
|
372
|
+
left: window.scrollX
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
target,
|
|
376
|
+
top: target.scrollTop,
|
|
377
|
+
left: target.scrollLeft
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** Puts the scroll position back at once, ignoring scroll-behavior: smooth. */
|
|
381
|
+
function restoreScroll(saved) {
|
|
382
|
+
const { target, top, left } = saved;
|
|
383
|
+
if (target instanceof Window) {
|
|
384
|
+
if (window.scrollY !== top || window.scrollX !== left) window.scrollTo({
|
|
385
|
+
top,
|
|
386
|
+
left,
|
|
387
|
+
behavior: "instant"
|
|
388
|
+
});
|
|
389
|
+
} else if (target.isConnected && (target.scrollTop !== top || target.scrollLeft !== left)) target.scrollTo({
|
|
390
|
+
top,
|
|
391
|
+
left,
|
|
392
|
+
behavior: "instant"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Records every attribute it changes and puts them back in reverse order, so
|
|
397
|
+
* nothing the library touched survives a cleanup.
|
|
398
|
+
*/
|
|
399
|
+
var Undo = class {
|
|
400
|
+
steps = [];
|
|
401
|
+
attr(el, name, value) {
|
|
402
|
+
const previous = el.getAttribute(name);
|
|
403
|
+
if (value === null) el.removeAttribute(name);
|
|
404
|
+
else el.setAttribute(name, value);
|
|
405
|
+
this.steps.push(() => {
|
|
406
|
+
if (previous === null) el.removeAttribute(name);
|
|
407
|
+
else el.setAttribute(name, previous);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
get size() {
|
|
411
|
+
return this.steps.length;
|
|
412
|
+
}
|
|
413
|
+
flush() {
|
|
414
|
+
for (let step = this.steps.pop(); step; step = this.steps.pop()) step();
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/timing.ts
|
|
419
|
+
const defaults = {
|
|
420
|
+
duration: {
|
|
421
|
+
open: 400,
|
|
422
|
+
close: 300
|
|
423
|
+
},
|
|
424
|
+
easing: {
|
|
425
|
+
surface: "cubic-bezier(0.32, 0.72, 0, 1)",
|
|
426
|
+
content: "cubic-bezier(0.23, 1, 0.32, 1)"
|
|
427
|
+
},
|
|
428
|
+
stagger: 45,
|
|
429
|
+
backgroundScale: .96,
|
|
430
|
+
reducedMotion: "system",
|
|
431
|
+
timeScale: 1,
|
|
432
|
+
closeOnEscape: true,
|
|
433
|
+
restoreScroll: true
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Fractions of the open or close duration. With the defaults (400 / 300 ms):
|
|
437
|
+
* content enters at 130 ms, the card copy of a crossfading text is gone by
|
|
438
|
+
* 160 ms, and on close the big heading hands over to the card text by 90 ms.
|
|
439
|
+
*/
|
|
440
|
+
const choreography = {
|
|
441
|
+
open: {
|
|
442
|
+
contentDelay: .325,
|
|
443
|
+
content: .65,
|
|
444
|
+
copyOut: .4,
|
|
445
|
+
targetIn: .5,
|
|
446
|
+
restOut: .35,
|
|
447
|
+
borderOut: .2,
|
|
448
|
+
lateIn: .4,
|
|
449
|
+
dockDelay: .4,
|
|
450
|
+
dock: .75
|
|
451
|
+
},
|
|
452
|
+
close: {
|
|
453
|
+
copyIn: .37,
|
|
454
|
+
targetOut: .3,
|
|
455
|
+
restDelay: .5,
|
|
456
|
+
rest: .5,
|
|
457
|
+
contentOut: .37,
|
|
458
|
+
dock: .6
|
|
459
|
+
},
|
|
460
|
+
/** Opacity only, in ms: the empty surface covers the page before any text appears. */
|
|
461
|
+
fade: {
|
|
462
|
+
surface: 120,
|
|
463
|
+
contentDelay: 100,
|
|
464
|
+
content: 140,
|
|
465
|
+
closeContent: 100,
|
|
466
|
+
closeSurfaceDelay: 100,
|
|
467
|
+
closeSurface: 120
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
function resolveTiming(options, base) {
|
|
471
|
+
return {
|
|
472
|
+
duration: {
|
|
473
|
+
...base.duration,
|
|
474
|
+
...options.duration
|
|
475
|
+
},
|
|
476
|
+
easing: {
|
|
477
|
+
...base.easing,
|
|
478
|
+
...options.easing
|
|
479
|
+
},
|
|
480
|
+
stagger: options.stagger ?? base.stagger,
|
|
481
|
+
backgroundScale: options.backgroundScale ?? base.backgroundScale,
|
|
482
|
+
reducedMotion: options.reducedMotion ?? base.reducedMotion,
|
|
483
|
+
timeScale: options.timeScale ?? base.timeScale,
|
|
484
|
+
closeOnEscape: options.closeOnEscape ?? base.closeOnEscape,
|
|
485
|
+
restoreScroll: options.restoreScroll ?? base.restoreScroll
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function durationFor(direction, timing, fade) {
|
|
489
|
+
const f = choreography.fade;
|
|
490
|
+
return (fade ? direction === "open" ? f.contentDelay + f.content : f.closeSurfaceDelay + f.closeSurface : timing.duration[direction]) * timing.timeScale;
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/animate.ts
|
|
494
|
+
const EASE_OUT = "ease-out";
|
|
495
|
+
/** Build one run from measured geometry. Lifecycle and cleanup belong to morph.ts. */
|
|
496
|
+
function buildAnimations(r, g, context) {
|
|
497
|
+
const { sheet, background, scrim, timing, session } = context;
|
|
498
|
+
const pending = [];
|
|
499
|
+
function queueAnimation(el, frames, duration, delay = 0, easing = "linear") {
|
|
500
|
+
pending.push({
|
|
501
|
+
el,
|
|
502
|
+
frames,
|
|
503
|
+
options: {
|
|
504
|
+
duration: Math.max(0, duration),
|
|
505
|
+
delay,
|
|
506
|
+
easing,
|
|
507
|
+
fill: "both"
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* A copy of the card placed exactly over it, inside the sheet. It carries
|
|
513
|
+
* what the sheet does not have (meta lines, icons, the border) and the
|
|
514
|
+
* card's own version of texts that crossfade.
|
|
515
|
+
*/
|
|
516
|
+
function makeGhost(r, g) {
|
|
517
|
+
const card = g.card;
|
|
518
|
+
const ghost = card.cloneNode(true);
|
|
519
|
+
ghost.removeAttribute("id");
|
|
520
|
+
for (const node of ghost.querySelectorAll("[id]")) node.removeAttribute("id");
|
|
521
|
+
for (const node of ghost.querySelectorAll("[name]")) node.removeAttribute("name");
|
|
522
|
+
ghost.setAttribute(GHOST_ATTR, "");
|
|
523
|
+
ghost.setAttribute("aria-hidden", "true");
|
|
524
|
+
ghost.inert = true;
|
|
525
|
+
const style = getComputedStyle(card);
|
|
526
|
+
for (const prop of [
|
|
527
|
+
"color",
|
|
528
|
+
"font-family",
|
|
529
|
+
"font-size",
|
|
530
|
+
"font-weight",
|
|
531
|
+
"font-style",
|
|
532
|
+
"line-height",
|
|
533
|
+
"letter-spacing",
|
|
534
|
+
"text-align",
|
|
535
|
+
"white-space",
|
|
536
|
+
"direction",
|
|
537
|
+
"font-feature-settings",
|
|
538
|
+
"font-variant-numeric"
|
|
539
|
+
]) ghost.style.setProperty(prop, style.getPropertyValue(prop));
|
|
540
|
+
const left = g.cardBox.left - g.sheetBox.left - sheet.clientLeft + sheet.scrollLeft;
|
|
541
|
+
const top = g.cardBox.top - g.sheetBox.top - sheet.clientTop + sheet.scrollTop;
|
|
542
|
+
Object.assign(ghost.style, {
|
|
543
|
+
position: "absolute",
|
|
544
|
+
boxSizing: "border-box",
|
|
545
|
+
margin: "0",
|
|
546
|
+
left: `${left}px`,
|
|
547
|
+
top: `${top}px`,
|
|
548
|
+
width: `${g.cardBox.width}px`,
|
|
549
|
+
height: `${g.cardBox.height}px`,
|
|
550
|
+
minWidth: "0",
|
|
551
|
+
maxWidth: "none",
|
|
552
|
+
minHeight: "0",
|
|
553
|
+
maxHeight: "none",
|
|
554
|
+
background: "transparent",
|
|
555
|
+
boxShadow: "none",
|
|
556
|
+
outline: "none",
|
|
557
|
+
transform: "none",
|
|
558
|
+
transition: "none",
|
|
559
|
+
animation: "none",
|
|
560
|
+
opacity: "1",
|
|
561
|
+
visibility: "visible",
|
|
562
|
+
pointerEvents: "none",
|
|
563
|
+
zIndex: "0"
|
|
564
|
+
});
|
|
565
|
+
ghost.style.borderColor = g.border ?? "transparent";
|
|
566
|
+
const copies = /* @__PURE__ */ new Map();
|
|
567
|
+
const rest = collectRest(ghost);
|
|
568
|
+
const pairs = new Map(g.pairs.map((pair) => [pair.key, pair]));
|
|
569
|
+
for (const node of ghost.querySelectorAll(`[${SHARED_ATTR}]`)) {
|
|
570
|
+
const key = node.getAttribute("data-morph") ?? "";
|
|
571
|
+
const pair = pairs.get(key);
|
|
572
|
+
if (pair?.mode === "scale") node.style.visibility = "hidden";
|
|
573
|
+
else if (pair?.mode === "crossfade") copies.set(key, node);
|
|
574
|
+
else rest.push(node);
|
|
575
|
+
}
|
|
576
|
+
sheet.prepend(ghost);
|
|
577
|
+
r.ghosts.push(ghost);
|
|
578
|
+
const placed = toLocal(rectOf(ghost), g.unit);
|
|
579
|
+
const dx = g.cardBox.left - placed.left;
|
|
580
|
+
const dy = g.cardBox.top - placed.top;
|
|
581
|
+
if (Math.abs(dx) > .5 || Math.abs(dy) > .5) {
|
|
582
|
+
ghost.style.left = `${left + dx}px`;
|
|
583
|
+
ghost.style.top = `${top + dy}px`;
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
root: ghost,
|
|
587
|
+
copies,
|
|
588
|
+
rest
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A sheet that does not cover the card at rest (a centred panel) starts
|
|
593
|
+
* moved over it and slides into place while it grows, on the same curve,
|
|
594
|
+
* so the surface and every flight follow the same path on screen as with
|
|
595
|
+
* a sheet that covers the card.
|
|
596
|
+
*/
|
|
597
|
+
function slide(g, duration, back) {
|
|
598
|
+
if (g.shift.x === 0 && g.shift.y === 0) return;
|
|
599
|
+
const moved = { translate: `${g.shift.x}px ${g.shift.y}px` };
|
|
600
|
+
const home = { translate: "0px 0px" };
|
|
601
|
+
queueAnimation(sheet, back ? [home, moved] : [moved, home], duration, 0, timing.easing.surface);
|
|
602
|
+
}
|
|
603
|
+
function recede(duration, back, isReduced) {
|
|
604
|
+
if (background && session?.scaled && !isReduced) {
|
|
605
|
+
const still = {
|
|
606
|
+
transform: "none",
|
|
607
|
+
transformOrigin: session.origin
|
|
608
|
+
};
|
|
609
|
+
const away = {
|
|
610
|
+
transform: `scale(${session.scale})`,
|
|
611
|
+
transformOrigin: session.origin
|
|
612
|
+
};
|
|
613
|
+
queueAnimation(background, back ? [away, still] : [still, away], duration, 0, timing.easing.surface);
|
|
614
|
+
}
|
|
615
|
+
if (scrim) queueAnimation(scrim, back ? [{ opacity: 1 }, { opacity: 0 }] : [{ opacity: 0 }, { opacity: 1 }], duration, 0, EASE_OUT);
|
|
616
|
+
}
|
|
617
|
+
function fillOf(p, direction) {
|
|
618
|
+
const f = p.fill ?? {
|
|
619
|
+
radius: [0, 0],
|
|
620
|
+
align: [.5, .5],
|
|
621
|
+
crop: true
|
|
622
|
+
};
|
|
623
|
+
const open = direction === "open";
|
|
624
|
+
return fillFrames({
|
|
625
|
+
big: p.dstBox,
|
|
626
|
+
small: p.srcBox,
|
|
627
|
+
from: open ? p.srcBox : p.dstBox,
|
|
628
|
+
to: open ? p.dstBox : p.srcBox,
|
|
629
|
+
align: f.align,
|
|
630
|
+
crop: f.crop,
|
|
631
|
+
radius: open ? f.radius : [f.radius[1], f.radius[0]]
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function buildOpen(r, g) {
|
|
635
|
+
const D = timing.duration.open * timing.timeScale;
|
|
636
|
+
const k = choreography.open;
|
|
637
|
+
const surface = timing.easing.surface;
|
|
638
|
+
const content = timing.easing.content;
|
|
639
|
+
queueAnimation(sheet, [{ clipPath: insetClip(g.clipBox, g.sheetBox, g.radius) }, { clipPath: fullClip(g.sheetRadius) }], D, 0, surface);
|
|
640
|
+
if (g.colors) queueAnimation(sheet, [{ backgroundColor: g.colors[0] }, { backgroundColor: g.colors[1] }], D, 0, surface);
|
|
641
|
+
const ghost = makeGhost(r, g);
|
|
642
|
+
slide(g, D, false);
|
|
643
|
+
for (const p of g.pairs) {
|
|
644
|
+
if (p.mode === "skip" || !p.dst) {
|
|
645
|
+
if (p.dst) queueAnimation(p.dst, [{ opacity: 0 }, { opacity: 1 }], k.lateIn * D, (1 - k.lateIn) * D, EASE_OUT);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const fill = p.fill ? fillOf(p, "open") : null;
|
|
649
|
+
queueAnimation(p.dst, fill ? fill.big : [{
|
|
650
|
+
transform: placeOver(p.dstBox, p.srcBox, p.scale),
|
|
651
|
+
transformOrigin: "0 0"
|
|
652
|
+
}, {
|
|
653
|
+
transform: "none",
|
|
654
|
+
transformOrigin: "0 0"
|
|
655
|
+
}], D, 0, surface);
|
|
656
|
+
if (p.mode !== "crossfade") continue;
|
|
657
|
+
const copy = ghost.copies.get(p.key);
|
|
658
|
+
if (copy) {
|
|
659
|
+
queueAnimation(copy, fill ? fill.small : [{
|
|
660
|
+
transform: "none",
|
|
661
|
+
transformOrigin: "0 0"
|
|
662
|
+
}, {
|
|
663
|
+
transform: placeOver(p.srcBox, p.dstBox, 1 / p.scale),
|
|
664
|
+
transformOrigin: "0 0"
|
|
665
|
+
}], D, 0, surface);
|
|
666
|
+
queueAnimation(copy, [{ opacity: 1 }, { opacity: 0 }], k.copyOut * D, 0, EASE_OUT);
|
|
667
|
+
}
|
|
668
|
+
queueAnimation(p.dst, [{ opacity: 0 }, { opacity: 1 }], k.targetIn * D, 0, EASE_OUT);
|
|
669
|
+
}
|
|
670
|
+
for (const el of ghost.rest) queueAnimation(el, [{ opacity: 1 }, { opacity: 0 }], k.restOut * D, 0, EASE_OUT);
|
|
671
|
+
if (g.border) queueAnimation(ghost.root, [{ borderColor: g.border }, { borderColor: "transparent" }], k.borderOut * D, 0, EASE_OUT);
|
|
672
|
+
const parts = collectContent(sheet);
|
|
673
|
+
parts.stagger.forEach((el, i) => {
|
|
674
|
+
queueAnimation(el, hasFixedDescendant(el) ? [{ opacity: 0 }, { opacity: 1 }] : [{
|
|
675
|
+
opacity: 0,
|
|
676
|
+
transform: "translateY(10px)"
|
|
677
|
+
}, {
|
|
678
|
+
opacity: 1,
|
|
679
|
+
transform: "none"
|
|
680
|
+
}], k.content * D, (k.contentDelay * timing.duration.open + i * timing.stagger) * timing.timeScale, content);
|
|
681
|
+
});
|
|
682
|
+
for (const el of parts.plain) queueAnimation(el, [{ opacity: 0 }, { opacity: 1 }], k.content * D, k.contentDelay * D, content);
|
|
683
|
+
for (const el of parts.docks) queueAnimation(el, hasFixedDescendant(el) ? [{ opacity: 0 }, { opacity: 1 }] : [{ transform: "translateY(100%)" }, { transform: "none" }], k.dock * D, k.dockDelay * D, content);
|
|
684
|
+
recede(D, false, false);
|
|
685
|
+
}
|
|
686
|
+
function buildClose(r, g) {
|
|
687
|
+
const C = timing.duration.close * timing.timeScale;
|
|
688
|
+
const k = choreography.close;
|
|
689
|
+
const surface = timing.easing.surface;
|
|
690
|
+
queueAnimation(sheet, [{ clipPath: fullClip(g.sheetRadius) }, { clipPath: insetClip(g.clipBox, g.sheetBox, g.radius) }], C, 0, surface);
|
|
691
|
+
if (g.colors) queueAnimation(sheet, [{ backgroundColor: g.colors[1] }, { backgroundColor: g.colors[0] }], C, 0, surface);
|
|
692
|
+
const ghost = makeGhost(r, g);
|
|
693
|
+
slide(g, C, true);
|
|
694
|
+
let anchor = null;
|
|
695
|
+
for (const p of g.pairs) {
|
|
696
|
+
if (p.mode === "skip" || !p.dst) {
|
|
697
|
+
if (p.dst) queueAnimation(p.dst, [{ opacity: 1 }, { opacity: 0 }], k.contentOut * C, 0, EASE_OUT);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (!anchor || p.dstBox.top + p.dstBox.height > anchor.dstBox.top + anchor.dstBox.height) anchor = p;
|
|
701
|
+
const fill = p.fill ? fillOf(p, "close") : null;
|
|
702
|
+
queueAnimation(p.dst, fill ? fill.big : [{
|
|
703
|
+
transform: "none",
|
|
704
|
+
transformOrigin: "0 0"
|
|
705
|
+
}, {
|
|
706
|
+
transform: placeOver(p.dstBox, p.srcBox, p.scale),
|
|
707
|
+
transformOrigin: "0 0"
|
|
708
|
+
}], C, 0, surface);
|
|
709
|
+
if (p.mode !== "crossfade") continue;
|
|
710
|
+
const copy = ghost.copies.get(p.key);
|
|
711
|
+
if (copy) {
|
|
712
|
+
queueAnimation(copy, fill ? fill.small : [{
|
|
713
|
+
transform: placeOver(p.srcBox, p.dstBox, 1 / p.scale),
|
|
714
|
+
transformOrigin: "0 0"
|
|
715
|
+
}, {
|
|
716
|
+
transform: "none",
|
|
717
|
+
transformOrigin: "0 0"
|
|
718
|
+
}], C, 0, surface);
|
|
719
|
+
queueAnimation(copy, [{ opacity: 0 }, { opacity: 1 }], k.copyIn * C, 0, EASE_OUT);
|
|
720
|
+
}
|
|
721
|
+
queueAnimation(p.dst, [{ opacity: 1 }, { opacity: 0 }], k.targetOut * C, 0, EASE_OUT);
|
|
722
|
+
}
|
|
723
|
+
for (const el of ghost.rest) queueAnimation(el, [{ opacity: 0 }, { opacity: 1 }], k.rest * C, k.restDelay * C, EASE_OUT);
|
|
724
|
+
if (g.border) queueAnimation(ghost.root, [{ borderColor: "transparent" }, { borderColor: g.border }], k.rest * C, k.restDelay * C, EASE_OUT);
|
|
725
|
+
const parts = collectContent(sheet);
|
|
726
|
+
const follow = anchor ? `translate(${anchor.srcBox.left - anchor.dstBox.left}px, ${anchor.srcBox.top - anchor.dstBox.top}px)` : null;
|
|
727
|
+
for (const el of parts.stagger) {
|
|
728
|
+
if (follow && !hasFixedDescendant(el)) queueAnimation(el, [{ transform: "none" }, { transform: follow }], C, 0, surface);
|
|
729
|
+
queueAnimation(el, [{ opacity: 1 }, { opacity: 0 }], k.contentOut * C, 0, EASE_OUT);
|
|
730
|
+
}
|
|
731
|
+
for (const el of parts.plain) queueAnimation(el, [{ opacity: 1 }, { opacity: 0 }], k.contentOut * C, 0, EASE_OUT);
|
|
732
|
+
for (const el of parts.docks) queueAnimation(el, hasFixedDescendant(el) ? [{ opacity: 1 }, { opacity: 0 }] : [{ transform: "none" }, { transform: "translateY(100%)" }], k.dock * C, 0, EASE_OUT);
|
|
733
|
+
recede(C, true, false);
|
|
734
|
+
}
|
|
735
|
+
function sheetChildren() {
|
|
736
|
+
return Array.from(sheet.children).filter((el) => !el.hasAttribute(GHOST_ATTR));
|
|
737
|
+
}
|
|
738
|
+
/** Opacity only: the empty surface first, then the content, so two screens of text never overlap. */
|
|
739
|
+
function buildFadeOpen(isReduced) {
|
|
740
|
+
const f = choreography.fade;
|
|
741
|
+
const t = timing.timeScale;
|
|
742
|
+
queueAnimation(sheet, [{ opacity: 0 }, { opacity: 1 }], f.surface * t, 0, "ease");
|
|
743
|
+
for (const el of sheetChildren()) queueAnimation(el, [{ opacity: 0 }, { opacity: 1 }], f.content * t, f.contentDelay * t, "ease");
|
|
744
|
+
recede((f.contentDelay + f.content) * t, false, isReduced);
|
|
745
|
+
}
|
|
746
|
+
function buildFadeClose(isReduced) {
|
|
747
|
+
const f = choreography.fade;
|
|
748
|
+
const t = timing.timeScale;
|
|
749
|
+
for (const el of sheetChildren()) queueAnimation(el, [{ opacity: 1 }, { opacity: 0 }], f.closeContent * t, 0, "ease");
|
|
750
|
+
queueAnimation(sheet, [{ opacity: 1 }, { opacity: 0 }], f.closeSurface * t, f.closeSurfaceDelay * t, "ease");
|
|
751
|
+
recede((f.closeSurfaceDelay + f.closeSurface) * t, true, isReduced);
|
|
752
|
+
}
|
|
753
|
+
if (g.plan.choreography === "morph") {
|
|
754
|
+
if (r.kind === "open") buildOpen(r, g);
|
|
755
|
+
else buildClose(r, g);
|
|
756
|
+
} else if (r.kind === "open") buildFadeOpen(g.plan.reduced);
|
|
757
|
+
else buildFadeClose(g.plan.reduced);
|
|
758
|
+
for (const { el, frames, options } of pending) r.anims.push(el.animate(frames, options));
|
|
759
|
+
}
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/layers.ts
|
|
762
|
+
const layers = /* @__PURE__ */ new WeakMap();
|
|
763
|
+
function activate(sheet) {
|
|
764
|
+
deactivate(sheet);
|
|
765
|
+
const stack = layers.get(sheet.ownerDocument) ?? [];
|
|
766
|
+
stack.push(sheet);
|
|
767
|
+
layers.set(sheet.ownerDocument, stack);
|
|
768
|
+
}
|
|
769
|
+
function deactivate(sheet) {
|
|
770
|
+
const stack = layers.get(sheet.ownerDocument);
|
|
771
|
+
const index = stack?.indexOf(sheet) ?? -1;
|
|
772
|
+
if (index >= 0) stack?.splice(index, 1);
|
|
773
|
+
}
|
|
774
|
+
function isTopLayer(sheet) {
|
|
775
|
+
return layers.get(sheet.ownerDocument)?.at(-1) === sheet;
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/measure.ts
|
|
779
|
+
const TRANSPARENT = /^(transparent|rgba\(0, 0, 0, 0\))$/;
|
|
780
|
+
const ZERO = {
|
|
781
|
+
left: 0,
|
|
782
|
+
top: 0,
|
|
783
|
+
width: 0,
|
|
784
|
+
height: 0
|
|
785
|
+
};
|
|
786
|
+
/** Jumps CSS transitions on the card (hover, press) to their end so the card is measured at rest. */
|
|
787
|
+
function settleTransitions(card) {
|
|
788
|
+
if (typeof card.getAnimations !== "function") return;
|
|
789
|
+
for (const a of card.getAnimations({ subtree: true })) if (typeof CSSTransition !== "undefined" && a instanceof CSSTransition) a.finish();
|
|
790
|
+
}
|
|
791
|
+
/** Smallest visible strip of a card, in px, that a flight starts from or lands on. */
|
|
792
|
+
const MIN_SEEN = 8;
|
|
793
|
+
function isSeen(b) {
|
|
794
|
+
return b !== null && b.width >= MIN_SEEN && b.height >= MIN_SEEN;
|
|
795
|
+
}
|
|
796
|
+
/** Measure one transition before constructing any of its animations. */
|
|
797
|
+
function measureTransition(direction, card, isReduced, hooks) {
|
|
798
|
+
const { sheet, backgroundScaled } = hooks;
|
|
799
|
+
const destinations = sharedElements(sheet);
|
|
800
|
+
const keys = [...destinations.keys()];
|
|
801
|
+
let sources;
|
|
802
|
+
const unit = unitOf(sheet.getBoundingClientRect().width, sheet.offsetWidth);
|
|
803
|
+
const rectOf$1 = (el) => toLocal(rectOf(el), unit);
|
|
804
|
+
const visibleBoxOf$1 = (el) => {
|
|
805
|
+
const v = visibleBoxOf(el);
|
|
806
|
+
return v ? toLocal(v, unit) : null;
|
|
807
|
+
};
|
|
808
|
+
function isShared(key) {
|
|
809
|
+
return !hooks.shared || hooks.shared.includes(key);
|
|
810
|
+
}
|
|
811
|
+
function radiusOf(el, width) {
|
|
812
|
+
return parseRadius(getComputedStyle(el).borderTopLeftRadius, width);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* A card picture that is an image or a video is a crop of the large one, at
|
|
816
|
+
* its object-position. A plain box (a gradient, an icon tile) is not.
|
|
817
|
+
*/
|
|
818
|
+
function cropOf(el) {
|
|
819
|
+
const media = /^(img|video|picture|canvas)$/i.test(el.tagName) ? el : el.querySelector("img, video, canvas");
|
|
820
|
+
const pos = media ? getComputedStyle(media).objectPosition : "";
|
|
821
|
+
const part = (v) => {
|
|
822
|
+
if (!v) return .5;
|
|
823
|
+
if (v === "left" || v === "top") return 0;
|
|
824
|
+
if (v === "right" || v === "bottom") return 1;
|
|
825
|
+
if (v.endsWith("%")) return Math.min(1, Math.max(0, Number.parseFloat(v) / 100));
|
|
826
|
+
return .5;
|
|
827
|
+
};
|
|
828
|
+
const [x, y] = pos.trim().split(/\s+/);
|
|
829
|
+
return {
|
|
830
|
+
align: [part(x), part(y)],
|
|
831
|
+
crop: Boolean(media)
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
function pairFor(key, card) {
|
|
835
|
+
sources ??= sharedElements(card);
|
|
836
|
+
const src = sources.get(key) ?? null;
|
|
837
|
+
const dst = destinations.get(key) ?? null;
|
|
838
|
+
const skip = (reason) => ({
|
|
839
|
+
key,
|
|
840
|
+
mode: "skip",
|
|
841
|
+
reason,
|
|
842
|
+
src,
|
|
843
|
+
dst,
|
|
844
|
+
srcBox: ZERO,
|
|
845
|
+
dstBox: ZERO,
|
|
846
|
+
scale: 1
|
|
847
|
+
});
|
|
848
|
+
if (!isShared(key)) return skip("not-shared");
|
|
849
|
+
if (!src) return skip("missing-card-element");
|
|
850
|
+
if (!dst) return skip("missing-sheet-element");
|
|
851
|
+
const srcBox = rectOf$1(src);
|
|
852
|
+
const dstBox = rectOf$1(dst);
|
|
853
|
+
if (area(srcBox) === 0) return skip("missing-card-element");
|
|
854
|
+
if (area(dstBox) === 0) return skip("missing-sheet-element");
|
|
855
|
+
if (!isOnScreen(dstBox, visibleBoxOf$1(dst))) return skip("sheet-element-offscreen");
|
|
856
|
+
const declared = dst.getAttribute("data-morph-mode") ?? src.getAttribute("data-morph-mode");
|
|
857
|
+
const mode = declared === "box" || declared === "text" ? declared : /^(img|svg|video|canvas|picture)$/i.test(dst.tagName) ? "box" : "text";
|
|
858
|
+
const srcStyle = getComputedStyle(src);
|
|
859
|
+
const dstStyle = getComputedStyle(dst);
|
|
860
|
+
const decision = planPair({
|
|
861
|
+
src: srcBox,
|
|
862
|
+
dst: dstBox,
|
|
863
|
+
mode,
|
|
864
|
+
srcFont: Number.parseFloat(srcStyle.fontSize) || 16,
|
|
865
|
+
dstFont: Number.parseFloat(dstStyle.fontSize) || 16,
|
|
866
|
+
srcLines: mode === "text" ? textLines(src, srcBox.height, srcStyle) : 1,
|
|
867
|
+
dstLines: mode === "text" ? textLines(dst, dstBox.height, dstStyle) : 1,
|
|
868
|
+
srcClipped: mode === "text" && isClipped(src, srcStyle)
|
|
869
|
+
});
|
|
870
|
+
return {
|
|
871
|
+
key,
|
|
872
|
+
mode: decision.crossfade ? "crossfade" : "scale",
|
|
873
|
+
src,
|
|
874
|
+
dst,
|
|
875
|
+
srcBox,
|
|
876
|
+
dstBox,
|
|
877
|
+
scale: decision.scale,
|
|
878
|
+
fill: decision.crossfade && mode === "box" ? {
|
|
879
|
+
radius: [radiusOf(src, srcBox.width), radiusOf(dst, dstBox.width)],
|
|
880
|
+
...cropOf(src)
|
|
881
|
+
} : void 0
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const sheetBox = rectOf$1(sheet);
|
|
885
|
+
let reason;
|
|
886
|
+
let cardBox = ZERO;
|
|
887
|
+
let clipBox = ZERO;
|
|
888
|
+
let shift = NO_SHIFT;
|
|
889
|
+
if (isReduced) reason = "reduced-motion";
|
|
890
|
+
else if (!card || !card.isConnected) reason = "no-card";
|
|
891
|
+
else if (area(sheetBox) === 0) reason = "sheet-hidden";
|
|
892
|
+
else {
|
|
893
|
+
settleTransitions(card);
|
|
894
|
+
cardBox = rectOf$1(card);
|
|
895
|
+
const view = visibleBoxOf$1(card);
|
|
896
|
+
const shown = view ? intersect(cardBox, view) : null;
|
|
897
|
+
if (!isSeen(shown)) reason = "card-offscreen";
|
|
898
|
+
else {
|
|
899
|
+
if (!hasFixedDescendant(sheet)) shift = coverShift(shown, sheetBox);
|
|
900
|
+
const under = intersect(shown, moveBox(sheetBox, shift));
|
|
901
|
+
if (!isSeen(under)) reason = "card-offscreen";
|
|
902
|
+
else clipBox = under;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const pairs = reason || !card ? keys.map((key) => ({
|
|
906
|
+
key,
|
|
907
|
+
mode: "skip",
|
|
908
|
+
reason: reason ?? "no-card",
|
|
909
|
+
src: null,
|
|
910
|
+
dst: destinations.get(key) ?? null,
|
|
911
|
+
srcBox: ZERO,
|
|
912
|
+
dstBox: ZERO,
|
|
913
|
+
scale: 1
|
|
914
|
+
})) : keys.map((key) => pairFor(key, card));
|
|
915
|
+
let colors = null;
|
|
916
|
+
let border = null;
|
|
917
|
+
let radius = 0;
|
|
918
|
+
let sheetRadius = 0;
|
|
919
|
+
if (!reason && card) {
|
|
920
|
+
const cardStyle = getComputedStyle(card);
|
|
921
|
+
const sheetStyle = getComputedStyle(sheet);
|
|
922
|
+
const from = cardStyle.backgroundColor;
|
|
923
|
+
const to = sheetStyle.backgroundColor;
|
|
924
|
+
if (from !== to && !TRANSPARENT.test(from) && !TRANSPARENT.test(to)) colors = [from, to];
|
|
925
|
+
if ((Number.parseFloat(cardStyle.borderTopWidth) || 0) > 0 && !TRANSPARENT.test(cardStyle.borderTopColor)) border = cardStyle.borderTopColor;
|
|
926
|
+
radius = hooks.radius ?? parseRadius(cardStyle.borderTopLeftRadius, cardBox.width);
|
|
927
|
+
sheetRadius = parseRadius(sheetStyle.borderTopLeftRadius, sheetBox.width);
|
|
928
|
+
}
|
|
929
|
+
const back = {
|
|
930
|
+
x: -shift.x,
|
|
931
|
+
y: -shift.y
|
|
932
|
+
};
|
|
933
|
+
if (reason) shift = NO_SHIFT;
|
|
934
|
+
const local = reason ? pairs : pairs.map((p) => p.mode === "skip" ? p : {
|
|
935
|
+
...p,
|
|
936
|
+
srcBox: moveBox(p.srcBox, back)
|
|
937
|
+
});
|
|
938
|
+
return {
|
|
939
|
+
plan: {
|
|
940
|
+
direction,
|
|
941
|
+
choreography: reason ? "fade" : "morph",
|
|
942
|
+
reason,
|
|
943
|
+
reduced: isReduced,
|
|
944
|
+
backgroundScaled: Boolean(backgroundScaled),
|
|
945
|
+
pairs: pairs.map(({ key, mode, reason: why }) => why ? {
|
|
946
|
+
key,
|
|
947
|
+
mode,
|
|
948
|
+
reason: why
|
|
949
|
+
} : {
|
|
950
|
+
key,
|
|
951
|
+
mode
|
|
952
|
+
})
|
|
953
|
+
},
|
|
954
|
+
card: reason ? null : card,
|
|
955
|
+
cardBox: moveBox(cardBox, back),
|
|
956
|
+
clipBox: moveBox(clipBox, back),
|
|
957
|
+
sheetBox,
|
|
958
|
+
shift,
|
|
959
|
+
unit,
|
|
960
|
+
radius,
|
|
961
|
+
sheetRadius,
|
|
962
|
+
colors,
|
|
963
|
+
border,
|
|
964
|
+
pairs: local
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function backgroundOriginFor(background) {
|
|
968
|
+
const unit = unitOf(background.getBoundingClientRect().width, background.offsetWidth);
|
|
969
|
+
const visible = visibleBoxOf(background);
|
|
970
|
+
return backgroundOrigin(toLocal(rectOf(background), unit), visible ? toLocal(visible, unit) : null);
|
|
971
|
+
}
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/morph.ts
|
|
974
|
+
/**
|
|
975
|
+
* createMorph: a card that grows into a full detail sheet and shrinks back.
|
|
976
|
+
*
|
|
977
|
+
* The sheet is one surface whose clip-path starts as the card's shape. Shared
|
|
978
|
+
* elements (data-morph="key") fly from the card to their place on the sheet.
|
|
979
|
+
* Everything else fades or slides. All motion uses the Web Animations API, so
|
|
980
|
+
* a close that arrives mid-open reverses the running animations from where
|
|
981
|
+
* they are instead of jumping.
|
|
982
|
+
*/
|
|
983
|
+
function time(a) {
|
|
984
|
+
return Number(a.currentTime ?? 0);
|
|
985
|
+
}
|
|
986
|
+
function createMorph(options) {
|
|
987
|
+
const sheet = options.sheet;
|
|
988
|
+
if (!sheet) throw new TypeError("morphcard: the `sheet` option is required");
|
|
989
|
+
const background = options.background ?? null;
|
|
990
|
+
const scrim = options.scrim ?? null;
|
|
991
|
+
if (background && (background.contains(sheet) || background === scrim)) throw new TypeError("morphcard: `background` must not contain the sheet");
|
|
992
|
+
if (scrim?.contains(sheet)) throw new TypeError("morphcard: `scrim` must not contain the sheet");
|
|
993
|
+
sheet.hidden = true;
|
|
994
|
+
if (scrim) scrim.hidden = true;
|
|
995
|
+
for (const el of [sheet, scrim]) if (el?.isConnected && getComputedStyle(el).display !== "none") console.warn("morphcard: an element with the hidden attribute is still displayed. A display rule in your CSS overrides [hidden]; add `[hidden] { display: none !important }`.", el);
|
|
996
|
+
let timing = resolveTiming(options, defaults);
|
|
997
|
+
let hooks = {
|
|
998
|
+
prepare: options.prepare,
|
|
999
|
+
onStateChange: options.onStateChange,
|
|
1000
|
+
shared: options.shared,
|
|
1001
|
+
radius: options.radius,
|
|
1002
|
+
scroller: options.scroller,
|
|
1003
|
+
resolveCard: options.resolveCard
|
|
1004
|
+
};
|
|
1005
|
+
let state = "closed";
|
|
1006
|
+
let current = null;
|
|
1007
|
+
let returnTo = null;
|
|
1008
|
+
let gen = 0;
|
|
1009
|
+
let run = null;
|
|
1010
|
+
let holds = [];
|
|
1011
|
+
let session = null;
|
|
1012
|
+
let plan = null;
|
|
1013
|
+
let lockTimer;
|
|
1014
|
+
let destroyed = false;
|
|
1015
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
1016
|
+
const runUndo = new Undo();
|
|
1017
|
+
const inertUndo = new Undo();
|
|
1018
|
+
const sessionUndo = new Undo();
|
|
1019
|
+
const reduced = () => timing.reducedMotion === "system" ? typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches : timing.reducedMotion;
|
|
1020
|
+
function setState(next) {
|
|
1021
|
+
if (state === next) return;
|
|
1022
|
+
state = next;
|
|
1023
|
+
if (next === "closed") sheet.removeAttribute("data-morph-state");
|
|
1024
|
+
else sheet.setAttribute("data-morph-state", next);
|
|
1025
|
+
hooks.onStateChange?.(next, current);
|
|
1026
|
+
}
|
|
1027
|
+
function wait(target) {
|
|
1028
|
+
return new Promise((resolve) => waiters.add({
|
|
1029
|
+
target,
|
|
1030
|
+
resolve
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
function resolveWaiters(reached) {
|
|
1034
|
+
for (const waiter of waiters) waiter.resolve(waiter.target === reached);
|
|
1035
|
+
waiters.clear();
|
|
1036
|
+
}
|
|
1037
|
+
function applyHolds() {
|
|
1038
|
+
cancelHolds();
|
|
1039
|
+
const hold = (el, frame) => holds.push(el.animate([frame, frame], {
|
|
1040
|
+
duration: 0,
|
|
1041
|
+
fill: "forwards"
|
|
1042
|
+
}));
|
|
1043
|
+
if (background && session?.scaled) hold(background, {
|
|
1044
|
+
transform: `scale(${session.scale})`,
|
|
1045
|
+
transformOrigin: session.origin
|
|
1046
|
+
});
|
|
1047
|
+
if (scrim) hold(scrim, { opacity: 1 });
|
|
1048
|
+
}
|
|
1049
|
+
function cancelHolds() {
|
|
1050
|
+
for (const a of holds) a.cancel();
|
|
1051
|
+
holds = [];
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* inert while the surface moves: clicks pass through to the page below (so
|
|
1055
|
+
* a card can be clicked again mid-close) and nothing inside takes focus.
|
|
1056
|
+
* An attribute, not an inline style, so nothing lingers after cleanup.
|
|
1057
|
+
*/
|
|
1058
|
+
function lock(ms) {
|
|
1059
|
+
if (!sheet.inert) runUndo.attr(sheet, "inert", "");
|
|
1060
|
+
if (scrim && !scrim.inert) runUndo.attr(scrim, "inert", "");
|
|
1061
|
+
clearTimeout(lockTimer);
|
|
1062
|
+
if (ms !== void 0) {
|
|
1063
|
+
const owner = run;
|
|
1064
|
+
lockTimer = setTimeout(() => {
|
|
1065
|
+
if (run === owner) runUndo.flush();
|
|
1066
|
+
}, ms);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
function unlock() {
|
|
1070
|
+
clearTimeout(lockTimer);
|
|
1071
|
+
runUndo.flush();
|
|
1072
|
+
}
|
|
1073
|
+
/** Keeps clicks and keys off the page behind while the sheet opens and stays open. */
|
|
1074
|
+
function shield() {
|
|
1075
|
+
if (background && !background.inert && inertUndo.size === 0) inertUndo.attr(background, "inert", "");
|
|
1076
|
+
}
|
|
1077
|
+
function focusIn() {
|
|
1078
|
+
const target = sheet.querySelector("[data-morph-focus]") ?? sheet.querySelector("a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])");
|
|
1079
|
+
if (target && !target.closest(`[data-morph-ghost]`)) {
|
|
1080
|
+
target.focus({ preventScroll: true });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (!sheet.hasAttribute("tabindex")) sessionUndo.attr(sheet, "tabindex", "-1");
|
|
1084
|
+
sheet.focus({ preventScroll: true });
|
|
1085
|
+
}
|
|
1086
|
+
function focusOut() {
|
|
1087
|
+
const active = document.activeElement;
|
|
1088
|
+
if ((!active || active === document.body || sheet.contains(active)) && returnTo?.isConnected) returnTo.focus({ preventScroll: true });
|
|
1089
|
+
}
|
|
1090
|
+
function watch(r) {
|
|
1091
|
+
const owner = gen;
|
|
1092
|
+
Promise.allSettled(r.anims.map((a) => a.finished)).then(() => {
|
|
1093
|
+
if (owner !== gen || run !== r) return;
|
|
1094
|
+
settle(reachedBy(r));
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function reachedBy(r) {
|
|
1098
|
+
return r.kind === "open" === (r.dir === 1) ? "open" : "closed";
|
|
1099
|
+
}
|
|
1100
|
+
function settle(target) {
|
|
1101
|
+
const r = run;
|
|
1102
|
+
run = null;
|
|
1103
|
+
if (r) {
|
|
1104
|
+
for (const a of r.anims) a.cancel();
|
|
1105
|
+
for (const ghost of r.ghosts) ghost.remove();
|
|
1106
|
+
}
|
|
1107
|
+
unlock();
|
|
1108
|
+
if (target === "open") {
|
|
1109
|
+
applyHolds();
|
|
1110
|
+
shield();
|
|
1111
|
+
focusIn();
|
|
1112
|
+
setState("open");
|
|
1113
|
+
} else {
|
|
1114
|
+
deactivate(sheet);
|
|
1115
|
+
cancelHolds();
|
|
1116
|
+
inertUndo.flush();
|
|
1117
|
+
focusOut();
|
|
1118
|
+
sessionUndo.flush();
|
|
1119
|
+
sheet.hidden = true;
|
|
1120
|
+
if (scrim) scrim.hidden = true;
|
|
1121
|
+
setState("closed");
|
|
1122
|
+
current = null;
|
|
1123
|
+
returnTo = null;
|
|
1124
|
+
session = null;
|
|
1125
|
+
}
|
|
1126
|
+
resolveWaiters(target);
|
|
1127
|
+
}
|
|
1128
|
+
/** Plays the current run the other way from where it is now. */
|
|
1129
|
+
function flip(r) {
|
|
1130
|
+
gen++;
|
|
1131
|
+
r.dir = r.dir === 1 ? -1 : 1;
|
|
1132
|
+
const target = reachedBy(r);
|
|
1133
|
+
setState(target === "open" ? "opening" : "closing");
|
|
1134
|
+
if (target === "closed") {
|
|
1135
|
+
inertUndo.flush();
|
|
1136
|
+
lock();
|
|
1137
|
+
} else {
|
|
1138
|
+
unlock();
|
|
1139
|
+
shield();
|
|
1140
|
+
}
|
|
1141
|
+
const elapsed = Math.max(0, ...r.anims.map(time));
|
|
1142
|
+
const now = Number(document.timeline.currentTime ?? NaN);
|
|
1143
|
+
const destinationMs = durationFor(target === "open" ? "open" : "close", timing, plan?.choreography === "fade");
|
|
1144
|
+
if (elapsed <= 0 || !Number.isFinite(now) || destinationMs <= 0) {
|
|
1145
|
+
settle(target);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const speed = r.duration > 0 ? r.duration / destinationMs : 1;
|
|
1149
|
+
const rate = r.dir * speed;
|
|
1150
|
+
for (const a of r.anims) {
|
|
1151
|
+
a.pause();
|
|
1152
|
+
a.playbackRate = rate;
|
|
1153
|
+
a.currentTime = elapsed;
|
|
1154
|
+
a.play();
|
|
1155
|
+
}
|
|
1156
|
+
watch(r);
|
|
1157
|
+
}
|
|
1158
|
+
function begin(card) {
|
|
1159
|
+
const owner = ++gen;
|
|
1160
|
+
activate(sheet);
|
|
1161
|
+
current = card;
|
|
1162
|
+
session = {
|
|
1163
|
+
scroll: null,
|
|
1164
|
+
scaled: false,
|
|
1165
|
+
scale: 1,
|
|
1166
|
+
origin: "50% 30%"
|
|
1167
|
+
};
|
|
1168
|
+
setState("opening");
|
|
1169
|
+
const result = wait("open");
|
|
1170
|
+
let prepared;
|
|
1171
|
+
try {
|
|
1172
|
+
prepared = hooks.prepare?.(card);
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
settle("closed");
|
|
1175
|
+
throw error;
|
|
1176
|
+
}
|
|
1177
|
+
if (prepared && typeof prepared.then === "function") prepared.then(() => {
|
|
1178
|
+
if (owner === gen) start();
|
|
1179
|
+
}, (error) => {
|
|
1180
|
+
if (owner !== gen) return;
|
|
1181
|
+
settle("closed");
|
|
1182
|
+
if (typeof reportError === "function") reportError(error);
|
|
1183
|
+
});
|
|
1184
|
+
else start();
|
|
1185
|
+
return result;
|
|
1186
|
+
}
|
|
1187
|
+
function start() {
|
|
1188
|
+
try {
|
|
1189
|
+
startOpen();
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
gen++;
|
|
1192
|
+
settle("closed");
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function startOpen() {
|
|
1197
|
+
const card = current;
|
|
1198
|
+
const isReduced = reduced();
|
|
1199
|
+
const s = session;
|
|
1200
|
+
if (card) s.scroll = saveScroll(hooks.scroller ?? scrollerOf(card));
|
|
1201
|
+
if (background && !isReduced && timing.backgroundScale !== false && !hasFixedDescendant(background)) {
|
|
1202
|
+
s.scaled = true;
|
|
1203
|
+
s.scale = timing.backgroundScale;
|
|
1204
|
+
s.origin = backgroundOriginFor(background);
|
|
1205
|
+
}
|
|
1206
|
+
sheet.hidden = false;
|
|
1207
|
+
if (scrim) scrim.hidden = false;
|
|
1208
|
+
sheet.scrollTop = 0;
|
|
1209
|
+
for (const el of sheet.querySelectorAll("*")) if (el.scrollTop > 0) el.scrollTop = 0;
|
|
1210
|
+
const g = measureTransition("open", card, isReduced, {
|
|
1211
|
+
...hooks,
|
|
1212
|
+
sheet,
|
|
1213
|
+
backgroundScaled: s.scaled
|
|
1214
|
+
});
|
|
1215
|
+
plan = g.plan;
|
|
1216
|
+
returnTo = pickReturn(card);
|
|
1217
|
+
shield();
|
|
1218
|
+
const r = {
|
|
1219
|
+
kind: "open",
|
|
1220
|
+
dir: 1,
|
|
1221
|
+
anims: [],
|
|
1222
|
+
ghosts: [],
|
|
1223
|
+
duration: durationFor("open", timing, g.plan.choreography === "fade")
|
|
1224
|
+
};
|
|
1225
|
+
run = r;
|
|
1226
|
+
buildAnimations(r, g, {
|
|
1227
|
+
sheet,
|
|
1228
|
+
background,
|
|
1229
|
+
scrim,
|
|
1230
|
+
timing,
|
|
1231
|
+
session
|
|
1232
|
+
});
|
|
1233
|
+
lock(.5 * (g.plan.choreography === "morph" ? timing.duration.open : choreography.fade.surface * 2) * timing.timeScale);
|
|
1234
|
+
watch(r);
|
|
1235
|
+
}
|
|
1236
|
+
function pickReturn(card) {
|
|
1237
|
+
const active = document.activeElement;
|
|
1238
|
+
if (card) return active && card.contains(active) ? active : focusTargetIn(card);
|
|
1239
|
+
return active && active !== document.body && !sheet.contains(active) ? active : null;
|
|
1240
|
+
}
|
|
1241
|
+
function beginClose() {
|
|
1242
|
+
try {
|
|
1243
|
+
return startClose();
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
gen++;
|
|
1246
|
+
settle("closed");
|
|
1247
|
+
throw error;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function startClose() {
|
|
1251
|
+
gen++;
|
|
1252
|
+
const isReduced = reduced();
|
|
1253
|
+
setState("closing");
|
|
1254
|
+
cancelHolds();
|
|
1255
|
+
if (timing.restoreScroll && session?.scroll) restoreScroll(session.scroll);
|
|
1256
|
+
const g = measureTransition("close", current, isReduced, {
|
|
1257
|
+
...hooks,
|
|
1258
|
+
sheet,
|
|
1259
|
+
backgroundScaled: Boolean(session?.scaled)
|
|
1260
|
+
});
|
|
1261
|
+
plan = g.plan;
|
|
1262
|
+
inertUndo.flush();
|
|
1263
|
+
const r = {
|
|
1264
|
+
kind: "close",
|
|
1265
|
+
dir: 1,
|
|
1266
|
+
anims: [],
|
|
1267
|
+
ghosts: [],
|
|
1268
|
+
duration: durationFor("close", timing, g.plan.choreography === "fade")
|
|
1269
|
+
};
|
|
1270
|
+
run = r;
|
|
1271
|
+
buildAnimations(r, g, {
|
|
1272
|
+
sheet,
|
|
1273
|
+
background,
|
|
1274
|
+
scrim,
|
|
1275
|
+
timing,
|
|
1276
|
+
session
|
|
1277
|
+
});
|
|
1278
|
+
lock();
|
|
1279
|
+
watch(r);
|
|
1280
|
+
return wait("closed");
|
|
1281
|
+
}
|
|
1282
|
+
function open(card) {
|
|
1283
|
+
if (destroyed) return Promise.resolve(false);
|
|
1284
|
+
const target = card ?? null;
|
|
1285
|
+
if (state === "open") return Promise.resolve(target === current);
|
|
1286
|
+
if (state === "opening") return target === current ? wait("open") : Promise.resolve(false);
|
|
1287
|
+
if (state === "closing") {
|
|
1288
|
+
if (run && target !== null && target === current) {
|
|
1289
|
+
const result = wait("open");
|
|
1290
|
+
flip(run);
|
|
1291
|
+
return result;
|
|
1292
|
+
}
|
|
1293
|
+
gen++;
|
|
1294
|
+
settle("closed");
|
|
1295
|
+
}
|
|
1296
|
+
return begin(target);
|
|
1297
|
+
}
|
|
1298
|
+
function close(opts) {
|
|
1299
|
+
if (destroyed || state === "closed") return Promise.resolve(state === "closed");
|
|
1300
|
+
if (state === "closing") return wait("closed");
|
|
1301
|
+
let retarget = opts !== void 0 && "to" in opts;
|
|
1302
|
+
let target = retarget ? opts?.to ?? null : current;
|
|
1303
|
+
if (!retarget && hooks.resolveCard) {
|
|
1304
|
+
const found = hooks.resolveCard(current);
|
|
1305
|
+
if (found !== current) {
|
|
1306
|
+
retarget = true;
|
|
1307
|
+
target = found;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (state === "opening") {
|
|
1311
|
+
if (!run) {
|
|
1312
|
+
gen++;
|
|
1313
|
+
settle("closed");
|
|
1314
|
+
return Promise.resolve(true);
|
|
1315
|
+
}
|
|
1316
|
+
if (target === current) {
|
|
1317
|
+
const result = wait("closed");
|
|
1318
|
+
flip(run);
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
gen++;
|
|
1322
|
+
settle("open");
|
|
1323
|
+
}
|
|
1324
|
+
if (retarget) {
|
|
1325
|
+
current = target;
|
|
1326
|
+
returnTo = target ? focusTargetIn(target) : null;
|
|
1327
|
+
}
|
|
1328
|
+
return beginClose();
|
|
1329
|
+
}
|
|
1330
|
+
const onKey = (event) => {
|
|
1331
|
+
if (event.key !== "Escape" || event.defaultPrevented || !timing.closeOnEscape || !isTopLayer(sheet)) return;
|
|
1332
|
+
event.preventDefault();
|
|
1333
|
+
if (state === "open" || state === "opening") close();
|
|
1334
|
+
};
|
|
1335
|
+
const onClick = (event) => {
|
|
1336
|
+
const trigger = event.target?.closest?.("[data-morph-close]");
|
|
1337
|
+
if (trigger && !trigger.closest(`[data-morph-ghost]`)) close();
|
|
1338
|
+
};
|
|
1339
|
+
document.addEventListener("keydown", onKey);
|
|
1340
|
+
sheet.addEventListener("click", onClick);
|
|
1341
|
+
scrim?.addEventListener("click", onClick);
|
|
1342
|
+
return {
|
|
1343
|
+
open,
|
|
1344
|
+
close,
|
|
1345
|
+
get state() {
|
|
1346
|
+
return state;
|
|
1347
|
+
},
|
|
1348
|
+
get card() {
|
|
1349
|
+
return current;
|
|
1350
|
+
},
|
|
1351
|
+
get plan() {
|
|
1352
|
+
return plan;
|
|
1353
|
+
},
|
|
1354
|
+
setOptions(next) {
|
|
1355
|
+
timing = resolveTiming(next, timing);
|
|
1356
|
+
hooks = {
|
|
1357
|
+
prepare: "prepare" in next ? next.prepare : hooks.prepare,
|
|
1358
|
+
onStateChange: "onStateChange" in next ? next.onStateChange : hooks.onStateChange,
|
|
1359
|
+
shared: "shared" in next ? next.shared : hooks.shared,
|
|
1360
|
+
radius: "radius" in next ? next.radius : hooks.radius,
|
|
1361
|
+
scroller: "scroller" in next ? next.scroller : hooks.scroller,
|
|
1362
|
+
resolveCard: "resolveCard" in next ? next.resolveCard : hooks.resolveCard
|
|
1363
|
+
};
|
|
1364
|
+
},
|
|
1365
|
+
destroy() {
|
|
1366
|
+
if (destroyed) return;
|
|
1367
|
+
gen++;
|
|
1368
|
+
if (state !== "closed") settle("closed");
|
|
1369
|
+
destroyed = true;
|
|
1370
|
+
document.removeEventListener("keydown", onKey);
|
|
1371
|
+
sheet.removeEventListener("click", onClick);
|
|
1372
|
+
scrim?.removeEventListener("click", onClick);
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/use-morph.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* useMorph: the public API. The sheet stays mounted; the hook shows and hides
|
|
1380
|
+
* it. The engine underneath (createMorph in ./morph) has no React in it and
|
|
1381
|
+
* is not part of the package's exports.
|
|
1382
|
+
*
|
|
1383
|
+
* const morph = useMorph<Delivery>();
|
|
1384
|
+
* <main ref={morph.backgroundRef}>
|
|
1385
|
+
* <article ref={morph.cardRef(d.id)} onClick={() => morph.open({ key: d.id, item: d })}>…</article>
|
|
1386
|
+
* </main>
|
|
1387
|
+
* <div ref={morph.scrimRef} data-morph-close hidden />
|
|
1388
|
+
* <section ref={morph.sheetRef} hidden>{morph.item && <Detail d={morph.item} />}</section>
|
|
1389
|
+
*/
|
|
1390
|
+
const isKey = (value) => typeof value === "string" || typeof value === "number";
|
|
1391
|
+
const isElement = (value) => typeof value === "object" && value !== null && "nodeType" in value;
|
|
1392
|
+
function useMorph(options = {}) {
|
|
1393
|
+
const [state, setState] = useState("closed");
|
|
1394
|
+
const [item, setItem] = useState(null);
|
|
1395
|
+
const [sheet, setSheet] = useState(null);
|
|
1396
|
+
const [background, setBackground] = useState(null);
|
|
1397
|
+
const [scrim, setScrim] = useState(null);
|
|
1398
|
+
const instance = useRef(null);
|
|
1399
|
+
const pending = useRef(null);
|
|
1400
|
+
const queued = useRef(null);
|
|
1401
|
+
const mounted = useRef(false);
|
|
1402
|
+
const initialized = useRef(false);
|
|
1403
|
+
const latest = useRef(options);
|
|
1404
|
+
latest.current = options;
|
|
1405
|
+
const cards = useRef(/* @__PURE__ */ new Map());
|
|
1406
|
+
const refs = useRef(/* @__PURE__ */ new Map());
|
|
1407
|
+
const openKey = useRef(null);
|
|
1408
|
+
const cardFor = useCallback((key) => {
|
|
1409
|
+
const el = cards.current.get(key);
|
|
1410
|
+
return el?.isConnected ? el : null;
|
|
1411
|
+
}, []);
|
|
1412
|
+
useEffect(() => {
|
|
1413
|
+
mounted.current = true;
|
|
1414
|
+
return () => {
|
|
1415
|
+
mounted.current = false;
|
|
1416
|
+
queueMicrotask(() => {
|
|
1417
|
+
if (mounted.current) return;
|
|
1418
|
+
queued.current?.resolve(false);
|
|
1419
|
+
queued.current = null;
|
|
1420
|
+
});
|
|
1421
|
+
};
|
|
1422
|
+
}, []);
|
|
1423
|
+
useEffect(() => {
|
|
1424
|
+
if (!sheet) return;
|
|
1425
|
+
const morph = createMorph({
|
|
1426
|
+
...latest.current,
|
|
1427
|
+
sheet,
|
|
1428
|
+
background,
|
|
1429
|
+
scrim,
|
|
1430
|
+
prepare: () => {
|
|
1431
|
+
const next = pending.current;
|
|
1432
|
+
pending.current = null;
|
|
1433
|
+
openKey.current = next?.key ?? null;
|
|
1434
|
+
if (next) flushSync(next.apply);
|
|
1435
|
+
},
|
|
1436
|
+
resolveCard: (card) => card?.isConnected || openKey.current === null ? card : cardFor(openKey.current) || card,
|
|
1437
|
+
onStateChange: (next, card) => {
|
|
1438
|
+
setState(next);
|
|
1439
|
+
if (next === "closed") {
|
|
1440
|
+
openKey.current = null;
|
|
1441
|
+
setItem(null);
|
|
1442
|
+
}
|
|
1443
|
+
latest.current.onStateChange?.(next, card);
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
instance.current = morph;
|
|
1447
|
+
initialized.current = true;
|
|
1448
|
+
queueMicrotask(() => {
|
|
1449
|
+
if (!mounted.current || instance.current !== morph) return;
|
|
1450
|
+
const job = queued.current;
|
|
1451
|
+
queued.current = null;
|
|
1452
|
+
if (job) try {
|
|
1453
|
+
job.run().then(job.resolve, job.reject);
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
job.reject(error);
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
return () => {
|
|
1459
|
+
morph.destroy();
|
|
1460
|
+
if (instance.current === morph) instance.current = null;
|
|
1461
|
+
setState("closed");
|
|
1462
|
+
};
|
|
1463
|
+
}, [
|
|
1464
|
+
sheet,
|
|
1465
|
+
background,
|
|
1466
|
+
scrim
|
|
1467
|
+
]);
|
|
1468
|
+
const applyOptions = useCallback(() => {
|
|
1469
|
+
const next = latest.current;
|
|
1470
|
+
instance.current?.setOptions({
|
|
1471
|
+
...resolveTiming(next, defaults),
|
|
1472
|
+
shared: next.shared,
|
|
1473
|
+
radius: next.radius,
|
|
1474
|
+
scroller: next.scroller
|
|
1475
|
+
});
|
|
1476
|
+
}, []);
|
|
1477
|
+
useEffect(applyOptions);
|
|
1478
|
+
const cardRef = useCallback((key) => {
|
|
1479
|
+
let ref = refs.current.get(key);
|
|
1480
|
+
if (!ref) {
|
|
1481
|
+
ref = (el) => {
|
|
1482
|
+
if (el) {
|
|
1483
|
+
cards.current.set(key, el);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
queueMicrotask(() => {
|
|
1487
|
+
if (cards.current.get(key)?.isConnected) return;
|
|
1488
|
+
cards.current.delete(key);
|
|
1489
|
+
refs.current.delete(key);
|
|
1490
|
+
});
|
|
1491
|
+
};
|
|
1492
|
+
refs.current.set(key, ref);
|
|
1493
|
+
}
|
|
1494
|
+
return ref;
|
|
1495
|
+
}, []);
|
|
1496
|
+
const open = useCallback((target, update) => {
|
|
1497
|
+
let card = null;
|
|
1498
|
+
let key = null;
|
|
1499
|
+
let hasItem = false;
|
|
1500
|
+
let nextItem = null;
|
|
1501
|
+
if (isKey(target)) key = target;
|
|
1502
|
+
else if (isElement(target)) card = target;
|
|
1503
|
+
else if (target) {
|
|
1504
|
+
key = target.key ?? null;
|
|
1505
|
+
card = target.card ?? null;
|
|
1506
|
+
if ("item" in target) {
|
|
1507
|
+
hasItem = true;
|
|
1508
|
+
nextItem = target.item ?? null;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
if (!card && key !== null) card = cardFor(key);
|
|
1512
|
+
const apply = () => {
|
|
1513
|
+
if (hasItem) setItem(() => nextItem);
|
|
1514
|
+
update?.();
|
|
1515
|
+
};
|
|
1516
|
+
const morph = instance.current;
|
|
1517
|
+
if (!morph) {
|
|
1518
|
+
if (!initialized.current && mounted.current) {
|
|
1519
|
+
queued.current?.resolve(false);
|
|
1520
|
+
return new Promise((resolve, reject) => {
|
|
1521
|
+
queued.current = {
|
|
1522
|
+
run: () => open(target, update),
|
|
1523
|
+
resolve,
|
|
1524
|
+
reject
|
|
1525
|
+
};
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
apply();
|
|
1529
|
+
return Promise.resolve(false);
|
|
1530
|
+
}
|
|
1531
|
+
applyOptions();
|
|
1532
|
+
pending.current = {
|
|
1533
|
+
key,
|
|
1534
|
+
apply
|
|
1535
|
+
};
|
|
1536
|
+
try {
|
|
1537
|
+
return morph.open(card);
|
|
1538
|
+
} finally {
|
|
1539
|
+
pending.current = null;
|
|
1540
|
+
}
|
|
1541
|
+
}, [cardFor, applyOptions]);
|
|
1542
|
+
const close = useCallback((opts) => {
|
|
1543
|
+
queued.current?.resolve(false);
|
|
1544
|
+
queued.current = null;
|
|
1545
|
+
const morph = instance.current;
|
|
1546
|
+
if (!morph) return Promise.resolve(true);
|
|
1547
|
+
applyOptions();
|
|
1548
|
+
if (!opts || !("to" in opts)) return morph.close();
|
|
1549
|
+
const to = opts.to ?? null;
|
|
1550
|
+
return morph.close({ to: isKey(to) ? cardFor(to) : to });
|
|
1551
|
+
}, [cardFor, applyOptions]);
|
|
1552
|
+
return useMemo(() => ({
|
|
1553
|
+
sheetRef: setSheet,
|
|
1554
|
+
backgroundRef: setBackground,
|
|
1555
|
+
scrimRef: setScrim,
|
|
1556
|
+
cardRef,
|
|
1557
|
+
state,
|
|
1558
|
+
item,
|
|
1559
|
+
open,
|
|
1560
|
+
close,
|
|
1561
|
+
get instance() {
|
|
1562
|
+
return instance.current;
|
|
1563
|
+
}
|
|
1564
|
+
}), [
|
|
1565
|
+
state,
|
|
1566
|
+
item,
|
|
1567
|
+
cardRef,
|
|
1568
|
+
open,
|
|
1569
|
+
close
|
|
1570
|
+
]);
|
|
1571
|
+
}
|
|
1572
|
+
//#endregion
|
|
1573
|
+
export { choreography, defaults, useMorph };
|
|
1574
|
+
|
|
1575
|
+
//# sourceMappingURL=index.js.map
|