fix(stage): unify mobile keyboard avoidance (#2426)
This commit is contained in:
@@ -1 +1,2 @@
|
||||
nodejs 26.7.0
|
||||
pnpm 11.24.0
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user