Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/cursor-and-caret.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ Putting the caret back does not end the drag, and the caret that was just restor

Restoring the selection is enough on its own, which is not obvious: `selectionchange` fires before WebKit moves the focus, so putting the selection back preempts the focus change that would otherwise let `onFocus` steal the cursor. Making the non-cursor thoughts non-editable to stop the focus moving was tried and removed — it prevented nothing the restore did not already prevent, and it forced the mousedown handler to promote a thought to an editing host before placing the caret, because the browser silently drops a caret placed in a non-editable element.

[`useRevertCorrection`](../src/components/Editable/useRevertCorrection.ts), mounted by `useEditMode` alongside `useCaretRestore` with the same scope, corrects a tap that iOS undoes. A tap a few characters from the caret gets no mouse events at all, so `useEditMode`'s `onMouseDown` never places the caret. After the `touchend`, iOS moves the caret to the tapped word and then, within about 70ms, back to where it was before the tap ([#4220](https://github.com/cybersemics/em/issues/4220)). The hook's `touchend` listener on the editable records the touch point and the collapsed caret offset, which iOS has not yet moved, and a later `selectionchange` that returns the caret to that offset after it had moved elsewhere is taken as the revert. A drag or long press has already moved the caret by its `touchend`, and nothing moves it back, so it needs no separate check. The watch lasts until the next input, through capture-phase listeners on the document, rather than for a fixed time: a `touchstart`, a `mousedown` (a tap with mouse events, which `onMouseDown` places itself), or a `keydown` or `beforeinput` (typing, deleting, undo). The hook then computes the tapped offset from the recorded point with `getCaretOffset`, sets the selection there a frame later, once iOS has finished with the tap, and dispatches `setCursor` so that the cursor offset in Redux follows. It does nothing when the computed offset is the one the caret returned to, so a tap on the caret is left alone.

**Known limitation: a caret is still *seen* travelling away from the insertion point and back.** It stays on the cursor thought's row — it does not visit other thoughts, so the restore above is doing its job — but it drifts horizontally with the finger, including well past the end of the text into empty space. That last detail is what identifies it: a thought of one character has only two text positions, so a caret drawn forty pixels beyond the glyph does not correspond to any position in the document, and therefore is not rendering the browser selection. Dragging right confirms it from the other side — the selection stays clamped to the end of the thought and never leaves the editable, so the restore above does not run, yet the caret still visibly drifts out. iOS draws it in its own text-interaction layer, composited above the web content, where page CSS cannot reach it.

Three further results agree. Adding `user-select: none` to the non-cursor thoughts stops the selection escaping at all — 56 consecutive `selectionchange` events all reported the cursor thought — and the artifact continues regardless. `caret-color: transparent` set on the document element hides the *real* caret and leaves this one untouched. Restoring the selection synchronously rather than a frame later makes no difference. Note that none of this reproduces in the Simulator, which does not drag the caret out of its editing host at all; a real device does, on iOS 17 as well as 26, so it is a simulator/device difference rather than a version one.
Expand Down
2 changes: 2 additions & 0 deletions src/components/Editable/useEditMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import head from '../../util/head'
import isCommandKey from '../../util/isCommandKey'
import lastTouch from './lastTouch'
import useCaretRestore from './useCaretRestore'
import useRevertCorrection from './useRevertCorrection'

/** 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. */
const useEditMode = ({
Expand Down Expand Up @@ -68,6 +69,7 @@ const useEditMode = ({
const editingOrOnCursor = isCursor || editing

useCaretRestore({ editableRef: contentRef, enabled: isCursor && !!editing })
useRevertCorrection({ editableRef: contentRef, enabled: isCursor && !!editing, path })

useEffect(
() => {
Expand Down
105 changes: 105 additions & 0 deletions src/components/Editable/useRevertCorrection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import Path from '../../@types/Path'
import { setCursorActionCreator as setCursor } from '../../actions/setCursor'
import { isSafari, isTouch } from '../../browser'
import getCaretOffset from '../../device/getCaretOffset'
import * as selection from '../../device/selection'
import isMultiEditing from '../../selectors/isMultiEditing'
import isMulticursorPath from '../../selectors/isMulticursorPath'
import multitouchStore from '../../stores/multitouchStore'

/**
* Puts the caret where a tap landed when iOS moves it back. A tap a few characters from the caret gets no mouse
* events, so onMouseDown never places the caret, and after the touchend iOS moves the caret to the tapped word and
* then, within about 70ms, back to where it was before the tap (#4220).
*/
const useRevertCorrection = ({
editableRef,
enabled,
path,
}: {
editableRef: React.RefObject<HTMLElement | null>
/** Correct only while this editable is the one being edited, i.e. the cursor thought with the keyboard open. */
enabled: boolean
/** The thought whose cursor offset follows the corrected caret. */
path: Path
}) => {
const dispatch = useDispatch()

useEffect(() => {
const editable = editableRef.current
if (!editable || !enabled || !isTouch || !isSafari()) return

// The last tap on the editable: where it landed, the caret offset at its touchend, and whether the caret has left
// that offset since. It lasts until the next input of any kind, since the revert arrives after the touchend.
let watch: { clientX: number; clientY: number; before: number; moved: boolean } | null = null

/** Starts watching a tap. */
const onTouchEnd = (e: TouchEvent) => {
const touch = e.changedTouches[0]
// iOS moves the caret for a tap only after its touchend, so this is still the offset from before the tap. A drag
// or long press has already moved the caret by now, and nothing moves it back.
const before = selection.isCollapsed() ? selection.offsetFromNode(editable) : null
watch =
touch && before !== null && e.touches.length === 0 && !multitouchStore.getState()
? { clientX: touch.clientX, clientY: touch.clientY, before, moved: false }
: null
}

/** Ends the watch on any other input. A mousedown means the tap is one that onMouseDown places itself. */
const endWatch = () => (watch = null)

/** Notes a move away from the offset before the tap, and corrects a return to it after one. */
const onSelectionChange = () => {
if (!watch || document.activeElement !== editable || !selection.isCollapsed()) return
const offset = selection.offsetFromNode(editable)
if (offset === null) return
if (offset !== watch.before) {
watch = { ...watch, moved: true }
return
}
if (!watch.moved) return

const { clientX, clientY, before } = watch
watch = null
const tapped = getCaretOffset(editable, { clientX, clientY }).offset
// a tap on the caret
if (tapped === null || tapped === before) return

// iOS is still handling the tap when it fires this selectionchange.
requestAnimationFrame(() => {

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.

Why requestAnimationFrame? I’m not sure there is any guarantee that the browser selection lines up with the frame rate.

Is there something more salient than the next frame that you could wait for? Or is it a magic number that might be sensitive to cpu load?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think that I managed to get rid of it now that this solution is specific to #4220 and doesn't also try to fix #5981.

selection.set(editable, { offset: tapped })

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.

This deviates from the declarative design of useEditMode where we attempt to centralize selection.set and avoid competing selection logic. Many conditions feed into one write.

Is there something fundamentally different about useRevertCorrection that it can’t follow the existing pattern?
Would it be possible to integrate into useEditMode?

It may be cleaner the way you have it, but since it does seem to be a departure from our earlier architecture, along with useCaretRestore, so I want to make sure that we have a unified vision rather than patching on a new paradigm. Refactoring may be necessary if there is a better way to layer multiple effects and touch handlers.

@ethan-james ethan-james Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The detection can't fold into the declarative effect, because it reacts to iOS changing the selection rather than to app state, so a selectionchange listener has to live somewhere (as with useCaretRestore). The write could, in principle: dispatch setCursor with the tapped offset and let setSelectionToCursorOffset place the caret, as the retarget path in onTouchEnd does.

The blocker is that the selection effect deliberately doesn't re-run when only cursorOffset changes, so that it doesn't set the selection while typing. The retarget path gets a re-run because the cursor moves to a different thought. Here the thought is already the cursor, and the only existing way to force a re-run is editableNonce. That's global: every ContentEditable re-checks its HTML and every Note drops its draft. On the thought being edited, HTML in Redux that lags the DOM would also be written over the text. A dedicated trigger would avoid that, but it would be new state for a single caller.

So I'm looking at keeping the detection in the hook and routing the write through useEditMode's setCaretOffset, which onMouseDown already uses, so both tap paths share one placement.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It seems like a new caretNonce dependency would be a pretty good solution for getting the declarative effect to run without messing up the editableNonce and risking losing typed changes. I think that this would be best handled in #4470, which I've de-prioritized but can re-prioritize now that the path forward seems clearer.

Anything decided here should be extended to cover useCaretRestore and, ideally, setCaretOffset as well. It would be great to keep state.cursorOffset in better sync by always calling setCursor rather than simply selection.set. However, there are some open questions about whether waiting for the Redux sync is fast enough for some of these caret adjustment operations to proceed without noticeable flicker. If I remember correctly, I was exploring replacing useEffect with useLayoutEffect, which is also a substantial change that needs a lot of testing.

For now, I think I'll drop the setCursor dispatch because it doesn't cover any new cases. If cursorOffset was up to date before the caret was reverted, then it will still be up to date after the correction. If it was stale due to lack of mouse events, then it can stay stale.

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.

Thanks for working on this!

Sorry for the long + granular replies. There are a lot of different aspects to this.

The detection can't fold into the declarative effect, because it reacts to iOS changing the selection rather than to app state, so a selectionchange listener has to live somewhere (as with useCaretRestore).

Okay, understood.

The selectionchange listener is not unlike the touch listeners that we already subscribe to in useEditMode. We use event handlers to respond to user actions, update state, and set the selection as a side effect. The main difference, it seems to me, is that the selectionchange state is not Redux state. This is intentional, as we wouldn't want to update Redux state on every selection change, the same way we don't want to update Redux state every character typed. That means we can't subscribe the side effect to selectionchange the same way as we subscribe to Redux state through useSelector dependencies.

However, we often use ministores to subscribe to realtime changes outside the Redux path. Not sure if that would be helpful in this case.

So I'm looking at keeping the detection in the hook and routing the write through useEditMode's setCaretOffset, which onMouseDown already uses, so both tap paths share one placement.

Yes, some shared logic would be an improvement.

It seems like a new caretNonce dependency would be a pretty good solution for getting the declarative effect to run without messing up the editableNonce and risking losing typed changes. I think that this would be best handled in #4470, which I've de-prioritized but can re-prioritize now that the path forward seems clearer.

I like this idea. But why use a nonce rather than a proper representation of the browser selection? A nonce is a course tool that says something needs updating without saying why. A selection representation (effectively a reactive representation of window.getSelection()) could be subscribed to with reactive selectors.

Anything decided here should be extended to cover useCaretRestore and, ideally, setCaretOffset as well.

Yes, exactly. Look for opportunities for a more unified architecture, as the selection handling has become more fragmented over time.

It would be great to keep state.cursorOffset in better sync by always calling setCursor rather than simply selection.set.

It was unfortunate when these two started to deviate.

However, there are some open questions about whether waiting for the Redux sync is fast enough for some of these caret adjustment operations to proceed without noticeable flicker. If I remember correctly, I was exploring replacing useEffect with useLayoutEffect, which is also a substantial change that needs a lot of testing.

That's a legitimate concern. useEffect was adequate in the past because a frame or two delay was not noticeable or relevant to the kinds of programmatic caret movement that we were doing. Think of the basic case of activating New Thought starting with the keyboard down. The the layout tree is recalculated, the UI renders the empty new thought, and thoughts animate into their new positions. The caret selection can basically take place any time between the user activating New Thought and when they are ready to start typing. There is plenty of time for a missed frame or two without impacting UX.

The situation is very different when overriding touch events. Now we're not just setting the selection from an idle state, we are actively competing with native browser events. Redux updates themselves are synchronous and can be guaranteed to make changes before the next paint using useLayoutEffect, but touch timing, delays, and iOS touch redirection do not fit within the normal render cycle. I don't think useLayoutEffect will solve all our problems here. We're dealing with a new class of problems that involve preventing default browser behavior and detecting and redirecting native events based on where the browser is trying to place the caret.

dispatch((dispatch, getState) => {
const state = getState()
dispatch(
setCursor({
path,
offset: tapped,
preserveMulticursor: isMultiEditing(state) && isMulticursorPath(state, path),
}),
)
})
})
}

editable.addEventListener('touchend', onTouchEnd)
// Capture, so that a handler that stops propagation cannot keep the watch open.
document.addEventListener('touchstart', endWatch, { capture: true, passive: true })
document.addEventListener('mousedown', endWatch, { capture: true })
document.addEventListener('keydown', endWatch, { capture: true })
document.addEventListener('beforeinput', endWatch, { capture: true })
document.addEventListener('selectionchange', onSelectionChange)

return () => {
editable.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchstart', endWatch, { capture: true })
document.removeEventListener('mousedown', endWatch, { capture: true })
document.removeEventListener('keydown', endWatch, { capture: true })
document.removeEventListener('beforeinput', endWatch, { capture: true })
document.removeEventListener('selectionchange', onSelectionChange)
}
}, [dispatch, editableRef, enabled, path])
}

export default useRevertCorrection
19 changes: 19 additions & 0 deletions src/e2e/iOS/__tests__/caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import paste from '../helpers/paste'
import scrubSpaceBar from '../helpers/scrubSpaceBar'
import setFontSize from '../helpers/setFontSize'
import tap from '../helpers/tap'
import waitForCaretOffset from '../helpers/waitForCaretOffset'
import waitForEditable from '../helpers/waitForEditable'
import waitForElement from '../helpers/waitForElement'
import waitUntil from '../helpers/waitUntil'
Expand Down Expand Up @@ -404,4 +405,22 @@ describe('Caret', () => {
// A non-cursor thought must not open the virtual keyboard.
expect(keyboard).toBe(false)
})

// https://github.com/cybersemics/em/issues/4220
it('Tap moves the caret to a word a few characters away', async () => {
const value = 'Hello beautiful people in the world'
await newThought(value)
const editable = await waitForEditable(value)
await waitUntil(isKeyboardShown)

// place the caret before "the"
const offsetBeforeThe = 'Hello beautiful people in '.length
await tap(editable, { offset: offsetBeforeThe, pointerType: 'touch' })
await waitForCaretOffset(offsetBeforeThe)

// tap the "i" of "in", four characters to the left of the caret
const offsetBeforeIn = 'Hello beautiful people '.length
await tap(editable, { offset: offsetBeforeIn, pointerType: 'touch' })
await waitForCaretOffset(offsetBeforeIn)
})
})
25 changes: 25 additions & 0 deletions src/e2e/iOS/helpers/waitForCaretOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import getCaretOffset from './getCaretOffset'

/**
* Waits for the caret to come to rest at the given offset in the thought being edited. A caret that iOS places at a
* tap can be moved back within about 70ms, so the offset must match on two samples taken further apart than that.
* Uses the global browser object from WDIO.
*/
const waitForCaretOffset = async (offset: number): Promise<void> => {
let matchedPreviousSample = false
try {
await browser.waitUntil(
async () => {
const matched = (await getCaretOffset()) === offset
const settled = matched && matchedPreviousSample
matchedPreviousSample = matched
return settled
},
{ timeout: 5000, interval: 250 },
)
} catch {
throw new Error(`Expected the caret to come to rest at offset ${offset}, but it was at ${await getCaretOffset()}.`)
}
}

export default waitForCaretOffset
Loading