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';
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);
if (!leadingCard) return;
@@ -9,6 +9,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
if (id === leadingId) return;
const card = store.items.get(id);
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 leadingCenterY = leadingCard.y + leadingCard.height / 2;
@@ -23,6 +26,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
const card = store.items.get(id);
if (!card) return;
// If card is already dragging by another pointer, skip it
if (card.isDragging && card.dragPointerId !== pointerId) return;
let myOffset = offset;
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 => {
if (id === leadingId) return;
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);
}
});
}
};
export const handleDragEnd = (store: LayoutStore, selection: string[]) => {
selection.forEach(id => {
const card = store.items.get(id);
if (card) {
export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => {
for (const card of store.items.values()) {
if (card.dragPointerId === pointerId) {
card.finishDrag();
}
});
}
store.saveLayout();
};
+28 -1
View File
@@ -16,6 +16,7 @@ import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.css';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import { createDocumentEntryKey } from '../app/entryKey';
import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext';
import type { Identifier } from '../types/identifiers';
import type { DocumentsListEntry, Document } from '../types/documents';
@@ -77,9 +78,10 @@ const DesktopDocumentContainer: React.FC<React.ComponentProps<typeof DesktopDocu
/>
);
});
DesktopDocumentContainer.displayName = 'DesktopDocumentContainer';
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
entries,
ensureAssetUrl,
getDocumentAsset,
@@ -89,6 +91,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
tenantId,
viewId,
}) => {
const { addPointer, removePointer } = usePointerTracking();
const containerRef = useRef<HTMLDivElement>(null);
const [isLayoutReady, setIsLayoutReady] = useState(false);
@@ -219,10 +222,26 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onKeyDown={handleShellKeyDown}
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
// Register background pointer
addPointer(e.pointerId);
(e.target as Element).setPointerCapture(e.pointerId);
onClearSelection();
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) => {
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;
+2 -2
View File
@@ -335,7 +335,7 @@ export class LayoutStore {
}, ref);
// Calculate initial position using the card instance
const { x: initX, y: initY } = getInitialPosition(
const { x: initX, y: initY, rotation: initRotation } = getInitialPosition(
this.containerWidth,
this.containerHeight,
card,
@@ -343,7 +343,7 @@ export class LayoutStore {
);
// 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);
@@ -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 { usePointerTracking } from './PointerTrackingContext';
import { LayoutCard } from './LayoutSystem';
import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic';
@@ -18,6 +19,9 @@ export const useCardPointer = (
const [state, setState] = React.useState<PointerState>('idle');
const initialPosition = 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) => {
if (state === 'click' && initialPosition.current) {
@@ -38,17 +42,59 @@ export const useCardPointer = (
e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId);
// Register pointer with card ID
addPointer(e.pointerId, card.id);
setState('click');
initialPosition.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) => {
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);
// 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') {
let effectiveSelection = selection;
@@ -82,12 +128,21 @@ export const useCardPointer = (
const rect = card.ref.getBoundingClientRect();
const centerX = rect.left + rect.width / 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 = {
x: initialPosition.current.x - centerX,
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
lastPosition.current = { x: e.clientX, y: e.clientY };
}
@@ -104,12 +159,23 @@ export const useCardPointer = (
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) => {
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);
if (state === 'drag' || state === 'drag-start') {
@@ -136,7 +202,7 @@ export const useCardPointer = (
initialPosition.current = null;
lastPosition.current = null;
(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 {
onPointerDown,
+3 -3
View File
@@ -26,10 +26,10 @@ export const getInitialPosition = (
containerHeight: number,
card: LayoutCard,
_existingCards: LayoutCard[] = []
): { x: number, y: number } => {
): { x: number, y: number, rotation: number } => {
// Mitchell's Best-Candidate Algorithm (Monte Carlo)
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;
// Padding to keep cards inside
@@ -79,7 +79,7 @@ export const getInitialPosition = (
if (score > bestScore) {
bestScore = score;
bestCandidate = { x, y };
bestCandidate = { x, y, rotation: Math.random() * 10 - 5 };
}
}
@@ -71,4 +71,4 @@
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
}