fix(stage-web): stabilize mobile keyboard layout (#2338)

This commit is contained in:
Neko
2026-08-22 14:38:22 +08:00
committed by GitHub
parent b6d0809ecb
commit 67a0410c4d
14 changed files with 1500 additions and 48 deletions
+2
View File
@@ -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",
@@ -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> = {}): ViewportProfile {
return {
displayMode: 'browser',
height: 714,
width: 390,
...options,
}
}
function createViewportSample(options: Partial<ViewportSample> = {}): 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()
})
})
@@ -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,
}
}
@@ -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<AdaptiveInputLayout> {
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()
}
}
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive, useForwardExpose } from 'reka-ui'
import { onScopeDispose, watch } from 'vue'
import { injectAdaptiveInputRootContext } from './adaptive-input-context'
/** The polymorphic region that contains the editable control and related controls. */
export interface AdaptiveInputAreaProps {
/**
* Element or component rendered as the input area.
*
* @default 'div'
*/
as?: PrimitiveProps['as']
/**
* Merges input-area behavior into the only child element.
*
* @default false
*/
asChild?: boolean
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AdaptiveInputAreaProps>(), {
as: 'div',
asChild: false,
})
const context = injectAdaptiveInputRootContext()
const { currentElement, forwardRef } = useForwardExpose()
watch(currentElement, (element) => {
context.setArea(element && element instanceof HTMLElement ? element : null)
}, { flush: 'post', immediate: true })
onScopeDispose(() => {
if (context.area.value === currentElement.value)
context.setArea(null)
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="props.as"
:as-child="props.asChild"
:data-keyboard-visible="context.keyboardVisible.value ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,16 @@
import type { ComputedRef, Ref } from 'vue'
import { createContext } from 'reka-ui'
interface AdaptiveInputRootContext {
area: Ref<HTMLElement | null>
enabled: ComputedRef<boolean>
keyboardVisible: Readonly<Ref<boolean>>
setArea: (element: HTMLElement | null) => void
setViewport: (element: HTMLElement | null) => void
viewport: Ref<HTMLElement | null>
viewportBottom: Readonly<Ref<number>>
}
export const [injectAdaptiveInputRootContext, provideAdaptiveInputRootContext]
= createContext<AdaptiveInputRootContext>('AdaptiveInputRoot')
@@ -0,0 +1,63 @@
<script lang="ts">
import { computed, shallowRef } from 'vue'
import { useAdaptiveInput } from '../../composables/use-adaptive-input'
import { provideAdaptiveInputRootContext } from './adaptive-input-context'
/** The feature policy applied by AdaptiveInputRoot. */
export interface AdaptiveInputRootProps {
/**
* Enables keyboard measurement and layout updates.
*
* @default true
*/
enabled?: boolean
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AdaptiveInputRootProps>(), {
enabled: true,
})
defineSlots<{
default: (props: {
keyboardVisible: boolean
visibleHeight: number
viewportBottom: number
viewportOffsetTop: number
}) => unknown
}>()
const area = shallowRef<HTMLElement | null>(null)
const viewport = shallowRef<HTMLElement | null>(null)
const enabled = computed(() => props.enabled)
const {
keyboardVisible,
visibleHeight,
viewportBottom,
viewportOffsetTop,
} = useAdaptiveInput({
area,
enabled,
viewport,
})
provideAdaptiveInputRootContext({
area,
enabled,
keyboardVisible,
setArea: element => area.value = element,
setViewport: element => viewport.value = element,
viewport,
viewportBottom,
})
</script>
<template>
<slot
:keyboard-visible="keyboardVisible"
:visible-height="visibleHeight"
:viewport-bottom="viewportBottom"
:viewport-offset-top="viewportOffsetTop"
/>
</template>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive, useForwardExpose } from 'reka-ui'
import { onScopeDispose, watch } from 'vue'
import { injectAdaptiveInputRootContext } from './adaptive-input-context'
/** The polymorphic element used as the adaptive viewport. */
export interface AdaptiveInputViewportProps {
/**
* Element or component rendered as the viewport.
*
* @default 'div'
*/
as?: PrimitiveProps['as']
/**
* Merges viewport behavior into the only child element.
*
* @default false
*/
asChild?: boolean
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AdaptiveInputViewportProps>(), {
as: 'div',
asChild: false,
})
const context = injectAdaptiveInputRootContext()
const { currentElement, forwardRef } = useForwardExpose()
watch(currentElement, (element) => {
context.setViewport(element && element instanceof HTMLElement ? element : null)
}, { flush: 'post', immediate: true })
onScopeDispose(() => {
if (context.viewport.value === currentElement.value)
context.setViewport(null)
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="props.as"
:as-child="props.asChild"
:data-keyboard-visible="context.keyboardVisible.value ? '' : undefined"
:style="context.enabled.value ? { height: `${context.viewportBottom.value}px` } : undefined"
>
<slot />
</Primitive>
</template>
@@ -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'
@@ -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<Props>(), {
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<HTMLElement>('mobileInteractiveArea')
const messageComposer = useTemplateRef<HTMLElement>('messageComposer')
const interactionControls = useTemplateRef<HTMLElement>('interactionControls')
const controlsIsland = useTemplateRef<HTMLElement>('controlsIsland')
const controlsIslandContent = useTemplateRef<HTMLElement>('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()
})
</script>
<template>
<div fixed bottom-0 w-full flex flex-col>
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
<KeepAlive>
<Transition name="fade">
<ChatHistory
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
variant="mobile"
:messages="historyMessages"
:sending="isActiveSessionSending"
:streaming-message="visibleStreamingMessage"
max-w="[calc(100%-3.5rem)]"
w-full self-start pb-3 pl-3
class="chat-history"
:class="[
'relative z-20',
]"
@delete-message="handleDeleteMessage($event.index)"
@tool-call-rerun="rerunToolCall"
/>
</Transition>
</KeepAlive>
<div relative w-full self-end>
<div
ref="mobileInteractiveArea"
data-testid="mobile-interactive-area"
:class="mobileInteractiveAreaClass"
:style="mobileInteractiveAreaStyle"
>
<BackgroundDialogPicker v-model="backgroundDialogOpen" class="pointer-events-auto" />
<div
:class="[
'min-h-0 flex flex-1 flex-col justify-end overflow-hidden',
]"
>
<KeepAlive>
<Transition name="fade">
<ChatHistory
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
variant="mobile"
:messages="historyMessages"
:sending="isActiveSessionSending"
:streaming-message="visibleStreamingMessage"
class="chat-history"
:style="chatHistoryStyle"
:class="chatHistoryClass"
@delete-message="handleDeleteMessage($event.index)"
@tool-call-rerun="rerunToolCall"
/>
</Transition>
</KeepAlive>
</div>
<div
ref="interactionControls"
data-testid="mobile-interaction-controls"
:class="[
'pointer-events-auto relative w-full shrink-0 self-end',
'bg-white dark:bg-neutral-800',
]"
>
<div
data-testid="mobile-composer-underlay"
aria-hidden="true"
:class="[
'pointer-events-none absolute inset-x-0 top-full h-100dvh',
'bg-white dark:bg-neutral-800',
]"
/>
<div translate-y="[-100%]" absolute left-0 px-3 pb-3 font-sans>
<div flex="~ col" gap-1>
<slot name="status" />
</div>
</div>
<div translate-y="[-100%]" absolute right-0 px-3 pb-3 font-sans>
<div flex="~ col" gap-1>
<div
ref="controlsIsland"
data-testid="mobile-controls-island"
:class="controlsIslandClass"
:style="controlsIslandStyle"
>
<div
ref="controlsIslandContent"
:class="[
'flex flex-col gap-1',
]"
>
<ActionAbout />
<div flex="~ col" items-end gap-1>
<button
@@ -282,17 +366,25 @@ onMounted(() => {
<ViewControls />
</div>
</div>
<div bg="white dark:neutral-800" max-h-100dvh max-w-100dvw w-full flex gap-1 overflow-auto px-3 pt-2 :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 12)}px` }">
<div
ref="messageComposer"
data-testid="mobile-message-composer"
:class="[
'max-h-100dvh max-w-100dvw w-full',
'flex gap-1 overflow-auto px-3 pt-2',
'bg-white dark:bg-neutral-800',
]"
:style="messageComposerStyle"
>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 neutral-200/60 dark:neutral-700/60"
text="neutral-500 hover:neutral-600 dark:neutral-100 dark:hover:neutral-200 placeholder:neutral-400 placeholder:hover:neutral-500 placeholder:dark:neutral-300 placeholder:dark:hover:neutral-400"
bg="neutral-100/80 dark:neutral-950/80"
max-h="[10lh]" min-h="[calc(1lh+4px+4px)]"
w-full resize-none overflow-y-scroll rounded="[1lh]" px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="[themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : '']"
:class="[
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full resize-none overflow-y-scroll rounded-[1lh] px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none',
'border-2 border-solid border-neutral-200/60 bg-neutral-100/80 text-neutral-500 dark:border-neutral-700/60 dark:bg-neutral-950/80 dark:text-neutral-100',
'transition-all duration-250 ease-in-out hover:text-neutral-600 placeholder:text-neutral-400 placeholder:transition-all placeholder:duration-250 placeholder:ease-in-out placeholder:hover:text-neutral-500 dark:hover:text-neutral-200 dark:placeholder:text-neutral-300 dark:placeholder:hover:text-neutral-400',
themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : undefined,
]"
default-height="1lh"
@submit="handleSubmit"
@compositionstart="isComposing = true"
@@ -342,6 +434,29 @@ onMounted(() => {
}
.chat-history {
max-height: 35dvh;
--gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%);
-webkit-mask-image: var(--gradient);
mask-image: var(--gradient);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: bottom;
mask-position: bottom;
}
.controls-island-scroll--overflowing {
--controls-island-mask: linear-gradient(
to bottom,
transparent 0,
black 1rem,
black calc(100% - 1rem),
transparent 100%
);
-webkit-mask-image: var(--controls-island-mask);
mask-image: var(--controls-island-mask);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -0,0 +1,97 @@
import type { ConfigurableWindow, MaybeComputedElementRef } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
import type { AdaptiveInputLayout } from '../browser/adaptive-input'
import { defaultWindow, unrefElement, useEventListener, useWindowSize } from '@vueuse/core'
import { computed, readonly, shallowReactive, shallowRef, toRefs, toValue, watch } from 'vue'
import { ADAPTIVE_INPUT_LAYOUT_EVENT, AdaptiveInput } from '../browser/adaptive-input'
/** The element targets and policy consumed by {@link useAdaptiveInput}. */
export interface UseAdaptiveInputOptions extends ConfigurableWindow {
/**
* Enables keyboard measurement and layout updates.
*
* @default true
*/
enabled?: MaybeRefOrGetter<boolean>
/** The region that contains editable controls and moves above the keyboard. */
area: MaybeComputedElementRef<HTMLElement | null>
/** The region whose height follows the available viewport. */
viewport: MaybeComputedElementRef<HTMLElement | null>
}
/**
* Exposes reactive layout values from the framework-free {@link AdaptiveInput} controller.
*
* The composable owns the controller while both element targets are available. It does not move
* unrelated visual layers. Consumers decide how to use `viewportOffsetTop`.
*/
export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
const targetWindow = options.window ?? defaultWindow
const enabled = computed(() => options.enabled === undefined || toValue(options.enabled))
const area = computed(() => unrefElement(options.area))
const viewport = computed(() => unrefElement(options.viewport))
const controller = shallowRef<AdaptiveInput>()
const { height: layoutViewportHeight } = useWindowSize({
includeScrollbar: false,
initialHeight: 0,
window: targetWindow,
})
const layout = shallowReactive<AdaptiveInputLayout>({
keyboardVisible: false,
visibleHeight: layoutViewportHeight.value,
viewportBottom: layoutViewportHeight.value,
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
})
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
return
}
const currentController = new AdaptiveInput({
area: areaElement,
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
onCleanup(() => {
controller.value = undefined
currentController.dispose()
})
}, { flush: 'post', immediate: true })
watch(layoutViewportHeight, (height) => {
if (controller.value)
return
layout.keyboardVisible = false
layout.visibleHeight = height
layout.viewportBottom = height
layout.viewportOffsetTop = 0
}, { flush: 'sync' })
return toRefs(readonly(layout))
}
@@ -0,0 +1,141 @@
import type { MaybeRefOrGetter, Ref } from 'vue'
import { defaultDocument, useElementSize, usePreferredReducedMotion, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { computed, nextTick, onMounted, onScopeDispose, shallowRef, toValue, watch } from 'vue'
import { useAdaptiveInput } from './use-adaptive-input'
/** The AIRI elements and feature policy used to lay out the mobile chat surface. */
export interface UseMobileInteractiveAreaLayoutOptions {
/** The region that contains the message composer and its related controls. */
area: Readonly<Ref<HTMLElement | null>>
/** The scrollable controls beside the message composer. */
controlsIsland: Readonly<Ref<HTMLElement | null>>
/** The content used to detect whether the controls island overflows. */
controlsIslandContent: Readonly<Ref<HTMLElement | null>>
/**
* Enables keyboard-aware layout policy.
*
* @default true
*/
enabled?: MaybeRefOrGetter<boolean>
/** The message composer used to reserve controls-island space. */
messageComposer: Readonly<Ref<HTMLElement | null>>
/** The mobile chat surface whose height follows the available viewport. */
viewport: Readonly<Ref<HTMLElement | null>>
}
/**
* Applies AIRI's mobile chat proportions and transition policy to adaptive input values.
*
* This composable owns only AIRI presentation decisions. Browser keyboard measurement remains
* in {@link useAdaptiveInput}, so another framework or layout can consume the same geometry.
*/
export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveAreaLayoutOptions) {
const enabled = computed(() => options.enabled === undefined || toValue(options.enabled))
const preferredMotion = usePreferredReducedMotion()
const controlsIslandNaturalHeight = shallowRef<number>()
const controlsIslandOverflowing = shallowRef(false)
const { height: messageComposerHeight } = useElementSize(options.messageComposer, undefined, { box: 'border-box' })
const {
keyboardVisible,
visibleHeight,
viewportBottom,
viewportOffsetTop,
} = useAdaptiveInput({
area: options.area,
enabled,
viewport: options.viewport,
})
const screenSafeArea = useScreenSafeArea()
useResizeObserver(defaultDocument?.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
const viewportStyle = computed(() => enabled.value
? { height: `${viewportBottom.value}px` }
: undefined)
const chatHistoryStyle = computed(() => enabled.value
? { maxHeight: `${visibleHeight.value * 0.35}px` }
: undefined)
const controlsIslandMaxHeight = computed(() => {
const availableHeight = Math.max(visibleHeight.value - messageComposerHeight.value, 0)
if (!enabled.value || !keyboardVisible.value)
return availableHeight
return Math.min(availableHeight, visibleHeight.value * 0.45)
})
const controlsIslandHeight = computed(() => controlsIslandNaturalHeight.value === undefined
? undefined
: Math.min(controlsIslandNaturalHeight.value, controlsIslandMaxHeight.value))
const controlsIslandStyle = computed(() => controlsIslandHeight.value === undefined
? undefined
: { height: `${controlsIslandHeight.value}px` })
const messageComposerStyle = computed(() => ({
paddingBottom: `${enabled.value && keyboardVisible.value
? 12
: Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 12)}px`,
}))
let areaAnimation: Animation | undefined
watch(keyboardVisible, async (_, __, onCleanup) => {
const target = options.area.value
if (!target || preferredMotion.value === 'reduce')
return
areaAnimation?.cancel()
const previousTop = target.getBoundingClientRect().top
let canceled = false
onCleanup(() => {
canceled = true
})
await nextTick()
if (canceled || target !== options.area.value)
return
const offset = previousTop - target.getBoundingClientRect().top
if (Math.abs(offset) < 1)
return
// WORKAROUND:
// NOTICE:
// Why: A CSS height transition leaves the input at its old position during Safari's focus check.
// Root cause: The keyboard workaround must apply the new layout before it calls focus().
// Code context: AdaptiveInput updates the viewport element synchronously before focus.
// Removal condition: Safari provides keyboard geometry before it applies the focus scroll.
areaAnimation = target.animate([
{ transform: `translate3d(0, ${offset}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },
], {
duration: 250,
easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
})
}, { flush: 'sync' })
function measureControlsIslandOverflow() {
const island = options.controlsIsland.value
if (!island) {
controlsIslandNaturalHeight.value = undefined
controlsIslandOverflowing.value = false
return
}
controlsIslandNaturalHeight.value = island.scrollHeight
controlsIslandOverflowing.value = island.scrollHeight > island.clientHeight + 1
}
useResizeObserver([options.controlsIsland, options.controlsIslandContent], measureControlsIslandOverflow)
watch(controlsIslandHeight, measureControlsIslandOverflow, { flush: 'post' })
onScopeDispose(() => areaAnimation?.cancel())
return {
chatHistoryStyle,
controlsIslandOverflowing,
controlsIslandStyle,
keyboardVisible,
messageComposerStyle,
viewportOffsetTop,
viewportStyle,
}
}