This commit is contained in:
2025-12-04 01:34:26 +01:00
parent 3f7c294948
commit cbd463e1d9
2 changed files with 243 additions and 224 deletions
@@ -1,224 +0,0 @@
import {
useCallback,
useEffect,
useRef,
} from 'react';
import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
} from '../../documents/tagTransfer';
const preventAll = (event) => {
if (!event) return;
if (typeof event.preventDefault === 'function') event.preventDefault();
if (typeof event.stopPropagation === 'function') event.stopPropagation();
};
const createDragPreview = (node, clientX, clientY) => {
if (!(node instanceof HTMLElement)) {
return null;
}
const rect = node.getBoundingClientRect();
const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2;
const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2;
const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width);
const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height);
const clone = node.cloneNode(true);
clone.style.position = 'absolute';
clone.style.top = '-9999px';
clone.style.left = '-9999px';
clone.style.pointerEvents = 'none';
clone.style.opacity = '1';
clone.style.transform = 'none';
document.body.appendChild(clone);
return { clone, offsetX, offsetY };
};
const cleanupPreview = (previewNode) => {
if (previewNode && previewNode.parentNode) {
previewNode.parentNode.removeChild(previewNode);
}
};
export const useDeskTagInteractions = ({
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
}) => {
const draggingTagRef = useRef(null);
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
const handleTagDragEnterDoc = useCallback(
(event) => {
if (!isTagTransfer(event)) return;
preventAll(event);
event.currentTarget.classList.add('is-tag-target');
},
[isTagTransfer],
);
const handleTagDragOverDoc = useCallback(
(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) => {
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) => {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
},
[],
);
const handleCanvasDrop = useCallback(
(event) => {
// Implicit removal via dragend (dropEffect='move')
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
preventAll(event);
},
[],
);
const handleTagDropOnDoc = useCallback(
(event, doc) => {
if (!doc) {
return;
}
if (!isTagTransfer(event)) {
return;
}
preventAll(event);
event.currentTarget.classList.remove('is-tag-target');
// Add to target
const payload = parseTagTransferPayload(event);
setTimeout(() => {
if (!payload || !payload.id) {
return;
}
if (payload.sourceDocId === doc.id) {
return;
}
requestCanvasFocus?.();
if (onAssignTagToDocument) {
onAssignTagToDocument(doc.id, payload.id);
}
}, 0);
},
[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 = 'copyMove';
writeTagTransferData(event.dataTransfer, tag, doc.id);
const pointerX = event.clientX;
const pointerY = event.clientY;
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
if (clone) {
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
}
const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (element) {
element.classList.add('is-drag-hidden');
}
draggingTagRef.current = {
element,
previewClone: clone,
sourceDocId: doc.id,
tagId: tag.id,
initialX: pointerX,
initialY: pointerY,
distance: 0,
};
},
[],
);
const handleDocTagDragEnd = useCallback(
(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);
}
}
}
draggingTagRef.current = null;
}, 0);
},
[onRemoveTagFromDocument],
);
useEffect(() => {
return () => {
if (draggingTagRef.current && draggingTagRef.current.previewClone) {
cleanupPreview(draggingTagRef.current.previewClone);
}
draggingTagRef.current = null;
};
}, []);
return {
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagDragStart,
handleDocTagDragEnd,
handleCanvasDragOver,
handleCanvasDrop,
};
};
export default useDeskTagInteractions;
@@ -0,0 +1,243 @@
import {
useCallback,
useEffect,
useRef,
} from 'react';
import React from 'react';
import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
} from '../../documents/tagTransfer';
import type { Identifier } from '../../types/identifiers';
import type { Document, DocumentTag } from '../../types/documents';
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
if (!event) return;
if (typeof event.preventDefault === 'function') event.preventDefault();
if (typeof event.stopPropagation === 'function') event.stopPropagation();
};
const createDragPreview = (node: EventTarget | null, clientX: number, clientY: number) => {
if (!(node instanceof HTMLElement)) {
return null;
}
const rect = node.getBoundingClientRect();
const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2;
const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2;
const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width);
const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height);
const clone = node.cloneNode(true) as HTMLElement;
clone.style.position = 'absolute';
clone.style.top = '-9999px';
clone.style.left = '-9999px';
clone.style.pointerEvents = 'none';
clone.style.opacity = '1';
clone.style.transform = 'none';
document.body.appendChild(clone);
return { clone, offsetX, offsetY };
};
const cleanupPreview = (previewNode: HTMLElement | null) => {
if (previewNode && previewNode.parentNode) {
previewNode.parentNode.removeChild(previewNode);
}
};
interface UseDeskTagInteractionsArgs {
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
requestCanvasFocus?: () => void;
}
interface DraggingTagState {
element: HTMLElement | null;
previewClone: HTMLElement | null;
sourceDocId: Identifier;
tagId: Identifier;
initialX: number;
initialY: number;
distance: number;
}
export const useDeskTagInteractions = ({
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
}: UseDeskTagInteractionsArgs) => {
const draggingTagRef = useRef<DraggingTagState | null>(null);
const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []);
const handleTagDragEnterDoc = useCallback(
(event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return;
preventAll(event);
event.currentTarget.classList.add('is-tag-target');
},
[isTagTransfer],
);
const handleTagDragOverDoc = useCallback(
(event: React.DragEvent<HTMLDivElement>, docId: Identifier) => {
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: React.DragEvent<HTMLDivElement>, _docId: Identifier) => {
if (!isTagTransfer(event)) return;
// Ignore if leaving to a child element
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget as Node)) {
return;
}
event.currentTarget.classList.remove('is-tag-target');
},
[isTagTransfer],
);
const handleCanvasDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
},
[],
);
const handleCanvasDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
// Implicit removal via dragend (dropEffect='move')
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
preventAll(event);
},
[],
);
const handleTagDropOnDoc = useCallback(
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) {
return;
}
if (!isTagTransfer(event)) {
return;
}
preventAll(event);
event.currentTarget.classList.remove('is-tag-target');
// Add to target
const payload = parseTagTransferPayload(event);
setTimeout(() => {
if (!payload || !payload.id) {
return;
}
if (payload.sourceDocId === doc.id) {
return;
}
requestCanvasFocus?.();
if (onAssignTagToDocument && doc.id) {
onAssignTagToDocument(doc.id, payload.id);
}
}, 0);
},
[isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
);
const handleDocTagDragStart = useCallback(
(event: React.DragEvent<HTMLElement>, doc: Document, tag: DocumentTag) => {
console.log('[Tag] handleDocTagDragStart', { docId: doc?.id, tagId: tag?.id });
if (!event?.dataTransfer || !doc?.id || !tag?.id) {
return;
}
event.dataTransfer.effectAllowed = 'copyMove';
writeTagTransferData(event.dataTransfer, tag, doc.id);
const pointerX = event.clientX;
const pointerY = event.clientY;
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
if (clone) {
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
}
const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (element) {
element.classList.add('is-drag-hidden');
}
draggingTagRef.current = {
element,
previewClone: clone || null,
sourceDocId: doc.id,
tagId: tag.id,
initialX: pointerX,
initialY: pointerY,
distance: 0,
};
},
[],
);
const handleDocTagDragEnd = useCallback(
(event: React.DragEvent<HTMLElement>) => {
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);
}
}
}
draggingTagRef.current = null;
}, 0);
},
[onRemoveTagFromDocument],
);
useEffect(() => {
return () => {
if (draggingTagRef.current && draggingTagRef.current.previewClone) {
cleanupPreview(draggingTagRef.current.previewClone);
}
draggingTagRef.current = null;
};
}, []);
return {
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagDragStart,
handleDocTagDragEnd,
handleCanvasDragOver,
handleCanvasDrop,
};
};
export default useDeskTagInteractions;