fix(stage-layouts): fix iOS keyboard closing right after opening (#2436)
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import type { ViewportProfile, ViewportRectangle, ViewportSample } from './adaptive-input-geometry'
|
||||
import type { ViewportRectangle } from './adaptive-input-geometry'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
calculateCachedViewportHeight,
|
||||
calculateKeyboardShift,
|
||||
calculateVisualViewportLayout,
|
||||
} from './adaptive-input-geometry'
|
||||
@@ -27,24 +26,6 @@ function createRectangle(options: {
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -150,94 +131,4 @@ describe('adaptive input geometry', () => {
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,26 +14,6 @@ export interface ViewportRectangle {
|
||||
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. */
|
||||
@@ -69,50 +49,6 @@ export interface VisualViewportLayout {
|
||||
/** 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.
|
||||
*
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('adaptive input', () => {
|
||||
adaptiveInput.dispose()
|
||||
})
|
||||
|
||||
it('uses cached pre-layout after the closing viewport becomes stable', () => {
|
||||
it('keeps native touch activation after the closing viewport becomes stable', () => {
|
||||
const viewport = document.createElement('div')
|
||||
const area = document.createElement('div')
|
||||
const textarea = document.createElement('textarea')
|
||||
@@ -186,11 +186,11 @@ describe('adaptive input', () => {
|
||||
const pointerDown = createPointerDown()
|
||||
const nativeActivationContinues = textarea.dispatchEvent(pointerDown)
|
||||
|
||||
expect(nativeActivationContinues).toBe(false)
|
||||
expect(pointerDown.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
expect(adaptiveInput.layout.keyboardVisible).toBe(true)
|
||||
expect(viewport.style.height).toBe(`${keyboardViewportHeight}px`)
|
||||
expect(nativeActivationContinues).toBe(true)
|
||||
expect(pointerDown.defaultPrevented).toBe(false)
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(adaptiveInput.layout.keyboardVisible).toBe(false)
|
||||
expect(viewport.style.height).toBe(`${layoutHeight}px`)
|
||||
|
||||
adaptiveInput.dispose()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AdaptiveInputFocusPhase, ViewportProfile, ViewportSample } from './adaptive-input-geometry'
|
||||
import type { AdaptiveInputFocusPhase } from './adaptive-input-geometry'
|
||||
|
||||
import {
|
||||
calculateCachedViewportHeight,
|
||||
calculateKeyboardShift,
|
||||
calculateVisualViewportLayout,
|
||||
toViewportRectangle,
|
||||
@@ -62,14 +61,8 @@ const TEXT_ENTRY_SELECTOR = [
|
||||
'[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 readLayoutHeight(targetWindow: Window): number {
|
||||
return targetWindow.document.documentElement.clientHeight
|
||||
}
|
||||
|
||||
function isTextEntry(element: Element): boolean {
|
||||
@@ -78,14 +71,13 @@ function isTextEntry(element: Element): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns keyboard measurements and focus timing for one adaptive input region.
|
||||
* Owns keyboard measurements and layout 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.
|
||||
* The focus-out path writes one synchronous inline height to `viewport`. 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()
|
||||
@@ -103,12 +95,9 @@ export class AdaptiveInput extends EventTarget {
|
||||
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 synchronousHeightBeforeWrite: string | undefined
|
||||
private synchronousHeightValue: string | undefined
|
||||
private viewportSample: ViewportSample | undefined
|
||||
|
||||
private readonly overlaysContentBeforeStart?: boolean
|
||||
|
||||
@@ -146,17 +135,13 @@ export class AdaptiveInput extends EventTarget {
|
||||
: '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)
|
||||
targetWindow.addEventListener('orientationchange', this.requestMeasurement, listenerOptions)
|
||||
|
||||
this.requestMeasurement()
|
||||
}
|
||||
@@ -173,17 +158,14 @@ export class AdaptiveInput extends EventTarget {
|
||||
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.synchronousHeightBeforeWrite !== undefined
|
||||
&& this.viewport.style.height === this.synchronousHeightValue
|
||||
) {
|
||||
this.viewport.style.height = this.synchronousHeightBeforePrediction
|
||||
this.viewport.style.height = this.synchronousHeightBeforeWrite
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +179,7 @@ export class AdaptiveInput extends EventTarget {
|
||||
private readonly measure = () => {
|
||||
this.animationFrame = undefined
|
||||
|
||||
const currentProfile = readViewportProfile(this.targetWindow)
|
||||
const currentLayoutHeight = currentProfile.height
|
||||
const currentLayoutHeight = readLayoutHeight(this.targetWindow)
|
||||
if (this.focusPhase === 'idle' && !this.pendingWindowScrollRepair)
|
||||
this.referenceLayoutHeight = currentLayoutHeight
|
||||
|
||||
@@ -207,8 +188,6 @@ export class AdaptiveInput extends EventTarget {
|
||||
let visibleHeight = currentLayoutHeight
|
||||
let viewportBottom = currentLayoutHeight
|
||||
let viewportOffsetTop = 0
|
||||
let updateViewportNow = false
|
||||
|
||||
if (this.visualViewport) {
|
||||
// WORKAROUND:
|
||||
// NOTICE:
|
||||
@@ -224,35 +203,10 @@ export class AdaptiveInput extends EventTarget {
|
||||
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
|
||||
}
|
||||
}
|
||||
keyboardVisible = viewportLayout.keyboardVisible
|
||||
visibleHeight = viewportLayout.height
|
||||
viewportBottom = viewportLayout.visibleBottom
|
||||
viewportOffsetTop = viewportLayout.offsetTop
|
||||
|
||||
if (viewportLayout.heightLossExceedsThreshold)
|
||||
this.pendingWindowScrollRepair = true
|
||||
@@ -303,13 +257,6 @@ export class AdaptiveInput extends EventTarget {
|
||||
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) => {
|
||||
@@ -320,7 +267,7 @@ export class AdaptiveInput extends EventTarget {
|
||||
return
|
||||
|
||||
this.focusPhase = 'focused'
|
||||
this.referenceLayoutHeight = readViewportProfile(this.targetWindow).height
|
||||
this.referenceLayoutHeight = readLayoutHeight(this.targetWindow)
|
||||
this.requestMeasurement()
|
||||
}
|
||||
|
||||
@@ -332,17 +279,13 @@ export class AdaptiveInput extends EventTarget {
|
||||
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
|
||||
const normalHeight = this.referenceLayoutHeight || readLayoutHeight(this.targetWindow)
|
||||
this.layoutValue = {
|
||||
keyboardVisible: false,
|
||||
stableViewportHeight: normalHeight,
|
||||
@@ -352,113 +295,11 @@ export class AdaptiveInput extends EventTarget {
|
||||
}
|
||||
this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
|
||||
|
||||
if (this.synchronousHeightBeforePrediction === undefined)
|
||||
this.synchronousHeightBeforePrediction = this.viewport.style.height
|
||||
if (this.synchronousHeightBeforeWrite === undefined)
|
||||
this.synchronousHeightBeforeWrite = 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
|
||||
|
||||
// NOTICE:
|
||||
// Why: Safari must own a second touch while its software keyboard closes.
|
||||
// Root cause: Canceling this touch leaves only programmatic focus, but the native dismissal can
|
||||
// still finish and leave the input focused without a keyboard.
|
||||
// Source/context: See the closing-focus regression in adaptive-input.test.ts.
|
||||
// Removal condition: Safari exposes a keyboard lifecycle that can cancel an active dismissal.
|
||||
if (this.focusPhase === 'closing')
|
||||
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,
|
||||
stableViewportHeight: currentProfile.height,
|
||||
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,
|
||||
stableViewportHeight: currentProfile.height,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,10 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
|
||||
|
||||
// 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.
|
||||
// Why: Prevent a visible jump when delayed keyboard geometry changes the input layout.
|
||||
// Root cause: Safari starts its viewport pan before Visual Viewport reports the final geometry.
|
||||
// Source: https://bugs.webkit.org/show_bug.cgi?id=265578
|
||||
// Removal condition: Safari reports keyboard geometry before its viewport pan starts.
|
||||
areaAnimation = target.animate([
|
||||
{ transform: `translate3d(0, ${offset}px, 0)` },
|
||||
{ transform: 'translate3d(0, 0, 0)' },
|
||||
|
||||
Reference in New Issue
Block a user