From c426431e8bbdd8030dee522753f278251e28f164 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:49:13 +0800 Subject: [PATCH] fix: recalculate panel geometry on resize --- src/client/Sidebar.tsx | 25 ++++++++++++++----- src/client/breakpoints.ts | 22 ++++++++++++----- tests/breakpoints-react.spec.tsx | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/breakpoints-react.spec.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index eddb857..552dd62 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -42,7 +42,7 @@ import { } from './state.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' -import { useNarrowViewport } from './breakpoints.ts' +import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { layoutPushSize } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' @@ -184,7 +184,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // — the merged display is the right sidebar alone, the bottom tabs thrown // into its strips. Widening never rewrites the migrated state: the tabs // keep living in the right tree. - const narrow = useNarrowViewport() + const viewport = useViewportSize() + const narrow = isNarrowWidth(viewport.width) // On-screen keyboard / visual-viewport inset (mobile, split-screen, …): // when the visual viewport shrinks below the layout viewport, bottom- @@ -836,11 +837,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomOpen: snapshot.state?.bottomOpen === true, width: snapshot.state?.width ?? 0, bottomHeight: snapshot.state?.bottomHeight ?? 0, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, }) writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, viewport.height]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css @@ -955,6 +956,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ) } + const bottomPanelHeight = layoutPushSize({ + narrow, + panelOpen: state.panelOpen, + // Keep the hidden panel's geometry ready for its slide-in transition; + // only the layout push itself becomes zero while it is closed. + bottomOpen: true, + width: state.width, + bottomHeight: state.bottomHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }).height + const onNewTab = (optionId: string): void => { const service = ctx.betterSidebar const descriptor = service?.getTab(optionId) @@ -1205,7 +1218,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { data-dsh-panel data-dsh-bottom-panel style={{ - height: Math.min(state.bottomHeight, window.innerHeight), + height: bottomPanelHeight, left: centerRect.left, // Keep the panel above the on-screen keyboard when the visual // viewport shrinks (see the keyboardInset effect). diff --git a/src/client/breakpoints.ts b/src/client/breakpoints.ts index 3901cff..2535861 100644 --- a/src/client/breakpoints.ts +++ b/src/client/breakpoints.ts @@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean { return width < NARROW_MAX_WIDTH } +export interface ViewportSize { + width: number + height: number +} + /** * Live narrow-viewport flag for components. Reads `window.innerWidth` and * re-measures on resize (rAF-throttled, the repo's existing drag pattern). @@ -27,16 +32,17 @@ export function isNarrowWidth(width: number): boolean { * resize listener is equally exact for a breakpoint that never changes * while the page is open. */ -export function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState( - () => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth), - ) +export function useViewportSize(): ViewportSize { + const [size, setSize] = useState(() => ({ + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + })) useEffect(() => { if (typeof window === 'undefined') return let frame: number | null = null const measure = (): void => { frame = null - setNarrow(isNarrowWidth(window.innerWidth)) + setSize({ width: window.innerWidth, height: window.innerHeight }) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) @@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean { if (frame !== null) cancelAnimationFrame(frame) } }, []) - return narrow + return size +} + +export function useNarrowViewport(): boolean { + return isNarrowWidth(useViewportSize().width) } diff --git a/tests/breakpoints-react.spec.tsx b/tests/breakpoints-react.spec.tsx new file mode 100644 index 0000000..a9ce885 --- /dev/null +++ b/tests/breakpoints-react.spec.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useViewportSize } from '../src/client/breakpoints.ts' + +describe('useViewportSize', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('publishes height-only resizes without requiring a breakpoint change', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + const observed: string[] = [] + function Probe(): null { + const size = useViewportSize() + observed.push(`${size.width}x${size.height}`) + return null + } + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + await act(async () => { root.render() }) + + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + await act(async () => { frames.shift()?.(0) }) + + expect(observed.at(-1)).toBe('1280x600') + await act(async () => { root.unmount() }) + }) +})