refactor: Introduce a layered architecture for card interactions, separating drag logic, and selection handling.
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
import { LayoutStore } from './LayoutSystem';
|
||||||
|
|
||||||
|
const capturedCards = new Set<string>();
|
||||||
|
|
||||||
|
export const handleDragMove = (store: LayoutStore, selection: string[], delta: { x: number, y: number }) => {
|
||||||
|
selection.forEach(id => {
|
||||||
|
const card = store.items.get(id);
|
||||||
|
if (card) {
|
||||||
|
if (!capturedCards.has(id)) {
|
||||||
|
card.captureDragStart();
|
||||||
|
capturedCards.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.update({
|
||||||
|
x: card.dragStartX + delta.x,
|
||||||
|
y: card.dragStartY + delta.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleDragEnd = () => {
|
||||||
|
capturedCards.clear();
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import '../styles/workspace/workspace-layout.css';
|
|||||||
import '../styles/workspace/workspace-items.css';
|
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 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';
|
||||||
|
|
||||||
@@ -56,10 +57,16 @@ export interface DesktopWorkspaceProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper to handle hooks per card
|
// Wrapper to handle hooks per card
|
||||||
const DesktopDocumentContainer: React.FC<React.ComponentProps<typeof DesktopDocumentCard>> = React.memo((props) => {
|
const DesktopDocumentContainer: React.FC<React.ComponentProps<typeof DesktopDocumentCard> & {
|
||||||
const { layoutCard, selected } = props;
|
onSelect: (ids: string[], extend?: boolean) => void;
|
||||||
|
onDeselect: (ids: string[]) => void;
|
||||||
|
onDocumentActivate?: (id: string) => void;
|
||||||
|
selection: string[];
|
||||||
|
}> = React.memo((props) => {
|
||||||
|
const { layoutCard, selected, onSelect, onDeselect, onDocumentActivate, selection } = props;
|
||||||
|
|
||||||
// We assume layoutCard is always present in this context
|
// We assume layoutCard is always present in this context
|
||||||
const cardPointerHandlers = useCardPointer(layoutCard!, !!selected);
|
const cardPointerHandlers = useCardPointer(layoutCard!, !!selected, selection, onSelect, onDeselect, onDocumentActivate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DesktopDocumentCard
|
<DesktopDocumentCard
|
||||||
@@ -94,22 +101,18 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
|
|
||||||
// Selection Context
|
// Selection Context
|
||||||
const {
|
const {
|
||||||
selectedDocumentIds: contextSelectedIds,
|
selectedDocumentIds,
|
||||||
setSelectedDocumentIds,
|
setSelectedEntries,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} = useWorkspaceSelectionContext();
|
} = useWorkspaceSelectionContext();
|
||||||
|
|
||||||
const [localSelectedIds, setLocalSelectedIds] = useState<Identifier[]>([]);
|
|
||||||
const selectedDocumentIds = contextSelectedIds || localSelectedIds;
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((ids: Identifier[]) => {
|
const handleSelectionChange = useCallback((ids: Identifier[]) => {
|
||||||
if (setSelectedDocumentIds) {
|
if (setSelectedEntries) {
|
||||||
setSelectedDocumentIds(ids);
|
const keys = ids.map(id => createDocumentEntryKey(id));
|
||||||
} else {
|
setSelectedEntries(keys);
|
||||||
setLocalSelectedIds(ids);
|
|
||||||
}
|
}
|
||||||
onSelectionChange?.(ids);
|
onSelectionChange?.(ids);
|
||||||
}, [setSelectedDocumentIds, onSelectionChange]);
|
}, [setSelectedEntries, onSelectionChange]);
|
||||||
|
|
||||||
const onClearSelection = useCallback(() => {
|
const onClearSelection = useCallback(() => {
|
||||||
clearSelection ? clearSelection() : handleSelectionChange([]);
|
clearSelection ? clearSelection() : handleSelectionChange([]);
|
||||||
@@ -207,7 +210,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
handleNavigatorSnapshot={() => { }}
|
handleNavigatorSnapshot={() => { }}
|
||||||
onDocumentActivate={onDocumentActivate}
|
onDocumentActivate={(id) => { onDocumentActivate?.({ id } as DeskDocument) }}
|
||||||
layoutCard={layoutCard}
|
layoutCard={layoutCard}
|
||||||
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
|
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
|
||||||
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
||||||
@@ -220,6 +223,21 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
tagTargetActive={tagDropTargetId === docId}
|
tagTargetActive={tagDropTargetId === docId}
|
||||||
tagTargetPending={pendingTagDocId === docId}
|
tagTargetPending={pendingTagDocId === docId}
|
||||||
pendingRemovalTag={pendingRemovalTag}
|
pendingRemovalTag={pendingRemovalTag}
|
||||||
|
onSelect={(ids, extend = false) => {
|
||||||
|
if (!extend) {
|
||||||
|
handleSelectionChange(ids);
|
||||||
|
} else {
|
||||||
|
const newSelection = new Set(selectedDocumentIds);
|
||||||
|
ids.forEach(id => newSelection.add(id));
|
||||||
|
handleSelectionChange(Array.from(newSelection));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeselect={(ids) => {
|
||||||
|
const newSelection = new Set(selectedDocumentIds);
|
||||||
|
ids.forEach(id => newSelection.delete(id));
|
||||||
|
handleSelectionChange(Array.from(newSelection));
|
||||||
|
}}
|
||||||
|
selection={selectedDocumentIds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
|
|
||||||
private _innerRadius: number = 0;
|
private _innerRadius: number = 0;
|
||||||
private _outerRadius: number = 0;
|
private _outerRadius: number = 0;
|
||||||
|
public store: LayoutStore;
|
||||||
|
public dragStartX: number = 0;
|
||||||
|
public dragStartY: number = 0;
|
||||||
|
|
||||||
constructor(id: string, 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;
|
||||||
|
this.store = store;
|
||||||
Object.assign(this, initialData);
|
Object.assign(this, initialData);
|
||||||
this.ref = ref;
|
this.ref = ref;
|
||||||
this.recalculateRadii();
|
this.recalculateRadii();
|
||||||
@@ -35,6 +39,11 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
this.applyTransform();
|
this.applyTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureDragStart() {
|
||||||
|
this.dragStartX = this.x;
|
||||||
|
this.dragStartY = this.y;
|
||||||
|
}
|
||||||
|
|
||||||
update(changes: Partial<LayoutCardState>) {
|
update(changes: Partial<LayoutCardState>) {
|
||||||
Object.assign(this, changes);
|
Object.assign(this, changes);
|
||||||
if (changes.width !== undefined || changes.height !== undefined) {
|
if (changes.width !== undefined || changes.height !== undefined) {
|
||||||
@@ -43,19 +52,113 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
this.applyTransform();
|
this.applyTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isUnobstructed(): boolean {
|
||||||
|
for (const other of this.store.items.values()) {
|
||||||
|
if (other.id === this.id) continue;
|
||||||
|
if (other.z <= this.z) continue;
|
||||||
|
|
||||||
|
const dx = other.x - this.x;
|
||||||
|
const dy = other.y - this.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Broad phase: check outer radii
|
||||||
|
if (distance < this.outerRadius + other.outerRadius) {
|
||||||
|
// Narrow phase: SAT intersection test
|
||||||
|
if (this.intersects(other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bringToFront() {
|
||||||
|
this.z = this.store.zCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVertices(): { x: number; y: number }[] {
|
||||||
|
const rad = (this.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
const hw = this.width / 2;
|
||||||
|
const hh = this.height / 2;
|
||||||
|
|
||||||
|
// Corners relative to center, then rotated, then translated
|
||||||
|
// (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)
|
||||||
|
const corners = [
|
||||||
|
{ x: -hw, y: -hh },
|
||||||
|
{ x: hw, y: -hh },
|
||||||
|
{ x: hw, y: hh },
|
||||||
|
{ x: -hw, y: hh }
|
||||||
|
];
|
||||||
|
|
||||||
|
return corners.map(p => ({
|
||||||
|
x: (p.x * cos - p.y * sin) + this.x,
|
||||||
|
y: (p.x * sin + p.y * cos) + this.y
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAxes(): { x: number; y: number }[] {
|
||||||
|
const rad = (this.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
// Normals of the edges (local x and y axes)
|
||||||
|
return [
|
||||||
|
{ x: cos, y: sin },
|
||||||
|
{ x: -sin, y: cos }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private intersects(other: LayoutCard): boolean {
|
||||||
|
const verticesA = this.getVertices();
|
||||||
|
const verticesB = other.getVertices();
|
||||||
|
const axes = [...this.getAxes(), ...other.getAxes()];
|
||||||
|
|
||||||
|
for (const axis of axes) {
|
||||||
|
const pA = this.project(verticesA, axis);
|
||||||
|
const pB = this.project(verticesB, axis);
|
||||||
|
|
||||||
|
if (pA.max < pB.min || pB.max < pA.min) {
|
||||||
|
return false; // Gap found, no intersection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private project(vertices: { x: number; y: number }[], axis: { x: number; y: number }) {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const v of vertices) {
|
||||||
|
const dot = v.x * axis.x + v.y * axis.y;
|
||||||
|
if (dot < min) min = dot;
|
||||||
|
if (dot > max) max = dot;
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
private recalculateRadii() {
|
private recalculateRadii() {
|
||||||
this._innerRadius = Math.min(this.width, this.height) / 2;
|
this._innerRadius = Math.min(this.width, this.height) / 2;
|
||||||
this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2;
|
this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rafId: number | null = null;
|
||||||
|
|
||||||
private applyTransform() {
|
private applyTransform() {
|
||||||
if (this.ref) {
|
if (this.rafId) {
|
||||||
this.ref.style.transform =
|
cancelAnimationFrame(this.rafId);
|
||||||
`translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
|
||||||
this.ref.style.zIndex = String(this.z);
|
|
||||||
this.ref.style.width = `${this.width}px`;
|
|
||||||
this.ref.style.height = `${this.height}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.rafId = requestAnimationFrame(() => {
|
||||||
|
if (this.ref) {
|
||||||
|
this.ref.style.transform =
|
||||||
|
`translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
||||||
|
this.ref.style.zIndex = String(this.z);
|
||||||
|
this.ref.style.width = `${this.width}px`;
|
||||||
|
this.ref.style.height = `${this.height}px`;
|
||||||
|
}
|
||||||
|
this.rafId = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toSnapshot(): LayoutCardState {
|
toSnapshot(): LayoutCardState {
|
||||||
@@ -101,12 +204,13 @@ export class LayoutStore {
|
|||||||
const y = Math.random() * 500;
|
const y = Math.random() * 500;
|
||||||
const rotation = Math.random() * 10 - 5;
|
const rotation = Math.random() * 10 - 5;
|
||||||
|
|
||||||
card = new LayoutCard(id, {
|
card = new LayoutCard(id, this, {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
rotation,
|
rotation,
|
||||||
width,
|
width,
|
||||||
height
|
height,
|
||||||
|
z: this.zCounter++
|
||||||
}, ref);
|
}, ref);
|
||||||
this.items.set(id, card);
|
this.items.set(id, card);
|
||||||
} else {
|
} else {
|
||||||
@@ -125,18 +229,6 @@ export class LayoutStore {
|
|||||||
this.items.delete(id);
|
this.items.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, updates: Partial<LayoutCardState>) {
|
|
||||||
const card = this.items.get(id);
|
|
||||||
if (card) {
|
|
||||||
if (updates.z) this.zCounter = Math.max(this.zCounter, updates.z);
|
|
||||||
card.update(updates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bringToFront(id: string) {
|
|
||||||
this.update(id, { z: ++this.zCounter });
|
|
||||||
}
|
|
||||||
|
|
||||||
getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] {
|
getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] {
|
||||||
const result: LayoutCard[] = [];
|
const result: LayoutCard[] = [];
|
||||||
for (const card of this.items.values()) {
|
for (const card of this.items.values()) {
|
||||||
|
|||||||
@@ -1,23 +1,102 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { LayoutCard } from './LayoutSystem';
|
import { LayoutCard } from './LayoutSystem';
|
||||||
|
import { handleDragMove, handleDragEnd } from './CardDragLogic';
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 3;
|
||||||
|
|
||||||
|
type PointerState = 'idle' | 'click' | 'drag';
|
||||||
|
|
||||||
|
export const useCardPointer = (
|
||||||
|
card: LayoutCard,
|
||||||
|
isSelected: boolean,
|
||||||
|
selection: string[],
|
||||||
|
onSelect: (ids: string[], extend?: boolean) => void,
|
||||||
|
onDeselect: (ids: string[]) => void,
|
||||||
|
onDocumentActivate?: (id: string) => void
|
||||||
|
) => {
|
||||||
|
const [state, setState] = React.useState<PointerState>('idle');
|
||||||
|
const initialPosition = useRef<{ x: number, y: number } | null>(null);
|
||||||
|
|
||||||
|
const updateState = useCallback((e: React.PointerEvent) => {
|
||||||
|
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) {
|
||||||
|
setState('drag-start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
export const useCardPointer = (card: LayoutCard, isSelected: boolean) => {
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
console.log('Pointer down on card', card.id, card.rotation, card.z, isSelected, e);
|
// Only left click
|
||||||
}, [card, isSelected]);
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
(e.target as Element).setPointerCapture(e.pointerId);
|
||||||
|
setState('click');
|
||||||
|
initialPosition.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
console.log('Pointer move on card', card.id, card.rotation, e);
|
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||||
}, [card]);
|
updateState(e);
|
||||||
|
|
||||||
|
if (state === 'drag-start') {
|
||||||
|
if (!isSelected) {
|
||||||
|
onSelect([card.id], hasModifier);
|
||||||
|
|
||||||
|
if (!hasModifier) {
|
||||||
|
card.bringToFront();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('drag');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'drag' && initialPosition.current) {
|
||||||
|
const delta = {
|
||||||
|
x: e.clientX - initialPosition.current.x,
|
||||||
|
y: e.clientY - initialPosition.current.y
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragMove(card.store, selection, delta);
|
||||||
|
}
|
||||||
|
}, [card, state, updateState, isSelected, selection, onSelect]);
|
||||||
|
|
||||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
console.log('Pointer up on card', card.id, card.rotation, e);
|
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||||
}, [card]);
|
updateState(e);
|
||||||
|
|
||||||
|
if (state === 'drag' || state === 'drag-start') {
|
||||||
|
handleDragEnd();
|
||||||
|
} else if (state === 'click') {
|
||||||
|
if (isSelected) {
|
||||||
|
const isUnobstructed = card.isUnobstructed();
|
||||||
|
|
||||||
|
if (hasModifier) {
|
||||||
|
onDeselect([card.id]);
|
||||||
|
} else if (isUnobstructed) {
|
||||||
|
onDocumentActivate?.(card.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect([card.id], hasModifier);
|
||||||
|
|
||||||
|
if (!hasModifier) {
|
||||||
|
card.bringToFront();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('idle');
|
||||||
|
initialPosition.current = null;
|
||||||
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
|
}, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user