refactor: Decompose document and folder views into modular components and hooks, replacing monolithic list/grid implementations.

This commit is contained in:
2025-11-26 01:50:53 +01:00
parent 342714528a
commit 0a4a3ae35c
20 changed files with 1074 additions and 917 deletions
-441
View File
@@ -1,441 +0,0 @@
import React, { useMemo } 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, parseTagTransferPayload } from './tagTransfer';
import useInlineRename from './useInlineRename';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import type {
Folder as FolderLike,
Document,
} from '../types/documents';
import type { DocumentsViewProps } from './panel/DocumentsPanel';
interface DocumentsGridProps extends DocumentsViewProps {
gridIconSize?: number;
}
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
entries,
draggingDocumentIdsSet,
draggedFolderId,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onDocumentClick,
onDocumentActivate,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
onDocumentTagDragLeave,
onDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
gridIconSize,
tagLookupById,
onTagClick,
scrollRef,
onCorrespondentClick,
activeCorrespondentIdSet,
onDocumentRename,
onFolderRename,
}) => {
const {
selectedDocumentIds,
selectedFolderIds,
clearSelection,
} = useWorkspaceSelectionContext();
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
const selectedFolderIdsSet = useMemo(() => new Set(selectedFolderIds || []), [selectedFolderIds]);
const {
editingId: editingDocumentId,
draftValue: documentDraft,
setDraftValue: setDocumentDraft,
beginEditing: beginDocumentEditing,
cancelEditing: cancelDocumentEditing,
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
} = useInlineRename<Document>(onDocumentRename, {
getCurrentValue: (doc: Document) => doc?.title ?? '',
getEntityId: (doc: Document) => doc?.id ?? null,
});
const {
editingId: editingFolderId,
draftValue: folderDraft,
setDraftValue: setFolderDraft,
beginEditing: beginFolderEditing,
cancelEditing: cancelFolderEditing,
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
} = useInlineRename<FolderLike>(onFolderRename, {
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
getEntityId: (folder: FolderLike) => 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) {
clearSelection();
}
}}
>
{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 = Boolean(onFolderRename) && folder.id !== 'root';
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) => {
event.preventDefault();
event.stopPropagation();
const payload = parseTagTransferPayload(event);
if (payload && onDocumentTagDrop) {
onDocumentTagDrop(doc.id, payload);
}
}}
>
<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, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
} : undefined}
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={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export default DocumentsGrid;
-449
View File
@@ -1,449 +0,0 @@
import React, { useMemo } from 'react';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
import { formatDate } from '../utils/date';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData, parseTagTransferPayload } from './tagTransfer';
import useInlineRename from './useInlineRename';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import type { DocumentsViewProps } from './panel/DocumentsPanel';
import type {
Document,
Folder,
} from '../types/documents';
const DocumentsList: React.FC<DocumentsViewProps> = ({
entries,
draggingDocumentIdsSet,
draggedFolderId,
ensureAssetUrl,
getDocumentAsset,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
onDocumentClick,
onDocumentActivate,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
onDocumentTagDragLeave,
onDocumentTagDrop,
onDocumentRename,
tagLookupById,
onTagClick,
onCorrespondentClick,
activeCorrespondentIdSet,
scrollRef,
}) => {
const {
selectedDocumentIds,
selectedFolderIds,
clearSelection,
} = useWorkspaceSelectionContext();
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
const selectedFolderIdsSet = useMemo(
() => new Set(selectedFolderIds || []),
[selectedFolderIds],
);
const {
editingId: editingDocumentId,
draftValue: documentDraft,
setDraftValue: setDocumentDraft,
beginEditing: beginDocumentEditing,
cancelEditing: cancelDocumentEditing,
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
} = useInlineRename<Document>(onDocumentRename, {
getCurrentValue: (doc: Document) => doc?.title ?? '',
getEntityId: (doc: Document) => doc?.id ?? null,
});
const {
editingId: editingFolderId,
draftValue: folderDraft,
setDraftValue: setFolderDraft,
beginEditing: beginFolderEditing,
cancelEditing: cancelFolderEditing,
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
} = useInlineRename<Folder>(onFolderRename, {
getCurrentValue: (folder: Folder) => folder?.name ?? '',
getEntityId: (folder: Folder) => folder?.id ?? null,
});
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
return (
<table aria-multiselectable="true">
<thead
onClick={() => {
clearSelection();
}}
>
<tr>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</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 canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
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 (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${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 className="doc-name__title">
<span className="doc-name__primary">
{isFolderEditing ? (
<span className="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>
</span>
) : (
<span
className="doc-name__primary-text"
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>
)}
</span>
</span>
</div>
</td>
<td></td>
<td></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 correspondents = resolveCorrespondents(doc);
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;
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<tr
key={entry.key}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={onDocumentTagDragOver}
onDragLeave={onDocumentTagDragLeave}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
const payload = parseTagTransferPayload(event);
if (payload && onDocumentTagDrop) {
onDocumentTagDrop(doc.id, payload);
}
}}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
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">
{isEditingDoc ? (
<span className="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>
</span>
) : (
<span
className="doc-name__primary-text"
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>
)}
</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
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={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
</td>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</tr>
);
})}
</tbody>
</table>
);
};
export default DocumentsList;
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic';
import DocumentsListRow from './components/DocumentsListRow';
import DocumentsGridCard from './components/DocumentsGridCard';
import DocumentsListContainer from './components/DocumentsListContainer';
import DocumentsGridContainer from './components/DocumentsGridContainer';
import type { DocumentsViewProps } from './panel/DocumentsPanel';
interface AbstractDocumentsViewProps<CProps extends { clearSelection: () => void; children: React.ReactNode }> extends DocumentsViewProps {
ContainerComponent: React.ComponentType<CProps>;
ItemComponent: React.ComponentType<{ entry: any; viewLogic: DocumentViewLogic } & DocumentsViewProps>;
containerProps?: Omit<CProps, 'children' | 'clearSelection'>;
[key: string]: any;
}
const AbstractDocumentsView = <CProps extends { clearSelection: () => void; children: React.ReactNode }>({
ContainerComponent,
ItemComponent,
containerProps,
...props
}: AbstractDocumentsViewProps<CProps>) => {
const { entries, onDocumentRename, onFolderRename } = props;
const viewLogic = useDocumentViewLogic({
onDocumentRename,
onFolderRename,
});
const { clearSelection } = viewLogic;
return (
<ContainerComponent clearSelection={clearSelection} {...(containerProps as any)}>
{entries.map((entry) => (
<ItemComponent
key={entry.key}
entry={entry}
viewLogic={viewLogic}
{...props}
/>
))}
</ContainerComponent>
);
};
export const DocumentsList: React.FC<DocumentsViewProps> = (props) => {
return (
<AbstractDocumentsView
ContainerComponent={DocumentsListContainer}
ItemComponent={DocumentsListRow}
{...props}
/>
);
};
export const DocumentsGrid: React.FC<DocumentsViewProps & { gridIconSize?: number }> = (props) => {
return (
<AbstractDocumentsView
ContainerComponent={DocumentsGridContainer}
ItemComponent={DocumentsGridCard}
containerProps={{ gridIconSize: props.gridIconSize }}
{...props}
/>
);
};
@@ -0,0 +1,37 @@
import React from 'react';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import { useDocumentItemLogic } from '../hooks/useDocumentItemLogic';
import EntryShell from './EntryShell';
interface DocumentEntryProps extends DocumentsViewProps {
doc: any;
viewLogic: DocumentViewLogic;
component: React.ElementType;
className?: string;
role?: string;
children: (logic: ReturnType<typeof useDocumentItemLogic>) => React.ReactNode;
}
const DocumentEntry: React.FC<DocumentEntryProps> = (props) => {
const { doc, component, className, role, children, viewLogic } = props;
const logic = useDocumentItemLogic({ doc, viewLogic, ...props });
return (
<EntryShell
component={component}
id={`document-${doc.id}`}
docId={doc.id}
handlers={logic.handlers}
isSelected={logic.isSelected}
isDragging={logic.isDraggingDoc}
canDrag={true}
className={className}
role={role}
>
{children(logic)}
</EntryShell>
);
};
export default DocumentEntry;
@@ -0,0 +1,85 @@
import React from 'react';
import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData } from '../tagTransfer';
import type { DocumentTag } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
interface DocumentTagsProps {
tags: DocumentTag[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
maxTags?: number;
}
const DocumentTags: React.FC<DocumentTagsProps> = ({
tags,
tagLookupById,
onTagClick,
docId,
maxTags,
}) => {
const visibleTags = maxTags ? tags.slice(0, maxTags) : tags;
const remainingTagCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
if (tags.length === 0) {
return null;
}
return (
<>
{visibleTags.map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${docId}-tag-${index}`;
return (
<span
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
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, docId);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</>
);
};
export default DocumentTags;
@@ -0,0 +1,129 @@
import React from 'react';
import { FolderIcon } from '../../ui/icons';
import DocumentThumbnailImage from '../DocumentThumbnailImage';
import { resolveCorrespondents } from '../correspondents';
import type { DocumentsListEntry } from '../../types/documents';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import EditableEntryTitle from './EditableEntryTitle';
import EntryCorrespondents from './EntryCorrespondents';
import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
interface DocumentsGridCardProps extends DocumentsViewProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
gridIconSize?: number;
}
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
const { entry, gridIconSize } = props;
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) return null;
return (
<FolderEntry
{...props}
folder={folder}
component="div"
className="document-card folder-card"
role="listitem"
>
{(logic) => (
<>
<div className="folder-card__icon">
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
</div>
<div className="folder-card__meta">
<div className="folder-card__label-row">
<EditableEntryTitle
isEditing={logic.isFolderEditing}
draftValue={logic.folderDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isFolderSaving}
canSubmit={logic.canSubmitFolder}
inputRef={logic.attachFolderInputRef}
allowInlineEdit={logic.allowInlineFolderEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="folder-card__name"
>
{folder.name}
</EditableEntryTitle>
</div>
</div>
</>
)}
</FolderEntry>
);
}
const doc = entry.document;
if (!doc) return null;
const correspondents = resolveCorrespondents(doc);
return (
<DocumentEntry
{...props}
doc={doc}
component="div"
className="document-card document"
role="listitem"
>
{(logic) => (
<>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={props.ensureAssetUrl}
getDocumentAsset={props.getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize}
scrollRootRef={props.scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={doc.title}>
<EntryCorrespondents
correspondents={correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
<div className="document-card__title-row">
<EditableEntryTitle
isEditing={logic.isEditingDoc}
draftValue={logic.documentDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isDocumentSaving}
canSubmit={logic.canSubmitDocument}
inputRef={logic.attachDocumentInputRef}
allowInlineEdit={logic.allowInlineDocumentEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="document-card__title-badge"
>
{doc.title}
</EditableEntryTitle>
</div>
</div>
<div className="document-card__tags">
<EntryTags
tags={doc.tags || []}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={doc.id}
maxTags={3}
/>
</div>
</div>
</>
)}
</DocumentEntry>
);
};
export default DocumentsGridCard;
@@ -0,0 +1,34 @@
import React from 'react';
interface DocumentsGridContainerProps {
children: React.ReactNode;
clearSelection: () => void;
gridIconSize?: number;
}
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
children,
clearSelection,
gridIconSize,
}) => {
return (
<div
className="documents-grid"
role="list"
style={
gridIconSize
? ({ '--documents-grid-icon-size': `${gridIconSize}px` } as React.CSSProperties)
: undefined
}
onClick={(event) => {
if (event.target === event.currentTarget) {
clearSelection();
}
}}
>
{children}
</div>
);
};
export default DocumentsGridContainer;
@@ -0,0 +1,31 @@
import React from 'react';
interface DocumentsListContainerProps {
children: React.ReactNode;
clearSelection: () => void;
}
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
children,
clearSelection,
}) => {
return (
<table aria-multiselectable="true">
<thead
onClick={() => {
clearSelection();
}}
>
<tr>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
);
};
export default DocumentsListContainer;
@@ -0,0 +1,143 @@
import React from 'react';
import { FolderIcon } from '../../ui/icons';
import { formatDate } from '../../utils/date';
import DocumentThumbnailImage from '../DocumentThumbnailImage';
import { resolveCorrespondents } from '../correspondents';
import type { DocumentsListEntry } from '../../types/documents';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import EditableEntryTitle from './EditableEntryTitle';
import EntryCorrespondents from './EntryCorrespondents';
import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
interface DocumentsListRowProps extends DocumentsViewProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
}
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
const { entry } = props;
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) return null;
return (
<FolderEntry
{...props}
folder={folder}
component="tr"
className="folder"
>
{(logic) => (
<>
<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 className="doc-name__title">
<span className="doc-name__primary">
<EditableEntryTitle
isEditing={logic.isFolderEditing}
draftValue={logic.folderDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isFolderSaving}
canSubmit={logic.canSubmitFolder}
inputRef={logic.attachFolderInputRef}
allowInlineEdit={logic.allowInlineFolderEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="doc-name__primary-text"
>
{folder.name}
</EditableEntryTitle>
</span>
</span>
</div>
</td>
<td></td>
<td></td>
</>
)}
</FolderEntry>
);
}
const doc = entry.document;
if (!doc) return null;
const correspondents = resolveCorrespondents(doc);
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<DocumentEntry
{...props}
doc={doc}
component="tr"
className="document"
>
{(logic) => (
<>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={props.ensureAssetUrl}
getDocumentAsset={props.getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
scrollRootRef={props.scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
<EntryCorrespondents
correspondents={correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
<span className="doc-name__primary">
<EditableEntryTitle
isEditing={logic.isEditingDoc}
draftValue={logic.documentDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isDocumentSaving}
canSubmit={logic.canSubmitDocument}
inputRef={logic.attachDocumentInputRef}
allowInlineEdit={logic.allowInlineDocumentEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="doc-name__primary-text"
>
{doc.title}
</EditableEntryTitle>
</span>
</span>
</div>
<div className="doc-name__tags">
<EntryTags
tags={doc.tags || []}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={doc.id}
/>
</div>
</div>
</td>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</>
)}
</DocumentEntry>
);
};
export default DocumentsListRow;
@@ -0,0 +1,67 @@
import React from 'react';
import InlineRenameInput from './InlineRenameInput';
interface EditableEntryTitleProps {
isEditing: boolean;
draftValue: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: (event?: React.SyntheticEvent) => void;
isSaving: boolean;
canSubmit: boolean;
inputRef: (ref: HTMLInputElement | null) => void;
allowInlineEdit: boolean | undefined;
onBeginEditing: (event: React.SyntheticEvent) => void;
children: React.ReactNode;
className?: string;
}
const EditableEntryTitle: React.FC<EditableEntryTitleProps> = ({
isEditing,
draftValue,
onChange,
onSubmit,
onCancel,
isSaving,
canSubmit,
inputRef,
allowInlineEdit,
onBeginEditing,
children,
className,
}) => {
if (isEditing) {
return (
<div className={`doc-title-edit ${className || ''}`}>
<InlineRenameInput
value={draftValue}
onChange={onChange}
onSubmit={onSubmit}
onCancel={onCancel}
isSaving={isSaving}
canSubmit={canSubmit}
inputRef={inputRef}
/>
</div>
);
}
return (
<span
className={className}
role={allowInlineEdit ? 'button' : undefined}
tabIndex={allowInlineEdit ? 0 : undefined}
onClick={onBeginEditing}
onKeyDown={(event) => {
if (!allowInlineEdit) return;
if (event.key === 'Enter') {
onBeginEditing(event);
}
}}
>
{children}
</span>
);
};
export default EditableEntryTitle;
@@ -0,0 +1,27 @@
import React from 'react';
import CorrespondentLinks from '../CorrespondentLinks';
import type { Identifier } from '../../types/identifiers';
interface EntryCorrespondentsProps {
correspondents: any[];
activeCorrespondentIdSet?: Set<Identifier> | null;
onCorrespondentClick?: (correspondentId: Identifier) => void;
}
const EntryCorrespondents: React.FC<EntryCorrespondentsProps> = (props) => {
if (!props.correspondents || props.correspondents.length === 0) {
return null;
}
return (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={props.correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
</span>
);
};
export default EntryCorrespondents;
@@ -0,0 +1,66 @@
import React, { type DragEvent } from 'react';
export interface EntryShellHandlers {
onClick: (event: React.MouseEvent) => void;
onDoubleClick: (event: React.MouseEvent) => void;
onDragStart: (event: DragEvent<HTMLElement>) => void;
onDragEnd: (event: DragEvent<HTMLElement>) => void;
onDragOver: (event: DragEvent<HTMLElement>) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
onDrop: (event: DragEvent<HTMLElement>) => void;
onDragOverCapture?: (event: DragEvent<HTMLElement>) => void;
onDragLeaveCapture?: (event: DragEvent<HTMLElement>) => void;
}
interface EntryShellProps {
component: React.ElementType;
handlers: EntryShellHandlers;
isSelected?: boolean;
isDragging?: boolean;
canDrag?: boolean;
id: string;
className?: string;
children: React.ReactNode;
docId?: number;
role?: string;
}
const EntryShell: React.FC<EntryShellProps> = ({
component: Component,
handlers,
isSelected,
isDragging,
canDrag,
id,
className = '',
children,
docId,
role,
}) => {
const classes = [className];
if (isSelected) classes.push('selected');
if (isDragging) classes.push('is-dragging');
const commonProps = {
id,
className: classes.join(' '),
onClick: handlers.onClick,
onDoubleClick: handlers.onDoubleClick,
draggable: canDrag,
onDragStart: handlers.onDragStart,
onDragEnd: handlers.onDragEnd,
onDragOver: handlers.onDragOver,
onDragLeave: handlers.onDragLeave,
onDrop: handlers.onDrop,
...(docId ? { 'data-doc-id': docId } : {}),
...(role ? { role } : {}),
};
return (
<Component {...commonProps}>
{children}
</Component>
);
};
export default EntryShell;
@@ -0,0 +1,30 @@
import React from 'react';
import DocumentTags from './DocumentTags';
import type { Identifier } from '../../types/identifiers';
import type { DocumentTag } from '../../types/documents';
interface EntryTagsProps {
tags: Identifier[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
maxTags?: number;
}
const EntryTags: React.FC<EntryTagsProps> = (props) => {
if (!props.tags || props.tags.length === 0) {
return null;
}
return (
<DocumentTags
tags={props.tags}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={props.docId}
maxTags={props.maxTags}
/>
);
};
export default EntryTags;
@@ -0,0 +1,36 @@
import React from 'react';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import { useFolderItemLogic } from '../hooks/useFolderItemLogic';
import EntryShell from './EntryShell';
interface FolderEntryProps extends DocumentsViewProps {
folder: any;
viewLogic: DocumentViewLogic;
component: React.ElementType;
className?: string;
role?: string;
children: (logic: ReturnType<typeof useFolderItemLogic>) => React.ReactNode;
}
const FolderEntry: React.FC<FolderEntryProps> = (props) => {
const { folder, component, className, role, children, viewLogic } = props;
const logic = useFolderItemLogic({ folder, viewLogic, ...props });
return (
<EntryShell
component={component}
id={`folder-${folder.id}`}
handlers={logic.handlers}
isSelected={logic.isSelectedFolder}
isDragging={logic.isDraggingFolder}
canDrag={logic.canDragFolder}
className={className}
role={role}
>
{children(logic)}
</EntryShell>
);
};
export default FolderEntry;
@@ -0,0 +1,77 @@
import React from 'react';
import { CheckIcon, CloseIcon } from '../../ui/icons';
interface InlineRenameInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: (event?: React.SyntheticEvent) => void;
isSaving?: boolean;
canSubmit?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
className?: string;
}
const InlineRenameInput: React.FC<InlineRenameInputProps> = ({
value,
onChange,
onSubmit,
onCancel,
isSaving = false,
canSubmit = true,
inputRef,
className = 'doc-title-edit',
}) => {
return (
<span className={className}>
<input
type="text"
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
} else if (event.key === 'Escape') {
event.preventDefault();
onCancel(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
onCancel();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save"
title="Save"
disabled={!canSubmit || isSaving}
onClick={(event) => {
event.stopPropagation();
onSubmit();
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
onCancel(event);
}}
>
<CloseIcon />
</button>
</span>
);
};
export default InlineRenameInput;
@@ -0,0 +1,89 @@
import React, { type DragEvent } from 'react';
import { parseTagTransferPayload } from '../tagTransfer';
import type { Document } from '../../types/documents';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from './useDocumentViewLogic';
interface UseDocumentItemLogicProps extends DocumentsViewProps {
doc: Document;
viewLogic: DocumentViewLogic;
}
export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
const {
doc,
viewLogic,
draggingDocumentIdsSet,
onDocumentClick,
onDocumentActivate,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
onDocumentTagDragLeave,
onDocumentTagDrop,
onDocumentRename,
} = props;
const {
selectedDocumentIdsSet,
totalSelectionCount,
documentRename: {
editingId: editingDocumentId,
draftValue: documentDraft,
setDraftValue: setDocumentDraft,
beginEditing: beginDocumentEditing,
cancelEditing: cancelDocumentEditing,
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
},
} = viewLogic;
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
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;
const handlers = {
onClick: (event: React.MouseEvent) => onDocumentClick?.(doc, event),
onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event),
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event),
onDragLeave: onDocumentTagDragLeave,
onDrop: (event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
const payload = parseTagTransferPayload(event);
if (payload && onDocumentTagDrop) {
onDocumentTagDrop(doc.id, payload);
}
},
onRenameChange: setDocumentDraft,
onRenameSubmit: () => submitDocumentEditing(doc),
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
onRenameBegin: (event: React.SyntheticEvent) => {
if (!allowInlineDocumentEdit) return;
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
},
};
return {
isSelected,
isDraggingDoc,
isEditingDoc,
documentDraftValue,
isDocumentSaving,
canSubmitDocument,
allowInlineDocumentEdit,
attachDocumentInputRef,
handlers,
};
};
@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import useInlineRename from '../useInlineRename';
import type { Document, Folder } from '../../types/documents';
interface UseDocumentViewLogicProps {
onDocumentRename?: (id: string, name: string) => Promise<boolean> | boolean;
onFolderRename?: (id: string, name: string) => Promise<boolean> | boolean;
}
export const useDocumentViewLogic = ({
onDocumentRename,
onFolderRename,
}: UseDocumentViewLogicProps) => {
const {
selectedDocumentIds,
selectedFolderIds,
clearSelection,
} = useWorkspaceSelectionContext();
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
const selectedFolderIdsSet = useMemo(
() => new Set(selectedFolderIds || []),
[selectedFolderIds],
);
const documentSelectionCount = selectedDocumentIdsSet.size;
const folderSelectionCount = selectedFolderIdsSet.size;
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
const documentRename = useInlineRename<Document>(onDocumentRename, {
getCurrentValue: (doc: Document) => doc?.title ?? '',
getEntityId: (doc: Document) => doc?.id ?? null,
});
const folderRename = useInlineRename<Folder>(onFolderRename, {
getCurrentValue: (folder: Folder) => folder?.name ?? '',
getEntityId: (folder: Folder) => folder?.id ?? null,
});
return {
selectedDocumentIdsSet,
selectedFolderIdsSet,
clearSelection,
totalSelectionCount,
documentRename,
folderRename,
};
};
export type DocumentViewLogic = ReturnType<typeof useDocumentViewLogic>;
@@ -0,0 +1,94 @@
import React, { type DragEvent } from 'react';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from './useDocumentViewLogic';
interface UseFolderItemLogicProps extends DocumentsViewProps {
folder: any;
viewLogic: DocumentViewLogic;
}
export const useFolderItemLogic = (props: UseFolderItemLogicProps) => {
const {
folder,
viewLogic,
draggedFolderId,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
} = props;
const {
selectedFolderIdsSet,
totalSelectionCount,
folderRename: {
editingId: editingFolderId,
draftValue: folderDraft,
setDraftValue: setFolderDraft,
beginEditing: beginFolderEditing,
cancelEditing: cancelFolderEditing,
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
},
} = viewLogic;
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
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;
const handlers = {
onClick: (event: React.MouseEvent) => onFolderClick?.(folder, event),
onDoubleClick: (event: React.MouseEvent) => {
event.preventDefault();
onFolderSelect?.(folder.id);
},
onDragOver: (event: DragEvent<HTMLElement>) => onFolderDragOver?.(event, folder.id),
onDragLeave: onFolderDragLeave,
onDrop: (event: DragEvent<HTMLElement>) => onFolderDrop?.(event, folder.id),
onDragStart: (event: DragEvent<HTMLElement>) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
},
onDragEnd: (event: DragEvent<HTMLElement>) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
},
onRenameChange: setFolderDraft,
onRenameSubmit: () => submitFolderEditing(folder),
onRenameCancel: (event?: React.SyntheticEvent) => cancelFolderEditing(event),
onRenameBegin: (event: React.SyntheticEvent) => {
if (!allowInlineFolderEdit) return;
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
},
};
return {
canDragFolder,
isDraggingFolder,
isSelectedFolder,
isFolderEditing,
folderDraftValue,
isFolderSaving,
canSubmitFolder,
allowInlineFolderEdit,
attachFolderInputRef,
handlers,
};
};
+11 -16
View File
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import DocumentsGrid from '../DocumentsGrid';
import DocumentsList from '../DocumentsList';
import { DocumentsList, DocumentsGrid } from '../DocumentsView';
import type { DragEvent, ReactNode, RefObject } from 'react';
import type {
DocumentsListEntry,
@@ -653,9 +652,9 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
if (!container) return;
let selector = null;
if (focusedEntryKey.startsWith('document:')) {
selector = `#document-row-${focusedEntryKey.slice('document:'.length)}`;
selector = `#document-${focusedEntryKey.slice('document:'.length)}`;
} else if (focusedEntryKey.startsWith('folder:')) {
selector = `#folder-row-${focusedEntryKey.slice('folder:'.length)}`;
selector = `#folder-${focusedEntryKey.slice('folder:'.length)}`;
}
if (!selector) {
return;
@@ -690,10 +689,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
const activeDescendantId = useMemo(() => {
if (!focusedEntryKey) return undefined;
if (focusedEntryKey.startsWith('document:')) {
return `document-row-${focusedEntryKey.slice('document:'.length)}`;
return `document-${focusedEntryKey.slice('document:'.length)}`;
}
if (focusedEntryKey.startsWith('folder:')) {
return `folder-row-${focusedEntryKey.slice('folder:'.length)}`;
return `folder-${focusedEntryKey.slice('folder:'.length)}`;
}
return undefined;
}, [focusedEntryKey]);
@@ -785,7 +784,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
[entries],
);
const viewProps: DocumentsViewProps = {
const viewProps = {
entries,
draggingDocumentIdsSet: draggingSet,
draggedFolderId,
@@ -796,6 +795,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
onDocumentClick: handleDocumentClick,
onDocumentActivate: handleDocumentActivate,
onDocumentDragStart: handleDocumentDragStartLocal,
@@ -804,22 +804,17 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onDocumentTagDragLeave: handleDocumentTagDragLeave,
onDocumentTagDrop,
onDocumentRename,
onFolderRename,
ensureAssetUrl,
getDocumentAsset,
tagLookupById,
onTagClick: toggleTagFilter,
onCorrespondentClick: toggleCorrespondentFilter,
activeCorrespondentIdSet: activeCorrespondentIdSet,
activeTagFilters,
scrollRef,
// Desk specific
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
activeCorrespondentIdSet: activeCorrespondentIdSet,
onCorrespondentClick: toggleCorrespondentFilter,
tenantId: currentTenantId,
viewId: deskViewId,
documentLinks: documentLinkMap,
ensureDownloadUrl,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
};
const renderBody = () => {
@@ -39,7 +39,7 @@ const useDocumentDragHandlers = ({
documentLookup,
setDraggedDocumentIds,
setDraggedFolderId,
documentsViewMode,
documentsViewMode: _documentsViewMode,
}: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const normalizedFolderIds = useMemo(
@@ -100,8 +100,7 @@ const useDocumentDragHandlers = ({
if (item.type === 'document') {
const doc = item.payload;
const rowEl = doc?.id
? (document.getElementById(`document-row-${doc.id}`)
|| document.getElementById(`document-card-${doc.id}`))
? document.getElementById(`document-${doc.id}`)
: null;
const wrapperEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
@@ -144,8 +143,7 @@ const useDocumentDragHandlers = ({
const payload = item.payload;
const folderId = payload as FolderIdentifier;
const rowEl = folderId
? (document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`))
? document.getElementById(`folder-${folderId}`)
: null;
const iconEl = rowEl instanceof HTMLElement
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
@@ -211,16 +209,13 @@ const useDocumentDragHandlers = ({
return;
}
const isGridView = documentsViewMode === 'grid';
const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection: Identifier[] = isAlreadySelected
? [...selectedDocumentIds]
: isGridView
? [...selectedDocumentIds, documentId]
: [documentId];
: [documentId];
const folderSelection: FolderIdentifier[] = [];
if (!isAlreadySelected && !isGridView) {
if (!isAlreadySelected) {
applySelection([documentKey], {
anchor: documentKey,
interactedKeys: [documentKey],
@@ -271,7 +266,6 @@ const useDocumentDragHandlers = ({
createDragPreview,
setDraggedFolderId,
setDraggedDocumentIds,
documentsViewMode,
],
);