diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js deleted file mode 100644 index 5c24808..0000000 --- a/frontend/src/desktop/tags/useDeskTagInteractions.js +++ /dev/null @@ -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; diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.ts b/frontend/src/desktop/tags/useDeskTagInteractions.ts new file mode 100644 index 0000000..9e58fca --- /dev/null +++ b/frontend/src/desktop/tags/useDeskTagInteractions.ts @@ -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(null); + + const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []); + + const handleTagDragEnterDoc = useCallback( + (event: React.DragEvent, _docId: Identifier) => { + if (!isTagTransfer(event)) return; + preventAll(event); + event.currentTarget.classList.add('is-tag-target'); + }, + [isTagTransfer], + ); + + const handleTagDragOverDoc = useCallback( + (event: React.DragEvent, 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, _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) => { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + }, + [], + ); + + const handleCanvasDrop = useCallback( + (event: React.DragEvent) => { + // Implicit removal via dragend (dropEffect='move') + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + preventAll(event); + }, + [], + ); + + const handleTagDropOnDoc = useCallback( + (event: React.DragEvent, 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, 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) => { + 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;