@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.
@@ -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
- }
@@ -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
- }