From 9c213115f8bd0fff9e6eabab02b077ac32da21be Mon Sep 17 00:00:00 2001 From: Doji Date: Wed, 2 Sep 2026 02:23:24 +0800 Subject: [PATCH] fix(stage): unify mobile keyboard avoidance (#2426) --- .tool-versions | 1 + apps/stage-pocket/src/pages/index.vue | 36 ++++++++++++++---- apps/stage-web/index.html | 2 +- apps/stage-web/src/pages/index.vue | 13 +++---- .../src/browser/adaptive-input.ts | 20 ++++++++-- .../Layouts/MobileInteractiveArea.vue | 36 +++++++----------- .../src/composables/use-adaptive-input.ts | 38 +++++++++++-------- .../use-mobile-interactive-area-layout.ts | 3 ++ .../src/components/scenes/Live2D.vue | 1 - .../src/components/scenes/live2d/Canvas.vue | 6 ++- .../src/components/scenes/Spine.vue | 1 - .../src/components/scenes/spine/Canvas.vue | 6 ++- 12 files changed, 101 insertions(+), 62 deletions(-) diff --git a/.tool-versions b/.tool-versions index a996bb002..39c4ea76b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ nodejs 26.7.0 +pnpm 11.24.0 diff --git a/apps/stage-pocket/src/pages/index.vue b/apps/stage-pocket/src/pages/index.vue index eb2a621a3..88482db1a 100644 --- a/apps/stage-pocket/src/pages/index.vue +++ b/apps/stage-pocket/src/pages/index.vue @@ -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" > -
+
- - -
+ + + + + diff --git a/apps/stage-web/index.html b/apps/stage-web/index.html index d99cb5fbe..5f6ea14de 100644 --- a/apps/stage-web/index.html +++ b/apps/stage-web/index.html @@ -3,7 +3,7 @@ AIRI - + diff --git a/apps/stage-web/src/pages/index.vue b/apps/stage-web/src/pages/index.vue index 3889b6262..ca1490f00 100644 --- a/apps/stage-web/src/pages/index.vue +++ b/apps/stage-web/src/pages/index.vue @@ -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(() => ({
@@ -239,9 +237,8 @@ const cursorPosition = computed(() => ({ diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts index b5b7c2529..3f3cecbed 100644 --- a/packages/stage-layouts/src/browser/adaptive-input.ts +++ b/packages/stage-layouts/src/browser/adaptive-input.ts @@ -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, diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index 0eeb4f749..6eee3cf06 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -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(), { - 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', diff --git a/packages/stage-layouts/src/composables/use-adaptive-input.ts b/packages/stage-layouts/src/composables/use-adaptive-input.ts index 72069711f..fe8df9bbe 100644 --- a/packages/stage-layouts/src/composables/use-adaptive-input.ts +++ b/packages/stage-layouts/src/composables/use-adaptive-input.ts @@ -16,6 +16,12 @@ export interface UseAdaptiveInputOptions extends ConfigurableWindow { * @default true */ enabled?: MaybeRefOrGetter + /** + * 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 /** The region whose height follows the available viewport. */ @@ -41,41 +47,44 @@ export function useAdaptiveInput(options: UseAdaptiveInputOptions) { }) const layout = shallowReactive({ 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)) diff --git a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts index 2df4302ac..ecb4cbbcd 100644 --- a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts +++ b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts @@ -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, } diff --git a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue index dac334208..ba64d0a80 100644 --- a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue +++ b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue @@ -106,7 +106,6 @@ defineExpose({ :height="height" :resolution="live2dRenderScale" :max-fps="live2dMaxFps" - max-h="100dvh" @error="emit('error', $event)" > { diff --git a/packages/stage-ui-spine/src/components/scenes/Spine.vue b/packages/stage-ui-spine/src/components/scenes/Spine.vue index b7173876b..c8d9c81e3 100644 --- a/packages/stage-ui-spine/src/components/scenes/Spine.vue +++ b/packages/stage-ui-spine/src/components/scenes/Spine.vue @@ -56,7 +56,6 @@ defineExpose({ :width="width" :height="height" :resolution="renderScale" - max-h="100dvh" > {