mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
New events for Astro's view transition API (#9090)
* draft new view transition events * initial state for PR * remove intraPageTransitions flag based on review comments * add createAnimationScope after review comments * remove style elements from styles after review comments * remove quotes from animation css to enable set:text * added changeset * move scrollRestoration call from popstate handler to scroll update * Update .changeset/few-keys-heal.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Less confusing after following review comments * Less confusing after following review comments --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
ac908b7839
commit
c87223c21a
10 changed files with 535 additions and 178 deletions
16
.changeset/few-keys-heal.md
Normal file
16
.changeset/few-keys-heal.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
Take full control over the behavior of view transitions!
|
||||
|
||||
Three new events now complement the existing `astro:after-swap` and `astro:page-load` events:
|
||||
|
||||
``` javascript
|
||||
astro:before-preparation // Control how the DOM and other resources of the target page are loaded
|
||||
astro:after-preparation // Last changes before taking off? Remove that loading indicator? Here you go!
|
||||
astro:before-swap // Control how the DOM is updated to match the new page
|
||||
```
|
||||
|
||||
The `astro:before-*` events allow you to change properties and strategies of the view transition implementation.
|
||||
The `astro:after-*` events are notifications that a phase is complete.
|
||||
Head over to docs to see [the full view transitions lifecycle](https://docs.astro.build/en/guides/view-transitions/#lifecycle-events) including these new events!
|
27
packages/astro/client.d.ts
vendored
27
packages/astro/client.d.ts
vendored
|
@ -109,6 +109,7 @@ declare module 'astro:transitions' {
|
|||
type TransitionModule = typeof import('./dist/transitions/index.js');
|
||||
export const slide: TransitionModule['slide'];
|
||||
export const fade: TransitionModule['fade'];
|
||||
export const createAnimationScope: TransitionModule['createAnimationScope'];
|
||||
|
||||
type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro');
|
||||
export const ViewTransitions: ViewTransitionsModule['default'];
|
||||
|
@ -116,10 +117,30 @@ declare module 'astro:transitions' {
|
|||
|
||||
declare module 'astro:transitions/client' {
|
||||
type TransitionRouterModule = typeof import('./dist/transitions/router.js');
|
||||
export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions'];
|
||||
export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage'];
|
||||
export const navigate: TransitionRouterModule['navigate'];
|
||||
export type Options = import('./dist/transitions/router.js').Options;
|
||||
|
||||
type TransitionUtilModule = typeof import('./dist/transitions/util.js');
|
||||
export const supportsViewTransitions: TransitionUtilModule['supportsViewTransitions'];
|
||||
export const getFallback: TransitionUtilModule['getFallback'];
|
||||
export const transitionEnabledOnThisPage: TransitionUtilModule['transitionEnabledOnThisPage'];
|
||||
|
||||
export type Fallback = import('./dist/transitions/types.ts').Fallback;
|
||||
export type Direction = import('./dist/transitions/types.ts').Direction;
|
||||
export type NavigationTypeString = import('./dist/transitions/types.ts').NavigationTypeString;
|
||||
export type Options = import('./dist/transitions/types.ts').Options;
|
||||
|
||||
type EventModule = typeof import('./dist/transitions/events.js');
|
||||
export const TRANSITION_BEFORE_PREPARATION: EventModule['TRANSITION_BEFORE_PREPARATION'];
|
||||
export const TRANSITION_AFTER_PREPARATION: EventModule['TRANSITION_AFTER_PREPARATION'];
|
||||
export const TRANSITION_BEFORE_SWAP: EventModule['TRANSITION_BEFORE_SWAP'];
|
||||
export const TRANSITION_AFTER_SWAP: EventModule['TRANSITION_AFTER_SWAP'];
|
||||
export const TRANSITION_PAGE_LOAD: EventModule['TRANSITION_PAGE_LOAD'];
|
||||
export type TransitionBeforePreparationEvent =
|
||||
import('./dist/transitions/events.ts').TransitionBeforePreparationEvent;
|
||||
export type TransitionBeforeSwapEvent =
|
||||
import('./dist/transitions/events.ts').TransitionBeforeSwapEvent;
|
||||
export const isTransitionBeforePreparationEvent: EventModule['isTransitionBeforePreparationEvent'];
|
||||
export const isTransitionBeforeSwapEvent: EventModule['isTransitionBeforeSwapEvent'];
|
||||
}
|
||||
|
||||
declare module 'astro:prefetch' {
|
||||
|
|
|
@ -33,7 +33,7 @@ const { fallback = 'animate', handleForms } = Astro.props;
|
|||
// @ts-ignore
|
||||
import { init } from 'astro/prefetch';
|
||||
|
||||
export type Fallback = 'none' | 'animate' | 'swap';
|
||||
type Fallback = 'none' | 'animate' | 'swap';
|
||||
|
||||
function getFallback(): Fallback {
|
||||
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
|
||||
|
@ -85,6 +85,7 @@ const { fallback = 'animate', handleForms } = Astro.props;
|
|||
ev.preventDefault();
|
||||
navigate(href, {
|
||||
history: link.dataset.astroHistory === 'replace' ? 'replace' : 'auto',
|
||||
sourceElement: link,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -102,7 +103,7 @@ const { fallback = 'animate', handleForms } = Astro.props;
|
|||
let action = submitter?.getAttribute('formaction') ?? form.action ?? location.pathname;
|
||||
const method = submitter?.getAttribute('formmethod') ?? form.method;
|
||||
|
||||
const options: Options = {};
|
||||
const options: Options = { sourceElement: submitter ?? form };
|
||||
if (method === 'get') {
|
||||
const params = new URLSearchParams(formData as any);
|
||||
const url = new URL(action);
|
||||
|
|
|
@ -78,7 +78,9 @@
|
|||
"default": "./dist/core/middleware/namespace.js"
|
||||
},
|
||||
"./transitions": "./dist/transitions/index.js",
|
||||
"./transitions/events": "./dist/transitions/events.js",
|
||||
"./transitions/router": "./dist/transitions/router.js",
|
||||
"./transitions/types": "./dist/transitions/types.js",
|
||||
"./prefetch": "./dist/prefetch/index.js",
|
||||
"./i18n": "./dist/i18n/index.js"
|
||||
},
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type {
|
||||
SSRResult,
|
||||
TransitionAnimation,
|
||||
TransitionAnimationPair,
|
||||
TransitionAnimationValue,
|
||||
TransitionDirectionalAnimations,
|
||||
} from '../../@types/astro.js';
|
||||
import { fade, slide } from '../../transitions/index.js';
|
||||
import { markHTMLString } from './escape.js';
|
||||
|
@ -34,6 +36,19 @@ const getAnimations = (name: TransitionAnimationValue) => {
|
|||
if (typeof name === 'object') return name;
|
||||
};
|
||||
|
||||
const addPairs = (
|
||||
animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>,
|
||||
stylesheet: ViewTransitionStyleSheet
|
||||
) => {
|
||||
for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
|
||||
for (const [image, rules] of Object.entries(images) as Entries<
|
||||
(typeof animations)[typeof direction]
|
||||
>) {
|
||||
stylesheet.addAnimationPair(direction, image, rules);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function renderTransition(
|
||||
result: SSRResult,
|
||||
hash: string,
|
||||
|
@ -48,13 +63,7 @@ export function renderTransition(
|
|||
|
||||
const animations = getAnimations(animationName);
|
||||
if (animations) {
|
||||
for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
|
||||
for (const [image, rules] of Object.entries(images) as Entries<
|
||||
(typeof animations)[typeof direction]
|
||||
>) {
|
||||
sheet.addAnimationPair(direction, image, rules);
|
||||
}
|
||||
}
|
||||
addPairs(animations, sheet);
|
||||
} else if (animationName === 'none') {
|
||||
sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;');
|
||||
sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;');
|
||||
|
@ -65,6 +74,19 @@ export function renderTransition(
|
|||
return scope;
|
||||
}
|
||||
|
||||
export function createAnimationScope(
|
||||
transitionName: string,
|
||||
animations: Record<string, TransitionAnimationPair>
|
||||
) {
|
||||
const hash = Math.random().toString(36).slice(2, 8);
|
||||
const scope = `astro-${hash}`;
|
||||
const sheet = new ViewTransitionStyleSheet(scope, transitionName);
|
||||
|
||||
addPairs(animations, sheet);
|
||||
|
||||
return { scope, styles: sheet.toString().replaceAll('"', '') };
|
||||
}
|
||||
|
||||
class ViewTransitionStyleSheet {
|
||||
private modern: string[] = [];
|
||||
private fallback: string[] = [];
|
||||
|
@ -113,13 +135,18 @@ class ViewTransitionStyleSheet {
|
|||
}
|
||||
|
||||
addAnimationPair(
|
||||
direction: 'forwards' | 'backwards',
|
||||
direction: 'forwards' | 'backwards' | string,
|
||||
image: 'old' | 'new',
|
||||
rules: TransitionAnimation | TransitionAnimation[]
|
||||
) {
|
||||
const { scope, name } = this;
|
||||
const animation = stringifyAnimation(rules);
|
||||
const prefix = direction === 'backwards' ? `[data-astro-transition=back]` : '';
|
||||
const prefix =
|
||||
direction === 'backwards'
|
||||
? `[data-astro-transition=back]`
|
||||
: direction === 'forwards'
|
||||
? ''
|
||||
: `[data-astro-transition=${direction}]`;
|
||||
this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
|
||||
this.addRule(
|
||||
'fallback',
|
||||
|
|
184
packages/astro/src/transitions/events.ts
Normal file
184
packages/astro/src/transitions/events.ts
Normal file
|
@ -0,0 +1,184 @@
|
|||
import { updateScrollPosition } from './router.js';
|
||||
import type { Direction, NavigationTypeString } from './types.js';
|
||||
|
||||
export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation';
|
||||
export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation';
|
||||
export const TRANSITION_BEFORE_SWAP = 'astro:before-swap';
|
||||
export const TRANSITION_AFTER_SWAP = 'astro:after-swap';
|
||||
export const TRANSITION_PAGE_LOAD = 'astro:page-load';
|
||||
|
||||
type Events =
|
||||
| typeof TRANSITION_AFTER_PREPARATION
|
||||
| typeof TRANSITION_AFTER_SWAP
|
||||
| typeof TRANSITION_PAGE_LOAD;
|
||||
export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
|
||||
export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD);
|
||||
|
||||
/*
|
||||
* Common stuff
|
||||
*/
|
||||
class BeforeEvent extends Event {
|
||||
readonly from: URL;
|
||||
to: URL;
|
||||
direction: Direction | string;
|
||||
readonly navigationType: NavigationTypeString;
|
||||
readonly sourceElement: Element | undefined;
|
||||
readonly info: any;
|
||||
newDocument: Document;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
eventInitDict: EventInit | undefined,
|
||||
from: URL,
|
||||
to: URL,
|
||||
direction: Direction | string,
|
||||
navigationType: NavigationTypeString,
|
||||
sourceElement: Element | undefined,
|
||||
info: any,
|
||||
newDocument: Document
|
||||
) {
|
||||
super(type, eventInitDict);
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.direction = direction;
|
||||
this.navigationType = navigationType;
|
||||
this.sourceElement = sourceElement;
|
||||
this.info = info;
|
||||
this.newDocument = newDocument;
|
||||
|
||||
Object.defineProperties(this, {
|
||||
from: { enumerable: true },
|
||||
to: { enumerable: true, writable: true },
|
||||
direction: { enumerable: true, writable: true },
|
||||
navigationType: { enumerable: true },
|
||||
sourceElement: { enumerable: true },
|
||||
info: { enumerable: true },
|
||||
newDocument: { enumerable: true, writable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* TransitionBeforePreparationEvent
|
||||
|
||||
*/
|
||||
export const isTransitionBeforePreparationEvent = (
|
||||
value: any
|
||||
): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION;
|
||||
export class TransitionBeforePreparationEvent extends BeforeEvent {
|
||||
formData: FormData | undefined;
|
||||
loader: () => Promise<void>;
|
||||
constructor(
|
||||
from: URL,
|
||||
to: URL,
|
||||
direction: Direction | string,
|
||||
navigationType: NavigationTypeString,
|
||||
sourceElement: Element | undefined,
|
||||
info: any,
|
||||
newDocument: Document,
|
||||
formData: FormData | undefined,
|
||||
loader: (event: TransitionBeforePreparationEvent) => Promise<void>
|
||||
) {
|
||||
super(
|
||||
TRANSITION_BEFORE_PREPARATION,
|
||||
{ cancelable: true },
|
||||
from,
|
||||
to,
|
||||
direction,
|
||||
navigationType,
|
||||
sourceElement,
|
||||
info,
|
||||
newDocument
|
||||
);
|
||||
this.formData = formData;
|
||||
this.loader = loader.bind(this, this);
|
||||
Object.defineProperties(this, {
|
||||
formData: { enumerable: true },
|
||||
loader: { enumerable: true, writable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* TransitionBeforeSwapEvent
|
||||
*/
|
||||
|
||||
export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent =>
|
||||
value.type === TRANSITION_BEFORE_SWAP;
|
||||
export class TransitionBeforeSwapEvent extends BeforeEvent {
|
||||
readonly direction: Direction | string;
|
||||
readonly viewTransition: ViewTransition;
|
||||
swap: () => void;
|
||||
|
||||
constructor(
|
||||
afterPreparation: BeforeEvent,
|
||||
viewTransition: ViewTransition,
|
||||
swap: (event: TransitionBeforeSwapEvent) => void
|
||||
) {
|
||||
super(
|
||||
TRANSITION_BEFORE_SWAP,
|
||||
undefined,
|
||||
afterPreparation.from,
|
||||
afterPreparation.to,
|
||||
afterPreparation.direction,
|
||||
afterPreparation.navigationType,
|
||||
afterPreparation.sourceElement,
|
||||
afterPreparation.info,
|
||||
afterPreparation.newDocument
|
||||
);
|
||||
this.direction = afterPreparation.direction;
|
||||
this.viewTransition = viewTransition;
|
||||
this.swap = swap.bind(this, this);
|
||||
|
||||
Object.defineProperties(this, {
|
||||
direction: { enumerable: true },
|
||||
viewTransition: { enumerable: true },
|
||||
swap: { enumerable: true, writable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function doPreparation(
|
||||
from: URL,
|
||||
to: URL,
|
||||
direction: Direction | string,
|
||||
navigationType: NavigationTypeString,
|
||||
sourceElement: Element | undefined,
|
||||
info: any,
|
||||
formData: FormData | undefined,
|
||||
defaultLoader: (event: TransitionBeforePreparationEvent) => Promise<void>
|
||||
) {
|
||||
const event = new TransitionBeforePreparationEvent(
|
||||
from,
|
||||
to,
|
||||
direction,
|
||||
navigationType,
|
||||
sourceElement,
|
||||
info,
|
||||
window.document,
|
||||
formData,
|
||||
defaultLoader
|
||||
);
|
||||
if (document.dispatchEvent(event)) {
|
||||
await event.loader();
|
||||
if (!event.defaultPrevented) {
|
||||
triggerEvent(TRANSITION_AFTER_PREPARATION);
|
||||
if (event.navigationType !== 'traverse') {
|
||||
// save the current scroll position before we change the DOM and transition to the new page
|
||||
updateScrollPosition({ scrollX, scrollY });
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function doSwap(
|
||||
afterPreparation: BeforeEvent,
|
||||
viewTransition: ViewTransition,
|
||||
defaultSwap: (event: TransitionBeforeSwapEvent) => void
|
||||
) {
|
||||
const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition, defaultSwap);
|
||||
document.dispatchEvent(event);
|
||||
event.swap();
|
||||
return event;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import type { TransitionAnimationPair, TransitionDirectionalAnimations } from '../@types/astro.js';
|
||||
export { createAnimationScope } from '../runtime/server/transition.js';
|
||||
|
||||
const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)';
|
||||
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
export type Fallback = 'none' | 'animate' | 'swap';
|
||||
export type Direction = 'forward' | 'back';
|
||||
export type Options = {
|
||||
history?: 'auto' | 'push' | 'replace';
|
||||
formData?: FormData;
|
||||
};
|
||||
import {
|
||||
doPreparation,
|
||||
TransitionBeforeSwapEvent,
|
||||
type TransitionBeforePreparationEvent,
|
||||
doSwap,
|
||||
TRANSITION_AFTER_SWAP,
|
||||
} from './events.js';
|
||||
import type { Fallback, Direction, Options } from './types.js';
|
||||
|
||||
type State = {
|
||||
index: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
intraPage?: boolean;
|
||||
};
|
||||
type Events = 'astro:page-load' | 'astro:after-swap';
|
||||
|
||||
// only update history entries that are managed by us
|
||||
// leave other entries alone and do not accidently add state.
|
||||
const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) =>
|
||||
history.state && history.replaceState({ ...history.state, ...positions }, '');
|
||||
|
||||
export const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => {
|
||||
if (history.state) {
|
||||
history.scrollRestoration = 'manual';
|
||||
history.replaceState({ ...history.state, ...positions }, '');
|
||||
}
|
||||
};
|
||||
const inBrowser = import.meta.env.SSR === false;
|
||||
|
||||
export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
|
||||
|
@ -25,8 +29,21 @@ export const supportsViewTransitions = inBrowser && !!document.startViewTransiti
|
|||
export const transitionEnabledOnThisPage = () =>
|
||||
inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]');
|
||||
|
||||
const samePage = (otherLocation: URL) =>
|
||||
location.pathname === otherLocation.pathname && location.search === otherLocation.search;
|
||||
const samePage = (thisLocation: URL, otherLocation: URL) =>
|
||||
thisLocation.origin === otherLocation.origin &&
|
||||
thisLocation.pathname === otherLocation.pathname &&
|
||||
thisLocation.search === otherLocation.search;
|
||||
|
||||
// When we traverse the history, the window.location is already set to the new location.
|
||||
// This variable tells us where we came from
|
||||
let originalLocation: URL;
|
||||
// The result of startViewTransition (browser or simulation)
|
||||
let viewTransition: ViewTransition | undefined;
|
||||
// skip transition flag for fallback simulation
|
||||
let skipTransition = false;
|
||||
// The resolve function of the finished promise for fallback simulation
|
||||
let viewTransitionFinished: () => void;
|
||||
|
||||
const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
|
||||
const onPageLoad = () => triggerEvent('astro:page-load');
|
||||
const announce = () => {
|
||||
|
@ -48,6 +65,9 @@ const announce = () => {
|
|||
};
|
||||
|
||||
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: DOMParser;
|
||||
|
@ -66,7 +86,8 @@ if (inBrowser) {
|
|||
} else if (transitionEnabledOnThisPage()) {
|
||||
// This page is loaded from the browser addressbar or via a link from extern,
|
||||
// it needs a state in the history
|
||||
history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, '');
|
||||
history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, '');
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,50 +168,61 @@ function runScripts() {
|
|||
return wait;
|
||||
}
|
||||
|
||||
function isInfinite(animation: 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';
|
||||
}
|
||||
|
||||
// Add a new entry to the browser history. This also sets the new page in the browser addressbar.
|
||||
// Sets the scroll position according to the hash fragment of the new location.
|
||||
const moveToLocation = (toLocation: URL, replace: boolean, intraPage: boolean) => {
|
||||
const fresh = !samePage(toLocation);
|
||||
const moveToLocation = (to: URL, from: URL, options: Options, historyState?: State) => {
|
||||
const intraPage = samePage(from, to);
|
||||
|
||||
let scrolledToTop = false;
|
||||
if (toLocation.href !== location.href) {
|
||||
if (replace) {
|
||||
history.replaceState({ ...history.state }, '', toLocation.href);
|
||||
} else {
|
||||
history.replaceState({ ...history.state, intraPage }, '');
|
||||
history.pushState(
|
||||
{ index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
|
||||
if (to.href !== location.href && !historyState) {
|
||||
if (options.history === 'replace') {
|
||||
const current = history.state;
|
||||
history.replaceState(
|
||||
{
|
||||
...options.state,
|
||||
index: current.index,
|
||||
scrollX: current.scrollX,
|
||||
scrollY: current.scrollY,
|
||||
},
|
||||
'',
|
||||
toLocation.href
|
||||
to.href
|
||||
);
|
||||
} else {
|
||||
history.pushState(
|
||||
{ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
|
||||
'',
|
||||
to.href
|
||||
);
|
||||
}
|
||||
// now we are on the new page for non-history navigations!
|
||||
// (with history navigation page change happens before popstate is fired)
|
||||
// freshly loaded pages start from the top
|
||||
if (fresh) {
|
||||
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
||||
scrolledToTop = true;
|
||||
}
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
if (toLocation.hash) {
|
||||
// because we are already on the target page ...
|
||||
// ... what comes next is a intra-page navigation
|
||||
// that won't reload the page but instead scroll to the fragment
|
||||
location.href = toLocation.href;
|
||||
// now we are on the new page for non-history navigations!
|
||||
// (with history navigation page change happens before popstate is fired)
|
||||
originalLocation = to;
|
||||
|
||||
// freshly loaded pages start from the top
|
||||
if (!intraPage) {
|
||||
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
||||
scrolledToTop = true;
|
||||
}
|
||||
|
||||
if (historyState) {
|
||||
scrollTo(historyState.scrollX, historyState.scrollY);
|
||||
} else {
|
||||
if (!scrolledToTop) {
|
||||
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
||||
if (to.hash) {
|
||||
// because we are already on the target page ...
|
||||
// ... what comes next is a intra-page navigation
|
||||
// that won't reload the page but instead scroll to the fragment
|
||||
location.href = to.href;
|
||||
} else {
|
||||
if (!scrolledToTop) {
|
||||
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function stylePreloadLinks(newDocument: Document) {
|
||||
function preloadStyleLinks(newDocument: Document) {
|
||||
const links: Promise<any>[] = [];
|
||||
for (const el of newDocument.querySelectorAll('head link[rel=stylesheet]')) {
|
||||
// Do not preload links that are already on the page.
|
||||
|
@ -221,24 +253,23 @@ function stylePreloadLinks(newDocument: Document) {
|
|||
// if popState is given, this holds the scroll position for history navigation
|
||||
// if fallback === "animate" then simulate view transitions
|
||||
async function updateDOM(
|
||||
newDocument: Document,
|
||||
toLocation: URL,
|
||||
preparationEvent: TransitionBeforePreparationEvent,
|
||||
options: Options,
|
||||
popState?: State,
|
||||
historyState?: State,
|
||||
fallback?: Fallback
|
||||
) {
|
||||
// Check for a head element that should persist and returns it,
|
||||
// either because it has the data attribute or is a link el.
|
||||
// Returns null if the element is not part of the new head, undefined if it should be left alone.
|
||||
const persistedHeadElement = (el: HTMLElement): Element | null => {
|
||||
const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null => {
|
||||
const id = el.getAttribute(PERSIST_ATTR);
|
||||
const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
|
||||
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 newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
|
||||
return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
@ -282,22 +313,22 @@ async function updateDOM(
|
|||
}
|
||||
};
|
||||
|
||||
const swap = () => {
|
||||
const defaultSwap = (beforeSwapEvent: TransitionBeforeSwapEvent) => {
|
||||
// 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(
|
||||
const astroAttributes = [...html.attributes].filter(
|
||||
({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-'))
|
||||
);
|
||||
[...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) =>
|
||||
html.setAttribute(name, value)
|
||||
[...beforeSwapEvent.newDocument.documentElement.attributes, ...astroAttributes].forEach(
|
||||
({ name, value }) => html.setAttribute(name, value)
|
||||
);
|
||||
|
||||
// Replace scripts in both the head and body.
|
||||
for (const s1 of document.scripts) {
|
||||
for (const s2 of newDocument.scripts) {
|
||||
for (const s2 of beforeSwapEvent.newDocument.scripts) {
|
||||
if (
|
||||
// Inline
|
||||
(!s1.src && s1.textContent === s2.textContent) ||
|
||||
|
@ -313,7 +344,7 @@ async function updateDOM(
|
|||
|
||||
// Swap head
|
||||
for (const el of Array.from(document.head.children)) {
|
||||
const newEl = persistedHeadElement(el as HTMLElement);
|
||||
const newEl = persistedHeadElement(el as HTMLElement, beforeSwapEvent.newDocument);
|
||||
// If the element exists in the document already, remove it
|
||||
// from the new document and leave the current node alone
|
||||
if (newEl) {
|
||||
|
@ -325,7 +356,7 @@ async function updateDOM(
|
|||
}
|
||||
|
||||
// Everything left in the new head is new, append it all.
|
||||
document.head.append(...newDocument.head.children);
|
||||
document.head.append(...beforeSwapEvent.newDocument.head.children);
|
||||
|
||||
// Persist elements in the existing body
|
||||
const oldBody = document.body;
|
||||
|
@ -333,7 +364,7 @@ async function updateDOM(
|
|||
const savedFocus = saveFocus();
|
||||
|
||||
// this will reset scroll Position
|
||||
document.body.replaceWith(newDocument.body);
|
||||
document.body.replaceWith(beforeSwapEvent.newDocument.body);
|
||||
|
||||
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
|
||||
const id = el.getAttribute(PERSIST_ATTR);
|
||||
|
@ -345,103 +376,187 @@ async function updateDOM(
|
|||
}
|
||||
}
|
||||
restoreFocus(savedFocus);
|
||||
|
||||
if (popState) {
|
||||
scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
|
||||
} else {
|
||||
moveToLocation(toLocation, options.history === 'replace', false);
|
||||
}
|
||||
|
||||
triggerEvent('astro:after-swap');
|
||||
};
|
||||
|
||||
const links = stylePreloadLinks(newDocument);
|
||||
links.length && (await Promise.all(links));
|
||||
|
||||
if (fallback === 'animate') {
|
||||
async function animate(phase: string) {
|
||||
function isInfinite(animation: 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';
|
||||
}
|
||||
// Trigger the animations
|
||||
const currentAnimations = document.getAnimations();
|
||||
document.documentElement.dataset.astroTransitionFallback = 'old';
|
||||
const newAnimations = document
|
||||
.getAnimations()
|
||||
.filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
|
||||
const finished = Promise.all(newAnimations.map((a) => a.finished));
|
||||
await finished;
|
||||
swap();
|
||||
document.documentElement.dataset.astroTransitionFallback = 'new';
|
||||
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 {
|
||||
swap();
|
||||
// that's what Chrome does
|
||||
throw new DOMException('Transition was skipped');
|
||||
}
|
||||
|
||||
const swapEvent = await doSwap(preparationEvent, viewTransition!, defaultSwap);
|
||||
moveToLocation(swapEvent.to, swapEvent.from, options, historyState);
|
||||
triggerEvent(TRANSITION_AFTER_SWAP);
|
||||
|
||||
if (fallback === 'animate' && !skipTransition) {
|
||||
animate('new').then(() => viewTransitionFinished());
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(
|
||||
direction: Direction,
|
||||
toLocation: URL,
|
||||
from: URL,
|
||||
to: URL,
|
||||
options: Options,
|
||||
popState?: State
|
||||
historyState?: State
|
||||
) {
|
||||
let finished: Promise<void>;
|
||||
const href = toLocation.href;
|
||||
const init: RequestInit = {};
|
||||
if (options.formData) {
|
||||
init.method = 'POST';
|
||||
init.body = options.formData;
|
||||
}
|
||||
const response = await fetchHTML(href, init);
|
||||
// If there is a problem fetching the new page, just do an MPA navigation to it.
|
||||
if (response === null) {
|
||||
location.href = href;
|
||||
return;
|
||||
}
|
||||
// if there was a redirection, show the final URL in the browser's address bar
|
||||
if (response.redirected) {
|
||||
toLocation = new URL(response.redirected);
|
||||
}
|
||||
const navigationType = historyState
|
||||
? 'traverse'
|
||||
: options.history === 'replace'
|
||||
? 'replace'
|
||||
: 'push';
|
||||
|
||||
parser ??= new DOMParser();
|
||||
|
||||
const newDocument = parser.parseFromString(response.html, response.mediaType);
|
||||
// The next line might look like a hack,
|
||||
// but it is actually necessary as noscript elements
|
||||
// and their contents are returned as markup by the parser,
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
|
||||
newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
|
||||
|
||||
// If ViewTransitions is not enabled on the incoming page, do a full page load to it.
|
||||
// Unless this was a form submission, in which case we do not want to trigger another mutation.
|
||||
if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !options.formData) {
|
||||
location.href = href;
|
||||
if (samePage(from, to) && !options.formData /* not yet: && to.hash*/) {
|
||||
if (navigationType !== 'traverse') {
|
||||
updateScrollPosition({ scrollX, scrollY });
|
||||
}
|
||||
moveToLocation(to, from, options, historyState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation);
|
||||
|
||||
if (!popState) {
|
||||
// save the current scroll position before we change the DOM and transition to the new page
|
||||
history.replaceState({ ...history.state, scrollX, scrollY }, '');
|
||||
const prepEvent = await doPreparation(
|
||||
from,
|
||||
to,
|
||||
direction,
|
||||
navigationType,
|
||||
options.sourceElement,
|
||||
options.info,
|
||||
options.formData,
|
||||
defaultLoader
|
||||
);
|
||||
if (prepEvent.defaultPrevented) {
|
||||
location.href = to.href;
|
||||
return;
|
||||
}
|
||||
document.documentElement.dataset.astroTransition = direction;
|
||||
|
||||
function pageMustReload(preparationEvent: TransitionBeforePreparationEvent) {
|
||||
return (
|
||||
preparationEvent.to.hash === '' ||
|
||||
!samePage(preparationEvent.from, preparationEvent.to) ||
|
||||
preparationEvent.sourceElement instanceof HTMLFormElement
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultLoader(preparationEvent: TransitionBeforePreparationEvent) {
|
||||
if (pageMustReload(preparationEvent)) {
|
||||
const href = preparationEvent.to.href;
|
||||
const init: RequestInit = {};
|
||||
if (preparationEvent.formData) {
|
||||
init.method = 'POST';
|
||||
init.body = preparationEvent.formData;
|
||||
}
|
||||
const response = await fetchHTML(href, init);
|
||||
// If there is a problem fetching the new page, just do an MPA navigation to it.
|
||||
if (response === null) {
|
||||
preparationEvent.preventDefault();
|
||||
return;
|
||||
}
|
||||
// if there was a redirection, show the final URL in the browser's address bar
|
||||
if (response.redirected) {
|
||||
preparationEvent.to = new URL(response.redirected);
|
||||
}
|
||||
|
||||
parser ??= new DOMParser();
|
||||
|
||||
preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
|
||||
// The next line might look like a hack,
|
||||
// but it is actually necessary as noscript elements
|
||||
// and their contents are returned as markup by the parser,
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
|
||||
preparationEvent.newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
|
||||
|
||||
// If ViewTransitions is not enabled on the incoming page, do a full page load to it.
|
||||
// Unless this was a form submission, in which case we do not want to trigger another mutation.
|
||||
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);
|
||||
} else {
|
||||
preparationEvent.newDocument = document;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
skipTransition = false;
|
||||
if (supportsViewTransitions) {
|
||||
finished = document.startViewTransition(() =>
|
||||
updateDOM(newDocument, toLocation, options, popState)
|
||||
).finished;
|
||||
viewTransition = document.startViewTransition(
|
||||
async () => await updateDOM(prepEvent, options, historyState)
|
||||
);
|
||||
} else {
|
||||
finished = updateDOM(newDocument, toLocation, options, popState, getFallback());
|
||||
const updateDone = (async () => {
|
||||
// immediatelly paused to setup the ViewTransition object for Fallback mode
|
||||
await new Promise((r) => setTimeout(r));
|
||||
await updateDOM(prepEvent, options, historyState, getFallback());
|
||||
})();
|
||||
|
||||
// When the updateDone promise is settled,
|
||||
// we have run and awaited all swap functions and the after-swap event
|
||||
// This qualifies for "updateCallbackDone".
|
||||
//
|
||||
// For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone",
|
||||
// i.e. after all pseudo elements are created and the animation is about to start.
|
||||
// In simulation mode the "old" animation starts before swap,
|
||||
// the "new" animation starts after swap. That is not really comparable.
|
||||
// Thus we go with "very, very shortly after updateCallbackDone" and make both equal.
|
||||
//
|
||||
// "finished" resolves after all animations are done.
|
||||
|
||||
viewTransition = {
|
||||
updateCallbackDone: updateDone, // this is about correct
|
||||
ready: updateDone, // good enough
|
||||
finished: new Promise((r) => (viewTransitionFinished = r)), // see end of updateDOM
|
||||
skipTransition: () => {
|
||||
skipTransition = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
await finished;
|
||||
} finally {
|
||||
// skip this for the moment as it tends to stop fallback animations
|
||||
// document.documentElement.removeAttribute('data-astro-transition');
|
||||
|
||||
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;
|
||||
|
||||
export function navigate(href: string, options?: Options) {
|
||||
export async function navigate(href: string, options?: Options) {
|
||||
if (inBrowser === false) {
|
||||
if (!navigateOnServerWarned) {
|
||||
// instantiate an error for the stacktrace to show to user.
|
||||
|
@ -461,17 +576,7 @@ export function navigate(href: string, options?: Options) {
|
|||
location.href = href;
|
||||
return;
|
||||
}
|
||||
const toLocation = new URL(href, location.href);
|
||||
// We do not have page transitions on navigations to the same page (intra-page navigation)
|
||||
// *unless* they are form posts which have side-effects and so need to happen
|
||||
// but we want to handle prevent reload on navigation to the same page
|
||||
// Same page means same origin, path and query params (but maybe different hash)
|
||||
if (location.origin === toLocation.origin && samePage(toLocation) && !options?.formData) {
|
||||
moveToLocation(toLocation, options?.history === 'replace', true);
|
||||
} else {
|
||||
// different origin will be detected by fetch
|
||||
transition('forward', toLocation, options ?? {});
|
||||
}
|
||||
await transition('forward', originalLocation, new URL(href, location.href), options ?? {});
|
||||
}
|
||||
|
||||
function onPopState(ev: PopStateEvent) {
|
||||
|
@ -479,10 +584,6 @@ function onPopState(ev: PopStateEvent) {
|
|||
// The current page doesn't have View Transitions enabled
|
||||
// but the page we navigate to does (because it set the state).
|
||||
// Do a full page refresh to reload the client-side router from the new page.
|
||||
// Scroll restauration will then happen during the reload when the router's code is re-executed
|
||||
if (history.scrollRestoration) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
@ -492,28 +593,13 @@ function onPopState(ev: PopStateEvent) {
|
|||
// Just ignore stateless entries.
|
||||
// The browser will handle navigation fine without our help
|
||||
if (ev.state === null) {
|
||||
if (history.scrollRestoration) {
|
||||
history.scrollRestoration = 'auto';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// With the default "auto", the browser will jump to the old scroll position
|
||||
// before the ViewTransition is complete.
|
||||
if (history.scrollRestoration) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
const state: State = history.state;
|
||||
if (state.intraPage) {
|
||||
// this is non transition intra-page scrolling
|
||||
scrollTo(state.scrollX, state.scrollY);
|
||||
} else {
|
||||
const nextIndex = state.index;
|
||||
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
|
||||
currentHistoryIndex = nextIndex;
|
||||
transition(direction, new URL(location.href), {}, state);
|
||||
}
|
||||
const nextIndex = state.index;
|
||||
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
|
||||
currentHistoryIndex = nextIndex;
|
||||
transition(direction, originalLocation, new URL(location.href), {}, state);
|
||||
}
|
||||
|
||||
// There's not a good way to record scroll position before a back button.
|
||||
|
@ -522,8 +608,10 @@ const onScroll = () => {
|
|||
updateScrollPosition({ scrollX, scrollY });
|
||||
};
|
||||
|
||||
// initialization
|
||||
if (inBrowser) {
|
||||
if (supportsViewTransitions || getFallback() !== 'none') {
|
||||
originalLocation = new URL(location.href);
|
||||
addEventListener('popstate', onPopState);
|
||||
addEventListener('load', onPageLoad);
|
||||
if ('onscrollend' in window) addEventListener('scrollend', onScroll);
|
||||
|
|
10
packages/astro/src/transitions/types.ts
Normal file
10
packages/astro/src/transitions/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export type Fallback = 'none' | 'animate' | 'swap';
|
||||
export type Direction = 'forward' | 'back';
|
||||
export type NavigationTypeString = 'push' | 'replace' | 'traverse';
|
||||
export type Options = {
|
||||
history?: 'auto' | 'push' | 'replace';
|
||||
info?: any;
|
||||
state?: any;
|
||||
formData?: FormData;
|
||||
sourceElement?: Element; // more than HTMLElement, e.g. SVGAElement
|
||||
};
|
|
@ -27,7 +27,14 @@ export default function astroTransitions({ settings }: { settings: AstroSettings
|
|||
}
|
||||
if (id === resolvedVirtualClientModuleId) {
|
||||
return `
|
||||
export * from "astro/transitions/router";
|
||||
export { navigate, supportsViewTransitions, transitionEnabledOnThisPage } from "astro/transitions/router";
|
||||
export * from "astro/transitions/types";
|
||||
export {
|
||||
TRANSITION_BEFORE_PREPARATION, isTransitionBeforePreparationEvent, TransitionBeforePreparationEvent,
|
||||
TRANSITION_AFTER_PREPARATION,
|
||||
TRANSITION_BEFORE_SWAP, isTransitionBeforeSwapEvent, TransitionBeforeSwapEvent,
|
||||
TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD
|
||||
} from "astro/transitions/events";
|
||||
`;
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue