@xingwangzhe/stalux 1.57.0 → 1.58.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/package.json +4 -12
- package/src/index.ts +26 -13
- package/src/internal/modern-css.ts +1 -0
- package/src/internal/style-names.generated.ts +87 -94
- package/src/internal/swup-options.ts +98 -0
- package/src/layouts/Stalux.astro +0 -1
- package/src/scripts/analytics-runtime.ts +4 -9
- package/src/scripts/list-reveal.ts +3 -15
- package/src/scripts/module-motion.ts +2 -3
- package/src/scripts/navigation-scripts.ts +12 -7
- package/src/scripts/route-guards.ts +200 -0
- package/src/scripts/swup-internals.d.ts +15 -0
- package/src/styles/base.css +0 -8
- package/src/styles/generated-prose.css +1 -1
- package/src/styles/generated-waline.css +1 -1
- package/src/styles/generated.css +1 -1
- package/src/styles/integrations.css +14 -16
- package/src/styles/math.css +3 -1
- package/src/styles/motion.css +1 -22
- package/src/utils/accent-color.ts +52 -0
- package/src/scripts/route-motion.ts +0 -340
- package/src/scripts/route-out.ts +0 -63
- package/src/scripts/smooth-scroll.ts +0 -139
- package/src/scripts/swup-router.ts +0 -580
- package/src/scripts/view-transitions.ts +0 -4
- package/src/utils/route-motion-policy.ts +0 -72
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import type { RouteMotionPreset } from "../utils/route-motion-policy";
|
|
2
|
-
|
|
3
|
-
export interface RouteMotionEvent {
|
|
4
|
-
// Retained for integrations using the shared gates with a native transition.
|
|
5
|
-
// Stalux's Swup router leaves this unset and owns the animation lifecycle.
|
|
6
|
-
viewTransition?: {
|
|
7
|
-
ready?: PromiseLike<unknown>;
|
|
8
|
-
finished?: PromiseLike<unknown>;
|
|
9
|
-
};
|
|
10
|
-
signal?: AbortSignal;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface RouteMotionOptions {
|
|
14
|
-
skip?: boolean;
|
|
15
|
-
/** Animate the route's own content stage instead of a bounded heading area. */
|
|
16
|
-
content?: boolean;
|
|
17
|
-
preset?: RouteMotionPreset;
|
|
18
|
-
browserMotion?: boolean;
|
|
19
|
-
/** Releases the outgoing fill effect inside the entry cue's sync block. */
|
|
20
|
-
releaseOutgoing?: () => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface RouteMotion {
|
|
24
|
-
ready: Promise<void>;
|
|
25
|
-
finished: Promise<void>;
|
|
26
|
-
start: (heading: HTMLElement | null, options?: RouteMotionOptions) => void;
|
|
27
|
-
cancel: () => void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface HeadingMotion {
|
|
31
|
-
opacity: number;
|
|
32
|
-
duration: number;
|
|
33
|
-
x?: number;
|
|
34
|
-
y?: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Legacy heading cues remain available to integrations. The router requests
|
|
38
|
-
// one content animation; it never stacks a second animation on the heading.
|
|
39
|
-
const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
40
|
-
glide: { opacity: 0.86, duration: 50 },
|
|
41
|
-
article: { opacity: 0.86, duration: 50 },
|
|
42
|
-
enter: { opacity: 0.86, duration: 70, y: 6 },
|
|
43
|
-
up: { opacity: 0.9, duration: 60, y: -4 },
|
|
44
|
-
taxonomy: { opacity: 0.85, duration: 60 },
|
|
45
|
-
previous: { opacity: 0.9, duration: 70, x: -8 },
|
|
46
|
-
next: { opacity: 0.9, duration: 70, x: 8 },
|
|
47
|
-
panel: { opacity: 0.9, duration: 60, y: 4 },
|
|
48
|
-
home: { opacity: 0.9, duration: 60, y: 4 },
|
|
49
|
-
restore: { opacity: 0.96, duration: 40 },
|
|
50
|
-
error: { opacity: 0.9, duration: 40 },
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const MAIN = "main[data-stalux-route]";
|
|
54
|
-
const ARTICLE = "article.stalux-article";
|
|
55
|
-
// Authored per page type by the theme, not inferred from layout: the article
|
|
56
|
-
// body, archive list, tag cloud, post list, word list, link list, home block.
|
|
57
|
-
const CONTENT = "[data-stalux-motion-content]";
|
|
58
|
-
// The one node a page nominates as its cue stage. Preferred over measuring,
|
|
59
|
-
// because measuring an article column forces a full layout of a document that
|
|
60
|
-
// is tens of thousands of pixels tall, before it has ever been painted.
|
|
61
|
-
const STAGE = "[data-stalux-motion-stage]";
|
|
62
|
-
// A persist node marks the three-column article shell. Those sidebars keep
|
|
63
|
-
// their identity across visits and must never travel with a route cue.
|
|
64
|
-
const PERSIST = "[data-swup-persist]";
|
|
65
|
-
|
|
66
|
-
interface StageMotion {
|
|
67
|
-
opacity: number;
|
|
68
|
-
/**
|
|
69
|
-
* Enter offset in px. Positive y enters from below, positive x from the right.
|
|
70
|
-
* Zero means a pure cross-fade: the column is replaced in place.
|
|
71
|
-
*/
|
|
72
|
-
distance: number;
|
|
73
|
-
axis: "x" | "y";
|
|
74
|
-
duration: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// One entry per route kind, because the components are not interchangeable: an
|
|
78
|
-
// article opens from below and going back up to an index pulls away upward.
|
|
79
|
-
//
|
|
80
|
-
// Article-to-article is the exception: the reader keeps the same shell (sidebars,
|
|
81
|
-
// header) and only the column changes, so the column fades in place instead of
|
|
82
|
-
// travelling. Sliding a 28,000px column sideways made the whole page look like it
|
|
83
|
-
// jerked, and the shell already tells the reader where they are.
|
|
84
|
-
const stageMotions: Record<Exclude<RouteMotionPreset, "none">, StageMotion> = {
|
|
85
|
-
previous: { opacity: 0, distance: 0, axis: "x", duration: 150 },
|
|
86
|
-
next: { opacity: 0, distance: 0, axis: "x", duration: 150 },
|
|
87
|
-
// Entering a different article without a reading-direction trigger.
|
|
88
|
-
article: { opacity: 0, distance: 0, axis: "y", duration: 150 },
|
|
89
|
-
// Reaching an article from any list or the home block.
|
|
90
|
-
enter: { opacity: 0, distance: 56, axis: "y", duration: 170 },
|
|
91
|
-
home: { opacity: 0, distance: 48, axis: "y", duration: 160 },
|
|
92
|
-
// Leaving an article for the archive or a taxonomy index.
|
|
93
|
-
up: { opacity: 0, distance: -44, axis: "y", duration: 150 },
|
|
94
|
-
// Moving between two taxonomy detail pages.
|
|
95
|
-
taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 140 },
|
|
96
|
-
// About / links / words: single-panel pages.
|
|
97
|
-
panel: { opacity: 0, distance: 44, axis: "y", duration: 150 },
|
|
98
|
-
glide: { opacity: 0, distance: 40, axis: "y", duration: 140 },
|
|
99
|
-
// History traversal restores what the reader already saw.
|
|
100
|
-
restore: { opacity: 0, distance: -36, axis: "y", duration: 140 },
|
|
101
|
-
error: { opacity: 0, distance: 28, axis: "y", duration: 130 },
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const laidOut = (element: HTMLElement): boolean => {
|
|
105
|
-
const rect = element.getBoundingClientRect();
|
|
106
|
-
return rect.width > 0 && rect.height > 0;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The single node a route cue may animate for the current document.
|
|
111
|
-
*
|
|
112
|
-
* A page can nominate its stage; only when it does not is the largest authored
|
|
113
|
-
* content stage measured. Measuring is the fallback, not the rule: on an article
|
|
114
|
-
* the column is tens of thousands of pixels tall and measuring it forces a full
|
|
115
|
-
* layout of the incoming document at the exact moment the entry cue needs to
|
|
116
|
-
* start. Sidebars (persisted shells), the header and the footer never qualify.
|
|
117
|
-
*/
|
|
118
|
-
export function getRouteStage(): HTMLElement | null {
|
|
119
|
-
const main = document.querySelector<HTMLElement>(MAIN);
|
|
120
|
-
if (!main?.isConnected) return null;
|
|
121
|
-
const authored = main.querySelector<HTMLElement>(STAGE);
|
|
122
|
-
if (authored?.isConnected && !authored.closest(PERSIST)) return authored;
|
|
123
|
-
const candidates: HTMLElement[] = [];
|
|
124
|
-
const article = main.querySelector<HTMLElement>(ARTICLE);
|
|
125
|
-
if (article) candidates.push(article);
|
|
126
|
-
for (const element of main.querySelectorAll<HTMLElement>(CONTENT)) candidates.push(element);
|
|
127
|
-
|
|
128
|
-
let stage: HTMLElement | null = null;
|
|
129
|
-
let area = 0;
|
|
130
|
-
for (const element of candidates) {
|
|
131
|
-
// A stage inside a persisted subtree is the shell itself.
|
|
132
|
-
if (!element.isConnected || element.closest(PERSIST)) continue;
|
|
133
|
-
const rect = element.getBoundingClientRect();
|
|
134
|
-
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
135
|
-
const current = rect.width * rect.height;
|
|
136
|
-
if (current > area) {
|
|
137
|
-
area = current;
|
|
138
|
-
stage = element;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (stage) return stage;
|
|
142
|
-
if (main.querySelector(PERSIST)) return null;
|
|
143
|
-
return laidOut(main) ? main : null;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Offset applied to the outgoing document, continuing the entry direction. */
|
|
147
|
-
export function getStageExit(preset: RouteMotionPreset): StageMotion | null {
|
|
148
|
-
if (preset === "none") return null;
|
|
149
|
-
const motion = stageMotions[preset];
|
|
150
|
-
// Entering from below means leaving upward, so both halves read as one
|
|
151
|
-
// continuous flow instead of an enter/leave bounce.
|
|
152
|
-
return { ...motion, distance: -Math.round(motion.distance * 0.72) };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const motions = new WeakMap<object, RouteMotion>();
|
|
156
|
-
const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
|
|
157
|
-
Promise.resolve(promise).then(
|
|
158
|
-
() => undefined,
|
|
159
|
-
() => undefined,
|
|
160
|
-
);
|
|
161
|
-
function completion() {
|
|
162
|
-
let resolve = () => {};
|
|
163
|
-
const promise = new Promise<void>((done) => {
|
|
164
|
-
resolve = done;
|
|
165
|
-
});
|
|
166
|
-
return { promise, resolve };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** A route transition never captures a long article, list, or off-screen heading. */
|
|
170
|
-
export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | null {
|
|
171
|
-
if (!(focus?.isConnected && focus.hasAttribute("data-stalux-motion-focus"))) return null;
|
|
172
|
-
const visible = (element: HTMLElement, maxHeight: number) => {
|
|
173
|
-
const rect = element.getBoundingClientRect();
|
|
174
|
-
return (
|
|
175
|
-
rect.width > 0 &&
|
|
176
|
-
rect.height > 0 &&
|
|
177
|
-
rect.height <= maxHeight &&
|
|
178
|
-
rect.top < window.innerHeight &&
|
|
179
|
-
rect.bottom > 0 &&
|
|
180
|
-
rect.left < window.innerWidth &&
|
|
181
|
-
rect.right > 0
|
|
182
|
-
);
|
|
183
|
-
};
|
|
184
|
-
if (!visible(focus, Math.min(320, window.innerHeight / 2))) return null;
|
|
185
|
-
const region = focus.closest?.<HTMLElement>("[data-stalux-motion-region]");
|
|
186
|
-
return region?.isConnected && visible(region, Math.min(420, window.innerHeight * 0.6))
|
|
187
|
-
? region
|
|
188
|
-
: focus;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const regionMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
192
|
-
glide: { opacity: 0.72, duration: 80 },
|
|
193
|
-
article: { opacity: 0.72, duration: 90, y: 4 },
|
|
194
|
-
enter: { opacity: 0.72, duration: 90, y: 6 },
|
|
195
|
-
up: { opacity: 0.8, duration: 80, y: -4 },
|
|
196
|
-
taxonomy: { opacity: 0.72, duration: 80, x: 6 },
|
|
197
|
-
previous: { opacity: 0.76, duration: 90, x: -8 },
|
|
198
|
-
next: { opacity: 0.76, duration: 90, x: 8 },
|
|
199
|
-
panel: { opacity: 0.76, duration: 80, y: 4 },
|
|
200
|
-
home: { opacity: 0.76, duration: 90, y: 4 },
|
|
201
|
-
restore: { opacity: 1, duration: 0 },
|
|
202
|
-
error: { opacity: 0.85, duration: 50 },
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
/** Share Swup's actual content animation with background and analytics consumers. */
|
|
206
|
-
export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
207
|
-
const native = event.viewTransition;
|
|
208
|
-
const key = native ?? event;
|
|
209
|
-
const existing = motions.get(key);
|
|
210
|
-
if (existing) return existing;
|
|
211
|
-
|
|
212
|
-
// Installation is a separate gate: before-swap consumers must not mistake
|
|
213
|
-
// the gap before Swup's incoming animation hook for a completed transition.
|
|
214
|
-
const installed = completion();
|
|
215
|
-
const animationDone = completion();
|
|
216
|
-
const canceled = completion();
|
|
217
|
-
let animation: Animation | undefined;
|
|
218
|
-
let started = false;
|
|
219
|
-
let disposed = false;
|
|
220
|
-
const clearAnimation = () => {
|
|
221
|
-
const active = animation;
|
|
222
|
-
animation = undefined;
|
|
223
|
-
try {
|
|
224
|
-
// Remove fill effects and restore the authored final styles. No
|
|
225
|
-
// inline transform/opacity survives completion, failure or abort.
|
|
226
|
-
active?.cancel();
|
|
227
|
-
} catch {
|
|
228
|
-
// Releasing an already detached effect must not hold up navigation.
|
|
229
|
-
}
|
|
230
|
-
animationDone.resolve();
|
|
231
|
-
};
|
|
232
|
-
const cancel = () => {
|
|
233
|
-
if (disposed) return;
|
|
234
|
-
disposed = true;
|
|
235
|
-
started = true;
|
|
236
|
-
clearAnimation();
|
|
237
|
-
installed.resolve();
|
|
238
|
-
canceled.resolve();
|
|
239
|
-
event.signal?.removeEventListener("abort", cancel);
|
|
240
|
-
};
|
|
241
|
-
const ready = Promise.race([
|
|
242
|
-
canceled.promise,
|
|
243
|
-
Promise.all([settle(native?.ready), installed.promise]).then(() => undefined),
|
|
244
|
-
]);
|
|
245
|
-
const finished = Promise.race([
|
|
246
|
-
canceled.promise,
|
|
247
|
-
Promise.all([settle(native?.finished), animationDone.promise]).then(() => undefined),
|
|
248
|
-
]);
|
|
249
|
-
const motion: RouteMotion = {
|
|
250
|
-
ready,
|
|
251
|
-
finished,
|
|
252
|
-
start(heading, options) {
|
|
253
|
-
if (started) return;
|
|
254
|
-
started = true;
|
|
255
|
-
// Every exit path must release the outgoing fill, or the swapped
|
|
256
|
-
// document stays transparent behind a cancelled cue.
|
|
257
|
-
const release = options?.releaseOutgoing;
|
|
258
|
-
try {
|
|
259
|
-
const preset = options?.preset ?? "glide";
|
|
260
|
-
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
261
|
-
if (
|
|
262
|
-
options?.skip ||
|
|
263
|
-
options?.browserMotion ||
|
|
264
|
-
preset === "none" ||
|
|
265
|
-
(options?.content && preset === "restore") ||
|
|
266
|
-
// History never replays an entry on a phone: the reader is
|
|
267
|
-
// returning to a position, not arriving somewhere new.
|
|
268
|
-
(mobile && preset === "restore") ||
|
|
269
|
-
document.hidden ||
|
|
270
|
-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
271
|
-
) {
|
|
272
|
-
release?.();
|
|
273
|
-
animationDone.resolve();
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const stage = options?.content ? getRouteStage() : null;
|
|
277
|
-
const target = stage ?? getRouteMotionTarget(heading);
|
|
278
|
-
if (!target || typeof target.animate !== "function") {
|
|
279
|
-
release?.();
|
|
280
|
-
animationDone.resolve();
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Cancelling the outgoing fill and starting the entry cue must
|
|
284
|
-
// share one synchronous block. Split across frames, the reader
|
|
285
|
-
// sees the new document at full opacity for a frame first.
|
|
286
|
-
release?.();
|
|
287
|
-
if (stage) {
|
|
288
|
-
const spec = stageMotions[preset];
|
|
289
|
-
// Phones get a shorter throw; the same offset reads as a much
|
|
290
|
-
// larger move on a narrow viewport.
|
|
291
|
-
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
292
|
-
const from =
|
|
293
|
-
spec.axis === "x"
|
|
294
|
-
? `translate3d(${distance}px, 0, 0)`
|
|
295
|
-
: `translate3d(0, ${distance}px, 0)`;
|
|
296
|
-
// A zero distance is a cross-fade in place. Authoring a
|
|
297
|
-
// translate3d(0,0,0) would put a 28,000px column on its own
|
|
298
|
-
// compositor layer for nothing.
|
|
299
|
-
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
300
|
-
if (distance !== 0) keyframes.transform = [from, "translate3d(0, 0, 0)"];
|
|
301
|
-
animation = stage.animate(keyframes, {
|
|
302
|
-
duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
|
|
303
|
-
// Strong ease-out: quick arrival, soft settle. The
|
|
304
|
-
// built-in ease-out is too weak to read at this size.
|
|
305
|
-
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
306
|
-
fill: "both",
|
|
307
|
-
});
|
|
308
|
-
} else {
|
|
309
|
-
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
310
|
-
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
311
|
-
// Phones use a pure fade. Explicit previous/next alone imply a
|
|
312
|
-
// lateral reading direction; history never replays the entry.
|
|
313
|
-
if (!mobile && (spec.x || spec.y)) {
|
|
314
|
-
keyframes.transform = [
|
|
315
|
-
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
316
|
-
"translate3d(0px, 0px, 0)",
|
|
317
|
-
];
|
|
318
|
-
}
|
|
319
|
-
animation = target.animate(keyframes, {
|
|
320
|
-
duration: mobile ? (options?.content ? 60 : 40) : spec.duration,
|
|
321
|
-
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
322
|
-
fill: "both",
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
void animation.finished.then(clearAnimation, clearAnimation);
|
|
326
|
-
} catch {
|
|
327
|
-
release?.();
|
|
328
|
-
clearAnimation();
|
|
329
|
-
} finally {
|
|
330
|
-
installed.resolve();
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
cancel,
|
|
334
|
-
};
|
|
335
|
-
motions.set(key, motion);
|
|
336
|
-
if (event.signal?.aborted) cancel();
|
|
337
|
-
else event.signal?.addEventListener("abort", cancel, { once: true });
|
|
338
|
-
void finished.then(() => event.signal?.removeEventListener("abort", cancel));
|
|
339
|
-
return motion;
|
|
340
|
-
}
|
package/src/scripts/route-out.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { RouteMotionPreset } from "../utils/route-motion-policy";
|
|
2
|
-
import { getRouteStage, getStageExit } from "./route-motion";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Fade and shift the current content stage out, continuing the entry direction
|
|
6
|
-
* so the two halves read as one movement instead of a flash.
|
|
7
|
-
*
|
|
8
|
-
* The returned release keeps the fill effect until the entry cue takes over.
|
|
9
|
-
* Cancelling here would expose the freshly swapped document at full opacity for
|
|
10
|
-
* a frame before it animates in — the exact flash this replaces.
|
|
11
|
-
*/
|
|
12
|
-
export async function animateRouteOut(
|
|
13
|
-
preset: RouteMotionPreset,
|
|
14
|
-
signal: AbortSignal,
|
|
15
|
-
): Promise<() => void> {
|
|
16
|
-
const spec = getStageExit(preset);
|
|
17
|
-
const stage = spec ? getRouteStage() : null;
|
|
18
|
-
if (
|
|
19
|
-
!(spec && stage) ||
|
|
20
|
-
signal.aborted ||
|
|
21
|
-
document.hidden ||
|
|
22
|
-
typeof stage.animate !== "function" ||
|
|
23
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
24
|
-
)
|
|
25
|
-
return () => {};
|
|
26
|
-
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
|
27
|
-
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
28
|
-
const to =
|
|
29
|
-
spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
|
|
30
|
-
// Article-to-article has no distance: the column fades out in place, matching
|
|
31
|
-
// the entry fade, so the shell stays put and only the content disappears.
|
|
32
|
-
const keyframes: PropertyIndexedKeyframes = { opacity: [1, 0] };
|
|
33
|
-
if (distance !== 0) keyframes.transform = ["translate3d(0, 0, 0)", to];
|
|
34
|
-
let animation: Animation | undefined;
|
|
35
|
-
const cancel = () => {
|
|
36
|
-
const active = animation;
|
|
37
|
-
animation = undefined;
|
|
38
|
-
try {
|
|
39
|
-
active?.cancel();
|
|
40
|
-
} catch {
|
|
41
|
-
/* An already detached effect cannot be released further. */
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
try {
|
|
45
|
-
animation = stage.animate(keyframes, {
|
|
46
|
-
duration: mobile ? Math.round(spec.duration * 0.62) : Math.round(spec.duration * 0.58),
|
|
47
|
-
// Strong ease-in: the outgoing document accelerates away. An exit
|
|
48
|
-
// is the one place ease-in is correct; entrances use ease-out.
|
|
49
|
-
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
50
|
-
fill: "forwards",
|
|
51
|
-
});
|
|
52
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
53
|
-
await animation.finished;
|
|
54
|
-
// The stage node survives the swap, so this filled effect is what holds
|
|
55
|
-
// the incoming document hidden until its own cue starts.
|
|
56
|
-
} catch {
|
|
57
|
-
/* Cancellation and unsupported animation must not block a visit. */
|
|
58
|
-
}
|
|
59
|
-
return () => {
|
|
60
|
-
signal.removeEventListener("abort", cancel);
|
|
61
|
-
cancel();
|
|
62
|
-
};
|
|
63
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
let stopActive: (() => void) | undefined;
|
|
2
|
-
|
|
3
|
-
export function cancelPageScroll(): void {
|
|
4
|
-
stopActive?.();
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface PageGlide {
|
|
8
|
-
/** Settles when the page reaches the top, is interrupted, or is canceled. */
|
|
9
|
-
promise: Promise<void>;
|
|
10
|
-
/** Stop immediately at the current position without further frames. */
|
|
11
|
-
cancel: () => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Bound the pre-swap return-to-top so a 6000px article does not add a second. */
|
|
15
|
-
const GLIDE_MIN_MS = 80;
|
|
16
|
-
const GLIDE_MAX_MS = 180;
|
|
17
|
-
const GLIDE_SAFETY_MS = 380;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Walk the current document back to the top before the container is replaced.
|
|
21
|
-
* Swup's own between-page reset is instant; animating it after the swap would
|
|
22
|
-
* make the new article start wherever the old page happened to be scrolled.
|
|
23
|
-
* Doing it on the outgoing document keeps the incoming page at its real top.
|
|
24
|
-
*/
|
|
25
|
-
export function glideToTop(): PageGlide {
|
|
26
|
-
const element = document.scrollingElement ?? document.documentElement;
|
|
27
|
-
const startTop = Math.max(0, element.scrollTop || window.scrollY || 0);
|
|
28
|
-
const startLeft = Math.max(0, element.scrollLeft || window.scrollX || 0);
|
|
29
|
-
let resolve = () => {};
|
|
30
|
-
const promise = new Promise<void>((done) => {
|
|
31
|
-
resolve = done;
|
|
32
|
-
});
|
|
33
|
-
if (
|
|
34
|
-
startTop < 1 ||
|
|
35
|
-
typeof requestAnimationFrame !== "function" ||
|
|
36
|
-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
37
|
-
document.hidden
|
|
38
|
-
) {
|
|
39
|
-
resolve();
|
|
40
|
-
return { promise, cancel: resolve };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Longer documents take proportionally longer, but the ceiling keeps the
|
|
44
|
-
// whole cue inside the same budget as the content fade.
|
|
45
|
-
const duration = Math.min(GLIDE_MAX_MS, GLIDE_MIN_MS + startTop / 20);
|
|
46
|
-
let frame: number | undefined;
|
|
47
|
-
let settled = false;
|
|
48
|
-
const release = () => {
|
|
49
|
-
window.removeEventListener("wheel", finish);
|
|
50
|
-
window.removeEventListener("touchstart", finish);
|
|
51
|
-
window.removeEventListener("keydown", finish);
|
|
52
|
-
clearTimeout(safety);
|
|
53
|
-
if (stopActive === finish) stopActive = undefined;
|
|
54
|
-
};
|
|
55
|
-
const finish = () => {
|
|
56
|
-
if (settled) return;
|
|
57
|
-
settled = true;
|
|
58
|
-
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
59
|
-
release();
|
|
60
|
-
resolve();
|
|
61
|
-
};
|
|
62
|
-
// A wheel/touch/key press means the reader reclaimed the scroll; stop at
|
|
63
|
-
// the frame already rendered instead of fighting the gesture.
|
|
64
|
-
const safety = setTimeout(finish, GLIDE_SAFETY_MS);
|
|
65
|
-
stopActive = finish;
|
|
66
|
-
window.addEventListener("wheel", finish, { passive: true, once: true });
|
|
67
|
-
window.addEventListener("touchstart", finish, { passive: true, once: true });
|
|
68
|
-
window.addEventListener("keydown", finish, { once: true });
|
|
69
|
-
|
|
70
|
-
const begin = performance.now();
|
|
71
|
-
let frames = 0;
|
|
72
|
-
const step = (now: number) => {
|
|
73
|
-
if (settled) return;
|
|
74
|
-
// Frame ceiling: a host that invokes rAF synchronously must not turn
|
|
75
|
-
// this loop into unbounded recursion. Real browsers reach the safety
|
|
76
|
-
// timeout first; twenty frames cover the 320ms budget at 60Hz.
|
|
77
|
-
if (++frames > 240) {
|
|
78
|
-
finish();
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const progress = Math.min(1, Math.max(0, (now - begin) / duration));
|
|
82
|
-
// Quadratic ease-out: reads as a scroll rather than a snap, and still
|
|
83
|
-
// spends most of its time near the top where the article begins.
|
|
84
|
-
const remaining = (1 - progress) ** 2;
|
|
85
|
-
element.scrollTop = Math.round(startTop * remaining);
|
|
86
|
-
element.scrollLeft = Math.round(startLeft * remaining);
|
|
87
|
-
if (progress < 1) frame = requestAnimationFrame(step);
|
|
88
|
-
else finish();
|
|
89
|
-
};
|
|
90
|
-
frame = requestAnimationFrame(step);
|
|
91
|
-
return { promise, cancel: finish };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Native scrolling with bounded completion and explicit listener cleanup. */
|
|
95
|
-
export function scrollPage(
|
|
96
|
-
element: Element,
|
|
97
|
-
top: number,
|
|
98
|
-
left: number,
|
|
99
|
-
animate: boolean,
|
|
100
|
-
start: () => void,
|
|
101
|
-
end: () => void,
|
|
102
|
-
): void {
|
|
103
|
-
stopActive?.();
|
|
104
|
-
start();
|
|
105
|
-
const smooth = animate && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
106
|
-
if (
|
|
107
|
-
!smooth ||
|
|
108
|
-
(Math.abs(element.scrollTop - top) < 1 && Math.abs(element.scrollLeft - left) < 1)
|
|
109
|
-
) {
|
|
110
|
-
element.scrollTo({ top, left, behavior: "instant" });
|
|
111
|
-
end();
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const target =
|
|
115
|
-
element === document.documentElement || element === document.body ? window : element;
|
|
116
|
-
let finished = false;
|
|
117
|
-
const done = () => {
|
|
118
|
-
if (finished) return;
|
|
119
|
-
finished = true;
|
|
120
|
-
clearTimeout(timer);
|
|
121
|
-
target.removeEventListener("scrollend", done);
|
|
122
|
-
window.removeEventListener("wheel", interrupt);
|
|
123
|
-
window.removeEventListener("touchstart", interrupt);
|
|
124
|
-
window.removeEventListener("keydown", interrupt);
|
|
125
|
-
if (stopActive === interrupt) stopActive = undefined;
|
|
126
|
-
end();
|
|
127
|
-
};
|
|
128
|
-
const interrupt = () => {
|
|
129
|
-
element.scrollTo({ top: element.scrollTop, left: element.scrollLeft, behavior: "instant" });
|
|
130
|
-
done();
|
|
131
|
-
};
|
|
132
|
-
const timer = setTimeout(done, 1500);
|
|
133
|
-
stopActive = interrupt;
|
|
134
|
-
target.addEventListener("scrollend", done, { once: true });
|
|
135
|
-
window.addEventListener("wheel", interrupt, { passive: true, once: true });
|
|
136
|
-
window.addEventListener("touchstart", interrupt, { passive: true, once: true });
|
|
137
|
-
window.addEventListener("keydown", interrupt, { once: true });
|
|
138
|
-
element.scrollTo({ top, left, behavior: "smooth" });
|
|
139
|
-
}
|