feat: Add initial random rotation for cards, introduce a PointerTrackingContext for pointer management, and include a design document for spatial workspace architecture.

This commit is contained in:
2025-11-26 22:42:16 +01:00
parent 0c6504ef1c
commit 07e28b3f8d
7 changed files with 177 additions and 23 deletions
+36 -10
View File
@@ -1,6 +1,6 @@
import { LayoutStore } from './LayoutSystem'; import { LayoutStore } from './LayoutSystem';
export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }) => { export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }, pointerId: number) => {
const leadingCard = store.items.get(leadingId); const leadingCard = store.items.get(leadingId);
if (!leadingCard) return; if (!leadingCard) return;
@@ -9,6 +9,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
if (id === leadingId) return; if (id === leadingId) return;
const card = store.items.get(id); const card = store.items.get(id);
if (card) { if (card) {
// If card is already dragging by another pointer, skip it
if (card.isDragging && card.dragPointerId !== pointerId) return;
const leadingCenterX = leadingCard.x + leadingCard.width / 2; const leadingCenterX = leadingCard.x + leadingCard.width / 2;
const leadingCenterY = leadingCard.y + leadingCard.height / 2; const leadingCenterY = leadingCard.y + leadingCard.height / 2;
@@ -23,6 +26,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
const card = store.items.get(id); const card = store.items.get(id);
if (!card) return; if (!card) return;
// If card is already dragging by another pointer, skip it
if (card.isDragging && card.dragPointerId !== pointerId) return;
let myOffset = offset; let myOffset = offset;
if (id !== leadingId) { if (id !== leadingId) {
@@ -35,25 +41,45 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
}; };
} }
card.beginDrag(myOffset); card.beginDrag(myOffset, pointerId);
}); });
}; };
export const handleDragMove = (store: LayoutStore, selection: string[], delta: { x: number, y: number }) => { export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => {
const leadingCard = store.items.get(leadingId);
if (!leadingCard || !leadingCard.isDragging) return;
selection.forEach(id => { selection.forEach(id => {
if (id === leadingId) return;
const card = store.items.get(id); const card = store.items.get(id);
if (card) {
// If card exists and is not already dragging, attach it
if (card && !card.isDragging) {
const leadingCenterX = leadingCard.x + leadingCard.width / 2;
const leadingCenterY = leadingCard.y + leadingCard.height / 2;
const targetX = leadingCenterX - card.width / 2;
const targetY = leadingCenterY - card.height / 2;
card.snapTo(targetX, targetY);
card.beginDrag({ x: 0, y: 0 }, pointerId);
}
});
};
export const handleDragMove = (store: LayoutStore, _selection: string[], delta: { x: number, y: number }, pointerId: number) => {
for (const card of store.items.values()) {
if (card.isDragging && card.dragPointerId === pointerId) {
card.continueDrag(delta); card.continueDrag(delta);
} }
}); }
}; };
export const handleDragEnd = (store: LayoutStore, selection: string[]) => { export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => {
selection.forEach(id => { for (const card of store.items.values()) {
const card = store.items.get(id); if (card.dragPointerId === pointerId) {
if (card) {
card.finishDrag(); card.finishDrag();
} }
}); }
store.saveLayout(); store.saveLayout();
}; };
+28 -1
View File
@@ -16,6 +16,7 @@ import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.css'; import '../styles/workspace/workspace-cards.css';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import { createDocumentEntryKey } from '../app/entryKey'; import { createDocumentEntryKey } from '../app/entryKey';
import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext';
import type { Identifier } from '../types/identifiers'; import type { Identifier } from '../types/identifiers';
import type { DocumentsListEntry, Document } from '../types/documents'; import type { DocumentsListEntry, Document } from '../types/documents';
@@ -77,9 +78,10 @@ const DesktopDocumentContainer: React.FC<React.ComponentProps<typeof DesktopDocu
/> />
); );
}); });
DesktopDocumentContainer.displayName = 'DesktopDocumentContainer'; DesktopDocumentContainer.displayName = 'DesktopDocumentContainer';
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
entries, entries,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
@@ -89,6 +91,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
tenantId, tenantId,
viewId, viewId,
}) => { }) => {
const { addPointer, removePointer } = usePointerTracking();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isLayoutReady, setIsLayoutReady] = useState(false); const [isLayoutReady, setIsLayoutReady] = useState(false);
@@ -219,10 +222,26 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onKeyDown={handleShellKeyDown} onKeyDown={handleShellKeyDown}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
// Register background pointer
addPointer(e.pointerId);
(e.target as Element).setPointerCapture(e.pointerId);
onClearSelection(); onClearSelection();
focusShell(); focusShell();
} }
}} }}
onPointerUp={(e) => {
if (e.target === e.currentTarget) {
removePointer(e.pointerId);
(e.target as Element).releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.target === e.currentTarget) {
removePointer(e.pointerId);
(e.target as Element).releasePointerCapture(e.pointerId);
}
}}
> >
{isLayoutReady && items.map((doc, index) => { {isLayoutReady && items.map((doc, index) => {
const docId = doc.id ? String(doc.id) : `temp-${index}`; const docId = doc.id ? String(doc.id) : `temp-${index}`;
@@ -295,4 +314,12 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
); );
}; };
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = (props) => {
return (
<PointerTrackingProvider>
<DesktopWorkspaceContent {...props} />
</PointerTrackingProvider>
);
};
export default DesktopWorkspace; export default DesktopWorkspace;
+2 -2
View File
@@ -335,7 +335,7 @@ export class LayoutStore {
}, ref); }, ref);
// Calculate initial position using the card instance // Calculate initial position using the card instance
const { x: initX, y: initY } = getInitialPosition( const { x: initX, y: initY, rotation: initRotation } = getInitialPosition(
this.containerWidth, this.containerWidth,
this.containerHeight, this.containerHeight,
card, card,
@@ -343,7 +343,7 @@ export class LayoutStore {
); );
// Update card with calculated position // Update card with calculated position
card.update({ x: initX, y: initY }, { markDirty: true }); card.update({ x: initX, y: initY, rotation: initRotation }, { markDirty: true });
} }
this.items.set(id, card); this.items.set(id, card);
@@ -0,0 +1,35 @@
import React, { createContext, useContext, useRef, useCallback } from 'react';
interface PointerTrackingContextType {
activePointersRef: React.MutableRefObject<Map<number, string | undefined>>;
addPointer: (id: number, cardId?: string) => void;
removePointer: (id: number) => void;
}
const PointerTrackingContext = createContext<PointerTrackingContextType | null>(null);
export const PointerTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const activePointersRef = useRef(new Map<number, string | undefined>());
const addPointer = useCallback((id: number, cardId?: string) => {
activePointersRef.current.set(id, cardId);
}, []);
const removePointer = useCallback((id: number) => {
activePointersRef.current.delete(id);
}, []);
return React.createElement(
PointerTrackingContext.Provider,
{ value: { activePointersRef, addPointer, removePointer } },
children
);
};
export const usePointerTracking = () => {
const context = useContext(PointerTrackingContext);
if (!context) {
throw new Error('usePointerTracking must be used within a PointerTrackingProvider');
}
return context;
};
+72 -6
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useRef } from 'react';
import { usePointerTracking } from './PointerTrackingContext';
import { LayoutCard } from './LayoutSystem'; import { LayoutCard } from './LayoutSystem';
import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic'; import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic';
@@ -18,6 +19,9 @@ export const useCardPointer = (
const [state, setState] = React.useState<PointerState>('idle'); const [state, setState] = React.useState<PointerState>('idle');
const initialPosition = useRef<{ x: number, y: number } | null>(null); const initialPosition = useRef<{ x: number, y: number } | null>(null);
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 { activePointersRef, addPointer, removePointer } = usePointerTracking();
const updateState = useCallback((e: React.PointerEvent) => { const updateState = useCallback((e: React.PointerEvent) => {
if (state === 'click' && initialPosition.current) { if (state === 'click' && initialPosition.current) {
@@ -38,17 +42,59 @@ export const useCardPointer = (
e.preventDefault(); e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId); (e.target as Element).setPointerCapture(e.pointerId);
// Register pointer with card ID
addPointer(e.pointerId, card.id);
setState('click'); setState('click');
initialPosition.current = { x: e.clientX, y: e.clientY }; initialPosition.current = { x: e.clientX, y: e.clientY };
lastPosition.current = { x: e.clientX, y: e.clientY }; lastPosition.current = { x: e.clientX, y: e.clientY };
}, []);
// Long press detection for touch devices
if (e.pointerType === 'touch') {
longPressTimer.current = setTimeout(() => {
// Select stack
const stackIds = card.store.getStackBelow(card);
const idsToAdd = new Set<string>();
if (!isSelected) idsToAdd.add(card.id);
stackIds.forEach(id => idsToAdd.add(id));
// Only select what isn't already selected
const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id));
if (unselectedIdsToAdd.length > 0) {
onSelect(unselectedIdsToAdd, true);
// Haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
}, 500); // 500ms long press
}
}, [card, isSelected, selection, onSelect, addPointer]);
const onPointerMove = useCallback((e: React.PointerEvent) => { const onPointerMove = useCallback((e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; // Check for multi-touch (more than 1 active pointer implies we should add to selection)
// We check > 1 because the current pointer is already added
const isMultiTouch = activePointersRef.current.size > 1;
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch;
updateState(e); updateState(e);
// Cancel long press if moved
if (state === 'click' && initialPosition.current) {
const dx = e.clientX - initialPosition.current.x;
const dy = e.clientY - initialPosition.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_THRESHOLD && longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}
if (state === 'drag-start') { if (state === 'drag-start') {
let effectiveSelection = selection; let effectiveSelection = selection;
@@ -82,12 +128,21 @@ export const useCardPointer = (
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
// 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, card.id, offset); handleDragStart(card.store, effectiveSelection, leadingCardId, offset);
// 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 };
} }
@@ -104,12 +159,23 @@ export const useCardPointer = (
lastPosition.current = { x: e.clientX, y: e.clientY }; lastPosition.current = { x: e.clientX, y: e.clientY };
} }
} }
}, [card, state, updateState, isSelected, selection, onSelect]); }, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]);
const onPointerUp = useCallback((e: React.PointerEvent) => { const onPointerUp = useCallback((e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
// Check for multi-touch before removing the pointer
const isMultiTouch = activePointersRef.current.size > 1;
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch;
// Unregister pointer
removePointer(e.pointerId);
updateState(e); updateState(e);
if (state === 'drag' || state === 'drag-start') { if (state === 'drag' || state === 'drag-start') {
@@ -136,7 +202,7 @@ export const useCardPointer = (
initialPosition.current = null; initialPosition.current = null;
lastPosition.current = null; lastPosition.current = null;
(e.target as Element).releasePointerCapture(e.pointerId); (e.target as Element).releasePointerCapture(e.pointerId);
}, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection]); }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]);
return { return {
onPointerDown, onPointerDown,
+3 -3
View File
@@ -26,10 +26,10 @@ export const getInitialPosition = (
containerHeight: number, containerHeight: number,
card: LayoutCard, card: LayoutCard,
_existingCards: LayoutCard[] = [] _existingCards: LayoutCard[] = []
): { x: number, y: number } => { ): { x: number, y: number, rotation: number } => {
// Mitchell's Best-Candidate Algorithm (Monte Carlo) // Mitchell's Best-Candidate Algorithm (Monte Carlo)
const K = 20; // Number of candidates to test const K = 20; // Number of candidates to test
let bestCandidate = { x: 0, y: 0 }; let bestCandidate = { x: 0, y: 0, rotation: 0 };
let bestScore = -Infinity; let bestScore = -Infinity;
// Padding to keep cards inside // Padding to keep cards inside
@@ -79,7 +79,7 @@ export const getInitialPosition = (
if (score > bestScore) { if (score > bestScore) {
bestScore = score; bestScore = score;
bestCandidate = { x, y }; bestCandidate = { x, y, rotation: Math.random() * 10 - 5 };
} }
} }