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';
|
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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,4 +71,4 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user