fix: cap drag height to visual viewport

This commit is contained in:
Zzy
2026-08-22 00:08:06 +08:00
parent 1c330ba44f
commit 27cd211bb3
2 changed files with 18 additions and 9 deletions
+9 -7
View File
@@ -198,6 +198,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay // Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport. // at 0. rAF-throttled, same pattern as useNarrowViewport.
const [keyboardInset, setKeyboardInset] = useState(0) const [keyboardInset, setKeyboardInset] = useState(0)
const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
const vv = window.visualViewport const vv = window.visualViewport
if (vv === null || vv === undefined) return if (vv === null || vv === undefined) return
@@ -206,6 +207,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
frame = null frame = null
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
setKeyboardInset(inset > 1 ? Math.round(inset) : 0) setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
} }
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
vv.addEventListener('resize', onResize) vv.addEventListener('resize', onResize)
@@ -220,7 +222,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// The bottom panel is offset above the on-screen keyboard. Cap its height // The bottom panel is offset above the on-screen keyboard. Cap its height
// against that same visible area, not the taller layout viewport, so the // against that same visible area, not the taller layout viewport, so the
// conversation keeps PANEL_MIN even on wide touch devices. // conversation keeps PANEL_MIN even on wide touch devices.
const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) const layoutViewportHeight = visualViewportHeight ?? viewport.height
// Current conversation (the sessions list feed). // Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore( const sessionList = useSyncExternalStore(
@@ -792,10 +794,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0)
} else if (draggingBottom) { } else if (draggingBottom) {
width = Math.min(state?.width ?? 0, window.innerWidth) width = Math.min(state?.width ?? 0, window.innerWidth)
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)))
} else if (draggingCorner) { } else if (draggingCorner) {
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX)) width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)) height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)))
} }
} }
if (width !== undefined && height !== undefined) { if (width !== undefined && height !== undefined) {
@@ -1187,7 +1189,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX)) const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY)) const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
scheduleDrag(width, height) scheduleDrag(width, height)
}} }}
onPointerUp={(event) => { onPointerUp={(event) => {
@@ -1199,7 +1201,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Up position wins over the rAF pending value (see the width // Up position wins over the rAF pending value (see the width
// strip handler — issue #247). // strip handler — issue #247).
const width = clampWidth(startWidth + (startX - event.clientX)) const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY)) const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false) setDraggingCorner(false)
}} }}
@@ -1266,7 +1268,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onPointerMove={(event) => { onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current const { startY, startHeight } = bottomDrag.current
const height = clampHeight(startHeight + (startY - event.clientY)) const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
scheduleDrag(Math.min(state.width, window.innerWidth), height) scheduleDrag(Math.min(state.width, window.innerWidth), height)
}} }}
onPointerUp={(event) => { onPointerUp={(event) => {
@@ -1277,7 +1279,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { startY, startHeight } = bottomDrag.current const { startY, startHeight } = bottomDrag.current
// Up position wins over the rAF pending value (see the width // Up position wins over the rAF pending value (see the width
// strip handler — issue #247). // strip handler — issue #247).
const height = clampHeight(startHeight + (startY - event.clientY)) const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height)) commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
setDraggingBottom(false) setDraggingBottom(false)
}} }}
+9 -2
View File
@@ -6,12 +6,19 @@ const source = readFileSync('src/client/Sidebar.tsx', 'utf8')
describe('Sidebar layout-push integration', () => { describe('Sidebar layout-push integration', () => {
it('does not bypass the shared bottom-height cap during width drags', () => { it('does not bypass the shared bottom-height cap during width drags', () => {
expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)')
expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9)
}) })
it('caps panel geometry against the viewport visible above the keyboard', () => { it('caps panel geometry against the viewport visible above the keyboard', () => {
expect(source).toContain('viewport.height - keyboardInset') expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))')
expect(source).toContain('visualViewportHeight ?? viewport.height')
expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4)
expect(source).toContain('viewport.width, layoutViewportHeight]') expect(source).toContain('viewport.width, layoutViewportHeight]')
}) })
it('reapplies the visible-height cap to every vertical drag result', () => {
expect(source).not.toMatch(/const height = clampHeight\(/)
expect(source).not.toMatch(/height = clampHeight\(/)
expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6)
})
}) })