411 lines
16 KiB
React
411 lines
16 KiB
React
import React from 'react';
|
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
|
import CorrespondentLinks from './CorrespondentLinks';
|
|
import { getTagColorStyle } from '../utils/colors';
|
|
import { resolveCorrespondents } from './correspondents';
|
|
import { writeTagTransferData } from './tagTransfer';
|
|
import useInlineRename from './useInlineRename';
|
|
|
|
const DocumentsGrid = ({
|
|
entries,
|
|
selectedDocumentIdsSet,
|
|
selectedFolderIdsSet,
|
|
draggingDocumentIdsSet,
|
|
draggedFolderId,
|
|
onFolderClick,
|
|
onFolderSelect,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDrop,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
onDocumentClick,
|
|
onDocumentActivate,
|
|
onDocumentDragStart,
|
|
onDocumentDragEnd,
|
|
onDocumentTagDragOver,
|
|
onDocumentTagDragLeave,
|
|
onDocumentTagDrop,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
gridIconSize,
|
|
tagLookupById,
|
|
onTagClick,
|
|
scrollRef,
|
|
onCorrespondentClick,
|
|
activeCorrespondentIdSet,
|
|
onClearSelection,
|
|
onDocumentRename,
|
|
onFolderRename,
|
|
}) => {
|
|
const {
|
|
editingId: editingDocumentId,
|
|
draftValue: documentDraft,
|
|
setDraftValue: setDocumentDraft,
|
|
beginEditing: beginDocumentEditing,
|
|
cancelEditing: cancelDocumentEditing,
|
|
submitEditing: submitDocumentEditing,
|
|
savingId: savingDocumentId,
|
|
attachInputRef: attachDocumentInputRef,
|
|
} = useInlineRename(onDocumentRename, {
|
|
getCurrentValue: (doc) => doc?.title ?? '',
|
|
getEntityId: (doc) => doc?.id ?? null,
|
|
});
|
|
|
|
const {
|
|
editingId: editingFolderId,
|
|
draftValue: folderDraft,
|
|
setDraftValue: setFolderDraft,
|
|
beginEditing: beginFolderEditing,
|
|
cancelEditing: cancelFolderEditing,
|
|
submitEditing: submitFolderEditing,
|
|
savingId: savingFolderId,
|
|
attachInputRef: attachFolderInputRef,
|
|
} = useInlineRename(onFolderRename, {
|
|
getCurrentValue: (folder) => folder?.name ?? '',
|
|
getEntityId: (folder) => folder?.id ?? null,
|
|
});
|
|
|
|
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
|
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
|
|
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
|
|
|
return (
|
|
<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');
|
|
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
|
|
const isFolderEditing = editingFolderId === folder.id;
|
|
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
|
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
|
const isFolderSaving = savingFolderId === folder.id;
|
|
const canSubmitFolder =
|
|
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
|
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
|
|
|
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">
|
|
{isFolderEditing ? (
|
|
<div className="folder-card__edit doc-title-edit">
|
|
<input
|
|
type="text"
|
|
ref={attachFolderInputRef}
|
|
value={folderDraftValue}
|
|
onChange={(event) => setFolderDraft(event.target.value)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
submitFolderEditing(folder);
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelFolderEditing(event);
|
|
}
|
|
}}
|
|
onBlur={(event) => {
|
|
const nextFocus = event.relatedTarget;
|
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
|
cancelFolderEditing();
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Save name"
|
|
title="Save name"
|
|
disabled={!canSubmitFolder || isFolderSaving}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
submitFolderEditing(folder);
|
|
}}
|
|
>
|
|
<CheckIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Cancel"
|
|
title="Cancel"
|
|
onClick={(event) => cancelFolderEditing(event)}
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="folder-card__label-row">
|
|
<span
|
|
className="folder-card__name"
|
|
title={folder.name}
|
|
role={allowInlineFolderEdit ? 'button' : undefined}
|
|
tabIndex={allowInlineFolderEdit ? 0 : undefined}
|
|
onClick={(event) => {
|
|
if (!allowInlineFolderEdit) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
beginFolderEditing(folder);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (!allowInlineFolderEdit) {
|
|
return;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
beginFolderEditing(folder);
|
|
}
|
|
}}
|
|
>
|
|
{folder.name}
|
|
</span>
|
|
</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 isEditingDoc = editingDocumentId === doc.id;
|
|
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
|
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
|
const isDocumentSaving = savingDocumentId === doc.id;
|
|
const canSubmitDocument =
|
|
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
|
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
|
|
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={(event) => onDocumentActivate?.(doc, event)}
|
|
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 ${doc.title}`}
|
|
maxSize={gridIconSize}
|
|
scrollRootRef={scrollRef}
|
|
/>
|
|
<div className="document-card__meta">
|
|
<div className="document-card__title" title={doc.title}>
|
|
{correspondents.length > 0 ? (
|
|
<span className="doc-correspondents">
|
|
<CorrespondentLinks
|
|
correspondents={correspondents}
|
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
|
onCorrespondentClick={onCorrespondentClick}
|
|
/>
|
|
</span>
|
|
) : null}
|
|
{isEditingDoc ? (
|
|
<div className="document-card__title-edit doc-title-edit">
|
|
<input
|
|
type="text"
|
|
ref={attachDocumentInputRef}
|
|
value={documentDraftValue}
|
|
onChange={(event) => setDocumentDraft(event.target.value)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
submitDocumentEditing(doc);
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelDocumentEditing(event);
|
|
}
|
|
}}
|
|
onBlur={(event) => {
|
|
const nextFocus = event.relatedTarget;
|
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
|
cancelDocumentEditing();
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Save title"
|
|
title="Save title"
|
|
disabled={!canSubmitDocument || isDocumentSaving}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
submitDocumentEditing(doc);
|
|
}}
|
|
>
|
|
<CheckIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Cancel"
|
|
title="Cancel"
|
|
onClick={(event) => cancelDocumentEditing(event)}
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="document-card__title-row">
|
|
<span
|
|
className="document-card__title-badge"
|
|
role={allowInlineDocumentEdit ? 'button' : undefined}
|
|
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
|
onClick={(event) => {
|
|
if (!allowInlineDocumentEdit) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
beginDocumentEditing(doc);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (!allowInlineDocumentEdit) {
|
|
return;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
beginDocumentEditing(doc);
|
|
}
|
|
}}
|
|
>
|
|
{doc.title}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</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';
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to configure drag effect', error);
|
|
}
|
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
|
}}
|
|
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;
|