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:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user