diff --git a/frontend/src/app/DocumentsLayout.jsx b/frontend/src/app/DocumentsLayout.jsx index 0655d26..9c9c78f 100644 --- a/frontend/src/app/DocumentsLayout.jsx +++ b/frontend/src/app/DocumentsLayout.jsx @@ -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 (
diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx index 2769e64..81f15be 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.jsx @@ -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 = () => ( - + + + ); diff --git a/frontend/src/app/PanelManagerContext.js b/frontend/src/app/PanelManagerContext.js new file mode 100644 index 0000000..91fa6da --- /dev/null +++ b/frontend/src/app/PanelManagerContext.js @@ -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 {children}; +}; + +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; diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 5ff89dd..1d59a80 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -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; diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index c7480c3..2ca7bc3 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -26,88 +26,8 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import DocumentViewerLayout from './DocumentViewerLayout'; import useViewerLayoutMode from './useViewerLayoutMode'; -import { useSidebarContext } from '../sidebar/SidebarContext'; +import { usePanelResizeBindings } from '../app/PanelManagerContext'; -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; -}; - -const shouldSuppressSidebarForDetailWidth = (detailWidth) => { - if (!isBrowser || !Number.isFinite(detailWidth)) { - return false; - } - const sidebarWidth = getSidebarWidthFromRoot() || 0; - const minMainContentWidth = window.innerWidth / 3; - const occupiedWidth = detailWidth + sidebarWidth; - const availableWidth = window.innerWidth - occupiedWidth; - return availableWidth < minMainContentWidth; -}; export const createDocumentViewerHeaderActions = ({ document, @@ -181,7 +101,6 @@ const DocumentViewerPanel = ({ }) => { const navigate = useNavigate(); const isSidebarVariant = variant === 'sidebar'; - const { setSidebarSuppressed } = useSidebarContext(); const sortedCorrespondents = useMemo( () => sortCorrespondents(document?.correspondents || []), [document], @@ -322,166 +241,14 @@ 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; - setSidebarSuppressed(shouldSuppressSidebarForDetailWidth(panelWidth)); - }; - - 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) { - setSidebarSuppressed(shouldSuppressSidebarForDetailWidth(clamped)); - } - } - }; - - 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 { + panelStyle: managedDetailPanelStyle, + handleProps: managedResizeHandleProps, + isPanelResizing, + } = usePanelResizeBindings('detail', { enabled: isSidebarVariant, panelRef }); + const detailPanelStyle = isSidebarVariant ? managedDetailPanelStyle : undefined; + const resizeHandleProps = isSidebarVariant ? managedResizeHandleProps : {}; const viewerClassName = isStackedLayout ? 'document-viewer document-viewer--stacked' @@ -649,10 +416,9 @@ const DocumentViewerPanel = ({ const resizeHandle = isSidebarVariant ? ( @@ -715,7 +481,11 @@ const DocumentViewerPanel = ({ if (isSidebarVariant) { return ( <> -