@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.0.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 +356 -2
- package/dist/_chunks/core.js +2314 -0
- package/dist/_chunks/core.js.map +1 -0
- package/dist/_chunks/index.d.ts +431 -0
- package/dist/core/index.cjs +67 -0
- package/dist/core/index.d.cts +2 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/element/index.cjs +47 -0
- package/dist/element/index.cjs.map +1 -0
- package/dist/element/index.d.cts +24 -0
- package/dist/element/index.d.ts +24 -0
- package/dist/element/index.js +46 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.cjs +512 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +441 -0
- package/dist/index.js.map +1 -0
- package/dist/nuxt/index.cjs +36 -0
- package/dist/nuxt/index.cjs.map +1 -0
- package/dist/nuxt/index.d.cts +30 -0
- package/dist/nuxt/index.d.ts +30 -0
- package/dist/nuxt/index.js +36 -0
- package/dist/nuxt/index.js.map +1 -0
- package/dist/resolver/index.cjs +26 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +19 -0
- package/dist/resolver/index.d.ts +19 -0
- package/dist/resolver/index.js +26 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/styles.css +136 -0
- package/package.json +144 -3
- package/src/components/ASmartSkeleton.vue +228 -0
- package/src/components/ASmartSkeletonBox.vue +23 -0
- package/src/components/ASmartSkeletonProvider.vue +34 -0
- package/src/components/ASmartSkeletonSuspense.vue +142 -0
- package/src/composables/useSmartSkeleton.ts +20 -0
- package/src/composables/useStyles.ts +8 -0
- package/src/core/boot.ts +63 -0
- package/src/core/canvas.ts +7 -0
- package/src/core/constants.ts +76 -0
- package/src/core/debug.ts +154 -0
- package/src/core/dev.ts +53 -0
- package/src/core/fade.ts +490 -0
- package/src/core/ghost.ts +236 -0
- package/src/core/images.ts +28 -0
- package/src/core/index.ts +71 -0
- package/src/core/inline.generated.ts +9 -0
- package/src/core/measure.ts +571 -0
- package/src/core/memory.ts +146 -0
- package/src/core/motion.ts +12 -0
- package/src/core/plan.ts +368 -0
- package/src/core/reveal.ts +89 -0
- package/src/core/reveals.ts +39 -0
- package/src/core/shapes.ts +84 -0
- package/src/core/shimmer.ts +229 -0
- package/src/core/skeleton.ts +43 -0
- package/src/core/snapshot.ts +159 -0
- package/src/core/styles.ts +187 -0
- package/src/core/sweep.ts +203 -0
- package/src/core/theme.ts +162 -0
- package/src/element/index.ts +59 -0
- package/src/index.ts +27 -0
- package/src/nuxt/index.ts +59 -0
- package/src/resolver/index.ts +35 -0
- package/src/types.ts +100 -0
- package/src/vue/Skeleton.ts +275 -0
- package/src/vue/Suspense.ts +157 -0
- package/src/vue/config.ts +78 -0
- package/src/vue/index.ts +7 -0
- package/web-types.json +256 -0
package/src/core/fade.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ATTR_RECT,
|
|
3
|
+
ATTR_LAYER,
|
|
4
|
+
ATTR_EXITING,
|
|
5
|
+
ATTR_GHOST,
|
|
6
|
+
ATTR_OWN,
|
|
7
|
+
ATTR_PANE,
|
|
8
|
+
ATTR_SNAPSHOT,
|
|
9
|
+
FADE_DURATION,
|
|
10
|
+
RESIZE_DURATION,
|
|
11
|
+
} from './constants';
|
|
12
|
+
import { imageReady, pendingImages } from './images';
|
|
13
|
+
import { animationsOf, canAnimate, prefersReducedMotion } from './motion';
|
|
14
|
+
import { warnOnce } from './dev';
|
|
15
|
+
import {
|
|
16
|
+
planReveal,
|
|
17
|
+
pieces,
|
|
18
|
+
type Plan,
|
|
19
|
+
type RevealMode,
|
|
20
|
+
type SequencePlan,
|
|
21
|
+
type WipePlan,
|
|
22
|
+
} from './plan';
|
|
23
|
+
import { revealSpec } from './reveals';
|
|
24
|
+
import { rectsOf, shimmer } from './shimmer';
|
|
25
|
+
|
|
26
|
+
export { prefersReducedMotion };
|
|
27
|
+
|
|
28
|
+
const noop = () => {};
|
|
29
|
+
|
|
30
|
+
const settle = (animation: Animation) => animation.finished.then(noop, noop);
|
|
31
|
+
|
|
32
|
+
// How a reveal's incoming pane decided to arrive, for its outgoing pane to match
|
|
33
|
+
const plans = new WeakMap<Element, Plan | { kind: 'skeleton'; hold: number }>();
|
|
34
|
+
|
|
35
|
+
function appear(element: Element, delay = 0, duration = FADE_DURATION, from = 0, enter?: Keyframe) {
|
|
36
|
+
if (prefersReducedMotion()) return Promise.resolve();
|
|
37
|
+
return settle(
|
|
38
|
+
element.animate([{ ...enter, opacity: from }, { opacity: 1 }], {
|
|
39
|
+
duration,
|
|
40
|
+
delay,
|
|
41
|
+
easing: 'ease',
|
|
42
|
+
fill: 'backwards',
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The bones layer, or the ghost a skeleton is drawn from — vanilla adds its layer a moment later
|
|
48
|
+
const isSkeleton = (element: Element) =>
|
|
49
|
+
!!element.querySelector(`[${ATTR_LAYER}], [${ATTR_GHOST}], :scope > [${ATTR_OWN}]`);
|
|
50
|
+
|
|
51
|
+
const sameBox = (a: DOMRect, b: DOMRect) =>
|
|
52
|
+
Math.abs(a.left - b.left) < 2 &&
|
|
53
|
+
Math.abs(a.top - b.top) < 2 &&
|
|
54
|
+
Math.abs(a.width - b.width) < 2 &&
|
|
55
|
+
Math.abs(a.height - b.height) < 2;
|
|
56
|
+
|
|
57
|
+
// Bones standing in for images still loading, by reveal
|
|
58
|
+
const standIns = new WeakMap<Element, Set<HTMLElement>>();
|
|
59
|
+
|
|
60
|
+
function clearStandIns(root: Element) {
|
|
61
|
+
for (const stand of standIns.get(root) ?? []) stand.remove();
|
|
62
|
+
standIns.delete(root);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Each image still loading stays hidden behind a bone of its own until it's ready, so nothing else waits on it
|
|
66
|
+
function holdImages(element: HTMLElement, root: HTMLElement) {
|
|
67
|
+
const images = pendingImages(element);
|
|
68
|
+
const overSkeleton = [...root.children].some(
|
|
69
|
+
(pane) => pane !== element && pane.hasAttribute(ATTR_PANE) && isSkeleton(pane)
|
|
70
|
+
);
|
|
71
|
+
if (!images.length || !overSkeleton) return;
|
|
72
|
+
for (const image of images) image.style.opacity = '0';
|
|
73
|
+
|
|
74
|
+
// A microtask on, once the skeleton has popped out — layout read any sooner moves the page
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
if (!element.isConnected) return;
|
|
77
|
+
const origin = root.getBoundingClientRect();
|
|
78
|
+
const bones = [...root.querySelectorAll(`:scope > [${ATTR_EXITING}] [${ATTR_LAYER}]`)].flatMap(
|
|
79
|
+
rectsOf
|
|
80
|
+
);
|
|
81
|
+
const stands = standIns.get(root) ?? new Set<HTMLElement>();
|
|
82
|
+
standIns.set(root, stands);
|
|
83
|
+
|
|
84
|
+
for (const image of images) {
|
|
85
|
+
const rect = image.getBoundingClientRect();
|
|
86
|
+
// Its bone in the skeleton, which the stand-in takes over exactly
|
|
87
|
+
const bone = bones.find((b) => sameBox(b.getBoundingClientRect(), rect));
|
|
88
|
+
const box = bone?.getBoundingClientRect() ?? rect;
|
|
89
|
+
const stand = document.createElement('div');
|
|
90
|
+
// Same "media"-type bone the main pass produces — fill colour comes from the
|
|
91
|
+
// stylesheet (`[ask-rect]` → `var(--ask-base)`) so a consumer theme reaches it.
|
|
92
|
+
stand.setAttribute(ATTR_RECT, 'media');
|
|
93
|
+
Object.assign(stand.style, {
|
|
94
|
+
position: 'absolute',
|
|
95
|
+
left: `${box.left - origin.left - root.clientLeft}px`,
|
|
96
|
+
top: `${box.top - origin.top - root.clientTop}px`,
|
|
97
|
+
width: `${box.width}px`,
|
|
98
|
+
height: `${box.height}px`,
|
|
99
|
+
borderRadius: bone?.style.borderRadius || getComputedStyle(image).borderRadius,
|
|
100
|
+
backgroundRepeat: 'no-repeat',
|
|
101
|
+
pointerEvents: 'none',
|
|
102
|
+
});
|
|
103
|
+
root.append(stand);
|
|
104
|
+
stands.add(stand);
|
|
105
|
+
if (bone) bone.style.visibility = 'hidden';
|
|
106
|
+
shimmer(stand);
|
|
107
|
+
|
|
108
|
+
imageReady(image).then(() => {
|
|
109
|
+
if (!stands.has(stand)) return;
|
|
110
|
+
stands.delete(stand);
|
|
111
|
+
image.style.opacity = '';
|
|
112
|
+
appear(image);
|
|
113
|
+
if (prefersReducedMotion()) return stand.remove();
|
|
114
|
+
settle(
|
|
115
|
+
stand.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
116
|
+
duration: FADE_DURATION,
|
|
117
|
+
easing: 'ease',
|
|
118
|
+
fill: 'forwards',
|
|
119
|
+
})
|
|
120
|
+
).then(() => stand.remove());
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sequenceIn(element: HTMLElement, plan: SequencePlan) {
|
|
127
|
+
if (prefersReducedMotion()) return Promise.resolve();
|
|
128
|
+
return Promise.all(
|
|
129
|
+
pieces(element).map((piece) => {
|
|
130
|
+
const delay = plan.pieceDelay(piece);
|
|
131
|
+
if (delay === null) return;
|
|
132
|
+
return appear(piece, delay, plan.duration, 0, plan.enter);
|
|
133
|
+
})
|
|
134
|
+
).then(noop);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Content arriving at once: anything still on its way out goes now too
|
|
138
|
+
function clearExiting(root: Element) {
|
|
139
|
+
for (const pane of root.querySelectorAll<HTMLElement>(`:scope > [${ATTR_EXITING}]`)) {
|
|
140
|
+
for (const animation of animationsOf(pane)) animation.finish();
|
|
141
|
+
pane.style.opacity = '0';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Content still fading out as content lands again — what the new pane takes over from, so the two never dip together
|
|
146
|
+
function takeOver(root: Element) {
|
|
147
|
+
let from = 0;
|
|
148
|
+
for (const pane of root.querySelectorAll<HTMLElement>(`:scope > [${ATTR_EXITING}]`)) {
|
|
149
|
+
if (isSkeleton(pane)) continue;
|
|
150
|
+
from = Math.max(from, parseFloat(getComputedStyle(pane).opacity) || 0);
|
|
151
|
+
for (const animation of animationsOf(pane)) animation.finish();
|
|
152
|
+
pane.style.opacity = '0';
|
|
153
|
+
}
|
|
154
|
+
return from;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Brings a pane in: a skeleton after the reveal's `hold`, content as `reveal` plans; `initial` has nothing to replace. */
|
|
158
|
+
export function fadeIn(
|
|
159
|
+
element: HTMLElement,
|
|
160
|
+
reveal: RevealMode = 'smart',
|
|
161
|
+
{ initial = false } = {}
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
let arriving = arrivals.get(element);
|
|
164
|
+
if (!arriving) {
|
|
165
|
+
arriving = arrive(element, reveal, initial);
|
|
166
|
+
arrivals.set(element, arriving);
|
|
167
|
+
}
|
|
168
|
+
return arriving;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// One arrival per pane: StrictMode mounts twice, and a second plan would part from the first, which the outgoing pane already matched
|
|
172
|
+
const arrivals = new WeakMap<HTMLElement, Promise<void>>();
|
|
173
|
+
|
|
174
|
+
function arrive(element: HTMLElement, reveal: RevealMode, initial: boolean): Promise<void> {
|
|
175
|
+
const root = element.parentElement;
|
|
176
|
+
const spec = revealSpec(reveal);
|
|
177
|
+
|
|
178
|
+
if (isSkeleton(element)) {
|
|
179
|
+
if (root) clearStandIns(root);
|
|
180
|
+
const hold = Math.max(spec.hold ?? 0, 0);
|
|
181
|
+
if (!hold) {
|
|
182
|
+
if (root) plans.delete(root);
|
|
183
|
+
return initial ? Promise.resolve() : appear(element);
|
|
184
|
+
}
|
|
185
|
+
if (root) plans.set(root, { kind: 'skeleton', hold });
|
|
186
|
+
// A reveal's first skeleton has nothing on screen to hold back for — held, it would only be a blank
|
|
187
|
+
if (initial) return Promise.resolve();
|
|
188
|
+
return appear(element, hold);
|
|
189
|
+
}
|
|
190
|
+
if (initial || !root) return Promise.resolve();
|
|
191
|
+
|
|
192
|
+
const from = takeOver(root);
|
|
193
|
+
if (from > 0.01) {
|
|
194
|
+
const duration = FADE_DURATION * (1 - from);
|
|
195
|
+
plans.set(root, { kind: 'together', delay: 0, duration });
|
|
196
|
+
return appear(element, 0, duration, from);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
holdImages(element, root);
|
|
200
|
+
|
|
201
|
+
const plan = planReveal(element, root, spec, (key) =>
|
|
202
|
+
warnOnce(
|
|
203
|
+
`enter:${key}`,
|
|
204
|
+
`\`enter\` can't set \`${key}\`: only properties that change nothing's layout animate, so the reveal never moves the page.`
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
plans.set(root, plan);
|
|
208
|
+
if (plan.kind === 'instant') {
|
|
209
|
+
clearExiting(root);
|
|
210
|
+
return Promise.resolve();
|
|
211
|
+
}
|
|
212
|
+
if (plan.kind === 'sequence') return sequenceIn(element, plan);
|
|
213
|
+
if (plan.kind === 'wipe') return wipe(element, plan, true);
|
|
214
|
+
return appear(element, plan.delay, plan.duration, 0, plan.enter);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Where a pane stands within its reveal — what it's pinned to when it pops out. */
|
|
218
|
+
export type PaneBox = {
|
|
219
|
+
top: number;
|
|
220
|
+
left: number;
|
|
221
|
+
width: number;
|
|
222
|
+
height: number;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Measures `element` for `fadeOut`: before its replacement is inserted, or scroll anchoring moves the page. */
|
|
226
|
+
export function boxOf(element: HTMLElement): PaneBox {
|
|
227
|
+
// Fractional, not offset*: a width rounded down rewraps a shrink-to-fit pane's text as it leaves
|
|
228
|
+
const rect = element.getBoundingClientRect();
|
|
229
|
+
const parent = element.offsetParent ?? document.documentElement;
|
|
230
|
+
const origin = parent.getBoundingClientRect();
|
|
231
|
+
return {
|
|
232
|
+
top: rect.top - origin.top - parent.clientTop,
|
|
233
|
+
left: rect.left - origin.left - parent.clientLeft,
|
|
234
|
+
width: rect.width,
|
|
235
|
+
height: rect.height,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Takes the element out of flow where it stands, so what replaces it lays out at once
|
|
240
|
+
function popOut(element: HTMLElement, box = boxOf(element)) {
|
|
241
|
+
Object.assign(element.style, {
|
|
242
|
+
position: 'absolute',
|
|
243
|
+
top: `${box.top}px`,
|
|
244
|
+
left: `${box.left}px`,
|
|
245
|
+
width: `${box.width}px`,
|
|
246
|
+
height: `${box.height}px`,
|
|
247
|
+
margin: '0',
|
|
248
|
+
boxSizing: 'border-box',
|
|
249
|
+
pointerEvents: 'none',
|
|
250
|
+
});
|
|
251
|
+
element.inert = true;
|
|
252
|
+
element.setAttribute(ATTR_EXITING, '');
|
|
253
|
+
return box;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type Resize = { animation: Animation; overflow: string; boxSizing: string };
|
|
257
|
+
|
|
258
|
+
// A reveal's running resize, so the next starts from where it got to and restores the styles it found
|
|
259
|
+
const resizes = new WeakMap<HTMLElement, Resize>();
|
|
260
|
+
|
|
261
|
+
// Eases a reveal from the height the outgoing pane gave it to the incoming one's; the outgoing pane is already out of flow
|
|
262
|
+
function resize(root: HTMLElement, box: PaneBox) {
|
|
263
|
+
const incoming = root.querySelector<HTMLElement>(
|
|
264
|
+
`:scope > [${ATTR_PANE}]:not([${ATTR_EXITING}])`
|
|
265
|
+
);
|
|
266
|
+
if (!incoming) return;
|
|
267
|
+
const running = resizes.get(root);
|
|
268
|
+
const shown = root.getBoundingClientRect().height;
|
|
269
|
+
running?.animation.cancel();
|
|
270
|
+
const to = root.getBoundingClientRect().height;
|
|
271
|
+
const from = running ? shown : to - incoming.getBoundingClientRect().height + box.height;
|
|
272
|
+
if (Math.abs(from - to) < 0.5) {
|
|
273
|
+
if (running) restore(root, running);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const { overflow, boxSizing } = running ?? root.style;
|
|
278
|
+
Object.assign(root.style, { overflow: 'clip', boxSizing: 'border-box' });
|
|
279
|
+
const animation = root.animate([{ height: `${from}px` }, { height: `${to}px` }], {
|
|
280
|
+
duration: RESIZE_DURATION,
|
|
281
|
+
easing: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
282
|
+
});
|
|
283
|
+
const current = { animation, overflow, boxSizing };
|
|
284
|
+
resizes.set(root, current);
|
|
285
|
+
settle(animation).then(() => {
|
|
286
|
+
if (resizes.get(root) === current) restore(root, current);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function restore(root: HTMLElement, { overflow, boxSizing }: Resize) {
|
|
291
|
+
resizes.delete(root);
|
|
292
|
+
Object.assign(root.style, { overflow, boxSizing });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Resolves once `element` can be removed, leaving as the pane replacing it decides; its parent must be positioned. */
|
|
296
|
+
export function fadeOut(element: HTMLElement, box?: PaneBox): Promise<void> {
|
|
297
|
+
// Read before cancelling, so an interrupted fade-in leaves from where it got to
|
|
298
|
+
const opacity = parseFloat(getComputedStyle(element).opacity);
|
|
299
|
+
const from = Number.isNaN(opacity) ? 1 : opacity;
|
|
300
|
+
for (const animation of animationsOf(element)) animation.cancel();
|
|
301
|
+
clearMask(element);
|
|
302
|
+
element.style.filter = '';
|
|
303
|
+
|
|
304
|
+
const parent = element.parentElement;
|
|
305
|
+
// Content moving on: the bones standing in for its images go with it
|
|
306
|
+
if (parent && !isSkeleton(element)) clearStandIns(parent);
|
|
307
|
+
const stood = popOut(element, box);
|
|
308
|
+
|
|
309
|
+
const fade = (delay: number, duration: number) => {
|
|
310
|
+
if (!canAnimate()) {
|
|
311
|
+
element.style.opacity = '0';
|
|
312
|
+
return Promise.resolve();
|
|
313
|
+
}
|
|
314
|
+
return settle(
|
|
315
|
+
element.animate([{ opacity: from }, { opacity: 0 }], {
|
|
316
|
+
duration: duration * from,
|
|
317
|
+
delay,
|
|
318
|
+
easing: 'ease',
|
|
319
|
+
fill: 'forwards',
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// A microtask on, so the pane replacing it has registered how it arrives
|
|
325
|
+
return Promise.resolve().then(() => {
|
|
326
|
+
const plan = parent && plans.get(parent);
|
|
327
|
+
const instant = prefersReducedMotion() || plan?.kind === 'instant';
|
|
328
|
+
if (parent && !instant) resize(parent, stood);
|
|
329
|
+
|
|
330
|
+
// Content stays until the skeleton replacing it shows — which may be never
|
|
331
|
+
if (plan?.kind === 'skeleton') return fade(plan.hold, FADE_DURATION);
|
|
332
|
+
if (instant) {
|
|
333
|
+
element.style.opacity = '0';
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (plan?.kind === 'sequence' && isSkeleton(element)) {
|
|
337
|
+
return sequenceOut(element, plan);
|
|
338
|
+
}
|
|
339
|
+
if (plan?.kind === 'wipe' && isSkeleton(element)) {
|
|
340
|
+
return wipe(element, plan, false);
|
|
341
|
+
}
|
|
342
|
+
if (plan?.kind === 'together') return fade(plan.delay, plan.duration);
|
|
343
|
+
return fade(0, FADE_DURATION);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sequenceOut(element: HTMLElement, plan: SequencePlan) {
|
|
348
|
+
// The content's own surfaces arrive whole; only the bones go piece by piece
|
|
349
|
+
for (const ghost of element.querySelectorAll<HTMLElement>(
|
|
350
|
+
`[${ATTR_GHOST}], [${ATTR_SNAPSHOT}]`
|
|
351
|
+
)) {
|
|
352
|
+
ghost.style.visibility = 'hidden';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A skeleton the content draws itself sits in the ghost, but leaves like a bone
|
|
356
|
+
const own = [...element.querySelectorAll<HTMLElement>(`[${ATTR_GHOST}] [${ATTR_OWN}]`)].filter(
|
|
357
|
+
(part) => !part.parentElement?.closest(`[${ATTR_OWN}]`)
|
|
358
|
+
);
|
|
359
|
+
for (const part of own) part.style.visibility = 'visible';
|
|
360
|
+
|
|
361
|
+
const bones = [...[...element.querySelectorAll(`[${ATTR_LAYER}]`)].flatMap(rectsOf), ...own];
|
|
362
|
+
return Promise.all(
|
|
363
|
+
bones.map((bone) => {
|
|
364
|
+
const rect = bone.getBoundingClientRect();
|
|
365
|
+
const go = (delay: number) =>
|
|
366
|
+
settle(
|
|
367
|
+
bone.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
368
|
+
duration: plan.duration,
|
|
369
|
+
delay,
|
|
370
|
+
easing: 'ease',
|
|
371
|
+
fill: 'forwards',
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
const delay = plan.boneDelay(rect);
|
|
375
|
+
if (delay === null) {
|
|
376
|
+
bone.style.opacity = '0';
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
return go(delay);
|
|
380
|
+
})
|
|
381
|
+
).then(noop);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const STALL = 100; // ms between frames that counts as frames dropping — the wipe gives way to a crossfade
|
|
385
|
+
|
|
386
|
+
// Running fronts, by pane — stopped when the pane's mask is cleared
|
|
387
|
+
const fronts = new WeakMap<HTMLElement, () => void>();
|
|
388
|
+
|
|
389
|
+
// Prefixed too: Safari before 15.4 masks with -webkit- only
|
|
390
|
+
function setMask(element: HTMLElement, image: string) {
|
|
391
|
+
element.style.setProperty('mask-image', image);
|
|
392
|
+
element.style.setProperty('-webkit-mask-image', image);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The inverse of `image`: a full layer with `image` cut out of it
|
|
396
|
+
function setInverseMask(element: HTMLElement, image: string) {
|
|
397
|
+
setMask(element, `linear-gradient(#000 0 0), ${image}`);
|
|
398
|
+
// Prefixed first: WebKit aliases the two, and the later wins
|
|
399
|
+
element.style.setProperty('-webkit-mask-composite', 'xor');
|
|
400
|
+
element.style.setProperty('mask-composite', 'exclude');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function clearMask(element: HTMLElement) {
|
|
404
|
+
fronts.get(element)?.();
|
|
405
|
+
for (const property of [
|
|
406
|
+
'mask-image',
|
|
407
|
+
'-webkit-mask-image',
|
|
408
|
+
'mask-composite',
|
|
409
|
+
'-webkit-mask-composite',
|
|
410
|
+
]) {
|
|
411
|
+
element.style.removeProperty(property);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A wipe across one pane on the plan's shared clock; the plainest mask, rewritten each frame, for Safari 18
|
|
416
|
+
function wipe(element: HTMLElement, plan: WipePlan, incoming: boolean) {
|
|
417
|
+
const pane = { rect: element.getBoundingClientRect(), reveal: plan.reveal };
|
|
418
|
+
const { mask, out } = plan.wipe;
|
|
419
|
+
const draw = (at: number) => {
|
|
420
|
+
if (incoming) setMask(element, mask(at, pane));
|
|
421
|
+
else if (out) setMask(element, out(at, pane));
|
|
422
|
+
else setInverseMask(element, mask(at, pane));
|
|
423
|
+
};
|
|
424
|
+
const progress = () => (performance.now() - plan.start) / plan.duration;
|
|
425
|
+
if (progress() >= 1) {
|
|
426
|
+
if (!incoming) element.style.opacity = '0';
|
|
427
|
+
return Promise.resolve();
|
|
428
|
+
}
|
|
429
|
+
if (incoming) {
|
|
430
|
+
plan.wipe.effect?.(element, { ...pane, progress });
|
|
431
|
+
if (plan.enter) {
|
|
432
|
+
element.animate([plan.enter, {}], {
|
|
433
|
+
duration: plan.duration,
|
|
434
|
+
delay: Math.max(plan.start - performance.now(), 0),
|
|
435
|
+
easing: 'ease',
|
|
436
|
+
fill: 'backwards',
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Resolves null once crossed, or with how far it got when frames dropped and the wipe gave way
|
|
442
|
+
const run = new Promise<number | null>((resolve) => {
|
|
443
|
+
let frame = 0;
|
|
444
|
+
let last = performance.now();
|
|
445
|
+
let at = 0;
|
|
446
|
+
const tick = () => {
|
|
447
|
+
const now = performance.now();
|
|
448
|
+
if (now - last > STALL) plan.stalled = true;
|
|
449
|
+
last = now;
|
|
450
|
+
if (plan.stalled) return done(at);
|
|
451
|
+
at = Math.min(Math.max(progress(), 0), 1);
|
|
452
|
+
draw(at);
|
|
453
|
+
if (at < 1) frame = requestAnimationFrame(tick);
|
|
454
|
+
else done(null);
|
|
455
|
+
};
|
|
456
|
+
const done = (stalledAt: number | null = null) => {
|
|
457
|
+
cancelAnimationFrame(frame);
|
|
458
|
+
clearTimeout(deadline);
|
|
459
|
+
fronts.delete(element);
|
|
460
|
+
resolve(stalledAt);
|
|
461
|
+
};
|
|
462
|
+
// Frames can stop altogether, as in a hidden tab: the wipe still ends when its time is up
|
|
463
|
+
const deadline = setTimeout(
|
|
464
|
+
() => done(null),
|
|
465
|
+
Math.max(plan.start + plan.duration - performance.now(), 0) + STALL
|
|
466
|
+
);
|
|
467
|
+
fronts.set(element, done);
|
|
468
|
+
tick();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return run.then((stalledAt) => {
|
|
472
|
+
clearMask(element);
|
|
473
|
+
if (stalledAt === null) {
|
|
474
|
+
if (!incoming) element.style.opacity = '0';
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Frames dropped: the rest as a crossfade, from as far as the front got
|
|
478
|
+
const rest = FADE_DURATION * (1 - stalledAt);
|
|
479
|
+
if (incoming) return appear(element, 0, rest, stalledAt);
|
|
480
|
+
return settle(
|
|
481
|
+
element.animate([{ opacity: 1 - stalledAt }, { opacity: 0 }], {
|
|
482
|
+
duration: rest,
|
|
483
|
+
easing: 'ease',
|
|
484
|
+
fill: 'forwards',
|
|
485
|
+
})
|
|
486
|
+
).then(() => {
|
|
487
|
+
element.style.opacity = '0';
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|