701 lines
35 KiB
JavaScript
701 lines
35 KiB
JavaScript
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
|