From 67a0410c4d0c5520d34d12c6caf04701de527e08 Mon Sep 17 00:00:00 2001 From: Neko Date: Sat, 22 Aug 2026 14:38:22 +0800 Subject: [PATCH] fix(stage-web): stabilize mobile keyboard layout (#2338) --- AGENTS.md | 8 + apps/stage-web/src/pages/index.vue | 37 +- packages/stage-layouts/package.json | 2 + .../browser/adaptive-input-geometry.test.ts | 243 ++++++++++ .../src/browser/adaptive-input-geometry.ts | 180 +++++++ .../src/browser/adaptive-input.ts | 441 ++++++++++++++++++ .../AdaptiveInput/adaptive-input-area.vue | 53 +++ .../AdaptiveInput/adaptive-input-context.ts | 16 + .../AdaptiveInput/adaptive-input-root.vue | 63 +++ .../AdaptiveInput/adaptive-input-viewport.vue | 54 +++ .../src/components/AdaptiveInput/index.ts | 8 + .../Layouts/MobileInteractiveArea.vue | 205 ++++++-- .../src/composables/use-adaptive-input.ts | 97 ++++ .../use-mobile-interactive-area-layout.ts | 141 ++++++ 14 files changed, 1500 insertions(+), 48 deletions(-) create mode 100644 packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts create mode 100644 packages/stage-layouts/src/browser/adaptive-input-geometry.ts create mode 100644 packages/stage-layouts/src/browser/adaptive-input.ts create mode 100644 packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue create mode 100644 packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts create mode 100644 packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue create mode 100644 packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue create mode 100644 packages/stage-layouts/src/components/AdaptiveInput/index.ts create mode 100644 packages/stage-layouts/src/composables/use-adaptive-input.ts create mode 100644 packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts diff --git a/AGENTS.md b/AGENTS.md index 34c849f92..09f349344 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,14 @@ as a first language. - Comments should explain information the code cannot express clearly: intent, constraints, ownership, invariants, precedence, lifecycle, ordering, side effects, protocol shape, or non-obvious fallbacks. - Do not add comments that only restate names, types, or visible operations. +- Treat a contract comment as an explanation of the relationship between a producer and its consumers. +- Explain why a value exists in the system before you explain how the code represents it. +- Describe the decision, behavior, or invariant that a value controls. +- If different values select different control-flow or UI paths, describe each observable outcome. +- When a value crosses a module or component boundary, identify the consumer and how it applies the value. +- Put representation details after behavior: units, coordinate systems, thresholds, clamps, and source API fields. +- Put background evidence after the contract: browser behavior, issue links, investigation history, and removal conditions. +- If the name, type, and surrounding code express the full contract, omit the comment. - Place implementation comments next to the branch, calculation, transition, or side effect they explain. - For calculation-heavy code, explain non-obvious coordinate systems, units, conversions, clamps, rounding, aggregation, and precedence beside the relevant intermediate values or branches. - Prefer clearer names, types, and structured state over comments that compensate for hidden or encoded concepts. diff --git a/apps/stage-web/src/pages/index.vue b/apps/stage-web/src/pages/index.vue index 258b33355..79ff4972e 100644 --- a/apps/stage-web/src/pages/index.vue +++ b/apps/stage-web/src/pages/index.vue @@ -21,7 +21,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider' 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' const paused = ref(false) @@ -31,6 +31,23 @@ function handleSettingsOpen(open: boolean) { const breakpoints = useBreakpoints(breakpointsTailwind) const isMobile = breakpoints.smaller('md') +const stageViewportOffset = shallowRef(0) +// WORKAROUND: +// 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)`, + willChange: 'transform', + } + : undefined) const backgroundStore = useBackgroundStore() const { selectedOption, sampledColor } = storeToRefs(backgroundStore) @@ -183,9 +200,16 @@ const cursorPosition = computed(() => ({ ref="backgroundSurface" class="widgets top-widgets" :background="selectedOption" + :style="stageSurfaceStyle" :top-color="sampledColor" > -
+
-
+ + + diff --git a/packages/stage-layouts/package.json b/packages/stage-layouts/package.json index 6fdc00503..f2fab5a1b 100644 --- a/packages/stage-layouts/package.json +++ b/packages/stage-layouts/package.json @@ -16,7 +16,9 @@ }, "exports": { ".": "./src/index.ts", + "./browser/adaptive-input": "./src/browser/adaptive-input.ts", "./layouts/*": "./src/layouts/*.vue", + "./components/AdaptiveInput": "./src/components/AdaptiveInput/index.ts", "./components/Layouts/*": "./src/components/Layouts/*.vue", "./components/Layouts/InteractiveArea/Actions/*": "./src/components/Layouts/InteractiveArea/Actions/*.vue", "./components/Layouts/ViewControls/*": "./src/components/Layouts/ViewControls/*.vue", diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts new file mode 100644 index 000000000..0b46a1ed3 --- /dev/null +++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts @@ -0,0 +1,243 @@ +import type { ViewportProfile, ViewportRectangle, ViewportSample } from './adaptive-input-geometry' + +import { describe, expect, it } from 'vitest' + +import { + calculateCachedViewportHeight, + calculateKeyboardShift, + calculateVisualViewportLayout, +} from './adaptive-input-geometry' + +function createRectangle(options: { + bottom: number + left?: number + right?: number + top: number +}): ViewportRectangle { + const left = options.left ?? 0 + const right = options.right ?? 390 + + return { + bottom: options.bottom, + height: options.bottom - options.top, + left, + right, + top: options.top, + width: right - left, + } +} + +function createViewportProfile(options: Partial = {}): ViewportProfile { + return { + displayMode: 'browser', + height: 714, + width: 390, + ...options, + } +} + +function createViewportSample(options: Partial = {}): ViewportSample { + return { + bottomHiddenByKeyboard: 310, + measuredAt: 1_000, + profile: createViewportProfile(), + ...options, + } +} + +describe('adaptive input geometry', () => { + it('moves a bottom input area above a docked keyboard', () => { + const target = createRectangle({ top: 780, bottom: 844 }) + const keyboard = createRectangle({ top: 544, bottom: 844 }) + + expect(calculateKeyboardShift(target, keyboard)).toBe(300) + }) + + it('does not move the input area for a floating keyboard without horizontal overlap', () => { + const target = createRectangle({ top: 780, bottom: 844, left: 0, right: 180 }) + const keyboard = createRectangle({ top: 500, bottom: 700, left: 210, right: 380 }) + + expect(calculateKeyboardShift(target, keyboard)).toBe(0) + }) + + it('provides the offset needed to counter the iOS visual viewport pan', () => { + const layout = calculateVisualViewportLayout({ + height: 404, + offsetTop: 310, + pageTop: 310, + }, 714, 'focused') + + expect(layout.height).toBe(404) + expect(layout.keyboardVisible).toBe(true) + expect(layout.offsetTop).toBe(310) + expect(layout.visibleBottom).toBe(714) + }) + + // https://bugs.webkit.org/show_bug.cgi?id=265578 + it('keeps the chat bottom at the visual viewport bottom for WebKit bug 265578', () => { + // ROOT CAUSE: + // + // Safari can increase pageTop before it reports the final keyboard height. + // A calculation that uses height alone places the chat layer above the visible bottom edge. + // + // Before the fix, the chat layer used visualViewport.height - keyboardShift as its bottom edge. + // + // We fixed this by using height + pageTop as the bottom edge in document coordinates. + // Only the chat layer uses this value. + const layout = calculateVisualViewportLayout({ + height: 404, + offsetTop: 310, + pageTop: 310, + }, 714, 'focused') + + expect(layout.visibleBottom).toBe(714) + }) + + it('detects a keyboard when the browser resizes both viewports', () => { + const layout = calculateVisualViewportLayout({ + height: 404, + offsetTop: 0, + pageTop: 0, + }, 714, 'focused') + + expect(layout.keyboardVisible).toBe(true) + expect(layout.offsetTop).toBe(0) + expect(layout.visibleBottom).toBe(404) + }) + + it('does not treat browser controls as a keyboard', () => { + const layout = calculateVisualViewportLayout({ + height: 654, + offsetTop: 0, + pageTop: 0, + }, 714, 'focused') + + expect(layout.keyboardVisible).toBe(false) + expect(layout.visibleBottom).toBe(654) + }) + + // https://bugs.webkit.org/show_bug.cgi?id=265578 + it('restores the input layout while Safari finishes closing the keyboard', () => { + // ROOT CAUSE: + // + // Safari keeps reporting the keyboard-sized Visual Viewport for part of its close animation. + // If AIRI uses that stale measurement after blur, the input region stays compressed and leaves + // a visible gap above the keyboard. + // + // Before the fix, blur disabled keyboardVisible but kept height and visibleBottom at 404px. + // + // We fixed this by restoring the stable layout height as soon as the owned input loses focus. + // Later Visual Viewport events cannot compress this input region again during keyboard close. + const layout = calculateVisualViewportLayout({ + height: 404, + offsetTop: 0, + pageTop: 0, + }, 714, 'closing') + + expect(layout.height).toBe(714) + expect(layout.heightLossExceedsThreshold).toBe(true) + expect(layout.keyboardVisible).toBe(false) + expect(layout.offsetTop).toBe(0) + expect(layout.visibleBottom).toBe(714) + }) + + it('keeps the Stage correction offset while Safari closes a panned viewport', () => { + const layout = calculateVisualViewportLayout({ + height: 404, + offsetTop: 310, + pageTop: 310, + }, 714, 'closing') + + expect(layout.offsetTop).toBe(310) + }) + + // https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API + it('calculates the pre-focus height from a previous keyboard measurement', () => { + // ROOT CAUSE: + // + // Safari starts its native page pan before it reports stable Visual Viewport geometry. + // A correction after focus cannot remove the compositor frames that move the Stage. + // + // Before the fix, AIRI waited for a Visual Viewport event before it changed the chat height. + // + // We fixed this by applying a recent height hidden by the keyboard before focus. + // The focused control is inside the predicted visible area when Safari starts its focus policy. + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile(), + 2_000, + ) + + expect(cachedHeight).toBe(404) + }) + + it('applies a recent height hidden by the keyboard to a small layout-height change', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile({ height: 734 }), + 2_000, + ) + + expect(cachedHeight).toBe(424) + }) + + it('rejects a cached measurement after an orientation change', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile({ height: 390, width: 844 }), + 2_000, + ) + + expect(cachedHeight).toBeUndefined() + }) + + it('rejects a cached measurement from another display mode', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile({ displayMode: 'standalone' }), + 2_000, + ) + + expect(cachedHeight).toBeUndefined() + }) + + it('rejects a cached measurement after a large layout-height change', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile({ height: 834 }), + 2_000, + ) + + expect(cachedHeight).toBeUndefined() + }) + + it('rejects a cached measurement from another layout width', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile({ width: 430 }), + 2_000, + ) + + expect(cachedHeight).toBeUndefined() + }) + + it('rejects a cached measurement without a keyboard-sized hidden height', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample({ bottomHiddenByKeyboard: 60 }), + createViewportProfile(), + 2_000, + ) + + expect(cachedHeight).toBeUndefined() + }) + + it('rejects an old cached measurement', () => { + const cachedHeight = calculateCachedViewportHeight( + createViewportSample(), + createViewportProfile(), + 3_601_000, + ) + + expect(cachedHeight).toBeUndefined() + }) +}) diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts new file mode 100644 index 000000000..7f36ea661 --- /dev/null +++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts @@ -0,0 +1,180 @@ +/** A rectangle in layout viewport coordinates and CSS pixels. */ +export interface ViewportRectangle { + /** The bottom edge in CSS pixels. */ + bottom: number + /** The rectangle height in CSS pixels. */ + height: number + /** The left edge in CSS pixels. */ + left: number + /** The right edge in CSS pixels. */ + right: number + /** The top edge in CSS pixels. */ + top: number + /** The rectangle width in CSS pixels. */ + width: number +} + +/** The viewport properties that determine whether a keyboard sample can be reused. */ +export interface ViewportProfile { + /** The browser or installed-app mode that owns the viewport policy. */ + displayMode: 'browser' | 'standalone' + /** The layout viewport height in CSS pixels. */ + height: number + /** The layout viewport width in CSS pixels. */ + width: number +} + +/** A measured keyboard overlap that can prepare a later focus operation. */ +export interface ViewportSample { + /** The layout viewport height hidden by the keyboard, in CSS pixels. */ + bottomHiddenByKeyboard: number + /** The Unix timestamp in milliseconds when the sample was recorded. */ + measuredAt: number + /** The viewport profile that produced the sample. */ + profile: ViewportProfile +} + +/** The Visual Viewport values used by the fallback layout policy. */ +export interface VisualViewportMeasurement { + /** The current visual viewport height in CSS pixels. */ + height: number + /** The current visual viewport offsetTop in CSS pixels. */ + offsetTop: number + /** The current visual viewport pageTop in CSS pixels. */ + pageTop: number +} + +/** The input lifecycle phase used to choose how one Visual Viewport measurement affects layout. */ +export type AdaptiveInputFocusPhase = 'idle' | 'focused' | 'closing' + +/** The resolved fallback layout for an adaptive input region. */ +export interface VisualViewportLayout { + /** The height to assign to keyboard-aware content, in CSS pixels. */ + height: number + /** + * Whether to treat the height loss as keyboard-related. + * + * If true, a focused editable control can enable the keyboard-visible layout. + * If false, browser controls alone cannot enable the keyboard-visible layout. + */ + heightLossExceedsThreshold: boolean + /** Whether to apply the keyboard-visible layout for this measurement. */ + keyboardVisible: boolean + /** The translation that a separate visual layer can apply to cancel the Visual Viewport pan, in CSS pixels. */ + offsetTop: number + /** The bottom edge to assign to the adaptive viewport, in document coordinates and CSS pixels. */ + visibleBottom: number +} + +/** Changes below this threshold can come from browser controls instead of a software keyboard. */ +const KEYBOARD_HEIGHT_LOSS_THRESHOLD = 100 + +/** A sample expires before a long-lived tab can reuse geometry from an earlier keyboard mode. */ +const KEYBOARD_SAMPLE_MAX_AGE = 30 * 60 * 1000 + +/** A larger height change invalidates the sample because the browser layout is no longer comparable. */ +const LAYOUT_HEIGHT_CHANGE_LIMIT = 80 + +/** A width change larger than rounding noise identifies a different layout viewport. */ +const LAYOUT_WIDTH_CHANGE_LIMIT = 2 + +/** + * Calculates a pre-focus viewport height from a recent compatible sample. + * + * The result is undefined when the sample is too old or its viewport profile is not compatible. + */ +export function calculateCachedViewportHeight( + sample: ViewportSample, + currentProfile: ViewportProfile, + now: number, +): number | undefined { + const sampleAge = now - sample.measuredAt + if (sampleAge < 0 || sampleAge > KEYBOARD_SAMPLE_MAX_AGE) + return undefined + + if (sample.profile.displayMode !== currentProfile.displayMode) + return undefined + + const sampleIsLandscape = sample.profile.width > sample.profile.height + const currentIsLandscape = currentProfile.width > currentProfile.height + if (sampleIsLandscape !== currentIsLandscape) + return undefined + + if (Math.abs(sample.profile.width - currentProfile.width) > LAYOUT_WIDTH_CHANGE_LIMIT) + return undefined + + if (Math.abs(sample.profile.height - currentProfile.height) > LAYOUT_HEIGHT_CHANGE_LIMIT) + return undefined + + if (sample.bottomHiddenByKeyboard <= KEYBOARD_HEIGHT_LOSS_THRESHOLD) + return undefined + + const predictedHeight = currentProfile.height - sample.bottomHiddenByKeyboard + return predictedHeight > 0 ? predictedHeight : undefined +} + +/** + * Resolves keyboard visibility and available edges from one Visual Viewport measurement. + * + * The reference height must remain stable while an editable control has focus. + */ +export function calculateVisualViewportLayout( + viewport: VisualViewportMeasurement, + referenceLayoutHeight: number, + focusPhase: AdaptiveInputFocusPhase, +): VisualViewportLayout { + const heightLoss = Math.max(0, referenceLayoutHeight - viewport.height) + const heightLossExceedsThreshold = heightLoss > KEYBOARD_HEIGHT_LOSS_THRESHOLD + const viewportBottom = Math.max(0, viewport.height + viewport.pageTop) + const visibleBottom = referenceLayoutHeight > 0 + ? Math.min(referenceLayoutHeight, viewportBottom) + : viewportBottom + + if (focusPhase === 'closing') { + return { + height: referenceLayoutHeight, + heightLossExceedsThreshold, + keyboardVisible: false, + offsetTop: viewport.offsetTop, + visibleBottom: referenceLayoutHeight, + } + } + + return { + height: viewport.height, + heightLossExceedsThreshold, + keyboardVisible: focusPhase === 'focused' && heightLossExceedsThreshold, + offsetTop: viewport.offsetTop, + visibleBottom, + } +} + +/** Returns the upward distance needed to clear an overlapping keyboard rectangle. */ +export function calculateKeyboardShift(target: ViewportRectangle, keyboard: ViewportRectangle): number { + if (keyboard.width <= 0 || keyboard.height <= 0) + return 0 + + const hasHorizontalOverlap = target.left < keyboard.right && target.right > keyboard.left + if (!hasHorizontalOverlap) + return 0 + + return Math.max(0, target.bottom - keyboard.top) +} + +/** + * Normalizes a DOM rectangle and adds a vertical offset. + * + * @example + * toViewportRectangle(new DOMRect(0, 10, 390, 64), 20) + * // => { top: 30, bottom: 94, left: 0, right: 390, width: 390, height: 64 } + */ +export function toViewportRectangle(rect: DOMRectReadOnly, verticalOffset = 0): ViewportRectangle { + return { + bottom: rect.bottom + verticalOffset, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top + verticalOffset, + width: rect.width, + } +} diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts new file mode 100644 index 000000000..39beaf447 --- /dev/null +++ b/packages/stage-layouts/src/browser/adaptive-input.ts @@ -0,0 +1,441 @@ +import type { AdaptiveInputFocusPhase, ViewportProfile, ViewportSample } from './adaptive-input-geometry' + +import { + calculateCachedViewportHeight, + calculateKeyboardShift, + calculateVisualViewportLayout, + toViewportRectangle, +} from './adaptive-input-geometry' + +/** The event that reports a new {@link AdaptiveInputLayout}. */ +export const ADAPTIVE_INPUT_LAYOUT_EVENT = 'layoutchange' + +/** The layout values produced for an adaptive input region. */ +export interface AdaptiveInputLayout { + /** + * Whether to use the keyboard-visible layout for the input region. + * + * If true, the input region must fit above a software keyboard. + * If false, the input region can use the normal viewport layout. + */ + keyboardVisible: boolean + /** 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. */ + viewportBottom: number + /** The translation that a separate visual layer can apply to cancel the Visual Viewport pan, in CSS pixels. */ + viewportOffsetTop: number +} + +/** The elements and browser policy used by {@link AdaptiveInput}. */ +export interface AdaptiveInputOptions { + /** The region that contains editable controls and moves above the keyboard. */ + area: HTMLElement + /** The region whose height follows the available viewport. */ + viewport: HTMLElement + /** + * The window that owns the viewport measurements and browser events. + * + * @default window + */ + window?: Window +} + +const TEXT_ENTRY_SELECTOR = [ + 'textarea', + 'input:not([type])', + 'input[type="email"]', + 'input[type="number"]', + 'input[type="password"]', + 'input[type="search"]', + 'input[type="tel"]', + 'input[type="text"]', + 'input[type="url"]', + '[contenteditable]:not([contenteditable="false"])', +].join(',') + +function readViewportProfile(targetWindow: Window): ViewportProfile { + return { + displayMode: targetWindow.matchMedia('(display-mode: standalone)').matches + ? 'standalone' + : 'browser', + height: targetWindow.document.documentElement.clientHeight, + width: targetWindow.document.documentElement.clientWidth, + } +} + +function isTextEntry(element: Element): boolean { + return element.matches(TEXT_ENTRY_SELECTOR) + && !element.matches(':disabled, [readonly]') +} + +/** + * Owns keyboard measurements and focus timing for one adaptive input region. + * + * Construction starts event measurement. Call {@link dispose} when the owner releases the region. + * The controller reports each new layout through {@link ADAPTIVE_INPUT_LAYOUT_EVENT}. + * + * The Safari pre-focus path writes one synchronous inline height to `viewport`. This write must + * finish before `focus()`. The controller restores the previous height during disposal if the + * consumer has not replaced the value. + */ +export class AdaptiveInput extends EventTarget { + private readonly abortController = new AbortController() + private readonly area: HTMLElement + private readonly targetWindow: Window + private readonly viewport: HTMLElement + private readonly virtualKeyboard: (EventTarget & { + readonly boundingRect: DOMRectReadOnly + overlaysContent: boolean + }) | undefined + + private readonly visualViewport: VisualViewport | null + + private animationFrame: number | undefined + private focusPhase: AdaptiveInputFocusPhase = 'idle' + private layoutValue: AdaptiveInputLayout + private pendingWindowScrollRepair = false + private predictedViewportHeight: number | undefined + private predictionTimeout: number | undefined + private referenceLayoutHeight: number + private synchronousHeightBeforePrediction: string | undefined + private synchronousHeightValue: string | undefined + private viewportSample: ViewportSample | undefined + + private readonly overlaysContentBeforeStart: boolean | undefined + + constructor(options: AdaptiveInputOptions) { + super() + + const targetWindow = options.window ?? window + const initialHeight = targetWindow.document.documentElement.clientHeight + + this.area = options.area + this.targetWindow = targetWindow + this.viewport = options.viewport + this.visualViewport = targetWindow.visualViewport + this.referenceLayoutHeight = initialHeight + this.layoutValue = { + keyboardVisible: false, + visibleHeight: initialHeight, + viewportBottom: initialHeight, + viewportOffsetTop: 0, + } + + // 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) + this.virtualKeyboard.overlaysContent = true + + const activeElement = targetWindow.document.activeElement + this.focusPhase = activeElement !== null + && this.area.contains(activeElement) + && isTextEntry(activeElement) + ? 'focused' + : 'idle' + + const listenerOptions = { signal: this.abortController.signal } + this.area.addEventListener('pointerdown', this.onPointerDown, { + capture: true, + signal: this.abortController.signal, + }) + targetWindow.document.addEventListener('focusin', this.onFocusIn, listenerOptions) + targetWindow.document.addEventListener('focusout', this.onFocusOut, listenerOptions) + this.virtualKeyboard?.addEventListener('geometrychange', this.requestMeasurement, listenerOptions) + this.visualViewport?.addEventListener('resize', this.requestMeasurement, listenerOptions) + this.visualViewport?.addEventListener('scroll', this.requestMeasurement, listenerOptions) + targetWindow.addEventListener('resize', this.requestMeasurement, listenerOptions) + targetWindow.addEventListener('orientationchange', this.onOrientationChange, listenerOptions) + + this.requestMeasurement() + } + + /** Returns the latest layout values. */ + get layout(): Readonly { + return this.layoutValue + } + + /** Stops browser events and restores browser values changed by this controller. */ + dispose(): void { + this.abortController.abort() + + if (this.animationFrame !== undefined) + this.targetWindow.cancelAnimationFrame(this.animationFrame) + + if (this.predictionTimeout !== undefined) + this.targetWindow.clearTimeout(this.predictionTimeout) + + if (this.virtualKeyboard && this.overlaysContentBeforeStart !== undefined) + this.virtualKeyboard.overlaysContent = this.overlaysContentBeforeStart + + if ( + this.synchronousHeightBeforePrediction !== undefined + && this.viewport.style.height === this.synchronousHeightValue + ) { + this.viewport.style.height = this.synchronousHeightBeforePrediction + } + } + + private readonly requestMeasurement = () => { + if (this.animationFrame !== undefined) + return + + this.animationFrame = this.targetWindow.requestAnimationFrame(this.measure) + } + + private readonly measure = () => { + this.animationFrame = undefined + + const currentProfile = readViewportProfile(this.targetWindow) + const currentLayoutHeight = currentProfile.height + if (this.focusPhase === 'idle' && !this.pendingWindowScrollRepair) + this.referenceLayoutHeight = currentLayoutHeight + + const stableLayoutHeight = this.referenceLayoutHeight || currentLayoutHeight + let keyboardVisible = false + let visibleHeight = currentLayoutHeight + let viewportBottom = currentLayoutHeight + let viewportOffsetTop = 0 + let updateViewportNow = false + + if (this.visualViewport) { + // WORKAROUND: + // NOTICE: + // Why: Safari sends Visual Viewport changes after its compositor starts the input pan. + // Root cause: Safari browser tabs do not implement navigator.virtualKeyboard. + // Source: https://bugs.webkit.org/show_bug.cgi?id=265578 + // Related WebKit issue: https://bugs.webkit.org/show_bug.cgi?id=297779#c23 + // Code reference: https://github.com/Ajaxy/telegram-tt/blob/8b63941b230b3870accc442b5ef5ac95fc53c719/src/util/windowSize.ts#L11-L51 + // Removal condition: Safari provides keyboard geometry before the compositor pan starts. + const viewportLayout = calculateVisualViewportLayout({ + height: this.visualViewport.height, + offsetTop: this.visualViewport.offsetTop, + pageTop: this.visualViewport.pageTop, + }, stableLayoutHeight, this.focusPhase) + + const predictedHeight = this.predictedViewportHeight + const predictionIsActive = predictedHeight !== undefined && this.focusPhase === 'focused' + if (predictionIsActive && !viewportLayout.heightLossExceedsThreshold) { + keyboardVisible = true + visibleHeight = predictedHeight + viewportBottom = predictedHeight + } + else { + keyboardVisible = viewportLayout.keyboardVisible + visibleHeight = viewportLayout.height + viewportBottom = viewportLayout.visibleBottom + viewportOffsetTop = viewportLayout.offsetTop + + if (viewportLayout.keyboardVisible) { + this.viewportSample = { + bottomHiddenByKeyboard: Math.max(0, stableLayoutHeight - viewportLayout.height), + measuredAt: Date.now(), + profile: currentProfile, + } + } + + if (predictionIsActive && viewportLayout.heightLossExceedsThreshold) { + this.predictedViewportHeight = undefined + if (this.predictionTimeout !== undefined) + this.targetWindow.clearTimeout(this.predictionTimeout) + this.predictionTimeout = undefined + updateViewportNow = true + } + } + + if (viewportLayout.heightLossExceedsThreshold) + this.pendingWindowScrollRepair = true + + if (this.pendingWindowScrollRepair && !viewportLayout.heightLossExceedsThreshold) { + // WORKAROUND: + // NOTICE: + // Why: Safari can leave the page scrolled after the keyboard closes. + // Root cause: Safari keeps the Visual Viewport offset after the height returns. + // Source: https://github.com/Ajaxy/telegram-tt/blob/8b63941b230b3870accc442b5ef5ac95fc53c719/src/components/middle/MiddleColumn.tsx#L380-L415 + // Removal condition: Safari resets the page scroll when the keyboard closes. + if (this.visualViewport.offsetTop > 0 || this.visualViewport.pageTop > 0) + this.targetWindow.scrollTo({ top: 0 }) + + this.pendingWindowScrollRepair = false + } + + if (this.focusPhase === 'closing' && !viewportLayout.heightLossExceedsThreshold) + this.focusPhase = 'idle' + } + else if (this.focusPhase === 'closing') { + this.focusPhase = 'idle' + } + + if (this.focusPhase === 'focused' && this.virtualKeyboard?.overlaysContent && this.virtualKeyboard.boundingRect.height > 0) { + const shiftedAreaRect = this.area.getBoundingClientRect() + const appliedBottomInset = Math.max(0, currentLayoutHeight - this.layoutValue.viewportBottom) + // The rectangle includes the current upward shift. Add the inset to recover its layout position. + const areaRect = toViewportRectangle(shiftedAreaRect, appliedBottomInset) + + // VirtualKeyboard.boundingRect reports the keyboard intersection with the viewport. + // Intersect both rectangles so a floating keyboard moves only the covered area. + // Specification: https://github.com/w3c/virtual-keyboard/blob/8ed1fe298ba42579647315988e5875715bb010af/index.html + // Code reference: https://github.com/GoogleChrome/samples/tree/1eef1eeb6048684020d1160499e552b79843d000/virtualkeyboard + const keyboardShift = calculateKeyboardShift( + areaRect, + toViewportRectangle(this.virtualKeyboard.boundingRect), + ) + keyboardVisible = true + viewportBottom = Math.max(0, currentLayoutHeight - keyboardShift) + } + + this.layoutValue = { + keyboardVisible, + visibleHeight, + viewportBottom, + viewportOffsetTop, + } + this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT)) + + if (updateViewportNow) { + if (this.synchronousHeightBeforePrediction === undefined) + this.synchronousHeightBeforePrediction = this.viewport.style.height + this.synchronousHeightValue = `${viewportBottom}px` + this.viewport.style.height = this.synchronousHeightValue + } + } + + private readonly onFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Element)) + throw new TypeError('The focusin event target must be an Element.') + + if (!this.area.contains(event.target) || !isTextEntry(event.target)) + return + + this.focusPhase = 'focused' + this.referenceLayoutHeight = readViewportProfile(this.targetWindow).height + this.requestMeasurement() + } + + private readonly onFocusOut = (event: FocusEvent) => { + if (!(event.target instanceof Element)) + throw new TypeError('The focusout event target must be an Element.') + + if (!this.area.contains(event.target) || !isTextEntry(event.target)) + return + + this.focusPhase = 'closing' + this.predictedViewportHeight = undefined + if (this.predictionTimeout !== undefined) + this.targetWindow.clearTimeout(this.predictionTimeout) + this.predictionTimeout = undefined + + // NOTICE: + // Why: The input region must follow the keyboard as soon as its owned input loses focus. + // Root cause: Safari keeps the keyboard-sized Visual Viewport until its close animation ends. + // Source: https://bugs.webkit.org/show_bug.cgi?id=265578 + // Removal condition: Safari reports each intermediate keyboard close frame through a keyboard API. + const normalHeight = this.referenceLayoutHeight || readViewportProfile(this.targetWindow).height + this.layoutValue = { + keyboardVisible: false, + visibleHeight: normalHeight, + viewportBottom: normalHeight, + viewportOffsetTop: this.visualViewport?.offsetTop ?? 0, + } + this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT)) + + if (this.synchronousHeightBeforePrediction === undefined) + this.synchronousHeightBeforePrediction = this.viewport.style.height + this.synchronousHeightValue = `${normalHeight}px` + this.viewport.style.height = this.synchronousHeightValue + + this.requestMeasurement() + } + + private readonly onOrientationChange = () => { + this.viewportSample = undefined + this.predictedViewportHeight = undefined + if (this.predictionTimeout !== undefined) + this.targetWindow.clearTimeout(this.predictionTimeout) + this.predictionTimeout = undefined + this.requestMeasurement() + } + + private readonly onPointerDown = (event: PointerEvent) => { + if (this.virtualKeyboard || !this.visualViewport || !this.viewportSample) + return + + if (event.defaultPrevented || !event.cancelable || !event.isPrimary || event.button !== 0 || event.pointerType === 'mouse') + return + + if (!(event.target instanceof Element)) + throw new TypeError('The pointerdown event target must be an Element.') + + const editable = event.target.closest(TEXT_ENTRY_SELECTOR) + if (!editable || !this.area.contains(editable) || editable.matches(':disabled, [readonly]')) + return + + if (!(editable instanceof HTMLElement)) + throw new TypeError('The matched text-entry target must be an HTMLElement.') + + if (this.targetWindow.document.activeElement === editable) + return + + const currentProfile = readViewportProfile(this.targetWindow) + const cachedHeight = calculateCachedViewportHeight(this.viewportSample, currentProfile, Date.now()) + if (cachedHeight === undefined) + return + + // WORKAROUND: + // NOTICE: + // Why: The input region must clear the keyboard before Safari applies its focus pan. + // Root cause: Safari decides whether to pan the page before it reports keyboard geometry. + // Source: https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API + // Code reference: https://github.com/morethanwords/tweb/blob/b21491cfdec248127cfb6a1e6617e26826021ff4/src/helpers/dom/fixSafariStickyInput.ts#L1-L23 + // Removal condition: Safari provides keyboard geometry before its focus policy runs. + event.preventDefault() + this.referenceLayoutHeight = currentProfile.height + this.predictedViewportHeight = cachedHeight + this.layoutValue = { + keyboardVisible: true, + visibleHeight: cachedHeight, + viewportBottom: cachedHeight, + viewportOffsetTop: 0, + } + this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT)) + + if (this.synchronousHeightBeforePrediction === undefined) + this.synchronousHeightBeforePrediction = this.viewport.style.height + this.synchronousHeightValue = `${cachedHeight}px` + this.viewport.style.height = this.synchronousHeightValue + + this.viewport.getBoundingClientRect() + editable.focus({ preventScroll: true }) + + if (this.targetWindow.document.activeElement === editable) { + if (this.predictionTimeout !== undefined) + this.targetWindow.clearTimeout(this.predictionTimeout) + + // WORKAROUND: + // NOTICE: + // Why: A cached height must not keep the viewport compressed when no software keyboard opens. + // Root cause: An external keyboard produces no keyboard-sized Visual Viewport event. + // Code context: The pre-focus pointer handler applies a cached height before focus. + // Removal condition: Browsers expose keyboard visibility before focus. + this.predictionTimeout = this.targetWindow.setTimeout(() => { + this.predictedViewportHeight = undefined + this.predictionTimeout = undefined + this.requestMeasurement() + }, 1_000) + return + } + + this.predictedViewportHeight = undefined + this.layoutValue = { + keyboardVisible: false, + visibleHeight: currentProfile.height, + viewportBottom: currentProfile.height, + viewportOffsetTop: 0, + } + this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT)) + this.synchronousHeightValue = `${currentProfile.height}px` + this.viewport.style.height = this.synchronousHeightValue + this.requestMeasurement() + } +} diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue new file mode 100644 index 000000000..c68f227ff --- /dev/null +++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts new file mode 100644 index 000000000..90ba7f8a9 --- /dev/null +++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts @@ -0,0 +1,16 @@ +import type { ComputedRef, Ref } from 'vue' + +import { createContext } from 'reka-ui' + +interface AdaptiveInputRootContext { + area: Ref + enabled: ComputedRef + keyboardVisible: Readonly> + setArea: (element: HTMLElement | null) => void + setViewport: (element: HTMLElement | null) => void + viewport: Ref + viewportBottom: Readonly> +} + +export const [injectAdaptiveInputRootContext, provideAdaptiveInputRootContext] + = createContext('AdaptiveInputRoot') diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue new file mode 100644 index 000000000..00acd2b83 --- /dev/null +++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue new file mode 100644 index 000000000..2367d6d6a --- /dev/null +++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/stage-layouts/src/components/AdaptiveInput/index.ts b/packages/stage-layouts/src/components/AdaptiveInput/index.ts new file mode 100644 index 000000000..944e4c8bd --- /dev/null +++ b/packages/stage-layouts/src/components/AdaptiveInput/index.ts @@ -0,0 +1,8 @@ +export { default as AdaptiveInputArea } from './adaptive-input-area.vue' +export type { AdaptiveInputAreaProps } from './adaptive-input-area.vue' + +export { default as AdaptiveInputRoot } from './adaptive-input-root.vue' +export type { AdaptiveInputRootProps } from './adaptive-input-root.vue' + +export { default as AdaptiveInputViewport } from './adaptive-input-viewport.vue' +export type { AdaptiveInputViewportProps } from './adaptive-input-viewport.vue' diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index cf6b0281f..ccd713459 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -16,9 +16,8 @@ import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { BasicTextarea, useTheme } from '@proj-airi/ui' -import { useResizeObserver, useScreenSafeArea } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { RouterLink } from 'vue-router' @@ -26,11 +25,29 @@ import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue' import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue' import ActionAbout from './InteractiveArea/Actions/About.vue' +import { useMobileInteractiveAreaLayout } from '../../composables/use-mobile-interactive-area-layout' import { useTranscriptions } from '../../composables/use-transcriptions' 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] +}>() + const { isDark, toggleDark } = useTheme() const chatOrchestrator = useChatStore() const chatSession = useChatSessionStore() @@ -73,13 +90,51 @@ function handleCleanupMessages() { }) } -const messageInput = ref('') -const isComposing = ref(false) -const backgroundDialogOpen = ref(false) -const sessionsDrawerOpen = ref(false) +const messageInput = shallowRef('') +const isComposing = shallowRef(false) +const backgroundDialogOpen = shallowRef(false) +const sessionsDrawerOpen = shallowRef(false) +const mobileInteractiveArea = useTemplateRef('mobileInteractiveArea') +const messageComposer = useTemplateRef('messageComposer') +const interactionControls = useTemplateRef('interactionControls') +const controlsIsland = useTemplateRef('controlsIsland') +const controlsIslandContent = useTemplateRef('controlsIslandContent') +const { + chatHistoryStyle, + controlsIslandOverflowing, + controlsIslandStyle, + messageComposerStyle, + viewportOffsetTop, + viewportStyle: mobileInteractiveAreaStyle, +} = useMobileInteractiveAreaLayout({ + area: interactionControls, + controlsIsland, + controlsIslandContent, + enabled: () => props.keyboardAvoidance, + messageComposer, + viewport: mobileInteractiveArea, +}) -const screenSafeArea = useScreenSafeArea() -useResizeObserver(document.documentElement, () => screenSafeArea.update()) +watch(viewportOffsetTop, offsetTop => emit('viewportOffsetChange', offsetTop), { immediate: true }) + +const mobileInteractiveAreaClass = computed(() => [ + 'pointer-events-none fixed inset-x-0 z-20 w-full', + 'flex flex-col', + props.keyboardAvoidance ? 'top-0' : 'bottom-0', +]) +const chatHistoryClass = computed(() => [ + '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(() => [ + 'controls-island-scroll absolute right-0 translate-y-[-100%]', + 'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none', + 'transition-[height] duration-250 ease-out', + controlsIslandOverflowing.value + ? 'controls-island-scroll--overflowing' + : undefined, +]) const { themeColorsHueDynamic } = storeToRefs(useSettings()) const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl() const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl() @@ -166,42 +221,71 @@ watch([enabled, stream], () => { onUnmounted(() => { teardownAnalyzer() }) - -onMounted(() => { - screenSafeArea.update() -})