Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
15cfdb3
feat(autoscroll): add focusWithoutAutoscroll v2 behind an A/B debug t…
fbmcipher May 9, 2026
ff391c4
fix(autoscroll): unify v2 focus entry point and fix empty-thought tap
fbmcipher May 18, 2026
63f0768
refactor(autoscroll): adopt focusWithoutAutoscroll, remove v1 and deb…
fbmcipher Jun 11, 2026
206885e
fix(useEditMode): restore native browser selection on non-iOS platforms
fbmcipher Jun 11, 2026
f86f52a
fix(autoscroll): restore per-keystroke scroll rhythm at the bottom edge
fbmcipher Jun 11, 2026
dd52ea2
docs(focusWithoutAutoscroll): explain why iOS rejects async selection…
fbmcipher Jun 11, 2026
5eeb594
docs(focusWithoutAutoscroll): clarify selection-driven autoscroll sup…
fbmcipher Jun 11, 2026
2f7a91b
docs(autoscrollEdges): clarify trigger-edge model and platform notes
fbmcipher Jun 11, 2026
8368c2c
fix(autoscroll): remove top trigger buffer so a top scroll reveals on…
fbmcipher Jun 11, 2026
008ce64
style(focusWithoutAutoscroll): remove trailing whitespace flagged by …
fbmcipher Jun 11, 2026
d70856f
fix(autoscroll): compute smooth-scroll threshold from virtualKeyboard…
fbmcipher Jun 11, 2026
7268c77
docs(asyncFocus): align priming comment with focusWithoutAutoscroll t…
fbmcipher Jun 11, 2026
2a94efb
docs(autoscroll): simplify comments and unify trigger-buffer/landing-…
fbmcipher Jun 11, 2026
2916ab1
docs: improve comments
fbmcipher Jun 19, 2026
4e28334
refactor(autoscroll): retire viewport virtualKeyboardHeight; iOSSafar…
fbmcipher Jun 22, 2026
16e7798
docs(focusWithoutAutoscroll): clarify that scrollCursorIntoView solel…
fbmcipher Jun 22, 2026
ad3925b
feat(autoscroll): add symmetric top trigger buffer
fbmcipher Jun 22, 2026
bff79a4
Merge remote-tracking branch 'origin/main' into issue-3765
fbmcipher Jun 22, 2026
5ff5a01
fix empty note falling back to iOS native autoscroll
fbmcipher Jun 23, 2026
aeb9dd5
Use ScreenOrientation API for iOS keyboard prediction cache
Copilot Jun 23, 2026
72ca5db
Fix TypeScript errors in drag-and-drop multiselect test
Copilot Jun 23, 2026
6b1d070
Merge branch 'main' into issue-3765
fbmcipher Jun 30, 2026
a3d655c
fix(Note): preserve desktop double-click word selection
fbmcipher Jun 30, 2026
3ebf1cd
Merge remote-tracking branch 'origin/main' into issue-3765
fbmcipher Jun 30, 2026
a5eade5
Merge remote-tracking branch 'origin/main' into issue-3765
fbmcipher Jul 4, 2026
563d7a7
fix(autoscroll): skip top-edge correction when nothing is above to re…
fbmcipher Jul 4, 2026
8fda58d
refactor(autoscroll): generalize reachability guard; clarify edge/buf…
fbmcipher Jul 4, 2026
31ca58d
test(autoscroll): regenerate colored-text snapshot to the phantom-fre…
fbmcipher Jul 4, 2026
ff406e5
fix(Editable): preserve cursor offset when refocusing the same thought
fbmcipher Jul 5, 2026
66d9b37
Merge remote-tracking branch 'origin/main' into issue-3765
fbmcipher Jul 5, 2026
aa0dc01
Merge remote-tracking branch 'origin/main' into issue-3765
fbmcipher Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/@types/VirtualKeyboardState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 3 additions & 1 deletion src/components/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,9 @@ const Editable = ({
})
}

if (suppressFocusStore.getState()) return
if (suppressFocusStore.getState()) {
return
}
// Update editingValueUntrimmedStore with the current value
editingValueUntrimmedStore.update(value)

Expand Down
147 changes: 43 additions & 104 deletions src/components/Editable/useEditMode.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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 { 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'
Expand Down Expand Up @@ -40,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<number | null>(null)

// focus on the ContentEditable element if editing or on desktop
const editMode = !isTouch || editing
Expand All @@ -55,18 +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()

/** Set the selection to the current Editable at the cursor offset. */
const setSelectionToCursorOffset = () => {
// do not set the selection on hidden thoughts, otherwise it will cause a faulty focus event when switching windows
// https://github.com/cybersemics/em/issues/1596
if (style?.visibility === 'hidden') {
selection.clear()
} else {
selection.set(contentRef.current, { offset: cursorOffset ?? 0 })
}
}
const { cursorOffset } = store.getState()

// allow transient editable to have focus on render
const shouldSetSelection =
Expand All @@ -80,28 +66,19 @@ const useEditMode = ({
!dragHold &&
!disabledRef.current)

if (shouldSetSelection) {
preventAutoscroll(contentRef.current)

/*
When a new thought is created, the Shift key should be on when Auto-Capitalization is enabled.
On Mobile Safari, Auto-Capitalization is broken if the selection is set synchronously (#999).
Only breaks on Enter or Backspace, not gesture.

setTimeout fixes it, however it introduces an infinite loop when a nested empty thought is created.
Not calling asyncFocus when the selection is already on a thought prevents the infinite loop.
Also, setTimeout is frequently pushed into the next frame and the keyboard will intermittently close on iOS Safari.
Replacing setTimeout with requestAnimationFrame guarantees (hopefully?) that it will be processed before the next repaint,
keeping the keyboard open while rapidly deleting thoughts. (#3129)

If 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()
}
if (!shouldSetSelection) return

setSelectionToCursorOffset()
// do not set the selection on hidden thoughts, otherwise it will cause a faulty focus event when switching windows
// https://github.com/cybersemics/em/issues/1596
if (style?.visibility === 'hidden') {
selection.clear()
return
}

// Whenever the cursor changes programmatically, focusWithoutAutoscroll focuses, places the
// caret, and suppresses the native focus + selection autoscroll that would otherwise jolt
// `position: fixed` elements on iOS (#3765).
focusWithoutAutoscroll(contentRef.current, { offset: cursorOffset ?? 0 })
},
// React Hook useEffect has missing dependencies: 'contentRef', 'editMode', and 'style?.visibility'.
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -122,33 +99,15 @@ 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
})
}, [])
Comment on lines -125 to -134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What changed that this is no longer required?


// 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 CMD/CTRL is pressed, don't focus the editable.
Expand All @@ -158,38 +117,41 @@ 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) {
// 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()
// Block the native mousedown so it can't focus the element and place the caret itself.
// There are two reasons we want to do this:
// 1. iOS Safari: always block; let focusWithoutAutoscroll re-takes focus cleanly.
// 2. Block when the tap lands in a void area (outside any text node) to stop the caret from being
// placed on the wrong thought.
if ((isTouch && isSafari()) || inVoidArea) {
e.preventDefault()
}

// getCaretOffset returns null for empty thoughts (no text nodes). We still need to take
// focus on those — otherwise tapping an empty thought that is already the cursor leaves
// it unfocused and the keyboard never opens.
const targetOffset = offset ?? 0

// Dispatch setCursor first so Editable.onFocus's setCursorOnThought sees
// state.cursor === path and early-returns instead of setting cursorOffset to 0.
dispatch(
setCursor({
path,
offset: targetOffset,
isKeyboardOpen: true,
cursorHistoryClear: true,
preserveMulticursor: true,
}),
)

focusWithoutAutoscroll(editable, { offset: targetOffset })
} else {
// There are areas on the outside edge of the thought that will fail to trigger onTouchEnd.
// In those cases, it is best to prevent onFocus or onClick, otherwise keyboard is open will be incorrectly activated.
Expand All @@ -199,34 +161,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
Expand Down
21 changes: 15 additions & 6 deletions src/components/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +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 preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll'
import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll'
import getCaretOffset from '../device/getCaretOffset'
import * as selection from '../device/selection'
import useFreshCallback from '../hooks/useFreshCallback'
import getThoughtById from '../selectors/getThoughtById'
Expand Down Expand Up @@ -48,7 +49,6 @@ const Note = React.memo(

/** Focus Handling with useFreshCallback. */
const onFocus = useFreshCallback(() => {
preventAutoscrollEnd(noteRef.current)
dispatch(
setCursor({
path,
Expand All @@ -60,11 +60,10 @@ const Note = React.memo(
)
}, [dispatch, path])

// set the caret on the note if editing this thought and noteFocus is true
// Set the caret on the note when noteFocus opens it programmatically (toggleNote command).
useEffect(() => {
// cursor must be true if note is focused
if (hasFocus && noteOffset !== null) {
selection.set(noteRef.current!, { offset: noteOffset })
focusWithoutAutoscroll(noteRef.current, { offset: noteOffset })
}
}, [hasFocus, noteOffset])

Expand Down Expand Up @@ -150,7 +149,17 @@ 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

// 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()
focusWithoutAutoscroll(note, { offset: offset ?? 0 })
}, [])

const onCopy = useCallback((e: React.ClipboardEvent) => {
const html = selection.html()
Expand Down
4 changes: 0 additions & 4 deletions src/components/VirtualThought.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,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'
Expand Down
7 changes: 6 additions & 1 deletion src/device/asyncFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ 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 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
}
Expand Down
72 changes: 72 additions & 0 deletions src/device/autoscrollEdges.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is only used in scrollCursorIntoView. If so, it should probably not be separated out into a separate module. This separation is obscuring some of the complex DOM and component dependencies, including toolbar, navigation, keyboard and Layout. I would suggest putting this back into scrollCursorView and then thinking more deeply about encapsulation boundaries, dependencies, and the desired API.

device utilities are meant to be low level functions that abstract away some of the browser API details and can easily be stubbed in tests or other non-browser environments. scrollCursorIntoView already violates this encapsulation, so we'd like to try to find way to not make it worse.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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. */
export interface AutoscrollEdges {
/** Bottom of the toolbar — the top of the visible area, in viewport coords. */
toolbarBottom: number
/** Height of the navbar at the bottom of the visible area. */
navbarHeight: number
/** Height of the virtual keyboard in px. 0 when closed. */
keyboardInset: number
/** Trigger buffer that insets topEdge below the toolbar — see getAutoscrollEdges. */
topBuffer: number
/** Trigger buffer that insets bottomEdge above the keyboard/navbar. 0 while the keyboard is open — see getAutoscrollEdges. */
bottomBuffer: number
/** Crossing above this edge triggers a scroll. */
topEdge: number
/** Crossing below this edge triggers a scroll. */
bottomEdge: number
}

/** Reads the live DOM and store values needed to compute the autoscroll trigger edges. */
export const getAutoscrollEdges = (): AutoscrollEdges => {
const toolbarRect = document.getElementById('toolbar')?.getBoundingClientRect()
const navbarRect = document.querySelector('[aria-label="nav"]')?.getBoundingClientRect()
const toolbarBottom = toolbarRect ? toolbarRect.bottom : 0
const navbarHeight = navbarRect?.height ?? 0

// Use the keyboard's target height — where it will be once its animation settles — rather than
// the spring-animated `height`. The edges are end-state math: the scroll they trigger is itself
// animated, so what matters is where the cursor sits relative to the keyboard's final position,
// not its mid-slide position. On platforms without a virtual keyboard, like desktop, the store
// stays closed with targetHeight 0, so no inset gets applied.
const { targetHeight: keyboardInset, open: keyboardOpen } = virtualKeyboardStore.getState()

const fontSize = store.getState().fontSize

// No bottom trigger buffer while the keyboard is open: the edge sits right at the keyboard,
// leaving just enough room for one line of text above it.
const bottomBuffer = keyboardOpen ? 0 : fontSize * 2

// Top trigger buffer, mirroring bottomBuffer: inset topEdge ~one thought below the toolbar so a
// scroll fires before the cursor slips fully under it. Without this buffer the cursor has to pass
// entirely under the toolbar before anything happens, which makes scrolling upward (cursorUp /
// arrow-up) feel sticky. Constant regardless of keyboard state, since the toolbar — unlike the
// keyboard — does not move.
// 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,
}
}

/** 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
Loading
Loading