From 15cfdb32ae5013f8d62ebcd4a4bbe9a7ca807f9d Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Sat, 9 May 2026 01:37:30 +0100 Subject: [PATCH 01/25] feat(autoscroll): add focusWithoutAutoscroll v2 behind an A/B debug toggle (#3765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a second technique for suppressing iOS WebKit's native autoscroll on focus, which is the cause of `position: fixed` elements (toolbar) jumping when the user taps a thought far from the current cursor. v1 (existing preventAutoscroll) tries to neutralize autoscroll by transforming the target. v2 (focusWithoutAutoscroll) takes ownership of focus instead: preventDefault on mousedown to block the native focus + caret-from-tap, then el.focus({ preventScroll: true }) to focus programmatically without scroll. In useEditMode mousedown the v2 ordering is: dispatch setCursor → focus → selection.set. Dispatching the cursor before focus() makes Editable.onFocus's setCursorOnThought no-op via its equalPath early return, which avoids a race that previously clobbered cursorOffset to 0. The dispatch carries isKeyboardOpen: true so Redux stays in sync with the visible keyboard. A small floating overlay (DebugAutoscrollToggle) lets the technique be flipped at runtime via localStorage and exposes an in-app log panel for on-device debugging, since console output isn't reachable in iOS Capacitor. The overlay and instrumentation are temporary; remove once the A/B is decided. --- src/components/AppComponent.tsx | 2 + src/components/DebugAutoscrollToggle.tsx | 211 +++++++++++++++++++++++ src/components/Editable.tsx | 25 ++- src/components/Editable/useEditMode.ts | 114 +++++++++--- src/components/Note.tsx | 24 ++- src/device/focusWithoutAutoscroll.ts | 53 ++++++ src/util/autoscrollTechnique.ts | 43 +++++ src/util/debugAutoscrollLog.ts | 81 +++++++++ 8 files changed, 527 insertions(+), 26 deletions(-) create mode 100644 src/components/DebugAutoscrollToggle.tsx create mode 100644 src/device/focusWithoutAutoscroll.ts create mode 100644 src/util/autoscrollTechnique.ts create mode 100644 src/util/debugAutoscrollLog.ts diff --git a/src/components/AppComponent.tsx b/src/components/AppComponent.tsx index a5a65636dab..9753863cca7 100644 --- a/src/components/AppComponent.tsx +++ b/src/components/AppComponent.tsx @@ -20,6 +20,7 @@ import isDocumentEditable from '../util/isDocumentEditable' import Alert from './Alert' import CommandCenter from './CommandCenter/CommandCenter' import Content from './Content' +import DebugAutoscrollToggle from './DebugAutoscrollToggle' import DesktopCommandUniverse from './DesktopCommandUniverse' import DropGutter from './DropGutter' import ErrorMessage from './ErrorMessage' @@ -174,6 +175,7 @@ const AppComponent: FC = () => { > + {isTouch && } {!isTouch && } {isTouch && } diff --git a/src/components/DebugAutoscrollToggle.tsx b/src/components/DebugAutoscrollToggle.tsx new file mode 100644 index 00000000000..136026ffc64 --- /dev/null +++ b/src/components/DebugAutoscrollToggle.tsx @@ -0,0 +1,211 @@ +import { Clipboard } from '@capacitor/clipboard' +import { useEffect, useState } from 'react' +import { css } from '../../styled-system/css' +import { + AutoscrollTechnique, + getAutoscrollTechnique, + setAutoscrollTechnique, + subscribeAutoscrollTechnique, +} from '../util/autoscrollTechnique' +import { + DebugAutoscrollLogEntry, + clearDebugLog, + getDebugLog, + subscribeDebugLog, +} from '../util/debugAutoscrollLog' +import fastClick from '../util/fastClick' + +/** Format entries oldest-first, one per line, for copying. */ +const formatLog = (entries: DebugAutoscrollLogEntry[]) => + entries + .slice() + .reverse() + .map(e => `+${String(e.dt).padStart(4, ' ')}ms ${e.tag}${e.data ? ' ' + e.data : ''}`) + .join('\n') + +/** + * Temporary debug overlay for issue #3765. + * + * - Left pill (autoscroll: vN) — taps cycle the v1/v2 toggle. + * - Right pill (log) — taps show/hide an in-app log of v2 events. Tap the log + * panel itself to clear. + * + * Persists in localStorage. Remove this component once the A/B is decided. + */ +const DebugAutoscrollToggle = () => { + const [technique, setTechnique] = useState(getAutoscrollTechnique) + const [showLog, setShowLog] = useState(false) + const [entries, setEntries] = useState(getDebugLog) + const [copied, setCopied] = useState(false) + + const onCopy = async () => { + const text = formatLog(entries) + // @capacitor/clipboard uses the native iOS pasteboard on Capacitor and falls back to + // navigator.clipboard on web. WKWebView's navigator.clipboard is unreliable, so this is the + // only path that works in iOS Capacitor. + try { + await Clipboard.write({ string: text }) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + } catch (err) { + // eslint-disable-next-line no-console + console.error('clipboard copy failed', err) + } + } + + useEffect(() => subscribeAutoscrollTechnique(setTechnique), []) + useEffect( + () => + subscribeDebugLog(latest => { + // copy so React sees a new reference + setEntries(latest.slice()) + }), + [], + ) + + return ( + <> + {/* Toggle pills — top-right. Kept small to minimize occlusion. */} +
+
setAutoscrollTechnique(technique === 'v1' ? 'v2' : 'v1'))} + className={css({ + padding: '6px 10px', + borderRadius: '999px', + fontSize: '11px', + fontFamily: 'monospace', + fontWeight: 'bold', + background: technique === 'v2' ? '#2563eb' : '#444', + color: 'white', + boxShadow: '0 2px 6px rgba(0,0,0,0.3)', + userSelect: 'none', + WebkitUserSelect: 'none', + cursor: 'pointer', + opacity: 0.85, + })} + > + autoscroll: {technique} +
+
setShowLog(s => !s))} + className={css({ + padding: '6px 10px', + borderRadius: '999px', + fontSize: '11px', + fontFamily: 'monospace', + fontWeight: 'bold', + background: showLog ? '#16a34a' : '#444', + color: 'white', + boxShadow: '0 2px 6px rgba(0,0,0,0.3)', + userSelect: 'none', + WebkitUserSelect: 'none', + cursor: 'pointer', + opacity: 0.85, + })} + > + log {showLog ? '▼' : '▶'} +
+
+ + {/* Log panel — top-right under the pills. Width-limited so thought text remains visible on the + left. Top placement guarantees the keyboard never occludes the clear button. */} + {showLog && ( +
+
+
+ {copied ? '✓ copied' : '⧉ copy'} +
+
clearDebugLog())} + className={css({ + padding: '4px 10px', + borderRadius: '999px', + fontSize: '11px', + fontFamily: 'monospace', + fontWeight: 'bold', + background: '#dc2626', + color: 'white', + boxShadow: '0 2px 6px rgba(0,0,0,0.3)', + userSelect: 'none', + WebkitUserSelect: 'none', + cursor: 'pointer', + opacity: 0.9, + })} + > + ✕ clear +
+
+
+ {entries.length === 0 ? ( +
(no events — tap a thought)
+ ) : ( + entries.map(entry => ( +
+ +{String(entry.dt).padStart(4, ' ')}ms + {entry.tag} + {entry.data ? {entry.data} : null} +
+ )) + )} +
+
+ )} + + ) +} + +export default DebugAutoscrollToggle diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 27337a80967..250b64f63c0 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -45,6 +45,8 @@ import editingValueStore from '../stores/editingValue' import editingValueUntrimmedStore from '../stores/editingValueUntrimmed' import storageModel from '../stores/storageModel' import suppressFocusStore from '../stores/suppressFocus' +import { getAutoscrollTechnique } from '../util/autoscrollTechnique' +import { debugLog, editableLabel, selectionSnapshot } from '../util/debugAutoscrollLog' import addEmojiSpace from '../util/addEmojiSpace' import containsURL from '../util/containsURL' import ellipsize from '../util/ellipsize' @@ -229,6 +231,10 @@ const Editable = ({ // Prevent the cursor offset from being restored after the initial setCursorOnThought. cursorOffsetInitialized = true + if (getAutoscrollTechnique() === 'v2') { + debugLog('setCursorOnThought', `el=${editableLabel(contentRef.current)} offset=${offset}`) + } + dispatch( setCursor({ cursorHistoryClear: true, @@ -501,6 +507,12 @@ const Editable = ({ /** Flushes edits and updates certain state variables on blur. */ const onBlur: FocusEventHandler = useCallback( e => { + if (getAutoscrollTechnique() === 'v2') { + debugLog( + 'onBlur', + `el=${editableLabel(contentRef.current)} relatedTarget=${editableLabel(e.relatedTarget)}`, + ) + } throttledChangeRef.current.flush() // update the ContentEditable if the new scrubbed value is different (i.e. stripped, space after emoji added, etc) @@ -577,7 +589,15 @@ const Editable = ({ }) } - if (suppressFocusStore.getState()) return + if (suppressFocusStore.getState()) { + if (getAutoscrollTechnique() === 'v2') { + debugLog('onFocus', `el=${editableLabel(contentRef.current)} suppressed=true`) + } + return + } + if (getAutoscrollTechnique() === 'v2') { + debugLog('onFocus', `el=${editableLabel(contentRef.current)} suppressed=false`) + } // Update editingValueUntrimmedStore with the current value editingValueUntrimmedStore.update(value) @@ -598,6 +618,9 @@ const Editable = ({ */ const handleTapBehavior = useCallback( (e: MouseEvent | TouchEvent) => { + if (getAutoscrollTechnique() === 'v2') { + debugLog('handleTap', `type=${e.type} el=${editableLabel(contentRef.current)} ${selectionSnapshot()}`) + } // When MultiGesture is below the gesture threshold it is possible that onClick and onTouchEnd // both trigger. Prevent handleTapBehavior from running a second time via touchend in that case. // https://github.com/cybersemics/em/issues/1268 diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 003a169c73e..9a28d819886 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -7,11 +7,14 @@ 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' +import { getAutoscrollTechnique } from '../../util/autoscrollTechnique' +import { debugLog, editableLabel, selectionSnapshot } from '../../util/debugAutoscrollLog' import equalPath from '../../util/equalPath' /** Automatically sets the selection on the given contentRef element when the thought should be selected. Handles a variety of conditions that determine whether this should occur. */ @@ -80,6 +83,13 @@ const useEditMode = ({ !dragHold && !disabledRef.current) + if (getAutoscrollTechnique() === 'v2') { + debugLog( + 'useEffect', + `el=${editableLabel(contentRef.current)} cursorOffset=${cursorOffset} shouldSet=${shouldSetSelection} active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`, + ) + } + if (shouldSetSelection) { preventAutoscroll(contentRef.current) @@ -151,6 +161,13 @@ const useEditMode = ({ * Prevents default behavior and manages autoscroll for certain edge cases where browser selection would be incorrect. */ const onMouseDown = (e: MouseEvent) => { + if (getAutoscrollTechnique() === 'v2') { + debugLog( + 'mousedown.entry', + `el=${editableLabel(editable)} editingOrOnCursor=${editingOrOnCursor} isMulticursor=${isMulticursor} active=${editableLabel(document.activeElement)}`, + ) + } + // If CMD/CTRL is pressed, don't focus the editable. const isMultiselectClick = isMac ? e.metaKey : e.ctrlKey if (isMultiselectClick) { @@ -161,36 +178,85 @@ const useEditMode = ({ // If editing or the cursor is on the thought, allow the default browser selection or perform manual caret positioning so the offset is correct. // See: #981 if (editingOrOnCursor && !isMulticursor) { - // 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, - }) - - const { inVoidArea, offset } = getCaretOffset(editable, { - clientX: e.clientX, - clientY: e.clientY, - }) - - if (offset !== null) { - if (isTouch && isSafari()) { - offsetRef.current = offset - allowDefaultSelection() - } else { - setCaretOffset(offset) + // A/B toggle for issue #3765 — see src/util/autoscrollTechnique.ts. + const technique = getAutoscrollTechnique() + + if (technique === 'v2') { + // v2 strategy for issue #3765: + // 1. Dispatch setCursor first — so when focus() below fires Editable.onFocus, its + // setCursorOnThought call sees state.cursor already equal to this path and + // returns early, instead of clobbering cursorOffset to 0. + // 2. focusWithoutAutoscroll: preventDefault on mousedown to block native focus + // + native caret positioning; el.focus({ preventScroll: true }) to take focus + // programmatically without triggering iOS autoscroll (the cause of position:fixed + // elements jumping in #3765). + // 3. selection.set: place the caret AFTER focus, so iOS doesn't drop our selection + // during the focus transition. + const { offset } = getCaretOffset(editable, { + clientX: e.clientX, + clientY: e.clientY, + }) + + debugLog( + 'mousedown', + `el=${editableLabel(editable)} active=${editableLabel(document.activeElement)} offset=${offset}`, + ) + + if (offset !== null) { + dispatch( + setCursor({ + path, + offset, + isKeyboardOpen: true, + cursorHistoryClear: true, + preserveMulticursor: true, + }), + ) } - // 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() + focusWithoutAutoscroll(editable, e) + + debugLog('postFocus', `active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`) + + if (offset !== null) { + selection.set(editable, { offset }) + debugLog('selection.set', `${selectionSnapshot()}`) } } else { - allowDefaultSelection() + // v1: existing centering hack. 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, + }) + + const { inVoidArea, offset } = getCaretOffset(editable, { + clientX: e.clientX, + clientY: e.clientY, + }) + + if (offset !== null) { + if (isTouch && isSafari()) { + offsetRef.current = offset + allowDefaultSelection() + } else { + 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() + } + } else { + allowDefaultSelection() + } } } else { + if (getAutoscrollTechnique() === 'v2') { + debugLog('mousedown.elseBranch', `el=${editableLabel(editable)} (preventDefault, no v2 logic)`) + } // 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. // Steps to Reproduce: https://github.com/cybersemics/em/pull/2948#issuecomment-2887186117 diff --git a/src/components/Note.tsx b/src/components/Note.tsx index b96f439d488..3cac2cf31c0 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -12,6 +12,8 @@ import { setDescendantActionCreator as setDescendant } from '../actions/setDesce import { setNoteFocusActionCreator as setNoteFocus } from '../actions/setNoteFocus' import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote' import { isTouch } from '../browser' +import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll' +import getCaretOffset from '../device/getCaretOffset' import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll' import * as selection from '../device/selection' import useFreshCallback from '../hooks/useFreshCallback' @@ -20,6 +22,7 @@ import noteValue from '../selectors/noteValue' import resolveNotePath from '../selectors/resolveNotePath' import store from '../stores/app' import batchEditingStore from '../stores/batchEditing' +import { getAutoscrollTechnique } from '../util/autoscrollTechnique' import equalPathHead from '../util/equalPathHead' import head from '../util/head' import strip from '../util/strip' @@ -150,7 +153,26 @@ 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 + + // A/B toggle for issue #3765 — see src/util/autoscrollTechnique.ts. + if (getAutoscrollTechnique() === 'v2') { + // v2: focusWithoutAutoscroll blocks native focus+autoscroll and takes focus programmatically + // with preventScroll: true. Note.onFocus then runs and dispatches noteOffset:null + + // noteFocus:true, which is fine — Note's selection useEffect bails when noteOffset is null, + // so our manual selection.set is left in place. + const { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) + focusWithoutAutoscroll(note, e.nativeEvent) + if (offset !== null) { + selection.set(note, { offset }) + } + } else { + // v1: existing centering hack. + preventAutoscroll(note) + } + }, []) const onCopy = useCallback((e: React.ClipboardEvent) => { const html = selection.html() diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts new file mode 100644 index 00000000000..5ca3ce208f2 --- /dev/null +++ b/src/device/focusWithoutAutoscroll.ts @@ -0,0 +1,53 @@ +import { isTouch } from '../browser' +import { getAutoscrollTechnique } from '../util/autoscrollTechnique' +import { debugLog, editableLabel, selectionSnapshot } from '../util/debugAutoscrollLog' + +/** + * v2 technique for issue #3765. + * + * Blocks the browser's native focus + autoscroll path entirely, so iOS WebKit + * never runs its autoscroll and `position: fixed` elements (toolbar) never + * jump. Must be invoked from a `mousedown` handler — `preventDefault` on + * mousedown is what suppresses native focus. + * + * The caller is then responsible for placing the caret (e.g. via + * `getCaretOffset` + `selection.set`) and for scrolling the editor with + * `scrollCursorIntoView`. + * + * Returns true if focus was taken over, false if the call was a no-op (caller + * should fall back to default behavior). + */ +const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, e: MouseEvent | TouchEvent): boolean => { + if (!isTouch || !el) return false + + // Order matters on iOS WebKit: + // + // 1) Focus first, while the user-activation gesture from the tap is still valid. + // `preventScroll: true` (iOS Safari 15.5+) suppresses the autoscroll that normally accompanies + // focus changes, which is what causes `position: fixed` elements to jump (issue #3765). + // Calling focus() before preventDefault preserves caret-rendering: when preventDefault runs + // first, iOS sometimes registers the activeElement change but does NOT paint a caret until + // another user gesture arrives, so the caller's manual selection.set is invisible. + // + // 2) Then preventDefault to suppress the native caret-from-tap that would otherwise overwrite + // the caller's manual caret placement after this function returns. + const v2Logging = getAutoscrollTechnique() === 'v2' + const wasFocused = el === document.activeElement + + if (!wasFocused) { + if (v2Logging) { + debugLog('focus.before', `target=${editableLabel(el)} active=${editableLabel(document.activeElement)}`) + } + el.focus({ preventScroll: true }) + if (v2Logging) { + debugLog('focus.after', `active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`) + } + } else if (v2Logging) { + debugLog('focus.skip', `target=${editableLabel(el)} (already active)`) + } + e.preventDefault() + + return true +} + +export default focusWithoutAutoscroll diff --git a/src/util/autoscrollTechnique.ts b/src/util/autoscrollTechnique.ts new file mode 100644 index 00000000000..87122aa3f24 --- /dev/null +++ b/src/util/autoscrollTechnique.ts @@ -0,0 +1,43 @@ +import storage from './storage' + +/** + * Temporary A/B toggle for issue #3765 — selects which technique is used to + * suppress iOS native autoscroll on tap-to-focus: + * + * - 'v1': existing `preventAutoscroll` (transform/padding centering hack) + * - 'v2': new `focusWithoutAutoscroll` (preventDefault + focus({preventScroll: true})) + * + * Persisted to localStorage so it survives the keyboard-induced reloads that + * are common on iOS Capacitor during testing. + * + * Remove this file once the A/B is decided. + */ + +export type AutoscrollTechnique = 'v1' | 'v2' + +const STORAGE_KEY = 'debug-autoscroll-technique' +const DEFAULT: AutoscrollTechnique = 'v1' + +const listeners = new Set<(t: AutoscrollTechnique) => void>() + +const isValid = (value: string | null): value is AutoscrollTechnique => value === 'v1' || value === 'v2' + +/** Read the current technique synchronously. Safe to call from anywhere — falls back to default on SSR. */ +export const getAutoscrollTechnique = (): AutoscrollTechnique => { + const raw = storage.getItem(STORAGE_KEY) + return isValid(raw) ? raw : DEFAULT +} + +/** Set the technique and notify subscribers. */ +export const setAutoscrollTechnique = (technique: AutoscrollTechnique) => { + storage.setItem(STORAGE_KEY, technique) + listeners.forEach(l => l(technique)) +} + +/** Subscribe to changes. Returns an unsubscribe function. */ +export const subscribeAutoscrollTechnique = (listener: (t: AutoscrollTechnique) => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/src/util/debugAutoscrollLog.ts b/src/util/debugAutoscrollLog.ts new file mode 100644 index 00000000000..d6ed31611ec --- /dev/null +++ b/src/util/debugAutoscrollLog.ts @@ -0,0 +1,81 @@ +/** + * Lightweight in-memory log for debugging issue #3765 v2 autoscroll path. + * Logs are visible in the DebugAutoscrollToggle panel on-device, since iOS + * Capacitor doesn't surface console output where we can read it. + * + * Remove this file once the A/B is decided. + */ + +export interface DebugAutoscrollLogEntry { + /** Monotonic counter — used as React key and to spot dropped events. */ + id: number + /** ms since the previous entry, or 0 for the first. */ + dt: number + /** Short tag identifying the call site. */ + tag: string + /** Optional payload — kept short, since we render it on a 4-inch screen. */ + data?: string +} + +const MAX_ENTRIES = 30 + +let nextId = 1 +let lastTime = 0 +const entries: DebugAutoscrollLogEntry[] = [] +const listeners = new Set<(entries: DebugAutoscrollLogEntry[]) => void>() + +/** Log a debug event. Drops events past MAX_ENTRIES (keeps newest). */ +export const debugLog = (tag: string, data?: string) => { + const now = performance.now() + const dt = lastTime === 0 ? 0 : Math.round(now - lastTime) + lastTime = now + + entries.unshift({ id: nextId++, dt, tag, data }) + if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES + + listeners.forEach(l => l(entries)) +} + +/** Snapshot of current entries (newest first). */ +export const getDebugLog = () => entries + +/** Subscribe to log changes. Returns unsubscribe. */ +export const subscribeDebugLog = (listener: (entries: DebugAutoscrollLogEntry[]) => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** Clear all entries. */ +export const clearDebugLog = () => { + entries.length = 0 + nextId = 1 + lastTime = 0 + listeners.forEach(l => l(entries)) +} + +/** + * Returns a short identifier for an editable element, suitable for log lines. + * Prefers the first ~20 chars of its text content (collapsed whitespace). + */ +export const editableLabel = (el: Element | EventTarget | null | undefined): string => { + if (!el || !(el instanceof Element)) return 'null' + const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() + if (!text) return '' + return text.length > 20 ? text.slice(0, 20) + '…' : text +} + +/** Returns a one-liner describing the current DOM selection — focused node, offset, and whether it's collapsed. */ +export const selectionSnapshot = (): string => { + const sel = typeof window !== 'undefined' ? window.getSelection() : null + if (!sel || sel.rangeCount === 0) return 'sel=none' + const node = sel.focusNode + const owner = + node && node.nodeType === Node.TEXT_NODE + ? (node.parentElement?.closest('[data-editable], [aria-label="note-editable"]') ?? node.parentElement) + : node instanceof Element + ? node.closest('[data-editable], [aria-label="note-editable"]') + : null + return `sel=${editableLabel(owner)}@${sel.focusOffset}${sel.isCollapsed ? '' : ' (range)'}` +} From ff391c4802184dfe8acebe871216d9610e45b270 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Mon, 18 May 2026 17:01:13 +0100 Subject: [PATCH 02/25] fix(autoscroll): unify v2 focus entry point and fix empty-thought tap Checkpoint commit before merging issue-3596 (virtualKeyboardStore) so the in-flight work isn't sitting in the working tree during the merge. - focusWithoutAutoscroll is now the single chokepoint for v2 cursor changes (tap, Return, arrow keys, gestures, sidebar restore). Suppresses both the focus-driven and selection-driven autoscroll on iOS. - useEditMode mousedown dispatches setCursor before focus so Editable.onFocus early-returns instead of clobbering cursorOffset to 0. - Empty-thought tap fix: getCaretOffset returns null on empty thoughts; the v2 mousedown branch now falls back to offset 0 so the second tap still focuses and opens the keyboard. - Debug log scroll instrumentation patches focus/scrollIntoView/scrollTo/By and clusters scroll bursts to spot native autoscroll sources. --- src/components/AppComponent.tsx | 6 ++ src/components/Editable/useEditMode.ts | 88 ++++++++++++++------------ src/components/Note.tsx | 12 ++-- src/device/focusWithoutAutoscroll.ts | 84 ++++++++++++------------ src/util/debugAutoscrollLog.ts | 79 ++++++++++++++++++++++- 5 files changed, 177 insertions(+), 92 deletions(-) diff --git a/src/components/AppComponent.tsx b/src/components/AppComponent.tsx index 9753863cca7..7399625914a 100644 --- a/src/components/AppComponent.tsx +++ b/src/components/AppComponent.tsx @@ -16,6 +16,7 @@ import isTutorial from '../selectors/isTutorial' import theme from '../selectors/theme' import themeColors from '../selectors/themeColors' import store from '../stores/app' +import { installScrollInstrumentation } from '../util/debugAutoscrollLog' import isDocumentEditable from '../util/isDocumentEditable' import Alert from './Alert' import CommandCenter from './CommandCenter/CommandCenter' @@ -38,6 +39,11 @@ import UndoSlider from './UndoSlider' import MobileCommandUniverse from './dialog/MobileCommandUniverse' import * as modals from './modals' +// Patch focus/scroll APIs and listen for scroll bursts so the v2 debug log can show +// what's actually causing native autoscroll (e.g. selection.set after newThought). Idempotent. +// Remove with the rest of the v2 debug scaffolding once #3765 is resolved. +installScrollInstrumentation() + /** A hook that sets an attribute on the document.body element. */ const useBodyAttribute = (name: string, value: string) => { useLayoutEffect(() => { diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 9a28d819886..4bfa99ea599 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -59,17 +59,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 technique = getAutoscrollTechnique() // allow transient editable to have focus on render const shouldSetSelection = @@ -83,14 +73,28 @@ const useEditMode = ({ !dragHold && !disabledRef.current) - if (getAutoscrollTechnique() === 'v2') { + if (technique === 'v2') { debugLog( 'useEffect', `el=${editableLabel(contentRef.current)} cursorOffset=${cursorOffset} shouldSet=${shouldSetSelection} active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`, ) } - if (shouldSetSelection) { + if (!shouldSetSelection) return + + // 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 + } + + if (technique === 'v2') { + // Chokepoint — handles focus, selection.set with autoscroll suppression, and deferred + // scroll. This is the path for all programmatic cursor changes: Return-new-thought, + // arrow keys (incl. bluetooth on iPad), gestures, sidebar restore. + focusWithoutAutoscroll(contentRef.current, { offset: cursorOffset ?? 0 }) + } else { preventAutoscroll(contentRef.current) /* @@ -110,7 +114,7 @@ const useEditMode = ({ asyncFocus() } - setSelectionToCursorOffset() + selection.set(contentRef.current, { offset: cursorOffset ?? 0 }) } }, // React Hook useEffect has missing dependencies: 'contentRef', 'editMode', and 'style?.visibility'. @@ -182,16 +186,14 @@ const useEditMode = ({ const technique = getAutoscrollTechnique() if (technique === 'v2') { - // v2 strategy for issue #3765: - // 1. Dispatch setCursor first — so when focus() below fires Editable.onFocus, its - // setCursorOnThought call sees state.cursor already equal to this path and - // returns early, instead of clobbering cursorOffset to 0. - // 2. focusWithoutAutoscroll: preventDefault on mousedown to block native focus - // + native caret positioning; el.focus({ preventScroll: true }) to take focus - // programmatically without triggering iOS autoscroll (the cause of position:fixed - // elements jumping in #3765). - // 3. selection.set: place the caret AFTER focus, so iOS doesn't drop our selection - // during the focus transition. + // v2 strategy for issue #3765. All cursor changes (taps, Return, arrow keys, gestures) + // converge on `focusWithoutAutoscroll` — see its docstring. Mousedown calls it inline + // here, synchronously within the user gesture, so: + // - iOS Safari accepts the focus (no asyncFocus dance needed for the tap path) + // - same-thought re-taps reposition the caret (cursorOffset is not a useEffect dep, + // so we cannot rely on the useEffect for offset-only changes) + // The cursor-change useEffect also calls focusWithoutAutoscroll for programmatic + // paths; the function is idempotent so the second call is a no-op. const { offset } = getCaretOffset(editable, { clientX: e.clientX, clientY: e.clientY, @@ -202,26 +204,28 @@ const useEditMode = ({ `el=${editableLabel(editable)} active=${editableLabel(document.activeElement)} offset=${offset}`, ) - if (offset !== null) { - dispatch( - setCursor({ - path, - offset, - isKeyboardOpen: true, - cursorHistoryClear: true, - preserveMulticursor: true, - }), - ) - } - - focusWithoutAutoscroll(editable, e) + // Block the native focus + native caret-from-tap before they fire. + 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 clobbering cursorOffset to 0. + dispatch( + setCursor({ + path, + offset: targetOffset, + isKeyboardOpen: true, + cursorHistoryClear: true, + preserveMulticursor: true, + }), + ) + focusWithoutAutoscroll(editable, { offset: targetOffset }) debugLog('postFocus', `active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`) - - if (offset !== null) { - selection.set(editable, { offset }) - debugLog('selection.set', `${selectionSnapshot()}`) - } } else { // v1: existing centering hack. Prevent the browser from autoscrolling to this editable element. // For some reason doesn't work on touchend. diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 3cac2cf31c0..c4aa56daed5 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -159,14 +159,14 @@ const Note = React.memo( // A/B toggle for issue #3765 — see src/util/autoscrollTechnique.ts. if (getAutoscrollTechnique() === 'v2') { - // v2: focusWithoutAutoscroll blocks native focus+autoscroll and takes focus programmatically - // with preventScroll: true. Note.onFocus then runs and dispatches noteOffset:null + - // noteFocus:true, which is fine — Note's selection useEffect bails when noteOffset is null, - // so our manual selection.set is left in place. + // v2: block native focus + native caret-from-tap, then route through focusWithoutAutoscroll + // (same chokepoint Editable uses) which focuses with preventScroll, places the caret at + // the tap offset, suppresses selection-driven autoscroll, and schedules the post-keyboard + // scroll into view. const { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) - focusWithoutAutoscroll(note, e.nativeEvent) if (offset !== null) { - selection.set(note, { offset }) + e.preventDefault() + focusWithoutAutoscroll(note, { offset }) } } else { // v1: existing centering hack. diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index 5ca3ce208f2..b84c60fddfb 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -1,53 +1,51 @@ -import { isTouch } from '../browser' +import { isSafari, isTouch } from '../browser' import { getAutoscrollTechnique } from '../util/autoscrollTechnique' -import { debugLog, editableLabel, selectionSnapshot } from '../util/debugAutoscrollLog' - -/** - * v2 technique for issue #3765. - * - * Blocks the browser's native focus + autoscroll path entirely, so iOS WebKit - * never runs its autoscroll and `position: fixed` elements (toolbar) never - * jump. Must be invoked from a `mousedown` handler — `preventDefault` on - * mousedown is what suppresses native focus. - * - * The caller is then responsible for placing the caret (e.g. via - * `getCaretOffset` + `selection.set`) and for scrolling the editor with - * `scrollCursorIntoView`. - * - * Returns true if focus was taken over, false if the call was a no-op (caller - * should fall back to default behavior). - */ -const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, e: MouseEvent | TouchEvent): boolean => { - if (!isTouch || !el) return false - - // Order matters on iOS WebKit: - // - // 1) Focus first, while the user-activation gesture from the tap is still valid. - // `preventScroll: true` (iOS Safari 15.5+) suppresses the autoscroll that normally accompanies - // focus changes, which is what causes `position: fixed` elements to jump (issue #3765). - // Calling focus() before preventDefault preserves caret-rendering: when preventDefault runs - // first, iOS sometimes registers the activeElement change but does NOT paint a caret until - // another user gesture arrives, so the caller's manual selection.set is invisible. - // - // 2) Then preventDefault to suppress the native caret-from-tap that would otherwise overwrite - // the caller's manual caret placement after this function returns. - const v2Logging = getAutoscrollTechnique() === 'v2' +import { debugLog, editableLabel } from '../util/debugAutoscrollLog' +import asyncFocus from './asyncFocus' +import * as selection from './selection' + +/** V2 chokepoint for issue #3765. Focuses an editable, places the caret at the given offset, and suppresses the native autoscroll triggered by focus and selection placement. Called from useEditMode's mousedown (synchronously within the user gesture so iOS accepts the focus, and so same-thought re-taps reposition the caret — cursorOffset is not a useEffect dep) and from its cursor-change useEffect (for programmatic cursor changes such as Return-new-thought, arrow keys, gestures, and sidebar restore). Idempotent — safe to call from both back-to-back. Strategy: (1) focus({preventScroll: true}) blocks the focus-driven autoscroll; (2) save/restore window.scrollY around selection.set to suppress the selection-driven autoscroll the browser fires when adding a Range inside a contentEditable. Keeping the cursor visible after the keyboard opens is handled separately by useScrollCursorIntoView in BulletCursorOverlay — this function intentionally does NOT scroll. */ +const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: { offset: number }): void => { + if (!el || getAutoscrollTechnique() !== 'v2') 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) { + debugLog('focusWithoutAutoscroll.noop', `el=${editableLabel(el)} offset=${offset}`) + return + } + + debugLog( + 'focusWithoutAutoscroll', + `el=${editableLabel(el)} offset=${offset} wasFocused=${wasFocused} currentOffset=${currentOffset}`, + ) + + // iOS Safari rejects programmatic selection outside a user gesture unless an asyncFocus has + // primed it. The condition mirrors useEditMode's v1 path. + if (isTouch && isSafari() && !selection.isThought()) { + asyncFocus() + } if (!wasFocused) { - if (v2Logging) { - debugLog('focus.before', `target=${editableLabel(el)} active=${editableLabel(document.activeElement)}`) - } el.focus({ preventScroll: true }) - if (v2Logging) { - debugLog('focus.after', `active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`) - } - } else if (v2Logging) { - debugLog('focus.skip', `target=${editableLabel(el)} (already active)`) + debugLog('focus.after', `active=${editableLabel(document.activeElement)}`) } - e.preventDefault() - return true + // Suppress selection-driven autoscroll: setting a Range inside a contentEditable makes the + // browser scroll the caret into view, independently of focus. Capture and revert any scrollY + // shift synchronously. The system-wide useScrollCursorIntoView in BulletCursorOverlay handles + // bringing the cursor into view once layout settles — we must not scroll here, otherwise the + // two sources interrupt each other on rapid cursor changes (spam-Enter) and the page jumps to + // a mid-screen position. + const yBefore = window.scrollY + selection.set(el, { offset }) + if (window.scrollY !== yBefore) { + const delta = window.scrollY - yBefore + debugLog('selection.set.scrollSuppressed', `Δ=${delta.toFixed(0)}`) + window.scrollTo(window.scrollX, yBefore) + } } export default focusWithoutAutoscroll diff --git a/src/util/debugAutoscrollLog.ts b/src/util/debugAutoscrollLog.ts index d6ed31611ec..b73558241d6 100644 --- a/src/util/debugAutoscrollLog.ts +++ b/src/util/debugAutoscrollLog.ts @@ -5,11 +5,12 @@ * * Remove this file once the A/B is decided. */ +import { getAutoscrollTechnique } from './autoscrollTechnique' export interface DebugAutoscrollLogEntry { /** Monotonic counter — used as React key and to spot dropped events. */ id: number - /** ms since the previous entry, or 0 for the first. */ + /** Ms since the previous entry, or 0 for the first. */ dt: number /** Short tag identifying the call site. */ tag: string @@ -66,6 +67,82 @@ export const editableLabel = (el: Element | EventTarget | null | undefined): str return text.length > 20 ? text.slice(0, 20) + '…' : text } +let scrollInstrumented = false +let scrollBurstActive = false +let scrollBurstStartY = 0 +let scrollBurstTimer: ReturnType | undefined + +/** True if v2 is active. Gating ensures v1 runs the patched paths but does not flood the log. */ +const isLogActive = () => getAutoscrollTechnique() === 'v2' + +/** + * Patches HTMLElement.focus, Element.scrollIntoView, window.scrollTo/scrollBy and adds a + * burst-clustered window.scroll listener. Use to identify what is causing native autoscroll + * (or any scroll) when working on issue #3765. Idempotent. + * + * Patched calls log unconditionally so we can see if anything fires from v1 paths too, but + * scroll bursts are noisy so they're gated on v2. + */ +export const installScrollInstrumentation = () => { + if (scrollInstrumented || typeof window === 'undefined') return + scrollInstrumented = true + + const originalFocus = HTMLElement.prototype.focus + HTMLElement.prototype.focus = function (options) { + const preventScroll = !!(options && typeof options === 'object' && options.preventScroll) + if (isLogActive()) { + debugLog('focus()', `el=${editableLabel(this)} preventScroll=${preventScroll}`) + } + return originalFocus.call(this, options) + } + + const originalScrollIntoView = Element.prototype.scrollIntoView + Element.prototype.scrollIntoView = function (arg?: boolean | ScrollIntoViewOptions) { + if (isLogActive()) { + debugLog('scrollIntoView', `el=${editableLabel(this)}`) + } + return originalScrollIntoView.call(this, arg as ScrollIntoViewOptions) + } + + const originalScrollTo = window.scrollTo.bind(window) + window.scrollTo = ((...args: unknown[]) => { + if (isLogActive()) { + const y = typeof args[0] === 'object' && args[0] !== null ? (args[0] as ScrollToOptions).top : args[1] + debugLog('window.scrollTo', `y=${y}`) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (originalScrollTo as any)(...args) + }) as typeof window.scrollTo + + const originalScrollBy = window.scrollBy.bind(window) + window.scrollBy = ((...args: unknown[]) => { + if (isLogActive()) { + debugLog('window.scrollBy', '') + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (originalScrollBy as any)(...args) + }) as typeof window.scrollBy + + window.addEventListener( + 'scroll', + () => { + if (!isLogActive()) return + if (!scrollBurstActive) { + scrollBurstActive = true + scrollBurstStartY = window.scrollY + debugLog('scroll.start', `y=${window.scrollY.toFixed(0)}`) + } + clearTimeout(scrollBurstTimer) + scrollBurstTimer = setTimeout(() => { + const delta = window.scrollY - scrollBurstStartY + debugLog('scroll.end', `y=${window.scrollY.toFixed(0)} Δ=${delta.toFixed(0)}`) + scrollBurstActive = false + }, 150) + }, + { passive: true }, + ) +} + /** Returns a one-liner describing the current DOM selection — focused node, offset, and whether it's collapsed. */ export const selectionSnapshot = (): string => { const sel = typeof window !== 'undefined' ? window.getSelection() : null From 63f076898cd85f20faaa89a82bd0339270095d59 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 01:09:18 +0100 Subject: [PATCH 03/25] refactor(autoscroll): adopt focusWithoutAutoscroll, remove v1 and debug tooling The A/B is decided in favor of v2 (focusWithoutAutoscroll): - Remove v1 preventAutoscroll and its call sites (Editable, VirtualThought). - Remove the A/B debug tooling (DebugAutoscrollToggle, autoscrollTechnique, debugAutoscrollLog). - Extract the trigger-edge computation into autoscrollEdges, keyboard-aware via virtualKeyboardStore. - Rewrite scrollCursorIntoView around the trigger edges, reading the cursor's target position from its offsetParent so it is not stale during layout animations. - asyncFocus focuses its hidden input with preventScroll: true so priming cannot itself scroll the page. --- src/components/AppComponent.tsx | 8 - src/components/DebugAutoscrollToggle.tsx | 211 ---------------------- src/components/Editable.tsx | 21 --- src/components/Editable/useEditMode.ts | 214 +++++------------------ src/components/Note.tsx | 31 ++-- src/components/VirtualThought.tsx | 4 - src/device/asyncFocus.ts | 6 +- src/device/autoscrollEdges.ts | 55 ++++++ src/device/focusWithoutAutoscroll.ts | 45 ++--- src/device/preventAutoscroll.ts | 87 --------- src/device/scrollCursorIntoView.ts | 71 ++++---- src/util/autoscrollTechnique.ts | 43 ----- src/util/debugAutoscrollLog.ts | 158 ----------------- 13 files changed, 175 insertions(+), 779 deletions(-) delete mode 100644 src/components/DebugAutoscrollToggle.tsx create mode 100644 src/device/autoscrollEdges.ts delete mode 100644 src/device/preventAutoscroll.ts delete mode 100644 src/util/autoscrollTechnique.ts delete mode 100644 src/util/debugAutoscrollLog.ts diff --git a/src/components/AppComponent.tsx b/src/components/AppComponent.tsx index 7399625914a..a5a65636dab 100644 --- a/src/components/AppComponent.tsx +++ b/src/components/AppComponent.tsx @@ -16,12 +16,10 @@ import isTutorial from '../selectors/isTutorial' import theme from '../selectors/theme' import themeColors from '../selectors/themeColors' import store from '../stores/app' -import { installScrollInstrumentation } from '../util/debugAutoscrollLog' import isDocumentEditable from '../util/isDocumentEditable' import Alert from './Alert' import CommandCenter from './CommandCenter/CommandCenter' import Content from './Content' -import DebugAutoscrollToggle from './DebugAutoscrollToggle' import DesktopCommandUniverse from './DesktopCommandUniverse' import DropGutter from './DropGutter' import ErrorMessage from './ErrorMessage' @@ -39,11 +37,6 @@ import UndoSlider from './UndoSlider' import MobileCommandUniverse from './dialog/MobileCommandUniverse' import * as modals from './modals' -// Patch focus/scroll APIs and listen for scroll bursts so the v2 debug log can show -// what's actually causing native autoscroll (e.g. selection.set after newThought). Idempotent. -// Remove with the rest of the v2 debug scaffolding once #3765 is resolved. -installScrollInstrumentation() - /** A hook that sets an attribute on the document.body element. */ const useBodyAttribute = (name: string, value: string) => { useLayoutEffect(() => { @@ -181,7 +174,6 @@ const AppComponent: FC = () => { > - {isTouch && } {!isTouch && } {isTouch && } diff --git a/src/components/DebugAutoscrollToggle.tsx b/src/components/DebugAutoscrollToggle.tsx deleted file mode 100644 index 136026ffc64..00000000000 --- a/src/components/DebugAutoscrollToggle.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Clipboard } from '@capacitor/clipboard' -import { useEffect, useState } from 'react' -import { css } from '../../styled-system/css' -import { - AutoscrollTechnique, - getAutoscrollTechnique, - setAutoscrollTechnique, - subscribeAutoscrollTechnique, -} from '../util/autoscrollTechnique' -import { - DebugAutoscrollLogEntry, - clearDebugLog, - getDebugLog, - subscribeDebugLog, -} from '../util/debugAutoscrollLog' -import fastClick from '../util/fastClick' - -/** Format entries oldest-first, one per line, for copying. */ -const formatLog = (entries: DebugAutoscrollLogEntry[]) => - entries - .slice() - .reverse() - .map(e => `+${String(e.dt).padStart(4, ' ')}ms ${e.tag}${e.data ? ' ' + e.data : ''}`) - .join('\n') - -/** - * Temporary debug overlay for issue #3765. - * - * - Left pill (autoscroll: vN) — taps cycle the v1/v2 toggle. - * - Right pill (log) — taps show/hide an in-app log of v2 events. Tap the log - * panel itself to clear. - * - * Persists in localStorage. Remove this component once the A/B is decided. - */ -const DebugAutoscrollToggle = () => { - const [technique, setTechnique] = useState(getAutoscrollTechnique) - const [showLog, setShowLog] = useState(false) - const [entries, setEntries] = useState(getDebugLog) - const [copied, setCopied] = useState(false) - - const onCopy = async () => { - const text = formatLog(entries) - // @capacitor/clipboard uses the native iOS pasteboard on Capacitor and falls back to - // navigator.clipboard on web. WKWebView's navigator.clipboard is unreliable, so this is the - // only path that works in iOS Capacitor. - try { - await Clipboard.write({ string: text }) - setCopied(true) - setTimeout(() => setCopied(false), 1200) - } catch (err) { - // eslint-disable-next-line no-console - console.error('clipboard copy failed', err) - } - } - - useEffect(() => subscribeAutoscrollTechnique(setTechnique), []) - useEffect( - () => - subscribeDebugLog(latest => { - // copy so React sees a new reference - setEntries(latest.slice()) - }), - [], - ) - - return ( - <> - {/* Toggle pills — top-right. Kept small to minimize occlusion. */} -
-
setAutoscrollTechnique(technique === 'v1' ? 'v2' : 'v1'))} - className={css({ - padding: '6px 10px', - borderRadius: '999px', - fontSize: '11px', - fontFamily: 'monospace', - fontWeight: 'bold', - background: technique === 'v2' ? '#2563eb' : '#444', - color: 'white', - boxShadow: '0 2px 6px rgba(0,0,0,0.3)', - userSelect: 'none', - WebkitUserSelect: 'none', - cursor: 'pointer', - opacity: 0.85, - })} - > - autoscroll: {technique} -
-
setShowLog(s => !s))} - className={css({ - padding: '6px 10px', - borderRadius: '999px', - fontSize: '11px', - fontFamily: 'monospace', - fontWeight: 'bold', - background: showLog ? '#16a34a' : '#444', - color: 'white', - boxShadow: '0 2px 6px rgba(0,0,0,0.3)', - userSelect: 'none', - WebkitUserSelect: 'none', - cursor: 'pointer', - opacity: 0.85, - })} - > - log {showLog ? '▼' : '▶'} -
-
- - {/* Log panel — top-right under the pills. Width-limited so thought text remains visible on the - left. Top placement guarantees the keyboard never occludes the clear button. */} - {showLog && ( -
-
-
- {copied ? '✓ copied' : '⧉ copy'} -
-
clearDebugLog())} - className={css({ - padding: '4px 10px', - borderRadius: '999px', - fontSize: '11px', - fontFamily: 'monospace', - fontWeight: 'bold', - background: '#dc2626', - color: 'white', - boxShadow: '0 2px 6px rgba(0,0,0,0.3)', - userSelect: 'none', - WebkitUserSelect: 'none', - cursor: 'pointer', - opacity: 0.9, - })} - > - ✕ clear -
-
-
- {entries.length === 0 ? ( -
(no events — tap a thought)
- ) : ( - entries.map(entry => ( -
- +{String(entry.dt).padStart(4, ' ')}ms - {entry.tag} - {entry.data ? {entry.data} : null} -
- )) - )} -
-
- )} - - ) -} - -export default DebugAutoscrollToggle diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 250b64f63c0..85d7313e812 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -45,8 +45,6 @@ import editingValueStore from '../stores/editingValue' import editingValueUntrimmedStore from '../stores/editingValueUntrimmed' import storageModel from '../stores/storageModel' import suppressFocusStore from '../stores/suppressFocus' -import { getAutoscrollTechnique } from '../util/autoscrollTechnique' -import { debugLog, editableLabel, selectionSnapshot } from '../util/debugAutoscrollLog' import addEmojiSpace from '../util/addEmojiSpace' import containsURL from '../util/containsURL' import ellipsize from '../util/ellipsize' @@ -231,10 +229,6 @@ const Editable = ({ // Prevent the cursor offset from being restored after the initial setCursorOnThought. cursorOffsetInitialized = true - if (getAutoscrollTechnique() === 'v2') { - debugLog('setCursorOnThought', `el=${editableLabel(contentRef.current)} offset=${offset}`) - } - dispatch( setCursor({ cursorHistoryClear: true, @@ -507,12 +501,6 @@ const Editable = ({ /** Flushes edits and updates certain state variables on blur. */ const onBlur: FocusEventHandler = useCallback( e => { - if (getAutoscrollTechnique() === 'v2') { - debugLog( - 'onBlur', - `el=${editableLabel(contentRef.current)} relatedTarget=${editableLabel(e.relatedTarget)}`, - ) - } throttledChangeRef.current.flush() // update the ContentEditable if the new scrubbed value is different (i.e. stripped, space after emoji added, etc) @@ -590,14 +578,8 @@ const Editable = ({ } if (suppressFocusStore.getState()) { - if (getAutoscrollTechnique() === 'v2') { - debugLog('onFocus', `el=${editableLabel(contentRef.current)} suppressed=true`) - } return } - if (getAutoscrollTechnique() === 'v2') { - debugLog('onFocus', `el=${editableLabel(contentRef.current)} suppressed=false`) - } // Update editingValueUntrimmedStore with the current value editingValueUntrimmedStore.update(value) @@ -618,9 +600,6 @@ const Editable = ({ */ const handleTapBehavior = useCallback( (e: MouseEvent | TouchEvent) => { - if (getAutoscrollTechnique() === 'v2') { - debugLog('handleTap', `type=${e.type} el=${editableLabel(contentRef.current)} ${selectionSnapshot()}`) - } // When MultiGesture is below the gesture threshold it is possible that onClick and onTouchEnd // both trigger. Prevent handleTapBehavior from running a second time via touchend in that case. // https://github.com/cybersemics/em/issues/1268 diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 4bfa99ea599..58a57e7baf4 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -1,20 +1,16 @@ -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' import Path from '../../@types/Path' import { setCursorActionCreator as setCursor } from '../../actions/setCursor' -import { isMac, isSafari, isTouch } from '../../browser' +import { isMac, 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' -import { getAutoscrollTechnique } from '../../util/autoscrollTechnique' -import { debugLog, editableLabel, selectionSnapshot } from '../../util/debugAutoscrollLog' import equalPath from '../../util/equalPath' /** Automatically sets the selection on the given contentRef element when the thought should be selected. Handles a variety of conditions that determine whether this should occur. */ @@ -43,12 +39,10 @@ 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() const dispatch = useDispatch() - const offsetRef = useRef(null) // focus on the ContentEditable element if editing or on desktop const editMode = !isTouch || editing @@ -58,8 +52,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() - const technique = getAutoscrollTechnique() + const { cursorOffset } = store.getState() // allow transient editable to have focus on render const shouldSetSelection = @@ -73,13 +66,6 @@ const useEditMode = ({ !dragHold && !disabledRef.current) - if (technique === 'v2') { - debugLog( - 'useEffect', - `el=${editableLabel(contentRef.current)} cursorOffset=${cursorOffset} shouldSet=${shouldSetSelection} active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`, - ) - } - if (!shouldSetSelection) return // do not set the selection on hidden thoughts, otherwise it will cause a faulty focus event when switching windows @@ -89,33 +75,11 @@ const useEditMode = ({ return } - if (technique === 'v2') { - // Chokepoint — handles focus, selection.set with autoscroll suppression, and deferred - // scroll. This is the path for all programmatic cursor changes: Return-new-thought, - // arrow keys (incl. bluetooth on iPad), gestures, sidebar restore. - focusWithoutAutoscroll(contentRef.current, { offset: cursorOffset ?? 0 }) - } else { - 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 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() - } - - selection.set(contentRef.current, { offset: cursorOffset ?? 0 }) - } + // Chokepoint for all programmatic cursor changes — Return-new-thought, arrow keys (incl. + // bluetooth on iPad), gestures, sidebar restore. 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 @@ -136,42 +100,17 @@ 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 })) - } - /** * 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 (getAutoscrollTechnique() === 'v2') { - debugLog( - 'mousedown.entry', - `el=${editableLabel(editable)} editingOrOnCursor=${editingOrOnCursor} isMulticursor=${isMulticursor} active=${editableLabel(document.activeElement)}`, - ) - } - // If CMD/CTRL is pressed, don't focus the editable. const isMultiselectClick = isMac ? e.metaKey : e.ctrlKey if (isMultiselectClick) { @@ -179,88 +118,44 @@ const useEditMode = ({ 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) { - // A/B toggle for issue #3765 — see src/util/autoscrollTechnique.ts. - const technique = getAutoscrollTechnique() - - if (technique === 'v2') { - // v2 strategy for issue #3765. All cursor changes (taps, Return, arrow keys, gestures) - // converge on `focusWithoutAutoscroll` — see its docstring. Mousedown calls it inline - // here, synchronously within the user gesture, so: - // - iOS Safari accepts the focus (no asyncFocus dance needed for the tap path) - // - same-thought re-taps reposition the caret (cursorOffset is not a useEffect dep, - // so we cannot rely on the useEffect for offset-only changes) - // The cursor-change useEffect also calls focusWithoutAutoscroll for programmatic - // paths; the function is idempotent so the second call is a no-op. - const { offset } = getCaretOffset(editable, { - clientX: e.clientX, - clientY: e.clientY, - }) - - debugLog( - 'mousedown', - `el=${editableLabel(editable)} active=${editableLabel(document.activeElement)} offset=${offset}`, - ) - - // Block the native focus + native caret-from-tap before they fire. - 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 clobbering cursorOffset to 0. - dispatch( - setCursor({ - path, - offset: targetOffset, - isKeyboardOpen: true, - cursorHistoryClear: true, - preserveMulticursor: true, - }), - ) - - focusWithoutAutoscroll(editable, { offset: targetOffset }) - debugLog('postFocus', `active=${editableLabel(document.activeElement)} ${selectionSnapshot()}`) - } else { - // v1: existing centering hack. 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, - }) - - const { inVoidArea, offset } = getCaretOffset(editable, { - clientX: e.clientX, - clientY: e.clientY, - }) + // All cursor changes (taps, Return, arrow keys, gestures) converge on + // `focusWithoutAutoscroll`. Mousedown calls it inline here, synchronously within the + // user gesture, so: + // - iOS Safari accepts the focus (no asyncFocus dance needed for the tap path). + // - Same-thought re-taps reposition the caret (cursorOffset is not a useEffect dep, + // so we cannot rely on the cursor-change useEffect for offset-only changes). + // The cursor-change useEffect also calls focusWithoutAutoscroll for programmatic paths; + // the function is idempotent so the second call is a no-op. + const { offset } = getCaretOffset(editable, { + clientX: e.clientX, + clientY: e.clientY, + }) + + // Block the native focus + native caret-from-tap before they fire. + e.preventDefault() - if (offset !== null) { - if (isTouch && isSafari()) { - offsetRef.current = offset - allowDefaultSelection() - } else { - setCaretOffset(offset) - } + // 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 clobbering cursorOffset to 0. + dispatch( + setCursor({ + path, + offset: targetOffset, + isKeyboardOpen: true, + cursorHistoryClear: true, + preserveMulticursor: true, + }), + ) - // 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() - } - } else { - allowDefaultSelection() - } - } + focusWithoutAutoscroll(editable, { offset: targetOffset }) } else { - if (getAutoscrollTechnique() === 'v2') { - debugLog('mousedown.elseBranch', `el=${editableLabel(editable)} (preventDefault, no v2 logic)`) - } // 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. // Steps to Reproduce: https://github.com/cybersemics/em/pull/2948#issuecomment-2887186117 @@ -269,34 +164,11 @@ const useEditMode = ({ } } - /** - * Handles the mouseup event for the editable element. - * Preserve native drag selection behavior by deferring setCaretOffset until mouseup. - */ - const onMouseUp = (e: MouseEvent) => { - if (offsetRef.current !== null) { - // Certain taps that are outside of the regular bounds of the editable element will fail to trigger onMouseUp. - // In those cases, allowDefaultSelection will be activated in onMouseDown, which will allow the native browser - // selection behavior to take over instead of responding to setCursor with a null offset. - disabledRef.current = false - setCaretOffset(offsetRef.current) - } - - /** 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. - */ - queueMicrotask(() => preventAutoscrollEnd(editable)) - } - editable.addEventListener('mousedown', onMouseDown) - if (isTouch && isSafari()) editable.addEventListener('mouseup', onMouseUp) - return () => { editable.removeEventListener('mousedown', onMouseDown) - if (isTouch && isSafari()) editable.removeEventListener('mouseup', onMouseUp) } - }, [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 c4aa56daed5..ca6e07fa066 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -14,7 +14,6 @@ import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote' import { isTouch } from '../browser' import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll' import getCaretOffset from '../device/getCaretOffset' -import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll' import * as selection from '../device/selection' import useFreshCallback from '../hooks/useFreshCallback' import getThoughtById from '../selectors/getThoughtById' @@ -22,7 +21,6 @@ import noteValue from '../selectors/noteValue' import resolveNotePath from '../selectors/resolveNotePath' import store from '../stores/app' import batchEditingStore from '../stores/batchEditing' -import { getAutoscrollTechnique } from '../util/autoscrollTechnique' import equalPathHead from '../util/equalPathHead' import head from '../util/head' import strip from '../util/strip' @@ -51,7 +49,6 @@ const Note = React.memo( /** Focus Handling with useFreshCallback. */ const onFocus = useFreshCallback(() => { - preventAutoscrollEnd(noteRef.current) dispatch( setCursor({ path, @@ -63,11 +60,12 @@ 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). + // Routes through the focusWithoutAutoscroll chokepoint so the iOS native selection autoscroll + // doesn't jitter the toolbar (#3765). 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]) @@ -157,20 +155,13 @@ const Note = React.memo( const note = noteRef.current if (!note) return - // A/B toggle for issue #3765 — see src/util/autoscrollTechnique.ts. - if (getAutoscrollTechnique() === 'v2') { - // v2: block native focus + native caret-from-tap, then route through focusWithoutAutoscroll - // (same chokepoint Editable uses) which focuses with preventScroll, places the caret at - // the tap offset, suppresses selection-driven autoscroll, and schedules the post-keyboard - // scroll into view. - const { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) - if (offset !== null) { - e.preventDefault() - focusWithoutAutoscroll(note, { offset }) - } - } else { - // v1: existing centering hack. - preventAutoscroll(note) + // Block native focus + native caret-from-tap, then route through focusWithoutAutoscroll + // (same chokepoint Editable uses) — focuses with preventScroll, places the caret at the + // tap offset, and suppresses the selection-driven autoscroll on iOS. + const { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) + if (offset !== null) { + e.preventDefault() + focusWithoutAutoscroll(note, { offset }) } }, []) diff --git a/src/components/VirtualThought.tsx b/src/components/VirtualThought.tsx index 1e9c610e7d2..e3f4a1f60ce 100644 --- a/src/components/VirtualThought.tsx +++ b/src/components/VirtualThought.tsx @@ -158,10 +158,6 @@ const VirtualThought = ({ ) const widthNew = ref.current.querySelector(`[data-editable]`)?.getBoundingClientRect().width - // skip updating height when preventAutoscroll is enabled, as it modifies the element's height in order to trick Safari into not scrolling - const editable = ref.current.querySelector(`[data-editable]`) - if (editable?.hasAttribute('data-prevent-autoscroll')) return - // 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. const isVisibleNew = autofocus === 'show' || autofocus === 'dim' diff --git a/src/device/asyncFocus.ts b/src/device/asyncFocus.ts index b02e7e8e6bb..c3e69dd1c92 100644 --- a/src/device/asyncFocus.ts +++ b/src/device/asyncFocus.ts @@ -35,7 +35,11 @@ export const AsyncFocus: () => () => void = () => { // do not set the selection if it is already on a thought or a note if (!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 iOS's selection-allowed state). + 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..7881b8c937f --- /dev/null +++ b/src/device/autoscrollEdges.ts @@ -0,0 +1,55 @@ +import store from '../stores/app' +import viewportStore from '../stores/viewport' +import virtualKeyboardStore from '../stores/virtualKeyboardStore' + +/** Computed top/bottom edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. The "comfort zone" is between topEdge and bottomEdge. The buffer is the same distance applied at both edges — a thought entering within `buffer` px of the visible area triggers scrolling. */ +export interface AutoscrollEdges { + /** Top of the visible area below the toolbar, in viewport coords. */ + toolbarBottom: number + /** Height of the navbar at the bottom of the visible area. */ + navbarHeight: number + /** Effective keyboard inset in px. Accounts for both visualViewport shrink (web Safari) and `virtualKeyboardStore.height` (iOS Capacitor). 0 when keyboard is closed. */ + keyboardInset: number + /** Px of buffer applied at both top and bottom (~2 line-heights). */ + buffer: number + /** Trigger threshold at the top, in viewport coords. */ + topEdge: number + /** Trigger threshold at the bottom, in viewport coords. */ + bottomEdge: number +} + +/** Reads the live DOM + store values needed to compute the autoscroll trigger zones. */ +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 + + // On web Safari `visualViewport.height` shrinks when the keyboard opens, so + // `innerHeight - visualViewport.height` is the keyboard inset. On iOS Capacitor the WKWebView + // keeps `visualViewport.height` at the full innerHeight (the keyboard overlays the page), so we + // also consult `virtualKeyboardStore.height` which is set by the @capacitor/keyboard handler. + // Taking the max means whichever source thinks the keyboard is taller wins. + const visualViewportHeight = window.visualViewport?.height ?? window.innerHeight + const { height: keyboardAnimatedHeight, open: keyboardOpenStore } = virtualKeyboardStore.getState() + const keyboardOpen = keyboardOpenStore || store.getState().isKeyboardOpen === true + const keyboardInset = keyboardOpen ? Math.max(window.innerHeight - visualViewportHeight, keyboardAnimatedHeight) : 0 + + const fontSize = store.getState().fontSize + const buffer = fontSize * 2 + + const topEdge = toolbarBottom + buffer + const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - buffer + + return { + toolbarBottom, + navbarHeight, + keyboardInset, + buffer, + topEdge, + bottomEdge, + } +} + +/** LayoutTreeTop fallback for scrollCursorIntoView when the cursor element's offsetParent can't be read from the DOM. */ +export const getLayoutTreeTop = () => viewportStore.getState().layoutTreeTop diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index b84c60fddfb..ccb1459a3a7 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -1,49 +1,52 @@ import { isSafari, isTouch } from '../browser' -import { getAutoscrollTechnique } from '../util/autoscrollTechnique' -import { debugLog, editableLabel } from '../util/debugAutoscrollLog' import asyncFocus from './asyncFocus' import * as selection from './selection' -/** V2 chokepoint for issue #3765. Focuses an editable, places the caret at the given offset, and suppresses the native autoscroll triggered by focus and selection placement. Called from useEditMode's mousedown (synchronously within the user gesture so iOS accepts the focus, and so same-thought re-taps reposition the caret — cursorOffset is not a useEffect dep) and from its cursor-change useEffect (for programmatic cursor changes such as Return-new-thought, arrow keys, gestures, and sidebar restore). Idempotent — safe to call from both back-to-back. Strategy: (1) focus({preventScroll: true}) blocks the focus-driven autoscroll; (2) save/restore window.scrollY around selection.set to suppress the selection-driven autoscroll the browser fires when adding a Range inside a contentEditable. Keeping the cursor visible after the keyboard opens is handled separately by useScrollCursorIntoView in BulletCursorOverlay — this function intentionally does NOT scroll. */ +/** + * Chokepoint for focusing an editable and placing the caret without triggering iOS native + * autoscroll. Suppresses the autoscroll iOS WebKit fires when focus or selection moves — the + * cause of `position: fixed` elements jumping in issue #3765. + * + * Called from `useEditMode` (mousedown + cursor-change useEffect) and `Note` (mousedown + + * note-focus useEffect). Mousedown calls it synchronously within the user gesture so iOS accepts + * the focus and same-thought re-taps can reposition the caret. The useEffect call handles + * programmatic cursor changes (Return, arrow keys, gestures, sidebar restore, toggleNote). + * Idempotent — safe to call repeatedly with the same element + offset. + * + * Strategy: (1) `focus({ preventScroll: true })` blocks the focus-driven autoscroll; (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. + * + * Keeping the cursor visible after the keyboard opens is handled separately by + * `useScrollCursorIntoView` in `BulletCursorOverlay` — this function intentionally does NOT scroll. + */ const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: { offset: number }): void => { - if (!el || getAutoscrollTechnique() !== 'v2') return + 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) { - debugLog('focusWithoutAutoscroll.noop', `el=${editableLabel(el)} offset=${offset}`) - return - } - - debugLog( - 'focusWithoutAutoscroll', - `el=${editableLabel(el)} offset=${offset} wasFocused=${wasFocused} currentOffset=${currentOffset}`, - ) + if (wasFocused && currentOffset === offset) return // iOS Safari rejects programmatic selection outside a user gesture unless an asyncFocus has - // primed it. The condition mirrors useEditMode's v1 path. + // primed it. if (isTouch && isSafari() && !selection.isThought()) { asyncFocus() } if (!wasFocused) { el.focus({ preventScroll: true }) - debugLog('focus.after', `active=${editableLabel(document.activeElement)}`) } // Suppress selection-driven autoscroll: setting a Range inside a contentEditable makes the // browser scroll the caret into view, independently of focus. Capture and revert any scrollY - // shift synchronously. The system-wide useScrollCursorIntoView in BulletCursorOverlay handles - // bringing the cursor into view once layout settles — we must not scroll here, otherwise the - // two sources interrupt each other on rapid cursor changes (spam-Enter) and the page jumps to - // a mid-screen position. + // shift synchronously. `useScrollCursorIntoView` handles bringing the cursor into view once + // layout settles — we must not scroll here, otherwise the two sources interrupt each other on + // rapid cursor changes (spam-Enter) and the page jumps to a mid-screen position. const yBefore = window.scrollY selection.set(el, { offset }) if (window.scrollY !== yBefore) { - const delta = window.scrollY - yBefore - debugLog('selection.set.scrollSuppressed', `Δ=${delta.toFixed(0)}`) window.scrollTo(window.scrollX, yBefore) } } diff --git a/src/device/preventAutoscroll.ts b/src/device/preventAutoscroll.ts deleted file mode 100644 index 05b60021d6b..00000000000 --- a/src/device/preventAutoscroll.ts +++ /dev/null @@ -1,87 +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 -/** 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 - - 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 - - transformOld = el.style.transform - paddingBottomOld = el.style.paddingBottom - paddingTopOld = el.style.paddingTop - 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}px` - } - // 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` - } - - // 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 - -export default preventAutoscroll diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index fe1dd9c3a87..4e550f2979f 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -1,56 +1,59 @@ 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 store from '../stores/app' -/** Scrolls the minimum amount necessary to move the viewport so that it includes the element. */ +/** Scrolls the minimum amount necessary to move the viewport so that it includes the cursor. */ 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 trigger edge the cursor settles after a scroll fires. + // Independent from the trigger buffer above: the cursor begins scrolling once it enters the + // buffer band, but settles `scrollMargin` px inside the edge so it doesn't sit flush against + // the toolbar/keyboard. ~1 line-height feels right at default fontSize. + const scrollMargin = store.getState().fontSize * 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 + // Target viewport y of the cursor. + // + // Sources we *don't* use, and why: + // - `cursorEl.getBoundingClientRect().top` — the cursor-overlay-tree-node has a CSS transition + // on `top`, so the rect returns the mid-flight animating value, not the target. Reading it + // would make the trigger see "where the cursor used to be" right after every cursor change. + // - `viewportStore.layoutTreeTop + y − scrollY` — `layoutTreeTop` is set in a useEffect and is + // one frame behind autocrop changes, so this disagrees with the live layout-tree position + // during cursor moves that change autocrop. + // + // What we *do* use: the cursor element's offsetParent (the inner layout-tree div with the + // translateX transform) has its position updated synchronously when autocrop changes — no CSS + // transition, no stale store value. Its live `rect.top` plus the `y` arg (the post-render target + // the cursor's inline `top` style is being transitioned toward) gives the target viewport y. + 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) + const isAboveViewport = yViewport < topEdge + const isBelowViewport = yViewport + height > bottomEdge if (!isAboveViewport && !isBelowViewport) 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 - - // 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 + // Therefore, we need to calculate the scroll position ourselves. + // Land the cursor `scrollMargin` px inside the trigger edge so it sits in the comfort zone, not flush with the edge. const scrollYNew = isAboveViewport - ? yDocument - (toolbarRect?.height ?? 0) - height / 2 - : yDocument - visualViewportHeight + height * 1.5 + (navbarRect?.height ?? 0) + ? yDocument - topEdge - scrollMargin + : yDocument + height - bottomEdge + scrollMargin // 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 visualViewportHeight = window.visualViewport?.height ?? window.innerHeight const scrollDistance = Math.abs(scrollYNew - window.scrollY) const behavior: ScrollBehavior = scrollDistance < visualViewportHeight ? 'smooth' : 'auto' diff --git a/src/util/autoscrollTechnique.ts b/src/util/autoscrollTechnique.ts deleted file mode 100644 index 87122aa3f24..00000000000 --- a/src/util/autoscrollTechnique.ts +++ /dev/null @@ -1,43 +0,0 @@ -import storage from './storage' - -/** - * Temporary A/B toggle for issue #3765 — selects which technique is used to - * suppress iOS native autoscroll on tap-to-focus: - * - * - 'v1': existing `preventAutoscroll` (transform/padding centering hack) - * - 'v2': new `focusWithoutAutoscroll` (preventDefault + focus({preventScroll: true})) - * - * Persisted to localStorage so it survives the keyboard-induced reloads that - * are common on iOS Capacitor during testing. - * - * Remove this file once the A/B is decided. - */ - -export type AutoscrollTechnique = 'v1' | 'v2' - -const STORAGE_KEY = 'debug-autoscroll-technique' -const DEFAULT: AutoscrollTechnique = 'v1' - -const listeners = new Set<(t: AutoscrollTechnique) => void>() - -const isValid = (value: string | null): value is AutoscrollTechnique => value === 'v1' || value === 'v2' - -/** Read the current technique synchronously. Safe to call from anywhere — falls back to default on SSR. */ -export const getAutoscrollTechnique = (): AutoscrollTechnique => { - const raw = storage.getItem(STORAGE_KEY) - return isValid(raw) ? raw : DEFAULT -} - -/** Set the technique and notify subscribers. */ -export const setAutoscrollTechnique = (technique: AutoscrollTechnique) => { - storage.setItem(STORAGE_KEY, technique) - listeners.forEach(l => l(technique)) -} - -/** Subscribe to changes. Returns an unsubscribe function. */ -export const subscribeAutoscrollTechnique = (listener: (t: AutoscrollTechnique) => void) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } -} diff --git a/src/util/debugAutoscrollLog.ts b/src/util/debugAutoscrollLog.ts deleted file mode 100644 index b73558241d6..00000000000 --- a/src/util/debugAutoscrollLog.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Lightweight in-memory log for debugging issue #3765 v2 autoscroll path. - * Logs are visible in the DebugAutoscrollToggle panel on-device, since iOS - * Capacitor doesn't surface console output where we can read it. - * - * Remove this file once the A/B is decided. - */ -import { getAutoscrollTechnique } from './autoscrollTechnique' - -export interface DebugAutoscrollLogEntry { - /** Monotonic counter — used as React key and to spot dropped events. */ - id: number - /** Ms since the previous entry, or 0 for the first. */ - dt: number - /** Short tag identifying the call site. */ - tag: string - /** Optional payload — kept short, since we render it on a 4-inch screen. */ - data?: string -} - -const MAX_ENTRIES = 30 - -let nextId = 1 -let lastTime = 0 -const entries: DebugAutoscrollLogEntry[] = [] -const listeners = new Set<(entries: DebugAutoscrollLogEntry[]) => void>() - -/** Log a debug event. Drops events past MAX_ENTRIES (keeps newest). */ -export const debugLog = (tag: string, data?: string) => { - const now = performance.now() - const dt = lastTime === 0 ? 0 : Math.round(now - lastTime) - lastTime = now - - entries.unshift({ id: nextId++, dt, tag, data }) - if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES - - listeners.forEach(l => l(entries)) -} - -/** Snapshot of current entries (newest first). */ -export const getDebugLog = () => entries - -/** Subscribe to log changes. Returns unsubscribe. */ -export const subscribeDebugLog = (listener: (entries: DebugAutoscrollLogEntry[]) => void) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } -} - -/** Clear all entries. */ -export const clearDebugLog = () => { - entries.length = 0 - nextId = 1 - lastTime = 0 - listeners.forEach(l => l(entries)) -} - -/** - * Returns a short identifier for an editable element, suitable for log lines. - * Prefers the first ~20 chars of its text content (collapsed whitespace). - */ -export const editableLabel = (el: Element | EventTarget | null | undefined): string => { - if (!el || !(el instanceof Element)) return 'null' - const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() - if (!text) return '' - return text.length > 20 ? text.slice(0, 20) + '…' : text -} - -let scrollInstrumented = false -let scrollBurstActive = false -let scrollBurstStartY = 0 -let scrollBurstTimer: ReturnType | undefined - -/** True if v2 is active. Gating ensures v1 runs the patched paths but does not flood the log. */ -const isLogActive = () => getAutoscrollTechnique() === 'v2' - -/** - * Patches HTMLElement.focus, Element.scrollIntoView, window.scrollTo/scrollBy and adds a - * burst-clustered window.scroll listener. Use to identify what is causing native autoscroll - * (or any scroll) when working on issue #3765. Idempotent. - * - * Patched calls log unconditionally so we can see if anything fires from v1 paths too, but - * scroll bursts are noisy so they're gated on v2. - */ -export const installScrollInstrumentation = () => { - if (scrollInstrumented || typeof window === 'undefined') return - scrollInstrumented = true - - const originalFocus = HTMLElement.prototype.focus - HTMLElement.prototype.focus = function (options) { - const preventScroll = !!(options && typeof options === 'object' && options.preventScroll) - if (isLogActive()) { - debugLog('focus()', `el=${editableLabel(this)} preventScroll=${preventScroll}`) - } - return originalFocus.call(this, options) - } - - const originalScrollIntoView = Element.prototype.scrollIntoView - Element.prototype.scrollIntoView = function (arg?: boolean | ScrollIntoViewOptions) { - if (isLogActive()) { - debugLog('scrollIntoView', `el=${editableLabel(this)}`) - } - return originalScrollIntoView.call(this, arg as ScrollIntoViewOptions) - } - - const originalScrollTo = window.scrollTo.bind(window) - window.scrollTo = ((...args: unknown[]) => { - if (isLogActive()) { - const y = typeof args[0] === 'object' && args[0] !== null ? (args[0] as ScrollToOptions).top : args[1] - debugLog('window.scrollTo', `y=${y}`) - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (originalScrollTo as any)(...args) - }) as typeof window.scrollTo - - const originalScrollBy = window.scrollBy.bind(window) - window.scrollBy = ((...args: unknown[]) => { - if (isLogActive()) { - debugLog('window.scrollBy', '') - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (originalScrollBy as any)(...args) - }) as typeof window.scrollBy - - window.addEventListener( - 'scroll', - () => { - if (!isLogActive()) return - if (!scrollBurstActive) { - scrollBurstActive = true - scrollBurstStartY = window.scrollY - debugLog('scroll.start', `y=${window.scrollY.toFixed(0)}`) - } - clearTimeout(scrollBurstTimer) - scrollBurstTimer = setTimeout(() => { - const delta = window.scrollY - scrollBurstStartY - debugLog('scroll.end', `y=${window.scrollY.toFixed(0)} Δ=${delta.toFixed(0)}`) - scrollBurstActive = false - }, 150) - }, - { passive: true }, - ) -} - -/** Returns a one-liner describing the current DOM selection — focused node, offset, and whether it's collapsed. */ -export const selectionSnapshot = (): string => { - const sel = typeof window !== 'undefined' ? window.getSelection() : null - if (!sel || sel.rangeCount === 0) return 'sel=none' - const node = sel.focusNode - const owner = - node && node.nodeType === Node.TEXT_NODE - ? (node.parentElement?.closest('[data-editable], [aria-label="note-editable"]') ?? node.parentElement) - : node instanceof Element - ? node.closest('[data-editable], [aria-label="note-editable"]') - : null - return `sel=${editableLabel(owner)}@${sel.focusOffset}${sel.isCollapsed ? '' : ' (range)'}` -} From 206885e4297cf507a1cd5421c00cba45f0766472 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 02:11:37 +0100 Subject: [PATCH 04/25] fix(useEditMode): restore native browser selection on non-iOS platforms The #3765 chokepoint made onMouseDown preventDefault unconditional, which suppressed native drag-select, double-click-word, and the right-click context menu on desktop. Restore main's inVoidArea escape hatch: preventDefault only fires in a void area on non-iOS platforms, while iOS Safari still always blocks so the focusWithoutAutoscroll chokepoint can suppress the position:fixed jolt. --- src/components/Editable/useEditMode.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 58a57e7baf4..8b07453ab15 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -4,7 +4,7 @@ import { useStore } from 'react-redux' import { useDispatch } from 'react-redux' import Path from '../../@types/Path' import { setCursorActionCreator as setCursor } from '../../actions/setCursor' -import { isMac, isTouch } from '../../browser' +import { isMac, isSafari, isTouch } from '../../browser' import { LongPressState } from '../../constants' import focusWithoutAutoscroll from '../../device/focusWithoutAutoscroll' import getCaretOffset from '../../device/getCaretOffset' @@ -129,13 +129,21 @@ const useEditMode = ({ // so we cannot rely on the cursor-change useEffect for offset-only changes). // The cursor-change useEffect also calls focusWithoutAutoscroll for programmatic paths; // the function is idempotent so the second call is a no-op. - const { offset } = getCaretOffset(editable, { + const { inVoidArea, offset } = getCaretOffset(editable, { clientX: e.clientX, clientY: e.clientY, }) - // Block the native focus + native caret-from-tap before they fire. - e.preventDefault() + // Block the native mousedown so it can't focus the element and place the caret itself. + // - iOS Safari: always block, so the native focus/selection autoscroll never fires and + // jolts position:fixed elements (#3765). focusWithoutAutoscroll re-takes focus cleanly. + // - Other platforms: only block when the tap lands in a void area (outside any text node), + // otherwise leave the native mousedown alone so browser selection — drag-select, + // double-click-word, right-click context menu — still works. Preventing it + // unconditionally would break those. See: #981, #2948 + 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 From f86f52a638573e659ed64666335b7e392641fb01 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 02:26:10 +0100 Subject: [PATCH 05/25] fix(autoscroll): restore per-keystroke scroll rhythm at the bottom edge Pressing Enter at the bottom of the screen should trigger a small scroll on every keystroke, as iOS native autoscroll (and main) does. The rhythm holds iff the landing margin is smaller than one line step; the fixed fontSize*2 margin plus the fontSize*2 trigger buffer could equal or exceed it, absorbing Enters and then scrolling too far. - scrollMargin: fontSize*2 -> height/2, so the cursor always lands half a line inside the edge and the next Enter re-triggers. Also scales the landing position with multi-line thoughts, matching the dynamic feel of iOS native. - bottomEdge buffer: 0 while the keyboard is open. The keyboard inset already keeps the cursor above the keyboard; an additional inset trigger edge on the small visible strip made scrolls fire early and overshoot. Top edge buffer unchanged. --- src/device/autoscrollEdges.ts | 24 +++++++++++++++++------- src/device/scrollCursorIntoView.ts | 16 +++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index 7881b8c937f..7fa21725323 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -2,7 +2,7 @@ import store from '../stores/app' import viewportStore from '../stores/viewport' import virtualKeyboardStore from '../stores/virtualKeyboardStore' -/** Computed top/bottom edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. The "comfort zone" is between topEdge and bottomEdge. The buffer is the same distance applied at both edges — a thought entering within `buffer` px of the visible area triggers scrolling. */ +/** Computed top/bottom edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. The "comfort zone" is between topEdge and bottomEdge. A thought entering within the buffer band of either edge triggers scrolling. */ export interface AutoscrollEdges { /** Top of the visible area below the toolbar, in viewport coords. */ toolbarBottom: number @@ -10,8 +10,10 @@ export interface AutoscrollEdges { navbarHeight: number /** Effective keyboard inset in px. Accounts for both visualViewport shrink (web Safari) and `virtualKeyboardStore.height` (iOS Capacitor). 0 when keyboard is closed. */ keyboardInset: number - /** Px of buffer applied at both top and bottom (~2 line-heights). */ - buffer: number + /** Px of buffer applied at the top edge (~2 line-heights). */ + topBuffer: number + /** Px of buffer applied at the bottom edge. 0 while the keyboard is open — the visible strip is small and an inset trigger edge makes typing-at-the-bottom scrolls feel like they fire early and overshoot (#3765). */ + bottomBuffer: number /** Trigger threshold at the top, in viewport coords. */ topEdge: number /** Trigger threshold at the bottom, in viewport coords. */ @@ -36,16 +38,24 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { const keyboardInset = keyboardOpen ? Math.max(window.innerHeight - visualViewportHeight, keyboardAnimatedHeight) : 0 const fontSize = store.getState().fontSize - const buffer = fontSize * 2 + const topBuffer = fontSize * 2 - const topEdge = toolbarBottom + buffer - const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - buffer + // No bottom buffer while the keyboard is open: with the visible strip already small, insetting + // the trigger edge here (on top of scrollCursorIntoView's landing margin) makes typing at the + // bottom scroll before the cursor reaches the keyboard and overshoot when it does — which breaks + // the scroll-per-Enter rhythm that iOS native autoscroll (and main) has. The keyboard inset + // itself already keeps the cursor above the keyboard. + const bottomBuffer = keyboardOpen ? 0 : fontSize * 2 + + const topEdge = toolbarBottom + topBuffer + const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - bottomBuffer return { toolbarBottom, navbarHeight, keyboardInset, - buffer, + topBuffer, + bottomBuffer, topEdge, bottomEdge, } diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index 4e550f2979f..cdfa3077569 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -1,16 +1,22 @@ import { isSafari, isTouch } from '../browser' import { getAutoscrollEdges, getLayoutTreeTop } from '../device/autoscrollEdges' -import store from '../stores/app' /** Scrolls the minimum amount necessary to move the viewport so that it includes the cursor. */ const scrollIntoViewIfNeeded = (y: number, height: number) => { const { topEdge, bottomEdge } = getAutoscrollEdges() // Landing margin — how far inside the trigger edge the cursor settles after a scroll fires. - // Independent from the trigger buffer above: the cursor begins scrolling once it enters the - // buffer band, but settles `scrollMargin` px inside the edge so it doesn't sit flush against - // the toolbar/keyboard. ~1 line-height feels right at default fontSize. - const scrollMargin = store.getState().fontSize * 2 + // Independent from the trigger buffer in autoscrollEdges: the cursor begins scrolling once it + // crosses the edge, but settles `scrollMargin` px inside it so it doesn't sit flush against the + // toolbar/keyboard. + // + // Half the cursor height, not a fixed fontSize multiple. The per-keystroke autoscroll rhythm + // (each Enter at the bottom of the screen triggers a small scroll, like iOS native) holds iff + // the landing margin is smaller than one line step: land half a line inside the edge and the + // next Enter always crosses it again. A fixed fontSize-based margin can equal or exceed the + // line step, absorbing Enters and then scrolling too far. height/2 also scales the landing + // position with multi-line thoughts, matching the dynamic feel of iOS native autoscroll. + const scrollMargin = height / 2 // Target viewport y of the cursor. // From dd52ea23a62c25084dbc02b4be39ffe1ff135d44 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 02:45:24 +0100 Subject: [PATCH 06/25] docs(focusWithoutAutoscroll): explain why iOS rejects async selection and how asyncFocus primes it --- src/device/focusWithoutAutoscroll.ts | 30 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index ccb1459a3a7..2a4aeef94bb 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -3,23 +3,28 @@ import asyncFocus from './asyncFocus' import * as selection from './selection' /** - * Chokepoint for focusing an editable and placing the caret without triggering iOS native - * autoscroll. Suppresses the autoscroll iOS WebKit fires when focus or selection moves — the - * cause of `position: fixed` elements jumping in issue #3765. + * Focus an editable and place the caret without triggering iOS native autoscroll. * - * Called from `useEditMode` (mousedown + cursor-change useEffect) and `Note` (mousedown + + * This function is called from `useEditMode` (mousedown + cursor-change useEffect) and `Note` (mousedown + * note-focus useEffect). Mousedown calls it synchronously within the user gesture so iOS accepts * the focus and same-thought re-taps can reposition the caret. The useEffect call handles * programmatic cursor changes (Return, arrow keys, gestures, sidebar restore, toggleNote). * Idempotent — safe to call repeatedly with the same element + offset. * - * Strategy: (1) `focus({ preventScroll: true })` blocks the focus-driven autoscroll; (2) save and - * restore `window.scrollY` around `selection.set` to suppress the selection-driven autoscroll the + * 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. * * Keeping the cursor visible after the keyboard opens is handled separately by * `useScrollCursorIntoView` in `BulletCursorOverlay` — this function intentionally does NOT scroll. */ + +/** 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 @@ -29,8 +34,17 @@ const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: // Idempotency guard — already focused at this offset, nothing to do. if (wasFocused && currentOffset === offset) return - // iOS Safari rejects programmatic selection outside a user gesture unless an asyncFocus has - // primed it. + // 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. See asyncFocus.ts. if (isTouch && isSafari() && !selection.isThought()) { asyncFocus() } From 5eeb594ceb69f2d96c88b6abde0e668f4476f594 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 02:52:31 +0100 Subject: [PATCH 07/25] docs(focusWithoutAutoscroll): clarify selection-driven autoscroll suppression comment --- src/device/focusWithoutAutoscroll.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index 2a4aeef94bb..50c335db2c2 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -44,7 +44,7 @@ const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: // 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. See asyncFocus.ts. + // thought, an editing session already exists and no priming is needed. if (isTouch && isSafari() && !selection.isThought()) { asyncFocus() } @@ -53,11 +53,17 @@ const focusWithoutAutoscroll = (el: HTMLElement | null | undefined, { offset }: el.focus({ preventScroll: true }) } - // Suppress selection-driven autoscroll: setting a Range inside a contentEditable makes the - // browser scroll the caret into view, independently of focus. Capture and revert any scrollY - // shift synchronously. `useScrollCursorIntoView` handles bringing the cursor into view once - // layout settles — we must not scroll here, otherwise the two sources interrupt each other on - // rapid cursor changes (spam-Enter) and the page jumps to a mid-screen position. + // 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) { From 2f7a91b8a5a14be0755a8fa9d2e352f690de850a Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 02:55:06 +0100 Subject: [PATCH 08/25] docs(autoscrollEdges): clarify trigger-edge model and platform notes --- src/device/autoscrollEdges.ts | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index 7fa21725323..7010a90d7e3 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -2,36 +2,43 @@ import store from '../stores/app' import viewportStore from '../stores/viewport' import virtualKeyboardStore from '../stores/virtualKeyboardStore' -/** Computed top/bottom edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. The "comfort zone" is between topEdge and bottomEdge. A thought entering within the buffer band of either edge triggers scrolling. */ +/** The trigger edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. + * Between topEdge and bottomEdge is the comfort zone: a cursor inside it never triggers a scroll; a cursor + * crossing either edge does. Each edge may be inset from the visible area by a buffer so that scrolling + * starts before the cursor reaches the toolbar or keyboard. */ export interface AutoscrollEdges { - /** Top of the visible area below the toolbar, in viewport coords. */ + /** 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 - /** Effective keyboard inset in px. Accounts for both visualViewport shrink (web Safari) and `virtualKeyboardStore.height` (iOS Capacitor). 0 when keyboard is closed. */ + /** Height of the virtual keyboard in px. 0 when closed. */ keyboardInset: number - /** Px of buffer applied at the top edge (~2 line-heights). */ + /** Inset of topEdge below the toolbar (~2 line-heights). */ topBuffer: number - /** Px of buffer applied at the bottom edge. 0 while the keyboard is open — the visible strip is small and an inset trigger edge makes typing-at-the-bottom scrolls feel like they fire early and overshoot (#3765). */ + /** Inset of bottomEdge above the keyboard/navbar. 0 while the keyboard is open — see getAutoscrollEdges. */ bottomBuffer: number - /** Trigger threshold at the top, in viewport coords. */ + /** Crossing above this edge triggers a scroll. */ topEdge: number - /** Trigger threshold at the bottom, in viewport coords. */ + /** Crossing below this edge triggers a scroll. */ bottomEdge: number } -/** Reads the live DOM + store values needed to compute the autoscroll trigger zones. */ +/** 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 - // On web Safari `visualViewport.height` shrinks when the keyboard opens, so - // `innerHeight - visualViewport.height` is the keyboard inset. On iOS Capacitor the WKWebView - // keeps `visualViewport.height` at the full innerHeight (the keyboard overlays the page), so we - // also consult `virtualKeyboardStore.height` which is set by the @capacitor/keyboard handler. - // Taking the max means whichever source thinks the keyboard is taller wins. + // The keyboard height is reported differently per platform. On web Safari the keyboard shrinks + // `visualViewport.height`, so `innerHeight - visualViewport.height` is the keyboard height. On + // iOS Capacitor the keyboard overlays the page and `visualViewport.height` stays at the full + // innerHeight, so the height comes from `virtualKeyboardStore` instead (fed by + // @capacitor/keyboard). Take the max so whichever source sees the keyboard wins. + // + // The open flag likewise has two sources: `virtualKeyboardStore.open` (native keyboard events) + // and `state.isKeyboardOpen` (set by the app when it intends to open the keyboard, e.g. + // setCursor) — trust either, since one may lead the other during the open animation. const visualViewportHeight = window.visualViewport?.height ?? window.innerHeight const { height: keyboardAnimatedHeight, open: keyboardOpenStore } = virtualKeyboardStore.getState() const keyboardOpen = keyboardOpenStore || store.getState().isKeyboardOpen === true @@ -40,11 +47,11 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { const fontSize = store.getState().fontSize const topBuffer = fontSize * 2 - // No bottom buffer while the keyboard is open: with the visible strip already small, insetting - // the trigger edge here (on top of scrollCursorIntoView's landing margin) makes typing at the - // bottom scroll before the cursor reaches the keyboard and overshoot when it does — which breaks - // the scroll-per-Enter rhythm that iOS native autoscroll (and main) has. The keyboard inset - // itself already keeps the cursor above the keyboard. + // No bottom buffer while the keyboard is open. The keyboard inset alone keeps the cursor above + // the keyboard, and any extra inset here (stacked on scrollCursorIntoView's landing margin) + // makes typing at the bottom of the screen scroll early and overshoot — breaking the + // one-scroll-per-Enter rhythm of iOS native autoscroll (#3765). With the keyboard closed there + // is room to spare, so keep a comfortable buffer. const bottomBuffer = keyboardOpen ? 0 : fontSize * 2 const topEdge = toolbarBottom + topBuffer @@ -61,5 +68,5 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { } } -/** LayoutTreeTop fallback for scrollCursorIntoView when the cursor element's offsetParent can't be read from the DOM. */ +/** 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 From 8368c2c34d488dd4260947244da8947febcbc712 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 03:17:27 +0100 Subject: [PATCH 09/25] fix(autoscroll): remove top trigger buffer so a top scroll reveals one thought MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fontSize*2 inset on the top edge fired while the cursor was still fully visible and, stacked with the landing margin, scrolled it ~2.5 lines below the toolbar — noticeably more than the bottom edge's one-line-per-Enter rhythm. Trigger at the toolbar itself and let the height/2 landing margin provide the headroom, matching main's reveal-one-thought behavior at the top. --- src/device/autoscrollEdges.ts | 38 ++++++++++++++++------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index 7010a90d7e3..ee150d7bce2 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -2,10 +2,10 @@ import store from '../stores/app' import viewportStore from '../stores/viewport' import virtualKeyboardStore from '../stores/virtualKeyboardStore' -/** The trigger edges (in viewport coordinates) that scrollCursorIntoView uses to decide whether to scroll. +/** The trigger 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 may be inset from the visible area by a buffer so that scrolling - * starts before the cursor reaches the toolbar or keyboard. */ + * crossing either edge does. The bottom edge may be inset from the visible area by a buffer so that + * scrolling starts before the cursor reaches the navbar. */ export interface AutoscrollEdges { /** Bottom of the toolbar — the top of the visible area, in viewport coords. */ toolbarBottom: number @@ -13,8 +13,6 @@ export interface AutoscrollEdges { navbarHeight: number /** Height of the virtual keyboard in px. 0 when closed. */ keyboardInset: number - /** Inset of topEdge below the toolbar (~2 line-heights). */ - topBuffer: number /** Inset of bottomEdge above the keyboard/navbar. 0 while the keyboard is open — see getAutoscrollEdges. */ bottomBuffer: number /** Crossing above this edge triggers a scroll. */ @@ -30,22 +28,16 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0 const navbarHeight = navbarRect?.height ?? 0 - // The keyboard height is reported differently per platform. On web Safari the keyboard shrinks - // `visualViewport.height`, so `innerHeight - visualViewport.height` is the keyboard height. On - // iOS Capacitor the keyboard overlays the page and `visualViewport.height` stays at the full - // innerHeight, so the height comes from `virtualKeyboardStore` instead (fed by - // @capacitor/keyboard). Take the max so whichever source sees the keyboard wins. - // - // The open flag likewise has two sources: `virtualKeyboardStore.open` (native keyboard events) - // and `state.isKeyboardOpen` (set by the app when it intends to open the keyboard, e.g. - // setCursor) — trust either, since one may lead the other during the open animation. - const visualViewportHeight = window.visualViewport?.height ?? window.innerHeight - const { height: keyboardAnimatedHeight, open: keyboardOpenStore } = virtualKeyboardStore.getState() - const keyboardOpen = keyboardOpenStore || store.getState().isKeyboardOpen === true - const keyboardInset = keyboardOpen ? Math.max(window.innerHeight - visualViewportHeight, keyboardAnimatedHeight) : 0 + // virtualKeyboardStore is the single source of truth for the keyboard. It is fed by + // platform-specific handlers (see src/device/virtual-keyboard/): native keyboard events on iOS + // Capacitor, visualViewport measurements on iOS Safari. On platforms with no handler (e.g. + // desktop) the store stays closed with height 0, so no inset is applied — unlike + // `state.isKeyboardOpen`, which really means "is editing" and is set on desktop too. + // `open` stays true through the closing animation while `height` springs to 0, so the edges + // keep accounting for the keyboard until it has fully closed. + const { height: keyboardInset, open: keyboardOpen } = virtualKeyboardStore.getState() const fontSize = store.getState().fontSize - const topBuffer = fontSize * 2 // No bottom buffer while the keyboard is open. The keyboard inset alone keeps the cursor above // the keyboard, and any extra inset here (stacked on scrollCursorIntoView's landing margin) @@ -54,14 +46,18 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { // is room to spare, so keep a comfortable buffer. const bottomBuffer = keyboardOpen ? 0 : fontSize * 2 - const topEdge = toolbarBottom + topBuffer + // No top buffer at all: trigger only once the cursor actually passes under the toolbar, and let + // scrollCursorIntoView's landing margin (half the cursor height) provide the headroom. An inset + // trigger edge here scrolls while the cursor is still fully visible and lands it ~2.5 lines deep — + // each top scroll should instead reveal about one thought, mirroring the bottom edge's + // one-line-per-Enter rhythm. + const topEdge = toolbarBottom const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - bottomBuffer return { toolbarBottom, navbarHeight, keyboardInset, - topBuffer, bottomBuffer, topEdge, bottomEdge, From 008ce64106023b54fd927007dd1f856359688324 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 13:38:11 +0100 Subject: [PATCH 10/25] style(focusWithoutAutoscroll): remove trailing whitespace flagged by prettier --- src/device/focusWithoutAutoscroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index 50c335db2c2..0e7281be419 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -12,7 +12,7 @@ import * as selection from './selection' * Idempotent — safe to call repeatedly with the same element + offset. * * 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. From d70856f97440b0f477339c01dbb04bea4a6cc634 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 14:08:18 +0100 Subject: [PATCH 11/25] fix(autoscroll): compute smooth-scroll threshold from virtualKeyboardStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit visualViewport.height never shrinks in the native app (Capacitor Keyboard resize: 'none'), so the smooth-vs-instant threshold was a full screenful there instead of the visible area above the keyboard. Read the keyboard height from virtualKeyboardStore instead — the same source autoscrollEdges uses — so the whole autoscroll flow shares one source of keyboard truth. Also clarify comments for fresh readers (name the TreeNodePositioner transition and autocrop explicitly) and rename locals to match the trigger-edge model: scrollMargin -> landingMargin, isAboveViewport/isBelowViewport -> isAboveTopEdge/isBelowBottomEdge. --- src/device/scrollCursorIntoView.ts | 75 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index cdfa3077569..fdcf2cc16b1 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -1,37 +1,34 @@ import { isSafari, isTouch } from '../browser' 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 cursor. */ +/** + * 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) => { const { topEdge, bottomEdge } = getAutoscrollEdges() - // Landing margin — how far inside the trigger edge the cursor settles after a scroll fires. - // Independent from the trigger buffer in autoscrollEdges: the cursor begins scrolling once it - // crosses the edge, but settles `scrollMargin` px inside it so it doesn't sit flush against the - // toolbar/keyboard. - // - // Half the cursor height, not a fixed fontSize multiple. The per-keystroke autoscroll rhythm - // (each Enter at the bottom of the screen triggers a small scroll, like iOS native) holds iff - // the landing margin is smaller than one line step: land half a line inside the edge and the - // next Enter always crosses it again. A fixed fontSize-based margin can equal or exceed the - // line step, absorbing Enters and then scrolling too far. height/2 also scales the landing - // position with multi-line thoughts, matching the dynamic feel of iOS native autoscroll. - const scrollMargin = height / 2 + // 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 - // Target viewport y of the cursor. - // - // Sources we *don't* use, and why: - // - `cursorEl.getBoundingClientRect().top` — the cursor-overlay-tree-node has a CSS transition - // on `top`, so the rect returns the mid-flight animating value, not the target. Reading it - // would make the trigger see "where the cursor used to be" right after every cursor change. - // - `viewportStore.layoutTreeTop + y − scrollY` — `layoutTreeTop` is set in a useEffect and is - // one frame behind autocrop changes, so this disagrees with the live layout-tree position - // during cursor moves that change autocrop. + // 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). // - // What we *do* use: the cursor element's offsetParent (the inner layout-tree div with the - // translateX transform) has its position updated synchronously when autocrop changes — no CSS - // transition, no stale store value. Its live `rect.top` plus the `y` arg (the post-render target - // the cursor's inline `top` style is being transitioned toward) gives the target viewport y. + // 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 @@ -42,26 +39,30 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => { /** The y position of the cursor relative to the document. */ const yDocument = yViewport + window.scrollY - const isAboveViewport = yViewport < topEdge - const isBelowViewport = yViewport + height > bottomEdge + const isAboveTopEdge = yViewport < topEdge + const isBelowBottomEdge = yViewport + height > bottomEdge - if (!isAboveViewport && !isBelowViewport) return + if (!isAboveTopEdge && !isBelowBottomEdge) 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. - // Land the cursor `scrollMargin` px inside the trigger edge so it sits in the comfort zone, not flush with the edge. - const scrollYNew = isAboveViewport - ? yDocument - topEdge - scrollMargin - : yDocument + height - bottomEdge + scrollMargin + // 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. + // Scroll just far enough that the cursor lands `landingMargin` px inside the edge it crossed. + const scrollYNew = isAboveTopEdge + ? yDocument - topEdge - landingMargin + : yDocument + height - bottomEdge + landingMargin // 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 visualViewportHeight = window.visualViewport?.height ?? window.innerHeight + // 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().height const scrollDistance = Math.abs(scrollYNew - window.scrollY) - const behavior: ScrollBehavior = scrollDistance < visualViewportHeight ? 'smooth' : 'auto' + const behavior: ScrollBehavior = scrollDistance < visibleHeight ? 'smooth' : 'auto' window.scrollTo({ top, From 7268c774b994468f3a718124a83ed507c0e7906b Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 14:08:24 +0100 Subject: [PATCH 12/25] docs(asyncFocus): align priming comment with focusWithoutAutoscroll terminology --- src/device/asyncFocus.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/device/asyncFocus.ts b/src/device/asyncFocus.ts index c3e69dd1c92..af15beb204a 100644 --- a/src/device/asyncFocus.ts +++ b/src/device/asyncFocus.ts @@ -38,7 +38,8 @@ export const AsyncFocus: () => () => void = () => { // 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 iOS's selection-allowed state). + // 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 From 2a94efb0f1a2a2dbf0af2f1273987e3dab76d640 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Thu, 11 Jun 2026 14:10:12 +0100 Subject: [PATCH 13/25] docs(autoscroll): simplify comments and unify trigger-buffer/landing-margin terminology Trim insider phrasing ("chokepoint") and over-detailed rationale, and name the two distances consistently across files: the trigger buffer decides when a scroll starts (autoscrollEdges); the landing margin decides where the cursor settles (scrollCursorIntoView). --- src/components/Editable/useEditMode.ts | 3 +-- src/components/Note.tsx | 6 ++---- src/device/autoscrollEdges.ts | 26 ++++++++++---------------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 8b07453ab15..87bb1eb94a7 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -75,8 +75,7 @@ const useEditMode = ({ return } - // Chokepoint for all programmatic cursor changes — Return-new-thought, arrow keys (incl. - // bluetooth on iPad), gestures, sidebar restore. focusWithoutAutoscroll focuses, places the + // 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 }) diff --git a/src/components/Note.tsx b/src/components/Note.tsx index ca6e07fa066..eda3a6d9746 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -61,8 +61,6 @@ const Note = React.memo( }, [dispatch, path]) // Set the caret on the note when noteFocus opens it programmatically (toggleNote command). - // Routes through the focusWithoutAutoscroll chokepoint so the iOS native selection autoscroll - // doesn't jitter the toolbar (#3765). useEffect(() => { if (hasFocus && noteOffset !== null) { focusWithoutAutoscroll(noteRef.current, { offset: noteOffset }) @@ -156,8 +154,8 @@ const Note = React.memo( if (!note) return // Block native focus + native caret-from-tap, then route through focusWithoutAutoscroll - // (same chokepoint Editable uses) — focuses with preventScroll, places the caret at the - // tap offset, and suppresses the selection-driven autoscroll on iOS. + // (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 { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) if (offset !== null) { e.preventDefault() diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index ee150d7bce2..e2cefa586db 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -4,8 +4,9 @@ import virtualKeyboardStore from '../stores/virtualKeyboardStore' /** The trigger 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. The bottom edge may be inset from the visible area by a buffer so that - * scrolling starts before the cursor reaches the navbar. */ + * crossing either edge does. The bottom edge may be inset from the visible area by a trigger buffer so + * that scrolling starts before the cursor reaches the navbar. The trigger buffer decides when a scroll + * starts; where the cursor settles afterwards is the landing margin in scrollCursorIntoView. */ export interface AutoscrollEdges { /** Bottom of the toolbar — the top of the visible area, in viewport coords. */ toolbarBottom: number @@ -13,7 +14,7 @@ export interface AutoscrollEdges { navbarHeight: number /** Height of the virtual keyboard in px. 0 when closed. */ keyboardInset: number - /** Inset of bottomEdge above the keyboard/navbar. 0 while the keyboard is open — see getAutoscrollEdges. */ + /** 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 @@ -28,25 +29,18 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0 const navbarHeight = navbarRect?.height ?? 0 - // virtualKeyboardStore is the single source of truth for the keyboard. It is fed by - // platform-specific handlers (see src/device/virtual-keyboard/): native keyboard events on iOS - // Capacitor, visualViewport measurements on iOS Safari. On platforms with no handler (e.g. - // desktop) the store stays closed with height 0, so no inset is applied — unlike - // `state.isKeyboardOpen`, which really means "is editing" and is set on desktop too. - // `open` stays true through the closing animation while `height` springs to 0, so the edges - // keep accounting for the keyboard until it has fully closed. + // Get the virtual keyboard's height and open state from the canonical store. + // On platforms that don't have a virtual keyboard, like desktop, the store stays closed + // with height 0, so no inset gets applied. const { height: keyboardInset, open: keyboardOpen } = virtualKeyboardStore.getState() const fontSize = store.getState().fontSize - // No bottom buffer while the keyboard is open. The keyboard inset alone keeps the cursor above - // the keyboard, and any extra inset here (stacked on scrollCursorIntoView's landing margin) - // makes typing at the bottom of the screen scroll early and overshoot — breaking the - // one-scroll-per-Enter rhythm of iOS native autoscroll (#3765). With the keyboard closed there - // is room to spare, so keep a comfortable buffer. + // 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 - // No top buffer at all: trigger only once the cursor actually passes under the toolbar, and let + // No top trigger buffer at all: trigger only once the cursor actually passes under the toolbar, and let // scrollCursorIntoView's landing margin (half the cursor height) provide the headroom. An inset // trigger edge here scrolls while the cursor is still fully visible and lands it ~2.5 lines deep — // each top scroll should instead reveal about one thought, mirroring the bottom edge's From 2916ab18008475fdfee9408bd255fbfba1365fd3 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Fri, 19 Jun 2026 04:45:14 +0100 Subject: [PATCH 14/25] docs: improve comments --- src/@types/VirtualKeyboardState.ts | 4 +- src/components/Editable/useEditMode.ts | 20 +++------- src/device/autoscrollEdges.ts | 10 +++-- src/device/focusWithoutAutoscroll.ts | 10 +---- src/device/scrollCursorIntoView.ts | 2 +- .../handlers/iOSCapacitorHandler.ts | 6 ++- .../handlers/iOSSafariHandler.ts | 38 +++++++++++++++++-- src/hooks/useScrollCursorIntoView.ts | 30 ++++++++++++++- src/stores/storageModel.ts | 10 +++++ src/stores/virtualKeyboardStore.ts | 1 + 10 files changed, 96 insertions(+), 35 deletions(-) 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/useEditMode.ts b/src/components/Editable/useEditMode.ts index 87bb1eb94a7..3d4331fc0ff 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -120,26 +120,16 @@ const useEditMode = ({ // If editing or the cursor is on the thought, perform manual caret positioning so the offset is correct. // See: #981 if (editingOrOnCursor && !isMulticursor) { - // All cursor changes (taps, Return, arrow keys, gestures) converge on - // `focusWithoutAutoscroll`. Mousedown calls it inline here, synchronously within the - // user gesture, so: - // - iOS Safari accepts the focus (no asyncFocus dance needed for the tap path). - // - Same-thought re-taps reposition the caret (cursorOffset is not a useEffect dep, - // so we cannot rely on the cursor-change useEffect for offset-only changes). - // The cursor-change useEffect also calls focusWithoutAutoscroll for programmatic paths; - // the function is idempotent so the second call is a no-op. const { inVoidArea, offset } = getCaretOffset(editable, { clientX: e.clientX, clientY: e.clientY, }) // Block the native mousedown so it can't focus the element and place the caret itself. - // - iOS Safari: always block, so the native focus/selection autoscroll never fires and - // jolts position:fixed elements (#3765). focusWithoutAutoscroll re-takes focus cleanly. - // - Other platforms: only block when the tap lands in a void area (outside any text node), - // otherwise leave the native mousedown alone so browser selection — drag-select, - // double-click-word, right-click context menu — still works. Preventing it - // unconditionally would break those. See: #981, #2948 + // 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() } @@ -150,7 +140,7 @@ const useEditMode = ({ const targetOffset = offset ?? 0 // Dispatch setCursor first so Editable.onFocus's setCursorOnThought sees - // state.cursor === path and early-returns instead of clobbering cursorOffset to 0. + // state.cursor === path and early-returns instead of setting cursorOffset to 0. dispatch( setCursor({ path, diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index e2cefa586db..78bddca1fab 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -29,10 +29,12 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0 const navbarHeight = navbarRect?.height ?? 0 - // Get the virtual keyboard's height and open state from the canonical store. - // On platforms that don't have a virtual keyboard, like desktop, the store stays closed - // with height 0, so no inset gets applied. - const { height: keyboardInset, open: keyboardOpen } = virtualKeyboardStore.getState() + // 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 diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index 0e7281be419..550ea61c63f 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -5,12 +5,6 @@ import * as selection from './selection' /** * Focus an editable and place the caret without triggering iOS native autoscroll. * - * This function is called from `useEditMode` (mousedown + cursor-change useEffect) and `Note` (mousedown + - * note-focus useEffect). Mousedown calls it synchronously within the user gesture so iOS accepts - * the focus and same-thought re-taps can reposition the caret. The useEffect call handles - * programmatic cursor changes (Return, arrow keys, gestures, sidebar restore, toggleNote). - * Idempotent — safe to call repeatedly with the same element + offset. - * * We do this using two strategies: * * (1) `focus({ preventScroll: true })` blocks the focus-driven autoscroll. This differs from prior strategies, @@ -20,8 +14,8 @@ import * as selection from './selection' * (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. * - * Keeping the cursor visible after the keyboard opens is handled separately by - * `useScrollCursorIntoView` in `BulletCursorOverlay` — this function intentionally does NOT scroll. + * This function just handles *disabling* autoscroll. Autoscroll is handled by `useScrollCursorIntoView` + * in `BulletCursorOverlay`, on all platforms. */ /** Focus an editable and place the caret without triggering iOS native autoscroll. */ diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index fdcf2cc16b1..d75b967b557 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -60,7 +60,7 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => { // 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().height + const visibleHeight = window.innerHeight - virtualKeyboardStore.getState().targetHeight const scrollDistance = Math.abs(scrollYNew - window.scrollY) const behavior: ScrollBehavior = scrollDistance < visibleHeight ? 'smooth' : 'auto' diff --git a/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts b/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts index a6d07544b00..8b5dc0848a6 100644 --- a/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts @@ -25,7 +25,9 @@ const iOSCapacitorHandler: VirtualKeyboardHandler = { // 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 +47,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..6ad26c0619b 100644 --- a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts @@ -17,6 +17,12 @@ 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. @@ -39,11 +45,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 viewportStore's estimate (via rawHeight) 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 +87,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/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/storageModel.ts b/src/stores/storageModel.ts index 137d9636bc2..4cf65bdeaf3 100644 --- a/src/stores/storageModel.ts +++ b/src/stores/storageModel.ts @@ -24,6 +24,16 @@ const storageModel = storage.model({ recentCommands: { default: [] as CommandId[], }, + /** The settled height of the iOS Safari virtual keyboard in landscape orientation, learned when the keyboard closes. Lets iOSSafariHandler predict the keyboard's final height at open detection — before it can be measured — across page reloads. */ + virtualKeyboardHeightLandscape: { + default: 0, + decode: (s: string | null) => (s ? +s : undefined), + }, + /** The settled height of the iOS Safari virtual keyboard in portrait orientation. See virtualKeyboardHeightLandscape. */ + virtualKeyboardHeightPortrait: { + default: 0, + decode: (s: string | null) => (s ? +s : undefined), + }, }) export default storageModel 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 From 4e28334dd19f9000e3952797744cdfe4e4939136 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Mon, 22 Jun 2026 23:10:08 +0100 Subject: [PATCH 15/25] refactor(autoscroll): retire viewport virtualKeyboardHeight; iOSSafariHandler owns keyboard-height estimate Move per-orientation keyboard-height estimation out of viewport.ts and into iOSSafariHandler, its only consumer. viewport.ts no longer tracks keyboard height at all. Also removes two dead code paths: - storageModel virtualKeyboardHeight{Landscape,Portrait} fields (never read/written) - iOSCapacitorHandler virtualKeyboardHeight write (orphaned after preventAutoscroll was removed earlier on this branch) Behavior is unchanged: the same measurement (innerHeight - visualViewport.height), per-orientation cache, and geometric fallback now live in the handler. --- .../handlers/iOSCapacitorHandler.ts | 2 - .../handlers/iOSSafariHandler.ts | 31 ++++++++++++--- src/stores/storageModel.ts | 10 ----- src/stores/viewport.ts | 38 +------------------ 4 files changed, 28 insertions(+), 53 deletions(-) diff --git a/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts b/src/device/virtual-keyboard/handlers/iOSCapacitorHandler.ts index 8b5dc0848a6..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,7 +23,6 @@ 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 }) // 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 }) diff --git a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts index 6ad26c0619b..312f35ec6ca 100644 --- a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts @@ -3,13 +3,34 @@ 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 + +/** 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 = window.innerHeight > window.innerWidth + 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 @@ -28,9 +49,9 @@ 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 @@ -57,7 +78,7 @@ const updateIOSSafariKeyboardState = () => { // 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 viewportStore's estimate (via rawHeight) on the first open. + // from measureKeyboardHeight's per-orientation estimate on the first open. if (isOpening) { predictedFinalHeight = Math.max(targetHeight, predictedFinalHeight ?? targetHeight) virtualKeyboardStore.update({ open: true, targetHeight: predictedFinalHeight }) diff --git a/src/stores/storageModel.ts b/src/stores/storageModel.ts index 4cf65bdeaf3..137d9636bc2 100644 --- a/src/stores/storageModel.ts +++ b/src/stores/storageModel.ts @@ -24,16 +24,6 @@ const storageModel = storage.model({ recentCommands: { default: [] as CommandId[], }, - /** The settled height of the iOS Safari virtual keyboard in landscape orientation, learned when the keyboard closes. Lets iOSSafariHandler predict the keyboard's final height at open detection — before it can be measured — across page reloads. */ - virtualKeyboardHeightLandscape: { - default: 0, - decode: (s: string | null) => (s ? +s : undefined), - }, - /** The settled height of the iOS Safari virtual keyboard in portrait orientation. See virtualKeyboardHeightLandscape. */ - virtualKeyboardHeightPortrait: { - default: 0, - decode: (s: string | null) => (s ? +s : undefined), - }, }) export default storageModel 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 From 16e779816138babb242cf43ecdd6653affb1c69e Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Mon, 22 Jun 2026 23:19:17 +0100 Subject: [PATCH 16/25] docs(focusWithoutAutoscroll): clarify that scrollCursorIntoView solely owns autoscroll --- src/device/focusWithoutAutoscroll.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/device/focusWithoutAutoscroll.ts b/src/device/focusWithoutAutoscroll.ts index 550ea61c63f..9a5bd88ed9d 100644 --- a/src/device/focusWithoutAutoscroll.ts +++ b/src/device/focusWithoutAutoscroll.ts @@ -14,8 +14,7 @@ import * as selection from './selection' * (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. * - * This function just handles *disabling* autoscroll. Autoscroll is handled by `useScrollCursorIntoView` - * in `BulletCursorOverlay`, on all platforms. + * Autoscroll is handled solely in the `scrollCursorIntoView` module. */ /** Focus an editable and place the caret without triggering iOS native autoscroll. */ From ad3925b170a81795921dfde6dfec0a11456ab228 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Tue, 23 Jun 2026 00:00:59 +0100 Subject: [PATCH 17/25] feat(autoscroll): add symmetric top trigger buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit topEdge was flush with the toolbar, so the cursor had to pass fully under the toolbar before an upward scroll fired — which made cursorUp/arrow-up navigation feel sticky. Add a fontSize*2 top buffer mirroring bottomBuffer so the scroll fires ~one thought early, the same rhythm as the bottom edge. Experimental (#3765): topBuffer is a single named constant; set it to 0 to restore the previous flush-to-toolbar behavior if the upward scroll over-reveals. Exposed on the AutoscrollEdges interface alongside bottomBuffer. --- src/device/autoscrollEdges.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index 78bddca1fab..fc24ca15b7f 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -4,9 +4,10 @@ import virtualKeyboardStore from '../stores/virtualKeyboardStore' /** The trigger 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. The bottom edge may be inset from the visible area by a trigger buffer so - * that scrolling starts before the cursor reaches the navbar. The trigger buffer decides when a scroll - * starts; where the cursor settles afterwards is the landing margin in scrollCursorIntoView. */ + * crossing either edge does. Each edge is inset from its occluder (toolbar / keyboard+navbar) by a + * trigger buffer so that scrolling starts before the cursor reaches the occluder. The trigger buffer + * decides when a scroll starts; where the cursor settles afterwards is the landing margin in + * scrollCursorIntoView. */ export interface AutoscrollEdges { /** Bottom of the toolbar — the top of the visible area, in viewport coords. */ toolbarBottom: number @@ -14,6 +15,8 @@ export interface AutoscrollEdges { 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. */ @@ -42,18 +45,23 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { // leaving just enough room for one line of text above it. const bottomBuffer = keyboardOpen ? 0 : fontSize * 2 - // No top trigger buffer at all: trigger only once the cursor actually passes under the toolbar, and let - // scrollCursorIntoView's landing margin (half the cursor height) provide the headroom. An inset - // trigger edge here scrolls while the cursor is still fully visible and lands it ~2.5 lines deep — - // each top scroll should instead reveal about one thought, mirroring the bottom edge's - // one-line-per-Enter rhythm. - const topEdge = toolbarBottom + // 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. + // NOTE (#3765): newly symmetric with the bottom edge; tune fontSize * 2 or set to 0 (flush to the + // toolbar, headroom from the landing margin alone) if the upward scroll over-reveals. + const topBuffer = fontSize * 2 + + const topEdge = toolbarBottom + topBuffer const bottomEdge = window.innerHeight - keyboardInset - navbarHeight - bottomBuffer return { toolbarBottom, navbarHeight, keyboardInset, + topBuffer, bottomBuffer, topEdge, bottomEdge, From 5ff5a01df504d8fff69bcbcd508d74ee9755e323 Mon Sep 17 00:00:00 2001 From: faiz Date: Tue, 23 Jun 2026 04:45:43 +0100 Subject: [PATCH 18/25] fix empty note falling back to iOS native autoscroll Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/components/Note.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Note.tsx b/src/components/Note.tsx index eda3a6d9746..abfecc2bb1c 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -157,10 +157,8 @@ const Note = React.memo( // (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 { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) - if (offset !== null) { - e.preventDefault() - focusWithoutAutoscroll(note, { offset }) - } + e.preventDefault() + focusWithoutAutoscroll(note, { offset: offset ?? 0 }) }, []) const onCopy = useCallback((e: React.ClipboardEvent) => { From aeb9dd546574d651532aa335201940d8081288f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:57:59 +0000 Subject: [PATCH 19/25] Use ScreenOrientation API for iOS keyboard prediction cache Co-authored-by: fbmcipher <16063438+fbmcipher@users.noreply.github.com> --- src/device/virtual-keyboard/handlers/iOSSafariHandler.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts index 312f35ec6ca..ef5bc19c062 100644 --- a/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts +++ b/src/device/virtual-keyboard/handlers/iOSSafariHandler.ts @@ -17,12 +17,18 @@ let controls: AnimationPlaybackControls | null = null 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 = window.innerHeight > window.innerWidth + const isPortrait = getIsPortrait() const measured = window.visualViewport ? window.innerHeight - window.visualViewport.height : 0 if (measured > 0) { if (isPortrait) kbHeightPortrait = measured From 72ca5dbfadbe05ca2d225ee591c5c7999caa0681 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:00:21 +0000 Subject: [PATCH 20/25] Fix TypeScript errors in drag-and-drop multiselect test Co-authored-by: fbmcipher <16063438+fbmcipher@users.noreply.github.com> --- src/e2e/puppeteer/__tests__/drag-and-drop-multiselect.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/drag-and-drop-multiselect.ts b/src/e2e/puppeteer/__tests__/drag-and-drop-multiselect.ts index 92558cdbb0d..2bc5d55cc3a 100644 --- a/src/e2e/puppeteer/__tests__/drag-and-drop-multiselect.ts +++ b/src/e2e/puppeteer/__tests__/drag-and-drop-multiselect.ts @@ -5,7 +5,7 @@ import getEditingText from '../helpers/getEditingText' import hideHUD from '../helpers/hideHUD' import multiselectThoughts from '../helpers/multiselectThoughts' import paste from '../helpers/paste' -import { page } from '../setup' +import { page } from '../session' vi.setConfig({ testTimeout: 60000, hookTimeout: 20000 }) @@ -80,7 +80,9 @@ describe('drag and drop multiple thoughts', () => { expect(highlightedBullets.length).toBe(0) // 2. the "Drag and drop to move thought" hint alert is dismissed - const alertContent = await page.$eval('[data-testid=alert-content]', el => el.textContent).catch(() => null) + const alertContent = await page + .$eval('[data-testid=alert-content]', (el: Element) => el.textContent) + .catch(() => null) expect(alertContent).not.toContain('Drag and drop to move thought') // 3. the cursor is placed on the drop target From a3d655ccd45632b3da55a0bd7140741e3c00a234 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Tue, 30 Jun 2026 03:45:18 +0100 Subject: [PATCH 21/25] fix(Note): preserve desktop double-click word selection Note.onMouseDown called e.preventDefault() unconditionally, which blocked native double-click word selection on desktop (the copy-from-note paste flow broke). Gate preventDefault to iOS Safari / void-area taps, mirroring Editable.onMouseDown, so desktop native selection is preserved while the iOS autoscroll suppression still routes through focusWithoutAutoscroll. --- src/components/Note.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/Note.tsx b/src/components/Note.tsx index abfecc2bb1c..2ba0191d4df 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -11,7 +11,7 @@ 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 { isSafari, isTouch } from '../browser' import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll' import getCaretOffset from '../device/getCaretOffset' import * as selection from '../device/selection' @@ -153,11 +153,18 @@ const Note = React.memo( const note = noteRef.current if (!note) return - // Block native focus + native caret-from-tap, then 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 { offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY }) - e.preventDefault() + // 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 }) }, []) From 563d7a7b1518554aa0a9cfffc22b14cf335b2773 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Sat, 4 Jul 2026 18:53:09 +0100 Subject: [PATCH 22/25] fix(autoscroll): skip top-edge correction when nothing is above to reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top trigger buffer (fontSize * 2) fires as soon as the cursor enters the buffer band below the toolbar, even when the document is already scrolled to the top and there's nothing above to reveal. scrollYNew then computes an unreachable negative target, and Math.max(1, scrollYNew) manufactured a 1px scroll out of nothing — shifting the entire page and breaking every puppeteer snapshot with a near-top cursor at font sizes where the buffer reaches the cursor (22, 28). Confirmed via bisect: window.scrollY flipped from 0 to 1 at exactly this trigger, isolated by diffing raw screenshots between the commit that introduced the buffer and its parent. --- src/device/scrollCursorIntoView.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index d75b967b557..9e6451daf82 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -51,6 +51,14 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => { ? yDocument - topEdge - landingMargin : yDocument + height - bottomEdge + landingMargin + // The top trigger buffer fires as soon as the cursor enters the buffer band below the toolbar, + // even when the document is already scrolled all the way up and there is nothing above to + // reveal. In that case scrollYNew computes to an unreachable negative position — scrolling + // "further up" than 0 is impossible — and flooring it to 1 below would manufacture a 1px scroll + // out of nothing, shifting the whole page for no visible benefit. Skip the correction rather + // than let the floor turn a no-op into a scroll. + if (isAboveTopEdge && scrollYNew <= 0 && window.scrollY === 0) return + // scroll to 1 instead of 0 // otherwise Mobile Safari scrolls to the top after MultiGesture // See: touchmove in MultiGesture.tsx From 8fda58d7f484489139629bac90e296d301f22100 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Sat, 4 Jul 2026 20:33:33 +0100 Subject: [PATCH 23/25] refactor(autoscroll): generalize reachability guard; clarify edge/buffer/margin roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (behavior-neutral): replace the scrollY===0 special case with a general reachability clamp. The scroll target is clamped to the range the page can actually reach (0..maxScroll); if that lands on the current position there is nowhere to go, so we bail before the Safari floor-to-1 turns a no-op into a phantom 1px scroll. This covers the bottom edge's latent short-document case for the same price, and expresses reachability in the logic rather than as a corner-case guard bolted on afterward. Clarity: rename isAboveTopEdge/isBelowBottomEdge -> crossedTopEdge/ crossedBottomEdge (they mean "crossed the trigger line", not "is occluded"), and document the deliberate coupling: because the landing is measured from the edge, the trigger buffer sets both when a scroll fires and how deep the cursor parks (buffer + landingMargin inside the visible area). No behavior change — all five scroll-sensitive snapshot tests still pass. --- src/device/autoscrollEdges.ts | 22 ++++++++++------ src/device/scrollCursorIntoView.ts | 41 ++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/device/autoscrollEdges.ts b/src/device/autoscrollEdges.ts index fc24ca15b7f..df85019d40b 100644 --- a/src/device/autoscrollEdges.ts +++ b/src/device/autoscrollEdges.ts @@ -2,12 +2,17 @@ import store from '../stores/app' import viewportStore from '../stores/viewport' import virtualKeyboardStore from '../stores/virtualKeyboardStore' -/** The trigger 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 that scrolling starts before the cursor reaches the occluder. The trigger buffer - * decides when a scroll starts; where the cursor settles afterwards is the landing margin in - * scrollCursorIntoView. */ +/** 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 @@ -50,8 +55,9 @@ export const getAutoscrollEdges = (): AutoscrollEdges => { // 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. - // NOTE (#3765): newly symmetric with the bottom edge; tune fontSize * 2 or set to 0 (flush to the - // toolbar, headroom from the landing margin alone) if the upward scroll over-reveals. + // 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 diff --git a/src/device/scrollCursorIntoView.ts b/src/device/scrollCursorIntoView.ts index 9e6451daf82..e065fb156c4 100644 --- a/src/device/scrollCursorIntoView.ts +++ b/src/device/scrollCursorIntoView.ts @@ -39,37 +39,50 @@ const scrollIntoViewIfNeeded = (y: number, height: number) => { /** The y position of the cursor relative to the document. */ const yDocument = yViewport + window.scrollY - const isAboveTopEdge = yViewport < topEdge - const isBelowBottomEdge = yViewport + height > bottomEdge + // 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 (!isAboveTopEdge && !isBelowBottomEdge) return + if (!crossedTopEdge && !crossedBottomEdge) return // 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. - // Scroll just far enough that the cursor lands `landingMargin` px inside the edge it crossed. - const scrollYNew = isAboveTopEdge + // + // 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 - // The top trigger buffer fires as soon as the cursor enters the buffer band below the toolbar, - // even when the document is already scrolled all the way up and there is nothing above to - // reveal. In that case scrollYNew computes to an unreachable negative position — scrolling - // "further up" than 0 is impossible — and flooring it to 1 below would manufacture a 1px scroll - // out of nothing, shifting the whole page for no visible benefit. Skip the correction rather - // than let the floor turn a no-op into a scroll. - if (isAboveTopEdge && scrollYNew <= 0 && window.scrollY === 0) return + // 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) // 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(scrollYNew - window.scrollY) + const scrollDistance = Math.abs(target - window.scrollY) const behavior: ScrollBehavior = scrollDistance < visibleHeight ? 'smooth' : 'auto' window.scrollTo({ From 31ca58d5ca788dd4e3bf4684b7937bdcc2986295 Mon Sep 17 00:00:00 2001 From: Faiz Mustafa Date: Sat, 4 Jul 2026 21:45:23 +0100 Subject: [PATCH 24/25] test(autoscroll): regenerate colored-text snapshot to the phantom-free scroll position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The color-theme "colored and highlighted text" baseline was baked on main at window.scrollY=1. Instrumenting main's scrollCursorIntoView shows that 1 is not an intended scroll target: with the colored test's color-picker-expanded toolbar (toolbarBottom≈138) the cursor is technically occluded, so main computes a corrective scrollYNew of -65 — an unreachable target (you can't scroll above the document top) — and Math.max(1, -65) floors it to 1. On that tall page (763px) the 1px artifact sticks; on the shorter superscript/subthought pages the same Math.max floor evaluates to 1 but never takes, so those baselines are at 0. The branch's scrollCursorIntoView correctly resolves the unreachable scroll to "best reachable target is 0, already there, no-op" and stays at 0 in every context (verified deterministic, alone and combined). Regenerating this one baseline aligns it with the correct phantom-free behavior; all other snapshots already matched. --- ...r-theme-colored-and-highlighted-text-1.png | Bin 7991 -> 7990 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 6f4e85f1d860843c5349542716d4452c2a45a845..88c3e9e9b402b6e919965f13a33877b1440d91bd 100644 GIT binary patch literal 7990 zcmeHL>0i?6-u{`DHI_EhX)!BmGCe($nJXD8HZ_^KP}8KDTjic2 zPg&WFxg;u~BCQ%JVw$KRkjdi0qM#tM9Xii{@Vt6n^n3HWKlh9K{w~*beXq}b^H^xW z>hCsv2LQn8z$1r#1pv!!0I=lLisj}VLeB3O%s-3FUju#y>UuX%0>IXpz(WUq%OlBn z$kLc>v}TqS?1+cs*8C8>!s6$9*BY!BjvP2Pd;Hj;n&Q9F{_vAc=btN^*c=YCrUfSY zD?4@Y@5l1NzyI;wKliL#mw?~$L-Cp69~$fNa5s0u_`QVEdp)bhi2kwl*z4Y!GL?>r z7PY<*WcDgZ#SSzJ0QhHLYqgU(B0X!nKLA{ITe8%=x8vLLTO9^ks-0dA43riW1e5md z8t=Tz94>gCV$Uz`?ahuLCx(R8p8j)Z(E4ghbrKA=7Pw3c4#M$$eOFfaojx7uj$YjE zJ@GQd{2^Z(*w3Pg)J(~fOd03E7Son`eN+KTU8bcKf(Ug?`^vd|=t3tb-q1Nnoe;#R z9u<2213M3aGOM~JB9IGGwbc#;oI#AQT4Bll__1h&T=>4!wJMjY7%nIvUC^&AgJLmT z^JlJD6uRcjJ(5D?m%BI%3~qJY(d(4Eqt0EMI19QY-u08Hw0v`vcRjbQIuewcY6Hzz zU;Ywzo6RonDZO~aF_biSzjrlox!N|#f8)J_Hf2Qx1+E7dN6N^F;!gXp>YCJn^zwoC z>VnNs)w9ieQ*y4awy${n6X^ZwTvI?;ZS*IEz;%a)p(-@i25*FT+U{=k9AarhTTxyJ zw4wB1EY5SfI5BRh)yFg;`0)GVT*-!9v^$R%ZMn4?z3`Nj^%fCty6H~NdsnX{bLp*b zkdzji?o1mL(x&{a8kArvh&+jG4@=1|=&UhlbU5B0MOh5TEfK3glB z-1t29a8vf9=Eu&_JbUtk)P((D=V@|N@*xz;1|n@3%^oUmgNM35skjFQr1 zb|ZW+M{PdYmV`=p0`uNexTmt@Y%Sbd@-J)=)z-Kht7#5TkS&TJaFoc~ z6rs5qvD8LF|KdI=mw!;8rG3v%k+%i9={^rAB?x0ZDZ3w~f|+I^k(%;G#RIMJZY4uf zpBH}VQfsEZhpg-XM-jl~vl^{1m;WN0zBY;GCZNx{Q|Acu?rYCa3{Ww@^gvP-pov~&!cd3#VRqSd!w&d_#DQMdc&VILq(eDlNbA5a-#}i#3F4X@L%002Cl=s6n8qJqF zbshU*rlrU`W6_#ydRtbuKPAkjd~q~*GuSyOtXDxc8LSZ>I_t90Ej-=(?Elv9rJvRO(X|K`!evHT~(&xmQPb2HzDapaEh9!dI?)Jgy!~|E`~zX z?ZK59QZ|6p=L0~+N;9@Td9|CMcs9GGiIP1hs9-ly=3}4nxm@DMt1qsy>wFOERAoze z{{Hb!Y02PpvG~<56iKfZ+G|{!q~_*!x89P~a{How^lFE{Wy6VEH|(<9k`#~XitCbM z+jy&qEz5@f=we@$Ci|7!6S`|S*Jb&B007HfaxMBa?NUf*p*=T=E0(X0pq)K%*ru{M zI}(9o(uj>2yf=n|kLLa+bT49Ow%!UHxBalJ-f({F>5L@5g@2uByf8wGjMWL8+G_$JkfdcP(!Kv!4ndxkJRJm@0F{4jm z1-u-%>278Pr`ho4EPCkHX!GYrGslURYVSQeIGyo>i_6OBa|EA8r5K~j&8Z7Qz*v{N ze9U6fk^ON&T}M>a`ZO&R%*|y!H5gVRUQ^2^OT%*{0Gc9e2}&X^809pmd83q@97jH9jqvJHXM zR`+w;WVtcq^pv_6@^PeyfxVa8uFTZR=PQF#ryEFUB}mx!my@_#fUWcdnORX`X2p7! znER6hwxPP2F?0)JwB0LneN3^pwF`HWMQLgh7A-5BL-z6O^ouG1{f(?}58VaYA^MVU zyp=0zFNwKd*ntXF4nO|Qgqe>`RCcg>Bb*Eva$;3xAWqooEeOTwnOf$Lv)T6HlFSmi zJCr&f1Sx;>aGl0BkAyJI)emqu#o-mo8%V8c&k+Oyfyo#FBMh9=z?7L?X(VB*ozc94$ z{HoPsRc^@Fcg(fzeVw3DdvhChtnCWv{wk2~`>AG`8QW!NrbmFI0+k$Aht9eIix-^B z@MW`cPFT9)|Fq<&=a!zSX*1@S0ZSYCULhE82Y?*Q zr2n&X zp4^2VNWvRB9XHUwy?|+OY}wQy~ZKt$;I5OIM&ObsL-%`XS*15hI0+8}fCr1OHD%!^4$0 zsQyxhWCugnyGj4Nq%OrT$~K<NeDc?rC+?a$Il%)a2&^YVzPq4Q)aHYKuK^X3f|0 zf-q&Hs$?bKUDp+WTLmiCPu4p@X-?EJV~0gQ53d)>g}pcM#l^x>OT2`YW<(`$i(jLuy}p8&b>~RvzWC3I@dEL3z-PybHP_P*MY<*AyXE}W&*tRci~Tr4 zqD^f1;#%Kcd-D#MH1}2)Y*!hjlQ5pD;oRb9fqbJ2V{{plbC>DMsK)d?LoNvI@#w>f zg;TaJFusx+|LTMIsut(T03(jD;Ztn_!fHkvefSV-E|A%R)t^;uH>{QHj63YX^~?Ch zovsZcYRFK)kcV$g&TS#Q7hwvoay5mp6OSDCLHDpk?KZ;{&+6(F~ojR_jlr*+%#4 z|1{wd!J*KHBWipb-n!#{zECN2i6O%UAXW%J|GZL{BhVe@~~lc zxm)K5E6{I|Cv1#dT>kzs*b{7iX=mIJkFJi zeKL%i2`7m#YZ4pu58lElvZ z9MVpiXi(vc`*hNy^;GK10XAW>2Gm59z-I^>{x+HipjeiNIcIjB6dr7<>h*I;W%$epy= z4)P*DM?-?8we_SL;-)=wvb23Kjx&eqn1)zxV{cplh0DW8d+#!bPEO-c8|Hb4yvU4x zvX0-9v#^%;I_I)=Tm>YK8{H7I4^q~QRdxxI*+1vYow;8U8&sU4B8WG{ZP+#DLf+cY z65i-iGlaln%?W20&q&&8s1^V$Xvwt)BMrNd@yJHjQ*lo~fBs8;-=97W@e^I>5m#n; z#U9!&q#<~b1nrpni-{{lHEysOePm$LP?T2=zf*k~HwbZ8b-4S8t&3ZZ#&WCjt29XnmMq( zii=mp-XAp{QytK%Z{V~+1a2=z(<08Oism&>Gd4lov@d@ma;;4x6vAiwp7xfgMD--d z_u{bZ{TZz0%@cSK(q&nwMQ$qzdEIT09q&-r+YAX92MGIa|isS;L;U8Cy&(6&3N5 z9PzL91w~9zL@mVoA|)kLwdQ3DTgpdyc>?)t2Ol~&Yks=94xCjyuL7<(n=O~mj#HLP0O0=j+W^4d zuH=l_l_iiYzIwA2YXIQicm8YBzAeAi;Ts;lk>Q&td{e}4li}M~{ALgSA9y1@nAN^% Wo9lrJx%u@B2>d1VP~FdGe*X`^Q`a*9 delta 4924 zcmXAtYgm%`*2d}RZa3wgw>_3)W;3GBq}e1TDhf4D8RLQW+b%OrDm-L}q^O7jk4*<> z#!`dQJi)G}+NFkxN{B*cfs|-@00n_c&`c2p6A^)zuJ_yX>ABXr*S&uCy8f+j4!mqP zFo*nQ|G)EUbV|~##2kugt}n`)j-!>W+4OT^*`YJTYsi0X|L@|z5C2kE{)n;9Xnom!@z!>jb4|gUnf=fMA?IqT)n}N@I~)8 zvMJ{2Uds3~m4N2@&zNeAum@g9JO~8(`?}^C-8#pIoxr~R&EHo7Q3@3UghF9LLITqI zHCNrBff$(ZJ}xgl1SU(>$y?$&@^i}lDSJA?!U_tiK%i;N3EKRO4mmsjwqIW53fU;> zbc#krM!w!O!^u^)^)L~L+9~OVPmB6IZ7N}!-Pf(BrXOo-P0#N)THm<|m>a64n&&`) zgbtS;Ue*G5JGiv|cp1>XC@DxLg9<6vAltt>9uW~S-WwK*5nfg4_!wA7)^esO_5Jh9 zq<0rAPNBl#w{%G6=!MdjIXVX|8vF|H#*HZ1+dSvoy>zY4Bwqx9EZ<`HZ7D04N<$}t z2~Pj?h;M0aO&aeBhd7#ikevYng;Ii6Juln}}17M zMBma-{Yimeb(?fzO@uYYWhc)UAuzg4&dhu}8>H)s(7xyb!knV2Y9nH80f-JA;0voI zVy7BIO^p?)(&kP53I)AjTU#Kz^tus4{tzGwzT0iAyuzG$W!foC*DtB&Uok*pzRL@OE>>J^QlEVy>nm&VL*Hdi^Dk%s#N zc6j75|L&TLF})7$o9-15GTKakMpgbq*f$o6eSX{gWwKV02kpRX{i{n)1`M<@b@_E2 z=E38stoZWDsy7n zc$_3ukp3HH43QX1HQi-5W$EsK#iQXd5@v^4kTw2?UV^ZS@OTCB(~CH>Y6G)(!XJnQ z^7GpWqtg&0ADTde*4l{X7aa9doM6*gS$L?u8LgjkR0rfiD^u zMa;0>>s^#(?{S_48&*&=GJu+f=#6NLc54tR1HOE(LOWg{po4=ayJgyC8P#i|_H*U3 zQU*{ej4MESZJV}FOFzB^?k?fn2H?RF?u(y#$kK-jm;Z^LxpnicZWB9y>`qTSIZnWI z4bDmmfjJRl>ub}}(j?CKrG#=8^?dw4!QyrcP@}E$=&@Pc`%~?EYa@-9^@(K+#tc|| zjfgJC&MY2`2{C*&PrRo3R0k5hk%1YB6~%lbBO5nz!^>Ci2FwOo^iaglYW~hk)V?HCss^)40o3r5VMgVDpI1UOu)F26?cR661ZfwiQ<| z>T?3UtK8?9+}(o<&ShWY+$%$7W~s#8XYp?Iw6W4jkL?{Du8@?TZR^TZDn)uSt7-!`+x1G3Ou7|m6R6WxKILV>KJ>1Sr8~^ zgc8L?>_Zo-}#1kbg%M#Jjnr2sJ?C7_Fw$@U}e- z`9eaCCt5u*;ar&?bwBDBS7+gQER$NZ@x2ctn1BLbf20KNGX-rBQ;LDzA5E)3?2;5wc!O$~N-+!u)*p#AOjbnCRcWqb{T(sdvARmXo;fB-@lel8YQXhU=0W`7*#B z!Jk#GZ@Txumldong;4BV%1mJkWR-jB5!q70GF{L+6=ON9T5c*Gw?N4un?&xnIPJ^y zNuAOLV>w7)fwnwpH~gjUgC&|r$P;a-zglvR_l8WGAXxc2bH7QIDsckMUiH~G+z{b$ zlI93Zq-FL!r4=u4@;ADmfhtn*E~U1Q)7-3if56#9dZU_ImakU{26UzMPoao5^BwYx zfv6p=NW3KgOzoN{kq>RwM}qDffAsZM`q&cLDR&8JG>x33{*|`;nI!ps?z1Cc*%ciS z1YcXstLdWMG;PSM!CN(~e!09nma*(aG8_9)cs!nZehdMmYTNh~c@(Wh-#7kv1L-9z zp=_X}Akr{=FV3Fz(#-uJFLhqDegW3GN#l#4yp@~0V|w03xYvrpHYV*W--*7i!1HoS z`yNsj|Le#Dh%zj`DbE~6=?$Fj`73h(k~^;nZBM5`WXab zs8;{&VgSitt2EXGyQ*3(b*A#rwmqg%3b8ePPDTNNzFm1~KNjzCy=x=P@5ud_wB9T8 z3->u3=+2!D4tk1(*Q&EjN@DVeBg6_Gfp$9%n$>>`m#X03ySuwP5EUiA!nwz0LuzYl zM;fV={I!`PUl7PP@sI5w&^}k^?^n}TxFG)jYDyJUbHZmihxcH`$buu$p|me)^<+H(jaN7n6}DiUTrhi(blsL@t2GmB0A(L#y>7>ma;1Ze_pf}v}Lv9 zf7%adFcCp(H(j;OT?cm+c>xBj7&Q~~Sir&;W}CnOg73>6yZzTaui&NsXx6Z+?W&vC zPKs}Gyw+EE4fur4X-{@^h*A2O3`(QfcIzUY?mShn%v2B5G*p`FHM~-H!mFp6t9R+^ zFjwZw(+Sed=)zIU$9Lm7Myq&NMWEf<1*5ytt0PCKxu}5C*#S*q-0ATop)GpQ`k_M7 z2h1$m^ch#(HhcTR=oMSv0;*UED&!zo9=QqyXiXD~zB6N!(f1uniK|MCJlm2a4z(vZu@%D-&+np4hu$-ZxtMSrM@`r0; z>RlqWzUUYjZ%%JId(_xTo|J3c0GR`rCM8}c)F{zs@1zPfDGG$`Kjt#q9D%;uvkK?I zZLd^6PnKAIIvr)0eWk&uBl!!xnlf=X8ABUwd1}sowh_B;Z2$|eDodcCjB>l+d-ITI zHOO3TkJ`w%HJS%Boue4}%mCg<>T#^0g}8xyHGbP!stMl?3qAuZEtr)8KXYv{&D7q! zuIvK86jtr#z})tITdEdXlV+MVl3j;N;6amHZu*^gyWC>A!vy=voOMr)n;k(9cIU+b zPcUS#*n`>-2Qy5ne~qaeNauYhPwD7p0>0)VLN2uiVLi4f8cj*f(#$6vi8b&6Ec~$k zWB6t$patwN+~@-v@vYXy$C~9`-X2^(vr(3=HbofeP$q3zH7T*okD0|V|8p)wc5z*< zecPz@5adBaV6GPtUPE3I(5`n`gd_XLKi`&l(_vLi!L&WYUd99?YATk6MmndaDzvRN zZrYZ&%5|)M!W*#HYWk}!YrDOmfl%i>U})|l;NgMb5D+aE(?-zn#JYc6NpV;T3V4ww zuPgN9p#|pxGNTOdUTM@Ux8>cuHu}&Mo$E9Y=w2ykXuouSHL?ogHx}Wgw+)RO?qFTO z6O8sSI8Bo*+Ot1QlMtS91o!F`O+|QH$L0ZusAGgp&feL?tm-%7^9{s|`g!=fnOKZOQcA89t zLP77+kcB_(89V4D0=5~RMu?mDRA9pNZ*H&Yo;@uo>OJbVB*-n9rpf>4Pg?bcvp-L*2Iy>g*^oh|7qs=3Q=hcBP_Yd>av%!f_nCVK~}M#G+juJ z4%(TQZOPooXbyw7x)TPUF)@bsY&z0iQdK@R6Uooc0Sq6wVsx$2H2w0_L<@w?Xqn=j z*^qKImieZpWzpOrkypEI4kQLxItGdY)k?_*P~HgxK)jMN+*phD-do?IFVb{|WZTF8 z_tEF->MbLmD5(n_x|tCVHMLT9MI^;ePaAG$-mPMtj9(J8=G2OMGdR4l;o9mdR-Mep z2N8)72;yAz#_P7IIM0n6k55#)_8WqWA?^LfVj&y`{iHSq)lP z#|RX@@k7f-Q*zDn@ey}rn8fum+CRjBgaDQoGzb)|TldqUoUru=ZD^>FDLP^jk@UCeEoDVdjt zeZQeYL9f%t_yhd!%tgL*tln)YQ&bWK&yOK2yl-G)Gu|(0nO{b07Tyg7&)?PJZgEUzs?jCS2i3FH8rjLg#0#<%FL zS%iuE4F@?s6CFWZUFY~k1J2odeMKOr*4o=30kj8sJgkhqH#f;}AvTb+**1U=?1bJT z;W~Pzk08h&8WokDwwa>n=#nZ1d8{p|caTqfA>XWu0G(nH*13dpQo;#biHxOWi^7(l zX%u=S+=!N(yz#h7U~QSZOP4Xq+O?M{%!iqsuHx=i4H{^pJX!jyWIWsw>=QNz9LEEh z#?v-qtQUGI&iGS1n<=Zo4ZkJr%3$OeDm}bnRjPEix6;uJ({GT4k1YG87{rcXTn77% zr8pLfan4TM75u&V`H;l*#*_ Date: Mon, 6 Jul 2026 01:38:26 +0200 Subject: [PATCH 25/25] fix(Editable): preserve cursor offset when refocusing the same thought MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setCursorOnThought (Editable.onFocus) unconditionally nulled the cursor offset. While editing, setCursor coerces a null offset to 0, so any programmatic refocus of the thought that already holds the cursor reset the caret to the start. This surfaced after merging siblings: the merge places the caret mid-thought, then focusWithoutAutoscroll's focus() fires onFocus -> setCursorOnThought, which nulled the offset -> coerced to 0 -> the caret effect yanked the caret back to 0. The deleteThought "after merging siblings, caret should be in between" unit test has been red on this branch since the focusWithoutAutoscroll adoption (real browsers happened to avoid it via keyboard-open timing, but jsdom does not). Fix: only default the offset to null (let the browser choose the position) when focusing a DIFFERENT thought — the genuine click/tap case this handler targets. When refocusing the thought that already holds the cursor, preserve the existing offset so a deliberately-placed caret survives. Verified: deleteThought passes, and caret/caret-multiline (incl. within-thought line navigation, which needs setCursorOnThought to keep running) stay green. --- src/components/Editable.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 24a2b41cadb..9a7f819669c 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -217,8 +217,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) {