aboutsummaryrefslogtreecommitdiff
path: root/packages/astro/src/runtime/server/transition.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src/runtime/server/transition.ts')
-rw-r--r--packages/astro/src/runtime/server/transition.ts268
1 files changed, 268 insertions, 0 deletions
diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts
new file mode 100644
index 000000000..bae712489
--- /dev/null
+++ b/packages/astro/src/runtime/server/transition.ts
@@ -0,0 +1,268 @@
+import cssesc from 'cssesc';
+import { generateCspDigest } from '../../core/encryption.js';
+import { fade, slide } from '../../transitions/index.js';
+import type { SSRResult } from '../../types/public/internal.js';
+import type {
+ TransitionAnimation,
+ TransitionAnimationPair,
+ TransitionAnimationValue,
+ TransitionDirectionalAnimations,
+} from '../../types/public/view-transitions.js';
+import { markHTMLString } from './escape.js';
+
+const transitionNameMap = new WeakMap<SSRResult, number>();
+function incrementTransitionNumber(result: SSRResult) {
+ let num = 1;
+ if (transitionNameMap.has(result)) {
+ num = transitionNameMap.get(result)! + 1;
+ }
+ transitionNameMap.set(result, num);
+ return num;
+}
+
+export function createTransitionScope(result: SSRResult, hash: string) {
+ const num = incrementTransitionNumber(result);
+ return `astro-${hash}-${num}`;
+}
+
+type Entries<T extends Record<string, any>> = Iterable<[keyof T, T[keyof T]]>;
+
+const getAnimations = (name: TransitionAnimationValue) => {
+ if (name === 'fade') return fade();
+ if (name === 'slide') return slide();
+ 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);
+ }
+ }
+};
+
+// Chrome (121) accepts custom-idents for view-transition-names as generated by cssesc,
+// but it just ignores them during view transitions if they contain escaped 7-bit ASCII characters
+// like \<space> or \. A special case are digits and minus at the beginning of the string,
+// which cssesc also encodes as \xx
+const reEncodeValidChars: string[] =
+ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
+ .split('')
+ .reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);
+const reEncodeInValidStart: string[] = '-0123456789_'
+ .split('')
+ .reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);
+
+function reEncode(s: string) {
+ let result = '';
+ let codepoint;
+ // we work on codepoints that might use more than 16bit, not character codes.
+ // so the index will often step by 1 as usual or by 2 if the codepoint is greater than 0xFFFF
+ for (let i = 0; i < s.length; i += (codepoint ?? 0) > 0xffff ? 2 : 1) {
+ codepoint = s.codePointAt(i);
+ if (codepoint !== undefined) {
+ // this should never happen, they said!
+
+ // If we find a character in the range \x00 - \x7f that is not one of the reEncodeValidChars,
+ // we replace it with its hex value escaped by an underscore for decodability (and better readability,
+ // because most of them are punctuations like ,'"":;_..., and '_' might be a better choice than '-')
+ // The underscore itself (code 95) is also escaped and encoded as two underscores to avoid
+ // collisions between original and encoded strings.
+ // All other values are just copied over
+ result +=
+ codepoint < 0x80
+ ? codepoint === 95
+ ? '__'
+ : (reEncodeValidChars[codepoint] ?? '_' + codepoint.toString(16).padStart(2, '0'))
+ : String.fromCodePoint(codepoint);
+ }
+ }
+ // Digits and minus sign at the beginning of the string are special, so we simply prepend an underscore
+ return reEncodeInValidStart[result.codePointAt(0) ?? 0] ? '_' + result : result;
+}
+
+export async function renderTransition(
+ result: SSRResult,
+ hash: string,
+ animationName: TransitionAnimationValue | undefined,
+ transitionName: string,
+) {
+ if (typeof (transitionName ?? '') !== 'string') {
+ throw new Error(`Invalid transition name {${transitionName}}`);
+ }
+ // Default to `fade` (similar to `initial`, but snappier)
+ if (!animationName) animationName = 'fade';
+ const scope = createTransitionScope(result, hash);
+ const name = transitionName ? cssesc(reEncode(transitionName), { isIdentifier: true }) : scope;
+ const sheet = new ViewTransitionStyleSheet(scope, name);
+
+ const animations = getAnimations(animationName);
+ if (animations) {
+ 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;');
+ sheet.addAnimationRaw('new', 'animation: none; mix-blend-mode: normal;');
+ sheet.addModern('group', 'animation: none');
+ }
+
+ const css = sheet.toString();
+ if (result.shouldInjectCspMetaTags) {
+ result._metadata.extraStyleHashes.push(await generateCspDigest(css, result.cspAlgorithm));
+ }
+ result._metadata.extraHead.push(markHTMLString(`<style>${css}</style>`));
+ 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[] = [];
+
+ constructor(
+ private scope: string,
+ private name: string,
+ ) {}
+
+ toString() {
+ const { scope, name } = this;
+ const [modern, fallback] = [this.modern, this.fallback].map((rules) => rules.join(''));
+ return [
+ `[data-astro-transition-scope="${scope}"] { view-transition-name: ${name}; }`,
+ this.layer(modern),
+ fallback,
+ ].join('');
+ }
+
+ private layer(cssText: string) {
+ return cssText ? `@layer astro { ${cssText} }` : '';
+ }
+
+ private addRule(target: 'modern' | 'fallback', cssText: string) {
+ this[target].push(cssText);
+ }
+
+ addAnimationRaw(image: 'old' | 'new' | 'group', animation: string) {
+ this.addModern(image, animation);
+ this.addFallback(image, animation);
+ }
+
+ addModern(image: 'old' | 'new' | 'group', animation: string) {
+ const { name } = this;
+ this.addRule('modern', `::view-transition-${image}(${name}) { ${animation} }`);
+ }
+
+ addFallback(image: 'old' | 'new' | 'group', animation: string) {
+ const { scope } = this;
+ this.addRule(
+ 'fallback',
+ // Two selectors here, the second in case there is an animation on the root.
+ `[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
+ [data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
+ );
+ }
+
+ addAnimationPair(
+ 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]`
+ : direction === 'forwards'
+ ? ''
+ : `[data-astro-transition=${direction}]`;
+ this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
+ this.addRule(
+ 'fallback',
+ `${prefix}[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
+ ${prefix}[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
+ );
+ }
+}
+
+type AnimationBuilder = {
+ toString(): string;
+ [key: string]: string[] | ((k: string) => string);
+};
+
+function addAnimationProperty(builder: AnimationBuilder, prop: string, value: string | number) {
+ let arr = builder[prop];
+ if (Array.isArray(arr)) {
+ arr.push(value.toString());
+ } else {
+ builder[prop] = [value.toString()];
+ }
+}
+
+function animationBuilder(): AnimationBuilder {
+ return {
+ toString() {
+ let out = '';
+ for (let k in this) {
+ let value = this[k];
+ if (Array.isArray(value)) {
+ out += `\n\t${k}: ${value.join(', ')};`;
+ }
+ }
+ return out;
+ },
+ };
+}
+
+function stringifyAnimation(anim: TransitionAnimation | TransitionAnimation[]): string {
+ if (Array.isArray(anim)) {
+ return stringifyAnimations(anim);
+ } else {
+ return stringifyAnimations([anim]);
+ }
+}
+
+function stringifyAnimations(anims: TransitionAnimation[]): string {
+ const builder = animationBuilder();
+
+ for (const anim of anims) {
+ if (anim.duration) {
+ addAnimationProperty(builder, 'animation-duration', toTimeValue(anim.duration));
+ }
+ if (anim.easing) {
+ addAnimationProperty(builder, 'animation-timing-function', anim.easing);
+ }
+ if (anim.direction) {
+ addAnimationProperty(builder, 'animation-direction', anim.direction);
+ }
+ if (anim.delay) {
+ addAnimationProperty(builder, 'animation-delay', anim.delay);
+ }
+ if (anim.fillMode) {
+ addAnimationProperty(builder, 'animation-fill-mode', anim.fillMode);
+ }
+ addAnimationProperty(builder, 'animation-name', anim.name);
+ }
+
+ return builder.toString();
+}
+
+function toTimeValue(num: number | string) {
+ return typeof num === 'number' ? num + 'ms' : num;
+}