This commit is contained in:
2025-11-02 00:35:32 +01:00
parent 49998fa23d
commit 09430c6f40
6 changed files with 867 additions and 782 deletions
@@ -0,0 +1,60 @@
import React from 'react';
const NBSP = String.fromCharCode(160);
const CorrespondentLinks = ({
correspondents,
activeCorrespondentIdSet,
onCorrespondentClick,
}) => {
if (!Array.isArray(correspondents) || correspondents.length === 0) {
return null;
}
const activeSet = activeCorrespondentIdSet || new Set();
const handleClick = (event, correspondent) => {
if (!onCorrespondentClick || correspondent.id == null) {
return;
}
event.stopPropagation();
onCorrespondentClick(correspondent.id);
};
return correspondents.map((correspondent, index) => {
const isActive = correspondent.id != null && activeSet.has(correspondent.id);
const hasHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
const classNames = ['doc-correspondent-link'];
if (isActive) classNames.push('is-active');
if (!hasHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1;
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
return (
<React.Fragment
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
>
<button
type="button"
className={classNames.join(' ')}
aria-disabled={hasHandler ? undefined : true}
onClick={(event) => handleClick(event, correspondent)}
onKeyDown={(event) => {
if (!hasHandler) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
handleClick(event, correspondent);
}
}}
>
{label}
</button>
{isLast ? null : <span className="doc-correspondent-link__separator">, </span>}
</React.Fragment>
);
});
};
export default CorrespondentLinks;
@@ -0,0 +1,155 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
const DEFAULT_THUMBNAIL_SIZE = 48;
// Detect when an element becomes visible within a scroll container so we can delay loading.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(false);
}, [resetKey]);
const rootNode = rootRef?.current || null;
useEffect(() => {
if (isVisible) {
return undefined;
}
const element = targetRef.current;
if (!element) {
return undefined;
}
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{
root: rootNode,
rootMargin: '200px 0px',
threshold: 0.01,
},
);
observer.observe(element);
return () => observer.disconnect();
}, [isVisible, rootNode, resetKey]);
return { ref: targetRef, isVisible };
};
const getPageCount = (doc) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
: null;
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
getDocumentAsset,
alt = '',
maxSize = DEFAULT_THUMBNAIL_SIZE,
scrollRootRef = null,
}) => {
const documentId = document?.id;
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
return { width: resolvedMaxSize, height: resolvedMaxSize };
}
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
return {
width: Math.max(1, Math.round(assetWidth * scale)),
height: Math.max(1, Math.round(assetHeight * scale)),
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
const url = useMemo(() => {
if (!isVisible) {
return null;
}
return resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
const innerClasses = ['document-thumbnail-inner'];
if (showMultiPageBadge) {
innerClasses.push('document-thumbnail-inner--multipage');
}
const aspectRatio = useMemo(() => {
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
return assetWidth / assetHeight;
}
return null;
}, [assetWidth, assetHeight]);
useEffect(() => {
const node = visibilityRef.current;
if (!node) {
return;
}
if (aspectRatio) {
node.dataset.thumbnailAspect = String(aspectRatio);
} else {
delete node.dataset.thumbnailAspect;
}
}, [aspectRatio, visibilityRef]);
return (
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? (
<img
src={url}
alt={alt}
className="document-thumbnail"
loading="lazy"
decoding="async"
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="thumb-placeholder">DOC</div>
)}
</div>
</div>
);
};
export default DocumentThumbnailImage;
export { DEFAULT_THUMBNAIL_SIZE };
+217
View File
@@ -0,0 +1,217 @@
import React from 'react';
import { FolderIcon } from '../ui/icons';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { getTagColorStyle } from '../utils/colors';
import { resolveCorrespondents } from './correspondents';
const DocumentsGrid = ({
entries,
selectedDocumentIdsSet,
selectedFolderIdsSet,
draggingDocumentIdsSet,
draggedFolderId,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onDocumentClick,
onDocumentOpen,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
onDocumentTagDragLeave,
onDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
gridIconSize,
tagLookupById,
onTagClick,
scrollRef,
onCorrespondentClick,
activeCorrespondentIdSet,
onClearSelection,
}) => (
<div
className="documents-grid"
role="list"
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
return (
<div
key={entry.key}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<div className="folder-card__icon">
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
</div>
<div className="folder-card__meta">
<div className="folder-card__name" title={folder.name}>
{folder.name}
</div>
</div>
</div>
);
}
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
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}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={(event) => onDocumentTagDragOver?.(event)}
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
onDragLeave={onDocumentTagDragLeave}
onDragLeaveCapture={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={titleText}>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
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);
}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
}
}}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
export default DocumentsGrid;
+331
View File
@@ -0,0 +1,331 @@
import React from 'react';
import {
FolderIcon,
EditIcon,
DownloadIcon,
TrashIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents';
const DocumentsList = ({
entries,
focusedRowKey,
selectedDocumentIdsSet,
selectedFolderIdsSet,
draggingDocumentIdsSet,
draggedFolderId,
ensureAssetUrl,
getDocumentAsset,
getDownloadHref,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
onFolderDelete,
onDocumentClick,
onDocumentOpen,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
onDocumentTagDragLeave,
onDocumentTagDrop,
onDocumentRename,
onDocumentDelete,
tagLookupById,
onTagClick,
onCorrespondentClick,
activeCorrespondentIdSet,
scrollRef,
}) => (
<table aria-multiselectable="true">
<thead>
<tr>
<th className="thumb-column"></th>
<th>Name</th>
<th>Tags</th>
<th>Issued</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const rowKey = `folder:${folder.id}`;
return (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === rowKey ? ' focused' : ''
}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
draggable={canDragFolder}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<td className="thumb-cell">
<div className="thumb-icon">
<FolderIcon className="thumb-icon__image" size={32} />
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span>{folder.name}</span>
</div>
</td>
<td></td>
<td className="actions">
<div className="action-buttons">
{folder.id !== 'root' && onFolderRename && (
<button
type="button"
className="icon-button"
title="Rename"
aria-label={`Rename folder ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt('Rename folder', folder.name);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === folder.name) {
return;
}
onFolderRename?.(folder.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
<button
type="button"
className="icon-button danger"
title="Delete"
aria-label={`Delete folder ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
onFolderDelete?.(folder.id);
}}
>
<TrashIcon className="icon-inline" />
</button>
</div>
</td>
</tr>
);
}
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
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}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={onDocumentTagDragOver}
onDragLeave={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
scrollRootRef={scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
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);
}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
}
}}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
</td>
<td>
{(() => {
const issuedAt = doc.issued_at || null;
if (!issuedAt) {
return '—';
}
const timestamp = Date.parse(issuedAt);
if (Number.isNaN(timestamp)) {
return '—';
}
return new Date(timestamp).toLocaleDateString();
})()}
</td>
<td className="actions">
<div className="action-buttons">
{onDocumentRename && (
<button
type="button"
className="icon-button"
title="Rename"
aria-label={`Rename document ${titleText}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt('Rename document', doc.title);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === doc.title) {
return;
}
onDocumentRename?.(doc.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
title="Download"
aria-label="Download document"
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
<DownloadIcon className="icon-inline" />
</a>
) : (
<span className="meta">No download</span>
)}
<button
type="button"
className="icon-button danger"
title="Delete"
aria-label="Delete document"
onClick={(event) => {
event.stopPropagation();
onDocumentDelete?.(doc.id);
}}
>
<TrashIcon className="icon-inline" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
);
export default DocumentsList;
+66 -782
View File
@@ -1,206 +1,25 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import {
DownloadIcon,
EditIcon,
ViewListIcon,
ViewGridIcon,
IconFileStack,
FolderIcon,
TrashIcon,
RefreshIcon,
MinusVerticalIcon,
} from '../ui/icons';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import createWorkspaceSurfaceConfig from './workspaceHeader';
import DetailPanel from '../detail/DetailPanel';
import DocumentsGrid from './DocumentsGrid';
import DocumentsList from './DocumentsList';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 144;
const LIST_ICON_SIZE = 48;
const EntryType = {
folder: 'folder',
document: 'document',
};
const getPageCount = (doc) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
: null;
const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry || typeof entry.name !== 'string') return;
const id = entry.id;
const name = entry.name.trim();
if (!name) return;
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name,
key: id ?? `${name}-${index}`,
});
});
return results;
};
// Detects when an element becomes visible within a scroll container.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(false);
}, [resetKey]);
const rootNode = rootRef?.current || null;
useEffect(() => {
if (isVisible) {
return undefined;
}
const element = targetRef.current;
if (!element) {
return undefined;
}
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{
root: rootNode,
rootMargin: '200px 0px',
threshold: 0.01,
},
);
observer.observe(element);
return () => observer.disconnect();
}, [isVisible, rootNode, resetKey]);
return { ref: targetRef, isVisible };
};
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
getDocumentAsset,
alt = '',
maxSize = LIST_ICON_SIZE,
scrollRootRef = null,
}) => {
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
return { width: resolvedMaxSize, height: resolvedMaxSize };
}
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
return {
width: Math.max(1, Math.round(assetWidth * scale)),
height: Math.max(1, Math.round(assetHeight * scale)),
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
const url = useMemo(() => {
if (!isVisible) {
return null;
}
return resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
const innerClasses = ['document-thumbnail-inner'];
if (showMultiPageBadge) {
innerClasses.push('document-thumbnail-inner--multipage');
}
const aspectRatio = useMemo(() => {
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
return assetWidth / assetHeight;
}
return null;
}, [assetWidth, assetHeight]);
useEffect(() => {
const node = visibilityRef.current;
if (!node) {
return;
}
if (aspectRatio) {
node.dataset.thumbnailAspect = String(aspectRatio);
} else {
delete node.dataset.thumbnailAspect;
}
}, [aspectRatio, visibilityRef]);
return (
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? (
<img
src={url}
alt={alt}
className="document-thumbnail"
loading="lazy"
decoding="async"
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="thumb-placeholder">DOC</div>
)}
</div>
</div>
);
};
const DocumentsPanel = ({
currentFolderName,
breadcrumbs,
@@ -498,571 +317,6 @@ const DocumentsPanel = ({
[onDocumentDragEnd],
);
const renderCorrespondentLinks = useCallback(
(correspondents) =>
correspondents.map((correspondent, index) => {
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
const classNames = ['doc-correspondent-link'];
if (isActive) classNames.push('is-active');
if (!hasClickHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1;
const label = isLast
? `${correspondent.name}:${String.fromCharCode(160)}`
: correspondent.name;
return (
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
<button
type="button"
className={classNames.join(' ')}
aria-disabled={hasClickHandler ? undefined : true}
onClick={(event) => {
if (!hasClickHandler) {
return;
}
event.stopPropagation();
onCorrespondentClick(correspondent.id, correspondent);
}}
onKeyDown={(event) => {
if (!hasClickHandler) {
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
>
{label}
</button>
{!isLast ? (
<span className="doc-correspondent-link__separator">, </span>
) : null}
</React.Fragment>
);
}),
[activeCorrespondentIdSet, onCorrespondentClick],
);
const renderFolderGridEntry = useCallback(
(entry) => {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
return (
<div
key={entry.key}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => handleFolderClick(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd(event);
}
}}
>
<div className="folder-card__icon">
<FolderIcon
className="folder-card__icon-svg"
size={gridIconSize}
/>
</div>
<div className="folder-card__meta">
<div className="folder-card__name" title={folder.name}>
{folder.name}
</div>
</div>
</div>
);
},
[
draggedFolderId,
gridIconSize,
handleFolderClick,
onFolderDragEnd,
onFolderDragLeave,
onFolderDragOver,
onFolderDragStart,
onFolderDrop,
onFolderSelect,
selectedFolderSet,
],
);
const renderDocumentGridEntry = useCallback(
(entry) => {
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedSet.has(doc.id);
const isDraggingDoc = draggingSet.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
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}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={handleDocumentDragEndLocal}
onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave}
onDragLeaveCapture={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/>
<div className="document-card__meta">
<div
className="document-card__title"
title={titleText}
>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
{renderCorrespondentLinks(correspondents)}
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
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);
}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
);
},
[
draggingSet,
ensureAssetUrl,
getDocumentAsset,
gridIconSize,
handleDocumentClick,
handleDocumentDragEndLocal,
handleDocumentDragStartLocal,
handleDocumentTagDragLeave,
handleDocumentTagDragOver,
handleDocumentTagDrop,
onDocumentOpen,
onTagClick,
renderCorrespondentLinks,
scrollRef,
selectedSet,
tagLookupById,
],
);
const renderFolderListEntry = useCallback(
(entry) => {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const rowKey = `folder:${folder.id}`;
return (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === rowKey ? ' focused' : ''
}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => handleFolderClick(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
draggable={canDragFolder}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd(event);
}
}}
>
<td className="thumb-cell">
<div className="thumb-icon">
<FolderIcon
className="thumb-icon__image"
size={32}
/>
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span>{folder.name}</span>
</div>
</td>
<td></td>
<td className="actions">
<div className="action-buttons">
{folder.id !== 'root' && onFolderRename && (
<button
type="button"
className="icon-button"
title="Rename"
aria-label={`Rename folder ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt('Rename folder', folder.name);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === folder.name) {
return;
}
onFolderRename(folder.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
<button
type="button"
className="icon-button danger"
title="Delete"
aria-label={`Delete folder ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
onFolderDelete(folder.id);
}}
>
<TrashIcon className="icon-inline" />
</button>
</div>
</td>
</tr>
);
},
[
draggedFolderId,
focusedRowKey,
handleFolderClick,
onFolderDelete,
onFolderDragEnd,
onFolderDragLeave,
onFolderDragOver,
onFolderDragStart,
onFolderDrop,
onFolderRename,
onFolderSelect,
selectedFolderSet,
],
);
const renderDocumentListEntry = useCallback(
(entry) => {
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedSet.has(doc.id);
const isDraggingDoc = draggingSet.has(doc.id);
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
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}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={handleDocumentDragEndLocal}
onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${titleText}`}
scrollRootRef={scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
{correspondents.length > 0 ? (
<span className="doc-correspondents">
{renderCorrespondentLinks(correspondents)}
</span>
) : null}
<span className="doc-name__primary">{titleText}</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
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);
}
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
</td>
<td>
{(() => {
const issuedAt = doc.issued_at || null;
if (!issuedAt) {
return '—';
}
const timestamp = Date.parse(issuedAt);
if (Number.isNaN(timestamp)) {
return '—';
}
return new Date(timestamp).toLocaleDateString();
})()}
</td>
<td className="actions">
<div className="action-buttons">
{onDocumentRename && (
<button
type="button"
className="icon-button"
title="Rename"
aria-label={`Rename document ${titleText}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt(
'Rename document',
doc.title
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === doc.title) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
title="Download"
aria-label="Download document"
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
<DownloadIcon className="icon-inline" />
</a>
) : (
<span className="meta">No download</span>
)}
<button
type="button"
className="icon-button danger"
title="Delete"
aria-label="Delete document"
onClick={(event) => {
event.stopPropagation();
onDocumentDelete?.(doc.id);
}}
>
<TrashIcon className="icon-inline" />
</button>
</div>
</td>
</tr>
);
},
[
draggingSet,
ensureAssetUrl,
getDocumentAsset,
getDownloadHref,
handleDocumentClick,
handleDocumentDragEndLocal,
handleDocumentDragStartLocal,
handleDocumentTagDragLeave,
handleDocumentTagDragOver,
handleDocumentTagDrop,
onDocumentDelete,
onDocumentOpen,
onDocumentRename,
onTagClick,
renderCorrespondentLinks,
scrollRef,
selectedSet,
tagLookupById,
],
);
const hasDocumentEntries = useMemo(
() => entries.some((entry) => entry.type === EntryType.document),
[entries],
@@ -1184,40 +438,71 @@ const DocumentsPanel = ({
aria-activedescendant={isGridView ? undefined : activeDescendantId}
>
{isGridView ? (
<div
className="documents-grid"
role="list"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
>
{entries.map((entry) =>
entry.type === EntryType.folder
? renderFolderGridEntry(entry)
: renderDocumentGridEntry(entry)
)}
</div>
<DocumentsGrid
entries={entries}
selectedDocumentIdsSet={selectedSet}
selectedFolderIdsSet={selectedFolderSet}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
gridIconSize={gridIconSize}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
scrollRef={scrollRef}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onClearSelection={onClearSelection}
/>
) : !showTableRows ? null : (
<table aria-multiselectable="true">
<thead>
<tr>
<th className="thumb-column"></th>
<th>Name</th>
<th>Issued</th>
<th className="actions-column">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) =>
entry.type === EntryType.folder
? renderFolderListEntry(entry)
: renderDocumentListEntry(entry)
)}
</tbody>
</table>
<DocumentsList
entries={entries}
focusedRowKey={focusedRowKey}
selectedDocumentIdsSet={selectedSet}
selectedFolderIdsSet={selectedFolderSet}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
getDownloadHref={getDownloadHref}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename}
onFolderDelete={onFolderDelete}
onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
onDocumentRename={onDocumentRename}
onDocumentDelete={onDocumentDelete}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
scrollRef={scrollRef}
/>
)}
</div>
{showSearchHint && (
@@ -1232,7 +517,6 @@ const DocumentsPanel = ({
};
export default DocumentsPanel;
export { DocumentThumbnailImage };
export const createDocumentsTableHeaderActions = ({
viewMode,
+38
View File
@@ -0,0 +1,38 @@
export const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry || typeof entry.name !== 'string') {
return;
}
const id = entry.id;
const name = entry.name.trim();
if (!name) {
return;
}
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name,
key: id ?? `${name}-${index}`,
});
});
return results;
};
export default resolveCorrespondents;