typescript
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
|
||||
type PanelKey = 'sidebar' | 'detail';
|
||||
|
||||
interface PanelLimits {
|
||||
maxRatio: number;
|
||||
minPx: number;
|
||||
}
|
||||
|
||||
interface SetPanelWidthOptions {
|
||||
commit?: boolean;
|
||||
log?: boolean;
|
||||
}
|
||||
|
||||
interface PanelManagerContextValue {
|
||||
sidebarWidth: number;
|
||||
detailWidth: number;
|
||||
sidebarSuppressed: boolean;
|
||||
resizingPanel: PanelKey | null;
|
||||
setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number;
|
||||
startPanelResize: (panel: PanelKey) => void;
|
||||
stopPanelResize: () => void;
|
||||
getPanelWidth: (panel: PanelKey) => number;
|
||||
setDetailActive: (isOpen: boolean) => void;
|
||||
closeDetailPanel: () => void;
|
||||
collapseSidebar: () => void;
|
||||
expandSidebar: () => void;
|
||||
registerDetailCloseHandler: (handler?: (() => void) | null) => void;
|
||||
detailPanelOpen: boolean;
|
||||
}
|
||||
|
||||
type PanelResizeBindings = {
|
||||
panelStyle?: CSSProperties;
|
||||
handleProps: {
|
||||
onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isPanelResizing: boolean;
|
||||
};
|
||||
|
||||
const PanelManagerContext = createContext<PanelManagerContextValue | null>(null);
|
||||
|
||||
const PANEL_LIMITS: Record<PanelKey, PanelLimits> = {
|
||||
sidebar: {
|
||||
maxRatio: 1 / 3,
|
||||
minPx: 280,
|
||||
},
|
||||
detail: {
|
||||
maxRatio: 2 / 3,
|
||||
minPx: 320,
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEYS: Record<PanelKey, string> = {
|
||||
sidebar: 'papercrate_sidebar_width',
|
||||
detail: 'papercrate_detail_width',
|
||||
};
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
const DEFAULT_DETAIL_WIDTH = 420;
|
||||
|
||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
|
||||
const MINIMUM_MAIN_CONTENT_WIDTH = 160;
|
||||
|
||||
const clampPanelWidth = (panel: PanelKey, value: number): number => {
|
||||
const numeric = Number(value);
|
||||
const limits = PANEL_LIMITS[panel];
|
||||
const viewport = window.innerWidth;
|
||||
const minLimit = Math.max(0, limits.minPx);
|
||||
const ratioMax = Math.round(viewport * limits.maxRatio);
|
||||
const rawMax = Math.max(ratioMax, minLimit);
|
||||
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
|
||||
const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
|
||||
return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
|
||||
};
|
||||
|
||||
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
|
||||
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: PanelKey, value: number): void => {
|
||||
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
};
|
||||
|
||||
const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => {
|
||||
if (!Number.isFinite(width)) {
|
||||
return;
|
||||
}
|
||||
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
||||
document.documentElement.style.setProperty(varName, `${width}px`);
|
||||
};
|
||||
|
||||
interface PanelManagerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children }) => {
|
||||
const { collapsed, setCollapsed } = useSidebarContext();
|
||||
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
|
||||
|
||||
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 preferredPanelWidthsRef = 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]);
|
||||
|
||||
const handlePanelLayoutChange = useCallback((
|
||||
panel: PanelKey,
|
||||
action: 'opened' | 'closed' | 'resized',
|
||||
value?: number,
|
||||
{ detailOpen }: { detailOpen?: boolean } = {},
|
||||
) => {
|
||||
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;
|
||||
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
|
||||
|
||||
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 && effectiveDetailOpen) {
|
||||
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, closeDetailPanel, detailPanelOpen]);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
if (!collapsed) {
|
||||
setCollapsed(true);
|
||||
handlePanelLayoutChange('sidebar', 'closed');
|
||||
}
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const setPanelWidth = useCallback(
|
||||
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
|
||||
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) {
|
||||
preferredPanelWidthsRef.current[panel] = clamped;
|
||||
persistWidth(panel, clamped);
|
||||
}
|
||||
|
||||
if (log) {
|
||||
handlePanelLayoutChange(panel, 'resized', clamped);
|
||||
}
|
||||
return clamped;
|
||||
},
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const resetSidebarPreferredWidth = useCallback(() => {
|
||||
if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) {
|
||||
return;
|
||||
}
|
||||
preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||
persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
}, []);
|
||||
|
||||
const clampPanelsWithinViewport = useCallback(() => {
|
||||
const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
|
||||
if (viewportWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar;
|
||||
const desiredDetailWidth = preferredPanelWidthsRef.current.detail;
|
||||
|
||||
let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth);
|
||||
let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth);
|
||||
let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth);
|
||||
if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = 0;
|
||||
detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth);
|
||||
} else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = viewportWidth;
|
||||
detailDisplayWidth = 0;
|
||||
resetSidebarPreferredWidth();
|
||||
}
|
||||
|
||||
panelWidthsRef.current.sidebar = sidebarDisplayWidth;
|
||||
panelWidthsRef.current.detail = detailDisplayWidth;
|
||||
|
||||
setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth));
|
||||
setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth));
|
||||
}, [detailPanelOpen, resetSidebarPreferredWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
clampPanelsWithinViewport();
|
||||
window.addEventListener('resize', clampPanelsWithinViewport);
|
||||
return () => window.removeEventListener('resize', clampPanelsWithinViewport);
|
||||
}, [clampPanelsWithinViewport]);
|
||||
|
||||
const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => {
|
||||
detailCloseHandlerRef.current = handler ?? null;
|
||||
}, []);
|
||||
|
||||
const setDetailActive = useCallback(
|
||||
(isOpen) => {
|
||||
setDetailPanelOpen(Boolean(isOpen));
|
||||
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
|
||||
},
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
handlePanelLayoutChange('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
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: PanelKey,
|
||||
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
|
||||
): PanelResizeBindings => {
|
||||
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: ReactPointerEvent<HTMLDivElement>) => {
|
||||
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: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const delta = panel === 'sidebar'
|
||||
? moveEvent.clientX - startX
|
||||
: startX - moveEvent.clientX;
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
|
||||
latestWidthRef.current = lastWidth;
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
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;
|
||||
Reference in New Issue
Block a user