ui
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { clamp, formatTransform } from './math';
|
||||
|
||||
export const MIN_TIMESTEP = 1 / 120;
|
||||
export const MAX_TIMESTEP = 1 / 20;
|
||||
export const MAX_DYNAMIC_ROTATION = 4;
|
||||
export const MAX_ANGULAR_VELOCITY = 180;
|
||||
export const ANGULAR_DAMPING = 11;
|
||||
export const TORQUE_TO_ACCELERATION = 0.006;
|
||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
|
||||
const callRef = (ref) => {
|
||||
const handler = ref?.current;
|
||||
if (typeof handler === 'function') {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
export const createDragPhysics = ({
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
markLayoutDirtyRef,
|
||||
syncLayoutSnapshotRef,
|
||||
}) => {
|
||||
const inertiaAnimations = new Map();
|
||||
|
||||
const applyTransform = (docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.style.transform = formatTransform(
|
||||
centerX - width / 2,
|
||||
centerY - height / 2,
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
};
|
||||
|
||||
const finalizeGroupDrag = (dragState) => {
|
||||
if (!dragState?.groupItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragState.groupItems.forEach((item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
||||
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
||||
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
||||
|
||||
layoutRef.current.set(item.docId, {
|
||||
...entryItem,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
});
|
||||
|
||||
applyTransform(
|
||||
item.docId,
|
||||
centerX,
|
||||
centerY,
|
||||
item.width,
|
||||
item.height,
|
||||
rotation,
|
||||
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
||||
);
|
||||
});
|
||||
|
||||
callRef(markLayoutDirtyRef);
|
||||
};
|
||||
|
||||
const cancelInertiaAnimation = (docId) => {
|
||||
if (typeof window === 'undefined') {
|
||||
inertiaAnimations.delete(docId);
|
||||
return;
|
||||
}
|
||||
const existing = inertiaAnimations.get(docId);
|
||||
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(existing.frameId);
|
||||
}
|
||||
inertiaAnimations.delete(docId);
|
||||
};
|
||||
|
||||
const integrateRotation = (simulationState, dt, torque = 0, dampingOverride = null) => {
|
||||
const { docId } = simulationState;
|
||||
const entry = layoutRef.current.get(docId);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const centerX = Number(entry.centerX);
|
||||
const centerY = Number(entry.centerY);
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
||||
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
||||
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
||||
|
||||
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
||||
const dampingFactor = Math.exp(-dampingConstant * dt);
|
||||
angularVelocity *= dampingFactor;
|
||||
|
||||
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
||||
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.min(angularVelocity, 0);
|
||||
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.max(angularVelocity, 0);
|
||||
}
|
||||
|
||||
simulationState.angularVelocity = angularVelocity;
|
||||
simulationState.dynamicRotation = dynamicRotation;
|
||||
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
||||
|
||||
const rotation = simulationState.rotation;
|
||||
layoutRef.current.set(docId, { ...entry, rotation });
|
||||
callRef(markLayoutDirtyRef);
|
||||
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
centerX - simulationState.width / 2,
|
||||
centerY - simulationState.height / 2,
|
||||
rotation,
|
||||
simulationState.dragScale || 1,
|
||||
);
|
||||
}
|
||||
|
||||
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
||||
return isSettled;
|
||||
};
|
||||
|
||||
const startInertiaAnimation = (docId, baseState) => {
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInertiaAnimation(docId);
|
||||
|
||||
const now =
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
const simulationState = {
|
||||
...baseState,
|
||||
docId,
|
||||
dragScale: baseState.dragScale || 1,
|
||||
lastTimestamp: now,
|
||||
};
|
||||
|
||||
const step = (timestamp) => {
|
||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||
const previous = simulationState.lastTimestamp;
|
||||
let dt = (safeTimestamp - previous) / 1000;
|
||||
if (!Number.isFinite(dt) || dt <= 0) {
|
||||
dt = MIN_TIMESTEP;
|
||||
}
|
||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||
simulationState.lastTimestamp = safeTimestamp;
|
||||
|
||||
const settled = integrateRotation(simulationState, dt, 0);
|
||||
if (settled) {
|
||||
inertiaAnimations.delete(docId);
|
||||
callRef(syncLayoutSnapshotRef);
|
||||
return;
|
||||
}
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
inertiaAnimations.set(docId, simulationState);
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
inertiaAnimations.forEach((animation) => {
|
||||
if (animation?.frameId != null) {
|
||||
window.cancelAnimationFrame(animation.frameId);
|
||||
}
|
||||
});
|
||||
}
|
||||
inertiaAnimations.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
applyTransform,
|
||||
finalizeGroupDrag,
|
||||
cancelInertiaAnimation,
|
||||
integrateRotation,
|
||||
startInertiaAnimation,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
|
||||
export default createDragPhysics;
|
||||
@@ -1,18 +1,12 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDesktopContext } from './context';
|
||||
import { preventAll } from './events';
|
||||
import { clamp, formatTransform } from './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics';
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
const MIN_TIMESTEP = 1 / 120;
|
||||
const MAX_TIMESTEP = 1 / 20;
|
||||
const MAX_DYNAMIC_ROTATION = 4;
|
||||
const MAX_ANGULAR_VELOCITY = 180;
|
||||
const ANGULAR_DAMPING = 11;
|
||||
const TORQUE_TO_ACCELERATION = 0.006;
|
||||
const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||
|
||||
const useDocumentDrag = () => {
|
||||
@@ -37,61 +31,40 @@ const useDocumentDrag = () => {
|
||||
markLayoutDirty,
|
||||
} = useDesktopContext();
|
||||
|
||||
const applyTransform = useCallback(
|
||||
(docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.style.transform = formatTransform(
|
||||
centerX - width / 2,
|
||||
centerY - height / 2,
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
},
|
||||
[itemRefs],
|
||||
);
|
||||
const markLayoutDirtyRef = useRef(markLayoutDirty);
|
||||
useEffect(() => {
|
||||
markLayoutDirtyRef.current = markLayoutDirty;
|
||||
}, [markLayoutDirty]);
|
||||
|
||||
const finalizeGroupDrag = useCallback(
|
||||
(dragState) => {
|
||||
if (!dragState?.groupItems) {
|
||||
return;
|
||||
}
|
||||
const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
|
||||
useEffect(() => {
|
||||
syncLayoutSnapshotRef.current = syncLayoutSnapshot;
|
||||
}, [syncLayoutSnapshot]);
|
||||
|
||||
dragState.groupItems.forEach((item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
||||
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
||||
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
||||
|
||||
layoutRef.current.set(item.docId, {
|
||||
...entryItem,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
});
|
||||
|
||||
applyTransform(
|
||||
item.docId,
|
||||
centerX,
|
||||
centerY,
|
||||
item.width,
|
||||
item.height,
|
||||
rotation,
|
||||
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
||||
);
|
||||
const physicsRef = useRef(null);
|
||||
if (!physicsRef.current) {
|
||||
physicsRef.current = createDragPhysics({
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
markLayoutDirtyRef,
|
||||
syncLayoutSnapshotRef,
|
||||
});
|
||||
}
|
||||
|
||||
markLayoutDirty?.();
|
||||
},
|
||||
[applyTransform, layoutRef, markLayoutDirty],
|
||||
useEffect(
|
||||
() => () => {
|
||||
physicsRef.current?.dispose?.();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
applyTransform,
|
||||
finalizeGroupDrag,
|
||||
cancelInertiaAnimation,
|
||||
startInertiaAnimation,
|
||||
} = physicsRef.current;
|
||||
|
||||
const tapHandler = usePointerTap({
|
||||
delay: 220,
|
||||
onSingle: ({ data, event }) => {
|
||||
@@ -119,118 +92,8 @@ const useDocumentDrag = () => {
|
||||
});
|
||||
|
||||
const dragStateRef = useRef(null);
|
||||
const inertiaAnimationsRef = useRef(new Map());
|
||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
||||
|
||||
const cancelInertiaAnimation = useCallback((docId) => {
|
||||
if (typeof window === 'undefined') {
|
||||
inertiaAnimationsRef.current.delete(docId);
|
||||
return;
|
||||
}
|
||||
const existing = inertiaAnimationsRef.current.get(docId);
|
||||
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(existing.frameId);
|
||||
}
|
||||
inertiaAnimationsRef.current.delete(docId);
|
||||
}, []);
|
||||
|
||||
const integrateRotation = useCallback(
|
||||
(simulationState, dt, torque = 0, dampingOverride = null) => {
|
||||
const { docId } = simulationState;
|
||||
const entry = layoutRef.current.get(docId);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const centerX = Number(entry.centerX);
|
||||
const centerY = Number(entry.centerY);
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
||||
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
||||
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
||||
|
||||
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
||||
const dampingFactor = Math.exp(-dampingConstant * dt);
|
||||
angularVelocity *= dampingFactor;
|
||||
|
||||
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
||||
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.min(angularVelocity, 0);
|
||||
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.max(angularVelocity, 0);
|
||||
}
|
||||
|
||||
simulationState.angularVelocity = angularVelocity;
|
||||
simulationState.dynamicRotation = dynamicRotation;
|
||||
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
||||
|
||||
const rotation = simulationState.rotation;
|
||||
layoutRef.current.set(docId, { ...entry, rotation });
|
||||
markLayoutDirty?.();
|
||||
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
centerX - simulationState.width / 2,
|
||||
centerY - simulationState.height / 2,
|
||||
rotation,
|
||||
simulationState.dragScale || 1,
|
||||
);
|
||||
}
|
||||
|
||||
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
||||
return isSettled;
|
||||
},
|
||||
[itemRefs, layoutRef, markLayoutDirty],
|
||||
);
|
||||
|
||||
const startInertiaAnimation = useCallback(
|
||||
(docId, baseState) => {
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
return;
|
||||
}
|
||||
cancelInertiaAnimation(docId);
|
||||
const now =
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
const simulationState = {
|
||||
...baseState,
|
||||
docId,
|
||||
dragScale: baseState.dragScale || 1,
|
||||
lastTimestamp: now,
|
||||
};
|
||||
|
||||
const step = (timestamp) => {
|
||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||
const previous = simulationState.lastTimestamp;
|
||||
let dt = (safeTimestamp - previous) / 1000;
|
||||
if (!Number.isFinite(dt) || dt <= 0) {
|
||||
dt = MIN_TIMESTEP;
|
||||
}
|
||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||
simulationState.lastTimestamp = safeTimestamp;
|
||||
|
||||
const settled = integrateRotation(simulationState, dt, 0);
|
||||
if (settled) {
|
||||
inertiaAnimationsRef.current.delete(docId);
|
||||
syncLayoutSnapshot();
|
||||
return;
|
||||
}
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
inertiaAnimationsRef.current.set(docId, simulationState);
|
||||
},
|
||||
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
|
||||
);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId) => {
|
||||
const state = dragStateRef.current;
|
||||
@@ -346,7 +209,18 @@ const useDocumentDrag = () => {
|
||||
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
if (!modifierPressed) {
|
||||
if (isGroupDrag) {
|
||||
groupDocIds.forEach((id) => bringToFront(id));
|
||||
const layout = layoutRef.current;
|
||||
const ordered = [...groupDocIds]
|
||||
.filter((id, index, array) => array.indexOf(id) === index)
|
||||
.sort((a, b) => {
|
||||
const aZ = layout.get(a)?.z ?? 0;
|
||||
const bZ = layout.get(b)?.z ?? 0;
|
||||
return aZ - bZ;
|
||||
});
|
||||
|
||||
ordered.forEach((id) => {
|
||||
bringToFront(id === docKey ? docId : id);
|
||||
});
|
||||
} else {
|
||||
bringToFront(docId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user