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 DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
export const EDGE_COLLISION_THRESHOLD = 0.5;
|
export const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||||
|
|
||||||
export const TAG_REMOVE_DISTANCE = 160;
|
|
||||||
|
|
||||||
export const DEBUG_DRAG = false;
|
export const DEBUG_DRAG = false;
|
||||||
export const DEBUG_FOCUS = false;
|
export const DEBUG_FOCUS = false;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resolveCorrespondents } from '../documents/correspondents';
|
|||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import type { DocumentId } from '../types/identifiers';
|
import type { DocumentId } from '../types/identifiers';
|
||||||
import type { Document } from '../types/documents';
|
import type { Document } from '../types/documents';
|
||||||
|
import { LayoutCard } from './LayoutSystem';
|
||||||
|
|
||||||
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
@@ -11,20 +12,11 @@ const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
|||||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PendingRemovalTag {
|
|
||||||
docId?: string;
|
|
||||||
tagId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { LayoutCard } from './LayoutSystem';
|
|
||||||
|
|
||||||
interface DesktopDocumentCardProps {
|
interface DesktopDocumentCardProps {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
shouldLoad?: boolean;
|
shouldLoad?: boolean;
|
||||||
matchesFilter?: boolean;
|
matchesFilter?: boolean;
|
||||||
tagTargetActive?: boolean;
|
|
||||||
tagTargetPending?: boolean;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
docTagTokens?: string;
|
docTagTokens?: string;
|
||||||
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||||
@@ -36,11 +28,8 @@ interface DesktopDocumentCardProps {
|
|||||||
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
||||||
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
|
||||||
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: Document) => 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;
|
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: Document, tag: any) => void;
|
||||||
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
|
|
||||||
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||||
pendingRemovalTag?: PendingRemovalTag | null;
|
|
||||||
layoutCard?: LayoutCard;
|
layoutCard?: LayoutCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +38,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
style,
|
style,
|
||||||
shouldLoad,
|
shouldLoad,
|
||||||
matchesFilter,
|
matchesFilter,
|
||||||
tagTargetActive,
|
|
||||||
tagTargetPending,
|
|
||||||
selected,
|
selected,
|
||||||
docTagTokens,
|
docTagTokens,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -62,19 +49,14 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
onTagDragOver,
|
onTagDragOver,
|
||||||
onTagDragLeave,
|
onTagDragLeave,
|
||||||
onTagDrop,
|
onTagDrop,
|
||||||
onDocTagPointerDown,
|
|
||||||
onDocTagDragStart,
|
onDocTagDragStart,
|
||||||
onDocTagDrag,
|
|
||||||
onDocTagDragEnd,
|
onDocTagDragEnd,
|
||||||
pendingRemovalTag,
|
|
||||||
layoutCard,
|
layoutCard,
|
||||||
}) => {
|
}) => {
|
||||||
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
||||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||||
|
|
||||||
const itemClasses = ['desk-item'];
|
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 (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||||
if (selected) itemClasses.push('is-selected');
|
if (selected) itemClasses.push('is-selected');
|
||||||
|
|
||||||
@@ -145,14 +127,8 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="desk-item__tags" aria-hidden="true">
|
<div className="desk-item__tags" aria-hidden="true">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
if (pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const colorStyle = getTagColorStyle(tag.color);
|
const colorStyle = getTagColorStyle(tag.color);
|
||||||
const pendingRemoval =
|
|
||||||
pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id;
|
|
||||||
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
||||||
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
@@ -161,11 +137,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
title={tag.label}
|
title={tag.label}
|
||||||
draggable
|
draggable
|
||||||
data-desk-tag-chip="true"
|
data-desk-tag-chip="true"
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
onDocTagPointerDown?.(event, doc, tag);
|
|
||||||
}}
|
|
||||||
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
||||||
onDrag={onDocTagDrag}
|
|
||||||
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
||||||
>
|
>
|
||||||
<span className="tag-chip__label">{tag.label}</span>
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export interface DesktopWorkspaceProps {
|
|||||||
getDocumentAsset?: (...args: any[]) => unknown;
|
getDocumentAsset?: (...args: any[]) => unknown;
|
||||||
onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void;
|
onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void;
|
||||||
onSelectionChange?: (selectedIds: Identifier[]) => 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;
|
tenantId?: Identifier | null;
|
||||||
viewId?: string | null;
|
viewId?: string | null;
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
onDocumentActivate,
|
onDocumentActivate,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onDocumentTagDrop,
|
onDocumentTagAttach,
|
||||||
|
onDocumentTagDetach,
|
||||||
tenantId,
|
tenantId,
|
||||||
viewId,
|
viewId,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -183,20 +185,12 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
const focusShell = useCallback(() => { }, []);
|
const focusShell = useCallback(() => { }, []);
|
||||||
|
|
||||||
// Tag Interactions
|
// 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({
|
const tagInteractions = useDeskTagInteractions({
|
||||||
engine: tagEngine,
|
onAssignTagToDocument: (docId: string, tagId: string) => {
|
||||||
onAssignTagToDocument: (docId: string, tag: any) => {
|
onDocumentTagAttach?.(docId, tagId);
|
||||||
onDocumentTagDrop?.(docId, tag);
|
},
|
||||||
|
onRemoveTagFromDocument: (docId: string, tagId: string) => {
|
||||||
|
onDocumentTagDetach?.(docId, tagId);
|
||||||
},
|
},
|
||||||
requestCanvasFocus: focusShell,
|
requestCanvasFocus: focusShell,
|
||||||
});
|
});
|
||||||
@@ -250,6 +244,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDrop={tagInteractions.handleCanvasDrop}
|
||||||
|
onDragOver={tagInteractions.handleCanvasDragOver}
|
||||||
>
|
>
|
||||||
{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}`;
|
||||||
@@ -286,13 +282,8 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
onTagDragOver={tagInteractions.handleTagDragOverDoc}
|
||||||
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
|
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
|
||||||
onTagDrop={tagInteractions.handleTagDropOnDoc}
|
onTagDrop={tagInteractions.handleTagDropOnDoc}
|
||||||
onDocTagPointerDown={tagInteractions.handleDocTagPointerDown}
|
|
||||||
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
|
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
|
||||||
onDocTagDrag={tagInteractions.handleDocTagDrag}
|
|
||||||
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
|
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
|
||||||
tagTargetActive={tagDropTargetId === docId}
|
|
||||||
tagTargetPending={pendingTagDocId === docId}
|
|
||||||
pendingRemovalTag={pendingRemovalTag}
|
|
||||||
onSelect={(ids, extend = false) => {
|
onSelect={(ids, extend = false) => {
|
||||||
if (!extend) {
|
if (!extend) {
|
||||||
handleSelectionChange(ids);
|
handleSelectionChange(ids);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const preventAll = (event) => {
|
|||||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
import { TAG_REMOVE_DISTANCE } from '../../constants/desktop';
|
|
||||||
|
|
||||||
const createDragPreview = (node, clientX, clientY) => {
|
const createDragPreview = (node, clientX, clientY) => {
|
||||||
if (!(node instanceof HTMLElement)) {
|
if (!(node instanceof HTMLElement)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -44,63 +42,68 @@ const cleanupPreview = (previewNode) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useDeskTagInteractions = ({
|
export const useDeskTagInteractions = ({
|
||||||
engine,
|
|
||||||
onAssignTagToDocument,
|
onAssignTagToDocument,
|
||||||
|
onRemoveTagFromDocument,
|
||||||
requestCanvasFocus,
|
requestCanvasFocus,
|
||||||
}) => {
|
}) => {
|
||||||
const draggingTagRef = useRef(null);
|
const draggingTagRef = useRef(null);
|
||||||
|
|
||||||
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
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(
|
const handleTagDragEnterDoc = useCallback(
|
||||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }),
|
(event) => {
|
||||||
[runTagHoverTransition],
|
if (!isTagTransfer(event)) return;
|
||||||
|
preventAll(event);
|
||||||
|
event.currentTarget.classList.add('is-tag-target');
|
||||||
|
},
|
||||||
|
[isTagTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDragOverDoc = useCallback(
|
const handleTagDragOverDoc = useCallback(
|
||||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }),
|
(event, docId) => {
|
||||||
[runTagHoverTransition],
|
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(
|
const handleTagDragLeaveDoc = useCallback(
|
||||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
(event) => {
|
||||||
[runTagHoverTransition],
|
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(
|
const handleCanvasDragOver = useCallback(
|
||||||
(event) => runTagHoverTransition(event, null),
|
(event) => {
|
||||||
[runTagHoverTransition],
|
if (event.dataTransfer) {
|
||||||
);
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
const handleCanvasDragLeave = useCallback(
|
},
|
||||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
[],
|
||||||
[runTagHoverTransition],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCanvasDrop = useCallback(
|
const handleCanvasDrop = useCallback(
|
||||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
(event) => {
|
||||||
[runTagHoverTransition],
|
// Implicit removal via dragend (dropEffect='move')
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
preventAll(event);
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDropOnDoc = useCallback(
|
const handleTagDropOnDoc = useCallback(
|
||||||
@@ -112,38 +115,37 @@ export const useDeskTagInteractions = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
engine.setTagDropTargetId(null);
|
event.currentTarget.classList.remove('is-tag-target');
|
||||||
engine.setPendingTagDocId(null);
|
|
||||||
|
|
||||||
|
// Add to target
|
||||||
const payload = parseTagTransferPayload(event);
|
const payload = parseTagTransferPayload(event);
|
||||||
if (!payload || !payload.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
engine.setPendingRemovalTag(null);
|
|
||||||
|
|
||||||
if (payload.sourceDocId === doc.id) {
|
setTimeout(() => {
|
||||||
return;
|
if (!payload || !payload.id) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
requestCanvasFocus?.();
|
if (payload.sourceDocId === doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onAssignTagToDocument) {
|
requestCanvasFocus?.();
|
||||||
onAssignTagToDocument(doc.id, {
|
|
||||||
id: payload.id,
|
if (onAssignTagToDocument) {
|
||||||
label: payload.label || '',
|
onAssignTagToDocument(doc.id, payload.id);
|
||||||
sourceDocId: payload.sourceDocId ?? null,
|
}
|
||||||
});
|
}, 0);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocTagDragStart = useCallback(
|
const handleDocTagDragStart = useCallback(
|
||||||
(event, doc, tag) => {
|
(event, doc, tag) => {
|
||||||
|
console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id });
|
||||||
if (!event?.dataTransfer || !doc || !tag) {
|
if (!event?.dataTransfer || !doc || !tag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||||
|
|
||||||
const pointerX = event.clientX;
|
const pointerX = event.clientX;
|
||||||
@@ -167,65 +169,54 @@ export const useDeskTagInteractions = ({
|
|||||||
initialY: pointerY,
|
initialY: pointerY,
|
||||||
distance: 0,
|
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 handleDocTagDragEnd = useCallback(
|
||||||
() => {
|
(event) => {
|
||||||
const state = draggingTagRef.current;
|
console.log('[Tag] handleDocTagDragEnd', { dropEffect: event?.dataTransfer?.dropEffect });
|
||||||
if (state) {
|
const dropEffect = event?.dataTransfer?.dropEffect;
|
||||||
const element = state.element;
|
|
||||||
if (element) {
|
setTimeout(() => {
|
||||||
element.classList.remove('is-drag-hidden');
|
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;
|
||||||
}
|
}, 0);
|
||||||
draggingTagRef.current = null;
|
|
||||||
engine.setPendingRemovalTag(null);
|
|
||||||
engine.setTagDropTargetId(null);
|
|
||||||
engine.setPendingTagDocId(null);
|
|
||||||
},
|
},
|
||||||
[engine],
|
[onRemoveTagFromDocument],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (draggingTagRef.current && draggingTagRef.current.previewClone) {
|
||||||
|
cleanupPreview(draggingTagRef.current.previewClone);
|
||||||
|
}
|
||||||
draggingTagRef.current = null;
|
draggingTagRef.current = null;
|
||||||
engine.setPendingRemovalTag(null);
|
|
||||||
};
|
};
|
||||||
}, [engine]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleTagDragEnterDoc,
|
handleTagDragEnterDoc,
|
||||||
handleTagDragOverDoc,
|
handleTagDragOverDoc,
|
||||||
handleTagDragLeaveDoc,
|
handleTagDragLeaveDoc,
|
||||||
handleTagDropOnDoc,
|
handleTagDropOnDoc,
|
||||||
handleDocTagPointerDown,
|
|
||||||
handleDocTagDragStart,
|
handleDocTagDragStart,
|
||||||
handleDocTagDrag,
|
|
||||||
handleDocTagDragEnd,
|
handleDocTagDragEnd,
|
||||||
handleCanvasDragOver,
|
handleCanvasDragOver,
|
||||||
handleCanvasDragLeave,
|
|
||||||
handleCanvasDrop,
|
handleCanvasDrop,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const useCardPointer = (
|
|||||||
const lastClickTime = useRef<number>(0);
|
const lastClickTime = useRef<number>(0);
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
||||||
|
|
||||||
const updateState = useCallback((e: React.PointerEvent) => {
|
const updateState = useCallback((e: React.PointerEvent) => {
|
||||||
@@ -41,6 +40,14 @@ export const useCardPointer = (
|
|||||||
// Allow left (0) and middle (1) click
|
// Allow left (0) and middle (1) click
|
||||||
if (e.button !== 0 && e.button !== 1) return;
|
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.preventDefault();
|
||||||
|
|
||||||
(e.target as Element).setPointerCapture(e.pointerId);
|
(e.target as Element).setPointerCapture(e.pointerId);
|
||||||
@@ -101,6 +108,13 @@ export const useCardPointer = (
|
|||||||
}, [card, isSelected, selection, onSelect, addPointer, activePointersRef]);
|
}, [card, isSelected, selection, onSelect, addPointer, activePointersRef]);
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
// Check for multi-touch (more than 1 active pointer implies we should add to selection)
|
// 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]);
|
}, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]);
|
||||||
|
|
||||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
if (longPressTimer.current) {
|
if (longPressTimer.current) {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface DocumentTagsProps {
|
|||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
docId: Identifier;
|
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> = ({
|
const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||||
@@ -17,18 +19,17 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
docId,
|
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) {
|
if (tags.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{visibleTags.map((tag, index) => {
|
{tags.map((tag, index) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
const style = getTagColorStyle(colorSource);
|
const style = getTagColorStyle(colorSource);
|
||||||
const tagId = tag?.id ?? null;
|
const tagId = tag?.id ?? null;
|
||||||
@@ -58,9 +59,16 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
console.warn('[documents] Failed to configure drag effect', error);
|
console.warn('[documents] Failed to configure drag effect', error);
|
||||||
}
|
}
|
||||||
writeTagTransferData(event.dataTransfer, tag, docId);
|
writeTagTransferData(event.dataTransfer, tag, docId);
|
||||||
|
if (tagId) {
|
||||||
|
onTagDragStart?.(event, tagId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (event.dataTransfer.dropEffect === 'move' && tagId && onDocumentTagDetach) {
|
||||||
|
onDocumentTagDetach(docId, tagId);
|
||||||
|
}
|
||||||
|
onTagDragEnd?.(event);
|
||||||
}}
|
}}
|
||||||
onKeyDown={clickable ? (event) => {
|
onKeyDown={clickable ? (event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -75,9 +83,6 @@ const DocumentTags: React.FC<DocumentTagsProps> = ({
|
|||||||
</span>
|
</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}
|
tagLookupById={props.tagLookupById}
|
||||||
onTagClick={props.onTagClick}
|
onTagClick={props.onTagClick}
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
maxTags={3}
|
onDocumentTagDetach={props.onDocumentTagDetach}
|
||||||
|
onTagDragStart={logic.handlers.onTagDragStart}
|
||||||
|
onTagDragEnd={logic.handlers.onTagDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
|||||||
tagLookupById={props.tagLookupById}
|
tagLookupById={props.tagLookupById}
|
||||||
onTagClick={props.onTagClick}
|
onTagClick={props.onTagClick}
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
|
onDocumentTagDetach={props.onDocumentTagDetach}
|
||||||
|
onTagDragStart={logic.handlers.onTagDragStart}
|
||||||
|
onTagDragEnd={logic.handlers.onTagDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ interface EntryTagsProps {
|
|||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
docId: Identifier;
|
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) => {
|
const EntryTags: React.FC<EntryTagsProps> = (props) => {
|
||||||
@@ -22,7 +24,9 @@ const EntryTags: React.FC<EntryTagsProps> = (props) => {
|
|||||||
tagLookupById={props.tagLookupById}
|
tagLookupById={props.tagLookupById}
|
||||||
onTagClick={props.onTagClick}
|
onTagClick={props.onTagClick}
|
||||||
docId={props.docId}
|
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 { Document } from '../../types/documents';
|
||||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||||
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
||||||
|
import type { Identifier } from '../../types/identifiers';
|
||||||
|
|
||||||
interface UseDocumentItemLogicProps extends DocumentsViewProps {
|
interface UseDocumentItemLogicProps extends DocumentsViewProps {
|
||||||
doc: Document;
|
doc: Document;
|
||||||
@@ -18,9 +19,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
|||||||
onDocumentActivate,
|
onDocumentActivate,
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
|
onDocumentTagDragStart,
|
||||||
|
onDocumentTagDragEnd,
|
||||||
onDocumentTagDragOver,
|
onDocumentTagDragOver,
|
||||||
onDocumentTagDragLeave,
|
onDocumentTagDragLeave,
|
||||||
onDocumentTagDrop,
|
onDocumentTagAttach,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -58,14 +61,16 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
|||||||
onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event),
|
onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event),
|
||||||
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
||||||
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
||||||
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event),
|
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event, doc.id),
|
||||||
onDragLeave: onDocumentTagDragLeave,
|
onDragLeave: onDocumentTagDragLeave,
|
||||||
|
onTagDragStart: (event: DragEvent<HTMLElement>, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId),
|
||||||
|
onTagDragEnd: (event: DragEvent<HTMLElement>) => onDocumentTagDragEnd?.(event),
|
||||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const payload = parseTagTransferPayload(event);
|
const payload = parseTagTransferPayload(event);
|
||||||
if (payload && onDocumentTagDrop) {
|
if (payload && payload.id && onDocumentTagAttach) {
|
||||||
onDocumentTagDrop(doc.id, payload);
|
onDocumentTagAttach(doc.id, payload.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRenameChange: setDocumentDraft,
|
onRenameChange: setDocumentDraft,
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export interface UseDocumentsPanelPropsArgs {
|
|||||||
activeCorrespondentFilters?: Identifier[];
|
activeCorrespondentFilters?: Identifier[];
|
||||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
|
||||||
|
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
||||||
documentsViewMode?: string;
|
documentsViewMode?: string;
|
||||||
documentsSortField?: string;
|
documentsSortField?: string;
|
||||||
documentsSortDirection?: string;
|
documentsSortDirection?: string;
|
||||||
@@ -104,7 +105,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleDocumentTagDrop,
|
handleDocumentTagAttach,
|
||||||
|
handleDocumentTagDetach,
|
||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
documentsSortField,
|
documentsSortField,
|
||||||
documentsSortDirection,
|
documentsSortDirection,
|
||||||
@@ -161,7 +163,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
activeCorrespondentIds: activeCorrespondentFilters,
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
onDocumentTagDrop: handleDocumentTagDrop,
|
onDocumentTagAttach: handleDocumentTagAttach,
|
||||||
|
onDocumentTagDetach: handleDocumentTagDetach,
|
||||||
viewMode: documentsViewMode,
|
viewMode: documentsViewMode,
|
||||||
sortField: documentsSortField,
|
sortField: documentsSortField,
|
||||||
sortDirection: documentsSortDirection,
|
sortDirection: documentsSortDirection,
|
||||||
@@ -210,7 +213,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
handleDeleteSelection,
|
handleDeleteSelection,
|
||||||
handleDocumentDragEnd,
|
handleDocumentDragEnd,
|
||||||
handleDocumentDragStart,
|
handleDocumentDragStart,
|
||||||
handleDocumentTagDrop,
|
handleDocumentTagAttach,
|
||||||
|
handleDocumentTagDetach,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentsSortDirectionToggle,
|
handleDocumentsSortDirectionToggle,
|
||||||
handleDocumentsSortFieldChange,
|
handleDocumentsSortFieldChange,
|
||||||
|
|||||||
@@ -59,11 +59,15 @@ export interface DocumentsViewProps {
|
|||||||
onFolderDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
onFolderDragEnd?: (event: DragEvent<HTMLElement>) => void;
|
||||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||||
onDocumentOpen?: DocumentEventHandler;
|
onDocumentOpen?: DocumentEventHandler;
|
||||||
|
onDocumentActivate?: (doc: Document, event?: unknown) => void;
|
||||||
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: Document) => void;
|
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: Document) => void;
|
||||||
onDocumentDragEnd?: (event: DragEvent<HTMLElement>) => 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;
|
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;
|
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
@@ -126,7 +130,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
activeTagFilters = [],
|
activeTagFilters = [],
|
||||||
activeCorrespondentFilters = [],
|
activeCorrespondentFilters = [],
|
||||||
selectedFolder = null,
|
selectedFolder = null,
|
||||||
onDocumentTagDrop,
|
onDocumentTagAttach,
|
||||||
|
onDocumentTagDetach,
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
const {
|
const {
|
||||||
@@ -317,13 +322,32 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
|
|
||||||
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
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(
|
const handleDocumentTagDragOver = useCallback(
|
||||||
(event) => {
|
(event, docId) => {
|
||||||
if (!isTagDragEvent(event)) {
|
if (!isTagDragEvent(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
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');
|
event.currentTarget.classList.add('tag-drop-target');
|
||||||
},
|
},
|
||||||
[isTagDragEvent],
|
[isTagDragEvent],
|
||||||
@@ -422,13 +446,15 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
|
|
||||||
onDocumentActivate: handleDocumentActivate,
|
onDocumentActivate: handleDocumentActivate,
|
||||||
onDocumentDragStart: handleDocumentDragStartLocal,
|
onDocumentDragStart: handleDocumentDragStartLocal,
|
||||||
onDocumentDragEnd: handleDocumentDragEndLocal,
|
onDocumentDragEnd: handleDocumentDragEndLocal,
|
||||||
|
onDocumentTagDragStart: handleDocumentTagDragStart,
|
||||||
|
onDocumentTagDragEnd: handleDocumentTagDragEnd,
|
||||||
onDocumentTagDragOver: handleDocumentTagDragOver,
|
onDocumentTagDragOver: handleDocumentTagDragOver,
|
||||||
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
||||||
onDocumentTagDrop,
|
onDocumentTagAttach,
|
||||||
|
onDocumentTagDetach,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
|||||||
@@ -937,32 +937,16 @@ const useDocumentsWorkspace = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagDrop = useCallback(
|
const handleDocumentTagDrop = useCallback(
|
||||||
async (documentId, tag) => {
|
async (documentId, tagId) => {
|
||||||
if (!documentId || !tag?.id) {
|
if (!documentId || !tagId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag.sourceDocId && tag.sourceDocId === documentId) {
|
await handleDocumentTagAttach({ documentId, tagId });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleDocumentTagAttach, handleTagRemove],
|
[handleDocumentTagAttach],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
|
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
|
||||||
if (creatingFolder) {
|
if (creatingFolder) {
|
||||||
return;
|
return;
|
||||||
@@ -1214,7 +1198,8 @@ const useDocumentsWorkspace = ({
|
|||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleDocumentTagDrop,
|
handleDocumentTagAttach: handleDocumentTagDrop,
|
||||||
|
handleDocumentTagDetach: handleTagRemove,
|
||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
documentsSortField,
|
documentsSortField,
|
||||||
documentsSortDirection,
|
documentsSortDirection,
|
||||||
|
|||||||
Reference in New Issue
Block a user