Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
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.

On WebKit 27, [`useRevertCorrection`](../src/components/Editable/useRevertCorrection.ts), mounted alongside `useCaretRestore` on the same two editables, corrects a tap that iOS undoes ([#5981](https://github.com/cybersemics/em/issues/5981)). When the `touchend` of a tap within the editing thought is withheld (see [Rapid taps between thoughts](#rapid-taps-between-thoughts)), no mouse events follow either, and iOS places the caret at the tap and then, within about 300ms, moves it back to where it was before the touch. The capture-phase `touchstart` listener in [`initEvents`](../src/util/initEvents.ts) records the touch point and the collapsed caret offset of a single-finger touch on the focused editable as `revertWatch` in [`touchStore`](../src/stores/touchStore.ts), alongside `pressOnCaret`. While that touch is open, a `selectionchange` that returns the caret to its recorded offset after it had moved elsewhere is taken by `useRevertCorrection` as the revert. Any other input ends the watch, through capture-phase listeners in `initEvents`: the next `touchstart`, the touch's `touchend` or `touchcancel`, a `mousedown` (iOS sends one about 600ms into a held press, as the loupe begins), a `keydown` or `beforeinput` (typing, deleting, undo), or a `touchmove` farther than `TOUCH_SLOP` from where the touch began, since a caret drag sweeping back across its starting offset is not a revert. A withheld touch sends no `touchmove` or `touchend`, so for it the other inputs are what end the watch. It then computes the tapped offset from the recorded point with `getCaretOffset` and places the caret there a frame later, once iOS has finished with the tap. On a thought, `useEditMode` places it with the same `setCaretOffset` its `mousedown` handler uses, so the cursor offset in Redux follows the corrected caret just as it does for a tap with mouse events; on a note, only the selection is set. 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
36 changes: 27 additions & 9 deletions src/components/Editable/useEditMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import getCaretOffset from '../../device/getCaretOffset'
import preventAutoscroll, { preventAutoscrollEnd } from '../../device/preventAutoscroll'
import * as selection from '../../device/selection'
import virtualKeyboard from '../../device/virtual-keyboard'
import useFreshCallback from '../../hooks/useFreshCallback'
import usePrevious from '../../hooks/usePrevious'
import hasMulticursor from '../../selectors/hasMulticursor'
import isMultiEditing from '../../selectors/isMultiEditing'
Expand All @@ -23,6 +24,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 @@ -200,19 +202,34 @@ const useEditMode = ({
})
}, [])

// 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 = (
/** Sets the DOM selection and updates the Redux cursor state. */
const setCaretOffset = useFreshCallback(
(
offset: number,
{ cursorHistoryClear, preserveMulticursor }: { cursorHistoryClear?: boolean; preserveMulticursor?: boolean } = {},
) => {
selection.set(editable, { offset })
selection.set(contentRef.current, { offset })
dispatch(setCursor({ path, offset, cursorHistoryClear, preserveMulticursor }))
}
},
[path],
)

/** Places the caret where a tap that iOS reverted landed, as onMouseDown does for a tap with mouse events. */
const onRevertCorrect = useFreshCallback(
(offset: number) => {
const state = store.getState()
allowDefaultSelection()
setCaretOffset(offset, { preserveMulticursor: isMultiEditing(state) && isMulticursorPath(state, path) })
},
[path],
)

useRevertCorrection({ editableRef: contentRef, enabled: isCursor && !!editing, onCorrect: onRevertCorrect })

// Handles the caret positioning logic for the editable element.
useEffect(() => {
const editable = contentRef.current
if (!editable) return

/** Marks the beginning of a touch so that onMouseDown can determine whether a long press is occurring. */
const onTouchStart = () => (pressingRef.current = true)
Expand Down Expand Up @@ -381,6 +398,7 @@ const useEditMode = ({
allowDefaultSelection,
path,
dispatch,
setCaretOffset,
store,
style?.visibility,
])
Expand Down
61 changes: 61 additions & 0 deletions src/components/Editable/useRevertCorrection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { isSafari, isTouch } from '../../browser'
import getCaretOffset from '../../device/getCaretOffset'
import * as selection from '../../device/selection'
import touchStore from '../../stores/touchStore'

/**
* On WebKit 27, iOS can move the caret for a tap whose touchend it withholds (#5660), then move it back to where it was
* before the touch (#5981). Puts the caret where the tap landed instead. The touch is recorded in touchStore's
* revertWatch by initEvents, which also ends the watch on any other input.
*/
const useRevertCorrection = ({
editableRef,
enabled,
onCorrect,
}: {
editableRef: React.RefObject<HTMLElement | null>
/** Correct only while this editable is the one being edited, i.e. the cursor thought or its focused note. */
enabled: boolean
/** Places the caret at the corrected offset. Defaults to setting the selection alone. Must be stable. */
onCorrect?: (offset: number) => void
}) => {
useEffect(() => {
const editable = editableRef.current
if (!editable || !enabled || !isTouch || !isSafari()) return

/** Notes a move away from the offset before the touch, and corrects a return to it after one. */
const onSelectionChange = () => {
// the selection belongs to another editable
if (document.activeElement !== editable) return
const watch = touchStore.getState().revertWatch
// no touch on this editable is being watched
if (watch?.editable !== editable) return
// a range is not a caret that iOS placed or reverted, e.g. a word selected by a double tap
if (!selection.isCollapsed()) return
const offset = selection.offsetFromNode(editable)
// the caret is not inside this editable
if (offset === null) return

if (!watch.moved && offset !== watch.before) touchStore.update({ revertWatch: { ...watch, moved: true } })
// not a revert: the caret has not left its offset yet, or has moved somewhere other than back to it
if (!watch.moved || offset !== watch.before) return

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.

Too many return statements in this function, but they all have a purpose. Would it be better to move them into helper functions?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The return statements are reasonable IMO, but it might be helpful to include a comment for each explaining what that purpose is.


touchStore.update({ revertWatch: null })
const tapped = getCaretOffset(editable, watch).offset
// nowhere to put the caret, or the tap was on the caret, so there is nothing to undo
if (tapped === null || tapped === watch.before) return
// iOS is still handling the tap when it fires this selectionchange.
requestAnimationFrame(() => (onCorrect ? onCorrect(tapped) : selection.set(editable, { offset: tapped })))
}

document.addEventListener('selectionchange', onSelectionChange)

return () => {
if (touchStore.getState().revertWatch?.editable === editable) touchStore.update({ revertWatch: null })
document.removeEventListener('selectionchange', onSelectionChange)
}
}, [editableRef, enabled, onCorrect])
}

export default useRevertCorrection
2 changes: 2 additions & 0 deletions src/components/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import strip from '../util/strip'
import trimHtml from '../util/trimHtml'
import useCaretRestore from './Editable/useCaretRestore'
import useOnCut from './Editable/useOnCut'
import useRevertCorrection from './Editable/useRevertCorrection'
import FauxCaret from './FauxCaret'

/** Renders an editable note that modifies the content of the hidden =note attribute. */
Expand Down Expand Up @@ -81,6 +82,7 @@ const Note = React.memo(
// pressed, so the caret escapes without any drag at all. It only escapes from the end, where the note abuts
// the parent thought, so that is where it belongs when restored. (#3276)
useCaretRestore({ editableRef: noteRef, enabled: !!hasFocus, end: true })
useRevertCorrection({ editableRef: noteRef, enabled: !!hasFocus })

/** Focus Handling with useFreshCallback. */
const onFocus = useFreshCallback(() => {
Expand Down
70 changes: 70 additions & 0 deletions src/e2e/iOS/__tests__/doubleTap.ios27.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,49 @@ import tap from '../helpers/tap'
import waitForEditable from '../helpers/waitForEditable'
import waitUntil from '../helpers/waitUntil'

/**
* Records the caret's focus offset on every selectionchange from the next touchstart on, in the page, so that a change
* between two WebDriver calls is not missed, and one still pending from before the touch is not counted.
*/
const recordCaretFromNextTouch = async () => {
await browser.execute(() => {
const w = window as unknown as { __caretOffsets: number[] }
w.__caretOffsets = []
document.addEventListener(
'touchstart',
() =>
document.addEventListener('selectionchange', () => {
const offset = document.getSelection()?.focusOffset
if (offset != null) w.__caretOffsets.push(offset)
}),
{ capture: true, once: true },
)
})

return {
/**
* Waits until the caret has left the offset `before`, returned to it, and left it again, i.e. a tap that iOS
* reverted has been corrected. Also passes if `timeout` expires without the caret being reverted.
*/
waitToSettle: async ({ before, timeout }: { before: number; timeout: number }): Promise<void> => {
const start = Date.now()
/** True once the offsets show the caret leaving before, returning to it, and leaving it again. */
const corrected = (offsets: number[]) => {
const left = offsets.findIndex(offset => offset !== before)
const returned = offsets.findIndex((offset, i) => i > left && offset === before)
return left !== -1 && returned !== -1 && offsets.slice(returned + 1).some(offset => offset !== before)
}
// Safari's driver does not wait for a promise returned from execute, so poll, with the deadline in the condition
await browser.waitUntil(
async () =>
Date.now() - start >= timeout ||
corrected(await browser.execute(() => (window as unknown as { __caretOffsets: number[] }).__caretOffsets)),
{ timeout: timeout * 5, interval: 50 },
)
},
}
}

describe('Double tap', () => {
// https://github.com/cybersemics/em/issues/5660
it('a tap after a double tap on the caret does not activate drag and drop', async () => {
Expand Down Expand Up @@ -64,4 +107,31 @@ describe('Double tap', () => {
expect(alertText).not.toBe('Drag and drop to move thought')
expect(await isKeyboardShown()).toBe(true)
})

// https://github.com/cybersemics/em/issues/5981
it('a tap within the thought being edited after a double tap is not undone', async () => {
await newThought('Existentialism eternally')
await hideKeyboardByTappingDone()
const editable = await waitForEditable('Existentialism eternally')
await tap(editable, { pointerType: 'touch' })
await waitUntil(isKeyboardShown)

await tap(editable, { offset: 'Existentialism'.length, pointerType: 'touch' })
await waitUntil(
async () => (await browser.execute(() => document.getSelection()?.focusOffset)) === 'Existentialism'.length,
)
await tap(editable, { offset: 'Existentialism'.length, pointerType: 'touch', count: 2 })
await waitUntil(async () => (await getSelection().toString()) === 'Existentialism')
await setSelection('Existentialism'.length, 'Existentialism'.length)

const caret = await recordCaretFromNextTouch()
// the middle of "eternally"
await tap(editable, { offset: 'Existentialism eter'.length, pointerType: 'touch' })
// iOS can move the caret back within about 300ms of the tap, after which em puts it back again
await caret.waitToSettle({ before: 'Existentialism'.length, timeout: 1000 })

const offset = await browser.execute(() => document.getSelection()?.focusOffset ?? null)
// anywhere in "eternally", which runs to the end of the text
expect(offset).toBeGreaterThanOrEqual('Existentialism '.length)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a reason to use toBeGreaterThanOrEqual rather than asserting the exact expected offset?

@ethan-james ethan-james Oct 22, 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.

Copilot says:

The exact value would be 15, 'Existentialism '.length, the start of "eternally". But asserting it exactly would make the test depend on details that don't matter to the user.

An exact 15 tests em's word snapping, not the bug. It would depend on where the synthesized tap lands relative to the middle of the word. 19 is only half a character from tying with the end. A slightly different landing could snap to 24, which is just as correct.

I would support that argument mainly because our word snapping works a little differently from Safari's, and I foresee a future in which we fix that because it can be annoying to have the caret bouncing around.

})
})
8 changes: 8 additions & 0 deletions src/stores/touchStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const touchStore = ministore({
touching: false,
/** Set when a completed touch has already handled its intended cursor behavior, and cleared on the next touchstart. While set, cursor-producing events on an editable belong to the completed touch: browsers can synthesize them after touchend called preventDefault, or after drag cleanup has finished. A legitimate tap always begins with a new touchstart, which clears the flag first. */
suppressCursorAfterTouch: false,
/** On WebKit 27, the touch on the focused editable, where it landed, and the collapsed caret offset before it, so that useRevertCorrection can tell iOS reverting the tap from the caret moving for any other reason (#5981). Set by the capture-phase touchstart listener in initEvents and cleared by the next input of any kind. `moved` records that the caret has left `before` since. */
revertWatch: null as {
editable: HTMLElement
clientX: number
clientY: number
before: number
moved: boolean
} | null,
})

export default touchStore
Loading
Loading