feat: Implement multi-touch drag and long-press stack selection with pointer tracking and physics model documentation.
This commit is contained in:
@@ -32,6 +32,7 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
public intendedX: number = 0;
|
public intendedX: number = 0;
|
||||||
public intendedY: number = 0;
|
public intendedY: number = 0;
|
||||||
private dragOffset: { x: number, y: number } | null = null;
|
private dragOffset: { x: number, y: number } | null = null;
|
||||||
|
public dragPointerId: number | null = null;
|
||||||
|
|
||||||
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -49,10 +50,11 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
this.applyTransform();
|
this.applyTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
beginDrag(offset: { x: number, y: number }) {
|
beginDrag(offset: { x: number, y: number }, pointerId: number) {
|
||||||
this.dragOffset = offset;
|
this.dragOffset = offset;
|
||||||
this.dragStartX = this.x;
|
this.dragStartX = this.x;
|
||||||
this.dragStartY = this.y;
|
this.dragStartY = this.y;
|
||||||
|
this.dragPointerId = pointerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
|
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
|
||||||
@@ -91,6 +93,7 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
|
|
||||||
finishDrag() {
|
finishDrag() {
|
||||||
this.dragOffset = null;
|
this.dragOffset = null;
|
||||||
|
this.dragPointerId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
|
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
|
||||||
@@ -278,6 +281,10 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
get centerY(): number {
|
get centerY(): number {
|
||||||
return this._centerY;
|
return this._centerY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isDragging(): boolean {
|
||||||
|
return !!this.dragOffset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LayoutStore {
|
export class LayoutStore {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useRef } from 'react';
|
|||||||
import { usePointerTracking } from './PointerTrackingContext';
|
import { usePointerTracking } from './PointerTrackingContext';
|
||||||
|
|
||||||
import { LayoutCard } from './LayoutSystem';
|
import { LayoutCard } from './LayoutSystem';
|
||||||
import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic';
|
import { handleDragMove, handleDragEnd, handleDragStart, attachToDragGroup } from './CardDragLogic';
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 3;
|
const DRAG_THRESHOLD = 3;
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ export const useCardPointer = (
|
|||||||
const lastPosition = useRef<{ x: number, y: number } | null>(null);
|
const lastPosition = useRef<{ x: number, y: number } | null>(null);
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
||||||
|
|
||||||
const updateState = useCallback((e: React.PointerEvent) => {
|
const updateState = useCallback((e: React.PointerEvent) => {
|
||||||
@@ -65,6 +66,30 @@ export const useCardPointer = (
|
|||||||
|
|
||||||
if (unselectedIdsToAdd.length > 0) {
|
if (unselectedIdsToAdd.length > 0) {
|
||||||
onSelect(unselectedIdsToAdd, true);
|
onSelect(unselectedIdsToAdd, true);
|
||||||
|
|
||||||
|
const isMultiTouch = activePointersRef.current.size > 1;
|
||||||
|
if (isMultiTouch) {
|
||||||
|
// Find an existing drag group to attach to
|
||||||
|
let targetLeaderId: string | null = null;
|
||||||
|
let targetPointerId: number | null = null;
|
||||||
|
|
||||||
|
for (const [ptrId, cId] of activePointersRef.current.entries()) {
|
||||||
|
if (ptrId === e.pointerId) continue; // Skip self
|
||||||
|
if (cId) {
|
||||||
|
const c = card.store.items.get(cId);
|
||||||
|
if (c && c.isDragging) {
|
||||||
|
targetLeaderId = cId;
|
||||||
|
targetPointerId = c.dragPointerId; // Use the pointer driving that card
|
||||||
|
break; // Attach to the first found drag group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLeaderId && targetPointerId !== null) {
|
||||||
|
attachToDragGroup(card.store, unselectedIdsToAdd, targetLeaderId, targetPointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Haptic feedback if available
|
// Haptic feedback if available
|
||||||
if (navigator.vibrate) {
|
if (navigator.vibrate) {
|
||||||
navigator.vibrate(50);
|
navigator.vibrate(50);
|
||||||
@@ -72,7 +97,7 @@ export const useCardPointer = (
|
|||||||
}
|
}
|
||||||
}, 500); // 500ms long press
|
}, 500); // 500ms long press
|
||||||
}
|
}
|
||||||
}, [card, isSelected, selection, onSelect, addPointer]);
|
}, [card, isSelected, selection, onSelect, addPointer, activePointersRef]);
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -124,25 +149,20 @@ export const useCardPointer = (
|
|||||||
|
|
||||||
setState('drag');
|
setState('drag');
|
||||||
|
|
||||||
|
// Start the drag for THIS pointer
|
||||||
if (initialPosition.current) {
|
if (initialPosition.current) {
|
||||||
const rect = card.ref.getBoundingClientRect();
|
const rect = card.ref.getBoundingClientRect();
|
||||||
const centerX = rect.left + rect.width / 2;
|
const centerX = rect.left + rect.width / 2;
|
||||||
const centerY = rect.top + rect.height / 2;
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
// Determine leading card (anchor) based on oldest pointer
|
const leadingCardId = card.id;
|
||||||
// The Map iterates in insertion order, so the first entry is the oldest
|
|
||||||
const oldestEntry = activePointersRef.current.entries().next().value;
|
|
||||||
const oldestCardId = oldestEntry ? oldestEntry[1] : card.id;
|
|
||||||
|
|
||||||
// Use the oldest card ID if available, otherwise current card
|
|
||||||
const leadingCardId = oldestCardId || card.id;
|
|
||||||
|
|
||||||
const offset = {
|
const offset = {
|
||||||
x: initialPosition.current.x - centerX,
|
x: initialPosition.current.x - centerX,
|
||||||
y: initialPosition.current.y - centerY
|
y: initialPosition.current.y - centerY
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDragStart(card.store, effectiveSelection, leadingCardId, offset);
|
handleDragStart(card.store, effectiveSelection, leadingCardId, offset, e.pointerId);
|
||||||
// Reset lastPosition to current pointer to avoid jump on first move
|
// Reset lastPosition to current pointer to avoid jump on first move
|
||||||
lastPosition.current = { x: e.clientX, y: e.clientY };
|
lastPosition.current = { x: e.clientX, y: e.clientY };
|
||||||
}
|
}
|
||||||
@@ -155,7 +175,7 @@ export const useCardPointer = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (delta.x !== 0 || delta.y !== 0) {
|
if (delta.x !== 0 || delta.y !== 0) {
|
||||||
handleDragMove(card.store, selection, delta);
|
handleDragMove(card.store, selection, delta, e.pointerId);
|
||||||
lastPosition.current = { x: e.clientX, y: e.clientY };
|
lastPosition.current = { x: e.clientX, y: e.clientY };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +199,7 @@ export const useCardPointer = (
|
|||||||
updateState(e);
|
updateState(e);
|
||||||
|
|
||||||
if (state === 'drag' || state === 'drag-start') {
|
if (state === 'drag' || state === 'drag-start') {
|
||||||
handleDragEnd(card.store, selection);
|
handleDragEnd(card.store, selection, e.pointerId);
|
||||||
} else if (state === 'click') {
|
} else if (state === 'click') {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const isUnobstructed = card.isUnobstructed();
|
const isUnobstructed = card.isUnobstructed();
|
||||||
@@ -204,9 +224,27 @@ export const useCardPointer = (
|
|||||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
}, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]);
|
}, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]);
|
||||||
|
|
||||||
|
const onPointerCancel = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we clean of any drags associated with this pointer
|
||||||
|
handleDragEnd(card.store, selection, e.pointerId);
|
||||||
|
|
||||||
|
removePointer(e.pointerId);
|
||||||
|
setState('idle');
|
||||||
|
initialPosition.current = null;
|
||||||
|
lastPosition.current = null;
|
||||||
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
|
}, [card, selection, removePointer]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp
|
onPointerUp,
|
||||||
|
onPointerCancel
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user