UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

193 lines (192 loc) • 7.61 kB
import cssesc from "../../transitions/cssesc.js"; import { fade, slide } from "../../transitions/index.js"; import { escapeStyleText, markHTMLString } from "./escape.js"; const transitionNameMap = new WeakMap(); function incrementTransitionNumber(result) { let num = 1; if (transitionNameMap.has(result)) { num = transitionNameMap.get(result) + 1; } transitionNameMap.set(result, num); return num; } export function createTransitionScope(result, hash) { const num = incrementTransitionNumber(result); return `astro-${hash}-${num}`; } const getAnimations = (name) => { if (name === "fade") return fade(); if (name === "slide") return slide(); if (typeof name === "object") return name; }; const addPairs = (animations, stylesheet) => { for (const [direction, images] of Object.entries(animations)) { for (const [image, rules] of Object.entries(images)) { 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 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_".split("").reduce((v, c) => (v[c.charCodeAt(0)] = c, v), []); const reEncodeInValidStart = "-0123456789_".split("").reduce((v, c) => (v[c.charCodeAt(0)] = c, v), []); export function reEncode(s) { 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) > 65535 ? 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 punctuation 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 < 128 ? 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 function renderTransition(result, hash, animationName, transitionName) { 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 = escapeStyleText(sheet.toString()); result._metadata.extraHead.push(markHTMLString(`<style>${css}</style>`)); return scope; } export class ViewTransitionStyleSheet { modern = []; fallback = []; scope; name; constructor(scope, name) { this.scope = scope; this.name = name; } 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(""); } layer(cssText) { return cssText ? `@layer astro { ${cssText} }` : ""; } addRule(target, cssText) { this[target].push(cssText); } addAnimationRaw(image, animation) { this.addModern(image, animation); this.addFallback(image, animation); } addModern(image, animation) { const { name } = this; this.addRule("modern", `::view-transition-${image}(${name}) { ${animation} }`); } addFallback(image, animation) { 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, image, rules) { const { scope, name } = this; const animation = stringifyAnimation(rules); const prefix = direction === "backwards" ? `[data-astro-transition=back]` : direction === "forwards" ? "" : // Quote custom direction names so values that are not valid CSS identifiers // (e.g. names starting with a digit or containing spaces) still match. `[data-astro-transition=${cssesc(direction, { quotes: "double", wrap: true })}]`; 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} }`); } } // Keep animation values within their declaration while preserving valid CSS functions and strings. const CSS_VALUE_DELIMITERS = /[;{}]/g; function escapeCSSValue(value) { return value.toString().replace(CSS_VALUE_DELIMITERS, (character) => { return `\\${character.charCodeAt(0).toString(16).toUpperCase()} `; }); } function addAnimationProperty(builder, prop, value) { const escapedValue = escapeCSSValue(value); let arr = builder[prop]; if (Array.isArray(arr)) { arr.push(escapedValue); } else { builder[prop] = [escapedValue]; } } function 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; } }; } export function stringifyAnimation(anim) { if (Array.isArray(anim)) { return stringifyAnimations(anim); } else { return stringifyAnimations([anim]); } } export function stringifyAnimations(anims) { 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(); } export function toTimeValue(num) { return typeof num === "number" ? num + "ms" : num; }