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
+5 -1
View File
@@ -69,7 +69,8 @@
} }
.desk-item.is-tag-pending .desk-item__card { .desk-item.is-tag-pending .desk-item__card {
opacity: 0.6; outline: 0.25rem solid var(--accent-outline);
outline-offset: 0.25rem;
} }
.desk-item.is-filtered-out { .desk-item.is-filtered-out {
@@ -201,6 +202,9 @@ body.desk-cursor-remove * {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block; display: block;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
} }
.desk-item__card--empty { .desk-item__card--empty {
+92 -132
View File
@@ -17,13 +17,17 @@ import useDocumentDrag from './desktop/useDocumentDrag';
import { DesktopProvider, useDesktopContext } from './desktop/context'; import { DesktopProvider, useDesktopContext } from './desktop/context';
import PreviewZoomOverlay from './detail/PreviewZoomOverlay'; import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
import { getTagColorStyle } from './utils/colors'; import { getTagColorStyle } from './utils/colors';
import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
} from './documents/tagTransfer';
import './DesktopWorkspace.css'; import './DesktopWorkspace.css';
const CANVAS_PADDING = 24; const CANVAS_PADDING = 24;
const ROTATION_RANGE = 7; const ROTATION_RANGE = 7;
const DEFAULT_CANVAS_WIDTH = 1024; const DEFAULT_CANVAS_WIDTH = 1024;
const DEFAULT_CANVAS_HEIGHT = 680; const DEFAULT_CANVAS_HEIGHT = 680;
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const CARD_MIN = 240; const CARD_MIN = 240;
const CARD_MAX = 340; 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 = ({ const DesktopPreviewCard = ({
doc, doc,
title, title,
@@ -214,9 +174,21 @@ const DesktopPreviewCard = ({
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
return ( return (
<div className={cardClasses.join(' ')}> <div
className={cardClasses.join(' ')}
onDragStart={(event) => {
if (event instanceof DragEvent) {
event.preventDefault();
}
}}
>
{hasPreview ? ( {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__empty">
<div className="desk-item__placeholder">DOC</div> <div className="desk-item__placeholder">DOC</div>
@@ -817,21 +789,71 @@ const DesktopWorkspace = ({
[updateRemovalCursor], [updateRemovalCursor],
); );
const isTagTransfer = useCallback((event) => { const isTagTransfer = useCallback((event) => isTagTransferEvent(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 handleTagDragEnd = useCallback(() => { const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false); updateRemovalCursor(false);
setTagDropTargetId(null); setTagDropTargetId(null);
}, [updateRemovalCursor]); }, [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) => { const ensureDocumentSize = useCallback((doc) => {
if (!doc?.id) { if (!doc?.id) {
return null; return null;
@@ -897,7 +919,7 @@ const DesktopWorkspace = ({
return null; return null;
} }
const doc = documentLookup.get(overlayDocId); const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview'; const alt = snapshot.alt || doc?.title;
return { return {
url: snapshot.url, url: snapshot.url,
alt, alt,
@@ -1374,7 +1396,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload); console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload);
} }
requestCanvasFocus();
return; return;
} }
@@ -1389,7 +1410,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId); console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId);
} }
requestCanvasFocus();
return; return;
} }
@@ -1401,7 +1421,6 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId); console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId);
} }
requestCanvasFocus();
return; return;
} }
@@ -1433,7 +1452,8 @@ const syncLayoutSnapshot = useCallback(() => {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId); console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId);
} }
requestCanvasFocus(); handleTagDragEnd();
finalizeTagDrag(payload?.sourceDocId ? 'move' : 'copy');
} }
}, },
[ [
@@ -1441,7 +1461,8 @@ const syncLayoutSnapshot = useCallback(() => {
markActiveTagDropHandled, markActiveTagDropHandled,
onAssignTagToDocument, onAssignTagToDocument,
onRemoveTagFromDocument, onRemoveTagFromDocument,
requestCanvasFocus, handleTagDragEnd,
finalizeTagDrag,
], ],
); );
@@ -1556,14 +1577,7 @@ const syncLayoutSnapshot = useCallback(() => {
console.warn('[desk] Failed to set drag effect', error); console.warn('[desk] Failed to set drag effect', error);
} }
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id }); writeTagTransferData(event.dataTransfer, tag, 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);
}
const pending = pendingDocTagDragRef.current; const pending = pendingDocTagDragRef.current;
const node = event.currentTarget; const node = event.currentTarget;
@@ -1600,7 +1614,7 @@ const syncLayoutSnapshot = useCallback(() => {
draggingTagRef.current = { draggingTagRef.current = {
sourceDocId: doc.id, sourceDocId: doc.id,
tagId: tag.id, tagId: tag.id,
tagLabel: tag.label || 'Tag', tagLabel: tag.label,
startX: initialX, startX: initialX,
startY: initialY, startY: initialY,
distance: 0, distance: 0,
@@ -1657,61 +1671,9 @@ const syncLayoutSnapshot = useCallback(() => {
const handleDocTagDragEnd = useCallback( const handleDocTagDragEnd = useCallback(
(event) => { (event) => {
handleTagDragEnd(); handleTagDragEnd();
const state = draggingTagRef.current; finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
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);
}
})();
}, },
[requestCanvasFocus, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor], [handleTagDragEnd, finalizeTagDrag],
); );
const contextValue = useMemo( const contextValue = useMemo(
@@ -1906,11 +1868,10 @@ const DesktopWorkspaceView = () => {
}; };
const docKey = doc?.id != null ? String(doc.id) : null; const docKey = doc?.id != null ? String(doc.id) : null;
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id; const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : []; const tags = Array.isArray(doc.tags) ? doc.tags : [];
const docTagKeys = tags const docTagKeys = tags
.map((tag) => resolveTagKey(tag)) .map((tag) => (tag ? tag.id : null))
.filter(Boolean); .filter(Boolean);
const matchesFilter = const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
@@ -1956,7 +1917,7 @@ const DesktopWorkspaceView = () => {
<div className="desk-item__body"> <div className="desk-item__body">
<DesktopPreviewCard <DesktopPreviewCard
doc={doc} doc={doc}
title={title} title={doc.title}
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
onNavigatorSnapshot={handleNavigatorSnapshot} onNavigatorSnapshot={handleNavigatorSnapshot}
@@ -1965,7 +1926,6 @@ const DesktopWorkspaceView = () => {
{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) => {
const key = tag.id || tag.label || String(tag);
if ( if (
pendingRemovalTag && pendingRemovalTag &&
pendingRemovalTag.docId === doc.id && pendingRemovalTag.docId === doc.id &&
@@ -1982,17 +1942,17 @@ const DesktopWorkspaceView = () => {
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending'); if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
return ( return (
<span <span
key={key} key={tag.id}
className={tagClasses.join(' ')} className={tagClasses.join(' ')}
style={colorStyle || undefined} style={colorStyle || undefined}
title={tag.label || 'Tag'} title={tag.label}
draggable draggable
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)} onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
onDragStart={(event) => handleDocTagDragStart(event, doc, tag)} onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
onDrag={handleDocTagDrag} onDrag={handleDocTagDrag}
onDragEnd={(event) => handleDocTagDragEnd(event)} onDragEnd={(event) => handleDocTagDragEnd(event)}
> >
<span className="tag-chip__label">{tag.label || 'Tag'}</span> <span className="tag-chip__label">{tag.label}</span>
</span> </span>
); );
})} })}
+46 -26
View File
@@ -21,9 +21,9 @@ import { useManagementModals } from './useManagementModals';
import { api, useAppDispatch, useAppState } from './appState'; import { api, useAppDispatch, useAppState } from './appState';
import { useDetailPanel } from './useDetailPanel'; import { useDetailPanel } from './useDetailPanel';
import { useDocumentSelection } from './useDocumentSelection'; import { useDocumentSelection } from './useDocumentSelection';
import { isTagTransferEvent } from '../documents/tagTransfer';
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_FOLDER_NAME = 'Documents'; const DEFAULT_FOLDER_NAME = 'Documents';
@@ -2180,9 +2180,8 @@ const AppLayout = () => {
}; };
const { data } = await api.post('/folders/path', payload); const { data } = await api.post('/folders/path', payload);
const folderId = data.folder.id; cache.set(cacheKey, data.folder.id);
cache.set(cacheKey, folderId); return data.folder.id;
return folderId;
}, },
[], [],
); );
@@ -2547,7 +2546,7 @@ const AppLayout = () => {
const entry = { const entry = {
url: href, url: href,
contentType: docResponse.data?.document?.current_version?.version?.content_type || null, contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
filename: docResponse.data?.document?.filename || 'document', filename: docResponse.data?.document?.filename,
expiresAt: Date.now() + 5 * 60 * 1000, expiresAt: Date.now() + 5 * 60 * 1000,
}; };
setPreviewEntries((prev) => { setPreviewEntries((prev) => {
@@ -3106,6 +3105,8 @@ const AppLayout = () => {
? focusedRowKey ? focusedRowKey
: null; : null;
let initializedFromEmptyState = false;
if (!activeKey) { if (!activeKey) {
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
const candidate = selectedEntries[index]; const candidate = selectedEntries[index];
@@ -3117,8 +3118,17 @@ const AppLayout = () => {
} }
if (!activeKey) { if (!activeKey) {
activeKey = navigableRowKeys[0]; if (key === 'ArrowUp') {
const lastKey = navigableRowKeys[navigableRowKeys.length - 1];
if (!lastKey) {
return;
}
activeKey = lastKey;
} else {
activeKey = navigableRowKeys[0];
}
setFocusedRowKey(activeKey); setFocusedRowKey(activeKey);
initializedFromEmptyState = true;
} }
let currentIndex = navigableRowKeys.indexOf(activeKey); let currentIndex = navigableRowKeys.indexOf(activeKey);
@@ -3140,8 +3150,22 @@ const AppLayout = () => {
let nextIndex = currentIndex; let nextIndex = currentIndex;
if (key === 'ArrowDown') { if (key === 'ArrowDown') {
if (initializedFromEmptyState && selectedEntries.length === 0) {
handleRowSelection(activeKey, {
shiftKey,
preventDefault: () => {},
});
return;
}
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
} else if (key === 'ArrowUp') { } else if (key === 'ArrowUp') {
if (initializedFromEmptyState && selectedEntries.length === 0) {
handleRowSelection(activeKey, {
shiftKey,
preventDefault: () => {},
});
return;
}
nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0);
} else if (key === 'Home') { } else if (key === 'Home') {
nextIndex = 0; nextIndex = 0;
@@ -3363,16 +3387,14 @@ const AppLayout = () => {
const resolveTagForCache = () => { const resolveTagForCache = () => {
const lookupTag = tagLookupById.get(tagId); const lookupTag = tagLookupById.get(tagId);
const source = lookupTag || tagData; const source = lookupTag ?? tagData;
if (!source) { if (!source || source.id == null || typeof source.label !== 'string') {
return { id: tagId, label: 'Tag', color: null }; return null;
} }
return { return {
id: source.id ?? tagId, id: source.id,
label: source.label || source.name || 'Tag', label: source.label,
color: Object.prototype.hasOwnProperty.call(source, 'color') color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
? source.color
: null,
}; };
}; };
@@ -3386,10 +3408,16 @@ const AppLayout = () => {
if (currentTags.some((existing) => existing?.id === tagId)) { if (currentTags.some((existing) => existing?.id === tagId)) {
return doc; return doc;
} }
return { ...doc, tags: [...currentTags, resolveTagForCache()] }; const resolvedTag = resolveTagForCache();
if (!resolvedTag) {
return doc;
}
return { ...doc, tags: [...currentTags, resolvedTag] };
}); });
setStatusMessage('Tag assigned.', 'success'); setStatusMessage('Tag assigned.', 'success');
await refreshCurrentFolder(); if (documentsViewMode !== 'desk') {
await refreshCurrentFolder();
}
return true; return true;
} catch (error) { } catch (error) {
const message = error.response?.data?.error || 'Failed to assign tag.'; const message = error.response?.data?.error || 'Failed to assign tag.';
@@ -3399,6 +3427,7 @@ const AppLayout = () => {
}, },
[ [
refreshCurrentFolder, refreshCurrentFolder,
documentsViewMode,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
updateDocumentCaches, updateDocumentCaches,
@@ -3851,16 +3880,7 @@ const AppLayout = () => {
return undefined; return undefined;
} }
const isTagTransfer = (event) => { const isTagTransfer = (event) => isTagTransferEvent(event);
const types = event?.dataTransfer?.types;
if (!types) {
return false;
}
if (typeof types.includes === 'function') {
return TAG_MIME_TYPES.some((type) => types.includes(type));
}
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
};
const isDocumentDropTarget = (target) => const isDocumentDropTarget = (target) =>
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
+379 -21
View File
@@ -5,6 +5,21 @@ import { clamp, formatTransform } from './math';
const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const MIN_TIMESTEP = 1 / 120;
const MAX_TIMESTEP = 1 / 20;
const MAX_DYNAMIC_ROTATION = 4;
const MAX_ANGULAR_VELOCITY = 180;
const ANGULAR_DAMPING = 11;
const TORQUE_TO_ACCELERATION = 0.006;
const SETTLE_ANGULAR_VELOCITY = 1.2;
const EDGE_COLLISION_THRESHOLD = 0.5;
const EDGE_ALIGNMENT_STIFFNESS = 30;
const BASE_TORQUE_FACTOR = 0.4;
const EDGE_COLLISION_TORQUE_FACTOR = 0.08;
const EDGE_ALIGNMENT_TORQUE_MULTIPLIER = 35;
const EDGE_COLLISION_EXTRA_DAMPING = 4;
const EDGE_REST_REALIGN_RATE = 10;
const EDGE_ALIGNMENT_EPSILON = 0.15;
const useDocumentDrag = () => { const useDocumentDrag = () => {
const { const {
@@ -20,11 +35,121 @@ const useDocumentDrag = () => {
openOverlayForDoc, openOverlayForDoc,
recalcVisibleDocIds, recalcVisibleDocIds,
settings, settings,
containerRef,
} = useDesktopContext(); } = useDesktopContext();
const dragStateRef = useRef(null); const dragStateRef = useRef(null);
const inertiaAnimationsRef = useRef(new Map());
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const cancelInertiaAnimation = useCallback((docId) => {
if (typeof window === 'undefined') {
inertiaAnimationsRef.current.delete(docId);
return;
}
const existing = inertiaAnimationsRef.current.get(docId);
if (existing && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(existing.frameId);
}
inertiaAnimationsRef.current.delete(docId);
}, []);
const integrateRotation = useCallback(
(simulationState, dt, torque = 0, dampingOverride = null) => {
const { docId } = simulationState;
const entry = layoutRef.current.get(docId);
if (!entry) {
return true;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return true;
}
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
const dampingFactor = Math.exp(-dampingConstant * dt);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
dynamicRotation = MAX_DYNAMIC_ROTATION;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
dynamicRotation = -MAX_DYNAMIC_ROTATION;
angularVelocity = Math.max(angularVelocity, 0);
}
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
const rotation = simulationState.rotation;
layoutRef.current.set(docId, { ...entry, rotation });
const node = itemRefs.current.get(docId);
if (node) {
node.style.transform = formatTransform(
centerX - simulationState.width / 2,
centerY - simulationState.height / 2,
rotation,
simulationState.dragScale || 1,
);
}
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
return isSettled;
},
[itemRefs, layoutRef],
);
const startInertiaAnimation = useCallback(
(docId, baseState) => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return;
}
cancelInertiaAnimation(docId);
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const simulationState = {
...baseState,
docId,
dragScale: baseState.dragScale || 1,
lastTimestamp: now,
};
const step = (timestamp) => {
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
const previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
simulationState.lastTimestamp = safeTimestamp;
const settled = integrateRotation(simulationState, dt, 0);
if (settled) {
inertiaAnimationsRef.current.delete(docId);
syncLayoutSnapshot();
return;
}
simulationState.frameId = window.requestAnimationFrame(step);
};
simulationState.frameId = window.requestAnimationFrame(step);
inertiaAnimationsRef.current.set(docId, simulationState);
},
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
);
const finishDrag = useCallback( const finishDrag = useCallback(
(pointerId) => { (pointerId) => {
const state = dragStateRef.current; const state = dragStateRef.current;
@@ -63,6 +188,7 @@ const useDocumentDrag = () => {
); );
} }
preventAll(event); preventAll(event);
cancelInertiaAnimation(docId);
const docKey = docId != null ? String(docId) : null; const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null; const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
@@ -90,6 +216,25 @@ const useDocumentDrag = () => {
} }
} }
} }
const containerRect = containerRef?.current?.getBoundingClientRect?.() || null;
const containerLeft = containerRect?.left || 0;
const containerTop = containerRect?.top || 0;
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const pointerOffsetX = pointerCanvasX - centerX;
const pointerOffsetY = pointerCanvasY - centerY;
const initialRotationDeg = entry?.rotation ?? 0;
const initialRotationRad = (initialRotationDeg * Math.PI) / 180;
const cosInitial = Math.cos(-initialRotationRad);
const sinInitial = Math.sin(-initialRotationRad);
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const eventTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
dragStateRef.current = { dragStateRef.current = {
docId, docId,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -98,6 +243,9 @@ const useDocumentDrag = () => {
startX: event.clientX, startX: event.clientX,
startY: event.clientY, startY: event.clientY,
rotation: entry?.rotation ?? 0, rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0,
dynamicRotation: 0,
angularVelocity: 0,
moved: false, moved: false,
locked: false, locked: false,
width: docWidth, width: docWidth,
@@ -105,18 +253,27 @@ const useDocumentDrag = () => {
dragScale: 1, dragScale: 1,
baseScale: normalizedBaseScale, baseScale: normalizedBaseScale,
capturedTarget, capturedTarget,
lastClientX: event.clientX,
lastClientY: event.clientY,
lastTimestamp: eventTimestamp,
localPointerOffsetX,
localPointerOffsetY,
containerRectLeft: containerLeft,
containerRectTop: containerTop,
}; };
setDraggingId(docId); setDraggingId(docId);
}, },
[ [
bringToFront, bringToFront,
canvasPadding, canvasPadding,
cancelInertiaAnimation,
documentLookup, documentLookup,
ensureDocumentSize, ensureDocumentSize,
layoutRef, layoutRef,
resolveBaseMetrics, resolveBaseMetrics,
setDraggingId, setDraggingId,
debugDrag, debugDrag,
containerRef,
], ],
); );
@@ -155,21 +312,47 @@ const useDocumentDrag = () => {
const deltaX = event.clientX - state.startX; const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY; const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width; const docWidth = state.width;
const docHeight = state.height; const docHeight = state.height;
const halfWidth = docWidth / 2; const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2; const halfHeight = docHeight / 2;
const containerRect = containerRef?.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
}
const containerLeft = state.containerRectLeft;
const containerTop = state.containerRectTop;
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const rotationDeg = state.rotation || 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad);
const sinRot = Math.sin(rotationRad);
const rotatedOffsetX =
state.localPointerOffsetX * cosRot - state.localPointerOffsetY * sinRot;
const rotatedOffsetY =
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
const desiredCenterX = pointerCanvasX - rotatedOffsetX;
const desiredCenterY = pointerCanvasY - rotatedOffsetY;
const absCos = Math.abs(cosRot);
const absSin = Math.abs(sinRot);
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
const rotatedHalfHeight = absSin * halfWidth + absCos * halfHeight;
const canvasWidth = canvasSize.width || defaultCanvasWidth; const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight; const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth; const minCenterX = canvasPadding + rotatedHalfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth); const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth);
const minCenterY = canvasPadding + halfHeight; const minCenterY = canvasPadding + rotatedHalfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight); const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX); const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY); const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY);
if (!state.moved) { if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY; const distanceSquared = deltaX * deltaX + deltaY * deltaY;
@@ -180,20 +363,167 @@ const useDocumentDrag = () => {
state.moved = true; state.moved = true;
} }
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY }; let currentCenterX = clampedCenterX;
let currentCenterY = clampedCenterY;
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
layoutRef.current.set(state.docId, updated); layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId); const collidedWithHorizontalEdge =
if (node) { Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
node.style.transform = formatTransform( const collidedWithVerticalEdge =
clampedCenterX - state.width / 2, Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
clampedCenterY - state.height / 2, const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
state.rotation,
state.dragScale || 1, const offsetX = pointerCanvasX - currentCenterX;
); const offsetY = pointerCanvasY - currentCenterY;
const currentTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
let dt = (currentTimestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
const previousClientX = state.lastClientX;
const previousClientY = state.lastClientY;
const velocityX = (event.clientX - previousClientX) / dt;
const velocityY = (event.clientY - previousClientY) / dt;
const rawTorque = offsetX * velocityY - offsetY * velocityX;
const rotationBeforeIntegration = state.rotation;
let torque = rawTorque * BASE_TORQUE_FACTOR;
let dampingOverride = null;
if (collidedWithEdge) {
const currentRotation =
typeof state.rotation === 'number'
? state.rotation
: state.restRotation + state.dynamicRotation;
torque =
(rawTorque * EDGE_COLLISION_TORQUE_FACTOR + currentRotation * EDGE_ALIGNMENT_STIFFNESS) *
EDGE_ALIGNMENT_TORQUE_MULTIPLIER;
const restBlend = 1 - Math.exp(-EDGE_REST_REALIGN_RATE * dt);
if (restBlend > 0) {
const previousRest = state.restRotation;
const nextRest = previousRest + (0 - previousRest) * restBlend;
state.restRotation = nextRest;
}
dampingOverride = ANGULAR_DAMPING + EDGE_COLLISION_EXTRA_DAMPING;
}
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestamp;
integrateRotation(state, dt, torque, dampingOverride);
if (!collidedWithEdge) {
const rotationAfter = state.rotation || 0;
if (rotationAfter !== rotationBeforeIntegration) {
const rotationAfterRad = (rotationAfter * Math.PI) / 180;
const cosAfter = Math.cos(rotationAfterRad);
const sinAfter = Math.sin(rotationAfterRad);
const rotatedOffsetXAfter =
state.localPointerOffsetX * cosAfter - state.localPointerOffsetY * sinAfter;
const rotatedOffsetYAfter =
state.localPointerOffsetX * sinAfter + state.localPointerOffsetY * cosAfter;
const desiredCenterXAfter = pointerCanvasX - rotatedOffsetXAfter;
const desiredCenterYAfter = pointerCanvasY - rotatedOffsetYAfter;
const absCosAfter = Math.abs(cosAfter);
const absSinAfter = Math.abs(sinAfter);
const rotatedHalfWidthAfter = absCosAfter * halfWidth + absSinAfter * halfHeight;
const rotatedHalfHeightAfter = absSinAfter * halfWidth + absCosAfter * halfHeight;
const minCenterXAfter = canvasPadding + rotatedHalfWidthAfter;
const maxCenterXAfter = Math.max(
minCenterXAfter,
canvasWidth - canvasPadding - rotatedHalfWidthAfter,
);
const minCenterYAfter = canvasPadding + rotatedHalfHeightAfter;
const maxCenterYAfter = Math.max(
minCenterYAfter,
canvasHeight - canvasPadding - rotatedHalfHeightAfter,
);
const correctedCenterX = clamp(
desiredCenterXAfter,
minCenterXAfter,
maxCenterXAfter,
);
const correctedCenterY = clamp(
desiredCenterYAfter,
minCenterYAfter,
maxCenterYAfter,
);
if (
Math.abs(correctedCenterX - currentCenterX) > 0.01 ||
Math.abs(correctedCenterY - currentCenterY) > 0.01
) {
const entryAfter = layoutRef.current.get(state.docId);
if (entryAfter) {
const adjustedEntry = {
...entryAfter,
centerX: correctedCenterX,
centerY: correctedCenterY,
};
layoutRef.current.set(state.docId, adjustedEntry);
const nodeAfter = itemRefs.current.get(state.docId);
if (nodeAfter) {
nodeAfter.style.transform = formatTransform(
correctedCenterX - state.width / 2,
correctedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
}
currentCenterX = correctedCenterX;
currentCenterY = correctedCenterY;
}
}
}
}
if (collidedWithEdge) {
const rotationAfter = state.rotation;
const crossedAlignment =
rotationBeforeIntegration > EDGE_ALIGNMENT_EPSILON && rotationAfter < 0
? rotationBeforeIntegration - rotationAfter > EDGE_ALIGNMENT_EPSILON
: rotationBeforeIntegration < -EDGE_ALIGNMENT_EPSILON && rotationAfter > 0
? rotationAfter - rotationBeforeIntegration > EDGE_ALIGNMENT_EPSILON
: false;
const nearAlignment = Math.abs(rotationAfter) <= EDGE_ALIGNMENT_EPSILON;
if (crossedAlignment || nearAlignment) {
state.restRotation = 0;
state.dynamicRotation = 0;
state.angularVelocity = 0;
state.rotation = 0;
const updatedEntry = layoutRef.current.get(state.docId);
if (updatedEntry) {
const alignedEntry = { ...updatedEntry, rotation: 0 };
layoutRef.current.set(state.docId, alignedEntry);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
alignedEntry.centerX - state.width / 2,
alignedEntry.centerY - state.height / 2,
0,
state.dragScale || 1,
);
}
}
}
} }
if (debugDrag) { if (debugDrag) {
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY); console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
} }
recalcVisibleDocIds(); recalcVisibleDocIds();
}, },
@@ -204,7 +534,8 @@ const useDocumentDrag = () => {
canvasSize.width, canvasSize.width,
defaultCanvasHeight, defaultCanvasHeight,
defaultCanvasWidth, defaultCanvasWidth,
itemRefs, containerRef,
integrateRotation,
layoutRef, layoutRef,
recalcVisibleDocIds, recalcVisibleDocIds,
debugDrag, debugDrag,
@@ -216,7 +547,18 @@ const useDocumentDrag = () => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) { if (state && state.pointerId === event.pointerId) {
if (state.moved) { if (state.moved) {
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
};
const docId = state.docId;
finishDrag(event.pointerId); finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
return; return;
} }
@@ -234,14 +576,30 @@ const useDocumentDrag = () => {
} }
finishDrag(event.pointerId); finishDrag(event.pointerId);
}, },
[bringToFront, finishDrag, openOverlayForDoc], [bringToFront, finishDrag, openOverlayForDoc, startInertiaAnimation],
); );
const handlePointerCancel = useCallback( const handlePointerCancel = useCallback(
(event) => { (event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity,
rotation: state.rotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
};
const docId = state.docId;
finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
return;
}
finishDrag(event.pointerId); finishDrag(event.pointerId);
}, },
[finishDrag], [finishDrag, startInertiaAnimation],
); );
return { return {
+1 -2
View File
@@ -215,12 +215,11 @@ const DetailPanel = ({
})) }))
: []; : [];
const documentLabel = detailSummary.title || 'Document';
return [ return [
...normalizedSegments, ...normalizedSegments,
{ {
id: singleDoc.id || 'current-document', id: singleDoc.id || 'current-document',
label: documentLabel, label: detailSummary.title,
}, },
]; ];
}, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); }, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
@@ -327,7 +327,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => { const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return; if (!editableTitle || !document) return;
setIsTitleEditing(true); setIsTitleEditing(true);
setTitleDraft(document.title || document.original_name || ''); setTitleDraft(document.title);
setTitleError(null); setTitleError(null);
}, [document, editableTitle]); }, [document, editableTitle]);
@@ -396,8 +396,6 @@ const DocumentSummarySection = ({
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued], [cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
); );
const titleDisplay = summary.title || document?.original_name || 'Untitled document';
if (!document) { if (!document) {
return null; return null;
} }
@@ -440,7 +438,7 @@ const DocumentSummarySection = ({
</form> </form>
) : ( ) : (
<> <>
<h3 className="doc-title-row__title">{titleDisplay}</h3> <h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? ( {editableTitle ? (
<button <button
type="button" type="button"
+6 -14
View File
@@ -4,6 +4,7 @@ import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks'; import CorrespondentLinks from './CorrespondentLinks';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { resolveCorrespondents } from './correspondents'; import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
const DocumentsGrid = ({ const DocumentsGrid = ({
entries, entries,
@@ -110,8 +111,6 @@ const DocumentsGrid = ({
const cardClasses = ['document-card', 'document']; const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected'); if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging'); if (isDraggingDoc) cardClasses.push('is-dragging');
const titleText = doc.title || doc.original_name;
return ( return (
<div <div
key={entry.key} key={entry.key}
@@ -135,12 +134,12 @@ const DocumentsGrid = ({
document={doc} document={doc}
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`} alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize} maxSize={gridIconSize}
scrollRootRef={scrollRef} scrollRootRef={scrollRef}
/> />
<div className="document-card__meta"> <div className="document-card__meta">
<div className="document-card__title" title={titleText}> <div className="document-card__title" title={doc.title}>
{correspondents.length > 0 ? ( {correspondents.length > 0 ? (
<span className="doc-correspondents"> <span className="doc-correspondents">
<CorrespondentLinks <CorrespondentLinks
@@ -150,7 +149,7 @@ const DocumentsGrid = ({
/> />
</span> </span>
) : null} ) : null}
<span className="doc-name__primary">{titleText}</span> <span className="doc-name__primary">{doc.title}</span>
</div> </div>
{visibleTags.length > 0 && ( {visibleTags.length > 0 && (
<div className="document-card__tags"> <div className="document-card__tags">
@@ -175,17 +174,10 @@ const DocumentsGrid = ({
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.effectAllowed = 'copyMove';
} }
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
sourceDocId: doc.id,
});
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) { } catch (error) {
console.warn('[documents] Failed to configure tag drag payload', error); console.warn('[documents] Failed to configure drag effect', error);
} }
writeTagTransferData(event.dataTransfer, tag, doc.id);
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
event.stopPropagation(); event.stopPropagation();
+6 -14
View File
@@ -9,6 +9,7 @@ import { getTagColorStyle } from '../utils/colors';
import DocumentThumbnailImage from './DocumentThumbnailImage'; import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks'; import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents'; import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
const DocumentsList = ({ const DocumentsList = ({
entries, entries,
@@ -156,8 +157,6 @@ const DocumentsList = ({
if (isDraggingDoc) rowClasses.push('is-dragging'); if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null; const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc); const correspondents = resolveCorrespondents(doc);
const titleText = doc.title || doc.original_name;
return ( return (
<tr <tr
key={entry.key} key={entry.key}
@@ -178,7 +177,7 @@ const DocumentsList = ({
document={doc} document={doc}
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`} alt={`Thumbnail for ${doc.title}`}
scrollRootRef={scrollRef} scrollRootRef={scrollRef}
/> />
</td> </td>
@@ -195,7 +194,7 @@ const DocumentsList = ({
/> />
</span> </span>
) : null} ) : null}
<span className="doc-name__primary">{titleText}</span> <span className="doc-name__primary">{doc.title}</span>
</span> </span>
</div> </div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
@@ -221,17 +220,10 @@ const DocumentsList = ({
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.effectAllowed = 'copyMove';
} }
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
sourceDocId: doc.id,
});
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) { } catch (error) {
console.warn('[documents] Failed to configure tag drag payload', error); console.warn('[documents] Failed to configure drag effect', error);
} }
writeTagTransferData(event.dataTransfer, tag, doc.id);
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -272,7 +264,7 @@ const DocumentsList = ({
type="button" type="button"
className="icon-button" className="icon-button"
title="Rename" title="Rename"
aria-label={`Rename document ${titleText}`} aria-label={`Rename document ${doc.title}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
const nextName = window.prompt('Rename document', doc.title); const nextName = window.prompt('Rename document', doc.title);
+2 -5
View File
@@ -11,8 +11,8 @@ import createWorkspaceSurfaceConfig from './workspaceHeader';
import DetailPanel from '../detail/DetailPanel'; import DetailPanel from '../detail/DetailPanel';
import DocumentsGrid from './DocumentsGrid'; import DocumentsGrid from './DocumentsGrid';
import DocumentsList from './DocumentsList'; import DocumentsList from './DocumentsList';
import { isTagTransferEvent } from './tagTransfer';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 144; const DEFAULT_GRID_ICON_SIZE = 144;
const EntryType = { const EntryType = {
@@ -138,10 +138,7 @@ const DocumentsPanel = ({
scrollRef.current.scrollTop = 0; scrollRef.current.scrollTop = 0;
} }
}, [viewMode]); }, [viewMode]);
const isTagDragEvent = useCallback((event) => { const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type));
}, []);
const ensureFocusedRowVisible = useCallback(() => { const ensureFocusedRowVisible = useCallback(() => {
if (!focusedRowKey) return; if (!focusedRowKey) return;
const container = scrollRef.current; const container = scrollRef.current;
+7 -7
View File
@@ -6,14 +6,14 @@ export const resolveCorrespondents = (doc) => {
const seen = new Set(); const seen = new Set();
const results = []; const results = [];
doc.correspondents.forEach((entry, index) => { doc.correspondents.forEach((entry = {}, index) => {
if (!entry || typeof entry.name !== 'string') { const { id, name } = entry;
if (typeof name !== 'string') {
return; return;
} }
const id = entry.id; const trimmedName = name.trim();
const name = entry.name.trim(); if (!trimmedName) {
if (!name) {
return; return;
} }
@@ -27,8 +27,8 @@ export const resolveCorrespondents = (doc) => {
results.push({ results.push({
id, id,
name, name: trimmedName,
key: id ?? `${name}-${index}`, key: id ?? `${trimmedName}-${index}`,
}); });
}); });
+5 -6
View File
@@ -29,14 +29,14 @@ const sanitizeTags = (tags) => {
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
return []; return [];
} }
return tags.filter((tag) => tag && (tag.label || tag.id)); return tags.filter(Boolean);
}; };
const sanitizeCorrespondents = (entries) => { const sanitizeCorrespondents = (entries) => {
if (!Array.isArray(entries)) { if (!Array.isArray(entries)) {
return []; return [];
} }
return entries.filter((entry) => entry && (entry.name || entry.id)); return entries.filter(Boolean);
}; };
export const describeDocumentSummary = (document, options = {}) => { export const describeDocumentSummary = (document, options = {}) => {
@@ -64,7 +64,6 @@ export const describeDocumentSummary = (document, options = {}) => {
formatDateTime = defaultFormatDateTime, formatDateTime = defaultFormatDateTime,
} = options; } = options;
const title = document.title;
const originalName = document.original_name; const originalName = document.original_name;
const mimeTypeLabel = document.content_type || 'Unknown'; const mimeTypeLabel = document.content_type || 'Unknown';
@@ -79,7 +78,7 @@ export const describeDocumentSummary = (document, options = {}) => {
const issuedLabel = formatDateTime(document.issued_at); const issuedLabel = formatDateTime(document.issued_at);
const updatedAtLabel = formatDateTime(document.updated_at); const updatedAtLabel = formatDateTime(document.updated_at);
const folderLabel = document.folder_path || document.folder_name || null; const folderLabel = document.folder_path;
const tags = sanitizeTags(document.tags); const tags = sanitizeTags(document.tags);
const correspondents = sanitizeCorrespondents(document.correspondents); const correspondents = sanitizeCorrespondents(document.correspondents);
@@ -99,13 +98,13 @@ export const describeDocumentSummary = (document, options = {}) => {
{ key: 'issued', label: 'Issued', value: issuedLabel }, { key: 'issued', label: 'Issued', value: issuedLabel },
{ key: 'pages', label: 'Pages', value: pageCountLabel }, { key: 'pages', label: 'Pages', value: pageCountLabel },
{ key: 'updated', label: 'Updated', value: updatedAtLabel }, { key: 'updated', label: 'Updated', value: updatedAtLabel },
{ key: 'folder', label: 'Folder', value: folderLabel || '—' }, { key: 'folder', label: 'Folder', value: folderLabel },
{ key: 'tags', label: 'Tags', value: tagsSummary }, { key: 'tags', label: 'Tags', value: tagsSummary },
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary }, { key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
]; ];
return { return {
title, title: document.title,
originalName, originalName,
mimeTypeLabel, mimeTypeLabel,
sizeLabel, sizeLabel,
+8 -9
View File
@@ -51,7 +51,7 @@ const DocumentViewerPanel = ({
{ label: 'Updated at', value: formatDateTime(document.updated_at) }, { label: 'Updated at', value: formatDateTime(document.updated_at) },
{ {
label: 'Filename', label: 'Filename',
value: document.archive_path || document.filename || '—', value: document.filename,
}, },
{ {
label: 'Original filename', label: 'Original filename',
@@ -68,19 +68,18 @@ const DocumentViewerPanel = ({
]; ];
}, [document]); }, [document]);
const contentType = (document?.content_type || '').toLowerCase();
const previewContent = useMemo(() => { const previewContent = useMemo(() => {
if (!previewEntry?.url) { if (!document || !previewEntry?.url) {
return null; return null;
} }
const title = document?.title || document?.original_name || 'Document'; const contentType = (document.content_type || '').toLowerCase();
if (contentType.startsWith('image/')) { if (contentType.startsWith('image/')) {
return ( return (
<img <img
src={previewEntry.url} src={previewEntry.url}
alt={`Preview of ${title}`} alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image" className="document-viewer__object document-viewer__object--image"
draggable={false} draggable={false}
/> />
@@ -90,11 +89,11 @@ const DocumentViewerPanel = ({
return ( return (
<iframe <iframe
src={previewEntry.url} src={previewEntry.url}
title={`Preview of ${title}`} title={`Preview of ${document.title}`}
className="document-viewer__object" className="document-viewer__object"
/> />
); );
}, [previewEntry?.url, contentType, document?.title, document?.original_name]); }, [previewEntry?.url, document]);
const metadataPayload = useMemo(() => { const metadataPayload = useMemo(() => {
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) { if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
@@ -411,10 +410,10 @@ export const createDocumentViewerSurface = ({
.filter((segment) => segment && segment.id && segment.name) .filter((segment) => segment && segment.id && segment.name)
.map((segment) => ({ id: segment.id, name: segment.name })) .map((segment) => ({ id: segment.id, name: segment.name }))
: []; : [];
const documentLabel = document.title || document.original_name || 'Document';
breadcrumbs = [ breadcrumbs = [
...normalizedSegments, ...normalizedSegments,
{ id: document.id || 'current-document', name: documentLabel }, { id: document.id, name: document.title },
]; ];
} }
+1 -2
View File
@@ -674,7 +674,6 @@ const Sidebar = ({
{sortedCorrespondents.map((correspondent) => { {sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id); const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed';
const handleSelect = () => { const handleSelect = () => {
const nextId = isActive ? null : correspondent.id; const nextId = isActive ? null : correspondent.id;
onToggleCorrespondentFilter?.(nextId); onToggleCorrespondentFilter?.(nextId);
@@ -692,7 +691,7 @@ const Sidebar = ({
} }
}} }}
> >
{label} {correspondent.name}
</span> </span>
</li> </li>
); );