diff --git a/src/web-ui/src/app/layout/WorkspaceBody.scss b/src/web-ui/src/app/layout/WorkspaceBody.scss index 8a12a8484..8be759c7d 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.scss +++ b/src/web-ui/src/app/layout/WorkspaceBody.scss @@ -44,6 +44,27 @@ $_workbench-gap: 6px; width: 0; overflow: hidden; pointer-events: none; + + // Hover-to-expand flyout (issue #1039): when the collapsed sidebar is + // hovered, the full nav panel becomes a floating overlay so the user can + // peek at content without committing to a permanent expand. + &.is-hover-expanded { + position: absolute; + top: 0; + bottom: $_workbench-gap; + left: $_workbench-gap; + z-index: $z-dropdown; + flex: none; + flex-basis: auto; + width: var(--nav-width); + min-width: 0; + overflow: visible; + pointer-events: auto; + border-radius: $size-radius-md; + box-shadow: var(--bf-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.24)); + background: var(--bf-appearance-token-color-bg-primary); + transition: width 150ms ease, opacity 150ms ease; + } } } @@ -120,6 +141,10 @@ $_workbench-gap: 6px; left: calc(#{$_workbench-gap} + 72px); } +.bitfun-app-layout--macos .bitfun-workspace-body__nav-area.is-collapsed.is-hover-expanded { + left: calc(#{$_workbench-gap} + 72px); +} + .bitfun-app-layout--macos .bitfun-workspace-body__nav-area.is-collapsed + .bitfun-workspace-body__scene-area { .bitfun-scene-bar { padding-left: calc(#{$_nav-collapsed-width} + #{$size-gap-1} + 72px); @@ -143,4 +168,8 @@ $_workbench-gap: 6px; .bitfun-workspace-body__nav-divider::after { transition: none; } + + .bitfun-workspace-body__nav-area.is-collapsed.is-hover-expanded { + transition: none; + } } diff --git a/src/web-ui/src/app/layout/WorkspaceBody.tsx b/src/web-ui/src/app/layout/WorkspaceBody.tsx index 2dc9fa092..99d3efc06 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.tsx +++ b/src/web-ui/src/app/layout/WorkspaceBody.tsx @@ -23,6 +23,8 @@ const NAV_DEFAULT_WIDTH = 240; const NAV_MIN_WIDTH = 240; const NAV_MAX_WIDTH = 480; const COLLAPSE_THRESHOLD = 64; +const HOVER_EXPAND_DELAY = 200; +const HOVER_COLLAPSE_DELAY = 200; interface WorkspaceBodyProps { className?: string; @@ -49,16 +51,36 @@ const WorkspaceBody: React.FC = ({ const { state, toggleLeftPanel } = useApp(); const isNavCollapsed = state.layout.leftPanelCollapsed; const [navWidth, setNavWidth] = useState(NAV_DEFAULT_WIDTH); + const [isHoverExpanded, setIsHoverExpanded] = useState(false); const navAreaRef = useRef(null); const navDividerRef = useRef(null); // Active drag cleanup, so window listeners never leak if we unmount mid-drag. const dragCleanupRef = useRef<(() => void) | null>(null); + // Hover-expand/collapse timeout, shared between collapsed-nav and nav-area. + const hoverTimeoutRef = useRef(null); + + // Clear any pending hover timeout (expand or collapse). + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current !== null) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + // When the panel is permanently expanded (toggleLeftPanel), reset hover state. + useEffect(() => { + if (!isNavCollapsed) { + clearHoverTimeout(); + setIsHoverExpanded(false); + } + }, [isNavCollapsed, clearHoverTimeout]); useEffect(() => { return () => { dragCleanupRef.current?.(); + clearHoverTimeout(); }; - }, []); + }, [clearHoverTimeout]); const handleNavCollapseDragStart = useCallback((event: React.MouseEvent) => { if (event.button !== 0 || isNavCollapsed) return; @@ -130,6 +152,39 @@ const WorkspaceBody: React.FC = ({ dragCleanupRef.current = cleanup; }, [isNavCollapsed, navWidth, toggleLeftPanel]); + // --- Hover-to-expand (issue #1039) --- + // When the sidebar is collapsed, hovering the collapsed strip temporarily + // expands the nav panel as a floating overlay. A shared timeout lets the + // cursor move between the collapsed strip and the flyout without collapsing. + + const scheduleHoverExpand = useCallback(() => { + clearHoverTimeout(); + hoverTimeoutRef.current = window.setTimeout(() => { + setIsHoverExpanded(true); + hoverTimeoutRef.current = null; + }, HOVER_EXPAND_DELAY); + }, [clearHoverTimeout]); + + const cancelHoverExpand = useCallback(() => { + clearHoverTimeout(); + }, [clearHoverTimeout]); + + const scheduleHoverCollapse = useCallback(() => { + clearHoverTimeout(); + hoverTimeoutRef.current = window.setTimeout(() => { + setIsHoverExpanded(false); + hoverTimeoutRef.current = null; + }, HOVER_COLLAPSE_DELAY); + }, [clearHoverTimeout]); + + // Entering either the collapsed strip or the flyout itself keeps it open. + const handleNavHoverEnter = useCallback(() => { + clearHoverTimeout(); + setIsHoverExpanded(true); + }, [clearHoverTimeout]); + + const showHoverFlyout = isNavCollapsed && isHoverExpanded; + return (
= ({ data-bf-state={isNavCollapsed ? 'collapsed' : undefined} > {isNavCollapsed && ( -
+
)} @@ -146,11 +207,13 @@ const WorkspaceBody: React.FC = ({ {/* Left: nav history bar + navigation sidebar — always rendered for slide animation */}