framer-motion
Version:
A simple and powerful JavaScript animation library
338 lines (335 loc) • 13.6 kB
JavaScript
"use client";
import { jsx } from 'react/jsx-runtime';
import { visualElementStore, elementDragControls, microtask } from 'motion-dom';
import { useRef } from 'react';
import { ReorderBoardContext } from '../../context/ReorderBoardContext.mjs';
import { useConstant } from '../../utils/use-constant.mjs';
import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-effect.mjs';
const centre = (element) => {
const { left, top, width, height } = element.getBoundingClientRect();
return { x: left + width / 2, y: top + height / 2 };
};
/**
* Its centre in page coordinates.
*/
const pageCentre = (element) => {
const { x, y } = centre(element);
const { scrollX, scrollY } = element.ownerDocument.defaultView;
return { x: x + scrollX, y: y + scrollY };
};
/**
* Whether `point` comes before `element` in its group's order: before its
* centre on `axis` or, on "xy", above its line or before its centre on it.
*/
function isBefore(point, element, axis, isRTL) {
const { left, right, top, bottom } = element.getBoundingClientRect();
const x = isRTL
? point.x > (left + right) / 2
: point.x <= (left + right) / 2;
const y = point.y <= (top + bottom) / 2;
return axis === "x"
? x
: axis === "y"
? y
: point.y < top || (point.y < bottom && x);
}
/**
* Whether one group is, or holds, the other: like a list of columns and the
* list of cards in one of them. Items only move between unrelated groups.
*/
const isNested = (a, b) => {
const x = a.groupRef.current;
const y = b.groupRef.current;
return x.contains(y) || y.contains(x);
};
function createBoard(props) {
const groups = new Set();
let handoff;
/**
* Whether `value` can move between these groups, if the app lets it.
*/
const canMove = (from, to, value) => !isNested(from, to) &&
props.current.canMove?.(value, from.values, to.values) !== false;
/**
* Where the latest keyboard grab began, for Escape to return it there.
*/
let origin;
/**
* Move `value` from one group to another: to `index` in its values or,
* by default, the slot under the item's centre.
*/
const move = (from, to, value, element, props, index) => {
const point = centre(element);
const { values } = to;
if (index === undefined) {
/**
* Only the items it shows: a group keeps unmounted (e.g.
* virtualized) items' data while their values remain.
*/
const order = to
.getOrder()
.filter((item) => item.ref?.current?.isConnected);
const isRTL = getComputedStyle(to.groupRef.current).direction === "rtl";
const next = order.find((item) => isBefore(point, item.ref.current, to.axis, isRTL));
const last = order[order.length - 1];
index = next
? values.indexOf(next.value)
: last
? values.indexOf(last.value) + 1
: values.length;
}
handoff = {
value,
to: to.groupRef,
element,
centre: pageCentre(element),
...props,
};
/**
* The release that ends a drag lands over the new group.
*/
to.hasReordered.current = true;
from.onReorder(from.values.filter((item) => item !== value), value);
to.onReorder([...values.slice(0, index), value, ...values.slice(index)], value);
};
return {
add: (group) => {
groups.add(group);
return () => groups.delete(group);
},
drag: (from, value, element, event) => {
/**
* The item can unmount mid-drag (e.g. the app removed it), while
* its gesture carries on until release.
*/
if (!element?.isConnected)
return false;
/**
* Wait for the item to arrive in its new group before moving
* it again, keeping up with where it's dragged meanwhile, for
* its drag to carry on from there. If the groups don't take
* it, it stays here until this gesture ends.
*/
if (handoff?.controls?.isDragging) {
if (handoff.value === value) {
handoff.centre = pageCentre(element);
handoff.event = event;
}
return true;
}
const point = centre(element);
let to;
for (const hit of element.ownerDocument.elementsFromPoint(point.x, point.y)) {
// Skip the item, which is drawn above everything else
if (element.contains(hit))
continue;
for (const group of groups) {
if (group.groupRef.current === hit)
to = group;
}
if (to)
break;
}
if (!to || !canMove(from, to, value))
return false;
const visualElement = visualElementStore.get(element);
move(from, to, value, element, {
controls: visualElement && elementDragControls.get(visualElement),
event,
});
return true;
},
key: (from, value, keyIndex, element, grabbed) => {
const point = centre(element);
const axis = keyIndex % 2 ? "x" : "y";
const direction = keyIndex > 1 ? 1 : -1;
let to;
let nearest = Infinity;
for (const group of groups) {
const target = group.groupRef.current;
if (!target)
continue;
const { left, right, top, bottom, width, height } = target.getBoundingClientRect();
// Hidden, e.g. display: none
if (!width && !height)
continue;
/**
* Its near edge is past the item that way, so a group beside
* this one isn't below its last item, whatever their heights.
*/
const edge = [bottom, right, top, left][keyIndex];
if ((edge - point[axis]) * direction <= 0)
continue;
const x = Math.max(left - point.x, 0, point.x - right);
const y = Math.max(top - point.y, 0, point.y - bottom);
if (x * x + y * y < nearest && canMove(from, group, value)) {
nearest = x * x + y * y;
to = group;
}
}
if (!to)
return false;
if (origin?.from !== grabbed) {
origin = { group: from.groupRef, from: grabbed };
}
move(from, to, value, element, { keys: true, from: grabbed });
return true;
},
restore: (from, value, element, grabbed) => {
const to = origin?.from === grabbed &&
[...groups].find((group) => group.groupRef === origin.group);
if (!to || to.groupRef === from.groupRef)
return false;
/**
* Before the first value that followed it when it was grabbed,
* and is still there.
*/
const after = grabbed.slice(grabbed.indexOf(value) + 1);
const index = to.values.findIndex((item) => after.includes(item));
origin = undefined;
move(from, to, value, element, { keys: true }, index < 0 ? to.values.length : index);
return true;
},
end: (value) => {
if (handoff?.value === value)
handoff = undefined;
},
isMoving: (element) => handoff?.element === element,
receive: (to, value, element, grab) => {
const visualElement = visualElementStore.get(element);
const nextControls = visualElement && elementDragControls.get(visualElement);
/**
* React 19's Strict Mode remounts a new item, which resets its
* whileDrag, after its drag may already have carried on.
*/
nextControls?.isDragging &&
visualElement.animationState?.setActive("whileDrag", true);
const latest = handoff;
if (!latest ||
latest.value !== value ||
latest.to !== to.groupRef) {
return false;
}
const { keys, from, controls } = latest;
/**
* Whether the groups moved the item, rather than copied it.
*/
const isMoved = !latest.element.isConnected;
if (keys) {
handoff = undefined;
/**
* Unless focus has moved on since, e.g. the groups took it
* much later.
*/
const { activeElement, body } = element.ownerDocument;
if (isMoved && (!activeElement || activeElement === body)) {
element.focus();
grab(element, from);
}
return false;
}
if (!nextControls || !controls?.isDragging)
return false;
/**
* Once the commit that moved the item has been measured, and
* before it's drawn. Until then, the item's drag in its
* previous group waits.
*/
microtask.read(() => {
if (handoff !== latest)
return;
handoff = undefined;
/**
* Start it where, and as, the item was dragged to (e.g.
* mid-way through its whileDrag animation), and carry on
* dragging it from there. Values are read from the item
* first, for its whileDrag to return to on release.
*/
visualElementStore
.get(latest.element)
?.values.forEach((motionValue, key) => {
const current = motionValue.get();
visualElement
.getValue(key, visualElement.readValue(key, current))
?.set(current);
});
/**
* Into the item's own coordinates, e.g. in a scaled parent.
*/
const transform = visualElement.getTransformPagePoint() || ((p) => p);
const a = transform(latest.centre);
const b = transform(pageCentre(element));
visualElement.getValue("x").set(a.x - b.x);
visualElement.getValue("y").set(a.y - b.y);
visualElement.render();
/**
* If the item is still in its previous group, animate it
* back to its slot there.
*/
isMoved ? controls.cancel() : controls.stop();
nextControls.start(latest.event, {
immediate: true,
});
});
/**
* Its drag already started in its previous group.
*/
return isMoved;
},
column: ({ groupRef }) => {
let element = groupRef.current;
const holdsOther = (ancestor) => [...groups].some((group) => group.groupRef !== groupRef &&
ancestor.contains(group.groupRef.current));
while (element?.parentElement?.parentElement &&
!holdsOther(element.parentElement)) {
element = element.parentElement;
}
return element;
},
};
}
/**
* Lets items be dragged between the `Reorder.Group`s inside it.
*
* ```jsx
* <Reorder.Board>
* <Reorder.Group values={todo} onReorder={setTodo}>
* {todo.map((item) => <Reorder.Item key={item} value={item} />)}
* </Reorder.Group>
* <Reorder.Group values={done} onReorder={setDone}>
* {done.map((item) => <Reorder.Item key={item} value={item} />)}
* </Reorder.Group>
* </Reorder.Board>
* ```
*
* When an item's centre is dragged over another group, it moves there:
* the group it left calls `onReorder` without it, and the group it joined
* calls `onReorder` with it, both passing it as the moved value. Their
* other items animate to make room, and the drag carries on in the new
* group.
*
* Grabbed with the keyboard, an item moves to the nearest group in the
* direction of an arrow key across its group's axis, or along it from
* either end. Escape returns it to the group and slot it was grabbed from.
*
* `canMove` can refuse a move before either group updates.
*
* Values must be unique across the groups. If the app keeps the groups'
* values in one state, update it from the latest state (e.g.
* `setColumns((columns) => ...)`), as both groups update it in turn.
*
* @public
*/
function ReorderBoard({ children, ...props }) {
const latest = useRef(props);
/**
* In a layout effect, so a render React discards can't leave its
* props here.
*/
useIsomorphicLayoutEffect(() => {
latest.current = props;
});
return (jsx(ReorderBoardContext.Provider, { value: useConstant(() => createBoard(latest)), children: children }));
}
export { ReorderBoard };
//# sourceMappingURL=Board.mjs.map