Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/device/scrollCursorIntoView.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isSafari, isTouch } from '../browser'
import { isCapacitor, isSafari, isTouch } from '../browser'
import { PREVENT_AUTOSCROLL_TIMEOUT, isPreventAutoscrollInProgress } from '../device/preventAutoscroll'
import getSafeAreaBottom from '../device/virtual-keyboard/getSafeAreaBottom'
import viewportStore from '../stores/viewport'
import virtualKeyboardStore from '../stores/virtualKeyboardStore'

/** Scrolls the minimum amount necessary to move the viewport so that it includes the element. */
const scrollIntoViewIfNeeded = (y: number, height: number) => {
Expand All @@ -23,6 +25,17 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => {
// On desktop or when the virtual keyboard is down, it is equivalent to window.innerHeight.
const visualViewportHeight = window.visualViewport?.height ?? window.innerHeight

// On iOS Capacitor, window.visualViewport.height does NOT shrink when the keyboard opens (unlike desktop and iOS Safari),
// so it cannot detect keyboard overlap on its own. In that case, derive the visible height from the measured keyboard height.
// viewportStore.virtualKeyboardHeight is normalized against the safe-area-bottom inset by iOSCapacitorHandler, so add it back
// to recover the keyboard's true top edge. Safari and desktop keep using visualViewport.height (unchanged behavior).
// See: src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts (#3705)
const virtualKeyboard = virtualKeyboardStore.getState()
const effectiveViewportHeight =
isCapacitor() && virtualKeyboard.open
? viewport.innerHeight - viewport.virtualKeyboardHeight - getSafeAreaBottom()
: visualViewportHeight

/** The y position of the element relative to the document. */
const yDocument = viewport.layoutTreeTop + y

Expand All @@ -33,7 +46,7 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => {
const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0
const navbarRect = document.querySelector('[aria-label="nav"]')?.getBoundingClientRect()
const isAboveViewport = yViewport < toolbarBottom
const isBelowViewport = yViewport + height > visualViewportHeight - (navbarRect?.height ?? 0)
const isBelowViewport = yViewport + height > effectiveViewportHeight - (navbarRect?.height ?? 0)

if (!isAboveViewport && !isBelowViewport) return

Expand All @@ -44,7 +57,7 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => {
// add offset to account for the navbar height and prevent scrolled to elements from being hidden below
const scrollYNew = isAboveViewport
? yDocument - (toolbarRect?.height ?? 0) - height / 2
: yDocument - visualViewportHeight + height * 1.5 + (navbarRect?.height ?? 0)
: yDocument - effectiveViewportHeight + height * 1.5 + (navbarRect?.height ?? 0)

// scroll to 1 instead of 0
// otherwise Mobile Safari scrolls to the top after MultiGesture
Expand Down
18 changes: 17 additions & 1 deletion src/hooks/useScrollCursorIntoView.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { throttle } from 'lodash'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import VirtualKeyboardState from '../@types/VirtualKeyboardState'
import scrollCursorIntoView from '../device/scrollCursorIntoView'
import testFlags from '../e2e/testFlags'
import editingValueStore from '../stores/editingValue'
import virtualKeyboardStore from '../stores/virtualKeyboardStore'

const throttledScrollCursorIntoView = throttle((y: number, height: number) => scrollCursorIntoView(y, height), 400)

// Expose the throttled function so that tests can cancel its pending trailing call before asserting on the scroll position.
testFlags.throttledScrollCursorIntoView = throttledScrollCursorIntoView

/** Selects the virtual keyboard's open state. Defined at module scope so its identity is stable across renders. */
const selectVirtualKeyboardOpen = (state: VirtualKeyboardState) => state.open

/** Call scrollCursorIntoView when the y position of its container changes, or when the editing value changes. */
const useScrollCursorIntoView = (y: number, height: number) => {
const sizeRef = useRef({ y, height })
Expand All @@ -30,6 +35,17 @@ const useScrollCursorIntoView = (y: number, height: number) => {
setTimeout(() => throttledScrollCursorIntoView(sizeRef.current.y, sizeRef.current.height))
})

// Re-scroll the cursor into view when the virtual keyboard opens.
// On iOS Capacitor, tapping the cursor thought to enter edit mode opens the keyboard without changing the
// cursor's y/height or its editing value, so neither effect above fires. Without this, a thought near the
// bottom of the screen stays hidden behind the newly-opened keyboard. scrollCursorIntoView now accounts for
// the keyboard height, so re-running it once the keyboard opens lifts the thought above the keyboard (#3705).
const scrollOnKeyboardChange = useCallback(
() => throttledScrollCursorIntoView(sizeRef.current.y, sizeRef.current.height),
[],
)
virtualKeyboardStore.useSelectorEffect(scrollOnKeyboardChange, selectVirtualKeyboardOpen)

useEffect(() => scrollCursorIntoView(y, height), [height, y])
}

Expand Down
Loading