diff options
Diffstat (limited to 'packages/astro/src/runtime/server/transition.ts')
-rw-r--r-- | packages/astro/src/runtime/server/transition.ts | 268 |
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; +} |