diff --git a/src/@types/VirtualKeyboardState.ts b/src/@types/VirtualKeyboardState.ts index 554db66e946..725f8bbed03 100644 --- a/src/@types/VirtualKeyboardState.ts +++ b/src/@types/VirtualKeyboardState.ts @@ -2,6 +2,8 @@ export default interface VirtualKeyboardState { /** True if the virtual keyboard is open. */ open: boolean - /** The height of the virtual keyboard in pixels. */ + /** The height of the virtual keyboard in pixels. Spring-animated while the keyboard slides, for UI that rides the keyboard (see usePositionFixed). */ height: number + /** The height the keyboard is animating toward — its final height, known as soon as it starts moving. Changes once per keyboard event (open, close, resize), so subscribe to this slice to react to keyboard events without firing on every animation frame. On iOS Safari, where the final height cannot be measured until the keyboard settles, this is a predicted final height learned from the previous open (see iOSSafariHandler). */ + targetHeight: number } diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 8bc8b099520..90a94f12e7b 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -32,7 +32,7 @@ import { TUTORIAL_CONTEXT2_PARENT, } from '../constants' import asyncFocus from '../device/asyncFocus' -import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll' +import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll' import * as selection from '../device/selection' import globals from '../globals' import findDescendant from '../selectors/findDescendant' @@ -218,8 +218,13 @@ const Editable = ({ // do not set cursor if it is unchanged and we are not entering when keyboard is open if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) return - // set offset to null to allow the browser to set the position of the selection - let offset = null + // When refocusing the thought that already holds the cursor — e.g. after merging siblings, or + // any programmatic focus from focusWithoutAutoscroll — preserve the existing cursorOffset. + // Nulling it here gets coerced to 0 by setCursor while editing, clobbering a caret that was + // intentionally placed (the merge put it mid-thought, then focus nulled it back to 0). Only + // default to null — letting the browser choose the position — when focusing a different + // thought, which is the genuine click/tap case this handler was written for. (#3765) + let offset: number | null = equalPath(state.cursor, path) ? state.cursorOffset : null // if running for the first time, restore the offset if the path matches the restored cursor if (!cursorOffsetInitialized) { @@ -381,10 +386,10 @@ const Editable = ({ asyncFocus({ force: true }) - preventAutoscroll(editable) - // Restore the selection offset captured when insertText(' ') arrived. - selection.set(editable, { offset: savedCharOffset }) - preventAutoscrollEnd(editable) + // Restore the selection offset captured when insertText(' ') arrived, routing through + // focusWithoutAutoscroll so the caret is retargeted to the editable without triggering + // iOS native autoscroll (#3765). + focusWithoutAutoscroll(editable, { offset: savedCharOffset }) pendingAutocompleteAt = null } @@ -640,7 +645,9 @@ const Editable = ({ }) } - if (suppressFocusStore.getState()) return + if (suppressFocusStore.getState()) { + return + } // Update editingValueUntrimmedStore with the current value editingValueUntrimmedStore.update(value) diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index b988aaa764f..b1886e0fd42 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useEffect, useRef } from 'react' import { useSelector } from 'react-redux' import { useStore } from 'react-redux' import { useDispatch } from 'react-redux' @@ -6,9 +6,8 @@ import Path from '../../@types/Path' import { setCursorActionCreator as setCursor } from '../../actions/setCursor' import { isMac, isSafari, isTouch } from '../../browser' import { LongPressState } from '../../constants' -import asyncFocus from '../../device/asyncFocus' +import focusWithoutAutoscroll from '../../device/focusWithoutAutoscroll' import getCaretOffset from '../../device/getCaretOffset' -import preventAutoscroll, { preventAutoscrollEnd } from '../../device/preventAutoscroll' import * as selection from '../../device/selection' import usePrevious from '../../hooks/usePrevious' import hasMulticursor from '../../selectors/hasMulticursor' @@ -40,7 +39,6 @@ const useEditMode = ({ const disabledRef = useRef(false) const editableNonce = useSelector(state => state.editableNonce) const showSidebar = useSelector(state => state.showSidebar) - const fontSize = useSelector(state => state.fontSize) const isCursor = useSelector(state => equalPath(path, state.cursor)) const hadSidebar = usePrevious(showSidebar) const store = useStore() @@ -55,18 +53,7 @@ const useEditMode = ({ () => { // Get the cursorOffset directly from the store rather than subscribing to it reactively with useSelector. // Otherwise, it will try to set the selection while typing. - const { cursorOffset, lastUndoableActionType } = store.getState() - - /** Set the selection to the current Editable at the cursor offset. */ - const setSelectionToCursorOffset = () => { - // do not set the selection on hidden thoughts, otherwise it will cause a faulty focus event when switching windows - // https://github.com/cybersemics/em/issues/1596 - if (style?.visibility === 'hidden') { - selection.clear() - } else { - selection.set(contentRef.current, { offset: cursorOffset ?? 0 }) - } - } + const { cursorOffset } = store.getState() // allow transient editable to have focus on render const shouldSetSelection = @@ -80,28 +67,19 @@ const useEditMode = ({ !dragHold && !disabledRef.current) - if (shouldSetSelection) { - preventAutoscroll(contentRef.current) - - /* - When a new thought is created, the Shift key should be on when Auto-Capitalization is enabled. - On Mobile Safari, Auto-Capitalization is broken if the selection is set synchronously (#999). - Only breaks on Enter or Backspace, not gesture. - - setTimeout fixes it, however it introduces an infinite loop when a nested empty thought is created. - Not calling asyncFocus when the selection is already on a thought prevents the infinite loop. - Also, setTimeout is frequently pushed into the next frame and the keyboard will intermittently close on iOS Safari. - Replacing setTimeout with requestAnimationFrame guarantees (hopefully?) that it will be processed before the next repaint, - keeping the keyboard open while rapidly deleting thoughts. (#3129) + if (!shouldSetSelection) return - If the last action is swapParent, set the selection synchronously to keep the focus stable after the swap. - */ - if (isTouch && isSafari() && lastUndoableActionType !== 'swapParent' && !selection.isThought()) { - asyncFocus() - } - - setSelectionToCursorOffset() + // do not set the selection on hidden thoughts, otherwise it will cause a faulty focus event when switching windows + // https://github.com/cybersemics/em/issues/1596 + if (style?.visibility === 'hidden') { + selection.clear() + return } + + // Whenever the cursor changes programmatically, focusWithoutAutoscroll focuses, places the + // caret, and suppresses the native focus + selection autoscroll that would otherwise jolt + // `position: fixed` elements on iOS (#3765). + focusWithoutAutoscroll(contentRef.current, { offset: cursorOffset ?? 0 }) }, // React Hook useEffect has missing dependencies: 'contentRef', 'editMode', and 'style?.visibility'. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -122,28 +100,11 @@ const useEditMode = ({ ], ) - // Provide an escape hatch to allow the next default selection rather than setting it. - // This allows the user to set the selection in the middle of a non-cursor thought when keyboard is open. - // Otherwise the caret is moved to the beginning of the thought. - const allowDefaultSelection = useCallback(() => { - disabledRef.current = true - // enable on next tick, which is long enough to skip the next setSelectionToCursorOffset - setTimeout(() => { - disabledRef.current = false - }) - }, []) - // Handles the caret positioning logic for the editable element. useEffect(() => { const editable = contentRef.current if (!editable) return - /** Sets the DOM selection and updates the Redux cursor state. */ - const setCaretOffset = (offset: number) => { - selection.set(editable, { offset }) - dispatch(setCursor({ path, offset })) - } - /** Marks the beginning of a touch so that onMouseDown can determine whether a long press is occurring. */ const onTouchStart = () => (pressingRef.current = true) @@ -153,8 +114,7 @@ const useEditMode = ({ /** * Handles the mousedown event for the editable element. * Prevents focus on non-cursor thoughts or during multiselect clicks. - * When editing or cursor is present (and multicursor is not active), computes the caret position manually so that it can be set on mouseup. - * Prevents default behavior and manages autoscroll for certain edge cases where browser selection would be incorrect. + * When editing or cursor is present (and multicursor is not active), computes the caret position manually and routes through focusWithoutAutoscroll so the iOS native autoscroll never fires. */ const onMouseDown = (e: MouseEvent) => { // If CMD/CTRL is pressed, don't focus the editable. @@ -164,11 +124,11 @@ const useEditMode = ({ return } - // If the press is ongoing (touchend has not been dispatched) then a long press is ongoing and setCaretOffset will interfere - // with default iOS Safari drag-and-drop text selection. + // If the press is ongoing (touchend has not been dispatched) then a long press is ongoing and manual caret + // positioning will interfere with default iOS Safari drag-and-drop text selection. if (pressingRef.current) return - // If editing or the cursor is on the thought, allow the default browser selection or perform manual caret positioning so the offset is correct. + // If editing or the cursor is on the thought, perform manual caret positioning so the offset is correct. // See: #981 if (editingOrOnCursor && !isMulticursor) { const { inVoidArea, offset } = getCaretOffset(editable, { @@ -176,26 +136,33 @@ const useEditMode = ({ clientY: e.clientY, }) - if (offset !== null) { - // Prevent the browser from autoscrolling to this editable element. - // For some reason doesn't work on touchend. - preventAutoscroll(editable, { - // about the height of a single-line thought - bottomMargin: fontSize * 2, - }) - - // Setting the caret offset will activate the declarative shouldSetSelection effect, which will call preventAutoscroll and selection.set - // all over again. Since the selection is managed imperatively in this handler, this duplicate behavior is undesirable. - allowDefaultSelection() - setCaretOffset(offset) - - // It's important to avoid preventDefault when the tap is somewhere that can be handled by native browser selection behavior. - // If the tap is prevented, it will interfere with functionality like double tap or the context menu. If the selection is - // truly in a void area, then preventDefault will stop the caret from being placed on the wrong thought. - if (inVoidArea) { - e.preventDefault() - } + // Block the native mousedown so it can't focus the element and place the caret itself. + // There are two reasons we want to do this: + // 1. iOS Safari: always block; let focusWithoutAutoscroll re-takes focus cleanly. + // 2. Block when the tap lands in a void area (outside any text node) to stop the caret from being + // placed on the wrong thought. + if ((isTouch && isSafari()) || inVoidArea) { + e.preventDefault() } + + // getCaretOffset returns null for empty thoughts (no text nodes). We still need to take + // focus on those — otherwise tapping an empty thought that is already the cursor leaves + // it unfocused and the keyboard never opens. + const targetOffset = offset ?? 0 + + // Dispatch setCursor first so Editable.onFocus's setCursorOnThought sees + // state.cursor === path and early-returns instead of setting cursorOffset to 0. + dispatch( + setCursor({ + path, + offset: targetOffset, + isKeyboardOpen: true, + cursorHistoryClear: true, + preserveMulticursor: true, + }), + ) + + focusWithoutAutoscroll(editable, { offset: targetOffset }) } else { // There are areas on the outside edge of the thought that will fail to trigger onTouchEnd. // In those cases, it is best to prevent onFocus or onClick, otherwise keyboard is open will be incorrectly activated. @@ -205,17 +172,10 @@ const useEditMode = ({ } } - /** Prevents the thought from autoscrolling to the bottom of the screen when the keyboard is open. - * Autoscroll must be prevented until focus handling is complete, so preventAutoscrollEnd is deferred - * using queueMicrotask without introducing any additional delay. - */ - const onFocus = () => queueMicrotask(() => preventAutoscrollEnd(editable)) - editable.addEventListener('mousedown', onMouseDown) if (isTouch && isSafari()) { editable.addEventListener('touchstart', onTouchStart) editable.addEventListener('touchend', onTouchEnd) - editable.addEventListener('focus', onFocus) } return () => { @@ -223,10 +183,9 @@ const useEditMode = ({ if (isTouch && isSafari()) { editable.removeEventListener('touchstart', onTouchStart) editable.removeEventListener('touchend', onTouchEnd) - editable.removeEventListener('focus', onFocus) } } - }, [contentRef, editingOrOnCursor, isCursor, isMulticursor, fontSize, allowDefaultSelection, path, dispatch]) + }, [contentRef, editingOrOnCursor, isMulticursor, path, dispatch]) // Resume focus if sidebar was just closed and isEditing is true. // Disable focus restoration on mobile until the hamburger menu & sidebar backdrop can be made to diff --git a/src/components/Note.tsx b/src/components/Note.tsx index b96f439d488..2ba0191d4df 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -11,8 +11,9 @@ import { setCursorActionCreator as setCursor } from '../actions/setCursor' import { setDescendantActionCreator as setDescendant } from '../actions/setDescendant' import { setNoteFocusActionCreator as setNoteFocus } from '../actions/setNoteFocus' import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote' -import { isTouch } from '../browser' -import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll' +import { isSafari, isTouch } from '../browser' +import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll' +import getCaretOffset from '../device/getCaretOffset' import * as selection from '../device/selection' import useFreshCallback from '../hooks/useFreshCallback' import getThoughtById from '../selectors/getThoughtById' @@ -48,7 +49,6 @@ const Note = React.memo( /** Focus Handling with useFreshCallback. */ const onFocus = useFreshCallback(() => { - preventAutoscrollEnd(noteRef.current) dispatch( setCursor({ path, @@ -60,11 +60,10 @@ const Note = React.memo( ) }, [dispatch, path]) - // set the caret on the note if editing this thought and noteFocus is true + // Set the caret on the note when noteFocus opens it programmatically (toggleNote command). useEffect(() => { - // cursor must be true if note is focused if (hasFocus && noteOffset !== null) { - selection.set(noteRef.current!, { offset: noteOffset }) + focusWithoutAutoscroll(noteRef.current, { offset: noteOffset }) } }, [hasFocus, noteOffset]) @@ -150,7 +149,24 @@ const Note = React.memo( [dispatch], ) - const onMouseDown = useCallback(() => preventAutoscroll(noteRef.current), [noteRef]) + const onMouseDown = useCallback((e: React.MouseEvent) => { + const note = noteRef.current + if (!note) return + + // Route through focusWithoutAutoscroll (the same single entry point Editable uses) — focuses + // with preventScroll, places the caret at the tap offset, and suppresses the selection-driven + // autoscroll on iOS. + const { inVoidArea, offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) + + // Only block the native mousedown on iOS Safari (so focusWithoutAutoscroll can re-take focus + // cleanly) or when the tap lands in a void area. On desktop, leaving the default intact + // preserves native behavior — notably double-click word selection, which preventDefault + // would otherwise suppress. + if ((isTouch && isSafari()) || inVoidArea) { + e.preventDefault() + } + focusWithoutAutoscroll(note, { offset: offset ?? 0 }) + }, []) const onCopy = useCallback((e: React.ClipboardEvent) => { const html = selection.html() diff --git a/src/components/VirtualThought.tsx b/src/components/VirtualThought.tsx index 771468d195d..82fdb72b787 100644 --- a/src/components/VirtualThought.tsx +++ b/src/components/VirtualThought.tsx @@ -7,7 +7,6 @@ import Path from '../@types/Path' import SimplePath from '../@types/SimplePath' import State from '../@types/State' import ThoughtId from '../@types/ThoughtId' -import { getAutoscrollPadding } from '../device/preventAutoscroll' import useDelayedAutofocus from '../hooks/useDelayedAutofocus' import useLayoutAnimationFrameEffect from '../hooks/useLayoutAnimationFrameEffect' import useSelectorEffect from '../hooks/useSelectorEffect' @@ -145,20 +144,12 @@ const VirtualThought = ({ const updateSize = useCallback(() => { if (!ref.current) return - // preventAutoscroll temporarily inflates the editable's padding to trick the browser out of autoscrolling. - // Subtract that padding so the measured height reflects the thought's true height even while the autoscroll - // window is open. Otherwise a height change that occurs during the window — e.g. a note added to this thought - // by Swap Note on touch devices — would be recorded with an inflated height or skipped entirely, leaving the - // next thought overlapping the note. (#4279) - const editable = ref.current.querySelector(`[data-editable]`) - const autoscrollPadding = getAutoscrollPadding(editable as HTMLElement | null) - // Need to grab max height between .thought and .thought-annotation since the annotation height might be bigger (due to wrapping link icon). const heightNew = Math.max( - ref.current.getBoundingClientRect().height - autoscrollPadding, + ref.current.getBoundingClientRect().height, ref.current.querySelector('[aria-label="thought-annotation"]')?.getBoundingClientRect().height || 0, ) - const widthNew = editable?.getBoundingClientRect().width + const widthNew = ref.current.querySelector(`[data-editable]`)?.getBoundingClientRect().width // Get the updated autofocus, otherwise isVisible will be stale. // Using the local autofocus and adding it as a dependency works when clicking on the cursor's parent but not when activating cursorBack from the keyboad for some reason. diff --git a/src/device/asyncFocus.ts b/src/device/asyncFocus.ts index ac5a3748fdc..c6cd5492cd7 100644 --- a/src/device/asyncFocus.ts +++ b/src/device/asyncFocus.ts @@ -36,7 +36,12 @@ export const AsyncFocus: () => (options?: { force?: boolean }) => void = () => { // provide the option to force the focus in order to retarget focus and prevent an iOS Safari bug (#4222) if (options.force || !selection.isThought()) { hiddenInput.disabled = false - hiddenInput.focus() + // preventScroll: true — the hidden input is pinned at the top of the document, so without + // this iOS would briefly scroll the page back to top before our real focusWithoutAutoscroll + // call takes over. preventScroll does not affect the focus itself, which is the whole point + // of asyncFocus (priming the active editing session that makes iOS honor programmatic + // selection — see focusWithoutAutoscroll). + hiddenInput.focus({ preventScroll: true }) // the hidden input should not be a valid focus target unless this function was invoked hiddenInput.disabled = true } diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts new file mode 100644 index 00000000000..df85019d40b --- /dev/null +++ b/src/device/autoscrollEdges.ts @@ -0,0 +1,78 @@ +import store from '../stores/app' +import viewportStore from '../stores/viewport' +import virtualKeyboardStore from '../stores/virtualKeyboardStore' + +/** The comfort-zone edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to + * autoscroll. Between topEdge and bottomEdge is the comfort zone: a cursor inside it never triggers a + * scroll; a cursor crossing either edge does. Each edge is inset from its occluder (toolbar / + * keyboard+navbar) by a trigger buffer, so a scroll starts a buffer's width *before* the cursor would + * actually be hidden. + * + * The edge is the single reference line for both halves of the behaviour: scrollCursorIntoView triggers + * when the cursor crosses an edge, and lands it a landingMargin back inside that same edge. So the buffer + * governs both — when a scroll fires *and*, because the landing is measured from the edge, how deep the + * cursor settles (it comes to rest buffer + landingMargin inside the visible area). Moving the buffer + * moves both together; that coupling is deliberate. */ +export interface AutoscrollEdges { + /** Bottom of the toolbar — the top of the visible area, in viewport coords. */ + toolbarBottom: number + /** Height of the navbar at the bottom of the visible area. */ + navbarHeight: number + /** Height of the virtual keyboard in px. 0 when closed. */ + keyboardInset: number + /** Trigger buffer that insets topEdge below the toolbar — see getAutoscrollEdges. */ + topBuffer: number + /** Trigger buffer that insets bottomEdge above the keyboard/navbar. 0 while the keyboard is open — see getAutoscrollEdges. */ + bottomBuffer: number + /** Crossing above this edge triggers a scroll. */ + topEdge: number + /** Crossing below this edge triggers a scroll. */ + bottomEdge: number +} + +/** Reads the live DOM and store values needed to compute the autoscroll trigger edges. */ +export const getAutoscrollEdges = (): AutoscrollEdges => { + const toolbarRect = document.getElementById('toolbar')?.getBoundingClientRect() + const navbarRect = document.querySelector('[aria-label="nav"]')?.getBoundingClientRect() + const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0 + const navbarHeight = navbarRect?.height ?? 0 + + // Use the keyboard's target height — where it will be once its animation settles — rather than + // the spring-animated `height`. The edges are end-state math: the scroll they trigger is itself + // animated, so what matters is where the cursor sits relative to the keyboard's final position, + // not its mid-slide position. On platforms without a virtual keyboard, like desktop, the store + // stays closed with targetHeight 0, so no inset gets applied. + const { targetHeight: keyboardInset, open: keyboardOpen } = virtualKeyboardStore.getState() + + const fontSize = store.getState().fontSize + + // No bottom trigger buffer while the keyboard is open: the edge sits right at the keyboard, + // leaving just enough room for one line of text above it. + const bottomBuffer = keyboardOpen ? 0 : fontSize * 2 + + // Top trigger buffer, mirroring bottomBuffer: inset topEdge ~one thought below the toolbar so a + // scroll fires before the cursor slips fully under it. Without this buffer the cursor has to pass + // entirely under the toolbar before anything happens, which makes scrolling upward (cursorUp / + // arrow-up) feel sticky. Constant regardless of keyboard state, since the toolbar — unlike the + // keyboard — does not move. + // Because the landing is measured from the edge (see scrollCursorIntoView), this buffer also sets + // how deep the cursor parks: a bigger buffer fires the scroll earlier *and* lands the cursor lower. + // Tune fontSize * 2, or set to 0 to trigger flush with the toolbar and land on the landing margin alone. + const topBuffer = fontSize * 2 + + const topEdge = toolbarBottom + topBuffer + const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - bottomBuffer + + return { + toolbarBottom, + navbarHeight, + keyboardInset, + topBuffer, + bottomBuffer, + topEdge, + bottomEdge, + } +} + +/** The y position of the layout tree relative to the document, from the viewport store. Fallback for scrollCursorIntoView when the cursor element's offsetParent cannot be read from the DOM. */ +export const getLayoutTreeTop = () => viewportStore.getState().layoutTreeTop diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts new file mode 100644 index 00000000000..9a5bd88ed9d --- /dev/null +++ b/src/device/focusWithoutAutoscroll.ts @@ -0,0 +1,67 @@ +import { isSafari, isTouch } from '../browser' +import asyncFocus from './asyncFocus' +import * as selection from './selection' + +/** + * Focus an editable and place the caret without triggering iOS native autoscroll. + * + * We do this using two strategies: + * + * (1) `focus({ preventScroll: true })` blocks the focus-driven autoscroll. This differs from prior strategies, + * which compensated for iOS autoscroll or attempted to disarm it with CSS tricks, rather than preventing it from firing + * in the first place. + * + * (2) Save and restore `window.scrollY` around `selection.set` to suppress the selection-driven autoscroll the + * browser fires when adding a Range inside a contentEditable. + * + * Autoscroll is handled solely in the `scrollCursorIntoView` module. + */ + +/** Focus an editable and place the caret without triggering iOS native autoscroll. */ +const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: { offset: number }): void => { + if (!el) return + + const wasFocused = el === document.activeElement + const currentOffset = wasFocused ? selection.offsetThought() : null + + // Idempotency guard — already focused at this offset, nothing to do. + if (wasFocused && currentOffset === offset) return + + // iOS Safari silently ignores programmatic selection (and any focus that would open the + // keyboard) unless one of two conditions holds: the call is within "transient user activation" + // (the short window following a real tap), or an editable element already holds focus. This is + // WebKit's anti-abuse policy — without it, pages could pop the keyboard or move the selection + // at any time without the user's involvement. + // + // asyncFocus converts the first condition into the second: while activation is still in effect + // it focuses a hidden input, putting the page into an active editing session that persists + // after the activation window closes, so a later asynchronous selection.set (e.g. from a + // post-render useEffect) is honored rather than dropped. If the selection is already on a + // thought, an editing session already exists and no priming is needed. + if (isTouch && isSafari() && !selection.isThought()) { + asyncFocus() + } + + if (!wasFocused) { + el.focus({ preventScroll: true }) + } + + // Focus is handled above, but there is a second, independent source of autoscroll: when a + // Range is set inside a contentEditable, the browser scrolls the caret into view on its own. + // Unlike focus(), selection.set has no preventScroll option, so instead we snapshot + // window.scrollY before setting the selection and restore it immediately after if it moved. + // Both happen within the same synchronous frame, so the user never sees the jump. + // + // Why suppress this scroll instead of letting the browser bring the caret into view? Because + // scrolling must be owned by exactly one place — useScrollCursorIntoView, which runs once + // layout settles. If this function scrolled too, the two would compete: on rapid cursor + // changes (e.g. holding Enter) each native scroll interrupts the previous smooth scroll + // mid-flight, stranding the page at an arbitrary position. + const yBefore = window.scrollY + selection.set(el, { offset }) + if (window.scrollY !== yBefore) { + window.scrollTo(window.scrollX, yBefore) + } +} + +export default focusWithoutAutoscroll diff --git a/src/device/preventAutoscroll.ts b/src/device/preventAutoscroll.ts deleted file mode 100644 index 4c871e6cc23..00000000000 --- a/src/device/preventAutoscroll.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isTouch } from '../browser' -import viewportStore from '../stores/viewport' - -/** Duration after preventAutoscroll is called before the temporary styles are reset. */ -export const PREVENT_AUTOSCROLL_TIMEOUT = 10 - -// store the existinp css properties so they can be restored after preventAutoscroll -let transformOld = '' -let paddingBottomOld = '' -let paddingTopOld = '' - -// the element that preventAutoscroll was most recently called on and whose styles still need to be cleaned up -let activeEl: HTMLElement | null | undefined -let timeoutId: number | undefined - -// the number of pixels of padding that preventAutoscroll has temporarily added to activeEl to inflate its height; used to recover the element's true height while preventAutoscroll is in progress -let autoscrollPadding = 0 - -/** Clean up styles from preventAutoscroll. This is called automatically 10 ms after preventAutoscroll, but it can and should be called as soon as focus has fired and the autoscroll window has safely passed. */ -export const preventAutoscrollEnd = (el: HTMLElement | null | undefined) => { - clearTimeout(timeoutId) - timeoutId = undefined - activeEl = undefined - autoscrollPadding = 0 - - if (!el) return - - el.style.transform = transformOld - el.style.paddingBottom = paddingBottomOld - el.style.paddingTop = paddingTopOld - el.removeAttribute('data-prevent-autoscroll') -} -/** Prevent the browser from autoscrolling to this editable element. If the element would be hidden by the virtual keyboard, scrolls just enough to make it visible. */ -const preventAutoscroll = ( - el: HTMLElement | null | undefined, - { - bottomMargin = 0, - }: { - /** Number of pixels to leave between the top edge of the virtual keyboard and the autoscroll element. */ - bottomMargin?: number - } = {}, -) => { - if (!isTouch || el === document.activeElement || !el) return - - // If preventAutoscroll was already called without preventAutoscrollEnd being called, clean up the previous element first. Otherwise its styles would be stuck forever, since the module-level saved styles and timeoutId are about to be overwritten. - if (timeoutId) { - preventAutoscrollEnd(activeEl) - } - - // find the center of the viewport so that the browser does not think it needs to autoscroll - const { height, y } = el.getBoundingClientRect() - const { innerHeight, virtualKeyboardHeight } = viewportStore.getState() - const viewportHeight = innerHeight - virtualKeyboardHeight - const yOffsetCenter = viewportHeight / 2 - height / 2 - y - - // get the distance of the thought below the keyboard which we can offset to keep the thought in view. - const yBelowKeyboard = Math.max(0, y + height + bottomMargin - viewportHeight) - const yCenter = yOffsetCenter + yBelowKeyboard - - // Batch all style reads before any style writes. Interleaving reads and writes forces the browser to recalculate layout multiple times, which is a common performance problem. - // Read the rendered paddingTop in pixels. getComputedStyle resolves the value even when it is applied via a CSS class rather than the inline style property (el.style.paddingTop), and always returns a px value. The data-prevent-autoscroll attribute set below does not affect paddingTop, so reading it here yields the same value as reading it after the write. - const paddingTopComputed = parseFloat(getComputedStyle(el).paddingTop) || 0 - transformOld = el.style.transform - paddingBottomOld = el.style.paddingBottom - paddingTopOld = el.style.paddingTop - - // All style writes happen below, after the reads above. - activeEl = el - el.setAttribute('data-prevent-autoscroll', 'true') - - // below center - if (yCenter < 0) { - // paddingTop keeps the actual text in the same place, despite the element being translated up to prevent autoscroll. - // Otherwise we are stuck with two bad options: - // - Only use transform (previous implementation): The browser selection becomes invisible on iOS 17. getSelection still returns the correct node and offset, so it is programmatically undetectable. - // - Add a setTimeout to the preventAutoscrollEnd that is called in Editable.onFocus: The browser selection is visible, but the timeout introduces enough delay that the thought is re-rendered before its style is restored. This causes the thought to blink out of existence for a split second. - el.style.transform = `translate(0, ${yCenter * 2}px)` - el.style.paddingTop = `${-yCenter * 2 + paddingTopComputed}px` - autoscrollPadding = -yCenter * 2 - } - // above center - else { - // When the bottom edge of element is below the bottom edge of the screen, autoscroll is disabled completely. - // TODO: Allow autoscroll if thought is above the top edge of the screen (can that happen?) - el.style.paddingBottom = `${viewportHeight}px` - autoscrollPadding = viewportHeight - } - - // 10ms should be plenty of time for Editable.onFocus to fire after preventAutoscroll is first called, and thus call preventAutoscrollEnd, but if for some reason that does not happen we should go ahead and call it to clean up. This will result in a noticeable blink, but it is better than the thought getting stuck. - timeoutId = setTimeout(() => preventAutoscrollEnd(el), PREVENT_AUTOSCROLL_TIMEOUT) as unknown as number - - // return cleanup function - return () => preventAutoscrollEnd(el) -} - -/** Returns true if preventAutoscroll is currently in progress. */ -export const isPreventAutoscrollInProgress = () => !!timeoutId - -/** Returns the number of pixels of padding that preventAutoscroll has temporarily added to the given element to inflate its height, or 0 if preventAutoscroll is not currently active on it. This allows height measurements to recover the element's true height during the autoscroll window. */ -export const getAutoscrollPadding = (el: HTMLElement | null | undefined): number => - el && el === activeEl ? autoscrollPadding : 0 - -export default preventAutoscroll diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index fe1dd9c3a87..e065fb156c4 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -1,58 +1,89 @@ import { isSafari, isTouch } from '../browser' -import { PREVENT_AUTOSCROLL_TIMEOUT, isPreventAutoscrollInProgress } from '../device/preventAutoscroll' -import viewportStore from '../stores/viewport' +import { getAutoscrollEdges, getLayoutTreeTop } from '../device/autoscrollEdges' +import virtualKeyboardStore from '../stores/virtualKeyboardStore' -/** Scrolls the minimum amount necessary to move the viewport so that it includes the element. */ +/** + * Scrolls the minimum amount necessary to bring the cursor back inside the comfort zone — the + * region between the autoscroll trigger edges defined by autoscrollEdges. + * A cursor inside the comfort zone never scrolls. + * A cursor that crosses the top edge (under the toolbar) or the bottom edge (at the keyboard/navbar) + * is scrolled back in, landing a small margin inside the edge it crossed. + */ const scrollIntoViewIfNeeded = (y: number, height: number) => { - // preventAutoscroll works by briefly increasing the element's height, which breaks isElementInViewport. - // Therefore, we need to wait until preventAutoscroll is done. - // See: preventAutoscroll.ts - if (isPreventAutoscrollInProgress()) { - setTimeout(() => { - scrollIntoViewIfNeeded(y, height) - }, PREVENT_AUTOSCROLL_TIMEOUT) - return - } + const { topEdge, bottomEdge } = getAutoscrollEdges() - // determine if the elements is above or below the viewport - // toolbar element is not present when distractionFreeTyping is activated - const viewport = viewportStore.getState() + // Landing margin: how far inside the crossed edge the cursor settles after a scroll, so it + // doesn't sit flush against the toolbar or keyboard. Distinct from the trigger buffer in + // autoscrollEdges — the trigger buffer decides when a scroll starts; the landing margin decides + // where the cursor settles once one has fired. + const landingMargin = height / 2 - // window.visualViewport.height excludes the virtual keyboard height (i.e. it changes when the keyboard is open/closed). - // It changes in a single step (before the virtual keyboard animation completes), so we can use it to determine if the element will be below the visible area. - // On desktop or when the virtual keyboard is down, it is equivalent to window.innerHeight. - const visualViewportHeight = window.visualViewport?.height ?? window.innerHeight + // Find the cursor's *target* viewport y — where it will be once the layout animation settles, + // not where it is mid-flight. Two moving parts make this non-trivial: + // - The cursor overlay glides into place: TreeNodePositioner gives it a CSS transition on + // `top`, so right after a cursor change the node is still animating from its old position. + // - Autocrop: navigating deeper crops away the space above the cursor by shifting the whole + // layout tree up with a translateY transform (see useAutocrop in LayoutTree). + // + // The `y` arg is the destination of that `top` transition: the cursor's final position relative + // to the layout tree, computed by LayoutTree and passed down via BulletCursorOverlay. Adding it + // to the layout tree's live viewport position — the cursor overlay's offsetParent rect, which + // reflects autocrop synchronously on render — gives the cursor's final viewport position. + const cursorEl = document.querySelector('[aria-label="cursor-overlay-tree-node"]') + const offsetParent = cursorEl instanceof HTMLElement ? cursorEl.offsetParent : null + const offsetParentTop = offsetParent ? offsetParent.getBoundingClientRect().top : null - /** The y position of the element relative to the document. */ - const yDocument = viewport.layoutTreeTop + y + /** The y position of the cursor relative to the viewport. */ + const yViewport = offsetParentTop != null ? offsetParentTop + y : getLayoutTreeTop() + y - window.scrollY - /** The y position of the element relative to the viewport. */ - const yViewport = yDocument - window.scrollY + /** The y position of the cursor relative to the document. */ + const yDocument = yViewport + window.scrollY - const toolbarRect = document.getElementById('toolbar')?.getBoundingClientRect() - 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) + // Did the cursor cross a comfort-zone edge? "Crossed the top edge" means it moved above the top + // trigger line — which sits a buffer *below* the toolbar — so it may be a fully-visible cursor + // that merely entered the buffer band, not one that is actually under the toolbar. + const crossedTopEdge = yViewport < topEdge + const crossedBottomEdge = yViewport + height > bottomEdge - if (!isAboveViewport && !isBelowViewport) return + if (!crossedTopEdge && !crossedBottomEdge) return - // The native el.scrollIntoView causes a bug where the top part of the content is cut off, even when a significant delay is added. - // Therefore, we need to calculate the scroll position ourselves + // Calculate the scroll position ourselves rather than using the native el.scrollIntoView, which + // cuts off the top of the content even when a significant delay is added. + // + // Both the trigger (above) and the landing (here) are measured from the comfort-zone *edge*, not + // from the raw toolbar/keyboard. The edge is the single reference line: you trigger a scroll when + // the cursor crosses it, and you land the cursor `landingMargin` back inside it. So the trigger + // buffer that positions the edge (see autoscrollEdges) deliberately governs both *when* a scroll + // fires and *how deep* the cursor settles — the cursor comes to rest `buffer + landingMargin` + // inside the visible area. That coupling is intentional: the buffer sets where the comfort zone + // begins; landingMargin sets how far past that line the cursor parks. + const scrollYNew = crossedTopEdge + ? yDocument - topEdge - landingMargin + : yDocument + height - bottomEdge + landingMargin - // leave a margin between the element and the viewport edge equal to half the element's height - // 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) + // Reachability. A trigger buffer can fire while the page has nowhere to actually scroll — a cursor + // near the top of a short document crosses the top edge, but with nothing above to reveal the + // ideal target is a negative (unreachable) scroll position. Clamp the target to the range the page + // can really reach — never above 0, never past the end — and if that clamp lands on where we + // already are, there is nothing to do, so bail before the Safari floor below turns a no-op into a + // phantom 1px scroll. The same clamp covers the bottom edge's version (short document, cursor near + // the end) for free. + const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight) + const target = Math.min(Math.max(scrollYNew, 0), maxScroll) + if (target === window.scrollY) return // scroll to 1 instead of 0 // otherwise Mobile Safari scrolls to the top after MultiGesture // See: touchmove in MultiGesture.tsx - const top = Math.max(1, scrollYNew) + const top = Math.max(1, target) - const scrollDistance = Math.abs(scrollYNew - window.scrollY) - const behavior: ScrollBehavior = scrollDistance < visualViewportHeight ? 'smooth' : 'auto' + // Smooth-scroll short distances, but jump instantly when the target is more than a screenful + // away, where a smooth scroll would be slow and disorienting. "A screenful" is the visible area + // above the virtual keyboard, from the same store autoscrollEdges uses. (visualViewport.height + // is not an option: with Capacitor's Keyboard resize: 'none', it never shrinks in the native app.) + const visibleHeight = window.innerHeight - virtualKeyboardStore.getState().targetHeight + const scrollDistance = Math.abs(target - window.scrollY) + const behavior: ScrollBehavior = scrollDistance < visibleHeight ? 'smooth' : 'auto' window.scrollTo({ top, diff --git a/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts b/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts index a6d07544b00..a7135d2f29f 100644 --- a/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts @@ -2,7 +2,6 @@ import { Capacitor } from '@capacitor/core' import { Keyboard } from '@capacitor/keyboard' import { AnimationPlaybackControls, animate } from 'framer-motion' import VirtualKeyboardHandler from '../../../@types/VirtualKeyboardHandler' -import viewportStore from '../../../stores/viewport' import virtualKeyboardStore from '../../../stores/virtualKeyboardStore' import getSafeAreaBottom from '../getSafeAreaBottom' @@ -24,8 +23,9 @@ const iOSCapacitorHandler: VirtualKeyboardHandler = { // Because we always add a safe-area-bottom inset whenever we position elements, this normalized height // is the value we actually need. Consider this an additional 'safe area inset' that applies only when the keyboard is open. const targetHeight = rawHeight - getSafeAreaBottom() - viewportStore.update({ virtualKeyboardHeight: targetHeight }) - virtualKeyboardStore.update({ open: true }) + // Publish the final height upfront so subscribers (e.g. useScrollCursorIntoView) can react + // to the keyboard event immediately instead of waiting for the spring to settle. + virtualKeyboardStore.update({ open: true, targetHeight }) // Stop any existing animation to prevent conflicts controls?.stop() @@ -45,7 +45,7 @@ const iOSCapacitorHandler: VirtualKeyboardHandler = { Keyboard.addListener('keyboardWillHide', () => { // note: leave open: true until the keyboard has fully hidden - virtualKeyboardStore.update({ open: true }) + virtualKeyboardStore.update({ open: true, targetHeight: 0 }) // Stop any existing animation to prevent conflict. controls?.stop() diff --git a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts index cdc1b4ff5c2..ef5bc19c062 100644 --- a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts @@ -3,13 +3,40 @@ import _ from 'lodash' import VirtualKeyboardHandler from '../../../@types/VirtualKeyboardHandler' import { isSafari, isTouch } from '../../../browser' import store from '../../../stores/app' -import viewportStore, { updateSize } from '../../../stores/viewport' +import { updateSize } from '../../../stores/viewport' import virtualKeyboardStore from '../../../stores/virtualKeyboardStore' import getSafeAreaBottom from '../getSafeAreaBottom' /** Provides control over the spring animation. */ let controls: AnimationPlaybackControls | null = null +/** Per-orientation virtual keyboard height in raw px (before the safe-area-bottom inset is + * subtracted). The iOS keyboard height is stable per orientation, so the last measured value is a + * reliable estimate for the next open. Seeded with a geometric guess until the keyboard can first + * be measured. */ +let kbHeightPortrait = window.innerHeight / 2.275 +let kbHeightLandscape = window.innerWidth / 1.7 + +/** Returns true when ScreenOrientation reports portrait; falls back to media query if unavailable. */ +const getIsPortrait = () => + window.screen.orientation?.type + ? window.screen.orientation.type.startsWith('portrait') + : window.matchMedia('(orientation: portrait)').matches + +/** Measures the keyboard's raw height from visualViewport, refreshing the per-orientation cache. + * Returns the live measurement while the keyboard is open, or the cached/estimated height when it + * cannot be measured directly (e.g. from a selectionchange before the keyboard has slid in). Safari + * has no API for the keyboard's final height, so we derive it from the viewport shrinkage. */ +const measureKeyboardHeight = (): number => { + const isPortrait = getIsPortrait() + const measured = window.visualViewport ? window.innerHeight - window.visualViewport.height : 0 + if (measured > 0) { + if (isPortrait) kbHeightPortrait = measured + else kbHeightLandscape = measured + } + return measured > 0 ? measured : isPortrait ? kbHeightPortrait : kbHeightLandscape +} + /** The spring's current target height. Unlike Capacitor — which delivers the keyboard's final height * upfront via `keyboardWillShow` — Safari only exposes the height progressively, via * `visualViewport.resize` events that fire as the keyboard slides in. We must re-target the spring @@ -17,14 +44,20 @@ let controls: AnimationPlaybackControls | null = null * and orientation changes that resize the keyboard mid-edit. */ let currentTargetHeight: number | null = null +/** The keyboard's predicted final height — the settled height of the previous open. Published to + * virtualKeyboardStore.targetHeight at open detection so subscribers get the final height upfront + * (Capacitor-style one-shot semantics) instead of Safari's progressive measurements. Exact after + * the first open, since iOS keyboard height is stable per orientation. */ +let predictedFinalHeight: number | null = null + /** Updates the virtualKeyboardStore state based on the selection. */ const updateIOSSafariKeyboardState = () => { // A timeout is necessary to ensure the isKeyboardOpen state is updated after the selection change. // This places the function call in the next event loop, after the state has been updated. setTimeout(() => { - // Get the raw height of the keyboard from the viewport store... - const { virtualKeyboardHeight } = viewportStore.getState() - const rawHeight = virtualKeyboardHeight || 0 + // Measure the raw height of the keyboard from visualViewport (falling back to the + // per-orientation estimate when it can't be measured directly)... + const rawHeight = measureKeyboardHeight() // ...then subtract the safe-area-bottom inset to get the height above the safe-area baseline. // Because we always add a safe-area-bottom inset whenever we position elements, this normalized height @@ -39,11 +72,34 @@ const updateIOSSafariKeyboardState = () => { // while already editing). if (targetHeight === currentTargetHeight) return + // Opening — rather than re-measuring mid-slide or resizing mid-edit — if the keyboard was + // fully closed (null) or still animating closed (0). + const isOpening = currentTargetHeight === null || currentTargetHeight === 0 + controls?.stop() - virtualKeyboardStore.update({ open: true }) + + // Publish the keyboard's *predicted* final height once, at open detection — emulating + // Capacitor's keyboardWillShow semantics (final height upfront, one targetHeight event per + // keyboard movement). Safari can only measure the keyboard progressively as it slides (see + // currentTargetHeight above); publishing every intermediate measurement would fire + // subscribers like useScrollCursorIntoView 5-10 times per open, restarting the autoscroll + // mid-flight each time. The prediction is the settled height of the previous open, seeded + // from measureKeyboardHeight's per-orientation estimate on the first open. + if (isOpening) { + predictedFinalHeight = Math.max(targetHeight, predictedFinalHeight ?? targetHeight) + virtualKeyboardStore.update({ open: true, targetHeight: predictedFinalHeight }) + } else if (targetHeight > virtualKeyboardStore.getState().targetHeight) { + // The keyboard exceeded the prediction (stale prediction, or the prediction bar toggled + // on mid-edit): correct upward. Settling *lower* than predicted is left alone — the + // bottom trigger edge then sits slightly above the keyboard, which over-reveals + // harmlessly — and the prediction self-corrects when the keyboard closes. + virtualKeyboardStore.update({ targetHeight }) + } currentTargetHeight = targetHeight - // Approximate iOS' keyboard spring animation (same curve as iOSCapacitorHandler) + // Approximate iOS' keyboard spring animation (same curve as iOSCapacitorHandler). + // The spring tracks the *measured* height so keyboard-riding UI (e.g. usePositionFixed) + // stays accurate even while the published targetHeight holds the prediction. controls = animate(virtualKeyboardStore.getState().height, targetHeight, { type: 'spring', stiffness: 3600, @@ -58,8 +114,11 @@ const updateIOSSafariKeyboardState = () => { controls?.stop() + // Remember the settled height as the prediction for the next open. + if (currentTargetHeight) predictedFinalHeight = currentTargetHeight + // Keep open: true during the closing animation so consumers still account for the keyboard - virtualKeyboardStore.update({ open: true }) + virtualKeyboardStore.update({ open: true, targetHeight: 0 }) currentTargetHeight = 0 controls = animate(virtualKeyboardStore.getState().height, 0, { diff --git a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-colored-and-highlighted-text-1.png b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-colored-and-highlighted-text-1.png index 6f4e85f1d86..88c3e9e9b40 100644 Binary files a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-colored-and-highlighted-text-1.png and b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-colored-and-highlighted-text-1.png differ diff --git a/src/hooks/useScrollCursorIntoView.ts b/src/hooks/useScrollCursorIntoView.ts index 2783481988c..b22b66634cf 100644 --- a/src/hooks/useScrollCursorIntoView.ts +++ b/src/hooks/useScrollCursorIntoView.ts @@ -1,8 +1,13 @@ 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' + +/** Selects the keyboard's target height, which changes exactly once per keyboard event (open, close, resize). */ +const selectKeyboardTargetHeight = (state: VirtualKeyboardState) => state.targetHeight const throttledScrollCursorIntoView = throttle((y: number, height: number) => scrollCursorIntoView(y, height), 400) @@ -30,6 +35,29 @@ const useScrollCursorIntoView = (y: number, height: number) => { setTimeout(() => throttledScrollCursorIntoView(sizeRef.current.y, sizeRef.current.height)) }) + /** Re-checks the scroll position with the cursor's current size. */ + const onKeyboardChange = useCallback(() => { + scrollCursorIntoView(sizeRef.current.y, sizeRef.current.height) + }, []) + + // Re-check when the keyboard opens, closes, or resizes (e.g. the prediction bar toggling): the + // bottom trigger edge moves with the keyboard, so a cursor that was comfortably visible at tap + // time can end up underneath it. The keyboard height arrives *after* the [y, height] effect + // below has already run, so without this subscription a tap on a thought that the keyboard is + // about to cover never triggers a scroll (#3765). + // + // targetHeight changes once per keyboard movement — Capacitor delivers the final height + // upfront, and iOSSafariHandler emulates the same one-shot semantics by publishing a predicted + // final height — so this fires immediately when the keyboard starts moving and the scroll + // animates concurrently with the keyboard slide. Do not subscribe to the spring-animated + // `height`, which changes every animation frame and would restart the scroll repeatedly. + // + // Needed on all iOS platforms, not just the Capacitor WebView (which has no native keyboard + // reveal due to Keyboard resize: 'none'): in mobile Safari, focusWithoutAutoscroll suppresses + // WebKit's own reveal-on-keyboard-open along with the focus/selection autoscroll, so em owns + // the reveal everywhere. + virtualKeyboardStore.useSelectorEffect(onKeyboardChange, selectKeyboardTargetHeight) + useEffect(() => scrollCursorIntoView(y, height), [height, y]) } diff --git a/src/stores/viewport.ts b/src/stores/viewport.ts index c133c61c1a4..20a5b05d15d 100644 --- a/src/stores/viewport.ts +++ b/src/stores/viewport.ts @@ -1,66 +1,32 @@ import _ from 'lodash' -import { isTouch } from '../browser' import reactMinistore from './react-ministore' /** Scroll zone as a percentage of the smaller size of the screen. */ const SCROLL_ZONE_WIDTH = 0.25 -// take a guess at the height of the virtual keyboard until we can measure it directly -let virtualKeyboardHeightPortrait = isTouch ? window.innerHeight / 2.275 : 0 -let virtualKeyboardHeightLandscape = isTouch ? window.innerWidth / 1.7 : 0 - export interface ViewportState { innerWidth: number innerHeight: number layoutTreeTop: number scrollZoneWidth: number - virtualKeyboardHeight: number } -/** A store that tracks the viewport dimensions, including the nontrivial virtual keyboard height. */ +/** A store that tracks the viewport dimensions. */ const viewportStore = reactMinistore({ innerWidth: window.innerWidth, /** Height of the viewport, including the virtual keyboard (i.e. does not change when the virtual keyboard is opened/closed). */ innerHeight: window.innerHeight, scrollZoneWidth: Math.min(window.innerWidth, window.innerHeight) * SCROLL_ZONE_WIDTH, - /** Height of the virtual keyboard regardless of whether it is open or closed. Initialized to estimated height. */ - virtualKeyboardHeight: - window.innerHeight > window.innerWidth ? virtualKeyboardHeightPortrait : virtualKeyboardHeightLandscape, /** The y position of the layout tree element relative to the document. Includes autocrop, i.e. this value changes when space above is cropped away as you navigate deeper. This ensures that scrollCursorIntoView can properly calculate the position of the cursor relative to the viewport. */ layoutTreeTop: 0, }) -/** Throttled update of viewport height. Invoked on window resize. */ +/** Throttled update of viewport dimensions. Invoked on window resize. */ export const updateSize = _.throttle( () => { - // There is a bug in iOS Safari where visualViewport.height is incorrect if the phone is rotated with the keyboard up, rotated back, and the keyboard is closed. - // It can be detected by ensuring the visualViewport portrait mode matches window portrait mode. - // If it is invalid, go back to the default - const isPortrait = window.innerHeight > window.innerWidth - const currentKeyboardHeight = window.visualViewport ? window.innerHeight - window.visualViewport.height : 0 - - // Only update the cached keyboard height when the keyboard is actually open (nonzero height). - // When the keyboard closes, currentKeyboardHeight is 0 — preserving the cache ensures - // iOSSafariHandler can still read the last-known height for its opening animation. - if (currentKeyboardHeight > 0) { - if (isPortrait) { - virtualKeyboardHeightPortrait = currentKeyboardHeight - } else { - virtualKeyboardHeightLandscape = currentKeyboardHeight - } - } - viewportStore.update({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, - // When the keyboard is closed, fall back to the cached height so consumers - // (e.g. iOSSafariHandler) still know the keyboard's expected height for animations. - virtualKeyboardHeight: - currentKeyboardHeight > 0 - ? currentKeyboardHeight - : isPortrait - ? virtualKeyboardHeightPortrait - : virtualKeyboardHeightLandscape, }) }, // lock to 60 fps diff --git a/src/stores/virtualKeyboardStore.ts b/src/stores/virtualKeyboardStore.ts index 9e12332fa9b..20980f8d8e7 100644 --- a/src/stores/virtualKeyboardStore.ts +++ b/src/stores/virtualKeyboardStore.ts @@ -6,6 +6,7 @@ import reactMinistore from './react-ministore' const virtualKeyboardStore = reactMinistore({ open: false, height: 0, + targetHeight: 0, }) export default virtualKeyboardStore