fix(stage): unify mobile keyboard avoidance (#2426)
This commit is contained in:
@@ -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