480 lines
16 KiB
Plaintext
480 lines
16 KiB
Plaintext
|
import { TRANSITION_AFTER_SWAP, doPreparation, doSwap } from "./events.js";
|
||
|
const inBrowser = import.meta.env.SSR === false;
|
||
|
const pushState = inBrowser && history.pushState.bind(history);
|
||
|
const replaceState = inBrowser && history.replaceState.bind(history);
|
||
|
const updateScrollPosition = (positions) => {
|
||
|
if (history.state) {
|
||
|
history.scrollRestoration = "manual";
|
||
|
replaceState({ ...history.state, ...positions }, "");
|
||
|
}
|
||
|
};
|
||
|
const supportsViewTransitions = inBrowser && !!document.startViewTransition;
|
||
|
const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]');
|
||
|
const samePage = (thisLocation, otherLocation) => thisLocation.pathname === otherLocation.pathname && thisLocation.search === otherLocation.search;
|
||
|
let originalLocation;
|
||
|
let viewTransition;
|
||
|
let skipTransition = false;
|
||
|
let viewTransitionFinished;
|
||
|
const triggerEvent = (name) => document.dispatchEvent(new Event(name));
|
||
|
const onPageLoad = () => triggerEvent("astro:page-load");
|
||
|
const announce = () => {
|
||
|
let div = document.createElement("div");
|
||
|
div.setAttribute("aria-live", "assertive");
|
||
|
div.setAttribute("aria-atomic", "true");
|
||
|
div.className = "astro-route-announcer";
|
||
|
document.body.append(div);
|
||
|
setTimeout(
|
||
|
() => {
|
||
|
let title = document.title || document.querySelector("h1")?.textContent || location.pathname;
|
||
|
div.textContent = title;
|
||
|
},
|
||
|
// Much thought went into this magic number; the gist is that screen readers
|
||
|
// need to see that the element changed and might not do so if it happens
|
||
|
// too quickly.
|
||
|
60
|
||
|
);
|
||
|
};
|
||
|
const PERSIST_ATTR = "data-astro-transition-persist";
|
||
|
const DIRECTION_ATTR = "data-astro-transition";
|
||
|
const OLD_NEW_ATTR = "data-astro-transition-fallback";
|
||
|
const VITE_ID = "data-vite-dev-id";
|
||
|
let parser;
|
||
|
let currentHistoryIndex = 0;
|
||
|
if (inBrowser) {
|
||
|
if (history.state) {
|
||
|
currentHistoryIndex = history.state.index;
|
||
|
scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
|
||
|
} else if (transitionEnabledOnThisPage()) {
|
||
|
replaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
|
||
|
history.scrollRestoration = "manual";
|
||
|
}
|
||
|
}
|
||
|
const throttle = (cb, delay) => {
|
||
|
let wait = false;
|
||
|
let onceMore = false;
|
||
|
return (...args) => {
|
||
|
if (wait) {
|
||
|
onceMore = true;
|
||
|
return;
|
||
|
}
|
||
|
cb(...args);
|
||
|
wait = true;
|
||
|
setTimeout(() => {
|
||
|
if (onceMore) {
|
||
|
onceMore = false;
|
||
|
cb(...args);
|
||
|
}
|
||
|
wait = false;
|
||
|
}, delay);
|
||
|
};
|
||
|
};
|
||
|
async function fetchHTML(href, init) {
|
||
|
try {
|
||
|
const res = await fetch(href, init);
|
||
|
const contentType = res.headers.get("content-type") ?? "";
|
||
|
const mediaType = contentType.split(";", 1)[0].trim();
|
||
|
if (mediaType !== "text/html" && mediaType !== "application/xhtml+xml") {
|
||
|
return null;
|
||
|
}
|
||
|
const html = await res.text();
|
||
|
return {
|
||
|
html,
|
||
|
redirected: res.redirected ? res.url : void 0,
|
||
|
mediaType
|
||
|
};
|
||
|
} catch (err) {
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
function getFallback() {
|
||
|
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
|
||
|
if (el) {
|
||
|
return el.getAttribute("content");
|
||
|
}
|
||
|
return "animate";
|
||
|
}
|
||
|
function runScripts() {
|
||
|
let wait = Promise.resolve();
|
||
|
for (const script of Array.from(document.scripts)) {
|
||
|
if (script.dataset.astroExec === "")
|
||
|
continue;
|
||
|
const type = script.getAttribute("type");
|
||
|
if (type && type !== "module" && type !== "text/javascript")
|
||
|
continue;
|
||
|
const newScript = document.createElement("script");
|
||
|
newScript.innerHTML = script.innerHTML;
|
||
|
for (const attr of script.attributes) {
|
||
|
if (attr.name === "src") {
|
||
|
const p = new Promise((r) => {
|
||
|
newScript.onload = newScript.onerror = r;
|
||
|
});
|
||
|
wait = wait.then(() => p);
|
||
|
}
|
||
|
newScript.setAttribute(attr.name, attr.value);
|
||
|
}
|
||
|
newScript.dataset.astroExec = "";
|
||
|
script.replaceWith(newScript);
|
||
|
}
|
||
|
return wait;
|
||
|
}
|
||
|
const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historyState) => {
|
||
|
const intraPage = samePage(from, to);
|
||
|
const targetPageTitle = document.title;
|
||
|
document.title = pageTitleForBrowserHistory;
|
||
|
let scrolledToTop = false;
|
||
|
if (to.href !== location.href && !historyState) {
|
||
|
if (options.history === "replace") {
|
||
|
const current = history.state;
|
||
|
replaceState(
|
||
|
{
|
||
|
...options.state,
|
||
|
index: current.index,
|
||
|
scrollX: current.scrollX,
|
||
|
scrollY: current.scrollY
|
||
|
},
|
||
|
"",
|
||
|
to.href
|
||
|
);
|
||
|
} else {
|
||
|
pushState(
|
||
|
{ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
|
||
|
"",
|
||
|
to.href
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
originalLocation = to;
|
||
|
if (!intraPage) {
|
||
|
scrollTo({ left: 0, top: 0, behavior: "instant" });
|
||
|
scrolledToTop = true;
|
||
|
}
|
||
|
if (historyState) {
|
||
|
scrollTo(historyState.scrollX, historyState.scrollY);
|
||
|
} else {
|
||
|
if (to.hash) {
|
||
|
history.scrollRestoration = "auto";
|
||
|
const savedState = history.state;
|
||
|
location.href = to.href;
|
||
|
history.state || replaceState(savedState, "");
|
||
|
} else {
|
||
|
if (!scrolledToTop) {
|
||
|
scrollTo({ left: 0, top: 0, behavior: "instant" });
|
||
|
}
|
||
|
}
|
||
|
history.scrollRestoration = "manual";
|
||
|
}
|
||
|
document.title = targetPageTitle;
|
||
|
};
|
||
|
function preloadStyleLinks(newDocument) {
|
||
|
const links = [];
|
||
|
for (const el of newDocument.querySelectorAll("head link[rel=stylesheet]")) {
|
||
|
if (!document.querySelector(
|
||
|
`[${PERSIST_ATTR}="${el.getAttribute(
|
||
|
PERSIST_ATTR
|
||
|
)}"], link[rel=stylesheet][href="${el.getAttribute("href")}"]`
|
||
|
)) {
|
||
|
const c = document.createElement("link");
|
||
|
c.setAttribute("rel", "preload");
|
||
|
c.setAttribute("as", "style");
|
||
|
c.setAttribute("href", el.getAttribute("href"));
|
||
|
links.push(
|
||
|
new Promise((resolve) => {
|
||
|
["load", "error"].forEach((evName) => c.addEventListener(evName, resolve));
|
||
|
document.head.append(c);
|
||
|
})
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
return links;
|
||
|
}
|
||
|
async function updateDOM(preparationEvent, options, historyState, fallback) {
|
||
|
const persistedHeadElement = (el, newDoc) => {
|
||
|
const id = el.getAttribute(PERSIST_ATTR);
|
||
|
const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
|
||
|
if (newEl) {
|
||
|
return newEl;
|
||
|
}
|
||
|
if (el.matches("link[rel=stylesheet]")) {
|
||
|
const href = el.getAttribute("href");
|
||
|
return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
|
||
|
}
|
||
|
return null;
|
||
|
};
|
||
|
const saveFocus = () => {
|
||
|
const activeElement = document.activeElement;
|
||
|
if (activeElement?.closest(`[${PERSIST_ATTR}]`)) {
|
||
|
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
|
||
|
const start = activeElement.selectionStart;
|
||
|
const end = activeElement.selectionEnd;
|
||
|
return { activeElement, start, end };
|
||
|
}
|
||
|
return { activeElement };
|
||
|
} else {
|
||
|
return { activeElement: null };
|
||
|
}
|
||
|
};
|
||
|
const restoreFocus = ({ activeElement, start, end }) => {
|
||
|
if (activeElement) {
|
||
|
activeElement.focus();
|
||
|
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
|
||
|
activeElement.selectionStart = start;
|
||
|
activeElement.selectionEnd = end;
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
const defaultSwap = (beforeSwapEvent) => {
|
||
|
const html = document.documentElement;
|
||
|
const astroAttributes = [...html.attributes].filter(
|
||
|
({ name }) => (html.removeAttribute(name), name.startsWith("data-astro-"))
|
||
|
);
|
||
|
[...beforeSwapEvent.newDocument.documentElement.attributes, ...astroAttributes].forEach(
|
||
|
({ name, value }) => html.setAttribute(name, value)
|
||
|
);
|
||
|
for (const s1 of document.scripts) {
|
||
|
for (const s2 of beforeSwapEvent.newDocument.scripts) {
|
||
|
if (
|
||
|
// Inline
|
||
|
!s1.src && s1.textContent === s2.textContent || // External
|
||
|
s1.src && s1.type === s2.type && s1.src === s2.src
|
||
|
) {
|
||
|
s2.dataset.astroExec = "";
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
for (const el of Array.from(document.head.children)) {
|
||
|
const newEl = persistedHeadElement(el, beforeSwapEvent.newDocument);
|
||
|
if (newEl) {
|
||
|
newEl.remove();
|
||
|
} else {
|
||
|
el.remove();
|
||
|
}
|
||
|
}
|
||
|
document.head.append(...beforeSwapEvent.newDocument.head.children);
|
||
|
const oldBody = document.body;
|
||
|
const savedFocus = saveFocus();
|
||
|
document.body.replaceWith(beforeSwapEvent.newDocument.body);
|
||
|
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
|
||
|
const id = el.getAttribute(PERSIST_ATTR);
|
||
|
const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`);
|
||
|
if (newEl) {
|
||
|
newEl.replaceWith(el);
|
||
|
}
|
||
|
}
|
||
|
restoreFocus(savedFocus);
|
||
|
};
|
||
|
async function animate(phase) {
|
||
|
function isInfinite(animation) {
|
||
|
const effect = animation.effect;
|
||
|
if (!effect || !(effect instanceof KeyframeEffect) || !effect.target)
|
||
|
return false;
|
||
|
const style = window.getComputedStyle(effect.target, effect.pseudoElement);
|
||
|
return style.animationIterationCount === "infinite";
|
||
|
}
|
||
|
const currentAnimations = document.getAnimations();
|
||
|
document.documentElement.setAttribute(OLD_NEW_ATTR, phase);
|
||
|
const nextAnimations = document.getAnimations();
|
||
|
const newAnimations = nextAnimations.filter(
|
||
|
(a) => !currentAnimations.includes(a) && !isInfinite(a)
|
||
|
);
|
||
|
return Promise.all(newAnimations.map((a) => a.finished));
|
||
|
}
|
||
|
if (!skipTransition) {
|
||
|
document.documentElement.setAttribute(DIRECTION_ATTR, preparationEvent.direction);
|
||
|
if (fallback === "animate") {
|
||
|
await animate("old");
|
||
|
}
|
||
|
} else {
|
||
|
throw new DOMException("Transition was skipped");
|
||
|
}
|
||
|
const pageTitleForBrowserHistory = document.title;
|
||
|
const swapEvent = await doSwap(preparationEvent, viewTransition, defaultSwap);
|
||
|
moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
|
||
|
triggerEvent(TRANSITION_AFTER_SWAP);
|
||
|
if (fallback === "animate" && !skipTransition) {
|
||
|
animate("new").then(() => viewTransitionFinished());
|
||
|
}
|
||
|
}
|
||
|
async function transition(direction, from, to, options, historyState) {
|
||
|
if (!transitionEnabledOnThisPage() || location.origin !== to.origin) {
|
||
|
location.href = to.href;
|
||
|
return;
|
||
|
}
|
||
|
const navigationType = historyState ? "traverse" : options.history === "replace" ? "replace" : "push";
|
||
|
if (navigationType !== "traverse") {
|
||
|
updateScrollPosition({ scrollX, scrollY });
|
||
|
}
|
||
|
if (samePage(from, to)) {
|
||
|
if (direction !== "back" && to.hash || direction === "back" && from.hash) {
|
||
|
moveToLocation(to, from, options, document.title, historyState);
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
const prepEvent = await doPreparation(
|
||
|
from,
|
||
|
to,
|
||
|
direction,
|
||
|
navigationType,
|
||
|
options.sourceElement,
|
||
|
options.info,
|
||
|
options.formData,
|
||
|
defaultLoader
|
||
|
);
|
||
|
if (prepEvent.defaultPrevented) {
|
||
|
location.href = to.href;
|
||
|
return;
|
||
|
}
|
||
|
async function defaultLoader(preparationEvent) {
|
||
|
const href = preparationEvent.to.href;
|
||
|
const init = {};
|
||
|
if (preparationEvent.formData) {
|
||
|
init.method = "POST";
|
||
|
const form = preparationEvent.sourceElement instanceof HTMLFormElement ? preparationEvent.sourceElement : preparationEvent.sourceElement instanceof HTMLElement && "form" in preparationEvent.sourceElement ? preparationEvent.sourceElement.form : preparationEvent.sourceElement?.closest("form");
|
||
|
init.body = form?.attributes.getNamedItem("enctype")?.value === "application/x-www-form-urlencoded" ? new URLSearchParams(preparationEvent.formData) : preparationEvent.formData;
|
||
|
}
|
||
|
const response = await fetchHTML(href, init);
|
||
|
if (response === null) {
|
||
|
preparationEvent.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
if (response.redirected) {
|
||
|
preparationEvent.to = new URL(response.redirected);
|
||
|
}
|
||
|
parser ??= new DOMParser();
|
||
|
preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
|
||
|
preparationEvent.newDocument.querySelectorAll("noscript").forEach((el) => el.remove());
|
||
|
if (!preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !preparationEvent.formData) {
|
||
|
preparationEvent.preventDefault();
|
||
|
return;
|
||
|
}
|
||
|
const links = preloadStyleLinks(preparationEvent.newDocument);
|
||
|
links.length && await Promise.all(links);
|
||
|
if (import.meta.env.DEV)
|
||
|
await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to);
|
||
|
}
|
||
|
skipTransition = false;
|
||
|
if (supportsViewTransitions) {
|
||
|
viewTransition = document.startViewTransition(
|
||
|
async () => await updateDOM(prepEvent, options, historyState)
|
||
|
);
|
||
|
} else {
|
||
|
const updateDone = (async () => {
|
||
|
await new Promise((r) => setTimeout(r));
|
||
|
await updateDOM(prepEvent, options, historyState, getFallback());
|
||
|
})();
|
||
|
viewTransition = {
|
||
|
updateCallbackDone: updateDone,
|
||
|
// this is about correct
|
||
|
ready: updateDone,
|
||
|
// good enough
|
||
|
finished: new Promise((r) => viewTransitionFinished = r),
|
||
|
// see end of updateDOM
|
||
|
skipTransition: () => {
|
||
|
skipTransition = true;
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
viewTransition.ready.then(async () => {
|
||
|
await runScripts();
|
||
|
onPageLoad();
|
||
|
announce();
|
||
|
});
|
||
|
viewTransition.finished.then(() => {
|
||
|
document.documentElement.removeAttribute(DIRECTION_ATTR);
|
||
|
document.documentElement.removeAttribute(OLD_NEW_ATTR);
|
||
|
});
|
||
|
await viewTransition.ready;
|
||
|
}
|
||
|
let navigateOnServerWarned = false;
|
||
|
async function navigate(href, options) {
|
||
|
if (inBrowser === false) {
|
||
|
if (!navigateOnServerWarned) {
|
||
|
const warning = new Error(
|
||
|
"The view transitions client API was called during a server side render. This may be unintentional as the navigate() function is expected to be called in response to user interactions. Please make sure that your usage is correct."
|
||
|
);
|
||
|
warning.name = "Warning";
|
||
|
console.warn(warning);
|
||
|
navigateOnServerWarned = true;
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
|
||
|
}
|
||
|
function onPopState(ev) {
|
||
|
if (!transitionEnabledOnThisPage() && ev.state) {
|
||
|
location.reload();
|
||
|
return;
|
||
|
}
|
||
|
if (ev.state === null) {
|
||
|
return;
|
||
|
}
|
||
|
const state = history.state;
|
||
|
const nextIndex = state.index;
|
||
|
const direction = nextIndex > currentHistoryIndex ? "forward" : "back";
|
||
|
currentHistoryIndex = nextIndex;
|
||
|
transition(direction, originalLocation, new URL(location.href), {}, state);
|
||
|
}
|
||
|
const onScroll = () => {
|
||
|
updateScrollPosition({ scrollX, scrollY });
|
||
|
};
|
||
|
if (inBrowser) {
|
||
|
if (supportsViewTransitions || getFallback() !== "none") {
|
||
|
originalLocation = new URL(location.href);
|
||
|
addEventListener("popstate", onPopState);
|
||
|
addEventListener("load", onPageLoad);
|
||
|
if ("onscrollend" in window)
|
||
|
addEventListener("scrollend", onScroll);
|
||
|
else
|
||
|
addEventListener("scroll", throttle(onScroll, 350), { passive: true });
|
||
|
}
|
||
|
for (const script of document.scripts) {
|
||
|
script.dataset.astroExec = "";
|
||
|
}
|
||
|
}
|
||
|
async function prepareForClientOnlyComponents(newDocument, toLocation) {
|
||
|
if (newDocument.body.querySelector(`astro-island[client='only']`)) {
|
||
|
const nextPage = document.createElement("iframe");
|
||
|
nextPage.src = toLocation.href;
|
||
|
nextPage.style.display = "none";
|
||
|
document.body.append(nextPage);
|
||
|
nextPage.contentWindow.console = Object.keys(console).reduce((acc, key) => {
|
||
|
acc[key] = () => {
|
||
|
};
|
||
|
return acc;
|
||
|
}, {});
|
||
|
await hydrationDone(nextPage);
|
||
|
const nextHead = nextPage.contentDocument?.head;
|
||
|
if (nextHead) {
|
||
|
document.head.querySelectorAll(`style[${PERSIST_ATTR}=""]`).forEach((s) => s.removeAttribute(PERSIST_ATTR));
|
||
|
const viteIds = [...nextHead.querySelectorAll(`style[${VITE_ID}]`)].map(
|
||
|
(style) => style.getAttribute(VITE_ID)
|
||
|
);
|
||
|
viteIds.forEach((id) => {
|
||
|
const style = document.head.querySelector(`style[${VITE_ID}="${id}"]`);
|
||
|
if (style && !newDocument.head.querySelector(`style[${VITE_ID}="${id}"]`)) {
|
||
|
newDocument.head.appendChild(style.cloneNode(true));
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
async function hydrationDone(loadingPage) {
|
||
|
await new Promise(
|
||
|
(r) => loadingPage.contentWindow?.addEventListener("load", r, { once: true })
|
||
|
);
|
||
|
return new Promise(async (r) => {
|
||
|
for (let count = 0; count <= 20; ++count) {
|
||
|
if (!loadingPage.contentDocument.body.querySelector("astro-island[ssr]"))
|
||
|
break;
|
||
|
await new Promise((r2) => setTimeout(r2, 50));
|
||
|
}
|
||
|
r();
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
export {
|
||
|
navigate,
|
||
|
supportsViewTransitions,
|
||
|
transitionEnabledOnThisPage,
|
||
|
updateScrollPosition
|
||
|
};
|