fix(stage-web): stabilize mobile keyboard layout (#2338)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user