playful
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user