fix(stage): unify mobile keyboard avoidance (#2426)

This commit is contained in:
Doji
2026-09-02 02:23:24 +08:00
committed by GitHub
parent 59f8d1bc16
commit 9c213115f8
12 changed files with 101 additions and 62 deletions
@@ -19,6 +19,8 @@ export interface AdaptiveInputLayout {
* If false, the input region can use the normal viewport layout.
*/
keyboardVisible: boolean
/** The layout viewport height before the software keyboard changes the visible area. */
stableViewportHeight: number
/** The height that remains visible above the keyboard, in CSS pixels. */
visibleHeight: number
/** The bottom edge to assign to the adaptive viewport, in document coordinates and CSS pixels. */
@@ -31,6 +33,12 @@ export interface AdaptiveInputLayout {
export interface AdaptiveInputOptions {
/** The region that contains editable controls and moves above the keyboard. */
area: HTMLElement
/**
* Lets the virtual keyboard cover page content so this controller can position the input area.
*
* @default true
*/
overlayVirtualKeyboard?: boolean
/** The region whose height follows the available viewport. */
viewport: HTMLElement
/**
@@ -102,7 +110,7 @@ export class AdaptiveInput extends EventTarget {
private synchronousHeightValue: string | undefined
private viewportSample: ViewportSample | undefined
private readonly overlaysContentBeforeStart: boolean | undefined
private readonly overlaysContentBeforeStart?: boolean
constructor(options: AdaptiveInputOptions) {
super()
@@ -117,6 +125,7 @@ export class AdaptiveInput extends EventTarget {
this.referenceLayoutHeight = initialHeight
this.layoutValue = {
keyboardVisible: false,
stableViewportHeight: initialHeight,
visibleHeight: initialHeight,
viewportBottom: initialHeight,
viewportOffsetTop: 0,
@@ -124,9 +133,10 @@ export class AdaptiveInput extends EventTarget {
// TypeScript 5.9 does not include this experimental browser API in lib.dom.d.ts.
this.virtualKeyboard = Reflect.get(targetWindow.navigator, 'virtualKeyboard')
this.overlaysContentBeforeStart = this.virtualKeyboard?.overlaysContent
if (this.virtualKeyboard)
if (this.virtualKeyboard && options.overlayVirtualKeyboard !== false) {
this.overlaysContentBeforeStart = this.virtualKeyboard.overlaysContent
this.virtualKeyboard.overlaysContent = true
}
const activeElement = targetWindow.document.activeElement
this.focusPhase = activeElement !== null
@@ -287,6 +297,7 @@ export class AdaptiveInput extends EventTarget {
this.layoutValue = {
keyboardVisible,
stableViewportHeight: stableLayoutHeight,
visibleHeight,
viewportBottom,
viewportOffsetTop,
@@ -334,6 +345,7 @@ export class AdaptiveInput extends EventTarget {
const normalHeight = this.referenceLayoutHeight || readViewportProfile(this.targetWindow).height
this.layoutValue = {
keyboardVisible: false,
stableViewportHeight: normalHeight,
visibleHeight: normalHeight,
viewportBottom: normalHeight,
viewportOffsetTop: this.visualViewport?.offsetTop ?? 0,
@@ -403,6 +415,7 @@ export class AdaptiveInput extends EventTarget {
this.predictedViewportHeight = cachedHeight
this.layoutValue = {
keyboardVisible: true,
stableViewportHeight: currentProfile.height,
visibleHeight: cachedHeight,
viewportBottom: cachedHeight,
viewportOffsetTop: 0,
@@ -438,6 +451,7 @@ export class AdaptiveInput extends EventTarget {
this.predictedViewportHeight = undefined
this.layoutValue = {
keyboardVisible: false,
stableViewportHeight: currentProfile.height,
visibleHeight: currentProfile.height,
viewportBottom: currentProfile.height,
viewportOffsetTop: 0,
@@ -33,21 +33,9 @@ import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
interface Props {
/**
* Enables keyboard measurement and limits the chat layer to the visible viewport.
*
* @default false
*/
keyboardAvoidance?: boolean
}
const props = withDefaults(defineProps<Props>(), {
keyboardAvoidance: false,
})
const emit = defineEmits<{
/** Sends visualViewport.offsetTop so the parent can keep the Stage at the same screen position. */
viewportOffsetChange: [offsetTop: number]
/** Reports the stable height and offset that keep the Stage in the same screen position. */
stageViewportChange: [viewport: { height: number, offsetTop: number }]
}>()
const { isDark, toggleDark } = useTheme()
@@ -112,29 +100,31 @@ const {
controlsIslandOverflowing,
controlsIslandStyle,
messageComposerStyle,
stableViewportHeight,
viewportOffsetTop,
viewportStyle: mobileInteractiveAreaStyle,
} = useMobileInteractiveAreaLayout({
area: interactionControls,
controlsIsland,
controlsIslandContent,
enabled: () => props.keyboardAvoidance,
messageComposer,
viewport: mobileInteractiveArea,
})
watch(viewportOffsetTop, offsetTop => emit('viewportOffsetChange', offsetTop), { immediate: true })
watch(
[stableViewportHeight, viewportOffsetTop],
([height, offsetTop]) => emit('stageViewportChange', { height, offsetTop }),
{ immediate: true },
)
const mobileInteractiveAreaClass = computed(() => [
'pointer-events-none fixed inset-x-0 z-20 w-full',
const mobileInteractiveAreaClass = [
'pointer-events-none fixed inset-x-0 top-0 z-20 w-full',
'flex flex-col',
props.keyboardAvoidance ? 'top-0' : 'bottom-0',
])
const chatHistoryClass = computed(() => [
]
const chatHistoryClass = [
'pointer-events-auto relative z-20',
'max-w-[calc(100%_-_3.5rem)] w-full self-start pb-3 pl-3',
props.keyboardAvoidance ? undefined : 'max-h-[35dvh]',
])
]
const controlsIslandClass = computed(() => [
'absolute right-0 translate-y-[-100%]',
'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none',
@@ -16,6 +16,12 @@ export interface UseAdaptiveInputOptions extends ConfigurableWindow {
* @default true
*/
enabled?: MaybeRefOrGetter<boolean>
/**
* Lets the virtual keyboard cover page content so the controller can position the input area.
*
* @default true
*/
overlayVirtualKeyboard?: boolean
/** The region that contains editable controls and moves above the keyboard. */
area: MaybeComputedElementRef<HTMLElement | null>
/** The region whose height follows the available viewport. */
@@ -41,41 +47,44 @@ export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
})
const layout = shallowReactive<AdaptiveInputLayout>({
keyboardVisible: false,
stableViewportHeight: layoutViewportHeight.value,
visibleHeight: layoutViewportHeight.value,
viewportBottom: layoutViewportHeight.value,
viewportOffsetTop: 0,
})
function resetLayout(height: number) {
Object.assign(layout, {
keyboardVisible: false,
stableViewportHeight: height,
visibleHeight: height,
viewportBottom: height,
viewportOffsetTop: 0,
})
}
useEventListener(controller, ADAPTIVE_INPUT_LAYOUT_EVENT, () => {
const currentLayout = controller.value?.layout
if (!currentLayout)
return
layout.keyboardVisible = currentLayout.keyboardVisible
layout.visibleHeight = currentLayout.visibleHeight
layout.viewportBottom = currentLayout.viewportBottom
layout.viewportOffsetTop = currentLayout.viewportOffsetTop
Object.assign(layout, currentLayout)
})
watch([viewport, area, enabled], ([viewportElement, areaElement, keyboardEnabled], _, onCleanup) => {
if (!viewportElement || !areaElement || !keyboardEnabled) {
layout.keyboardVisible = false
layout.visibleHeight = layoutViewportHeight.value
layout.viewportBottom = layoutViewportHeight.value
layout.viewportOffsetTop = 0
resetLayout(layoutViewportHeight.value)
return
}
const currentController = new AdaptiveInput({
area: areaElement,
overlayVirtualKeyboard: options.overlayVirtualKeyboard,
viewport: viewportElement,
window: targetWindow,
})
controller.value = currentController
layout.keyboardVisible = currentController.layout.keyboardVisible
layout.visibleHeight = currentController.layout.visibleHeight
layout.viewportBottom = currentController.layout.viewportBottom
layout.viewportOffsetTop = currentController.layout.viewportOffsetTop
Object.assign(layout, currentController.layout)
onCleanup(() => {
controller.value = undefined
@@ -87,10 +96,7 @@ export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
if (controller.value)
return
layout.keyboardVisible = false
layout.visibleHeight = height
layout.viewportBottom = height
layout.viewportOffsetTop = 0
resetLayout(height)
}, { flush: 'sync' })
return toRefs(readonly(layout))
@@ -39,12 +39,14 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
const { height: messageComposerHeight } = useElementSize(options.messageComposer, undefined, { box: 'border-box' })
const {
keyboardVisible,
stableViewportHeight,
visibleHeight,
viewportBottom,
viewportOffsetTop,
} = useAdaptiveInput({
area: options.area,
enabled,
overlayVirtualKeyboard: false,
viewport: options.viewport,
})
@@ -135,6 +137,7 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
controlsIslandStyle,
keyboardVisible,
messageComposerStyle,
stableViewportHeight,
viewportOffsetTop,
viewportStyle,
}