From 61366b7096eefce5a5dfa43332acf57bc2ad9b54 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Sun, 15 Jun 2025 11:29:11 -0700 Subject: [PATCH] Panel: add peeking support to the store --- src/common/layout/optima/optima.config.ts | 6 +- .../layout/optima/store-layout-optima.ts | 113 ++++++++++-------- 2 files changed, 66 insertions(+), 53 deletions(-) diff --git a/src/common/layout/optima/optima.config.ts b/src/common/layout/optima/optima.config.ts index 80300086f..78c2e000f 100644 --- a/src/common/layout/optima/optima.config.ts +++ b/src/common/layout/optima/optima.config.ts @@ -1,9 +1,9 @@ // configuration export const OPTIMA_DRAWER_BACKGROUND = 'var(--joy-palette-background-popup)'; export const OPTIMA_DRAWER_MOBILE_RADIUS = 'var(--joy-radius-lg)'; -export const OPTIMA_DRAWER_HOVER_TIMEOUT = 300; // ms - exit delay: time to hide after mouse leaves -export const OPTIMA_DRAWER_HOVER_ENTER_DELAY = 200; // ms - enter delay: prevents accidental triggers -export const OPTIMA_DRAWER_OPEN_DEBOUNCE = 100; // ms +export const OPTIMA_OPEN_DEBOUNCE = 100; // ms - prevent accidental immediate close +export const OPTIMA_PEEK_HOVER_ENTER_DELAY = 200; // ms - enter delay: prevents accidental triggers +export const OPTIMA_PEEK_HOVER_TIMEOUT = 300; // ms - exit delay: time to hide after mouse leaves export const OPTIMA_NAV_RADIUS = 'sm'; export const OPTIMA_PANEL_GROUPS_SPACING = 2.5; diff --git a/src/common/layout/optima/store-layout-optima.ts b/src/common/layout/optima/store-layout-optima.ts index 1962378a9..c4a29bdfc 100644 --- a/src/common/layout/optima/store-layout-optima.ts +++ b/src/common/layout/optima/store-layout-optima.ts @@ -5,7 +5,7 @@ import { getIsMobile } from '~/common/components/useMatchMedia'; import { isBrowser } from '~/common/util/pwaUtils'; import { navItems } from '~/common/app.nav'; -import { OPTIMA_DRAWER_HOVER_ENTER_DELAY, OPTIMA_DRAWER_HOVER_TIMEOUT, OPTIMA_DRAWER_OPEN_DEBOUNCE } from './optima.config'; +import { OPTIMA_OPEN_DEBOUNCE, OPTIMA_PEEK_HOVER_ENTER_DELAY, OPTIMA_PEEK_HOVER_TIMEOUT } from './optima.config'; export type PreferencesTabId = 'chat' | 'voice' | 'draw' | 'tools' | undefined; @@ -20,6 +20,7 @@ interface OptimaState { drawerIsOpen: boolean; drawerIsPeeking: boolean; panelIsOpen: boolean; + panelIsPeeking: boolean; // modals showAIXDebugger: boolean; @@ -63,6 +64,7 @@ const initialState: OptimaState = { drawerIsOpen: initialDrawerOpen(), drawerIsPeeking: false, panelIsOpen: false, + panelIsPeeking: false, // modals that can overlay anything ...modalsClosedState, @@ -86,6 +88,8 @@ export interface OptimaActions { closePanel: () => void; openPanel: () => void; togglePanel: () => void; + peekPanelEnter: () => void; + peekPanelLeave: () => void; closeAIXDebugger: () => void; openAIXDebugger: () => void; @@ -108,19 +112,50 @@ export interface OptimaActions { } -// global state: peek hover logic timers -let peekEnterTimeoutId: ReturnType | null = null; -let peekLeaveTimeoutId: ReturnType | null = null; +const drawerPeek = createPeekHandlers('drawerIsOpen', 'drawerIsPeeking'); +const panelPeek = createPeekHandlers('panelIsOpen', 'panelIsPeeking'); -function cancelPeekTimers(which?: 'enter' | 'leave') { - if (which !== 'leave' && peekEnterTimeoutId) { - clearTimeout(peekEnterTimeoutId); - peekEnterTimeoutId = null; - } - if (which !== 'enter' && peekLeaveTimeoutId) { - clearTimeout(peekLeaveTimeoutId); - peekLeaveTimeoutId = null; - } +function createPeekHandlers< + TOpenKey extends keyof OptimaState, + TPeekingKey extends keyof OptimaState, +>(isOpenKey: TOpenKey, isPeekingKey: TPeekingKey, overrideEnterDelay?: number) { + let enterTimer: any = null; + let leaveTimer: any = null; + + return { + cancel: () => { + clearTimeout(enterTimer); + clearTimeout(leaveTimer); + enterTimer = null; + leaveTimer = null; + }, + enter: (_get: () => OptimaState, _set: (state: Partial) => void) => { + clearTimeout(leaveTimer); + leaveTimer = null; + + const state = _get(); + if (state[isOpenKey] || state[isPeekingKey]) return; + + clearTimeout(enterTimer); + enterTimer = setTimeout(() => { + _set({ [isPeekingKey]: true } as Partial); + enterTimer = null; + }, overrideEnterDelay ?? OPTIMA_PEEK_HOVER_ENTER_DELAY); + }, + leave: (_get: () => OptimaState, _set: (state: Partial) => void) => { + clearTimeout(enterTimer); + enterTimer = null; + + const state = _get(); + if (!state[isPeekingKey]) return; + + clearTimeout(leaveTimer); + leaveTimer = setTimeout(() => { + _set({ [isPeekingKey]: false } as Partial); + leaveTimer = null; + }, OPTIMA_PEEK_HOVER_TIMEOUT); + }, + }; } @@ -132,53 +167,31 @@ export const useLayoutOptimaStore = create((_set, _ closeDrawer: () => { // prevent accidental immediate close (e.g. double-click, animation protection) - if (Date.now() - _get().lastDrawerOpenTime < OPTIMA_DRAWER_OPEN_DEBOUNCE) return; - cancelPeekTimers(); + if (Date.now() - _get().lastDrawerOpenTime < OPTIMA_OPEN_DEBOUNCE) return; + drawerPeek.cancel(); _set({ drawerIsOpen: false, drawerIsPeeking: false }); }, openDrawer: () => { - cancelPeekTimers(); + drawerPeek.cancel(); _set({ drawerIsOpen: true, drawerIsPeeking: false, lastDrawerOpenTime: Date.now() }); }, toggleDrawer: () => _get().drawerIsOpen ? _get().closeDrawer() : _get().openDrawer(), - - peekDrawerEnter: () => { - cancelPeekTimers('leave'); - - // if drawer is already open, no need to peek - const { drawerIsOpen, drawerIsPeeking } = _get(); - if (drawerIsOpen || drawerIsPeeking) return; - - // start a new timer to show the drawer after a small delay - cancelPeekTimers('enter'); - peekEnterTimeoutId = setTimeout(() => { - _set({ drawerIsPeeking: true }); - peekEnterTimeoutId = null; - }, OPTIMA_DRAWER_HOVER_ENTER_DELAY); - }, - peekDrawerLeave: () => { - cancelPeekTimers('enter'); - - // only start leave timer if currently peeking - const { drawerIsPeeking } = _get(); - if (!drawerIsPeeking) return; - - // start a new timer to hide the drawer - cancelPeekTimers('leave'); - peekLeaveTimeoutId = setTimeout(() => { - _set({ drawerIsPeeking: false }); - peekLeaveTimeoutId = null; - }, OPTIMA_DRAWER_HOVER_TIMEOUT); - }, + peekDrawerEnter: () => drawerPeek.enter(_get, _set), + peekDrawerLeave: () => drawerPeek.leave(_get, _set), closePanel: () => { - // NOTE: would this make sense? - // if (Date.now() - _get().lastPanelOpenTime >= 100) - // _set({ panelIsOpen: false }); - _set({ panelIsOpen: false }); + // prevent accidental immediate close (e.g. double-click, animation protection) + if (Date.now() - _get().lastPanelOpenTime < OPTIMA_OPEN_DEBOUNCE) return; + panelPeek.cancel(); + _set({ panelIsOpen: false, panelIsPeeking: false }); + }, + openPanel: () => { + panelPeek.cancel(); + _set({ panelIsOpen: true, panelIsPeeking: false, lastPanelOpenTime: Date.now() }); }, - openPanel: () => _set({ panelIsOpen: true, lastPanelOpenTime: Date.now() }), togglePanel: () => _get().panelIsOpen ? _get().closePanel() : _get().openPanel(), + peekPanelEnter: () => panelPeek.enter(_get, _set), + peekPanelLeave: () => panelPeek.leave(_get, _set), closeAIXDebugger: () => _set({ showAIXDebugger: false }), openAIXDebugger: () => _set({ ...modalsClosedState, showAIXDebugger: true }),