-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
/
ViewTransitions.astro
391 lines (359 loc) · 12.7 KB
/
ViewTransitions.astro
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
---
type Fallback = 'none' | 'animate' | 'swap';
export interface Props {
fallback?: Fallback;
}
const { fallback = 'animate' } = Astro.props as Props;
---
<meta name="astro-view-transitions-enabled" content="true" />
<meta name="astro-view-transitions-fallback" content={fallback} />
<script>
type Fallback = 'none' | 'animate' | 'swap';
type Direction = 'forward' | 'back';
type State = {
index: number;
scrollY: number;
};
type Events = 'astro:page-load' | 'astro:after-swap';
const persistState = (state: State) => history.replaceState(state, '');
const supportsViewTransitions = !!document.startViewTransition;
const transitionEnabledOnThisPage = () =>
!!document.querySelector('[name="astro-view-transitions-enabled"]');
const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
const onPageLoad = () => triggerEvent('astro:page-load');
const PERSIST_ATTR = 'data-astro-transition-persist';
// The History API does not tell you if navigation is forward or back, so
// you can figure it using an index. On pushState the index is incremented so you
// can use that to determine popstate if going forward or back.
let currentHistoryIndex = history.state?.index || 0;
if (!history.state && transitionEnabledOnThisPage()) {
persistState({ index: currentHistoryIndex, scrollY: 0 });
}
const throttle = (cb: (...args: any[]) => any, delay: number) => {
let wait = false;
// During the waiting time additional events are lost.
// So repeat the callback at the end if we have swallowed events.
let onceMore = false;
return (...args: any[]) => {
if (wait) {
onceMore = true;
return;
}
cb(...args);
wait = true;
setTimeout(() => {
if (onceMore) {
onceMore = false;
cb(...args);
}
wait = false;
}, delay);
};
};
async function getHTML(href: string) {
const res = await fetch(href);
const html = await res.text();
return { ok: res.ok, html };
}
function getFallback(): Fallback {
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
if (el) {
return el.getAttribute('content') as Fallback;
}
return 'animate';
}
function markScriptsExec() {
for (const script of document.scripts) {
script.dataset.astroExec = '';
}
}
function runScripts() {
let wait = Promise.resolve();
for (const script of Array.from(document.scripts)) {
if (script.dataset.astroExec === '') continue;
const s = document.createElement('script');
s.innerHTML = script.innerHTML;
for (const attr of script.attributes) {
if (attr.name === 'src') {
const p = new Promise((r) => {
s.onload = r;
});
wait = wait.then(() => p as any);
}
s.setAttribute(attr.name, attr.value);
}
s.dataset.astroExec = '';
script.replaceWith(s);
}
return wait;
}
const parser = new DOMParser();
async function updateDOM(html: string, state?: State, fallback?: Fallback) {
const doc = parser.parseFromString(html, 'text/html');
// Check for a head element that should persist, either because it has the data
// attribute or is a link el.
const persistedHeadElement = (el: Element): Element | null => {
const id = el.getAttribute(PERSIST_ATTR);
const newEl = id && doc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
if (newEl) {
return newEl;
}
if (el.matches('link[rel=stylesheet]')) {
const href = el.getAttribute('href');
return doc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
}
if (el.tagName === 'SCRIPT') {
let s1 = el as HTMLScriptElement;
for (const s2 of doc.scripts) {
if (
// Inline
(s1.textContent && s1.textContent === s2.textContent) ||
// External
(s1.type === s2.type && s1.src === s2.src)
) {
return s2;
}
}
}
return null;
};
const swap = () => {
// noscript tags inside head element are not honored on swap (#7969).
// Remove them before swapping.
doc.querySelectorAll('head noscript').forEach((el) => el.remove());
// swap attributes of the html element
// - delete all attributes from the current document
// - insert all attributes from doc
// - reinsert all original attributes that are named 'data-astro-*'
const html = document.documentElement;
const astro = [...html.attributes].filter(
({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-'))
);
[...doc.documentElement.attributes, ...astro].forEach(({ name, value }) =>
html.setAttribute(name, value)
);
// Swap head
for (const el of Array.from(document.head.children)) {
const newEl = persistedHeadElement(el);
// If the element exists in the document already, remove it
// from the new document and leave the current node alone
if (newEl) {
newEl.remove();
} else {
// Otherwise remove the element in the head. It doesn't exist in the new page.
el.remove();
}
}
// Everything left in the new head is new, append it all.
document.head.append(...doc.head.children);
// Persist elements in the existing body
const oldBody = document.body;
document.body.replaceWith(doc.body);
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
const id = el.getAttribute(PERSIST_ATTR);
const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`);
if (newEl) {
// The element exists in the new page, replace it with the element
// from the old page so that state is preserved.
newEl.replaceWith(el);
}
}
// Simulate scroll behavior of Safari and
// Chromium based browsers (Chrome, Edge, Opera, ...)
scrollTo({ left: 0, top: 0, behavior: 'instant' });
if (state?.scrollY === 0 && location.hash) {
const id = decodeURIComponent(location.hash.slice(1));
const elem = document.getElementById(id);
// prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account
if (elem) {
state.scrollY = elem.offsetTop;
persistState(state); // first guess, later updated by scroll handler
elem.scrollIntoView(); // for Firefox, this should better be {behavior: 'instant'}
}
} else if (state && state.scrollY !== 0) {
scrollTo(0, state.scrollY); // usings default scrollBehavior
}
triggerEvent('astro:after-swap');
};
// Wait on links to finish, to prevent FOUC
const links: Promise<any>[] = [];
for (const el of doc.querySelectorAll('head link[rel=stylesheet]')) {
// Do not preload links that are already on the page.
if (
!document.querySelector(
`[${PERSIST_ATTR}="${el.getAttribute(PERSIST_ATTR)}"], link[rel=stylesheet]`
)
) {
const c = document.createElement('link');
c.setAttribute('rel', 'preload');
c.setAttribute('as', 'style');
c.setAttribute('href', el.getAttribute('href')!);
links.push(
new Promise<any>((resolve) => {
['load', 'error'].forEach((evName) => c.addEventListener(evName, resolve));
document.head.append(c);
})
);
}
}
links.length && (await Promise.all(links));
if (fallback === 'animate') {
// Trigger the animations
document.documentElement.dataset.astroTransitionFallback = 'old';
const finished = Promise.all(document.getAnimations().map(a => a.finished));
const fallbackSwap = () => {
swap();
document.documentElement.dataset.astroTransitionFallback = 'new';
};
await finished;
fallbackSwap();
} else {
swap();
}
}
async function navigate(dir: Direction, href: string, state?: State) {
let finished: Promise<void>;
const { html, ok } = await getHTML(href);
// If there is a problem fetching the new page, just do an MPA navigation to it.
if (!ok) {
location.href = href;
return;
}
document.documentElement.dataset.astroTransition = dir;
if (supportsViewTransitions) {
finished = document.startViewTransition(() => updateDOM(html, state)).finished;
} else {
finished = updateDOM(html, state, getFallback());
}
try {
await finished;
} finally {
// skip this for the moment as it tends to stop fallback animations
// document.documentElement.removeAttribute('data-astro-transition');
await runScripts();
markScriptsExec();
onPageLoad();
}
}
// Prefetching
function maybePrefetch(pathname: string) {
if (document.querySelector(`link[rel=prefetch][href="${pathname}"]`)) return;
if (navigator.connection) {
let conn = navigator.connection;
if (conn.saveData || /(2|3)g/.test(conn.effectiveType || '')) return;
}
let link = document.createElement('link');
link.setAttribute('rel', 'prefetch');
link.setAttribute('href', pathname);
document.head.append(link);
}
if (supportsViewTransitions || getFallback() !== 'none') {
markScriptsExec();
document.addEventListener('click', (ev) => {
let link = ev.target;
if (link instanceof Element && link.tagName !== 'A') {
link = link.closest('a');
}
// This check verifies that the click is happening on an anchor
// that is going to another page within the same origin. Basically it determines
// same-origin navigation, but omits special key combos for new tabs, etc.
if (
!link ||
!(link instanceof HTMLAnchorElement) ||
link.dataset.astroReload !== undefined ||
!link.href ||
(link.target && link.target !== '_self') ||
link.origin !== location.origin ||
ev.button !== 0 || // left clicks only
ev.metaKey || // new tab (mac)
ev.ctrlKey || // new tab (windows)
ev.altKey || // download
ev.shiftKey || // new window
ev.defaultPrevented ||
!transitionEnabledOnThisPage()
)
// No page transitions in these cases,
// Let the browser standard action handle this
return;
// We do not need to handle same page links because there are no page transitions
// Same page means same path and same query params (but different hash)
if (location.pathname === link.pathname && location.search === link.search) {
if (link.hash) {
// The browser default action will handle navigations with hash fragments
return;
} else {
// Special case: self link without hash
// If handed to the browser it will reload the page
// But we want to handle it like any other same page navigation
// So we scroll to the top of the page but do not start page transitions
ev.preventDefault();
persistState({ ...history.state, scrollY });
scrollTo({ left: 0, top: 0, behavior: 'instant' });
if (location.hash) {
// last target was different
const newState: State = { index: ++currentHistoryIndex, scrollY: 0 };
history.pushState(newState, '', link.href);
}
return;
}
}
// these are the cases we will handle: same origin, different page
ev.preventDefault();
navigate('forward', link.href, { index: ++currentHistoryIndex, scrollY: 0 });
const newState: State = { index: currentHistoryIndex, scrollY };
persistState({ index: currentHistoryIndex - 1, scrollY });
history.pushState(newState, '', link.href);
});
addEventListener('popstate', (ev) => {
if (!transitionEnabledOnThisPage() && ev.state) {
// The current page doesn't haven't View Transitions,
// respect that with a full page reload
// -- but only for transition managed by us (ev.state is set)
location.reload();
return;
}
// History entries without state are created by the browser (e.g. for hash links)
// Our view transition entries always have state.
// Just ignore stateless entries.
// The browser will handle navigation fine without our help
if (ev.state === null) {
return;
}
const state: State | undefined = history.state;
const nextIndex = state?.index ?? currentHistoryIndex + 1;
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
navigate(direction, location.href, state);
currentHistoryIndex = nextIndex;
});
['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
document.addEventListener(
evName,
(ev) => {
if (ev.target instanceof HTMLAnchorElement) {
let el = ev.target;
if (
el.origin === location.origin &&
el.pathname !== location.pathname &&
transitionEnabledOnThisPage()
) {
maybePrefetch(el.pathname);
}
}
},
{ passive: true, capture: true }
);
});
addEventListener('load', onPageLoad);
// There's not a good way to record scroll position before a back button.
// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
const updateState = () => {
// only update history entries that are managed by us
// leave other entries alone and do not accidently add state.
if (history.state) {
persistState({ ...history.state, scrollY });
}
}
if ('onscrollend' in window) addEventListener('scrollend', updateState);
else addEventListener('scroll', throttle(updateState, 300));
}
</script>