UNPKG

motion-dom

Version:
599 lines (596 loc) • 22.5 kB
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