update mountained status

This commit is contained in:
Nate Kelley 2025-07-11 15:37:12 -06:00
parent f7a7ab7e7a
commit f41f16c108
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 246 additions and 63 deletions

View File

@ -19,56 +19,200 @@ import { sizeToPixels, easeInOutCubic, createAutoSaveId } from './helpers';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { useMount } from '@/hooks/useMount'; import { useMount } from '@/hooks/useMount';
// ================================
// INTERFACES AND TYPES
// ================================
/**
* Props for the AppSplitter component
*/
interface IAppSplitterProps { interface IAppSplitterProps {
/** Content to display in the left panel */
leftChildren: React.ReactNode; leftChildren: React.ReactNode;
/** Content to display in the right panel */
rightChildren: React.ReactNode; rightChildren: React.ReactNode;
/** Unique identifier for auto-saving layout to localStorage */
autoSaveId: string; autoSaveId: string;
/**
* Default layout configuration as [left, right] sizes
* Can be numbers (pixels), percentages (strings like "50%"), or "auto"
*/
defaultLayout: (string | number)[]; defaultLayout: (string | number)[];
/**
* Minimum size for the left panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
leftPanelMinSize?: number | string; leftPanelMinSize?: number | string;
/**
* Minimum size for the right panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
rightPanelMinSize?: number | string; 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; 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; rightPanelMaxSize?: number | string;
/** Additional CSS classes for the container */
className?: string; className?: string;
/**
* Whether the splitter can be resized by dragging
* @default true
*/
allowResize?: boolean; allowResize?: boolean;
/**
* Split direction
* @default 'vertical'
*/
split?: 'vertical' | 'horizontal'; split?: 'vertical' | 'horizontal';
/** Additional CSS classes for the splitter element */
splitterClassName?: string; 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'; preserveSide: 'left' | 'right';
/**
* Whether to hide the right panel completely
* @default false
*/
rightHidden?: boolean; rightHidden?: boolean;
/**
* Whether to hide the left panel completely
* @default false
*/
leftHidden?: boolean; leftHidden?: boolean;
/** Inline styles for the container */
style?: React.CSSProperties; style?: React.CSSProperties;
/**
* Whether to hide the splitter handle
* @default false
*/
hideSplitter?: boolean; hideSplitter?: boolean;
/** Additional CSS classes for the left panel */
leftPanelClassName?: string; leftPanelClassName?: string;
/** Additional CSS classes for the right panel */
rightPanelClassName?: string; 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); bustStorageOnInit?: boolean | ((preservedSideValue: number | null, refWidth: number) => boolean);
/**
* Whether to render the left panel content
* @default true
*/
renderLeftPanel?: boolean; renderLeftPanel?: boolean;
/**
* Whether to render the right panel content
* @default true
*/
renderRightPanel?: boolean; renderRightPanel?: boolean;
} }
/**
* Ref interface for controlling the AppSplitter imperatively
*/
export interface AppSplitterRef { 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: ( animateWidth: (
width: string | number, width: string | number,
side: 'left' | 'right', side: 'left' | 'right',
duration?: number duration?: number
) => Promise<void>; ) => Promise<void>;
/**
* Set the split sizes programmatically
* @param sizes - [left, right] sizes as pixels or percentages
*/
setSplitSizes: (sizes: [string | number, string | number]) => void; 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; isSideClosed: (side: 'left' | 'right') => boolean;
/**
* Get current sizes in pixels
* @returns [leftSize, rightSize] in pixels
*/
getSizesInPixels: () => [number, number]; getSizesInPixels: () => [number, number];
} }
// Consolidated state interface for better organization /**
* Internal state interface for the splitter
*/
interface SplitterState { interface SplitterState {
/** Current container size in pixels */
containerSize: number; containerSize: number;
/** Whether the user is currently dragging the splitter */
isDragging: boolean; isDragging: boolean;
/** Whether an animation is currently in progress */
isAnimating: boolean; isAnimating: boolean;
/** Whether the current size was set by an animation */
sizeSetByAnimation: boolean; sizeSetByAnimation: boolean;
/** Whether the user has interacted with the splitter */
hasUserInteracted: boolean; 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<AppSplitterRef, IAppSplitterProps>( const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => { ({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isVertical = split === 'vertical'; const isVertical = split === 'vertical';
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(!props.bustStorageOnInit);
const splitterAutoSaveId = createAutoSaveId(autoSaveId); const splitterAutoSaveId = createAutoSaveId(autoSaveId);
useMount(() => { useMount(() => {
@ -97,11 +241,13 @@ const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
AppSplitterWrapper.displayName = 'AppSplitterWrapper'; AppSplitterWrapper.displayName = 'AppSplitterWrapper';
// ================================
// CORE IMPLEMENTATION
// ================================
const AppSplitterBase = forwardRef< const AppSplitterBase = forwardRef<
AppSplitterRef, AppSplitterRef,
Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className'> & { Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className'> & {
// savedLayout: number | null;
// setSavedLayout: (layout: number | null) => void;
isVertical: boolean; isVertical: boolean;
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>;
splitterAutoSaveId: string; splitterAutoSaveId: string;
@ -134,25 +280,14 @@ const AppSplitterBase = forwardRef<
}, },
ref ref
) => { ) => {
// ================================
// REFS AND STATE
// ================================
const startPosRef = useRef(0); const startPosRef = useRef(0);
const startSizeRef = useRef(0); const startSizeRef = useRef(0);
const animationRef = useRef<number | null>(null); const animationRef = useRef<number | null>(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<number | null>(splitterAutoSaveId, {
defaultValue: null,
bustStorageOnInit: bustStorageOnInitSplitter
});
// Consolidated state management // Consolidated state management
const [state, setState] = useState<SplitterState>({ const [state, setState] = useState<SplitterState>({
containerSize: containerRef.current?.offsetWidth ?? 0, containerSize: containerRef.current?.offsetWidth ?? 0,
@ -162,6 +297,45 @@ const AppSplitterBase = forwardRef<
hasUserInteracted: false 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<number | null>(splitterAutoSaveId, {
defaultValue,
bustStorageOnInit: bustStorageOnInitSplitter
});
// ================================
// SIZE CALCULATION LOGIC
// ================================
// Calculate initial size based on default layout // Calculate initial size based on default layout
const calculateInitialSize = useMemoizedFn((containerSize: number): number => { const calculateInitialSize = useMemoizedFn((containerSize: number): number => {
if (containerSize === 0) return 0; if (containerSize === 0) return 0;
@ -282,11 +456,9 @@ const AppSplitterBase = forwardRef<
} }
}, [state, savedLayout, leftHidden, rightHidden, preserveSide, applyConstraints]); }, [state, savedLayout, leftHidden, rightHidden, preserveSide, applyConstraints]);
// Determine if splitter should be hidden // ================================
const shouldHideSplitter = // CONTAINER RESIZE HANDLING
hideSplitterProp || (leftHidden && rightHidden) || leftSize === 0 || rightSize === 0; // ================================
const showSplitter = !leftHidden && !rightHidden;
// Update container size and handle initialization // Update container size and handle initialization
const updateContainerSize = useMemoizedFn(() => { const updateContainerSize = useMemoizedFn(() => {
@ -346,6 +518,10 @@ const AppSplitterBase = forwardRef<
}); });
}); });
// ================================
// ANIMATION LOGIC
// ================================
// Animation function // Animation function
const animateWidth = useMemoizedFn( const animateWidth = useMemoizedFn(
async ( async (
@ -405,6 +581,10 @@ const AppSplitterBase = forwardRef<
} }
); );
// ================================
// IMPERATIVE API METHODS
// ================================
// Set split sizes function // Set split sizes function
const setSplitSizes = useMemoizedFn((sizes: [string | number, string | number]) => { const setSplitSizes = useMemoizedFn((sizes: [string | number, string | number]) => {
if (!state.containerSize) return; if (!state.containerSize) return;
@ -457,7 +637,10 @@ const AppSplitterBase = forwardRef<
return [leftSize, rightSize]; return [leftSize, rightSize];
}, [leftSize, rightSize]); }, [leftSize, rightSize]);
// Mouse event handlers // ================================
// MOUSE EVENT HANDLERS
// ================================
const handleMouseDown = useMemoizedFn((e: React.MouseEvent) => { const handleMouseDown = useMemoizedFn((e: React.MouseEvent) => {
if (!allowResize) return; if (!allowResize) return;
@ -495,13 +678,14 @@ const AppSplitterBase = forwardRef<
setState((prev) => ({ ...prev, isDragging: false })); setState((prev) => ({ ...prev, isDragging: false }));
}); });
// Use useLayoutEffect for initial measurement to ensure DOM is ready // ================================
useLayoutEffect(() => { // EFFECTS AND LIFECYCLE
updateContainerSize(); // ================================
}, [updateContainerSize]);
// Use useEffect for ongoing resize monitoring // Container resize monitoring
useEffect(() => { useEffect(() => {
updateContainerSize();
// If container is still 0 after layout, try again with animation frame // If container is still 0 after layout, try again with animation frame
if (containerRef.current?.offsetWidth === 0) { if (containerRef.current?.offsetWidth === 0) {
requestAnimationFrame(updateContainerSize); requestAnimationFrame(updateContainerSize);
@ -520,6 +704,7 @@ const AppSplitterBase = forwardRef<
}; };
}, [updateContainerSize]); }, [updateContainerSize]);
// Mouse event handling during drag
useEffect(() => { useEffect(() => {
if (state.isDragging) { if (state.isDragging) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@ -536,7 +721,7 @@ const AppSplitterBase = forwardRef<
} }
}, [state.isDragging, handleMouseMove, handleMouseUp, isVertical]); }, [state.isDragging, handleMouseMove, handleMouseUp, isVertical]);
// Expose methods via ref // Expose imperative API via ref
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
@ -548,6 +733,16 @@ const AppSplitterBase = forwardRef<
[animateWidth, setSplitSizes, isSideClosed, getSizesInPixels] [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]>( const sizes = useMemo<[string | number, string | number]>(
() => [`${leftSize}px`, `${rightSize}px`], () => [`${leftSize}px`, `${rightSize}px`],
[leftSize, rightSize] [leftSize, rightSize]

View File

@ -27,7 +27,7 @@ interface Options<T> {
export function useLocalStorageState<T>( export function useLocalStorageState<T>(
key: string, key: string,
options?: Options<T> options?: Options<T>
): [T | undefined, (value?: SetState<T>) => void, () => T | undefined] { ): [T | undefined, (value?: SetState<T>) => void] {
const { const {
defaultValue, defaultValue,
serializer = JSON.stringify, serializer = JSON.stringify,
@ -37,27 +37,22 @@ export function useLocalStorageState<T>(
expirationTime = DEFAULT_EXPIRATION_TIME expirationTime = DEFAULT_EXPIRATION_TIME
} = options || {}; } = 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 // Get initial value from localStorage or use default
const getInitialValue = useMemoizedFn((): T | undefined => { 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 is true, ignore localStorage and use default value
if (bustStorageOnInit === true) { if (bustStorageOnInit === true) {
return executeBustStorage(); return gonnaBustTheStorage();
} }
try { try {
const item = window.localStorage.getItem(key); const item = window.localStorage.getItem(key);
if (item === null) { if (item === null) {
return executeBustStorage(); return gonnaBustTheStorage();
} }
// Parse the stored data which includes value and timestamp // Parse the stored data which includes value and timestamp
@ -71,7 +66,7 @@ export function useLocalStorageState<T>(
!('timestamp' in storageData) !('timestamp' in storageData)
) { ) {
// If the data doesn't have the expected structure (legacy data), treat as expired // If the data doesn't have the expected structure (legacy data), treat as expired
return executeBustStorage(); return gonnaBustTheStorage();
} }
// Check if the data has expired // Check if the data has expired
@ -80,20 +75,20 @@ export function useLocalStorageState<T>(
if (timeDifference > expirationTime) { if (timeDifference > expirationTime) {
// Data has expired, remove it and return default value // Data has expired, remove it and return default value
return executeBustStorage(); return gonnaBustTheStorage();
} }
// Data is still valid, deserialize and return the value // Data is still valid, deserialize and return the value
const deserializedValue = deserializer(JSON.stringify(storageData.value)); const deserializedValue = deserializer(JSON.stringify(storageData.value));
if (typeof bustStorageOnInit === 'function' && bustStorageOnInit(deserializedValue)) { if (typeof bustStorageOnInit === 'function' && bustStorageOnInit(deserializedValue)) {
return executeBustStorage(); return gonnaBustTheStorage();
} }
return deserializedValue; return deserializedValue;
} catch (error) { } catch (error) {
onError?.(error); onError?.(error);
return executeBustStorage(); return gonnaBustTheStorage();
} }
}); });
@ -138,5 +133,5 @@ export function useLocalStorageState<T>(
} }
}); });
return [state, setStoredState, executeBustStorage]; return [state, setStoredState];
} }

View File

@ -3,8 +3,7 @@ import { AppPageLayout } from '@/components/ui/layouts';
import { ChatContent } from './ChatContent'; import { ChatContent } from './ChatContent';
import { ChatHeader } from './ChatHeader'; import { ChatHeader } from './ChatHeader';
export const ChatContainer = React.memo(({ mounted }: { mounted?: boolean }) => { export const ChatContainer = React.memo(() => {
console.log('ChatContainer', mounted);
return ( return (
<AppPageLayout <AppPageLayout
headerSizeVariant="default" headerSizeVariant="default"

View File

@ -41,17 +41,11 @@ export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
const mounted = true; const mounted = true;
const bustStorageOnInit = (preservedSideValue: number | null, containerSize: number) => { const bustStorageOnInit = (preservedSideValue: number | null, containerSize: number) => {
console.log( console.log('bustStorageOnInit', autoSaveId, preservedSideValue, {
selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView, selectedLayout,
'bustStorageOnInit', secondaryFileView,
autoSaveId, containerSize
preservedSideValue, });
{
selectedLayout,
secondaryFileView,
containerSize
}
);
return selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView; return selectedLayout === 'chat-only' || selectedLayout === 'file-only' || !!secondaryFileView;
}; };
@ -60,7 +54,7 @@ export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
<ChatContextProvider> <ChatContextProvider>
<AppSplitter <AppSplitter
ref={appSplitterRef} ref={appSplitterRef}
leftChildren={<div>HUH?</div>} leftChildren={useMemo(() => mounted && <ChatContainer />, [mounted])}
rightChildren={useMemo( rightChildren={useMemo(
() => mounted && <FileContainer>{children}</FileContainer>, () => mounted && <FileContainer>{children}</FileContainer>,
[children, mounted] [children, mounted]