Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.

[`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.

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 })

useEffect(
() => {
Expand Down
84 changes: 84 additions & 0 deletions src/components/Editable/useRevertCorrection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect } from 'react'
import { isSafari, isTouch } from '../../browser'
import getCaretOffset from '../../device/getCaretOffset'
import * as selection from '../../device/selection'
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,
}: {
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
}) => {
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 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 })
}

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)
}
}, [editableRef, enabled])
}

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 @@ -22,6 +22,7 @@ import setSelection from '../helpers/setSelection'
import showEditMenu from '../helpers/showEditMenu'
import tap from '../helpers/tap'
import tapEditMenuItem from '../helpers/tapEditMenuItem'
import waitForCaretOffset from '../helpers/waitForCaretOffset'
import waitForEditable from '../helpers/waitForEditable'
import waitForElement from '../helpers/waitForElement'
import waitUntil from '../helpers/waitUntil'
Expand Down Expand Up @@ -445,4 +446,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