This commit is contained in:
2025-11-17 01:46:01 +01:00
parent 42733a9a92
commit 61573431e3
2 changed files with 188 additions and 48 deletions
+146 -34
View File
@@ -12,6 +12,10 @@ import usePointerTap from '../ui/usePointerTap';
import {
MIN_TIMESTEP,
MAX_TIMESTEP,
MAX_ANGULAR_VELOCITY,
MAX_DYNAMIC_ROTATION,
CARD_BASE_WEIGHT_GRAMS,
CARD_PAGE_WEIGHT_GRAMS,
applyDomTransform,
type WorkspaceEngine,
} from './workspaceEngine';
@@ -21,6 +25,10 @@ type Identifier = string | number;
interface DocumentLike {
id?: Identifier | null;
title?: string;
current_version?: {
metadata?: { page_count?: number | string | null } | null;
} | null;
metadata?: { page_count?: number | string | null } | null;
[key: string]: unknown;
}
@@ -147,6 +155,10 @@ interface DragStateInternal extends EngineDragState {
stackDocIds: string[] | null;
stackSelectionApplied: boolean;
stackReplace: boolean;
massGrams: number;
pointerRadiusScale: number;
lastPointerCanvasX: number;
lastPointerCanvasY: number;
}
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
@@ -161,6 +173,20 @@ const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const EDGE_COLLISION_THRESHOLD = 0.5;
const getDocumentPageCount = (doc?: DocumentLike | null): number | null => {
const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count;
if (raw == null) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
};
const computeDocumentMassGrams = (doc?: DocumentLike | null): number => {
const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1));
return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS;
};
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
if (!event) {
return null;
@@ -319,6 +345,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
if (!doc) {
return;
}
const massGrams = computeDocumentMassGrams(doc);
const stackDocIdsOptionRaw = options?.stackDocIds;
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
@@ -512,6 +539,10 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
stackDocIds: hasStackSource ? stackDocIdsOption : null,
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
stackReplace,
massGrams,
pointerRadiusScale: 1,
lastPointerCanvasX: pointerCanvasX,
lastPointerCanvasY: pointerCanvasY,
} satisfies DragStateInternal;
const state = dragStateRef.current;
@@ -585,7 +616,63 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
if (state.pointerId !== event.pointerId) {
return;
}
preventAll(event);
preventAll(event);
const updatePointerAngularVelocity = (
pointerCanvasX: number,
pointerCanvasY: number,
centerX: number,
centerY: number,
dtSeconds: number,
) => {
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
return;
}
const leverX = pointerCanvasX - centerX;
const leverY = pointerCanvasY - centerY;
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
return;
}
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
? state.lastPointerCanvasX
: pointerCanvasX;
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
? state.lastPointerCanvasY
: pointerCanvasY;
const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds;
const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds;
state.lastPointerCanvasX = pointerCanvasX;
state.lastPointerCanvasY = pointerCanvasY;
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
return;
}
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
const influenceRadius = Math.max(state.width, state.height) / 2 || 1;
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
state.pointerRadiusScale = radiusScale;
const torqueResponse = 0.0025 * radiusScale;
const angularVelocityDeg = clamp(
torque * torqueResponse,
-MAX_ANGULAR_VELOCITY,
MAX_ANGULAR_VELOCITY,
);
const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
state.angularVelocity = angularVelocityDeg / massScale;
};
const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => {
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
return;
}
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
const response = 1.1 * radiusInfluence;
let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response;
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
state.dynamicRotation = nextDynamic * adjustedDamping;
state.rotation = state.restRotation + state.dynamicRotation;
};
if (state.isGroup) {
const containerRect = containerRef.current?.getBoundingClientRect?.();
@@ -701,14 +788,30 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
applyDomTransform(node, payload);
});
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp =
const currentTimestampGroup =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
dtGroup = MIN_TIMESTEP;
}
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestampGroup;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
applyDynamicRotation(dtGroup, 0.96);
state.groupItems.forEach((item) => {
if (item.docId === state.docKey) {
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
}
});
return;
}
@@ -736,6 +839,35 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const pointerCanvasY = event.clientY - containerTop;
const entry = layoutRef.current.get(state.docKey) || {};
const previousCenterX = Number.isFinite(state.currentCenterX)
? state.currentCenterX
: state.originCenterX;
const previousCenterY = Number.isFinite(state.currentCenterY)
? state.currentCenterY
: state.originCenterY;
const currentTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
let dt = (currentTimestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX;
const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt);
applyDynamicRotation(dt);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestamp;
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad);
@@ -745,13 +877,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const rotatedOffsetY =
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
const previousCenterX = Number.isFinite(state.currentCenterX)
? state.currentCenterX
: state.originCenterX;
const previousCenterY = Number.isFinite(state.currentCenterY)
? state.currentCenterY
: state.originCenterY;
const absCos = Math.abs(cosRot);
const absSin = Math.abs(sinRot);
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
@@ -826,23 +951,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const pointerInsideCard =
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
const currentTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
let dt = (currentTimestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestamp;
const rotationForOffsetDeg = state.rotation || 0;
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
const cosInverse = Math.cos(-rotationForOffsetRad);
@@ -892,16 +1000,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
if (state.moved) {
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
@@ -953,16 +1063,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
}
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);