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 { 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<void>;
/**
* 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<AppSplitterRef, IAppSplitterProps>(
({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => {
const containerRef = useRef<HTMLDivElement>(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<AppSplitterRef, IAppSplitterProps>(
AppSplitterWrapper.displayName = 'AppSplitterWrapper';
// ================================
// CORE IMPLEMENTATION
// ================================
const AppSplitterBase = forwardRef<
AppSplitterRef,
Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className'> & {
// savedLayout: number | null;
// setSavedLayout: (layout: number | null) => void;
isVertical: boolean;
containerRef: React.RefObject<HTMLDivElement>;
splitterAutoSaveId: string;
@ -134,25 +280,14 @@ const AppSplitterBase = forwardRef<
},
ref
) => {
// ================================
// REFS AND STATE
// ================================
const startPosRef = useRef(0);
const startSizeRef = useRef(0);
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
const [state, setState] = useState<SplitterState>({
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<number | null>(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]

View File

@ -27,7 +27,7 @@ interface Options<T> {
export function useLocalStorageState<T>(
key: string,
options?: Options<T>
): [T | undefined, (value?: SetState<T>) => void, () => T | undefined] {
): [T | undefined, (value?: SetState<T>) => void] {
const {
defaultValue,
serializer = JSON.stringify,
@ -37,27 +37,22 @@ export function useLocalStorageState<T>(
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<T>(
!('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<T>(
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<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 { ChatHeader } from './ChatHeader';
export const ChatContainer = React.memo(({ mounted }: { mounted?: boolean }) => {
console.log('ChatContainer', mounted);
export const ChatContainer = React.memo(() => {
return (
<AppPageLayout
headerSizeVariant="default"

View File

@ -41,17 +41,11 @@ export const ChatLayout: React.FC<ChatSplitterProps> = ({ 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<ChatSplitterProps> = ({ children }) => {
<ChatContextProvider>
<AppSplitter
ref={appSplitterRef}
leftChildren={<div>HUH?</div>}
leftChildren={useMemo(() => mounted && <ChatContainer />, [mounted])}
rightChildren={useMemo(
() => mounted && <FileContainer>{children}</FileContainer>,
[children, mounted]