This commit is contained in:
2025-11-02 04:40:07 +01:00
parent f7b274c1ec
commit e7e7881772
13 changed files with 560 additions and 243 deletions
+92 -132
View File
@@ -17,13 +17,17 @@ import useDocumentDrag from './desktop/useDocumentDrag';
import { DesktopProvider, useDesktopContext } from './desktop/context';
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
import { getTagColorStyle } from './utils/colors';
import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
} from './documents/tagTransfer';
import './DesktopWorkspace.css';
const CANVAS_PADDING = 24;
const ROTATION_RANGE = 7;
const DEFAULT_CANVAS_WIDTH = 1024;
const DEFAULT_CANVAS_HEIGHT = 680;
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const CARD_MIN = 240;
const CARD_MAX = 340;
@@ -111,50 +115,6 @@ const polygonCentroid = (polygon) => {
};
};
const readTransferData = (dataTransfer, mimeTypes) => {
if (!dataTransfer) {
return null;
}
for (let index = 0; index < mimeTypes.length; index += 1) {
const type = mimeTypes[index];
try {
const raw = dataTransfer.getData(type);
if (raw) {
return raw;
}
} catch (error) {
if (DEBUG_DROP) {
console.warn('[desk] readTransferData failed for type', type, error);
}
}
}
return null;
};
const parseTagTransferPayload = (event) => {
const raw = readTransferData(event?.dataTransfer, [
'application/x-papercrate-tag',
'text/papercrate-tag',
]);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (error) {
console.warn('[desk] parseTagTransferPayload failed', error);
}
return null;
};
const resolveTagKey = (tag) => {
if (!tag) {
return null;
}
const key = tag.id ?? tag.uuid ?? tag.slug ?? tag.label;
return key != null ? String(key) : null;
};
const DesktopPreviewCard = ({
doc,
title,
@@ -214,9 +174,21 @@ const DesktopPreviewCard = ({
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
return (
<div className={cardClasses.join(' ')}>
<div
className={cardClasses.join(' ')}
onDragStart={(event) => {
if (event instanceof DragEvent) {
event.preventDefault();
}
}}
>
{hasPreview ? (
<img src={currentUrl} alt={title} />
<img
src={currentUrl}
alt={title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="desk-item__empty">
<div className="desk-item__placeholder">DOC</div>
@@ -817,21 +789,71 @@ const DesktopWorkspace = ({
[updateRemovalCursor],
);
const isTagTransfer = useCallback((event) => {
const types = event.dataTransfer?.types;
if (!types) return false;
return TAG_MIME_TYPES.some((type) =>
typeof types.includes === 'function'
? types.includes(type)
: Array.from(types).includes(type),
);
}, []);
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false);
setTagDropTargetId(null);
}, [updateRemovalCursor]);
const finalizeTagDrag = useCallback(
(dropEffect = 'none') => {
const state = draggingTagRef.current;
if (!state) {
updateRemovalCursor(false);
return;
}
draggingTagRef.current = null;
const node = state.element;
const showNode = () => {
if (node instanceof HTMLElement) {
node.classList.remove('is-drag-hidden');
}
};
const scheduleShowNode = () => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
};
cleanupPreview(state.previewClone);
const shouldRemove =
!state.dropHandled &&
dropEffect === 'none' &&
state.sourceDocId &&
typeof onRemoveTagFromDocument === 'function' &&
(state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) {
scheduleShowNode();
updateRemovalCursor(false);
return;
}
updateRemovalCursor(false);
setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
void (async () => {
try {
await onRemoveTagFromDocument(state.sourceDocId, state.tagId);
if (DEBUG_DROP) {
console.log('[desk] finalizeTagDrag -> removed tag due to fling');
}
} catch (error) {
console.error('Failed to remove tag after drag', error);
scheduleShowNode();
} finally {
setPendingRemovalTag(null);
}
})();
},
[onRemoveTagFromDocument, updateRemovalCursor, setPendingRemovalTag],
);
const ensureDocumentSize = useCallback((doc) => {
if (!doc?.id) {
return null;
@@ -897,7 +919,7 @@ const DesktopWorkspace = ({
return null;
}
const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview';
const alt = snapshot.alt || doc?.title;
return {
url: snapshot.url,
alt,
@@ -1374,7 +1396,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload);
}
requestCanvasFocus();
return;
}
@@ -1389,7 +1410,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId);
}
requestCanvasFocus();
return;
}
@@ -1401,7 +1421,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId);
}
requestCanvasFocus();
return;
}
@@ -1433,7 +1452,8 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId);
}
requestCanvasFocus();
handleTagDragEnd();
finalizeTagDrag(payload?.sourceDocId ? 'move' : 'copy');
}
},
[
@@ -1441,7 +1461,8 @@ const syncLayoutSnapshot = useCallback(() => {
markActiveTagDropHandled,
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
handleTagDragEnd,
finalizeTagDrag,
],
);
@@ -1556,14 +1577,7 @@ const syncLayoutSnapshot = useCallback(() => {
console.warn('[desk] Failed to set drag effect', error);
}
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
try {
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (error) {
console.warn('[desk] Failed to populate drag data for tag', error);
}
writeTagTransferData(event.dataTransfer, tag, doc.id);
const pending = pendingDocTagDragRef.current;
const node = event.currentTarget;
@@ -1600,7 +1614,7 @@ const syncLayoutSnapshot = useCallback(() => {
draggingTagRef.current = {
sourceDocId: doc.id,
tagId: tag.id,
tagLabel: tag.label || 'Tag',
tagLabel: tag.label,
startX: initialX,
startY: initialY,
distance: 0,
@@ -1657,61 +1671,9 @@ const syncLayoutSnapshot = useCallback(() => {
const handleDocTagDragEnd = useCallback(
(event) => {
handleTagDragEnd();
const state = draggingTagRef.current;
if (!state) {
return;
}
draggingTagRef.current = null;
const node = state.element;
const showNode = () => {
if (node instanceof HTMLElement) {
node.classList.remove('is-drag-hidden');
}
};
cleanupPreview(state.previewClone);
const scheduleShowNode = () => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
};
const dropEffect = event?.dataTransfer?.dropEffect || 'none';
console.log('[desk] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
const shouldRemove =
!state.dropHandled &&
dropEffect === 'none' &&
state.sourceDocId &&
typeof onRemoveTagFromDocument === 'function' &&
(state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) {
console.log('[desk] dragEnd -> no removal. distance:', state.distance);
scheduleShowNode();
requestCanvasFocus();
updateRemovalCursor(false);
return;
}
requestCanvasFocus();
updateRemovalCursor(false);
setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
void (async () => {
try {
await onRemoveTagFromDocument(state.sourceDocId, state.tagId);
console.log('[desk] dragEnd -> removed tag due to fling');
} catch (error) {
console.error('Failed to remove tag after drag', error);
scheduleShowNode();
} finally {
setPendingRemovalTag(null);
}
})();
finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
},
[requestCanvasFocus, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
[handleTagDragEnd, finalizeTagDrag],
);
const contextValue = useMemo(
@@ -1906,11 +1868,10 @@ const DesktopWorkspaceView = () => {
};
const docKey = doc?.id != null ? String(doc.id) : null;
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : [];
const docTagKeys = tags
.map((tag) => resolveTagKey(tag))
.map((tag) => (tag ? tag.id : null))
.filter(Boolean);
const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
@@ -1956,7 +1917,7 @@ const DesktopWorkspaceView = () => {
<div className="desk-item__body">
<DesktopPreviewCard
doc={doc}
title={title}
title={doc.title}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
onNavigatorSnapshot={handleNavigatorSnapshot}
@@ -1965,7 +1926,6 @@ const DesktopWorkspaceView = () => {
{tags.length > 0 && (
<div className="desk-item__tags" aria-hidden="true">
{tags.map((tag) => {
const key = tag.id || tag.label || String(tag);
if (
pendingRemovalTag &&
pendingRemovalTag.docId === doc.id &&
@@ -1982,17 +1942,17 @@ const DesktopWorkspaceView = () => {
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
return (
<span
key={key}
key={tag.id}
className={tagClasses.join(' ')}
style={colorStyle || undefined}
title={tag.label || 'Tag'}
title={tag.label}
draggable
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
onDrag={handleDocTagDrag}
onDragEnd={(event) => handleDocTagDragEnd(event)}
>
<span className="tag-chip__label">{tag.label || 'Tag'}</span>
<span className="tag-chip__label">{tag.label}</span>
</span>
);
})}