feat: Refactor tag drag and drop interactions

This commit is contained in:
2025-11-27 17:59:28 +01:00
parent 68ed888172
commit 9bf9550b03
14 changed files with 380 additions and 196 deletions
+177
View File
@@ -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.
-2
View File
@@ -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;
+1 -29
View File
@@ -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>
+11 -20
View File
@@ -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 -1
View File
@@ -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,