Skip to content
Draft
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
84 changes: 79 additions & 5 deletions src/components/Editable/useEditMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import usePrevious from '../../hooks/usePrevious'
import hasMulticursor from '../../selectors/hasMulticursor'
import equalPath from '../../util/equalPath'

// #4173: Ghost-click suppression state. On a rapid tap between adjacent thoughts, iOS Safari coalesces the
// two taps into a double-tap and emits a delayed, retargeted synthesized mousedown/click/dblclick on the
// previously-focused thought ~50-250ms after the second tap's touchend. That delayed mousedown would drive
// onMouseDown -> setCursor and yank the cursor back. We record the last real touchend (time + element); a
// genuine mousedown follows its own touchend within a few ms on the same element, whereas the ghost arrives
// later on a different thought, so it can be detected and dropped.
let lastTouchEndTime = 0
let lastTouchEndTarget: EventTarget | null = null
const GHOST_MOUSE_WINDOW_MS = 700

/** 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 = ({
contentRef,
Expand Down Expand Up @@ -139,16 +149,53 @@ const useEditMode = ({
if (!editable) return

/** Sets the DOM selection and updates the Redux cursor state. */
const setCaretOffset = (offset: number) => {
const setCaretOffset = (offset: number, { cursorHistoryClear }: { cursorHistoryClear?: boolean } = {}) => {
selection.set(editable, { offset })
dispatch(setCursor({ path, offset }))
dispatch(setCursor({ path, offset, cursorHistoryClear }))
}

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

/** Marks the end of a touch, indicating to onMouseDown that a long press is not occurring. */
const onTouchEnd = () => (pressingRef.current = false)
/** Ends the touch, records it for ghost-click detection, and sets the cursor on the tapped thought. */
const onTouchEnd = (e: TouchEvent) => {
pressingRef.current = false
lastTouchEndTime = performance.now()
lastTouchEndTarget = editable

// #4173: touchend is the only event iOS reliably delivers to the tapped thought — on a rapid tap it
// retargets the synthesized mousedown/focus to the previously-focused thought (onMouseDown suppresses
// that ghost), so onFocus cannot be relied on to move the cursor. Set the cursor here, reading fresh
// store state (not the render closure) to avoid staleness across rapid taps.
const s = store.getState()
const move =
s.isKeyboardOpen &&
!equalPath(s.cursor, path) &&
!hasMulticursor(s) &&
s.longPress === LongPressState.Inactive &&
style?.visibility !== 'hidden'
if (!move) return

// Place the caret where the user tapped. getCaretOffset is coordinate-based, so it resolves the offset
// even though the synthesized mousedown/focus retargeted away.
const { offset } = getCaretOffset(editable, {
clientX: e.changedTouches[0].clientX,
clientY: e.changedTouches[0].clientY,
})

// Dispatch only the Redux cursor; the declarative selection effect places the caret on the next render.
// Calling selection.set() synchronously during touchend triggers iOS's text-selection machinery, which
// swallows the immediately-following rapid tap.
dispatch(
setCursor({
path,
offset,
cursorHistoryClear: true,
isKeyboardOpen: true,
preserveMulticursor: true,
}),
)
}

/**
* Handles the mousedown event for the editable element.
Expand All @@ -168,6 +215,21 @@ const useEditMode = ({
// with default iOS Safari drag-and-drop text selection.
if (pressingRef.current) return

// #4173: Suppress WebKit's delayed retargeted ghost mouse events. On a rapid tap between adjacent
// thoughts, iOS emits a delayed mousedown/click/dblclick on the previously-focused thought after the
// second tap already moved focus. A genuine mousedown follows its own touchend within a few ms on the
// same element; a ghost arrives later on a different thought. Dropping it (preventDefault also blocks
// the focus change) keeps the cursor on the thought the user actually tapped.
if (
isTouch &&
isSafari() &&
editable !== lastTouchEndTarget &&
performance.now() - lastTouchEndTime < GHOST_MOUSE_WINDOW_MS
) {
e.preventDefault()
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.
// See: #981
if (editingOrOnCursor && !isMulticursor) {
Expand Down Expand Up @@ -226,7 +288,19 @@ const useEditMode = ({
editable.removeEventListener('focus', onFocus)
}
}
}, [contentRef, editingOrOnCursor, isCursor, isMulticursor, fontSize, allowDefaultSelection, path, dispatch])
}, [
contentRef,
editing,
editingOrOnCursor,
isCursor,
isMulticursor,
fontSize,
allowDefaultSelection,
path,
dispatch,
store,
style?.visibility,
])

// 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
Loading