document viewer

This commit is contained in:
2025-11-12 02:24:46 +01:00
parent 762e4ee315
commit 44c982f015
7 changed files with 546 additions and 192 deletions
+147 -62
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
summaryPlacement = 'inline',
summaryTabLabel = 'Summary',
summaryTabId = 'summary',
leadingTabs = [],
trailingTabs = [],
tabsPlacement = 'top',
summaryLayout = 'default',
}) => {
const base = classNamePrefix;
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
detailItems={metadataItems}
layout={summaryLayout}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, metadataItems]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
), [base, metadataItems]);
const summaryInline = summaryPlacement !== 'tabs';
const summaryTab = useMemo(() => {
if (summaryPlacement !== 'tabs') {
return null;
}
return {
id: summaryTabId,
label: summaryTabLabel,
render: () => (
<div className={`${base}__summary-tab-content`}>
{renderSummarySection()}
</div>
),
};
}, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]);
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
: []),
[trailingTabs],
);
const summaryNode = summaryInline
? (
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
: null;
const visibleTabs = useMemo(() => {
const tabsList = [];
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
),
});
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
}
if (summaryTab) {
tabsList.push(summaryTab);
}
if (summaryPlacement !== 'tabs') {
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => renderDetailsSection(),
});
}
if (showContentTab && contentConfig) {
tabsList.push({
@@ -183,30 +251,38 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
if (normalizedTrailingTabs.length) {
tabsList.push(...normalizedTrailingTabs);
}
return tabsList;
}, [
base,
detailsTabLabel,
metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
summaryTab,
normalizedLeadingTabs,
normalizedTrailingTabs,
summaryPlacement,
renderDetailsSection,
]);
const fallbackTabId = useMemo(() => {
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, resetKey, isControlled]);
}, [fallbackTabId, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
const tabNav = (
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
const tabPanels = (
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
);
const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
return (
<>
<DocumentSummarySection
document={document}
{...summaryProps}
/>
{summaryNode}
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
</div>
</div>
) : (
<div className={`${base}__tabs-wrapper`}>
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
<div className={tabsWrapperClass}>
{tabsPlacement !== 'bottom' ? tabNav : null}
{tabsPlacement === 'bottom' ? tabPanels : null}
{tabsPlacement === 'bottom' ? tabNav : null}
{tabsPlacement !== 'bottom' ? tabPanels : null}
</div>
)}
</>
+213 -89
View File
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued
onUpdateIssued,
layout = 'default',
detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
return null;
}
const TitleSection = () => (
editableTitle && isTitleEditing ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)
);
const issuedDisplay = editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const metaItems = [
{
key: 'issued',
label: 'Issued',
valueContent: issuedDisplay,
error: issuedError,
},
...metaRows.map((row) => ({
key: row.key,
label: row.label,
fallbackValue: row.value,
})),
];
const detailRows = Array.isArray(detailItems)
? detailItems.map((item, index) => ({
key: `detail-${item?.label || index}`,
label: item?.label || '—',
fallbackValue: item?.value,
}))
: [];
const compactRows = [...metaItems, ...detailRows];
const renderTags = () => (
<section className="document-summary__section document-summary__section--tags">
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
</section>
);
const renderCorrespondents = () => (
<section className="document-summary__section document-summary__section--correspondents">
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
</section>
);
if (isCompactLayout) {
return (
<div className="document-summary document-summary--compact">
<section className="document-summary__section document-summary__title-row">
<TitleSection />
</section>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
{renderTags()}
{renderCorrespondents()}
{compactRows.length ? (
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
<dl className="document-summary__details-list document-summary__details-list--meta">
{compactRows.map((item) => (
<div key={item.key} className="document-summary__details-row">
<dt>{item.label}</dt>
<dd>
{item.valueContent != null && item.valueContent !== ''
? item.valueContent
: item.fallbackValue || '—'}
</dd>
{item.error ? <div className="status-inline error">{item.error}</div> : null}
</div>
))}
</dl>
</section>
) : null}
</div>
);
}
return (
<div className="document-summary">
<div className="doc-title-row">
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta">
<div className="detail-meta__row">
<span className="detail-meta__label">Issued:</span>
{editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
{issuedDisplay}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
))}
</div>
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
/>
{renderTags()}
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
/>
{renderCorrespondents()}
</div>
);
};
+61 -23
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
@@ -13,7 +13,10 @@ const DocumentViewerLayout = ({
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
layoutMode = 'split',
}) => {
const isStacked = layoutMode === 'stacked';
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
@@ -74,30 +77,65 @@ const DocumentViewerLayout = ({
);
}, [previewEntry, document]);
const renderViewportPane = useCallback(() => (
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
), [previewEntry?.url, previewLoadingMessage, previewContent]);
const viewportPane = renderViewportPane();
const stackedLeadingTabs = useMemo(() => (
isStacked
? [
{
id: 'preview',
label: 'Preview',
render: () => renderViewportPane(),
},
]
: []
), [isStacked, renderViewportPane]);
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
const summaryPlacement = 'tabs';
const tabsPlacement = isStacked ? 'bottom' : 'top';
const summaryLayout = 'compact';
const detailsPane = (
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={resolvedDefaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
summaryPlacement={summaryPlacement}
summaryLayout={summaryLayout}
leadingTabs={stackedLeadingTabs}
tabsPlacement={tabsPlacement}
{...infoPanelProps}
/>
</div>
</div>
);
if (isStacked) {
return detailsPane;
}
return (
<>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={defaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
{...infoPanelProps}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
{detailsPane}
{viewportPane}
</>
);
};
@@ -432,6 +432,7 @@ const DocumentViewerPanel = ({
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
layoutMode={isStackedLayout ? 'stacked' : 'split'}
/>
</section>
</div>
+1 -1
View File
@@ -100,7 +100,7 @@
font-size: 15px;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--detail-panel-width: 30em;
--detail-panel-width: calc(100vw / 3);
--documents-grid-title-size: 0.8rem;
}
+83 -2
View File
@@ -489,6 +489,88 @@
gap: 0.25rem;
}
.document-summary--compact {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.document-summary__title-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.document-summary__meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.document-summary__meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.document-summary__meta-label {
color: var(--muted);
}
.document-summary__meta-value {
color: var(--fg);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.document-summary__meta-value .detail-meta__value {
font-size: inherit;
color: inherit;
}
.document-summary__section {
padding: 0;
}
.tag-list.document-summary__tags,
.correspondent-list.document-summary__correspondents {
margin-top: 0.25rem;
}
.document-summary__details {
}
.document-summary__details-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.document-summary__details-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.document-summary__details-row dt {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
word-break: break-word;
}
.document-summary__details-row dd {
margin: 0;
font-size: 0.95rem;
word-break: break-word;
}
.detail-panel .doc-title-edit,
.document-summary .doc-title-edit {
display: flex;
@@ -773,7 +855,7 @@
.detail-panel dt {
font-weight: 600;
margin-top: 0.8rem;
margin-top: 0;
}
.detail-panel dd {
@@ -786,7 +868,6 @@
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
align-items: center;
}
+40 -15
View File
@@ -43,7 +43,7 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 1rem 1rem;
padding: 0.5rem 1rem 0.5rem;
overflow: auto;
}
@@ -53,7 +53,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
flex: 1;
min-height: 0;
order: -1;
}
@@ -189,6 +190,15 @@
min-height: 0;
}
.document-viewer__tabs-wrapper--bottom .document-viewer__tabpanes {
order: 1;
}
.document-viewer__tabs-wrapper--bottom .document-viewer__tabs {
order: 2;
margin-top: 0.75rem;
}
.document-viewer__section {
display: flex;
flex-direction: column;
@@ -268,28 +278,37 @@
font-size: 0.85rem;
line-height: 1.35;
overflow: auto;
word-break: break-word;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.document-viewer__tabs {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--outline-subtle);
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 999px;
background: var(--surface-overlay, var(--surface));
border: 1px solid var(--outline-subtle);
box-shadow: var(--panel-shadow-soft, none);
}
.document-viewer__tab {
appearance: none;
border: none;
background: transparent;
padding: 0.4rem 0.75rem;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color 120ms ease,
border-color 120ms ease;
background-color 120ms ease;
border-radius: 999px;
flex: 1;
text-align: center;
}
.document-viewer__tab:hover,
@@ -298,8 +317,8 @@
}
.document-viewer__tab.is-active {
color: var(--accent);
border-color: var(--accent);
color: var(--accent-strong, var(--accent));
background: var(--accent-soft);
}
.document-viewer__tabpanes {
@@ -308,6 +327,10 @@
display: flex;
}
.document-viewer--stacked .document-viewer__tabpanes {
overflow: auto;
}
.document-viewer__tabpanes--single {
flex: 1;
min-height: 0;
@@ -345,7 +368,7 @@
display: flex;
position: relative;
overflow: hidden;
align-items: flex-start;
align-items: stretch;
justify-content: flex-start;
max-height: 100%;
grid-area: viewport;
@@ -369,12 +392,11 @@
}
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
max-height: 100%;
height: 100%;
}
.document-viewer--stacked .document-viewer__object--image {
height: auto;
max-height: 100%;
}
.document-viewer__unsupported {
@@ -408,3 +430,6 @@
width: 1rem;
height: 1rem;
}
.document-viewer__summary-tab-content {
padding-top: 0.5rem;
}