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
+1
View File
@@ -1 +1,2 @@
nodejs 26.7.0
pnpm 11.24.0
+29 -7
View File
@@ -18,7 +18,7 @@ import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/module
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
import WebSocketStatusButton from '../components/websocket-status-button.vue'
@@ -31,6 +31,16 @@ function handleSettingsOpen(open: boolean) {
const positionCursor = useMouse()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const stageViewport = shallowRef({ height: 0, offsetTop: 0 })
const stageSurfaceStyle = computed(() => isMobile.value
? {
position: 'fixed' as const,
inset: '0',
height: stageViewport.value.height > 0 ? `${stageViewport.value.height}px` : '100dvh',
transform: `translate3d(0, ${stageViewport.value.offsetTop}px, 0)`,
willChange: 'transform',
}
: undefined)
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
@@ -178,9 +188,15 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
ref="backgroundSurface"
class="widgets top-widgets"
:background="selectedOption"
:style="stageSurfaceStyle"
:top-color="sampledColor"
>
<div flex="~ col" relative z-2 h-100dvh w-100vw of-hidden pt-safe>
<div
:class="[
'relative z-2 h-full w-100vw overflow-hidden pt-safe md:h-100dvh',
'flex flex-col',
]"
>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
@@ -208,13 +224,19 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
/>
</div>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen">
<template v-if="IS_DEV" #status>
<WebSocketStatusButton />
</template>
</MobileInteractiveArea>
</div>
</div>
<Teleport to="body">
<MobileInteractiveArea
v-if="isMobile"
@settings-open="handleSettingsOpen"
@stage-viewport-change="stageViewport = $event"
>
<template v-if="IS_DEV" #status>
<WebSocketStatusButton />
</template>
</MobileInteractiveArea>
</Teleport>
</BackgroundProvider>
</template>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>AIRI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="description" content="Open source attempt to create cyber companion.">
+5 -8
View File
@@ -28,20 +28,18 @@ function handleSettingsOpen(open: boolean) {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const stageViewportOffset = shallowRef(0)
// WORKAROUND:
const stageViewport = shallowRef({ height: 0, offsetTop: 0 })
// NOTICE:
// Why: A fixed Stage follows Safari's input pan and moves Live2D with the keyboard.
// Root cause: Safari moves the Visual Viewport before the page receives the new offsetTop value.
// Source: https://bugs.webkit.org/show_bug.cgi?id=265578
// Context: packages/stage-layouts/src/browser/adaptive-input.ts
// Removal condition: Safari keeps fixed content stable during the input pan.
const stageSurfaceStyle = computed(() => isMobile.value
? {
position: 'fixed' as const,
inset: '0',
height: '100dvh',
transform: `translate3d(0, ${stageViewportOffset.value}px, 0)`,
height: stageViewport.value.height > 0 ? `${stageViewport.value.height}px` : '100dvh',
transform: `translate3d(0, ${stageViewport.value.offsetTop}px, 0)`,
willChange: 'transform',
}
: undefined)
@@ -205,7 +203,7 @@ const cursorPosition = computed(() => ({
<div
data-testid="mobile-stage-content"
:class="[
'relative z-2 h-100dvh w-100vw overflow-hidden',
'relative z-2 h-full w-100vw overflow-hidden md:h-100dvh',
'flex flex-col',
]"
>
@@ -239,9 +237,8 @@ const cursorPosition = computed(() => ({
<Teleport to="body">
<MobileInteractiveArea
v-if="isMobile"
keyboard-avoidance
@settings-open="handleSettingsOpen"
@viewport-offset-change="stageViewportOffset = $event"
@stage-viewport-change="stageViewport = $event"
/>
</Teleport>
</BackgroundProvider>
@@ -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,
}
@@ -106,7 +106,6 @@ defineExpose({
:height="height"
:resolution="live2dRenderScale"
:max-fps="live2dMaxFps"
max-h="100dvh"
@error="emit('error', $event)"
>
<Live2DModel
@@ -152,7 +152,11 @@ import.meta.hot?.dispose(() => {
</script>
<template>
<div ref="containerRef" h-full w-full>
<div
ref="containerRef"
class="w-full"
:style="{ height: `${props.height}px` }"
>
<slot v-if="isPixiCanvasReady" :app="pixiApp" />
</div>
</template>
@@ -56,7 +56,6 @@ defineExpose({
:width="width"
:height="height"
:resolution="renderScale"
max-h="100dvh"
>
<SpineModel
ref="modelRef"
@@ -78,7 +78,11 @@ import.meta.hot?.dispose(() => {
</script>
<template>
<div ref="containerRef" h-full w-full>
<div
ref="containerRef"
class="w-full"
:style="{ height: `${props.height}px` }"
>
<slot v-if="isCanvasReady" :canvas="canvasRef" />
</div>
</template>