From 8435ba0e1bad4ab78026b694f804c3c781841b2a Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 27 Oct 2026 15:13:35 -0700 Subject: [PATCH 1/3] Put the caret where a tap landed when iOS reverts it after the touchend 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 back to where it was (#4220). useRevertCorrection, mounted by useEditMode on the thought being edited, records the caret offset and the touch point at the touchend, takes a selectionchange back to that offset after a move away as the revert, and places the caret at getCaretOffset of the touch point, dispatching setCursor so the Redux offset follows. The watch ends on the next touchstart, mousedown, keydown or beforeinput. This replaces #5983's revert correction. The case it was built for, a revert before a touchend that iOS 27 withholds (#5981), is waiting on a Safari fix, so the watch no longer spans the touch: it needs no touchStore state, no listeners in initEvents and no TOUCH_SLOP check for drags. Co-Authored-By: Claude claude-opus-5-5 (medium) --- docs/cursor-and-caret.md | 2 + src/components/Editable/useEditMode.ts | 2 + .../Editable/useRevertCorrection.ts | 105 ++++++++++++++++++ src/e2e/iOS/__tests__/caret.ts | 19 ++++ src/e2e/iOS/helpers/waitForCaretOffset.ts | 25 +++++ 5 files changed, 153 insertions(+) create mode 100644 src/components/Editable/useRevertCorrection.ts create mode 100644 src/e2e/iOS/helpers/waitForCaretOffset.ts diff --git a/docs/cursor-and-caret.md b/docs/cursor-and-caret.md index 7662636acbf..c9aa99d16f4 100644 --- a/docs/cursor-and-caret.md +++ b/docs/cursor-and-caret.md @@ -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. diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index 95ac0b6a281..b6ea1ddccad 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -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 = ({ @@ -68,6 +69,7 @@ const useEditMode = ({ const editingOrOnCursor = isCursor || editing useCaretRestore({ editableRef: contentRef, enabled: isCursor && !!editing }) + useRevertCorrection({ editableRef: contentRef, enabled: isCursor && !!editing, path }) useEffect( () => { diff --git a/src/components/Editable/useRevertCorrection.ts b/src/components/Editable/useRevertCorrection.ts new file mode 100644 index 00000000000..aef2e6fc317 --- /dev/null +++ b/src/components/Editable/useRevertCorrection.ts @@ -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 + /** 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(() => { + selection.set(editable, { offset: tapped }) + 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 diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 9ba282d20fb..2585f239e0c 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -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' @@ -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) + }) }) diff --git a/src/e2e/iOS/helpers/waitForCaretOffset.ts b/src/e2e/iOS/helpers/waitForCaretOffset.ts new file mode 100644 index 00000000000..e36c9543e66 --- /dev/null +++ b/src/e2e/iOS/helpers/waitForCaretOffset.ts @@ -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 => { + 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 From eba29906148a920a031339771a719c93a596372b Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 28 Oct 2026 09:18:27 -0700 Subject: [PATCH 2/3] Correct the reverted caret synchronously The correction no longer waits a frame. iOS does not move the caret again after the revert, so setting it in the revert's selectionchange holds: the caret.ts test for #4220 passed 9 of 9 times on BrowserStack on iOS 17, 26 and 27 without the frame, and on an iPhone on iOS 27 all 14 corrected taps in a debug log stayed where they were put. Co-Authored-By: Claude claude-opus-5-5 (medium) --- docs/cursor-and-caret.md | 2 +- .../Editable/useRevertCorrection.ts | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/cursor-and-caret.md b/docs/cursor-and-caret.md index c9aa99d16f4..2a3c73a0d5b 100644 --- a/docs/cursor-and-caret.md +++ b/docs/cursor-and-caret.md @@ -152,7 +152,7 @@ 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. +[`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 straight away, which iOS leaves alone once it has reverted, 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. diff --git a/src/components/Editable/useRevertCorrection.ts b/src/components/Editable/useRevertCorrection.ts index aef2e6fc317..e3218b3fc67 100644 --- a/src/components/Editable/useRevertCorrection.ts +++ b/src/components/Editable/useRevertCorrection.ts @@ -67,19 +67,17 @@ const useRevertCorrection = ({ // a tap on the caret if (tapped === null || tapped === before) return - // iOS is still handling the tap when it fires this selectionchange. - requestAnimationFrame(() => { - selection.set(editable, { offset: tapped }) - dispatch((dispatch, getState) => { - const state = getState() - dispatch( - setCursor({ - path, - offset: tapped, - preserveMulticursor: isMultiEditing(state) && isMulticursorPath(state, path), - }), - ) - }) + // iOS does not move the caret again after the revert, so it can be set synchronously. + selection.set(editable, { offset: tapped }) + dispatch((dispatch, getState) => { + const state = getState() + dispatch( + setCursor({ + path, + offset: tapped, + preserveMulticursor: isMultiEditing(state) && isMulticursorPath(state, path), + }), + ) }) } From 3c08c7d5e95f24eb00917c08f9542227b8adffd2 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 28 Oct 2026 11:42:26 -0700 Subject: [PATCH 3/3] Leave the cursor offset alone when correcting a reverted tap The revert correction no longer dispatches setCursor. Nothing writes the cursor offset in Redux during a tap that gets no mouse events, so a tap that iOS places itself leaves it where it was, and the correction now does the same: it only sets the selection. The hook no longer needs the thought's path. Co-Authored-By: Claude claude-opus-5-5 (medium) --- docs/cursor-and-caret.md | 2 +- src/components/Editable/useEditMode.ts | 2 +- .../Editable/useRevertCorrection.ts | 25 +++---------------- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/docs/cursor-and-caret.md b/docs/cursor-and-caret.md index b11fe00e06a..5f3cf0bea9d 100644 --- a/docs/cursor-and-caret.md +++ b/docs/cursor-and-caret.md @@ -152,7 +152,7 @@ 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 straight away, which iOS leaves alone once it has reverted, 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. +[`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 straight away, which iOS leaves alone once it has reverted. It leaves the cursor offset in Redux alone, as a tap that iOS places itself does, since nothing writes it during a tap without mouse events. 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. diff --git a/src/components/Editable/useEditMode.ts b/src/components/Editable/useEditMode.ts index b6ea1ddccad..20b5866ea80 100644 --- a/src/components/Editable/useEditMode.ts +++ b/src/components/Editable/useEditMode.ts @@ -69,7 +69,7 @@ const useEditMode = ({ const editingOrOnCursor = isCursor || editing useCaretRestore({ editableRef: contentRef, enabled: isCursor && !!editing }) - useRevertCorrection({ editableRef: contentRef, enabled: isCursor && !!editing, path }) + useRevertCorrection({ editableRef: contentRef, enabled: isCursor && !!editing }) useEffect( () => { diff --git a/src/components/Editable/useRevertCorrection.ts b/src/components/Editable/useRevertCorrection.ts index e3218b3fc67..ba9081e5d34 100644 --- a/src/components/Editable/useRevertCorrection.ts +++ b/src/components/Editable/useRevertCorrection.ts @@ -1,12 +1,7 @@ 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' /** @@ -17,16 +12,11 @@ import multitouchStore from '../../stores/multitouchStore' const useRevertCorrection = ({ editableRef, enabled, - path, }: { editableRef: React.RefObject /** 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 @@ -67,18 +57,9 @@ const useRevertCorrection = ({ // a tap on the caret if (tapped === null || tapped === before) return - // iOS does not move the caret again after the revert, so it can be set synchronously. + // iOS does not move the caret again after the revert, so it can be set synchronously. The cursor offset in Redux + // is left alone, as it is for a tap that iOS places itself: nothing writes it during a tap without mouse events. selection.set(editable, { offset: tapped }) - dispatch((dispatch, getState) => { - const state = getState() - dispatch( - setCursor({ - path, - offset: tapped, - preserveMulticursor: isMultiEditing(state) && isMulticursorPath(state, path), - }), - ) - }) } editable.addEventListener('touchend', onTouchEnd) @@ -97,7 +78,7 @@ const useRevertCorrection = ({ document.removeEventListener('beforeinput', endWatch, { capture: true }) document.removeEventListener('selectionchange', onSelectionChange) } - }, [dispatch, editableRef, enabled, path]) + }, [editableRef, enabled]) } export default useRevertCorrection