fix: recalculate panel geometry on resize
This commit is contained in:
+19
-6
@@ -42,7 +42,7 @@ import {
|
|||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||||
import { Workbench, type WorkbenchActions } from './split-pane.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 { layoutPushSize } from './layout-push.ts'
|
||||||
import { parseDesktopEnv } from './desktop-env.ts'
|
import { parseDesktopEnv } from './desktop-env.ts'
|
||||||
import { getWcoSnapshot, subscribeWco } from './wco.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
|
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
||||||
// into its strips. Widening never rewrites the migrated state: the tabs
|
// into its strips. Widening never rewrites the migrated state: the tabs
|
||||||
// keep living in the right tree.
|
// 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, …):
|
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
|
||||||
// when the visual viewport shrinks below the layout viewport, bottom-
|
// 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,
|
bottomOpen: snapshot.state?.bottomOpen === true,
|
||||||
width: snapshot.state?.width ?? 0,
|
width: snapshot.state?.width ?? 0,
|
||||||
bottomHeight: snapshot.state?.bottomHeight ?? 0,
|
bottomHeight: snapshot.state?.bottomHeight ?? 0,
|
||||||
viewportWidth: window.innerWidth,
|
viewportWidth: viewport.width,
|
||||||
viewportHeight: window.innerHeight,
|
viewportHeight: viewport.height,
|
||||||
})
|
})
|
||||||
writeGeometry(width, 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
|
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
// HMR), the CSS variables would otherwise stay on <html> 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 onNewTab = (optionId: string): void => {
|
||||||
const service = ctx.betterSidebar
|
const service = ctx.betterSidebar
|
||||||
const descriptor = service?.getTab(optionId)
|
const descriptor = service?.getTab(optionId)
|
||||||
@@ -1205,7 +1218,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
data-dsh-panel
|
data-dsh-panel
|
||||||
data-dsh-bottom-panel
|
data-dsh-bottom-panel
|
||||||
style={{
|
style={{
|
||||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
height: bottomPanelHeight,
|
||||||
left: centerRect.left,
|
left: centerRect.left,
|
||||||
// Keep the panel above the on-screen keyboard when the visual
|
// Keep the panel above the on-screen keyboard when the visual
|
||||||
// viewport shrinks (see the keyboardInset effect).
|
// viewport shrinks (see the keyboardInset effect).
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean {
|
|||||||
return width < NARROW_MAX_WIDTH
|
return width < NARROW_MAX_WIDTH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewportSize {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
* 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
|
* resize listener is equally exact for a breakpoint that never changes
|
||||||
* while the page is open.
|
* while the page is open.
|
||||||
*/
|
*/
|
||||||
export function useNarrowViewport(): boolean {
|
export function useViewportSize(): ViewportSize {
|
||||||
const [narrow, setNarrow] = useState(
|
const [size, setSize] = useState<ViewportSize>(() => ({
|
||||||
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
|
width: typeof window === 'undefined' ? 0 : window.innerWidth,
|
||||||
)
|
height: typeof window === 'undefined' ? 0 : window.innerHeight,
|
||||||
|
}))
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
let frame: number | null = null
|
let frame: number | null = null
|
||||||
const measure = (): void => {
|
const measure = (): void => {
|
||||||
frame = null
|
frame = null
|
||||||
setNarrow(isNarrowWidth(window.innerWidth))
|
setSize({ width: window.innerWidth, height: window.innerHeight })
|
||||||
}
|
}
|
||||||
const onResize = (): void => {
|
const onResize = (): void => {
|
||||||
if (frame === null) frame = requestAnimationFrame(measure)
|
if (frame === null) frame = requestAnimationFrame(measure)
|
||||||
@@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean {
|
|||||||
if (frame !== null) cancelAnimationFrame(frame)
|
if (frame !== null) cancelAnimationFrame(frame)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
return narrow
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNarrowViewport(): boolean {
|
||||||
|
return isNarrowWidth(useViewportSize().width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(<Probe />) })
|
||||||
|
|
||||||
|
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() })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user