Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
}
23 changes: 15 additions & 8 deletions src/components/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
TUTORIAL_CONTEXT2_PARENT,
} from '../constants'
import asyncFocus from '../device/asyncFocus'
import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll'
import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll'
import * as selection from '../device/selection'
import globals from '../globals'
import findDescendant from '../selectors/findDescendant'
Expand Down Expand Up @@ -218,8 +218,13 @@ const Editable = ({
// do not set cursor if it is unchanged and we are not entering when keyboard is open
if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) return

// set offset to null to allow the browser to set the position of the selection
let offset = null
// When refocusing the thought that already holds the cursor — e.g. after merging siblings, or
// any programmatic focus from focusWithoutAutoscroll — preserve the existing cursorOffset.
// Nulling it here gets coerced to 0 by setCursor while editing, clobbering a caret that was
// intentionally placed (the merge put it mid-thought, then focus nulled it back to 0). Only
// default to null — letting the browser choose the position — when focusing a different
// thought, which is the genuine click/tap case this handler was written for. (#3765)
let offset: number | null = equalPath(state.cursor, path) ? state.cursorOffset : null

// if running for the first time, restore the offset if the path matches the restored cursor
if (!cursorOffsetInitialized) {
Expand Down Expand Up @@ -381,10 +386,10 @@ const Editable = ({

asyncFocus({ force: true })

preventAutoscroll(editable)
// Restore the selection offset captured when insertText(' ') arrived.
selection.set(editable, { offset: savedCharOffset })
preventAutoscrollEnd(editable)
// Restore the selection offset captured when insertText(' ') arrived, routing through
// focusWithoutAutoscroll so the caret is retargeted to the editable without triggering
// iOS native autoscroll (#3765).
focusWithoutAutoscroll(editable, { offset: savedCharOffset })

pendingAutocompleteAt = null
}
Expand Down Expand Up @@ -640,7 +645,9 @@ const Editable = ({
})
}

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

Expand Down
131 changes: 45 additions & 86 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,7 +39,6 @@ const useEditMode = ({
const disabledRef = useRef(false)
const editableNonce = useSelector(state => state.editableNonce)
const showSidebar = useSelector(state => state.showSidebar)
const fontSize = useSelector(state => state.fontSize)
const isCursor = useSelector(state => equalPath(path, state.cursor))
const hadSidebar = usePrevious(showSidebar)
const store = useStore()
Expand All @@ -55,18 +53,7 @@ const useEditMode = ({
() => {
// Get the cursorOffset directly from the store rather than subscribing to it reactively with useSelector.
// Otherwise, it will try to set the selection while typing.
const { cursorOffset, lastUndoableActionType } = store.getState()

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

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

if (shouldSetSelection) {
preventAutoscroll(contentRef.current)

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

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

If the last action is swapParent, set the selection synchronously to keep the focus stable after the swap.
*/
if (isTouch && isSafari() && lastUndoableActionType !== 'swapParent' && !selection.isThought()) {
asyncFocus()
Comment on lines -86 to -100

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.

Can you confirm that Auto-Capitalization and swapParent behavior are preserved?

}

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,28 +100,11 @@ const useEditMode = ({
],
)

// Provide an escape hatch to allow the next default selection rather than setting it.
// This allows the user to set the selection in the middle of a non-cursor thought when keyboard is open.
// Otherwise the caret is moved to the beginning of the thought.
const allowDefaultSelection = useCallback(() => {
disabledRef.current = true
// enable on next tick, which is long enough to skip the next setSelectionToCursorOffset
setTimeout(() => {
disabledRef.current = false
})
}, [])
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 }))
}

/** Marks the beginning of a touch so that onMouseDown can determine whether a long press is occurring. */
const onTouchStart = () => (pressingRef.current = true)

Expand All @@ -153,8 +114,7 @@ const useEditMode = ({
/**
* Handles the mousedown event for the editable element.
* Prevents focus on non-cursor thoughts or during multiselect clicks.
* When editing or cursor is present (and multicursor is not active), computes the caret position manually so that it can be set on mouseup.
* Prevents default behavior and manages autoscroll for certain edge cases where browser selection would be incorrect.
* When editing or cursor is present (and multicursor is not active), computes the caret position manually and routes through focusWithoutAutoscroll so the iOS native autoscroll never fires.
*/
const onMouseDown = (e: MouseEvent) => {
// If CMD/CTRL is pressed, don't focus the editable.
Expand All @@ -164,38 +124,45 @@ const useEditMode = ({
return
}

// If the press is ongoing (touchend has not been dispatched) then a long press is ongoing and setCaretOffset will interfere
// with default iOS Safari drag-and-drop text selection.
// If the press is ongoing (touchend has not been dispatched) then a long press is ongoing and manual caret
// positioning will interfere with default iOS Safari drag-and-drop text selection.
if (pressingRef.current) return

// If editing or the cursor is on the thought, allow the default browser selection or perform manual caret positioning so the offset is correct.
// If editing or the cursor is on the thought, perform manual caret positioning so the offset is correct.
// See: #981
if (editingOrOnCursor && !isMulticursor) {
const { inVoidArea, offset } = getCaretOffset(editable, {
clientX: e.clientX,
clientY: e.clientY,
})

if (offset !== null) {
// Prevent the browser from autoscrolling to this editable element.
// For some reason doesn't work on touchend.
preventAutoscroll(editable, {
// about the height of a single-line thought
bottomMargin: fontSize * 2,
})

// Setting the caret offset will activate the declarative shouldSetSelection effect, which will call preventAutoscroll and selection.set
// all over again. Since the selection is managed imperatively in this handler, this duplicate behavior is undesirable.
allowDefaultSelection()
setCaretOffset(offset)

// It's important to avoid preventDefault when the tap is somewhere that can be handled by native browser selection behavior.
// If the tap is prevented, it will interfere with functionality like double tap or the context menu. If the selection is
// truly in a void area, then preventDefault will stop the caret from being placed on the wrong thought.
if (inVoidArea) {
e.preventDefault()
}
// Block the native mousedown so it can't focus the element and place the caret itself.
// There are two reasons we want to do this:
// 1. iOS Safari: always block; let focusWithoutAutoscroll re-takes focus cleanly.
// 2. Block when the tap lands in a void area (outside any text node) to stop the caret from being
// placed on the wrong thought.
if ((isTouch && isSafari()) || inVoidArea) {
e.preventDefault()
}

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

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

focusWithoutAutoscroll(editable, { offset: targetOffset })
} else {
// There are areas on the outside edge of the thought that will fail to trigger onTouchEnd.
// In those cases, it is best to prevent onFocus or onClick, otherwise keyboard is open will be incorrectly activated.
Expand All @@ -205,28 +172,20 @@ const useEditMode = ({
}
}

/** Prevents the thought from autoscrolling to the bottom of the screen when the keyboard is open.
* Autoscroll must be prevented until focus handling is complete, so preventAutoscrollEnd is deferred
* using queueMicrotask without introducing any additional delay.
*/
const onFocus = () => queueMicrotask(() => preventAutoscrollEnd(editable))

editable.addEventListener('mousedown', onMouseDown)
if (isTouch && isSafari()) {
editable.addEventListener('touchstart', onTouchStart)
editable.addEventListener('touchend', onTouchEnd)
editable.addEventListener('focus', onFocus)
}

return () => {
editable.removeEventListener('mousedown', onMouseDown)
if (isTouch && isSafari()) {
editable.removeEventListener('touchstart', onTouchStart)
editable.removeEventListener('touchend', onTouchEnd)
editable.removeEventListener('focus', onFocus)
}
}
}, [contentRef, editingOrOnCursor, isCursor, isMulticursor, fontSize, allowDefaultSelection, path, dispatch])
}, [contentRef, editingOrOnCursor, isMulticursor, path, dispatch])

// Resume focus if sidebar was just closed and isEditing is true.
// Disable focus restoration on mobile until the hamburger menu & sidebar backdrop can be made to
Expand Down
30 changes: 23 additions & 7 deletions src/components/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import { setDescendantActionCreator as setDescendant } from '../actions/setDescendant'
import { setNoteFocusActionCreator as setNoteFocus } from '../actions/setNoteFocus'
import { toggleNoteActionCreator as toggleNote } from '../actions/toggleNote'
import { isTouch } from '../browser'
import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll'
import { isSafari, isTouch } from '../browser'
import focusWithoutAutoscroll from '../device/focusWithoutAutoscroll'
import getCaretOffset from '../device/getCaretOffset'
import * as selection from '../device/selection'
import useFreshCallback from '../hooks/useFreshCallback'
import getThoughtById from '../selectors/getThoughtById'
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,24 @@ const Note = React.memo(
[dispatch],
)

const onMouseDown = useCallback(() => preventAutoscroll(noteRef.current), [noteRef])
const onMouseDown = useCallback((e: React.MouseEvent) => {
const note = noteRef.current
if (!note) return

// Route through focusWithoutAutoscroll (the same single entry point Editable uses) — focuses
// with preventScroll, places the caret at the tap offset, and suppresses the selection-driven
// autoscroll on iOS.
const { inVoidArea, offset } = getCaretOffset(note, { clientX: e.clientX, clientY: e.clientY })

// Only block the native mousedown on iOS Safari (so focusWithoutAutoscroll can re-take focus
// cleanly) or when the tap lands in a void area. On desktop, leaving the default intact
// preserves native behavior — notably double-click word selection, which preventDefault
// would otherwise suppress.
if ((isTouch && isSafari()) || inVoidArea) {
e.preventDefault()
}
focusWithoutAutoscroll(note, { offset: offset ?? 0 })
}, [])

const onCopy = useCallback((e: React.ClipboardEvent) => {
const html = selection.html()
Expand Down
13 changes: 2 additions & 11 deletions src/components/VirtualThought.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import Path from '../@types/Path'
import SimplePath from '../@types/SimplePath'
import State from '../@types/State'
import ThoughtId from '../@types/ThoughtId'
import { getAutoscrollPadding } from '../device/preventAutoscroll'
import useDelayedAutofocus from '../hooks/useDelayedAutofocus'
import useLayoutAnimationFrameEffect from '../hooks/useLayoutAnimationFrameEffect'
import useSelectorEffect from '../hooks/useSelectorEffect'
Expand Down Expand Up @@ -145,20 +144,12 @@ const VirtualThought = ({
const updateSize = useCallback(() => {
if (!ref.current) return

// preventAutoscroll temporarily inflates the editable's padding to trick the browser out of autoscrolling.
// Subtract that padding so the measured height reflects the thought's true height even while the autoscroll
// window is open. Otherwise a height change that occurs during the window — e.g. a note added to this thought
// by Swap Note on touch devices — would be recorded with an inflated height or skipped entirely, leaving the
// next thought overlapping the note. (#4279)
const editable = ref.current.querySelector(`[data-editable]`)
const autoscrollPadding = getAutoscrollPadding(editable as HTMLElement | null)

// Need to grab max height between .thought and .thought-annotation since the annotation height might be bigger (due to wrapping link icon).
const heightNew = Math.max(
ref.current.getBoundingClientRect().height - autoscrollPadding,
ref.current.getBoundingClientRect().height,
ref.current.querySelector('[aria-label="thought-annotation"]')?.getBoundingClientRect().height || 0,
)
const widthNew = editable?.getBoundingClientRect().width
const widthNew = ref.current.querySelector(`[data-editable]`)?.getBoundingClientRect().width

// Get the updated autofocus, otherwise isVisible will be stale.
// Using the local autofocus and adding it as a dependency works when clicking on the cursor's parent but not when activating cursorBack from the keyboad for some reason.
Expand Down
Loading
Loading