This commit is contained in:
2025-11-12 02:24:46 +01:00
parent 44c982f015
commit 32ef3ced59
12 changed files with 700 additions and 31 deletions
+254 -1
View File
@@ -26,6 +26,77 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
import { useSidebarContext } from '../sidebar/SidebarContext';
const DETAIL_PANEL_WIDTH_STORAGE_KEY = 'detailPanelWidth';
const MIN_DETAIL_PANEL_WIDTH = 320;
const MAX_DETAIL_PANEL_WIDTH = 960;
const isBrowser = typeof window !== 'undefined';
const isDocumentAvailable = typeof document !== 'undefined';
const getDetailPanelBounds = () => {
if (!isBrowser) {
return {
min: MIN_DETAIL_PANEL_WIDTH,
max: MAX_DETAIL_PANEL_WIDTH,
};
}
const viewportWidth = Math.max(window.innerWidth, 1);
const minFractionWidth = viewportWidth / 5;
const maxFractionWidth = viewportWidth * 0.75;
const rawMin = Math.max(MIN_DETAIL_PANEL_WIDTH, minFractionWidth);
const rawMax = Math.min(MAX_DETAIL_PANEL_WIDTH, maxFractionWidth);
if (rawMin >= rawMax) {
const fallback = Math.min(Math.max(rawMin, viewportWidth * 0.5), MAX_DETAIL_PANEL_WIDTH);
return { min: fallback, max: fallback };
}
return {
min: rawMin,
max: rawMax,
};
};
const clampDetailPanelWidth = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const { min, max } = getDetailPanelBounds();
return Math.min(Math.max(value, min), max);
};
const loadStoredDetailPanelWidth = () => {
if (!isBrowser) {
return null;
}
try {
const raw = window.localStorage?.getItem(DETAIL_PANEL_WIDTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch (error) {
console.warn('Failed to read detail panel width', error);
return null;
}
};
const applyDetailPanelWidth = (width) => {
if (!isDocumentAvailable || width == null) {
return;
}
document.documentElement.style.setProperty('--detail-panel-width', `${width}px`);
};
const getSidebarWidthFromRoot = () => {
if (!isBrowser || !isDocumentAvailable) {
return null;
}
const computed = window.getComputedStyle(document.documentElement);
const parsed = parseFloat(computed.getPropertyValue('--sidebar-width'));
return Number.isFinite(parsed) ? parsed : null;
};
export const createDocumentViewerHeaderActions = ({
document,
@@ -99,6 +170,7 @@ const DocumentViewerPanel = ({
}) => {
const navigate = useNavigate();
const isSidebarVariant = variant === 'sidebar';
const { setSidebarSuppressed } = useSidebarContext();
const sortedCorrespondents = useMemo(
() => sortCorrespondents(document?.correspondents || []),
[document],
@@ -239,6 +311,174 @@ const DocumentViewerPanel = ({
const panelRef = useRef(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const pendingWidthRef = useRef(null);
const [isResizingPanel, setIsResizingPanel] = useState(false);
const [detailPanelWidth, setDetailPanelWidth] = useState(() => {
if (!isBrowser) {
return null;
}
const stored = loadStoredDetailPanelWidth();
return stored != null ? clampDetailPanelWidth(stored) : null;
});
useEffect(() => {
if (detailPanelWidth != null) {
const clamped = clampDetailPanelWidth(detailPanelWidth);
if (clamped != null) {
applyDetailPanelWidth(clamped);
}
}
}, [detailPanelWidth]);
useEffect(() => {
if (!isSidebarVariant) {
setSidebarSuppressed(false);
return undefined;
}
const updateSuppression = () => {
if (!isBrowser) {
setSidebarSuppressed(false);
return;
}
const panelWidth = pendingWidthRef.current
?? detailPanelWidth
?? panelRef.current?.getBoundingClientRect().width;
const sidebarWidth = getSidebarWidthFromRoot();
if (!Number.isFinite(panelWidth)) {
setSidebarSuppressed(false);
return;
}
const minMainContentWidth = (window.innerWidth * 2) / 5;
const occupiedWidth = panelWidth + (Number.isFinite(sidebarWidth) ? sidebarWidth : 0);
const availableWidth = window.innerWidth - occupiedWidth;
setSidebarSuppressed(availableWidth < minMainContentWidth);
};
updateSuppression();
const handleWindowResize = () => updateSuppression();
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, [isSidebarVariant, detailPanelWidth, setSidebarSuppressed]);
useEffect(() => {
if (!isSidebarVariant) {
setSidebarSuppressed(false);
}
}, [isSidebarVariant, setSidebarSuppressed]);
useEffect(() => {
if (!isBrowser) {
return undefined;
}
const handleResize = () => {
setDetailPanelWidth((prev) => {
if (prev == null) {
return prev;
}
const clamped = clampDetailPanelWidth(prev);
if (clamped != null && clamped !== prev) {
applyDetailPanelWidth(clamped);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(clamped)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
return clamped;
}
return prev;
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleResizePointerDown = useCallback((event) => {
if (!isSidebarVariant || !panelRef.current || !isBrowser) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const target = event.currentTarget;
target.setPointerCapture?.(pointerId);
setIsResizingPanel(true);
const rect = panelRef.current.getBoundingClientRect();
const startWidth = rect.width;
const startX = event.clientX;
const updateWidth = (nextWidth) => {
const clamped = clampDetailPanelWidth(nextWidth);
if (clamped != null) {
pendingWidthRef.current = clamped;
applyDetailPanelWidth(clamped);
if (isSidebarVariant && isBrowser) {
setSidebarSuppressed(clamped > window.innerWidth / 2);
}
}
};
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = startX - moveEvent.clientX;
updateWidth(startWidth + delta);
};
const handlePointerUp = (upEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
target.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setIsResizingPanel(false);
if (pendingWidthRef.current != null) {
const finalizedWidth = pendingWidthRef.current;
pendingWidthRef.current = null;
setDetailPanelWidth(finalizedWidth);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}, [isSidebarVariant, setSidebarSuppressed]);
const handleResizeKeyDown = useCallback((event) => {
if (!isSidebarVariant || !isBrowser) {
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
const baseWidth = pendingWidthRef.current
?? detailPanelWidth
?? panelRef.current?.getBoundingClientRect().width;
if (!Number.isFinite(baseWidth)) {
return;
}
event.preventDefault();
const step = event.shiftKey ? 40 : 20;
const delta = event.key === 'ArrowLeft' ? step : -step;
const nextWidth = clampDetailPanelWidth(baseWidth + delta);
if (nextWidth == null) {
return;
}
setDetailPanelWidth(nextWidth);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(nextWidth)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
}, [detailPanelWidth, isSidebarVariant]);
const viewerClassName = isStackedLayout
? 'document-viewer document-viewer--stacked'
@@ -403,6 +643,18 @@ const DocumentViewerPanel = ({
)
: null;
const resizeHandle = isSidebarVariant ? (
<button
type="button"
className={`detail-panel__resize-handle${isResizingPanel ? ' is-active' : ''}`}
aria-label="Resize detail panel"
onPointerDown={handleResizePointerDown}
onKeyDown={handleResizeKeyDown}
>
<span aria-hidden="true" />
</button>
) : null;
const loadingSection = (
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--loading">
@@ -460,7 +712,8 @@ const DocumentViewerPanel = ({
if (isSidebarVariant) {
return (
<>
<aside className="detail-panel panel" ref={panelRef}>
<aside className={`detail-panel panel${isResizingPanel ? ' detail-panel--resizing' : ''}`} ref={panelRef}>
{resizeHandle}
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}