diff --git a/apps/web/src/components/ui/layouts/AppSplitter/AppSplitter.tsx b/apps/web/src/components/ui/layouts/AppSplitter/AppSplitter.tsx index 293471d86..3d090f5ee 100644 --- a/apps/web/src/components/ui/layouts/AppSplitter/AppSplitter.tsx +++ b/apps/web/src/components/ui/layouts/AppSplitter/AppSplitter.tsx @@ -19,56 +19,200 @@ import { sizeToPixels, easeInOutCubic, createAutoSaveId } from './helpers'; import { useMemoizedFn } from '@/hooks'; import { useMount } from '@/hooks/useMount'; +// ================================ +// INTERFACES AND TYPES +// ================================ + +/** + * Props for the AppSplitter component + */ interface IAppSplitterProps { + /** Content to display in the left panel */ leftChildren: React.ReactNode; + + /** Content to display in the right panel */ rightChildren: React.ReactNode; + + /** Unique identifier for auto-saving layout to localStorage */ autoSaveId: string; + + /** + * Default layout configuration as [left, right] sizes + * Can be numbers (pixels), percentages (strings like "50%"), or "auto" + */ defaultLayout: (string | number)[]; + + /** + * Minimum size for the left panel + * Can be a number (pixels) or string (percentage) + * @default 0 + */ leftPanelMinSize?: number | string; + + /** + * Minimum size for the right panel + * Can be a number (pixels) or string (percentage) + * @default 0 + */ rightPanelMinSize?: number | string; + + /** + * Maximum size for the left panel + * Can be a number (pixels) or string (percentage) + * If not specified, defaults to container size + */ leftPanelMaxSize?: number | string; + + /** + * Maximum size for the right panel + * Can be a number (pixels) or string (percentage) + * If not specified, defaults to container size + */ rightPanelMaxSize?: number | string; + + /** Additional CSS classes for the container */ className?: string; + + /** + * Whether the splitter can be resized by dragging + * @default true + */ allowResize?: boolean; + + /** + * Split direction + * @default 'vertical' + */ split?: 'vertical' | 'horizontal'; + + /** Additional CSS classes for the splitter element */ splitterClassName?: string; + + /** + * Which side to preserve when resizing + * 'left' - left panel maintains its size, right panel adjusts + * 'right' - right panel maintains its size, left panel adjusts + */ preserveSide: 'left' | 'right'; + + /** + * Whether to hide the right panel completely + * @default false + */ rightHidden?: boolean; + + /** + * Whether to hide the left panel completely + * @default false + */ leftHidden?: boolean; + + /** Inline styles for the container */ style?: React.CSSProperties; + + /** + * Whether to hide the splitter handle + * @default false + */ hideSplitter?: boolean; + + /** Additional CSS classes for the left panel */ leftPanelClassName?: string; + + /** Additional CSS classes for the right panel */ rightPanelClassName?: string; + + /** + * Whether to clear saved layout from localStorage on initialization + * Can be a boolean or a function that returns a boolean based on preserved side value and container width + */ bustStorageOnInit?: boolean | ((preservedSideValue: number | null, refWidth: number) => boolean); + + /** + * Whether to render the left panel content + * @default true + */ renderLeftPanel?: boolean; + + /** + * Whether to render the right panel content + * @default true + */ renderRightPanel?: boolean; } +/** + * Ref interface for controlling the AppSplitter imperatively + */ export interface AppSplitterRef { + /** + * Animate a panel to a specific width + * @param width - Target width (pixels or percentage) + * @param side - Which side to animate + * @param duration - Animation duration in milliseconds + */ animateWidth: ( width: string | number, side: 'left' | 'right', duration?: number ) => Promise; + + /** + * Set the split sizes programmatically + * @param sizes - [left, right] sizes as pixels or percentages + */ setSplitSizes: (sizes: [string | number, string | number]) => void; + + /** + * Check if a side is closed (hidden or 0px) + * @param side - Which side to check + */ isSideClosed: (side: 'left' | 'right') => boolean; + + /** + * Get current sizes in pixels + * @returns [leftSize, rightSize] in pixels + */ getSizesInPixels: () => [number, number]; } -// Consolidated state interface for better organization +/** + * Internal state interface for the splitter + */ interface SplitterState { + /** Current container size in pixels */ containerSize: number; + /** Whether the user is currently dragging the splitter */ isDragging: boolean; + /** Whether an animation is currently in progress */ isAnimating: boolean; + /** Whether the current size was set by an animation */ sizeSetByAnimation: boolean; + /** Whether the user has interacted with the splitter */ hasUserInteracted: boolean; } +// ================================ +// MAIN COMPONENT +// ================================ + +/** + * AppSplitter - A resizable split panel component with localStorage persistence + * + * Features: + * - Horizontal or vertical splitting + * - Drag to resize with constraints + * - Auto-save layout to localStorage + * - Smooth animations + * - Responsive container resizing + * - Panel hiding/showing + * - Imperative API via ref + */ const AppSplitterWrapper = forwardRef( ({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => { const containerRef = useRef(null); const isVertical = split === 'vertical'; - const [mounted, setMounted] = useState(false); + const [mounted, setMounted] = useState(!props.bustStorageOnInit); const splitterAutoSaveId = createAutoSaveId(autoSaveId); useMount(() => { @@ -97,11 +241,13 @@ const AppSplitterWrapper = forwardRef( AppSplitterWrapper.displayName = 'AppSplitterWrapper'; +// ================================ +// CORE IMPLEMENTATION +// ================================ + const AppSplitterBase = forwardRef< AppSplitterRef, Omit & { - // savedLayout: number | null; - // setSavedLayout: (layout: number | null) => void; isVertical: boolean; containerRef: React.RefObject; splitterAutoSaveId: string; @@ -134,25 +280,14 @@ const AppSplitterBase = forwardRef< }, ref ) => { + // ================================ + // REFS AND STATE + // ================================ + const startPosRef = useRef(0); const startSizeRef = useRef(0); const animationRef = useRef(null); - const bustStorageOnInitSplitter = (preservedSideValue: number | null) => { - const refWidth = containerRef.current?.offsetWidth; - // Don't bust storage if container hasn't been sized yet - if (!refWidth || refWidth === 0) return false; - return typeof bustStorageOnInit === 'function' - ? bustStorageOnInit(preservedSideValue, refWidth) - : !!bustStorageOnInit; - }; - - // Load saved layout from localStorage - const [savedLayout, setSavedLayout] = useLocalStorageState(splitterAutoSaveId, { - defaultValue: null, - bustStorageOnInit: bustStorageOnInitSplitter - }); - // Consolidated state management const [state, setState] = useState({ containerSize: containerRef.current?.offsetWidth ?? 0, @@ -162,6 +297,45 @@ const AppSplitterBase = forwardRef< hasUserInteracted: false }); + // ================================ + // STORAGE MANAGEMENT + // ================================ + + const bustStorageOnInitSplitter = (preservedSideValue: number | null) => { + const refWidth = containerRef.current?.offsetWidth; + // Don't bust storage if container hasn't been sized yet + if (!refWidth || refWidth === 0) { + console.warn('AppSplitter: container not sized yet'); + return false; + } + + return typeof bustStorageOnInit === 'function' + ? bustStorageOnInit(preservedSideValue, refWidth) + : !!bustStorageOnInit; + }; + + const defaultValue = () => { + const [leftValue, rightValue] = defaultLayout; + if (preserveSide === 'left' && leftValue === 'auto') { + return containerRef.current?.offsetWidth ?? 0; + } + if (preserveSide === 'right' && rightValue === 'auto') { + return containerRef.current?.offsetWidth ?? 0; + } + const preserveValue = preserveSide === 'left' ? leftValue : rightValue; + return sizeToPixels(preserveValue, containerRef.current?.offsetWidth ?? 0); + }; + + // Load saved layout from localStorage + const [savedLayout, setSavedLayout] = useLocalStorageState(splitterAutoSaveId, { + defaultValue, + bustStorageOnInit: bustStorageOnInitSplitter + }); + + // ================================ + // SIZE CALCULATION LOGIC + // ================================ + // Calculate initial size based on default layout const calculateInitialSize = useMemoizedFn((containerSize: number): number => { if (containerSize === 0) return 0; @@ -282,11 +456,9 @@ const AppSplitterBase = forwardRef< } }, [state, savedLayout, leftHidden, rightHidden, preserveSide, applyConstraints]); - // Determine if splitter should be hidden - const shouldHideSplitter = - hideSplitterProp || (leftHidden && rightHidden) || leftSize === 0 || rightSize === 0; - - const showSplitter = !leftHidden && !rightHidden; + // ================================ + // CONTAINER RESIZE HANDLING + // ================================ // Update container size and handle initialization const updateContainerSize = useMemoizedFn(() => { @@ -346,6 +518,10 @@ const AppSplitterBase = forwardRef< }); }); + // ================================ + // ANIMATION LOGIC + // ================================ + // Animation function const animateWidth = useMemoizedFn( async ( @@ -405,6 +581,10 @@ const AppSplitterBase = forwardRef< } ); + // ================================ + // IMPERATIVE API METHODS + // ================================ + // Set split sizes function const setSplitSizes = useMemoizedFn((sizes: [string | number, string | number]) => { if (!state.containerSize) return; @@ -457,7 +637,10 @@ const AppSplitterBase = forwardRef< return [leftSize, rightSize]; }, [leftSize, rightSize]); - // Mouse event handlers + // ================================ + // MOUSE EVENT HANDLERS + // ================================ + const handleMouseDown = useMemoizedFn((e: React.MouseEvent) => { if (!allowResize) return; @@ -495,13 +678,14 @@ const AppSplitterBase = forwardRef< setState((prev) => ({ ...prev, isDragging: false })); }); - // Use useLayoutEffect for initial measurement to ensure DOM is ready - useLayoutEffect(() => { - updateContainerSize(); - }, [updateContainerSize]); + // ================================ + // EFFECTS AND LIFECYCLE + // ================================ - // Use useEffect for ongoing resize monitoring + // Container resize monitoring useEffect(() => { + updateContainerSize(); + // If container is still 0 after layout, try again with animation frame if (containerRef.current?.offsetWidth === 0) { requestAnimationFrame(updateContainerSize); @@ -520,6 +704,7 @@ const AppSplitterBase = forwardRef< }; }, [updateContainerSize]); + // Mouse event handling during drag useEffect(() => { if (state.isDragging) { document.addEventListener('mousemove', handleMouseMove); @@ -536,7 +721,7 @@ const AppSplitterBase = forwardRef< } }, [state.isDragging, handleMouseMove, handleMouseUp, isVertical]); - // Expose methods via ref + // Expose imperative API via ref useImperativeHandle( ref, () => ({ @@ -548,6 +733,16 @@ const AppSplitterBase = forwardRef< [animateWidth, setSplitSizes, isSideClosed, getSizesInPixels] ); + // ================================ + // RENDER LOGIC + // ================================ + + // Determine if splitter should be hidden + const shouldHideSplitter = + hideSplitterProp || (leftHidden && rightHidden) || leftSize === 0 || rightSize === 0; + + const showSplitter = !leftHidden && !rightHidden; + const sizes = useMemo<[string | number, string | number]>( () => [`${leftSize}px`, `${rightSize}px`], [leftSize, rightSize] diff --git a/apps/web/src/hooks/useLocalStorageState.tsx b/apps/web/src/hooks/useLocalStorageState.tsx index 291f1ec28..e61f3f0ac 100644 --- a/apps/web/src/hooks/useLocalStorageState.tsx +++ b/apps/web/src/hooks/useLocalStorageState.tsx @@ -27,7 +27,7 @@ interface Options { export function useLocalStorageState( key: string, options?: Options -): [T | undefined, (value?: SetState) => void, () => T | undefined] { +): [T | undefined, (value?: SetState) => void] { const { defaultValue, serializer = JSON.stringify, @@ -37,27 +37,22 @@ export function useLocalStorageState( expirationTime = DEFAULT_EXPIRATION_TIME } = options || {}; - const executeBustStorage = useMemoizedFn(() => { - console.log( - '***executeBustStorage', - key, - typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue - ); - if (!isServer) window.localStorage.removeItem(key); - return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue; - }); - // Get initial value from localStorage or use default const getInitialValue = useMemoizedFn((): T | undefined => { + const gonnaBustTheStorage = () => { + if (!isServer) window.localStorage.removeItem(key); + return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue; + }; + // If bustStorageOnInit is true, ignore localStorage and use default value if (bustStorageOnInit === true) { - return executeBustStorage(); + return gonnaBustTheStorage(); } try { const item = window.localStorage.getItem(key); if (item === null) { - return executeBustStorage(); + return gonnaBustTheStorage(); } // Parse the stored data which includes value and timestamp @@ -71,7 +66,7 @@ export function useLocalStorageState( !('timestamp' in storageData) ) { // If the data doesn't have the expected structure (legacy data), treat as expired - return executeBustStorage(); + return gonnaBustTheStorage(); } // Check if the data has expired @@ -80,20 +75,20 @@ export function useLocalStorageState( if (timeDifference > expirationTime) { // Data has expired, remove it and return default value - return executeBustStorage(); + return gonnaBustTheStorage(); } // Data is still valid, deserialize and return the value const deserializedValue = deserializer(JSON.stringify(storageData.value)); if (typeof bustStorageOnInit === 'function' && bustStorageOnInit(deserializedValue)) { - return executeBustStorage(); + return gonnaBustTheStorage(); } return deserializedValue; } catch (error) { onError?.(error); - return executeBustStorage(); + return gonnaBustTheStorage(); } }); @@ -138,5 +133,5 @@ export function useLocalStorageState( } }); - return [state, setStoredState, executeBustStorage]; + return [state, setStoredState]; } diff --git a/apps/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx b/apps/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx index c76d26cff..565147255 100644 --- a/apps/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx +++ b/apps/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx @@ -3,8 +3,7 @@ import { AppPageLayout } from '@/components/ui/layouts'; import { ChatContent } from './ChatContent'; import { ChatHeader } from './ChatHeader'; -export const ChatContainer = React.memo(({ mounted }: { mounted?: boolean }) => { - console.log('ChatContainer', mounted); +export const ChatContainer = React.memo(() => { return ( = ({ children }) => { const mounted = true; const bustStorageOnInit = (preservedSideValue: number | null, containerSize: number) => { - console.log( - selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView, - 'bustStorageOnInit', - autoSaveId, - preservedSideValue, - { - selectedLayout, - secondaryFileView, - containerSize - } - ); + console.log('bustStorageOnInit', autoSaveId, preservedSideValue, { + selectedLayout, + secondaryFileView, + containerSize + }); return selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView; }; @@ -60,7 +54,7 @@ export const ChatLayout: React.FC = ({ children }) => { HUH?} + leftChildren={useMemo(() => mounted && , [mounted])} rightChildren={useMemo( () => mounted && {children}, [children, mounted]