feat: Refactor tag drag and drop interactions
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
Design Document: Spatial Workspace Architecture Refactor
|
||||
Status: Draft Target System: Desk/Workspace (Canvas, Dragging, Physics) Primary Goal: Decompose "God Objects" into a composable, layered architecture to improve performance, maintainability, and testability.
|
||||
|
||||
1. Executive Summary
|
||||
The current implementation relies on a monolithic class (WorkspaceEngine) and an overloaded hook (useDeskPointer). This coupling forces React to handle high-frequency logic (physics/drag), resulting in brittle code and potential performance bottlenecks.
|
||||
|
||||
The Proposal: Transition to a Layered Architecture. We will separate "Pure Math" (Physics/Geometry), "Mutable State" (Performance), and "React Interaction" (Events/Business Logic).
|
||||
|
||||
2. Architectural Overview
|
||||
We will adopt a unidirectional, event-driven flow for interactions, bypassing React's render cycle for high-frequency updates (dragging/animating), while using React for low-frequency updates (selection/mounting).
|
||||
|
||||
The Four Layers
|
||||
|
||||
The Physics Layer (Core): Stateless, pure functions for geometry and kinetics.
|
||||
|
||||
The Scene Layer (Store): A lightweight, mutable registry that holds the "truth" of layout (x, y, rotation) and manages direct DOM updates.
|
||||
|
||||
The Interaction Layer (Hooks): React hooks that bind DOM events to the Scene Layer.
|
||||
|
||||
The Persistence Layer: An asynchronous observer that syncs the Scene Layer to the Backend/DB.
|
||||
|
||||
Code-Snippet
|
||||
graph TD
|
||||
User[User Input] -->|Pointer Events| Interaction[Interaction Layer Hooks]
|
||||
Interaction -->|Calculate| Physics[Physics Layer Pure Math]
|
||||
Interaction -->|Update| Scene[Scene Layer Mutable Store]
|
||||
Scene -->|Direct Manipulation| DOM[DOM Elements 60fps]
|
||||
Scene -.->|Debounced Snapshot| DB[Persistence Layer]
|
||||
3. Detailed Component Design
|
||||
Layer 1: Physics & Geometry (lib/spatial)
|
||||
|
||||
Responsibility: Pure math. No side effects. No DOM references.
|
||||
|
||||
Key Modules:
|
||||
|
||||
geometry.ts: Hit testing, polygon intersection, coordinate projection (Screen <-> Canvas).
|
||||
|
||||
kinetics.ts: Inertia decay, angular velocity calculation, clamping.
|
||||
|
||||
Benefit: 100% Unit testable without mocking the DOM.
|
||||
|
||||
Layer 2: The Scene Store (lib/scene)
|
||||
|
||||
Responsibility: High-performance state management. It acts as the bridge between React and the DOM.
|
||||
|
||||
Structure:
|
||||
|
||||
TypeScript
|
||||
class SceneStore {
|
||||
// Fast lookups
|
||||
items: Map<string, SceneItem>;
|
||||
|
||||
// Updates DOM style immediately, skips React render
|
||||
updateItem(id, transform) { ... }
|
||||
|
||||
// Used by Persistence Layer
|
||||
getSnapshot() { ... }
|
||||
}
|
||||
Why: React State is too slow for 60fps drag interactions on complex DOM trees. We need direct manipulation.
|
||||
|
||||
Layer 3: Interaction Hooks (hooks/)
|
||||
|
||||
We split the "God Hook" (useDeskPointer) into specific responsibilities.
|
||||
|
||||
usePointerGesture:
|
||||
|
||||
Role: The "driver." Handles down, move, up, cancel.
|
||||
|
||||
Logic: Manages drag thresholds, long-press timers, and distinguishing taps from drags.
|
||||
|
||||
Output: Emits high-level events: onTap, onDragStart, onDrag, onDragEnd.
|
||||
|
||||
useSpatialQuery:
|
||||
|
||||
Role: The "eyes."
|
||||
|
||||
Logic: Wraps lib/spatial. Given an event (x, y), returns [DocID, StackInfo].
|
||||
|
||||
useDragController:
|
||||
|
||||
Role: The "business logic."
|
||||
|
||||
Logic: Listens to usePointerGesture. When a drag starts:
|
||||
|
||||
Locks the React View (prevents re-renders).
|
||||
|
||||
Calculates physics via lib/spatial.
|
||||
|
||||
Pushes updates to SceneStore.
|
||||
|
||||
On release, triggers inertia animation loop.
|
||||
|
||||
Layer 4: Persistence (Observer)
|
||||
|
||||
Role: Syncs the mutable SceneStore back to the database.
|
||||
|
||||
Mechanism:
|
||||
|
||||
Subscribes to onDragEnd or an internal dirty flag in the Store.
|
||||
|
||||
Uses a debounce strategy (e.g., wait 500ms after last movement) to save to the backend.
|
||||
|
||||
4. Data Flow Scenarios
|
||||
Scenario A: Selecting a Card
|
||||
|
||||
User: Clicks on a card.
|
||||
|
||||
usePointerGesture: Detects pointerDown + pointerUp (no movement). Fires onTap.
|
||||
|
||||
useDeskSelection: Receives onTap. Checks event.metaKey. Updates React State (setSelectedIds).
|
||||
|
||||
React: Re-renders to show selection border.
|
||||
|
||||
Scenario B: Dragging a Card (The Performance Path)
|
||||
|
||||
User: Presses and moves mouse > 5px.
|
||||
|
||||
usePointerGesture: Fires onDragStart.
|
||||
|
||||
useDragController:
|
||||
|
||||
Calculates initialOffsets.
|
||||
|
||||
While moving:
|
||||
|
||||
Calculates new x, y, rotation (using Physics Layer).
|
||||
|
||||
Calls SceneStore.updateItem().
|
||||
|
||||
Result: The DOM element moves via CSS Transform. React does not re-render.
|
||||
|
||||
User: Releases mouse.
|
||||
|
||||
useDragController: Fires onDragEnd. Starts Inertia Animation loop (updating SceneStore via requestAnimationFrame).
|
||||
|
||||
Persistence: Detects end of movement, saves new coordinates.
|
||||
|
||||
5. Migration Strategy
|
||||
We will apply the Strangler Fig Pattern: replacing pieces of the monolith gradually.
|
||||
|
||||
Phase 1: Math Extraction (Safe)
|
||||
|
||||
Extract geometry/physics logic from WorkspaceEngine and pointerUtils into pure functions in lib/spatial.
|
||||
|
||||
Risk: Low.
|
||||
|
||||
Phase 2: The Gesture Hook (Cleanup)
|
||||
|
||||
Implement usePointerGesture. Replace the event listeners in useDeskPointer with this hook.
|
||||
|
||||
Risk: Low.
|
||||
|
||||
Phase 3: The Scene Store (Core Replacement)
|
||||
|
||||
Build SceneStore.
|
||||
|
||||
Modify useDocumentDrag to write to SceneStore instead of WorkspaceEngine.
|
||||
|
||||
Risk: Medium. Visual synchronization bugs might occur during transition.
|
||||
|
||||
Phase 4: Persistence Decoupling
|
||||
|
||||
Move loadPersistedLayout and upsertLayoutRecords out of the Engine and into a specialized React Effect or standard async function triggered by the Store.
|
||||
|
||||
6. Comparison: Old vs. New
|
||||
Feature Old Architecture New Architecture
|
||||
State Monolithic Class (WorkspaceEngine) Mutable Store (SceneStore) + React State
|
||||
Dragging Mixed into Engine & Hooks Isolated Controller Hook
|
||||
Physics Hardcoded in Engine Pure Functional Module
|
||||
DOM Access Cached Refs inside Engine Direct management via Store
|
||||
Testing Difficult (Mocking Engine required) Easy (Test Physics/Store in isolation)
|
||||
7. Open Questions / Risks
|
||||
Z-Index Management: Currently handled by zCounter in the Engine. The SceneStore must maintain a global Z-index counter to ensure "Bring to Front" works reliably.
|
||||
|
||||
** React Context vs. Global Singleton:** Should SceneStore be a global singleton or provided via Context?
|
||||
|
||||
Decision: Context. This allows multiple independent Workspaces on one screen if needed in the future.
|
||||
@@ -25,8 +25,6 @@ export const DRAG_HYSTERESIS_PX = 4;
|
||||
export const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
export const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||
|
||||
export const TAG_REMOVE_DISTANCE = 160;
|
||||
|
||||
export const DEBUG_DRAG = false;
|
||||
export const DEBUG_FOCUS = false;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveCorrespondents } from '../documents/correspondents';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
import type { Document } from '../types/documents';
|
||||
import { LayoutCard } from './LayoutSystem';
|
||||
|
||||
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||
if (!event) return;
|
||||
@@ -11,20 +12,11 @@ const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
||||
};
|
||||
|
||||
interface PendingRemovalTag {
|
||||
docId?: string;
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
import { LayoutCard } from './LayoutSystem';
|
||||
|
||||
interface DesktopDocumentCardProps {
|
||||
doc: Document;
|
||||
style?: React.CSSProperties;
|
||||
shouldLoad?: boolean;
|
||||
matchesFilter?: boolean;
|
||||
tagTargetActive?: boolean;
|
||||
tagTargetPending?: boolean;
|
||||
selected?: boolean;
|
||||
docTagTokens?: string;
|
||||
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||
@@ -36,11 +28,8 @@ interface DesktopDocumentCardProps {
|
||||
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
||||
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
||||
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void;
|
||||
onDocTagPointerDown?: (event: React.PointerEvent<HTMLElement>, doc: Document, tag: any) => void;
|
||||
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
|
||||
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
pendingRemovalTag?: PendingRemovalTag | null;
|
||||
layoutCard?: LayoutCard;
|
||||
}
|
||||
|
||||
@@ -49,8 +38,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
style,
|
||||
shouldLoad,
|
||||
matchesFilter,
|
||||
tagTargetActive,
|
||||
tagTargetPending,
|
||||
selected,
|
||||
docTagTokens,
|
||||
ensureAssetUrl,
|
||||
@@ -62,19 +49,14 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
onTagDragOver,
|
||||
onTagDragLeave,
|
||||
onTagDrop,
|
||||
onDocTagPointerDown,
|
||||
onDocTagDragStart,
|
||||
onDocTagDrag,
|
||||
onDocTagDragEnd,
|
||||
pendingRemovalTag,
|
||||
layoutCard,
|
||||
}) => {
|
||||
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||
|
||||
const itemClasses = ['desk-item'];
|
||||
if (tagTargetActive) itemClasses.push('is-tag-target');
|
||||
if (tagTargetPending) itemClasses.push('is-tag-pending');
|
||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||
if (selected) itemClasses.push('is-selected');
|
||||
|
||||
@@ -145,14 +127,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
{tags.length > 0 && (
|
||||
<div className="desk-item__tags" aria-hidden="true">
|
||||
{tags.map((tag) => {
|
||||
if (pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id) {
|
||||
return null;
|
||||
}
|
||||
const colorStyle = getTagColorStyle(tag.color);
|
||||
const pendingRemoval =
|
||||
pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id;
|
||||
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
||||
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
@@ -161,11 +137,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
title={tag.label}
|
||||
draggable
|
||||
data-desk-tag-chip="true"
|
||||
onPointerDownCapture={(event) => {
|
||||
onDocTagPointerDown?.(event, doc, tag);
|
||||
}}
|
||||
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
||||
onDrag={onDocTagDrag}
|
||||
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
||||
>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
|
||||
@@ -48,7 +48,8 @@ export interface DesktopWorkspaceProps {
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void;
|
||||
onSelectionChange?: (selectedIds: Identifier[]) => void;
|
||||
onDocumentTagDrop?: (docId: Identifier, tag: any) => void;
|
||||
onDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
tenantId?: Identifier | null;
|
||||
viewId?: string | null;
|
||||
}
|
||||
@@ -81,7 +82,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
getDocumentAsset,
|
||||
onDocumentActivate,
|
||||
onSelectionChange,
|
||||
onDocumentTagDrop,
|
||||
onDocumentTagAttach,
|
||||
onDocumentTagDetach,
|
||||
tenantId,
|
||||
viewId,
|
||||
}) => {
|
||||
@@ -183,20 +185,12 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
const focusShell = useCallback(() => { }, []);
|
||||
|
||||
// Tag Interactions
|
||||
const [pendingRemovalTag, setPendingRemovalTag] = useState<{ docId?: string; tagId?: string } | null>(null);
|
||||
const [tagDropTargetId, setTagDropTargetId] = useState<string | null>(null);
|
||||
const [pendingTagDocId, setPendingTagDocId] = useState<string | null>(null);
|
||||
|
||||
const tagEngine = useMemo(() => ({
|
||||
setPendingRemovalTag,
|
||||
setTagDropTargetId,
|
||||
setPendingTagDocId,
|
||||
}), []);
|
||||
|
||||
const tagInteractions = useDeskTagInteractions({
|
||||
engine: tagEngine,
|
||||
onAssignTagToDocument: (docId: string, tag: any) => {
|
||||
onDocumentTagDrop?.(docId, tag);
|
||||
onAssignTagToDocument: (docId: string, tagId: string) => {
|
||||
onDocumentTagAttach?.(docId, tagId);
|
||||
},
|
||||
onRemoveTagFromDocument: (docId: string, tagId: string) => {
|
||||
onDocumentTagDetach?.(docId, tagId);
|
||||
},
|
||||
requestCanvasFocus: focusShell,
|
||||
});
|
||||
@@ -250,6 +244,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
onDrop={tagInteractions.handleCanvasDrop}
|
||||
onDragOver={tagInteractions.handleCanvasDragOver}
|
||||
>
|
||||
{isLayoutReady && items.map((doc, index) => {
|
||||
const docId = doc.id ? String(doc.id) : `temp-${index}`;
|
||||
@@ -286,13 +282,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
||||
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
|
||||
onTagDrop={tagInteractions.handleTagDropOnDoc}
|
||||
onDocTagPointerDown={tagInteractions.handleDocTagPointerDown}
|
||||
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
|
||||
onDocTagDrag={tagInteractions.handleDocTagDrag}
|
||||
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
|
||||
tagTargetActive={tagDropTargetId === docId}
|
||||
tagTargetPending={pendingTagDocId === docId}
|
||||
pendingRemovalTag={pendingRemovalTag}
|
||||
onSelect={(ids, extend = false) => {
|
||||
if (!extend) {
|
||||
handleSelectionChange(ids);
|
||||
|
||||
@@ -15,8 +15,6 @@ const preventAll = (event) => {
|
||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
||||
};
|
||||
|
||||
import { TAG_REMOVE_DISTANCE } from '../../constants/desktop';
|
||||
|
||||
const createDragPreview = (node, clientX, clientY) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return null;
|
||||
@@ -44,63 +42,68 @@ const cleanupPreview = (previewNode) => {
|
||||
};
|
||||
|
||||
export const useDeskTagInteractions = ({
|
||||
engine,
|
||||
onAssignTagToDocument,
|
||||
onRemoveTagFromDocument,
|
||||
requestCanvasFocus,
|
||||
}) => {
|
||||
const draggingTagRef = useRef(null);
|
||||
|
||||
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
||||
|
||||
const handleDocTagPointerDown = useCallback(() => {
|
||||
engine.setPendingRemovalTag(null);
|
||||
}, [engine]);
|
||||
|
||||
const runTagHoverTransition = useCallback(
|
||||
(event, docId, { applyTarget = false, applyPending = false } = {}) => {
|
||||
if (!isTagTransfer(event)) {
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
const stringId = docId != null ? String(docId) : null;
|
||||
if (applyTarget) {
|
||||
engine.setTagDropTargetId(stringId);
|
||||
}
|
||||
if (applyPending) {
|
||||
engine.setPendingTagDocId(stringId);
|
||||
}
|
||||
},
|
||||
[engine, isTagTransfer],
|
||||
);
|
||||
|
||||
const handleTagDragEnterDoc = useCallback(
|
||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }),
|
||||
[runTagHoverTransition],
|
||||
(event) => {
|
||||
if (!isTagTransfer(event)) return;
|
||||
preventAll(event);
|
||||
event.currentTarget.classList.add('is-tag-target');
|
||||
},
|
||||
[isTagTransfer],
|
||||
);
|
||||
|
||||
const handleTagDragOverDoc = useCallback(
|
||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
(event, docId) => {
|
||||
if (!isTagTransfer(event)) return;
|
||||
preventAll(event);
|
||||
event.currentTarget.classList.add('is-tag-target');
|
||||
|
||||
if (event.dataTransfer) {
|
||||
// If dragging over source, copy (no removal). Else move (removal).
|
||||
const isSource = draggingTagRef.current?.sourceDocId === docId;
|
||||
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
|
||||
}
|
||||
},
|
||||
[isTagTransfer],
|
||||
);
|
||||
|
||||
const handleTagDragLeaveDoc = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
(event) => {
|
||||
if (!isTagTransfer(event)) return;
|
||||
// Ignore if leaving to a child element
|
||||
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.classList.remove('is-tag-target');
|
||||
},
|
||||
[isTagTransfer],
|
||||
);
|
||||
|
||||
const handleCanvasDragOver = useCallback(
|
||||
(event) => runTagHoverTransition(event, null),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleCanvasDragLeave = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
(event) => {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCanvasDrop = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
(event) => {
|
||||
// Implicit removal via dragend (dropEffect='move')
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
preventAll(event);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTagDropOnDoc = useCallback(
|
||||
@@ -112,38 +115,37 @@ export const useDeskTagInteractions = ({
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
engine.setTagDropTargetId(null);
|
||||
engine.setPendingTagDocId(null);
|
||||
event.currentTarget.classList.remove('is-tag-target');
|
||||
|
||||
// Add to target
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (!payload || !payload.id) {
|
||||
return;
|
||||
}
|
||||
engine.setPendingRemovalTag(null);
|
||||
|
||||
if (payload.sourceDocId === doc.id) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!payload || !payload.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestCanvasFocus?.();
|
||||
if (payload.sourceDocId === doc.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onAssignTagToDocument) {
|
||||
onAssignTagToDocument(doc.id, {
|
||||
id: payload.id,
|
||||
label: payload.label || '',
|
||||
sourceDocId: payload.sourceDocId ?? null,
|
||||
});
|
||||
}
|
||||
requestCanvasFocus?.();
|
||||
|
||||
if (onAssignTagToDocument) {
|
||||
onAssignTagToDocument(doc.id, payload.id);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
||||
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
||||
);
|
||||
|
||||
const handleDocTagDragStart = useCallback(
|
||||
(event, doc, tag) => {
|
||||
console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id });
|
||||
if (!event?.dataTransfer || !doc || !tag) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||
|
||||
const pointerX = event.clientX;
|
||||
@@ -167,65 +169,54 @@ export const useDeskTagInteractions = ({
|
||||
initialY: pointerY,
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
engine.setPendingRemovalTag(null);
|
||||
},
|
||||
[engine],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDocTagDrag = useCallback((event) => {
|
||||
const state = draggingTagRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const dx = x - (state.initialX || 0);
|
||||
const dy = y - (state.initialY || 0);
|
||||
state.distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (state.distance >= TAG_REMOVE_DISTANCE) {
|
||||
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
|
||||
} else {
|
||||
engine.setPendingRemovalTag(null);
|
||||
}
|
||||
}, [engine]);
|
||||
|
||||
const handleDocTagDragEnd = useCallback(
|
||||
() => {
|
||||
const state = draggingTagRef.current;
|
||||
if (state) {
|
||||
const element = state.element;
|
||||
if (element) {
|
||||
element.classList.remove('is-drag-hidden');
|
||||
(event) => {
|
||||
console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect });
|
||||
const dropEffect = event?.dataTransfer?.dropEffect;
|
||||
|
||||
setTimeout(() => {
|
||||
const state = draggingTagRef.current;
|
||||
if (state) {
|
||||
const element = state.element;
|
||||
if (element) {
|
||||
element.classList.remove('is-drag-hidden');
|
||||
}
|
||||
cleanupPreview(state.previewClone);
|
||||
|
||||
// Remove if move operation completed
|
||||
if (dropEffect === 'move') {
|
||||
if (onRemoveTagFromDocument && state.sourceDocId && state.tagId) {
|
||||
onRemoveTagFromDocument(state.sourceDocId, state.tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanupPreview(state.previewClone);
|
||||
}
|
||||
draggingTagRef.current = null;
|
||||
engine.setPendingRemovalTag(null);
|
||||
engine.setTagDropTargetId(null);
|
||||
engine.setPendingTagDocId(null);
|
||||
draggingTagRef.current = null;
|
||||
}, 0);
|
||||
},
|
||||
[engine],
|
||||
[onRemoveTagFromDocument],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draggingTagRef.current && draggingTagRef.current.previewClone) {
|
||||
cleanupPreview(draggingTagRef.current.previewClone);
|
||||
}
|
||||
draggingTagRef.current = null;
|
||||
engine.setPendingRemovalTag(null);
|
||||
};
|
||||
}, [engine]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleTagDragEnterDoc,
|
||||
handleTagDragOverDoc,
|
||||
handleTagDragLeaveDoc,
|
||||
handleTagDropOnDoc,
|
||||
handleDocTagPointerDown,
|
||||
handleDocTagDragStart,
|
||||
handleDocTagDrag,
|
||||
handleDocTagDragEnd,
|
||||
handleCanvasDragOver,
|
||||
handleCanvasDragLeave,
|
||||
handleCanvasDrop,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ export const useCardPointer = (
|
||||
const lastClickTime = useRef<number>(0);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
||||
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
||||
|
||||
const updateState = useCallback((e: React.PointerEvent) => {
|
||||
@@ -41,6 +40,14 @@ export const useCardPointer = (
|
||||
// Allow left (0) and middle (1) click
|
||||
if (e.button !== 0 && e.button !== 1) return;
|
||||
|
||||
// Ignore interactions on interactive child elements (tags, inputs, buttons, etc.)
|
||||
// We want these elements to handle their own pointer/drag events.
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
@@ -101,6 +108,13 @@ export const useCardPointer = (
|
||||
}, [card, isSelected, selection, onSelect, addPointer, activePointersRef]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
// Ignore interactions on interactive child elements
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Check for multi-touch (more than 1 active pointer implies we should add to selection)
|
||||
@@ -183,6 +197,13 @@ export const useCardPointer = (
|
||||
}, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
// Ignore interactions on interactive child elements
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (longPressTimer.current) {
|
||||
|
||||
@@ -9,7 +9,9 @@ interface DocumentTagsProps {
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
docId: Identifier;
|
||||
maxTags?: number;
|
||||
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
|
||||
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
@@ -17,18 +19,17 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
tagLookupById,
|
||||
onTagClick,
|
||||
docId,
|
||||
maxTags,
|
||||
onDocumentTagDetach,
|
||||
onTagDragStart,
|
||||
onTagDragEnd,
|
||||
}) => {
|
||||
const visibleTags = maxTags ? tags.slice(0, maxTags) : tags;
|
||||
const remainingTagCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
|
||||
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleTags.map((tag, index) => {
|
||||
{tags.map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
@@ -58,9 +59,16 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
console.warn('[documents] Failed to configure drag effect', error);
|
||||
}
|
||||
writeTagTransferData(event.dataTransfer, tag, docId);
|
||||
if (tagId) {
|
||||
onTagDragStart?.(event, tagId);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) {
|
||||
onDocumentTagDetach(docId, tagId);
|
||||
}
|
||||
onTagDragEnd?.(event);
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -75,9 +83,6 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{remainingTagCount > 0 && (
|
||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -116,7 +116,9 @@ const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={doc.id}
|
||||
maxTags={3}
|
||||
onDocumentTagDetach={props.onDocumentTagDetach}
|
||||
onTagDragStart={logic.handlers.onTagDragStart}
|
||||
onTagDragEnd={logic.handlers.onTagDragEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,9 @@ const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={doc.id}
|
||||
onDocumentTagDetach={props.onDocumentTagDetach}
|
||||
onTagDragStart={logic.handlers.onTagDragStart}
|
||||
onTagDragEnd={logic.handlers.onTagDragEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@ interface EntryTagsProps {
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
docId: Identifier;
|
||||
maxTags?: number;
|
||||
onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
onTagDragStart?: (event: React.DragEvent<HTMLElement>, tagId: Identifier) => void;
|
||||
onTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
const EntryTags: React.FC<EntryTagsProps> = (props) => {
|
||||
@@ -22,7 +24,9 @@ const EntryTags: React.FC<EntryTagsProps> = (props) => {
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={props.docId}
|
||||
maxTags={props.maxTags}
|
||||
onDocumentTagDetach={props.onDocumentTagDetach}
|
||||
onTagDragStart={props.onTagDragStart}
|
||||
onTagDragEnd={props.onTagDragEnd}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createDocumentEntryKey } from '../../app/entryKey';
|
||||
import type { Document } from '../../types/documents';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface UseDocumentItemLogicProps extends DocumentsViewProps {
|
||||
doc: Document;
|
||||
@@ -18,9 +19,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
||||
onDocumentActivate,
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentTagDragStart,
|
||||
onDocumentTagDragEnd,
|
||||
onDocumentTagDragOver,
|
||||
onDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
onDocumentTagAttach,
|
||||
onDocumentRename,
|
||||
} = props;
|
||||
|
||||
@@ -58,14 +61,16 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
||||
onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event),
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event),
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc.id),
|
||||
onDragLeave: onDocumentTagDragLeave,
|
||||
onTagDragStart: (event: DragEvent<HTMLElement>, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId),
|
||||
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
|
||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (payload && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(doc.id, payload);
|
||||
if (payload && payload.id && onDocumentTagAttach) {
|
||||
onDocumentTagAttach(doc.id, payload.id);
|
||||
}
|
||||
},
|
||||
onRenameChange: setDocumentDraft,
|
||||
|
||||
@@ -47,7 +47,8 @@ export interface UseDocumentsPanelPropsArgs {
|
||||
activeCorrespondentFilters?: Identifier[];
|
||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
@@ -104,7 +105,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
activeCorrespondentFilters,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTagAttach,
|
||||
handleDocumentTagDetach,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
@@ -161,7 +163,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
onDocumentTagAttach: handleDocumentTagAttach,
|
||||
onDocumentTagDetach: handleDocumentTagDetach,
|
||||
viewMode: documentsViewMode,
|
||||
sortField: documentsSortField,
|
||||
sortDirection: documentsSortDirection,
|
||||
@@ -210,7 +213,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
handleDeleteSelection,
|
||||
handleDocumentDragEnd,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTagAttach,
|
||||
handleDocumentTagDetach,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
handleDocumentsSortFieldChange,
|
||||
|
||||
@@ -59,11 +59,15 @@ export interface DocumentsViewProps {
|
||||
onFolderDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDocumentOpen?: DocumentEventHandler;
|
||||
onDocumentActivate?: (doc: Document, event?: unknown) => void;
|
||||
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: Document) => void;
|
||||
onDocumentDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDocumentTagDragStart?: (event: DragEvent<HTMLElement>, docId: Identifier, tagId: Identifier) => void;
|
||||
onDocumentTagDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLElement>, docId: Identifier) => void;
|
||||
onDocumentTagDragLeave?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDocumentTagDrop?: (documentId: Identifier, tag: any) => void;
|
||||
onDocumentTagAttach?: (documentId: Identifier, tagId: Identifier) => void;
|
||||
onDocumentTagDetach?: (documentId: Identifier, tagId: Identifier) => void;
|
||||
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
@@ -126,7 +130,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
activeTagFilters = [],
|
||||
activeCorrespondentFilters = [],
|
||||
selectedFolder = null,
|
||||
onDocumentTagDrop,
|
||||
onDocumentTagAttach,
|
||||
onDocumentTagDetach,
|
||||
currentTenantId,
|
||||
}): ReactNode => {
|
||||
const {
|
||||
@@ -317,13 +322,32 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
|
||||
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
||||
|
||||
const draggingTagRef = useRef<{ docId: Identifier; tagId: Identifier } | null>(null);
|
||||
|
||||
const handleDocumentTagDragStart = useCallback(
|
||||
(_event, docId, tagId) => {
|
||||
draggingTagRef.current = { docId, tagId };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDocumentTagDragEnd = useCallback(
|
||||
(_event) => {
|
||||
draggingTagRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDocumentTagDragOver = useCallback(
|
||||
(event) => {
|
||||
(event, docId) => {
|
||||
if (!isTagDragEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
|
||||
const isSource = draggingTagRef.current?.docId === docId;
|
||||
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
|
||||
|
||||
event.currentTarget.classList.add('tag-drop-target');
|
||||
},
|
||||
[isTagDragEvent],
|
||||
@@ -422,13 +446,15 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
onFolderRename,
|
||||
|
||||
onDocumentActivate: handleDocumentActivate,
|
||||
onDocumentDragStart: handleDocumentDragStartLocal,
|
||||
onDocumentDragEnd: handleDocumentDragEndLocal,
|
||||
onDocumentTagDragStart: handleDocumentTagDragStart,
|
||||
onDocumentTagDragEnd: handleDocumentTagDragEnd,
|
||||
onDocumentTagDragOver: handleDocumentTagDragOver,
|
||||
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
onDocumentTagAttach,
|
||||
onDocumentTagDetach,
|
||||
onDocumentRename,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
|
||||
@@ -937,32 +937,16 @@ const useDocumentsWorkspace = ({
|
||||
);
|
||||
|
||||
const handleDocumentTagDrop = useCallback(
|
||||
async (documentId, tag) => {
|
||||
if (!documentId || !tag?.id) {
|
||||
async (documentId, tagId) => {
|
||||
if (!documentId || !tagId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag.sourceDocId && tag.sourceDocId === documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag });
|
||||
if (!attached) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag.sourceDocId && tag.sourceDocId !== documentId) {
|
||||
await handleTagRemove(tag.sourceDocId, tag.id, {
|
||||
refreshTagList: false,
|
||||
showMessage: false,
|
||||
});
|
||||
}
|
||||
await handleDocumentTagAttach({ documentId, tagId });
|
||||
},
|
||||
[handleDocumentTagAttach, handleTagRemove],
|
||||
[handleDocumentTagAttach],
|
||||
);
|
||||
|
||||
|
||||
|
||||
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
|
||||
if (creatingFolder) {
|
||||
return;
|
||||
@@ -1214,7 +1198,8 @@ const useDocumentsWorkspace = ({
|
||||
activeCorrespondentFilters,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTagAttach: handleDocumentTagDrop,
|
||||
handleDocumentTagDetach: handleTagRemove,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
|
||||
Reference in New Issue
Block a user