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 {
opacity: 0.6;
outline: 0.25rem solid var(--accent-outline);
outline-offset: 0.25rem;
}
.desk-item.is-filtered-out {
@@ -201,6 +202,9 @@ body.desk-cursor-remove * {
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.desk-item__card--empty {
+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>
);
})}
+46 -26
View File
@@ -21,9 +21,9 @@ import { useManagementModals } from './useManagementModals';
import { api, useAppDispatch, useAppState } from './appState';
import { useDetailPanel } from './useDetailPanel';
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 TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_FOLDER_NAME = 'Documents';
@@ -2180,9 +2180,8 @@ const AppLayout = () => {
};
const { data } = await api.post('/folders/path', payload);
const folderId = data.folder.id;
cache.set(cacheKey, folderId);
return folderId;
cache.set(cacheKey, data.folder.id);
return data.folder.id;
},
[],
);
@@ -2547,7 +2546,7 @@ const AppLayout = () => {
const entry = {
url: href,
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,
};
setPreviewEntries((prev) => {
@@ -3106,6 +3105,8 @@ const AppLayout = () => {
? focusedRowKey
: null;
let initializedFromEmptyState = false;
if (!activeKey) {
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
const candidate = selectedEntries[index];
@@ -3117,8 +3118,17 @@ const AppLayout = () => {
}
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);
initializedFromEmptyState = true;
}
let currentIndex = navigableRowKeys.indexOf(activeKey);
@@ -3140,8 +3150,22 @@ const AppLayout = () => {
let nextIndex = currentIndex;
if (key === 'ArrowDown') {
if (initializedFromEmptyState && selectedEntries.length === 0) {
handleRowSelection(activeKey, {
shiftKey,
preventDefault: () => {},
});
return;
}
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
} 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);
} else if (key === 'Home') {
nextIndex = 0;
@@ -3363,16 +3387,14 @@ const AppLayout = () => {
const resolveTagForCache = () => {
const lookupTag = tagLookupById.get(tagId);
const source = lookupTag || tagData;
if (!source) {
return { id: tagId, label: 'Tag', color: null };
const source = lookupTag ?? tagData;
if (!source || source.id == null || typeof source.label !== 'string') {
return null;
}
return {
id: source.id ?? tagId,
label: source.label || source.name || 'Tag',
color: Object.prototype.hasOwnProperty.call(source, 'color')
? source.color
: null,
id: source.id,
label: source.label,
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
};
};
@@ -3386,10 +3408,16 @@ const AppLayout = () => {
if (currentTags.some((existing) => existing?.id === tagId)) {
return doc;
}
return { ...doc, tags: [...currentTags, resolveTagForCache()] };
const resolvedTag = resolveTagForCache();
if (!resolvedTag) {
return doc;
}
return { ...doc, tags: [...currentTags, resolvedTag] };
});
setStatusMessage('Tag assigned.', 'success');
await refreshCurrentFolder();
if (documentsViewMode !== 'desk') {
await refreshCurrentFolder();
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to assign tag.';
@@ -3399,6 +3427,7 @@ const AppLayout = () => {
},
[
refreshCurrentFolder,
documentsViewMode,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
@@ -3851,16 +3880,7 @@ const AppLayout = () => {
return undefined;
}
const isTagTransfer = (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 isTagTransfer = (event) => isTagTransferEvent(event);
const isDocumentDropTarget = (target) =>
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_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 {
@@ -20,11 +35,121 @@ const useDocumentDrag = () => {
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef,
} = useDesktopContext();
const dragStateRef = useRef(null);
const inertiaAnimationsRef = useRef(new Map());
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(
(pointerId) => {
const state = dragStateRef.current;
@@ -63,6 +188,7 @@ const useDocumentDrag = () => {
);
}
preventAll(event);
cancelInertiaAnimation(docId);
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
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 = {
docId,
pointerId: event.pointerId,
@@ -98,6 +243,9 @@ const useDocumentDrag = () => {
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0,
dynamicRotation: 0,
angularVelocity: 0,
moved: false,
locked: false,
width: docWidth,
@@ -105,18 +253,27 @@ const useDocumentDrag = () => {
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
lastClientX: event.clientX,
lastClientY: event.clientY,
lastTimestamp: eventTimestamp,
localPointerOffsetX,
localPointerOffsetY,
containerRectLeft: containerLeft,
containerRectTop: containerTop,
};
setDraggingId(docId);
},
[
bringToFront,
canvasPadding,
cancelInertiaAnimation,
documentLookup,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
setDraggingId,
debugDrag,
containerRef,
],
);
@@ -155,21 +312,47 @@ const useDocumentDrag = () => {
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 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 canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
const minCenterX = canvasPadding + rotatedHalfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth);
const minCenterY = canvasPadding + rotatedHalfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight);
const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY);
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
@@ -180,20 +363,167 @@ const useDocumentDrag = () => {
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);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX - state.width / 2,
clampedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
const collidedWithHorizontalEdge =
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
const collidedWithVerticalEdge =
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
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) {
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
}
recalcVisibleDocIds();
},
@@ -204,7 +534,8 @@ const useDocumentDrag = () => {
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
itemRefs,
containerRef,
integrateRotation,
layoutRef,
recalcVisibleDocIds,
debugDrag,
@@ -216,7 +547,18 @@ const useDocumentDrag = () => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
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);
startInertiaAnimation(docId, inertiaState);
return;
}
@@ -234,14 +576,30 @@ const useDocumentDrag = () => {
}
finishDrag(event.pointerId);
},
[bringToFront, finishDrag, openOverlayForDoc],
[bringToFront, finishDrag, openOverlayForDoc, startInertiaAnimation],
);
const handlePointerCancel = useCallback(
(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],
[finishDrag, startInertiaAnimation],
);
return {
+1 -2
View File
@@ -215,12 +215,11 @@ const DetailPanel = ({
}))
: [];
const documentLabel = detailSummary.title || 'Document';
return [
...normalizedSegments,
{
id: singleDoc.id || 'current-document',
label: documentLabel,
label: detailSummary.title,
},
];
}, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
@@ -327,7 +327,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return;
setIsTitleEditing(true);
setTitleDraft(document.title || document.original_name || '');
setTitleDraft(document.title);
setTitleError(null);
}, [document, editableTitle]);
@@ -396,8 +396,6 @@ const DocumentSummarySection = ({
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
);
const titleDisplay = summary.title || document?.original_name || 'Untitled document';
if (!document) {
return null;
}
@@ -440,7 +438,7 @@ const DocumentSummarySection = ({
</form>
) : (
<>
<h3 className="doc-title-row__title">{titleDisplay}</h3>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
+6 -14
View File
@@ -4,6 +4,7 @@ import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { getTagColorStyle } from '../utils/colors';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
const DocumentsGrid = ({
entries,
@@ -110,8 +111,6 @@ const DocumentsGrid = ({
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
const titleText = doc.title || doc.original_name;
return (
<div
key={entry.key}
@@ -135,12 +134,12 @@ const DocumentsGrid = ({
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={titleText}>
<div className="document-card__title" title={doc.title}>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
@@ -150,7 +149,7 @@ const DocumentsGrid = ({
/>
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
<span className="doc-name__primary">{doc.title}</span>
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
@@ -175,17 +174,10 @@ const DocumentsGrid = ({
if (event.dataTransfer) {
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) {
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) => {
event.stopPropagation();
+6 -14
View File
@@ -9,6 +9,7 @@ import { getTagColorStyle } from '../utils/colors';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
const DocumentsList = ({
entries,
@@ -156,8 +157,6 @@ const DocumentsList = ({
if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc);
const titleText = doc.title || doc.original_name;
return (
<tr
key={entry.key}
@@ -178,7 +177,7 @@ const DocumentsList = ({
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
alt={`Thumbnail for ${doc.title}`}
scrollRootRef={scrollRef}
/>
</td>
@@ -195,7 +194,7 @@ const DocumentsList = ({
/>
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
<span className="doc-name__primary">{doc.title}</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
@@ -221,17 +220,10 @@ const DocumentsList = ({
if (event.dataTransfer) {
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) {
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) => {
event.stopPropagation();
@@ -272,7 +264,7 @@ const DocumentsList = ({
type="button"
className="icon-button"
title="Rename"
aria-label={`Rename document ${titleText}`}
aria-label={`Rename document ${doc.title}`}
onClick={(event) => {
event.stopPropagation();
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 DocumentsGrid from './DocumentsGrid';
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 EntryType = {
@@ -138,10 +138,7 @@ const DocumentsPanel = ({
scrollRef.current.scrollTop = 0;
}
}, [viewMode]);
const isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type));
}, []);
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
const ensureFocusedRowVisible = useCallback(() => {
if (!focusedRowKey) return;
const container = scrollRef.current;
+7 -7
View File
@@ -6,14 +6,14 @@ export const resolveCorrespondents = (doc) => {
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry || typeof entry.name !== 'string') {
doc.correspondents.forEach((entry = {}, index) => {
const { id, name } = entry;
if (typeof name !== 'string') {
return;
}
const id = entry.id;
const name = entry.name.trim();
if (!name) {
const trimmedName = name.trim();
if (!trimmedName) {
return;
}
@@ -27,8 +27,8 @@ export const resolveCorrespondents = (doc) => {
results.push({
id,
name,
key: id ?? `${name}-${index}`,
name: trimmedName,
key: id ?? `${trimmedName}-${index}`,
});
});
+5 -6
View File
@@ -29,14 +29,14 @@ const sanitizeTags = (tags) => {
if (!Array.isArray(tags)) {
return [];
}
return tags.filter((tag) => tag && (tag.label || tag.id));
return tags.filter(Boolean);
};
const sanitizeCorrespondents = (entries) => {
if (!Array.isArray(entries)) {
return [];
}
return entries.filter((entry) => entry && (entry.name || entry.id));
return entries.filter(Boolean);
};
export const describeDocumentSummary = (document, options = {}) => {
@@ -64,7 +64,6 @@ export const describeDocumentSummary = (document, options = {}) => {
formatDateTime = defaultFormatDateTime,
} = options;
const title = document.title;
const originalName = document.original_name;
const mimeTypeLabel = document.content_type || 'Unknown';
@@ -79,7 +78,7 @@ export const describeDocumentSummary = (document, options = {}) => {
const issuedLabel = formatDateTime(document.issued_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 correspondents = sanitizeCorrespondents(document.correspondents);
@@ -99,13 +98,13 @@ export const describeDocumentSummary = (document, options = {}) => {
{ key: 'issued', label: 'Issued', value: issuedLabel },
{ key: 'pages', label: 'Pages', value: pageCountLabel },
{ 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: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
];
return {
title,
title: document.title,
originalName,
mimeTypeLabel,
sizeLabel,
+8 -9
View File
@@ -51,7 +51,7 @@ const DocumentViewerPanel = ({
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
{
label: 'Filename',
value: document.archive_path || document.filename || '—',
value: document.filename,
},
{
label: 'Original filename',
@@ -68,19 +68,18 @@ const DocumentViewerPanel = ({
];
}, [document]);
const contentType = (document?.content_type || '').toLowerCase();
const previewContent = useMemo(() => {
if (!previewEntry?.url) {
if (!document || !previewEntry?.url) {
return null;
}
const title = document?.title || document?.original_name || 'Document';
const contentType = (document.content_type || '').toLowerCase();
if (contentType.startsWith('image/')) {
return (
<img
src={previewEntry.url}
alt={`Preview of ${title}`}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
@@ -90,11 +89,11 @@ const DocumentViewerPanel = ({
return (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
title={`Preview of ${document.title}`}
className="document-viewer__object"
/>
);
}, [previewEntry?.url, contentType, document?.title, document?.original_name]);
}, [previewEntry?.url, document]);
const metadataPayload = useMemo(() => {
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
@@ -411,10 +410,10 @@ export const createDocumentViewerSurface = ({
.filter((segment) => segment && segment.id && segment.name)
.map((segment) => ({ id: segment.id, name: segment.name }))
: [];
const documentLabel = document.title || document.original_name || 'Document';
breadcrumbs = [
...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) => {
const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed';
const handleSelect = () => {
const nextId = isActive ? null : correspondent.id;
onToggleCorrespondentFilter?.(nextId);
@@ -692,7 +691,7 @@ const Sidebar = ({
}
}}
>
{label}
{correspondent.name}
</span>
</li>
);