BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
+175
@@ -0,0 +1,175 @@
|
||||
import { noop } from 'motion-utils';
|
||||
import { addToQueue } from './queue.mjs';
|
||||
|
||||
class ViewTransitionBuilder {
|
||||
constructor(update, options = {}) {
|
||||
this.currentSubject = "root";
|
||||
this.targets = new Map();
|
||||
/**
|
||||
* Definitions that must be resolved to elements (and assigned a
|
||||
* `view-transition-name`) rather than treated as pre-named layers.
|
||||
*/
|
||||
this.resolveDefs = new Set();
|
||||
/**
|
||||
* Per-subject crop override: `true` forces the crop (clip + object-fit:
|
||||
* cover + animated corner radii) on, `false` forces it off. A subject with
|
||||
* no entry uses the default - crop only a genuine morph (a layer present in
|
||||
* both snapshots), so a fade-only enter/exit isn't clipped to nothing.
|
||||
*/
|
||||
this.cropOverride = new Map();
|
||||
/**
|
||||
* Subjects paired with a different new-snapshot target (the second `.add()`
|
||||
* argument), so two distinct elements share one name and morph into each
|
||||
* other - a shared-element transition.
|
||||
*/
|
||||
this.pairs = new Map();
|
||||
/**
|
||||
* A `view-transition-class` to apply to each subject's resolved elements,
|
||||
* so authors can target the generated layers from CSS by class rather than
|
||||
* the opaque generated name.
|
||||
*/
|
||||
this.classNames = new Map();
|
||||
/**
|
||||
* Subjects opted out of automatic group nesting via `.group(false)`. Their
|
||||
* layer stays a flat top-level group (`view-transition-group: none`) instead
|
||||
* of nesting under its DOM-ancestor layer - so it animates independently and
|
||||
* escapes an ancestor's clip/transform (e.g. an element that lifts out of a
|
||||
* card and flies across, which nesting would clip to the card).
|
||||
*/
|
||||
this.flatGroups = new Set();
|
||||
this.notifyReady = noop;
|
||||
this.notifyReject = noop;
|
||||
this.readyPromise = new Promise((resolve, reject) => {
|
||||
this.notifyReady = resolve;
|
||||
this.notifyReject = reject;
|
||||
});
|
||||
this.update = update;
|
||||
this.options = {
|
||||
interrupt: "wait",
|
||||
...options,
|
||||
};
|
||||
// Avoid an unhandled rejection when a failed transition has no
|
||||
// `.then(_, reject)` handler attached (e.g. fire-and-forget).
|
||||
this.readyPromise.catch(noop);
|
||||
addToQueue(this);
|
||||
}
|
||||
/**
|
||||
* Target elements resolved from a selector or Element, each assigned a
|
||||
* `view-transition-name` automatically.
|
||||
*
|
||||
* Passing a second target pairs them: the first is resolved in the old
|
||||
* snapshot and the second in the new, sharing one name so two *different*
|
||||
* elements morph into each other (e.g. `.add(card, ".modal")`). Symmetric -
|
||||
* pass them the other way round to morph back.
|
||||
*/
|
||||
add(subject, newSubject) {
|
||||
this.currentSubject = subject;
|
||||
this.resolveDefs.add(subject);
|
||||
if (newSubject !== undefined)
|
||||
this.pairs.set(subject, newSubject);
|
||||
// Register the subject so it participates (and gets an automatic
|
||||
// layout/morph animation) even without an explicit enter/exit/layout.
|
||||
if (!this.targets.has(subject))
|
||||
this.targets.set(subject, {});
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Control this subject's crop (clip + `object-fit: cover` + animated
|
||||
* corners). By default a subject auto-crops only when it actually morphs -
|
||||
* present in both snapshots (a survivor, or an `.add(a, b)` pair). A
|
||||
* fade-only enter/exit has no second box to crop against, so it's left to
|
||||
* the browser default; in particular the `overflow: clip` a crop adds would
|
||||
* otherwise clip a mis-sized enter/exit layer to nothing.
|
||||
*
|
||||
* `.crop(false)` forces the crop off (e.g. a text morph, where
|
||||
* `object-fit: cover` clips glyphs as the box grows); `.crop(true)` forces
|
||||
* it on for a non-morph the default wouldn't otherwise crop.
|
||||
*/
|
||||
crop(enabled = true) {
|
||||
this.cropOverride.set(this.currentSubject, enabled);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* By default a subject's layer nests under its nearest DOM-ancestor layer
|
||||
* (`view-transition-group: contain`), so the ancestor's clip/transform/opacity
|
||||
* apply to it through the transition - mirroring how the DOM actually paints,
|
||||
* and letting a wrapper crop its child for the whole morph rather than only
|
||||
* once the live DOM takes back over. (Needs a browser that supports nested
|
||||
* view-transition groups; elsewhere it degrades to the flat default.)
|
||||
*
|
||||
* Call `.group(false)` to opt out: the layer stays flat and top-level, so it
|
||||
* animates independently and escapes an ancestor's clip - e.g. an element
|
||||
* that should lift out of a card and fly across, which nesting would clip.
|
||||
*/
|
||||
group(enabled = true) {
|
||||
enabled
|
||||
? this.flatGroups.delete(this.currentSubject)
|
||||
: this.flatGroups.add(this.currentSubject);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Tag this subject's generated layers with a `view-transition-class`, so
|
||||
* they can be targeted from CSS - `::view-transition-group(.name)`,
|
||||
* `::view-transition-old/new(.name)`, `::view-transition-image-pair(.name)`
|
||||
* - without the opaque generated `view-transition-name`. Because `.add()`
|
||||
* can match many elements, a shared class targets them all at once (and,
|
||||
* for a pair, both ends). The escape hatch for z-index / custom keyframes
|
||||
* on a morph layer.
|
||||
*/
|
||||
class(name) {
|
||||
this.classNames.set(this.currentSubject, name);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Set the transition for this subject's morph. The morph is enabled
|
||||
* automatically by `.add()`; this just customises its timing (duration,
|
||||
* easing, a `delay`/`stagger`, …). On the implicit `root` subject it also
|
||||
* opts the page into the transition (the root crossfade).
|
||||
*/
|
||||
layout(options = {}) {
|
||||
this.updateTarget("layout", {}, options);
|
||||
return this;
|
||||
}
|
||||
enter(keyframes, options) {
|
||||
this.updateTarget("enter", keyframes, options);
|
||||
return this;
|
||||
}
|
||||
exit(keyframes, options) {
|
||||
this.updateTarget("exit", keyframes, options);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Animate the new view directly, whether the element is appearing or
|
||||
* persisting (unlike `.enter()`, which only fires for a pure newcomer).
|
||||
* Pair with `.old()` for a crossfade or slide-through.
|
||||
*/
|
||||
new(keyframes, options) {
|
||||
this.updateTarget("new", keyframes, options);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Animate the old view directly, whether the element is leaving or
|
||||
* persisting (unlike `.exit()`, which only fires for a pure leaver).
|
||||
*/
|
||||
old(keyframes, options) {
|
||||
this.updateTarget("old", keyframes, options);
|
||||
return this;
|
||||
}
|
||||
updateTarget(target, keyframes, options = {}) {
|
||||
const { currentSubject, targets } = this;
|
||||
if (!targets.has(currentSubject)) {
|
||||
targets.set(currentSubject, {});
|
||||
}
|
||||
const targetData = targets.get(currentSubject);
|
||||
targetData[target] = { keyframes, options };
|
||||
}
|
||||
then(resolve, reject) {
|
||||
return this.readyPromise.then(resolve, reject);
|
||||
}
|
||||
}
|
||||
function animateView(update, options = {}) {
|
||||
return new ViewTransitionBuilder(update, options);
|
||||
}
|
||||
|
||||
export { ViewTransitionBuilder, animateView };
|
||||
//# sourceMappingURL=index.mjs.map
|
||||
+1
File diff suppressed because one or more lines are too long
+59
@@ -0,0 +1,59 @@
|
||||
import { removeItem } from 'motion-utils';
|
||||
import { microtask } from '../frameloop/microtask.mjs';
|
||||
import { startViewAnimation } from './start.mjs';
|
||||
|
||||
let builders = [];
|
||||
let current = null;
|
||||
function next() {
|
||||
current = null;
|
||||
const [nextBuilder] = builders;
|
||||
if (nextBuilder)
|
||||
start(nextBuilder);
|
||||
}
|
||||
function start(builder) {
|
||||
removeItem(builders, builder);
|
||||
current = builder;
|
||||
startViewAnimation(builder)
|
||||
.then((animation) => {
|
||||
builder.notifyReady(animation);
|
||||
return animation.finished;
|
||||
})
|
||||
// A genuinely failed transition (a throwing update) rejects the
|
||||
// builder; a skipped/interrupted one resolves with no animations (see
|
||||
// start.ts). Either way, advance the queue - else later transitions hang.
|
||||
.catch((error) => builder.notifyReject(error))
|
||||
.finally(next);
|
||||
}
|
||||
function processQueue() {
|
||||
/**
|
||||
* Iterate backwards over the builders array. We can ignore the
|
||||
* "wait" animations. If we have an interrupting animation in the
|
||||
* queue then we need to batch all preceeding animations into it.
|
||||
* Currently this only batches the update functions but will also
|
||||
* need to batch the targets.
|
||||
*/
|
||||
for (let i = builders.length - 1; i >= 0; i--) {
|
||||
const builder = builders[i];
|
||||
const { interrupt } = builder.options;
|
||||
if (interrupt === "immediate") {
|
||||
const batchedUpdates = builders.slice(0, i + 1).map((b) => b.update);
|
||||
const remaining = builders.slice(i + 1);
|
||||
builder.update = () => {
|
||||
batchedUpdates.forEach((update) => update());
|
||||
};
|
||||
// Put the current builder at the front, followed by any "wait" builders
|
||||
builders = [builder, ...remaining];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!current || builders[0]?.options.interrupt === "immediate") {
|
||||
next();
|
||||
}
|
||||
}
|
||||
function addToQueue(builder) {
|
||||
builders.push(builder);
|
||||
microtask.render(processQueue);
|
||||
}
|
||||
|
||||
export { addToQueue };
|
||||
//# sourceMappingURL=queue.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"queue.mjs","sources":["../../../src/view/queue.ts"],"sourcesContent":["import { removeItem } from \"motion-utils\"\nimport type { ViewTransitionBuilder } from \".\"\nimport { microtask } from \"../frameloop/microtask\"\nimport { startViewAnimation } from \"./start\"\n\nlet builders: ViewTransitionBuilder[] = []\n\nlet current: ViewTransitionBuilder | null = null\n\nfunction next() {\n current = null\n const [nextBuilder] = builders\n if (nextBuilder) start(nextBuilder)\n}\n\nfunction start(builder: ViewTransitionBuilder) {\n removeItem(builders, builder)\n current = builder\n startViewAnimation(builder)\n .then((animation) => {\n builder.notifyReady(animation)\n return animation.finished\n })\n // A genuinely failed transition (a throwing update) rejects the\n // builder; a skipped/interrupted one resolves with no animations (see\n // start.ts). Either way, advance the queue - else later transitions hang.\n .catch((error) => builder.notifyReject(error))\n .finally(next)\n}\n\nfunction processQueue() {\n /**\n * Iterate backwards over the builders array. We can ignore the\n * \"wait\" animations. If we have an interrupting animation in the\n * queue then we need to batch all preceeding animations into it.\n * Currently this only batches the update functions but will also\n * need to batch the targets.\n */\n for (let i = builders.length - 1; i >= 0; i--) {\n const builder = builders[i]\n const { interrupt } = builder.options\n\n if (interrupt === \"immediate\") {\n const batchedUpdates = builders.slice(0, i + 1).map((b) => b.update)\n const remaining = builders.slice(i + 1)\n\n builder.update = () => {\n batchedUpdates.forEach((update) => update())\n }\n\n // Put the current builder at the front, followed by any \"wait\" builders\n builders = [builder, ...remaining]\n\n break\n }\n }\n\n if (!current || builders[0]?.options.interrupt === \"immediate\") {\n next()\n }\n}\n\nexport function addToQueue(builder: ViewTransitionBuilder) {\n builders.push(builder)\n microtask.render(processQueue)\n}\n"],"names":[],"mappings":";;;;AAKA,IAAI,QAAQ,GAA4B,EAAE;AAE1C,IAAI,OAAO,GAAiC,IAAI;AAEhD,SAAS,IAAI,GAAA;IACT,OAAO,GAAG,IAAI;AACd,IAAA,MAAM,CAAC,WAAW,CAAC,GAAG,QAAQ;AAC9B,IAAA,IAAI,WAAW;QAAE,KAAK,CAAC,WAAW,CAAC;AACvC;AAEA,SAAS,KAAK,CAAC,OAA8B,EAAA;AACzC,IAAA,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC7B,OAAO,GAAG,OAAO;IACjB,kBAAkB,CAAC,OAAO;AACrB,SAAA,IAAI,CAAC,CAAC,SAAS,KAAI;AAChB,QAAA,OAAO,CAAC,WAAW,CAAC,SAAS,CAAC;QAC9B,OAAO,SAAS,CAAC,QAAQ;AAC7B,IAAA,CAAC;;;;AAIA,SAAA,KAAK,CAAC,CAAC,KAAK,KAAK,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC;SAC5C,OAAO,CAAC,IAAI,CAAC;AACtB;AAEA,SAAS,YAAY,GAAA;AACjB;;;;;;AAMG;AACH,IAAA,KAAK,IAAI,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE;AAC3C,QAAA,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC;AAC3B,QAAA,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,OAAO;AAErC,QAAA,IAAI,SAAS,KAAK,WAAW,EAAE;YAC3B,MAAM,cAAc,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC;YACpE,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;AAEvC,YAAA,OAAO,CAAC,MAAM,GAAG,MAAK;gBAClB,cAAc,CAAC,OAAO,CAAC,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;AAChD,YAAA,CAAC;;AAGD,YAAA,QAAQ,GAAG,CAAC,OAAO,EAAE,GAAG,SAAS,CAAC;YAElC;QACJ;IACJ;AAEA,IAAA,IAAI,CAAC,OAAO,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,SAAS,KAAK,WAAW,EAAE;AAC5D,QAAA,IAAI,EAAE;IACV;AACJ;AAEM,SAAU,UAAU,CAAC,OAA8B,EAAA;AACrD,IAAA,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC;AACtB,IAAA,SAAS,CAAC,MAAM,CAAC,YAAY,CAAC;AAClC;;;;"}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
import { warnOnce, secondsToMilliseconds } from 'motion-utils';
|
||||
import { GroupAnimation } from '../animation/GroupAnimation.mjs';
|
||||
import { NativeAnimation } from '../animation/NativeAnimation.mjs';
|
||||
import { NativeAnimationWrapper } from '../animation/NativeAnimationWrapper.mjs';
|
||||
import { getValueTransition } from '../animation/utils/get-value-transition.mjs';
|
||||
import { mapEasingToNativeEasing } from '../animation/waapi/easing/map-easing.mjs';
|
||||
import { applyGeneratorOptions } from '../animation/waapi/utils/apply-generator.mjs';
|
||||
import { cornerRadiusProps } from '../utils/border-radius.mjs';
|
||||
import { resolveElements } from '../utils/resolve-elements.mjs';
|
||||
import { assignViewTransitionNames, releaseViewTransitionNames } from './utils/assign-names.mjs';
|
||||
import { chooseLayerType } from './utils/choose-layer-type.mjs';
|
||||
import { css } from './utils/css.mjs';
|
||||
import { getViewAnimationLayerInfo } from './utils/get-layer-info.mjs';
|
||||
import { getViewAnimations } from './utils/get-view-animations.mjs';
|
||||
import { hasTarget } from './utils/has-target.mjs';
|
||||
|
||||
const definitionNames = ["layout", "enter", "exit", "new", "old"];
|
||||
/**
|
||||
* Whether a computed border-radius is square (every component zero). Splitting
|
||||
* on whitespace handles two-value/elliptical radii like "0px 20px" - a leading
|
||||
* `parseFloat` alone would misread the non-zero vertical radius as square.
|
||||
*/
|
||||
const isSquareRadius = (radius) => radius.split(" ").every((value) => parseFloat(value) === 0);
|
||||
/**
|
||||
* The `ViewTransitionTarget` buckets driving each generated layer type, in
|
||||
* priority order - the inverse of `chooseLayerType`. The new view is driven by
|
||||
* `new`/`enter`, the old by `old`/`exit`. `group-children`/`image-pair` have no
|
||||
* bucket; they follow the default layout timing.
|
||||
*/
|
||||
const typeBuckets = {
|
||||
group: ["layout"],
|
||||
new: ["new", "enter"],
|
||||
old: ["old", "exit"],
|
||||
};
|
||||
/**
|
||||
* Default "absent" origin for a single-value keyframe, by pseudo type, so e.g.
|
||||
* `enter({ scale: 1 })` grows in from 0.85 and `exit({ opacity: 0 })` fades
|
||||
* from 1. `enter` prefers the matching `exit` value over these (see below).
|
||||
*/
|
||||
const ORIGIN_DEFAULTS = {
|
||||
new: { opacity: 0, scale: 0.85 },
|
||||
old: { opacity: 1, scale: 1 },
|
||||
};
|
||||
/**
|
||||
* How much two box aspect ratios must differ before a morph is treated as
|
||||
* aspect-changing (and so worth cropping). Matches the projection engine's
|
||||
* `preserve-aspect` threshold, so small layout jitter doesn't trigger a crop.
|
||||
*/
|
||||
const ASPECT_TOLERANCE = 0.2;
|
||||
function startViewAnimation(builder) {
|
||||
const { update, targets, resolveDefs, cropOverride, pairs, classNames, flatGroups, options: defaultOptions, } = builder;
|
||||
if (!document.startViewTransition) {
|
||||
// An async IIFE (not `new Promise(async …)`) so a throwing/rejecting
|
||||
// update rejects this promise rather than leaving it unsettled.
|
||||
return (async () => {
|
||||
await update();
|
||||
return new GroupAnimation([]);
|
||||
})();
|
||||
}
|
||||
/**
|
||||
* Resolve any selector/Element targets to layer names, assigning a
|
||||
* `view-transition-name` to each element as we go. We run this before the
|
||||
* update (so the elements are captured in the old snapshot) and again
|
||||
* after it (for the new snapshot). An element present in both keeps the
|
||||
* same name and animates as a single `group` layer.
|
||||
*/
|
||||
const nameRegistry = new Map();
|
||||
const assigned = [];
|
||||
/**
|
||||
* Elements we tagged with a `view-transition-class` (via `.class()`),
|
||||
* tracked separately from `assigned` so cleanup removes the class without
|
||||
* ever stripping an author's own inline `view-transition-name`.
|
||||
*/
|
||||
const classed = [];
|
||||
/**
|
||||
* Elements we set a `view-transition-group` on (for nesting), tracked for
|
||||
* cleanup. `clipChildren` collects the names of nested parents that clip in
|
||||
* the live layout, so their `::view-transition-group-children` is clipped
|
||||
* through the transition - not just once the live DOM takes back over.
|
||||
*/
|
||||
const grouped = [];
|
||||
const clipChildren = new Set();
|
||||
const layerTargets = new Map();
|
||||
const croppedNames = new Set();
|
||||
/**
|
||||
* Each layer's explicit `.crop(true | false)` override (by resolved name),
|
||||
* so `finalizeCrop` can let an author's choice win over the morph default.
|
||||
*/
|
||||
const cropForName = new Map();
|
||||
/**
|
||||
* Each layer's stagger position (index + total) within its subject, per
|
||||
* snapshot. Resolving against the snapshot the layer belongs to keeps
|
||||
* stagger correct when `update()` replaces the matched elements, and lets
|
||||
* us skip a layer that's absent from a snapshot (e.g. an exited element
|
||||
* has no `new` pseudo-element).
|
||||
*/
|
||||
const layerStagger = new Map();
|
||||
/**
|
||||
* Names allocated for a paired subject in the old snapshot, replayed onto
|
||||
* its new-snapshot target so both ends share a layer and morph.
|
||||
*/
|
||||
const pairNames = new Map();
|
||||
/**
|
||||
* The old (`from`) elements of each paired subject, so their names can be
|
||||
* transferred off before the new (`to`) elements inherit them.
|
||||
*/
|
||||
const pairFrom = new Map();
|
||||
const resolveLayers = (phase) => {
|
||||
targets.forEach((target, definition) => {
|
||||
const className = classNames.get(definition);
|
||||
/**
|
||||
* Nest each resolved layer under its DOM-ancestor layer by default
|
||||
* (`contain`), so an ancestor's clip/transform/opacity reach it
|
||||
* through the transition; `.group(false)` opts a subject out (`none`)
|
||||
* to stay flat and escape. Skipped for root / pre-named layers, which
|
||||
* aren't elements we resolve and style.
|
||||
*/
|
||||
const group = definition === "root" || !resolveDefs.has(definition)
|
||||
? undefined
|
||||
: flatGroups.has(definition)
|
||||
? "none"
|
||||
: "contain";
|
||||
let names;
|
||||
if (definition === "root" || !resolveDefs.has(definition)) {
|
||||
names = [definition];
|
||||
}
|
||||
else if (pairs.has(definition)) {
|
||||
/**
|
||||
* Paired morph: name the old target in the old snapshot, then
|
||||
* force the same name(s) onto the new target in the new one, so
|
||||
* two different elements morph as a single layer.
|
||||
*/
|
||||
if (phase === "old") {
|
||||
pairFrom.set(definition, resolveElements(definition));
|
||||
names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed, group, grouped, clipChildren);
|
||||
pairNames.set(definition, names);
|
||||
}
|
||||
else {
|
||||
/**
|
||||
* Transfer the name(s) off the `from` elements before the
|
||||
* `to` elements inherit them. A `from` that survives into
|
||||
* the new snapshot (e.g. hidden with `visibility: hidden`
|
||||
* rather than removed) would otherwise keep the name and
|
||||
* collide - "duplicate view-transition-name".
|
||||
*/
|
||||
for (const el of pairFrom.get(definition) ?? []) {
|
||||
el.style?.removeProperty("view-transition-name");
|
||||
/**
|
||||
* Drop the old end from the registry too, so the new
|
||||
* end alone supplies this name's `new` crop radii - we
|
||||
* neither re-measure nor get ordered by a stale element.
|
||||
*/
|
||||
nameRegistry.delete(el);
|
||||
}
|
||||
names = assignViewTransitionNames(pairs.get(definition), nameRegistry, assigned, pairNames.get(definition), className, classed, group, grouped, clipChildren);
|
||||
}
|
||||
}
|
||||
else {
|
||||
names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed, group, grouped, clipChildren);
|
||||
}
|
||||
// Record any explicit `.crop(true | false)` per resolved name; the
|
||||
// crop set itself is computed later by `finalizeCrop` (it needs both
|
||||
// snapshots to know which morphs change aspect ratio).
|
||||
const override = cropOverride.get(definition);
|
||||
names.forEach((name, index) => {
|
||||
/**
|
||||
* If two subjects resolve to the same element, merge their
|
||||
* definitions so neither subject's animations are dropped.
|
||||
*/
|
||||
const existing = layerTargets.get(name);
|
||||
layerTargets.set(name, existing && existing !== target
|
||||
? { ...existing, ...target }
|
||||
: target);
|
||||
if (override !== undefined)
|
||||
cropForName.set(name, override);
|
||||
const stagger = layerStagger.get(name) ?? {};
|
||||
stagger[phase] = [index, names.length];
|
||||
layerStagger.set(name, stagger);
|
||||
});
|
||||
});
|
||||
};
|
||||
/**
|
||||
* The stagger index/total for a layer, resolved against the snapshot it
|
||||
* belongs to. Returns index -1 when the layer is absent from that snapshot
|
||||
* so the caller can skip a pseudo-element that doesn't exist.
|
||||
*/
|
||||
const staggerPosition = (name, type) => {
|
||||
const stagger = layerStagger.get(name);
|
||||
const position = type === "old"
|
||||
? stagger?.old
|
||||
: type === "new"
|
||||
? stagger?.new
|
||||
: // group / group-children / image-pair persist across both.
|
||||
stagger?.new ?? stagger?.old;
|
||||
return position ?? [-1, 1];
|
||||
};
|
||||
/**
|
||||
* Merge default + per-layer transition options for a generated layer and
|
||||
* resolve any stagger/delay function against this element's position. Used
|
||||
* by both the morph-retiming and crop corner-radius passes.
|
||||
*/
|
||||
const resolveLayerTransition = (target, type, transitionName, index, total) => {
|
||||
const transition = mergeTransition(getValueTransition(defaultOptions, transitionName), getValueTransition((layerOptions(target, type) ?? {}), transitionName));
|
||||
if (typeof transition.delay === "function") {
|
||||
transition.delay = transition.delay(index, total);
|
||||
}
|
||||
return transition;
|
||||
};
|
||||
/**
|
||||
* Resolve a layer's group (`layout`) timing to plain WAAPI values: native
|
||||
* ms `delay`/`duration` and a baked `ease`. The single source of group
|
||||
* timing, shared by the generated-group retiming and the crop corner-radius
|
||||
* pass so the rounded clip animates on exactly the box's timing. It returns
|
||||
* no generator `type` (the WAAPI-only `NativeAnimation` rejects a string
|
||||
* type) nor `repeat`/`times` (which the group's `updateTiming` ignores), so
|
||||
* none of them can leak into the radius animation and desync it.
|
||||
*/
|
||||
const resolveGroupTiming = (name) => {
|
||||
const [index, total] = staggerPosition(name, "group");
|
||||
const transition = resolveLayerTransition(layerTargets.get(name), "group", "layout", index === -1 ? 0 : index, total);
|
||||
transition.duration && (transition.duration = secondsToMilliseconds(transition.duration));
|
||||
const { delay = 0, duration, ease } = applyGeneratorOptions(transition);
|
||||
return { delay: secondsToMilliseconds(delay), duration, ease };
|
||||
};
|
||||
/**
|
||||
* Each layer's measured box + corner radii per snapshot. The box lets
|
||||
* `finalizeCrop` tell whether a morph's aspect ratio changed (the only case
|
||||
* worth cropping); the radii let a cropped morph's group clip animate each
|
||||
* corner from the old element's radius to the new element's, keeping it
|
||||
* rounded where `overflow: clip` would otherwise square the corners.
|
||||
*
|
||||
* We never flatten the source for capture (a snapshot is a paint of the live
|
||||
* DOM, so squaring an element just for its capture would flash one real
|
||||
* square frame). For an aspect-changing morph `object-fit: cover` crops each
|
||||
* snapshot's own baked corners off-screen mid-morph, so the animated clip is
|
||||
* the only visible corner; a near-same-aspect forced crop (`.crop(true)`)
|
||||
* can't hide the outgoing snapshot's silhouette, but the endpoints coincide.
|
||||
*/
|
||||
const cropMeasurements = new Map();
|
||||
const measureLayers = (phase) => nameRegistry.forEach((name, element) => {
|
||||
const el = element;
|
||||
const rect = el.getBoundingClientRect?.();
|
||||
if (rect && rect.height) {
|
||||
const style = getComputedStyle(el);
|
||||
const radii = {};
|
||||
for (const corner of cornerRadiusProps) {
|
||||
radii[corner] = style[corner];
|
||||
}
|
||||
const entry = cropMeasurements.get(name) ?? {};
|
||||
entry[phase] = { width: rect.width, height: rect.height, radii };
|
||||
cropMeasurements.set(name, entry);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* With both snapshots measured, settle which layers crop. The default crops
|
||||
* only a morph whose aspect ratio *changes* between snapshots - the one case
|
||||
* where `object-fit: cover` does real work. A same-aspect morph or a
|
||||
* fade-only layer is left uncropped: its corners scale naturally (no flash
|
||||
* from squaring, no `overflow: clip` eating its shadow) and a backdrop can't
|
||||
* be clipped to nothing. An explicit `.crop(true | false)` overrides either
|
||||
* way. Runs after both snapshots are measured, since aspect needs both.
|
||||
*/
|
||||
const finalizeCrop = () => {
|
||||
croppedNames.clear();
|
||||
for (const name of layerStagger.keys()) {
|
||||
if (name === "root")
|
||||
continue;
|
||||
// An explicit `.crop(true | false)` wins; otherwise crop a morph
|
||||
// whose aspect ratio changed.
|
||||
if (cropForName.get(name) ?? aspectChanged(name)) {
|
||||
croppedNames.add(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Whether a layer is a morph whose box aspect ratio changed between
|
||||
* snapshots (beyond a small tolerance). Fade-only layers (one snapshot) are
|
||||
* never "changed".
|
||||
*/
|
||||
const aspectChanged = (name) => {
|
||||
const box = cropMeasurements.get(name);
|
||||
if (!box?.old || !box?.new || !box.old.height || !box.new.height) {
|
||||
return false;
|
||||
}
|
||||
return (Math.abs(box.old.width / box.old.height -
|
||||
box.new.width / box.new.height) > ASPECT_TOLERANCE);
|
||||
};
|
||||
/**
|
||||
* Write the persistent view-transition CSS: suppress root capture when the
|
||||
* root has no animations of its own; force linear timing (baked into the
|
||||
* keyframes, so we can retime later via updateTiming); and clip +
|
||||
* object-fit: cover every cropped morph (the UA default overflows on
|
||||
* aspect-ratio change).
|
||||
*
|
||||
* `css.commit` replaces rather than appends, so we re-set the full rule set
|
||||
* each call - the crop rules are only known after `finalizeCrop` runs in the
|
||||
* update callback, so the second call writes them.
|
||||
*/
|
||||
const commitViewCSS = () => {
|
||||
if (!hasTarget("root", targets)) {
|
||||
css.set(":root", { "view-transition-name": "none" });
|
||||
}
|
||||
css.set("::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*)", { "animation-timing-function": "linear !important" });
|
||||
croppedNames.forEach((name) => {
|
||||
css.set(`::view-transition-group(${name})`, { overflow: "clip" });
|
||||
css.set(`::view-transition-old(${name}), ::view-transition-new(${name})`, { width: "100%", height: "100%", "object-fit": "cover" });
|
||||
});
|
||||
/**
|
||||
* Clip the nested children of any layer that clips in the live layout,
|
||||
* so a wrapper crops its child for the whole morph (mirroring the DOM)
|
||||
* rather than only at the live-DOM handoff. No-op on browsers without
|
||||
* nested view-transition groups.
|
||||
*/
|
||||
clipChildren.forEach((name) => {
|
||||
css.set(`::view-transition-group-children(${name})`, {
|
||||
overflow: "clip",
|
||||
});
|
||||
});
|
||||
css.commit(); // Write
|
||||
};
|
||||
const cleanup = () => {
|
||||
releaseViewTransitionNames(assigned, classed, grouped);
|
||||
css.remove(); // Write
|
||||
};
|
||||
const callback = async () => {
|
||||
await update();
|
||||
/**
|
||||
* Re-resolve so elements created by the update are named for the new
|
||||
* snapshot, then measure them. With both snapshots measured we can
|
||||
* settle the crop set (aspect-changing morphs + forced).
|
||||
*/
|
||||
resolveLayers("new");
|
||||
measureLayers("new");
|
||||
finalizeCrop();
|
||||
/**
|
||||
* Re-commit the crop CSS unconditionally: `finalizeCrop` is computed
|
||||
* here (after both snapshots are measured), so the clip rules must be
|
||||
* (re)written to match the settled set.
|
||||
*/
|
||||
commitViewCSS();
|
||||
};
|
||||
let transition;
|
||||
try {
|
||||
resolveLayers("old");
|
||||
/**
|
||||
* Measure the old snapshot against the optimistic crop set (the new
|
||||
* snapshot doesn't exist yet, so aspect change can't be known here;
|
||||
* `finalizeCrop` settles it post-update).
|
||||
*/
|
||||
measureLayers("old");
|
||||
commitViewCSS();
|
||||
transition = document.startViewTransition(callback);
|
||||
}
|
||||
catch (error) {
|
||||
/**
|
||||
* The prelude writes inline names before the transition exists. If it
|
||||
* throws (e.g. startViewTransition rejects in a bad UA state), release
|
||||
* them so we neither leak DOM state nor stall the queue on a promise
|
||||
* that never settles - hand back a rejection it can advance past.
|
||||
*/
|
||||
cleanup();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
transition.finished.finally(cleanup);
|
||||
return new Promise((resolve, reject) => {
|
||||
transition.ready
|
||||
.then(() => {
|
||||
const generatedViewAnimations = getViewAnimations();
|
||||
const animations = [];
|
||||
/**
|
||||
* Create animations for each of our explicitly-defined subjects.
|
||||
* `opacityAnimated` additionally tracks which `${name}:${type}`
|
||||
* we faded, so we can keep the UA `plus-lighter` blend only for a
|
||||
* genuine opacity crossfade (both sides fading) and drop it for a
|
||||
* slide/transform, where additive compositing would flash bright.
|
||||
*/
|
||||
const explicitlyAnimated = new Set();
|
||||
const opacityAnimated = new Set();
|
||||
layerTargets.forEach((target, name) => {
|
||||
const stagger = layerStagger.get(name);
|
||||
/**
|
||||
* Presence: `enter` only fires for a pure newcomer (a new
|
||||
* view with no old), `exit` only for a pure leaver. A
|
||||
* survivor (both) gets neither - it just morphs.
|
||||
*/
|
||||
const enterApplies = !!stagger?.new && !stagger?.old;
|
||||
const exitApplies = !!stagger?.old && !stagger?.new;
|
||||
for (const key of definitionNames) {
|
||||
if (!target[key])
|
||||
continue;
|
||||
if (key === "enter" && !enterApplies)
|
||||
continue;
|
||||
if (key === "exit" && !exitApplies)
|
||||
continue;
|
||||
const type = chooseLayerType(key);
|
||||
const [index, total] = staggerPosition(name, type);
|
||||
// Skip a layer absent from its snapshot.
|
||||
if (index === -1)
|
||||
continue;
|
||||
const { keyframes, options } = target[key];
|
||||
for (let [valueName, valueKeyframes] of Object.entries(keyframes)) {
|
||||
// Skip only missing values - `0` (e.g. opacity: 0)
|
||||
// is valid and must reach the from-value inference.
|
||||
if (valueKeyframes == null)
|
||||
continue;
|
||||
/**
|
||||
* The view path hands keyframes straight to WAAPI,
|
||||
* so Motion's `x`/`y` shorthands (compiled to
|
||||
* `transform` only via the value pipeline) have no
|
||||
* effect. Warn and skip - use `transform`/`translate`.
|
||||
*/
|
||||
if (valueName === "x" || valueName === "y") {
|
||||
warnOnce(false, `animateView() animates view-transition layers with CSS properties; the "${valueName}" shorthand has no effect - use transform, e.g. { transform: "translateX(40px)" }.`);
|
||||
continue;
|
||||
}
|
||||
/**
|
||||
* enter/exit win over new/old on a shared property -
|
||||
* skip it here when the gated bucket also defines it.
|
||||
*/
|
||||
if (key === "new" &&
|
||||
enterApplies &&
|
||||
target.enter?.keyframes[valueName] != null) {
|
||||
continue;
|
||||
}
|
||||
if (key === "old" &&
|
||||
exitApplies &&
|
||||
target.exit?.keyframes[valueName] != null) {
|
||||
continue;
|
||||
}
|
||||
const valueOptions = mergeTransition(getValueTransition(defaultOptions, valueName), getValueTransition(options, valueName));
|
||||
/**
|
||||
* Infer an origin for a single-value keyframe. An
|
||||
* `enter` mirrors the matching `exit` value (a
|
||||
* defined exit reverses into the enter for free);
|
||||
* otherwise the per-type default (opacity 0/1, scale
|
||||
* 0.85). No default -> left as-is (animates from the
|
||||
* live value).
|
||||
*
|
||||
* `new`/`old` fire for survivors too, where only the
|
||||
* opacity crossfade default applies - a transform
|
||||
* default like scale 0.85 would pop a persisting
|
||||
* element, so gate it on the layer actually
|
||||
* entering/leaving.
|
||||
*/
|
||||
if (!Array.isArray(valueKeyframes)) {
|
||||
const exitValue = key === "enter"
|
||||
? target.exit?.keyframes[valueName]
|
||||
: undefined;
|
||||
const allowDefault = valueName === "opacity" ||
|
||||
(type === "new" ? enterApplies : exitApplies);
|
||||
const from = exitValue != null
|
||||
? Array.isArray(exitValue)
|
||||
? exitValue[exitValue.length - 1]
|
||||
: exitValue
|
||||
: allowDefault
|
||||
? ORIGIN_DEFAULTS[type]?.[valueName]
|
||||
: undefined;
|
||||
if (from !== undefined) {
|
||||
valueKeyframes = [from, valueKeyframes];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolve stagger function if provided, per element
|
||||
* across this subject's resolved layers.
|
||||
*/
|
||||
if (typeof valueOptions.delay === "function") {
|
||||
valueOptions.delay = valueOptions.delay(index, total);
|
||||
}
|
||||
valueOptions.duration && (valueOptions.duration = secondsToMilliseconds(valueOptions.duration));
|
||||
valueOptions.delay && (valueOptions.delay = secondsToMilliseconds(valueOptions.delay));
|
||||
animations.push(new NativeAnimation({
|
||||
...valueOptions,
|
||||
element: document.documentElement,
|
||||
name: valueName,
|
||||
pseudoElement: `::view-transition-${type}(${name})`,
|
||||
keyframes: valueKeyframes,
|
||||
}));
|
||||
explicitlyAnimated.add(`${name}:${type}`);
|
||||
if (valueName === "opacity") {
|
||||
opacityAnimated.add(`${name}:${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Handle browser generated animations
|
||||
*/
|
||||
for (const animation of generatedViewAnimations) {
|
||||
if (animation.playState === "finished")
|
||||
continue;
|
||||
const { effect } = animation;
|
||||
if (!effect || !(effect instanceof KeyframeEffect))
|
||||
continue;
|
||||
const { pseudoElement } = effect;
|
||||
if (!pseudoElement)
|
||||
continue;
|
||||
const name = getViewAnimationLayerInfo(pseudoElement);
|
||||
if (!name)
|
||||
continue;
|
||||
const targetDefinition = layerTargets.get(name.layer);
|
||||
/**
|
||||
* We built our own animation for this layer, so drop the
|
||||
* browser-generated fade we're replacing. The UA
|
||||
* `plus-lighter` blend is a *separate* generated animation on
|
||||
* the same pseudo (it sets `mix-blend-mode` in its keyframes):
|
||||
* keep it *only* for a true opacity crossfade - both sides
|
||||
* fading - so a symmetric crossfade composites without
|
||||
* darkening, but a slide/transform (where both layers stay
|
||||
* opaque and overlap) doesn't flash bright from the addition.
|
||||
*/
|
||||
if (explicitlyAnimated.has(`${name.layer}:${name.type}`)) {
|
||||
const isCrossfade = opacityAnimated.has(`${name.layer}:new`) &&
|
||||
opacityAnimated.has(`${name.layer}:old`);
|
||||
if (isCrossfade &&
|
||||
effect
|
||||
.getKeyframes()
|
||||
.some((keyframe) => keyframe.mixBlendMode)) {
|
||||
animations.push(new NativeAnimationWrapper(animation));
|
||||
}
|
||||
else {
|
||||
animation.cancel();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
/**
|
||||
* Drop the orphaned half of the default crossfade. The UA
|
||||
* fades old out and new in as a *pair*; if the opposing half
|
||||
* was explicitly overridden with something other than an
|
||||
* opacity fade (a clip or transform reveal), this side's
|
||||
* default opacity fade has no partner - left to run it would
|
||||
* dissolve what should be a static backdrop (e.g.
|
||||
* `.new({ clipPath })` should reveal over a still old view,
|
||||
* not fade the old out around the growing clip). Cancel it -
|
||||
* and its `plus-lighter` sibling on the same pseudo, which
|
||||
* would otherwise flash bright where the two opaque layers
|
||||
* overlap. A genuine crossfade (the opposing side also fading
|
||||
* opacity) keeps both halves and is handled above.
|
||||
*/
|
||||
const opposite = name.type === "old"
|
||||
? "new"
|
||||
: name.type === "new"
|
||||
? "old"
|
||||
: undefined;
|
||||
if (opposite &&
|
||||
explicitlyAnimated.has(`${name.layer}:${opposite}`) &&
|
||||
!opacityAnimated.has(`${name.layer}:${opposite}`)) {
|
||||
animation.cancel();
|
||||
continue;
|
||||
}
|
||||
/**
|
||||
* Otherwise retime the browser-generated animation to
|
||||
* Motion's timing. This auto-enables the layout (group)
|
||||
* morph for any resolved/named target, and applies the
|
||||
* default timing to old/new layers we haven't explicitly
|
||||
* overridden.
|
||||
*
|
||||
* group + group-children both follow the layout timing so
|
||||
* the nesting container stays in sync with the morph.
|
||||
*/
|
||||
/**
|
||||
* A survivor's old + new are the two halves of one
|
||||
* `plus-lighter` crossfade. They must share identical timing
|
||||
* (so their opacities stay mirrored and sum to 1 - else the
|
||||
* additive blend flashes bright wherever both are partly
|
||||
* visible) and fade linearly (the bounce belongs on the
|
||||
* group's geometry, not the opacity). So time them as the
|
||||
* group, rather than via their own - potentially staggered,
|
||||
* or enter/exit-derived - old/new options.
|
||||
*/
|
||||
const stagger = layerStagger.get(name.layer);
|
||||
const isMorphCrossfade = (name.type === "old" || name.type === "new") &&
|
||||
!!stagger?.old &&
|
||||
!!stagger?.new;
|
||||
let timing;
|
||||
if (name.type.startsWith("group")) {
|
||||
// group + group-children follow the resolved group
|
||||
// timing - the single source shared with the crop
|
||||
// corner-radius pass below.
|
||||
const { delay, duration, ease } = resolveGroupTiming(name.layer);
|
||||
timing = {
|
||||
delay,
|
||||
duration,
|
||||
easing: mapEasingToNativeEasing(ease, duration),
|
||||
};
|
||||
}
|
||||
else {
|
||||
const timingType = isMorphCrossfade ? "group" : name.type;
|
||||
const [index, total] = staggerPosition(name.layer, timingType);
|
||||
const transitionName = timingType === "group" ? "layout" : "";
|
||||
let animationTransition = resolveLayerTransition(targetDefinition, timingType, transitionName, index === -1 ? 0 : index, total);
|
||||
/**
|
||||
* The crossfade should resolve at the spring's
|
||||
* *perceptual* (visual) duration - the geometry can keep
|
||||
* bouncing, but the opacity shouldn't drag through the
|
||||
* settle. So capture `visualDuration` before
|
||||
* `applyGeneratorOptions` replaces it with the full
|
||||
* overshoot duration, and use it for the fade.
|
||||
*/
|
||||
const visualDuration = animationTransition.visualDuration;
|
||||
animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
|
||||
animationTransition =
|
||||
applyGeneratorOptions(animationTransition);
|
||||
timing = {
|
||||
delay: secondsToMilliseconds(animationTransition.delay ?? 0),
|
||||
duration: isMorphCrossfade && visualDuration !== undefined
|
||||
? secondsToMilliseconds(visualDuration)
|
||||
: animationTransition.duration,
|
||||
easing: isMorphCrossfade
|
||||
? "linear"
|
||||
: mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration),
|
||||
};
|
||||
}
|
||||
effect.updateTiming(timing);
|
||||
animations.push(new NativeAnimationWrapper(animation));
|
||||
}
|
||||
/**
|
||||
* Round each cropped layer's clip. Its `::view-transition-group`
|
||||
* has `overflow: clip`, which would otherwise square the corners
|
||||
* mid-morph; animate each corner from the old element's radius to
|
||||
* the new element's so the crop stays rounded. Timed as the group
|
||||
* (`layout`) so the radius tracks the morphing box.
|
||||
*/
|
||||
cropMeasurements.forEach((entry, name) => {
|
||||
if (!croppedNames.has(name))
|
||||
return;
|
||||
// Reuse the group's resolved timing - native ms delay/
|
||||
// duration + a baked ease, with no generator `type` or
|
||||
// repeat/times to leak into (or throw inside) NativeAnimation.
|
||||
const { delay, duration, ease } = resolveGroupTiming(name);
|
||||
for (const corner of cornerRadiusProps) {
|
||||
// `||` (not `??`) so an empty measurement falls back to
|
||||
// the other snapshot rather than an invalid keyframe.
|
||||
const from = entry.old?.radii[corner] ||
|
||||
entry.new?.radii[corner] ||
|
||||
"0px";
|
||||
const to = entry.new?.radii[corner] ||
|
||||
entry.old?.radii[corner] ||
|
||||
"0px";
|
||||
// Nothing to round if the corner is square at both ends.
|
||||
if (isSquareRadius(from) && isSquareRadius(to))
|
||||
continue;
|
||||
animations.push(new NativeAnimation({
|
||||
element: document.documentElement,
|
||||
name: corner,
|
||||
pseudoElement: `::view-transition-group(${name})`,
|
||||
keyframes: [from, to],
|
||||
delay,
|
||||
duration,
|
||||
ease,
|
||||
}));
|
||||
}
|
||||
});
|
||||
resolve(new GroupAnimation(animations));
|
||||
})
|
||||
.catch(() =>
|
||||
/**
|
||||
* `ready` rejects when the transition is skipped - no visual
|
||||
* change, or superseded by an interrupting transition. The DOM
|
||||
* update still applied, so settle with no animations rather than
|
||||
* surfacing it as an error to an awaiting caller. A genuine
|
||||
* failure in `update()` rejects `updateCallbackDone` (already
|
||||
* settled by now), so propagate that instead.
|
||||
*/
|
||||
transition.updateCallbackDone.then(() => resolve(new GroupAnimation([])), reject));
|
||||
});
|
||||
}
|
||||
/**
|
||||
* The options that should time a given generated layer type, so a retimed
|
||||
* group/old/new picks up any per-target transition the user provided. Checks
|
||||
* the type's buckets in priority order (e.g. `new` before `enter`).
|
||||
*/
|
||||
function layerOptions(target, type) {
|
||||
for (const bucket of typeBuckets[type] ?? []) {
|
||||
const options = target?.[bucket]?.options;
|
||||
if (options)
|
||||
return options;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Merge a base transition (e.g. the default `options`) with a per-layer/value
|
||||
* override. An explicit `duration` on the override must win over an inherited
|
||||
* generator's own timing: a spring prefers `visualDuration`, and
|
||||
* `spring.applyToOptions` overwrites `duration` with the computed settle time -
|
||||
* so without this the override is silently discarded. Dropping the inherited
|
||||
* `type`/`visualDuration` makes the layer a plain tween of that duration, unless
|
||||
* it asked for its own generator `type`/`visualDuration`.
|
||||
*/
|
||||
function mergeTransition(base, override) {
|
||||
const merged = { ...base, ...override };
|
||||
if (override.duration !== undefined) {
|
||||
if (override.visualDuration === undefined)
|
||||
delete merged.visualDuration;
|
||||
if (override.type === undefined)
|
||||
delete merged.type;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export { startViewAnimation };
|
||||
//# sourceMappingURL=start.mjs.map
|
||||
+1
File diff suppressed because one or more lines are too long
+146
@@ -0,0 +1,146 @@
|
||||
import { resolveElements } from '../../utils/resolve-elements.mjs';
|
||||
|
||||
let nameCount = 0;
|
||||
/**
|
||||
* Generated names live in their own namespace so we can tell a name we own
|
||||
* (and must clean up) from an author-defined one - and so a stale generated
|
||||
* name left behind by an interrupted transition is re-owned, not mistaken for
|
||||
* the author's and leaked.
|
||||
*/
|
||||
const generatedName = () => `motion-view-${nameCount++}`;
|
||||
const isGeneratedName = (name) => name.startsWith("motion-view-");
|
||||
/**
|
||||
* Tag a captured element with a `view-transition-class` so authors can target
|
||||
* its generated layer from CSS (e.g. `::view-transition-group(.hero)`) without
|
||||
* the opaque generated name. Tracked in `classed` - separate from the generated
|
||||
* names in `assigned` - so cleanup removes the class without ever stripping an
|
||||
* author's own inline `view-transition-name`.
|
||||
*/
|
||||
function tagClass(element, className, classed) {
|
||||
if (!className)
|
||||
return;
|
||||
element.style?.setProperty("view-transition-class", className);
|
||||
classed.push(element);
|
||||
}
|
||||
/**
|
||||
* Set the element's `view-transition-group` so its layer reconstructs the DOM
|
||||
* hierarchy in the pseudo-tree (`contain`) - or stays flat (`none`). Tracked in
|
||||
* `grouped` for cleanup. When the element clips (any non-`visible` overflow) its
|
||||
* name is recorded in `clipChildren` so the caller can clip the nested children
|
||||
* (`::view-transition-group-children(name)`), mirroring the live clip through
|
||||
* the whole transition rather than only at the live-DOM handoff.
|
||||
*
|
||||
* Ignored by browsers without nested view-transition groups, where it harmlessly
|
||||
* degrades to the flat default.
|
||||
*/
|
||||
function applyGroup(element, name, group, grouped, clipChildren) {
|
||||
if (!group)
|
||||
return;
|
||||
element.style?.setProperty("view-transition-group", group);
|
||||
grouped.push(element);
|
||||
if (group !== "none" && clipChildren) {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
||||
clipChildren.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolve a selector/Element to elements and ensure each one carries a
|
||||
* `view-transition-name` we can target from script.
|
||||
*
|
||||
* Author-defined names are reused as-is. Elements that are unnamed (or use
|
||||
* the browser's `auto`/`match-element`, whose generated name is not exposed
|
||||
* to script) are given a unique generated name, set inline so it's captured,
|
||||
* and tracked in `assigned` for later cleanup.
|
||||
*
|
||||
* `registry` maps each Element to its name so the same element keeps its name
|
||||
* across both captures (before and after the update), which is what allows a
|
||||
* persistent element to animate as a single `group` layer.
|
||||
*/
|
||||
function assignViewTransitionNames(definition, registry, assigned, forcedNames, className, classed = [], group, grouped = [], clipChildren) {
|
||||
const elements = resolveElements(definition);
|
||||
/**
|
||||
* The new end of a paired morph: give each element the matching name from
|
||||
* the old end (by index) so the two share one layer and morph. If the new
|
||||
* end resolves to *more* elements than the old end named, the extras have no
|
||||
* counterpart - give them a fresh name so they animate as newcomers rather
|
||||
* than being silently left unnamed. We return the names actually assigned
|
||||
* (sized to the resolved elements), not the raw `forcedNames`, so stagger
|
||||
* totals and the layer set stay in step with what's on the page.
|
||||
*/
|
||||
if (forcedNames) {
|
||||
return elements.map((element, i) => {
|
||||
const existing = registry.get(element);
|
||||
if (existing)
|
||||
return existing;
|
||||
const name = forcedNames[i] ?? generatedName();
|
||||
element.style?.setProperty("view-transition-name", name);
|
||||
assigned.push(element);
|
||||
registry.set(element, name);
|
||||
tagClass(element, className, classed);
|
||||
applyGroup(element, name, group, grouped, clipChildren);
|
||||
return name;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Read every current name up front, before assigning any. Interleaving the
|
||||
* reads with the inline `setProperty` writes below would dirty styles
|
||||
* between reads and force a style recalc per element; batching the reads
|
||||
* keeps it to one. Elements already in the registry keep their name and
|
||||
* need no read.
|
||||
*/
|
||||
const currentNames = elements.map((element) => registry.has(element)
|
||||
? undefined
|
||||
: getComputedStyle(element).getPropertyValue("view-transition-name"));
|
||||
return elements.map((element, i) => {
|
||||
const existing = registry.get(element);
|
||||
if (existing)
|
||||
return existing;
|
||||
const current = currentNames[i];
|
||||
let name;
|
||||
if (current &&
|
||||
current !== "none" &&
|
||||
current !== "auto" &&
|
||||
current !== "match-element" &&
|
||||
!isGeneratedName(current)) {
|
||||
/**
|
||||
* The author already named this layer - target it as-is and leave
|
||||
* it to them to clean up. `auto`/`match-element` are overridden
|
||||
* because their generated name is not exposed to script, and a
|
||||
* stale `motion-view-*` (e.g. left by an interrupted transition) is
|
||||
* re-owned rather than adopted as an author name and leaked.
|
||||
*/
|
||||
name = current;
|
||||
}
|
||||
else {
|
||||
name = generatedName();
|
||||
element.style?.setProperty("view-transition-name", name);
|
||||
assigned.push(element);
|
||||
}
|
||||
registry.set(element, name);
|
||||
tagClass(element, className, classed);
|
||||
applyGroup(element, name, group, grouped, clipChildren);
|
||||
return name;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Remove the `view-transition-name`s we generated and the
|
||||
* `view-transition-class`es we applied. Author-defined names are never touched
|
||||
* (they're not in `assigned`). Safe to call more than once (e.g. on both a
|
||||
* finished and an interrupted transition).
|
||||
*/
|
||||
function releaseViewTransitionNames(assigned, classed = [], grouped = []) {
|
||||
for (const element of assigned) {
|
||||
element.style?.removeProperty("view-transition-name");
|
||||
}
|
||||
for (const element of classed) {
|
||||
element.style?.removeProperty("view-transition-class");
|
||||
}
|
||||
for (const element of grouped) {
|
||||
element.style?.removeProperty("view-transition-group");
|
||||
}
|
||||
}
|
||||
|
||||
export { assignViewTransitionNames, releaseViewTransitionNames };
|
||||
//# sourceMappingURL=assign-names.mjs.map
|
||||
+1
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
function chooseLayerType(valueName) {
|
||||
if (valueName === "layout")
|
||||
return "group";
|
||||
if (valueName === "enter" || valueName === "new")
|
||||
return "new";
|
||||
return "old";
|
||||
}
|
||||
|
||||
export { chooseLayerType };
|
||||
//# sourceMappingURL=choose-layer-type.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"choose-layer-type.mjs","sources":["../../../../src/view/utils/choose-layer-type.ts"],"sourcesContent":["export function chooseLayerType(\n valueName: \"layout\" | \"enter\" | \"exit\" | \"new\" | \"old\"\n): \"group\" | \"old\" | \"new\" {\n if (valueName === \"layout\") return \"group\"\n if (valueName === \"enter\" || valueName === \"new\") return \"new\"\n return \"old\"\n}\n"],"names":[],"mappings":"AAAM,SAAU,eAAe,CAC3B,SAAsD,EAAA;IAEtD,IAAI,SAAS,KAAK,QAAQ;AAAE,QAAA,OAAO,OAAO;AAC1C,IAAA,IAAI,SAAS,KAAK,OAAO,IAAI,SAAS,KAAK,KAAK;AAAE,QAAA,OAAO,KAAK;AAC9D,IAAA,OAAO,KAAK;AAChB;;;;"}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
let pendingRules = {};
|
||||
let style = null;
|
||||
const css = {
|
||||
set: (selector, values) => {
|
||||
pendingRules[selector] = values;
|
||||
},
|
||||
commit: () => {
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "motion-view";
|
||||
}
|
||||
let cssText = "";
|
||||
for (const selector in pendingRules) {
|
||||
const rule = pendingRules[selector];
|
||||
cssText += `${selector} {\n`;
|
||||
for (const [property, value] of Object.entries(rule)) {
|
||||
cssText += ` ${property}: ${value};\n`;
|
||||
}
|
||||
cssText += "}\n";
|
||||
}
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
pendingRules = {};
|
||||
},
|
||||
remove: () => {
|
||||
if (style && style.parentElement) {
|
||||
style.parentElement.removeChild(style);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export { css };
|
||||
//# sourceMappingURL=css.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"css.mjs","sources":["../../../../src/view/utils/css.ts"],"sourcesContent":["let pendingRules: Record<string, Record<string, string>> = {}\n\nlet style: HTMLStyleElement | null = null\n\nexport const css = {\n set: (selector: string, values: Record<string, string>) => {\n pendingRules[selector] = values\n },\n\n commit: () => {\n if (!style) {\n style = document.createElement(\"style\")\n style.id = \"motion-view\"\n }\n\n let cssText = \"\"\n\n for (const selector in pendingRules) {\n const rule = pendingRules[selector]\n cssText += `${selector} {\\n`\n for (const [property, value] of Object.entries(rule)) {\n cssText += ` ${property}: ${value};\\n`\n }\n cssText += \"}\\n\"\n }\n\n style.textContent = cssText\n document.head.appendChild(style)\n\n pendingRules = {}\n },\n\n remove: () => {\n if (style && style.parentElement) {\n style.parentElement.removeChild(style)\n }\n },\n}\n"],"names":[],"mappings":"AAAA,IAAI,YAAY,GAA2C,EAAE;AAE7D,IAAI,KAAK,GAA4B,IAAI;AAElC,MAAM,GAAG,GAAG;AACf,IAAA,GAAG,EAAE,CAAC,QAAgB,EAAE,MAA8B,KAAI;AACtD,QAAA,YAAY,CAAC,QAAQ,CAAC,GAAG,MAAM;IACnC,CAAC;IAED,MAAM,EAAE,MAAK;QACT,IAAI,CAAC,KAAK,EAAE;AACR,YAAA,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC;AACvC,YAAA,KAAK,CAAC,EAAE,GAAG,aAAa;QAC5B;QAEA,IAAI,OAAO,GAAG,EAAE;AAEhB,QAAA,KAAK,MAAM,QAAQ,IAAI,YAAY,EAAE;AACjC,YAAA,MAAM,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAC;AACnC,YAAA,OAAO,IAAI,CAAA,EAAG,QAAQ,CAAA,IAAA,CAAM;AAC5B,YAAA,KAAK,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;AAClD,gBAAA,OAAO,IAAI,CAAA,EAAA,EAAK,QAAQ,CAAA,EAAA,EAAK,KAAK,KAAK;YAC3C;YACA,OAAO,IAAI,KAAK;QACpB;AAEA,QAAA,KAAK,CAAC,WAAW,GAAG,OAAO;AAC3B,QAAA,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAEhC,YAAY,GAAG,EAAE;IACrB,CAAC;IAED,MAAM,EAAE,MAAK;AACT,QAAA,IAAI,KAAK,IAAI,KAAK,CAAC,aAAa,EAAE;AAC9B,YAAA,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,KAAK,CAAC;QAC1C;IACJ,CAAC;;;;;"}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
function getViewAnimationLayerInfo(pseudoElement) {
|
||||
const match = pseudoElement.match(
|
||||
// `group-children` (nested transitions) before `group` so it wins.
|
||||
/::view-transition-(old|new|group-children|group|image-pair)\((.*?)\)/);
|
||||
if (!match)
|
||||
return null;
|
||||
return { layer: match[2], type: match[1] };
|
||||
}
|
||||
|
||||
export { getViewAnimationLayerInfo };
|
||||
//# sourceMappingURL=get-layer-info.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"get-layer-info.mjs","sources":["../../../../src/view/utils/get-layer-info.ts"],"sourcesContent":["export function getViewAnimationLayerInfo(pseudoElement: string) {\n const match = pseudoElement.match(\n // `group-children` (nested transitions) before `group` so it wins.\n /::view-transition-(old|new|group-children|group|image-pair)\\((.*?)\\)/\n )\n if (!match) return null\n\n return { layer: match[2], type: match[1] }\n}\n"],"names":[],"mappings":"AAAM,SAAU,yBAAyB,CAAC,aAAqB,EAAA;AAC3D,IAAA,MAAM,KAAK,GAAG,aAAa,CAAC,KAAK;;AAE7B,IAAA,sEAAsE,CACzE;AACD,IAAA,IAAI,CAAC,KAAK;AAAE,QAAA,OAAO,IAAI;AAEvB,IAAA,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE;AAC9C;;;;"}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
function getViewAnimations() {
|
||||
return document.getAnimations().filter((animation) => {
|
||||
const { effect } = animation;
|
||||
return (!!effect &&
|
||||
effect.target === document.documentElement &&
|
||||
effect.pseudoElement?.startsWith("::view-transition"));
|
||||
});
|
||||
}
|
||||
|
||||
export { getViewAnimations };
|
||||
//# sourceMappingURL=get-view-animations.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"get-view-animations.mjs","sources":["../../../../src/view/utils/get-view-animations.ts"],"sourcesContent":["export function getViewAnimations() {\n return document.getAnimations().filter((animation) => {\n const { effect } = animation\n return (\n !!effect &&\n effect.target === document.documentElement &&\n (effect as KeyframeEffect).pseudoElement?.startsWith(\n \"::view-transition\"\n )\n )\n })\n}\n"],"names":[],"mappings":"SAAgB,iBAAiB,GAAA;IAC7B,OAAO,QAAQ,CAAC,aAAa,EAAE,CAAC,MAAM,CAAC,CAAC,SAAS,KAAI;AACjD,QAAA,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS;QAC5B,QACI,CAAC,CAAC,MAAM;AACR,YAAA,MAAM,CAAC,MAAM,KAAK,QAAQ,CAAC,eAAe;YACzC,MAAyB,CAAC,aAAa,EAAE,UAAU,CAChD,mBAAmB,CACtB;AAET,IAAA,CAAC,CAAC;AACN;;;;"}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
function hasTarget(target, targets) {
|
||||
return targets.has(target) && Object.keys(targets.get(target)).length > 0;
|
||||
}
|
||||
|
||||
export { hasTarget };
|
||||
//# sourceMappingURL=has-target.mjs.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"has-target.mjs","sources":["../../../../src/view/utils/has-target.ts"],"sourcesContent":["import { ViewTransitionTarget, ViewTransitionTargetDefinition } from \"../types\"\n\nexport function hasTarget(\n target: ViewTransitionTargetDefinition,\n targets: Map<ViewTransitionTargetDefinition, ViewTransitionTarget>\n) {\n return targets.has(target) && Object.keys(targets.get(target)!).length > 0\n}\n"],"names":[],"mappings":"AAEM,SAAU,SAAS,CACrB,MAAsC,EACtC,OAAkE,EAAA;IAElE,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC,CAAC,MAAM,GAAG,CAAC;AAC9E;;;;"}
|
||||
Reference in New Issue
Block a user