motion-dom
Version:
599 lines (596 loc) • 22.5 kB
JavaScript
import { clamp } from 'motion-utils';
import { spring } from '../../animation/generators/spring.mjs';
import { styleSubjectEffect, readStyleValue } from '../../effects/style/index.mjs';
import { svgSubjectEffect } from '../../effects/svg/index.mjs';
import { time } from '../../frameloop/sync-time.mjs';
import { visualElementStore } from '../../render/store.mjs';
import { activeScope, addToScope } from '../../scope/active.mjs';
import { isSVGElement } from '../../utils/is-svg-element.mjs';
import { mixNumber } from '../../utils/mix/number.mjs';
import { resolveElements } from '../../utils/resolve-elements.mjs';
import { motionValue } from '../../value/index.mjs';
import { setDragLock } from '../drag/state/set-active.mjs';
import { defaultElastic, applyConstraints } from '../drag/utils/constraints.mjs';
import { elementDragControls } from '../drag/VisualElementDragControls.mjs';
import { frame, cancelFrame } from '../../frameloop/frame.mjs';
/**
* A trackpad pinch arrives as a stream of `wheel` events with no end
* event, so it ends once none have arrived for this long.
*/
const wheelTimeout = 150;
/**
* Convert an `x`/`y` set in other units (e.g. `"-50%"` to centre an
* element) to pixels, so the gesture can add to it.
*/
const toPixels = (value, size) => {
const latest = value.get();
if (typeof latest === "string") {
value.set(parseFloat(latest) * (latest.endsWith("%") ? size / 100 : 1));
}
};
const limitsOf = (option) => typeof option === "object" ? option : {};
/**
* Resolve `elastic` to a resistance for each side, as for `dragElastic`.
*/
const resolveElastic = (elastic = defaultElastic) => {
const side = (value) => value === true ? defaultElastic : +(value || 0);
return typeof elastic === "object"
? { min: side(elastic.min), max: side(elastic.max) }
: { min: side(elastic), max: side(elastic) };
};
/**
* Whether the gesture writes `x`/`y`: to follow the focal point, or to
* keep an `origin` on the element in place.
*/
const writesXY = ({ pan, origin }) => pan !== false || !!origin;
const clampTo = (value, { min = -Infinity, max = Infinity }) => clamp(min, max, value);
/**
* The raw gesture value that elastic shows as `value`. A pinch that starts
* while a value is still past its limit (like during a spring back) carries
* on from it, rather than jumping.
*/
const unconstrain = (value, option, resistance) => {
const limit = clampTo(value, limitsOf(option));
const elastic = resistance[value < limit ? "min" : "max"];
return elastic ? limit + (value - limit) / elastic : value;
};
/**
* Listen for pinch gestures on `element`: two touches (or pens), a trackpad
* pinch (`wheel` with `ctrlKey`, as Chrome, Edge and Firefox report it),
* and Safari's `gesture*` events.
*
* `getValues` is called each time a gesture starts, in the frame's read
* step, for the scale/rotate/x/y values to write to. It runs alongside the
* element measurement so neither forces a layout.
*
* `getOptions` is read whenever it's needed, so options can change while
* listening. Return `false` to disable the gesture, which also ends one in
* progress.
*
* `onPinchStart` is called once as each gesture starts, with the gesture's
* info as a third argument. It can return the gesture's handlers, as for
* `pinch()`.
*/
function addPinchListeners(element, getValues, getEnabledOptions, onPinchStart) {
const pointers = new Map();
/**
* The gesture as last reported: focal point, log of a distance and an
* angle. Only changes in distance and angle matter, so trackpad and
* Safari input write a virtual distance (the scale) here. Logs keep
* a long trackpad pinch from overflowing.
*/
const current = { x: 0, y: 0, l: 0, a: 0 };
const start = { ...current };
const prev = { ...current };
let source;
let lastEvent;
let values;
let originX = 0;
let originY = 0;
let width = 0;
let height = 0;
let lastX = 0;
let lastY = 0;
let hasMoved = false;
let wheelTime = 0;
let handlers = {};
let unlock;
/**
* The gesture's scale and rotation before limits and elastic apply.
*/
let rawScale = 1;
let rawRotate = 0;
let stopSnap = () => { };
/**
* The latest enabled options, so a gesture disabled mid-way can still
* report its end.
*/
let options = {};
const getOptions = () => (options = getEnabledOptions() || options);
const info = () => ({
point: { x: current.x, y: current.y },
offset: { x: current.x - start.x, y: current.y - start.y },
scale: Math.exp(current.l - start.l),
rotate: current.a - start.a,
});
/**
* Read the element's box and values, and find its layout origin: its
* centre without `x`/`y`. Scale and rotate are applied around the
* centre (the default transform origin), so they don't move it.
*/
const measure = () => {
const box = element.getBoundingClientRect();
const { left, top } = box;
const { scale, rotate, elastic } = getOptions();
const [s, r, x, y] = (values = getValues());
const resistance = resolveElastic(elastic);
/**
* Stop animations on values this gesture writes, and leave the
* rest running.
*/
scale !== false && s.stop();
rotate !== false && r.stop();
rawScale = unconstrain(s.get(), scale, resistance);
rawRotate = unconstrain(r.get(), rotate, resistance);
width = element.offsetWidth ?? box.width;
height = element.offsetHeight ?? box.height;
if (writesXY(options)) {
x.stop();
y.stop();
toPixels(x, width);
toPixels(y, height);
}
lastX = x.get();
lastY = y.get();
originX = left + box.width / 2 - lastX;
originY = top + box.height / 2 - lastY;
};
/**
* The point scale and rotation pivot around, in client coordinates:
* the focal point, or `origin` on the element at scale `s`, rotation
* `r` and offset `x`/`y`.
*/
const pivot = (focal, s, r, x, y) => {
const { origin } = options;
if (typeof origin !== "object")
return focal;
const a = (r * Math.PI) / 180;
const u = (origin.x - 0.5) * width * s;
const v = (origin.y - 0.5) * height * s;
return {
x: originX + x + Math.cos(a) * u - Math.sin(a) * v,
y: originY + y + Math.sin(a) * u + Math.cos(a) * v,
};
};
const dragControls = () => elementDragControls.get(visualElementStore.get(element));
/**
* On release, spring scale and rotation back inside their limits (or
* to a `snap` angle), keeping the focal point in place, or everything
* back to its origin with `snapToOrigin`.
*/
const snap = () => {
const { scale: scaleOption, rotate: rotateOption, snapToOrigin, transition, } = options;
const [scale, rotate, x, y] = values;
const s0 = scale.get();
const r0 = rotate.get();
const x0 = x.get();
const y0 = y.get();
const { snap: step } = limitsOf(rotateOption);
let s1 = clampTo(s0, limitsOf(scaleOption));
/**
* Snap to the step nearest where the release velocity would carry
* the rotation, found as inertia finds its target (with a lower power
* than inertia's 0.8).
*/
let r1 = clampTo(step
? Math.round((r0 + 0.2 * rotate.getVelocity()) / step) * step
: r0, limitsOf(rotateOption));
if (snapToOrigin) {
s1 = 1;
// The nearest upright angle, so it turns the shortest way
r1 = Math.round(r0 / 360) * 360;
}
/**
* Keep the focal point in place, unless the element doesn't move
* with it, or a drag carries on and owns x/y.
*/
const moves = writesXY(options) && !dragControls()?.isDragging;
const p0 = pivot(current, s0, r0, x0, y0);
const vx = originX + x0 - p0.x;
const vy = originY + y0 - p0.y;
/**
* Scale, rotation and x/y at progress `p` of the snap, keeping the
* focal point in place.
*/
const at = (p) => {
const k = mixNumber(s0, s1, p);
const r = mixNumber(r0, r1, p);
const angle = ((r - r0) * Math.PI) / 180;
const cos = (Math.cos(angle) * k) / s0;
const sin = (Math.sin(angle) * k) / s0;
return [
k,
r,
x0 + cos * vx - sin * vy - vx,
y0 + sin * vx + cos * vy - vy,
];
};
if (!s0 ||
(s1 === s0 && r1 === r0 && !(snapToOrigin && moves && (x0 || y0)))) {
return;
}
const generator = spring({
keyframes: [0, 1],
stiffness: 200,
damping: 40,
...transition,
});
const startTime = time.now();
/**
* Drive the values from one spring, from 0 to 1, so they stay in
* step and the focal point stays in place.
*/
const tick = () => {
const { value: p, done } = generator.next(time.now() - startTime);
if (done)
stopSnap();
const [k, r, fx, fy] = at(p);
scaleOption !== false && scale.set(k);
rotateOption !== false && rotate.set(r);
// A drag that starts mid-snap owns x/y from then on.
if (moves && !dragControls()?.isDragging) {
x.set(snapToOrigin ? mixNumber(x0, 0, p) : fx);
y.set(snapToOrigin ? mixNumber(y0, 0, p) : fy);
}
};
stopSnap = () => cancelFrame(tick);
frame.update(tick, true);
};
/**
* Apply the change in the gesture since the last frame as an
* incremental scale and rotation around the previous focal point,
* plus the focal point's movement.
*/
const apply = () => {
if (!values || !hasMoved)
return;
hasMoved = false;
const { rotate: rotateOption, scale: scaleOption, pan, elastic, } = getOptions();
const [scale, rotate, x, y] = values;
const resistance = resolveElastic(elastic);
const latest = scale.get();
const drag = dragControls();
const isDragging = drag?.isDragging;
if (!isDragging) {
lastX = x.get();
lastY = y.get();
}
const p = pivot(prev, latest, rotate.get(), lastX, lastY);
let theta = 0;
let k = 1;
/**
* Apply limits and elastic to the gesture's raw scale and rotation.
* At a hard limit, the raw value stops too, so moving back responds
* straight away.
*/
const constrain = (raw, option) => {
const limits = limitsOf(option);
const limit = clampTo(raw, limits);
return resistance[raw < limit ? "min" : "max"]
? [applyConstraints(raw, limits, resistance), raw]
: [limit, limit];
};
if (scaleOption !== false && latest) {
const [next, raw] = constrain(clamp(1e-9, 1e9, rawScale * Math.exp(current.l - prev.l)), scaleOption);
rawScale = raw;
k = next / latest;
scale.set(next);
}
if (rotateOption !== false) {
const [next, raw] = constrain(rawRotate + current.a - prev.a, rotateOption);
rawRotate = raw;
theta = next - rotate.get();
if (theta)
rotate.set(next);
}
if (writesXY(options)) {
/**
* Pinch owns x/y while it runs, so stop any animation started
* on them since (like a drag's snap back) taking over after.
*/
x.stop();
y.stop();
/**
* If drag() or the drag prop is dragging this element with one
* of the touches, it has set x/y this frame to where it started
* plus the touch's offset. Carry on from our own values instead,
* and restart the drag from them and the touch's latest point.
* That way it continues from wherever the pinch leaves the
* element, and its release velocity only counts movement after
* the pinch, so a zoom doesn't end in a fling.
*/
const angle = (theta * Math.PI) / 180;
const cos = Math.cos(angle) * k;
const sin = Math.sin(angle) * k;
const dx = p.x - originX - lastX;
const dy = p.y - originY - lastY;
const panX = pan !== false ? current.x - prev.x : 0;
const panY = pan !== false ? current.y - prev.y : 0;
/**
* Move the centre so the pivot, scaled and rotated around the
* centre, stays in place, plus the focal point's movement.
*/
lastX += dx + panX - (cos * dx - sin * dy);
lastY += dy + panY - (sin * dx + cos * dy);
if (isDragging) {
const history = drag.panSession.history;
history.splice(0, history.length - 1);
/**
* Date the point long ago, so drag only measures release
* velocity from moves after the pinch. A move that drag
* processes as the touch lifts is part of the pinch.
*/
history[0].timestamp = -Infinity;
drag.originPoint.x = lastX;
drag.originPoint.y = lastY;
}
x.set(lastX);
y.set(lastY);
}
Object.assign(prev, current);
handlers.onPinch?.(lastEvent, info());
};
/**
* Runs after the update step, where drag sets x/y, so it has the final
* say on them each frame.
*/
const update = () => {
if (!getEnabledOptions())
return end(lastEvent);
apply();
if (source === "wheel" && time.now() - wheelTime > wheelTimeout) {
end(lastEvent);
}
};
const begin = (event, from) => {
if (!getEnabledOptions())
return;
stopSnap();
source = from;
lastEvent = event;
hasMoved = false;
values = undefined;
handlers = {};
Object.assign(start, current);
Object.assign(prev, current);
frame.read(measure);
frame.preRender(update, true);
const result = onPinchStart(element, event, info());
handlers =
typeof result === "function" ? { onComplete: result } : result || {};
};
const end = (event) => {
if (!source)
return;
cancelFrame(measure);
cancelFrame(update);
if (getEnabledOptions()) {
apply();
values && snap();
}
source = undefined;
handlers.onComplete?.(event, info());
};
const moved = (event) => {
lastEvent = event;
hasMoved = true;
};
const readPointers = () => {
const [a, b] = pointers.values();
const angle = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
const delta = angle - current.a;
current.x = (a.x + b.x) / 2;
current.y = (a.y + b.y) / 2;
current.l = Math.log(Math.hypot(b.x - a.x, b.y - a.y) || 1);
/**
* Take the shortest way round, so rotation keeps counting past
* 180deg rather than flipping to -180deg.
*/
current.a += delta - 360 * Math.round(delta / 360);
};
const setPointer = (event) => pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const onPointerMove = (event) => {
if (!pointers.has(event.pointerId))
return;
setPointer(event);
if (source === "pointer") {
readPointers();
moved(event);
}
};
const onPointerUp = (event) => {
if (!pointers.delete(event.pointerId))
return;
if (source === "pointer")
end(event);
if (!pointers.size) {
toggleWindowListeners();
release();
}
};
/**
* Once a touch is down on the element, listen on the window too, so
* the second touch can land beside it (for instance on a small image).
*/
const onPointerDown = (event) => {
if (source ||
pointers.size > 1 ||
pointers.has(event.pointerId) ||
event.pointerType === "mouse") {
return;
}
if (!pointers.size)
toggleWindowListeners(true);
setPointer(event);
if (pointers.size === 2) {
readPointers();
begin(event, "pointer");
/**
* Unless this element drags, hold the drag lock until every
* touch lifts. A press on it then ends as a cancel, rather than
* a tap, and a draggable parent doesn't drag.
*/
if (source &&
!unlock &&
!elementDragControls.get(visualElementStore.get(element))) {
unlock = setDragLock(true);
}
}
};
/**
* Release the drag lock a frame later, so a press ending on this same
* pointerup still sees it.
*/
const release = () => {
const done = unlock;
unlock = undefined;
done && frame.postRender(done);
};
const toggleWindowListeners = (add) => {
const method = add ? "addEventListener" : "removeEventListener";
window[method]("pointerdown", onPointerDown);
window[method]("pointermove", onPointerMove);
window[method]("pointerup", onPointerUp);
window[method]("pointercancel", onPointerUp);
};
const onWheel = (event) => {
if (!event.ctrlKey || (source && source !== "wheel"))
return;
current.x = event.clientX;
current.y = event.clientY;
if (!source) {
current.l = 0;
begin(event, "wheel");
if (!source)
return;
}
event.preventDefault();
/**
* Lines (Firefox's mouse wheel) to pixels, and cap each event so a
* mouse wheel notch with ctrl held (about 100px) zooms a step.
*/
current.l -=
clamp(-50, 50, event.deltaY * (event.deltaMode ? 16 : 1)) / 100;
wheelTime = time.now();
moved(event);
};
/**
* Safari reports trackpad pinches as `gesture*` events. iOS Safari
* fires them for touches too, alongside the pointer events we track.
*/
const onGesture = (event) => {
if (!pointers.size && (!source || source === "gesture")) {
const { scale, rotation, clientX, clientY } = event;
current.x = clientX;
current.y = clientY;
current.l = Math.log(scale);
current.a = rotation;
if (source) {
moved(event);
if (event.type === "gestureend")
end(event);
}
else if (event.type === "gesturestart") {
begin(event, "gesture");
}
}
if (source)
event.preventDefault();
};
const listeners = {
pointerdown: onPointerDown,
wheel: onWheel,
gesturestart: onGesture,
gesturechange: onGesture,
gestureend: onGesture,
};
for (const name in listeners) {
element.addEventListener(name, listeners[name], { passive: false });
}
return () => {
for (const name in listeners) {
element.removeEventListener(name, listeners[name]);
}
toggleWindowListeners();
end(lastEvent);
stopSnap();
pointers.clear();
release();
};
}
const valueKeys = ["scale", "rotate", "x", "y"];
/**
* The element's scale/rotate/x/y motion values: those of its
* VisualElement if it has one (it's a <motion.*> component, or drag() or
* animateLayout() own it), otherwise those animate() binds to it, created
* from its current style if this is the first time.
*/
const getElementValues = (element) => {
const visualElement = visualElementStore.get(element);
const effect = isSVGElement(element) ? svgSubjectEffect : styleSubjectEffect;
return valueKeys.map((key) => {
if (visualElement) {
return visualElement.getValue(key, key === "scale" ? 1 : 0);
}
let value = effect.get(element, key);
if (!value) {
value = motionValue(readStyleValue(element, key));
effect(element, { [key]: value });
}
return value;
});
};
function pinch(elementOrSelector, startOrOptions, options = {}) {
let onPinchStart = () => { };
if (typeof startOrOptions === "function") {
onPinchStart = startOrOptions;
}
else if (startOrOptions) {
options = startOrOptions;
}
const scope = activeScope.current;
/**
* Run the start callback and the handlers it returns in the active
* scope, so work they start is collected. A returned function is
* bound by `scope.bind` itself.
*/
if (scope) {
const start = scope.bind(onPinchStart);
onPinchStart = (element, event) => {
const result = start(element, event);
if (typeof result !== "object")
return result;
const handlers = {};
for (const key in result) {
const name = key;
const handler = result[name];
handlers[name] = handler && scope.bind(handler);
}
return handlers;
};
}
const cleanups = resolveElements(elementOrSelector).map((element) => {
scope?.track(element);
const { style } = element;
const touchAction = style.touchAction;
style.touchAction = "none";
const remove = addPinchListeners(element, () => getElementValues(element), () => options, onPinchStart);
return () => {
remove();
style.touchAction = touchAction;
};
});
return addToScope(() => cleanups.forEach((cleanup) => cleanup()));
}
export { addPinchListeners, pinch };
//# sourceMappingURL=index.mjs.map