This commit is contained in:
2025-11-12 15:06:04 +01:00
parent e66b530f24
commit d2e27c12a2
7 changed files with 406 additions and 576 deletions
+3 -1
View File
@@ -1,9 +1,11 @@
import React from 'react';
import Sidebar from '../sidebar/Sidebar';
import { useSidebarContext } from '../sidebar/SidebarContext';
import { usePanelManager } from './PanelManagerContext';
const DocumentsLayout = ({ sidebarProps, children }) => {
const { collapsed, sidebarSuppressed } = useSidebarContext();
const { collapsed } = useSidebarContext();
const { sidebarSuppressed } = usePanelManager();
const sidebarHidden = collapsed || sidebarSuppressed;
return (
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
+12 -103
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
@@ -6,6 +6,7 @@ import { useWorkspaceSurface } from './useWorkspaceSurface';
import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
const DocumentsRouteContent = () => {
const {
@@ -28,105 +29,11 @@ const DocumentsRouteContent = () => {
notifyApiError,
} = useAppShell();
const navigate = useNavigate();
const { collapsed: sidebarCollapsed, sidebarSuppressed, setCollapsed, setSidebarSuppressed } = useSidebarContext();
const getDetailPanelWidth = useCallback(() => {
if (typeof window === 'undefined') {
return null;
}
const panelEl = document.querySelector('.detail-panel');
if (panelEl) {
const rect = panelEl.getBoundingClientRect();
if (Number.isFinite(rect?.width)) {
return rect.width;
}
}
const rootStyles = window.getComputedStyle(document.documentElement);
const varValue = rootStyles.getPropertyValue('--detail-panel-width');
const parsed = parseFloat(varValue);
return Number.isFinite(parsed) ? parsed : null;
}, []);
const getSidebarWidth = useCallback(() => {
if (typeof window === 'undefined') {
return null;
}
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) {
const rect = sidebarEl.getBoundingClientRect();
if (Number.isFinite(rect?.width)) {
return rect.width;
}
}
const rootStyles = window.getComputedStyle(document.documentElement);
const varValue = rootStyles.getPropertyValue('--sidebar-width');
const parsed = parseFloat(varValue);
return Number.isFinite(parsed) ? parsed : null;
}, []);
const shouldCloseDetailPanelForSidebar = useCallback(() => {
if (typeof window === 'undefined') {
return false;
}
if (!detailPanelOpen && !previewDocumentId) {
return false;
}
const detailWidth = getDetailPanelWidth();
const sidebarWidth = getSidebarWidth();
if (!Number.isFinite(detailWidth) || !Number.isFinite(sidebarWidth)) {
return false;
}
return detailWidth + sidebarWidth > window.innerWidth * (2 / 3);
}, [detailPanelOpen, previewDocumentId, getDetailPanelWidth, getSidebarWidth]);
const closeAnyDetailPanel = useCallback(() => {
if (previewDocumentId && typeof closeDocumentPreview === 'function') {
closeDocumentPreview();
return true;
}
if (detailPanelOpen && typeof detailPanelProps?.onClose === 'function') {
detailPanelProps.onClose();
return true;
}
return false;
}, [previewDocumentId, closeDocumentPreview, detailPanelOpen, detailPanelProps]);
const sidebarWidthRef = useRef(null);
useEffect(() => {
sidebarWidthRef.current = getSidebarWidth();
}, [getSidebarWidth]);
const expandSidebar = useCallback(() => {
if (shouldCloseDetailPanelForSidebar()) {
closeAnyDetailPanel();
}
setSidebarSuppressed(false);
setCollapsed(false);
}, [setCollapsed, setSidebarSuppressed, shouldCloseDetailPanelForSidebar, closeAnyDetailPanel]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleSidebarResize = (event) => {
const nextWidth = Number.isFinite(event?.detail?.width)
? event.detail.width
: getSidebarWidth();
const prevWidth = sidebarWidthRef.current;
if (Number.isFinite(nextWidth)) {
sidebarWidthRef.current = nextWidth;
}
if (Number.isFinite(nextWidth) && Number.isFinite(prevWidth) && nextWidth <= prevWidth) {
return;
}
if (shouldCloseDetailPanelForSidebar()) {
closeAnyDetailPanel();
}
};
window.addEventListener('sidebar-width-change', handleSidebarResize);
return () => window.removeEventListener('sidebar-width-change', handleSidebarResize);
}, [shouldCloseDetailPanelForSidebar, closeAnyDetailPanel, getSidebarWidth]);
const { collapsed: sidebarCollapsed } = useSidebarContext();
const {
sidebarSuppressed,
expandSidebar,
} = usePanelManager();
const sidebarPropsWithActions = useMemo(
() => ({
@@ -137,6 +44,8 @@ const DocumentsRouteContent = () => {
[sidebarProps, openTagsModal, openCorrespondentsModal],
);
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
@@ -163,8 +72,6 @@ const DocumentsRouteContent = () => {
navigate(target);
}, [navigate]);
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const { surface } = useWorkspaceSurface({
sidebarHidden,
onExpandSidebar: expandSidebar,
@@ -271,7 +178,9 @@ const DocumentsRouteContent = () => {
const DocumentsRoute = () => (
<SidebarProvider>
<DocumentsRouteContent />
<PanelManagerProvider>
<DocumentsRouteContent />
</PanelManagerProvider>
</SidebarProvider>
);
+344
View File
@@ -0,0 +1,344 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useSidebarContext } from '../sidebar/SidebarContext';
const PanelManagerContext = createContext(null);
const PANEL_LIMITS = {
sidebar: { minRatio: 1 / 6, maxRatio: 1 / 4 },
detail: { minRatio: 1 / 4, maxRatio: 3 / 4 },
};
const STORAGE_KEYS = {
sidebar: 'papercrate_sidebar_width',
detail: 'papercrate_detail_width',
};
const MINIMAL_FREE_RATIO = 1 / 3;
const clampPanelWidth = (panel, value) => {
const numeric = Number(value);
const limits = PANEL_LIMITS[panel];
if (!limits) {
return numeric;
}
const viewport = window.innerWidth;
const minLimit = Math.max(0, Math.round(viewport * limits.minRatio));
const rawMax = Math.max(minLimit, Math.round(viewport * limits.maxRatio));
const maxAllowed = Math.min(rawMax, viewport - 160);
const targetMax = Math.max(minLimit, maxAllowed);
return Math.min(Math.max(numeric, minLimit), targetMax);
};
const readStoredWidth = (panel, fallback) => {
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
if (!raw) {
return fallback;
}
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : fallback;
};
const persistWidth = (panel, value) => {
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
};
const applyPanelWidthToRoot = (panel, width) => {
if (!Number.isFinite(width)) {
return;
}
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
document?.documentElement?.style.setProperty(varName, `${width}px`);
};
export const PanelManagerProvider = ({ children }) => {
const { collapsed, setCollapsed } = useSidebarContext();
const initialSidebarWidth = readStoredWidth('sidebar', 320);
const initialDetailWidth = readStoredWidth('detail', 420);
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
const [resizingPanel, setResizingPanel] = useState(null);
const [sidebarSuppressed, setSidebarSuppressed] = useState(false);
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const detailCloseHandlerRef = useRef(null);
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const closeDetailPanel = useCallback(() => {
const handler = detailCloseHandlerRef.current;
handler?.();
}, []);
useEffect(() => {
panelWidthsRef.current.sidebar = sidebarWidth;
applyPanelWidthToRoot('sidebar', sidebarWidth);
}, [sidebarWidth]);
useEffect(() => {
panelWidthsRef.current.detail = detailWidth;
applyPanelWidthToRoot('detail', detailWidth);
}, [detailWidth]);
useEffect(() => {
persistWidth('sidebar', sidebarWidth);
}, [sidebarWidth]);
useEffect(() => {
persistWidth('detail', detailWidth);
}, [detailWidth]);
const logPanelState = useCallback((panel, action, value) => {
const viewportWidth = window.innerWidth;
const sidebarWidth = panelWidthsRef.current.sidebar;
const detailWidth = panelWidthsRef.current.detail;
const freeSpace = viewportWidth - sidebarWidth - detailWidth;
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
if (panel === 'detail' && !collapsed) {
if (action === 'opened' || action === 'resized') {
setSidebarSuppressed(!meetsThreshold);
} else if (action === 'closed') {
setSidebarSuppressed(false);
}
}
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) {
closeDetailPanel();
}
const normalizedAction = action === 'resized'
? `${panel} resized to ${value}px`
: `${panel} ${action}`;
console.log(normalizedAction, {
sidebar: sidebarWidth > 0 ? `${sidebarWidth}px` : 'closed',
detail: `${detailWidth}px`,
freeSpace,
viewportWidth,
freeRatio,
minimalFreeRatio: MINIMAL_FREE_RATIO,
meetsThreshold,
});
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]);
const collapseSidebar = useCallback(() => {
if (!collapsed) {
setCollapsed(true);
setSidebarSuppressed(false);
logPanelState('sidebar', 'closed');
}
}, [collapsed, setCollapsed, logPanelState]);
const setPanelWidth = useCallback(
(panel, width, commit = true) => {
const clamped = clampPanelWidth(panel, width);
if (!Number.isFinite(clamped)) {
return panelWidthsRef.current[panel];
}
if (panel === 'sidebar') {
setSidebarWidthState((prev) => (prev === clamped ? prev : clamped));
} else {
setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
}
panelWidthsRef.current[panel] = clamped;
applyPanelWidthToRoot(panel, clamped);
if (commit) {
persistWidth(panel, clamped);
}
logPanelState(panel, 'resized', clamped);
return clamped;
},
[collapsed, logPanelState, sidebarSuppressed],
);
const registerDetailCloseHandler = useCallback((handler = null) => {
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
}, []);
const setDetailActive = useCallback(
(isOpen) => {
setDetailPanelOpen(Boolean(isOpen));
logPanelState('detail', isOpen ? 'opened' : 'closed');
},
[logPanelState],
);
const expandSidebar = useCallback(() => {
if (collapsed) {
setCollapsed(false);
}
setSidebarSuppressed(false);
logPanelState('sidebar', 'opened');
}, [collapsed, setCollapsed, logPanelState]);
const startPanelResize = useCallback((panel) => {
setResizingPanel(panel);
}, []);
const stopPanelResize = useCallback(() => {
setResizingPanel(null);
}, []);
const getPanelWidth = useCallback((panel) => panelWidthsRef.current[panel] || 0, []);
const contextValue = useMemo(
() => ({
sidebarWidth,
detailWidth,
sidebarSuppressed,
resizingPanel,
setPanelWidth,
startPanelResize,
stopPanelResize,
getPanelWidth,
setDetailActive,
closeDetailPanel,
collapseSidebar,
expandSidebar,
registerDetailCloseHandler,
detailPanelOpen,
}),
[
sidebarWidth,
detailWidth,
sidebarSuppressed,
resizingPanel,
setPanelWidth,
startPanelResize,
stopPanelResize,
getPanelWidth,
setDetailActive,
closeDetailPanel,
collapseSidebar,
expandSidebar,
registerDetailCloseHandler,
detailPanelOpen,
],
);
return <PanelManagerContext.Provider value={contextValue}>{children}</PanelManagerContext.Provider>;
};
export const usePanelManager = () => {
const context = useContext(PanelManagerContext);
if (!context) {
throw new Error('usePanelManager must be used within a PanelManagerProvider');
}
return context;
};
export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => {
const {
sidebarWidth,
detailWidth,
resizingPanel,
setPanelWidth,
startPanelResize,
stopPanelResize,
getPanelWidth,
} = usePanelManager();
const liveWidth = panel === 'sidebar' ? sidebarWidth : detailWidth;
const latestWidthRef = useRef(liveWidth);
const cleanupRef = useRef(null);
useEffect(() => {
latestWidthRef.current = liveWidth;
}, [liveWidth]);
const teardownListeners = useCallback(() => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
stopPanelResize();
}, [stopPanelResize]);
useEffect(() => () => teardownListeners(), [teardownListeners]);
const handlePointerDown = useCallback(
(event) => {
if (!enabled || !panelRef?.current) {
return;
}
event.preventDefault();
event.stopPropagation();
const rect = panelRef.current.getBoundingClientRect();
const startWidth = Number.isFinite(rect?.width) ? rect.width : getPanelWidth(panel);
if (!Number.isFinite(startWidth)) {
return;
}
const pointerId = event.pointerId ?? 'mouse';
const startX = event.clientX;
startPanelResize(panel);
event.currentTarget?.setPointerCapture?.(pointerId);
let lastWidth = startWidth;
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = panel === 'sidebar'
? moveEvent.clientX - startX
: startX - moveEvent.clientX;
lastWidth = setPanelWidth(panel, startWidth + delta, false);
latestWidthRef.current = lastWidth;
};
const handlePointerUp = (upEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
event.currentTarget?.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setPanelWidth(panel, lastWidth);
teardownListeners();
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
cleanupRef.current = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
},
[
enabled,
panelRef,
panel,
getPanelWidth,
startPanelResize,
setPanelWidth,
teardownListeners,
],
);
const panelStyle = enabled && Number.isFinite(liveWidth)
? { width: `${liveWidth}px` }
: undefined;
const handleProps = enabled
? {
onPointerDown: handlePointerDown,
}
: {};
return {
panelStyle,
handleProps,
isPanelResizing: resizingPanel === panel,
};
};
export default PanelManagerContext;
+15 -1
View File
@@ -1,8 +1,9 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { SidebarExpandIcon } from '../ui/icons';
import { createDocumentsSurface } from '../documents/DocumentsPanel';
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
import createDesktopSurface from '../desktop/createDesktopSurface';
import { usePanelManager } from './PanelManagerContext';
export const useWorkspaceSurface = ({
sidebarHidden,
@@ -24,6 +25,19 @@ export const useWorkspaceSurface = ({
parentBreadcrumb,
onNavigateParent,
}) => {
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
useEffect(() => {
const handler = detailPanelProps?.onClose || null;
registerDetailCloseHandler(handler);
return () => registerDetailCloseHandler(null);
}, [registerDetailCloseHandler, detailPanelProps?.onClose]);
useEffect(() => {
setDetailActive(Boolean(detailPanelOpen));
return () => setDetailActive(false);
}, [detailPanelOpen, setDetailActive]);
const renderSidebarToggle = useCallback(() => {
if (!sidebarHidden) {
return null;