482 lines
21 KiB
JavaScript
482 lines
21 KiB
JavaScript
import { invariant } from '../../utils/errors.mjs';
|
|
import { PanSession } from '../pan/PanSession.mjs';
|
|
import { getGlobalLock } from './utils/lock.mjs';
|
|
import { isRefObject } from '../../utils/is-ref-object.mjs';
|
|
import { addPointerEvent } from '../../events/add-pointer-event.mjs';
|
|
import { applyConstraints, calcRelativeConstraints, resolveDragElastic, calcViewportConstraints, defaultElastic, rebaseAxisConstraints, calcOrigin } from './utils/constraints.mjs';
|
|
import { createBox } from '../../projection/geometry/models.mjs';
|
|
import { eachAxis } from '../../projection/utils/each-axis.mjs';
|
|
import { measurePageBox } from '../../projection/utils/measure.mjs';
|
|
import { extractEventInfo } from '../../events/event-info.mjs';
|
|
import { convertBoxToBoundingBox, convertBoundingBoxToBox } from '../../projection/geometry/conversion.mjs';
|
|
import { addDomEvent } from '../../events/add-dom-event.mjs';
|
|
import { calcLength } from '../../projection/geometry/delta-calc.mjs';
|
|
import { mix } from '../../utils/mix.mjs';
|
|
import { percent } from '../../value/types/numbers/units.mjs';
|
|
import { animateMotionValue } from '../../animation/interfaces/motion-value.mjs';
|
|
import { getContextWindow } from '../../utils/get-context-window.mjs';
|
|
import { frame } from '../../frameloop/frame.mjs';
|
|
|
|
const elementDragControls = new WeakMap();
|
|
/**
|
|
*
|
|
*/
|
|
// let latestPointerEvent: PointerEvent
|
|
class VisualElementDragControls {
|
|
constructor(visualElement) {
|
|
// This is a reference to the global drag gesture lock, ensuring only one component
|
|
// can "capture" the drag of one or both axes.
|
|
// TODO: Look into moving this into pansession?
|
|
this.openGlobalLock = null;
|
|
this.isDragging = false;
|
|
this.currentDirection = null;
|
|
this.originPoint = { x: 0, y: 0 };
|
|
/**
|
|
* The permitted boundaries of travel, in pixels.
|
|
*/
|
|
this.constraints = false;
|
|
this.hasMutatedConstraints = false;
|
|
/**
|
|
* The per-axis resolved elastic values.
|
|
*/
|
|
this.elastic = createBox();
|
|
this.visualElement = visualElement;
|
|
}
|
|
start(originEvent, { snapToCursor = false } = {}) {
|
|
/**
|
|
* Don't start dragging if this component is exiting
|
|
*/
|
|
const { presenceContext } = this.visualElement;
|
|
if (presenceContext && presenceContext.isPresent === false)
|
|
return;
|
|
const onSessionStart = (event) => {
|
|
const { dragSnapToOrigin } = this.getProps();
|
|
// Stop or pause any animations on both axis values immediately. This allows the user to throw and catch
|
|
// the component.
|
|
dragSnapToOrigin ? this.pauseAnimation() : this.stopAnimation();
|
|
if (snapToCursor) {
|
|
this.snapToCursor(extractEventInfo(event, "page").point);
|
|
}
|
|
};
|
|
const onStart = (event, info) => {
|
|
// Attempt to grab the global drag gesture lock - maybe make this part of PanSession
|
|
const { drag, dragPropagation, onDragStart } = this.getProps();
|
|
if (drag && !dragPropagation) {
|
|
if (this.openGlobalLock)
|
|
this.openGlobalLock();
|
|
this.openGlobalLock = getGlobalLock(drag);
|
|
// If we don 't have the lock, don't start dragging
|
|
if (!this.openGlobalLock)
|
|
return;
|
|
}
|
|
this.isDragging = true;
|
|
this.currentDirection = null;
|
|
this.resolveConstraints();
|
|
if (this.visualElement.projection) {
|
|
this.visualElement.projection.isAnimationBlocked = true;
|
|
this.visualElement.projection.target = undefined;
|
|
}
|
|
/**
|
|
* Record gesture origin
|
|
*/
|
|
eachAxis((axis) => {
|
|
let current = this.getAxisMotionValue(axis).get() || 0;
|
|
/**
|
|
* If the MotionValue is a percentage value convert to px
|
|
*/
|
|
if (percent.test(current)) {
|
|
const { projection } = this.visualElement;
|
|
if (projection && projection.layout) {
|
|
const measuredAxis = projection.layout.layoutBox[axis];
|
|
if (measuredAxis) {
|
|
const length = calcLength(measuredAxis);
|
|
current = length * (parseFloat(current) / 100);
|
|
}
|
|
}
|
|
}
|
|
this.originPoint[axis] = current;
|
|
});
|
|
// Fire onDragStart event
|
|
if (onDragStart) {
|
|
frame.update(() => onDragStart(event, info), false, true);
|
|
}
|
|
const { animationState } = this.visualElement;
|
|
animationState && animationState.setActive("whileDrag", true);
|
|
};
|
|
const onMove = (event, info) => {
|
|
// latestPointerEvent = event
|
|
const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag, } = this.getProps();
|
|
// If we didn't successfully receive the gesture lock, early return.
|
|
if (!dragPropagation && !this.openGlobalLock)
|
|
return;
|
|
const { offset } = info;
|
|
// Attempt to detect drag direction if directionLock is true
|
|
if (dragDirectionLock && this.currentDirection === null) {
|
|
this.currentDirection = getCurrentDirection(offset);
|
|
// If we've successfully set a direction, notify listener
|
|
if (this.currentDirection !== null) {
|
|
onDirectionLock && onDirectionLock(this.currentDirection);
|
|
}
|
|
return;
|
|
}
|
|
// Update each point with the latest position
|
|
this.updateAxis("x", info.point, offset);
|
|
this.updateAxis("y", info.point, offset);
|
|
/**
|
|
* Ideally we would leave the renderer to fire naturally at the end of
|
|
* this frame but if the element is about to change layout as the result
|
|
* of a re-render we want to ensure the browser can read the latest
|
|
* bounding box to ensure the pointer and element don't fall out of sync.
|
|
*/
|
|
this.visualElement.render();
|
|
/**
|
|
* This must fire after the render call as it might trigger a state
|
|
* change which itself might trigger a layout update.
|
|
*/
|
|
onDrag && onDrag(event, info);
|
|
};
|
|
const onSessionEnd = (event, info) => this.stop(event, info);
|
|
const resumeAnimation = () => eachAxis((axis) => {
|
|
var _a;
|
|
return this.getAnimationState(axis) === "paused" &&
|
|
((_a = this.getAxisMotionValue(axis).animation) === null || _a === void 0 ? void 0 : _a.play());
|
|
});
|
|
const { dragSnapToOrigin } = this.getProps();
|
|
this.panSession = new PanSession(originEvent, {
|
|
onSessionStart,
|
|
onStart,
|
|
onMove,
|
|
onSessionEnd,
|
|
resumeAnimation,
|
|
}, {
|
|
transformPagePoint: this.visualElement.getTransformPagePoint(),
|
|
dragSnapToOrigin,
|
|
contextWindow: getContextWindow(this.visualElement),
|
|
});
|
|
}
|
|
stop(event, info) {
|
|
const isDragging = this.isDragging;
|
|
this.cancel();
|
|
if (!isDragging)
|
|
return;
|
|
const { velocity } = info;
|
|
this.startAnimation(velocity);
|
|
const { onDragEnd } = this.getProps();
|
|
if (onDragEnd) {
|
|
frame.update(() => onDragEnd(event, info));
|
|
}
|
|
}
|
|
cancel() {
|
|
this.isDragging = false;
|
|
const { projection, animationState } = this.visualElement;
|
|
if (projection) {
|
|
projection.isAnimationBlocked = false;
|
|
}
|
|
this.panSession && this.panSession.end();
|
|
this.panSession = undefined;
|
|
const { dragPropagation } = this.getProps();
|
|
if (!dragPropagation && this.openGlobalLock) {
|
|
this.openGlobalLock();
|
|
this.openGlobalLock = null;
|
|
}
|
|
animationState && animationState.setActive("whileDrag", false);
|
|
}
|
|
updateAxis(axis, _point, offset) {
|
|
const { drag } = this.getProps();
|
|
// If we're not dragging this axis, do an early return.
|
|
if (!offset || !shouldDrag(axis, drag, this.currentDirection))
|
|
return;
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
let next = this.originPoint[axis] + offset[axis];
|
|
// Apply constraints
|
|
if (this.constraints && this.constraints[axis]) {
|
|
next = applyConstraints(next, this.constraints[axis], this.elastic[axis]);
|
|
}
|
|
axisValue.set(next);
|
|
}
|
|
resolveConstraints() {
|
|
var _a;
|
|
const { dragConstraints, dragElastic } = this.getProps();
|
|
const layout = this.visualElement.projection &&
|
|
!this.visualElement.projection.layout
|
|
? this.visualElement.projection.measure(false)
|
|
: (_a = this.visualElement.projection) === null || _a === void 0 ? void 0 : _a.layout;
|
|
const prevConstraints = this.constraints;
|
|
if (dragConstraints && isRefObject(dragConstraints)) {
|
|
if (!this.constraints) {
|
|
this.constraints = this.resolveRefConstraints();
|
|
}
|
|
}
|
|
else {
|
|
if (dragConstraints && layout) {
|
|
this.constraints = calcRelativeConstraints(layout.layoutBox, dragConstraints);
|
|
}
|
|
else {
|
|
this.constraints = false;
|
|
}
|
|
}
|
|
this.elastic = resolveDragElastic(dragElastic);
|
|
/**
|
|
* If we're outputting to external MotionValues, we want to rebase the measured constraints
|
|
* from viewport-relative to component-relative.
|
|
*/
|
|
if (prevConstraints !== this.constraints &&
|
|
layout &&
|
|
this.constraints &&
|
|
!this.hasMutatedConstraints) {
|
|
eachAxis((axis) => {
|
|
if (this.getAxisMotionValue(axis)) {
|
|
this.constraints[axis] = rebaseAxisConstraints(layout.layoutBox[axis], this.constraints[axis]);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
resolveRefConstraints() {
|
|
const { dragConstraints: constraints, onMeasureDragConstraints } = this.getProps();
|
|
if (!constraints || !isRefObject(constraints))
|
|
return false;
|
|
const constraintsElement = constraints.current;
|
|
invariant(constraintsElement !== null, "If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop.");
|
|
const { projection } = this.visualElement;
|
|
// TODO
|
|
if (!projection || !projection.layout)
|
|
return false;
|
|
const constraintsBox = measurePageBox(constraintsElement, projection.root, this.visualElement.getTransformPagePoint());
|
|
let measuredConstraints = calcViewportConstraints(projection.layout.layoutBox, constraintsBox);
|
|
/**
|
|
* If there's an onMeasureDragConstraints listener we call it and
|
|
* if different constraints are returned, set constraints to that
|
|
*/
|
|
if (onMeasureDragConstraints) {
|
|
const userConstraints = onMeasureDragConstraints(convertBoxToBoundingBox(measuredConstraints));
|
|
this.hasMutatedConstraints = !!userConstraints;
|
|
if (userConstraints) {
|
|
measuredConstraints = convertBoundingBoxToBox(userConstraints);
|
|
}
|
|
}
|
|
return measuredConstraints;
|
|
}
|
|
startAnimation(velocity) {
|
|
const { drag, dragMomentum, dragElastic, dragTransition, dragSnapToOrigin, onDragTransitionEnd, } = this.getProps();
|
|
const constraints = this.constraints || {};
|
|
const momentumAnimations = eachAxis((axis) => {
|
|
if (!shouldDrag(axis, drag, this.currentDirection)) {
|
|
return;
|
|
}
|
|
let transition = (constraints && constraints[axis]) || {};
|
|
if (dragSnapToOrigin)
|
|
transition = { min: 0, max: 0 };
|
|
/**
|
|
* Overdamp the boundary spring if `dragElastic` is disabled. There's still a frame
|
|
* of spring animations so we should look into adding a disable spring option to `inertia`.
|
|
* We could do something here where we affect the `bounceStiffness` and `bounceDamping`
|
|
* using the value of `dragElastic`.
|
|
*/
|
|
const bounceStiffness = dragElastic ? 200 : 1000000;
|
|
const bounceDamping = dragElastic ? 40 : 10000000;
|
|
const inertia = {
|
|
type: "inertia",
|
|
velocity: dragMomentum ? velocity[axis] : 0,
|
|
bounceStiffness,
|
|
bounceDamping,
|
|
timeConstant: 750,
|
|
restDelta: 1,
|
|
restSpeed: 10,
|
|
...dragTransition,
|
|
...transition,
|
|
};
|
|
// If we're not animating on an externally-provided `MotionValue` we can use the
|
|
// component's animation controls which will handle interactions with whileHover (etc),
|
|
// otherwise we just have to animate the `MotionValue` itself.
|
|
return this.startAxisValueAnimation(axis, inertia);
|
|
});
|
|
// Run all animations and then resolve the new drag constraints.
|
|
return Promise.all(momentumAnimations).then(onDragTransitionEnd);
|
|
}
|
|
startAxisValueAnimation(axis, transition) {
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
return axisValue.start(animateMotionValue(axis, axisValue, 0, transition));
|
|
}
|
|
stopAnimation() {
|
|
eachAxis((axis) => this.getAxisMotionValue(axis).stop());
|
|
}
|
|
pauseAnimation() {
|
|
eachAxis((axis) => { var _a; return (_a = this.getAxisMotionValue(axis).animation) === null || _a === void 0 ? void 0 : _a.pause(); });
|
|
}
|
|
getAnimationState(axis) {
|
|
var _a;
|
|
return (_a = this.getAxisMotionValue(axis).animation) === null || _a === void 0 ? void 0 : _a.state;
|
|
}
|
|
/**
|
|
* Drag works differently depending on which props are provided.
|
|
*
|
|
* - If _dragX and _dragY are provided, we output the gesture delta directly to those motion values.
|
|
* - Otherwise, we apply the delta to the x/y motion values.
|
|
*/
|
|
getAxisMotionValue(axis) {
|
|
const dragKey = "_drag" + axis.toUpperCase();
|
|
const props = this.visualElement.getProps();
|
|
const externalMotionValue = props[dragKey];
|
|
return externalMotionValue
|
|
? externalMotionValue
|
|
: this.visualElement.getValue(axis, (props.initial ? props.initial[axis] : undefined) || 0);
|
|
}
|
|
snapToCursor(point) {
|
|
eachAxis((axis) => {
|
|
const { drag } = this.getProps();
|
|
// If we're not dragging this axis, do an early return.
|
|
if (!shouldDrag(axis, drag, this.currentDirection))
|
|
return;
|
|
const { projection } = this.visualElement;
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
if (projection && projection.layout) {
|
|
const { min, max } = projection.layout.layoutBox[axis];
|
|
axisValue.set(point[axis] - mix(min, max, 0.5));
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* When the viewport resizes we want to check if the measured constraints
|
|
* have changed and, if so, reposition the element within those new constraints
|
|
* relative to where it was before the resize.
|
|
*/
|
|
scalePositionWithinConstraints() {
|
|
if (!this.visualElement.current)
|
|
return;
|
|
const { drag, dragConstraints } = this.getProps();
|
|
const { projection } = this.visualElement;
|
|
if (!isRefObject(dragConstraints) || !projection || !this.constraints)
|
|
return;
|
|
/**
|
|
* Stop current animations as there can be visual glitching if we try to do
|
|
* this mid-animation
|
|
*/
|
|
this.stopAnimation();
|
|
/**
|
|
* Record the relative position of the dragged element relative to the
|
|
* constraints box and save as a progress value.
|
|
*/
|
|
const boxProgress = { x: 0, y: 0 };
|
|
eachAxis((axis) => {
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
if (axisValue) {
|
|
const latest = axisValue.get();
|
|
boxProgress[axis] = calcOrigin({ min: latest, max: latest }, this.constraints[axis]);
|
|
}
|
|
});
|
|
/**
|
|
* Update the layout of this element and resolve the latest drag constraints
|
|
*/
|
|
const { transformTemplate } = this.visualElement.getProps();
|
|
this.visualElement.current.style.transform = transformTemplate
|
|
? transformTemplate({}, "")
|
|
: "none";
|
|
projection.root && projection.root.updateScroll();
|
|
projection.updateLayout();
|
|
this.resolveConstraints();
|
|
/**
|
|
* For each axis, calculate the current progress of the layout axis
|
|
* within the new constraints.
|
|
*/
|
|
eachAxis((axis) => {
|
|
if (!shouldDrag(axis, drag, null))
|
|
return;
|
|
/**
|
|
* Calculate a new transform based on the previous box progress
|
|
*/
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
const { min, max } = this.constraints[axis];
|
|
axisValue.set(mix(min, max, boxProgress[axis]));
|
|
});
|
|
}
|
|
addListeners() {
|
|
if (!this.visualElement.current)
|
|
return;
|
|
elementDragControls.set(this.visualElement, this);
|
|
const element = this.visualElement.current;
|
|
/**
|
|
* Attach a pointerdown event listener on this DOM element to initiate drag tracking.
|
|
*/
|
|
const stopPointerListener = addPointerEvent(element, "pointerdown", (event) => {
|
|
const { drag, dragListener = true } = this.getProps();
|
|
drag && dragListener && this.start(event);
|
|
});
|
|
const measureDragConstraints = () => {
|
|
const { dragConstraints } = this.getProps();
|
|
if (isRefObject(dragConstraints)) {
|
|
this.constraints = this.resolveRefConstraints();
|
|
}
|
|
};
|
|
const { projection } = this.visualElement;
|
|
const stopMeasureLayoutListener = projection.addEventListener("measure", measureDragConstraints);
|
|
if (projection && !projection.layout) {
|
|
projection.root && projection.root.updateScroll();
|
|
projection.updateLayout();
|
|
}
|
|
measureDragConstraints();
|
|
/**
|
|
* Attach a window resize listener to scale the draggable target within its defined
|
|
* constraints as the window resizes.
|
|
*/
|
|
const stopResizeListener = addDomEvent(window, "resize", () => this.scalePositionWithinConstraints());
|
|
/**
|
|
* If the element's layout changes, calculate the delta and apply that to
|
|
* the drag gesture's origin point.
|
|
*/
|
|
const stopLayoutUpdateListener = projection.addEventListener("didUpdate", (({ delta, hasLayoutChanged }) => {
|
|
if (this.isDragging && hasLayoutChanged) {
|
|
eachAxis((axis) => {
|
|
const motionValue = this.getAxisMotionValue(axis);
|
|
if (!motionValue)
|
|
return;
|
|
this.originPoint[axis] += delta[axis].translate;
|
|
motionValue.set(motionValue.get() + delta[axis].translate);
|
|
});
|
|
this.visualElement.render();
|
|
}
|
|
}));
|
|
return () => {
|
|
stopResizeListener();
|
|
stopPointerListener();
|
|
stopMeasureLayoutListener();
|
|
stopLayoutUpdateListener && stopLayoutUpdateListener();
|
|
};
|
|
}
|
|
getProps() {
|
|
const props = this.visualElement.getProps();
|
|
const { drag = false, dragDirectionLock = false, dragPropagation = false, dragConstraints = false, dragElastic = defaultElastic, dragMomentum = true, } = props;
|
|
return {
|
|
...props,
|
|
drag,
|
|
dragDirectionLock,
|
|
dragPropagation,
|
|
dragConstraints,
|
|
dragElastic,
|
|
dragMomentum,
|
|
};
|
|
}
|
|
}
|
|
function shouldDrag(direction, drag, currentDirection) {
|
|
return ((drag === true || drag === direction) &&
|
|
(currentDirection === null || currentDirection === direction));
|
|
}
|
|
/**
|
|
* Based on an x/y offset determine the current drag direction. If both axis' offsets are lower
|
|
* than the provided threshold, return `null`.
|
|
*
|
|
* @param offset - The x/y offset from origin.
|
|
* @param lockThreshold - (Optional) - the minimum absolute offset before we can determine a drag direction.
|
|
*/
|
|
function getCurrentDirection(offset, lockThreshold = 10) {
|
|
let direction = null;
|
|
if (Math.abs(offset.y) > lockThreshold) {
|
|
direction = "y";
|
|
}
|
|
else if (Math.abs(offset.x) > lockThreshold) {
|
|
direction = "x";
|
|
}
|
|
return direction;
|
|
}
|
|
|
|
export { VisualElementDragControls, elementDragControls };
|