Repository navigation
[lexical-table] Bug Fix: Cover a tapped empty table cell on iOS so a double tap cannot select past it - #9350
SujinKim1127 wants to merge 19 commits into
Conversation
… host on iOS ## Description On iOS, double tapping an empty table cell makes WebKit select back to the nearest preceding word, which lies outside the table when the cell is empty. meta#9267 restores the caret after `selectionchange`, which prevents the data loss, but iOS paints the native selection highlight before the page is notified, so the preceding content still flashes blue on every double tap (meta#9347). Nothing dispatched to the page can cancel that gesture: the second tap produces no cancelable event, and `::selection` / `user-select` do not affect the UIKit highlight. This change gives each table cell its own editing host on iOS so the word search never leaves the cell. `TableCellNode.createDOM` renders `<td contenteditable="false"><div data-lexical-table-cell-content contenteditable="true">` and `getDOMSlot` returns `slot.withElement(wrapper)`, so the reconciler places the cell's children inside the wrapper without any model change. `exportDOM` strips the wrapper and the `contenteditable` attribute, so copied and exported HTML keeps the existing `<td><p>` shape. Non-iOS DOM is unchanged. Two side effects of the nested host are handled in `LexicalTableSelectionHelpers`: - Backspace at the start of an empty cell would let WebKit move the caret into the previous editing host (the cell to the left). It is consumed for `deleteContentBackward` / `DELETE_CHARACTER_COMMAND` when the cell is empty and the caret is at its start. - The iOS selection UI fires `window.blur` before `selectionchange`, which cleared the `touchTapCellKey` marker from meta#9267 before it could be used. The marker is now kept for up to 500ms across that blur when the tapped cell is an empty wrapper cell, and a native range touching that cell is collapsed back to the cell start. Closes meta#9347 ## Test plan `IosTableCellEditingHost.test.ts` mocks `IS_IOS` (and `CAN_USE_BEFORE_INPUT`, which jsdom lacks) and pins the wrapper DOM shape, wrapper identity across added paragraphs, the editable toggle, the exported HTML, and the Backspace guard in both the empty and the non-empty cell. ### Before ``` $ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableCellEditingHost.test.ts × renders the cell content inside a nested editing host × keeps the same editing host as paragraphs are added × follows the editable state of the editor × consumes Backspace at the start of an empty cell × leaves Backspace alone in a cell with text Tests 5 failed | 1 passed (6) ``` On an iPhone (iOS Safari and iOS Chrome) with the playground: insert a table below a paragraph, double tap an empty cell. The cells above and the end of the paragraph above the table are highlighted blue for one frame, then the caret returns to the cell. ### After ``` $ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableCellEditingHost.test.ts Tests 6 passed (6) $ pnpm exec vitest run packages/lexical-table Test Files 26 passed (26) Tests 265 passed (265) $ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser/EmptyCellTouchSelection.test.ts Test Files 1 passed (1) Tests 30 passed (30) $ pnpm run tsc # 0 errors $ pnpm exec eslint <changed files> # ok $ pnpm exec prettier --check <changed files> # All matched files use Prettier code style! ``` Same device steps: nothing is highlighted and the caret stays in the cell. Also verified on device: typing in a previously empty cell (English with autocorrect, Korean IME), Enter creating several paragraphs, Backspace at an empty cell start staying in the cell, double tap on a cell with text selecting the word, selection-handle drag from the paragraph above into the table and from a cell out of the table, long-press drag across cells producing a table selection, tapping another cell moving the caret, table scrolling. Not exercised: arrow-key and Shift+arrow navigation across cells on iOS (no hardware keyboard available), and Android. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…double tap cannot select past it ## Description On iOS, double tapping an empty table cell makes WebKit select back to the nearest preceding word, which lies outside the table. meta#9267 restores the caret after `selectionchange`, but iOS paints the native highlight before the page is notified, so the preceding content still flashes on every double tap (meta#9347). The second tap dispatches no cancelable event, and page CSS does not affect the highlight. As suggested in review, this replaces the per-cell editing host from the previous commit with a much smaller change and reverts its other parts (the cell wrapper, the empty-cell Backspace guard, and the window blur / touch marker changes); `LexicalTableSelectionHelpers` is back to `main`. - Empty cell overlay (`LexicalTableIOSEmptyCellOverlay`): after a short, still touch tap in an empty cell, that cell is covered for 500ms by an unmanaged `contenteditable="false"` overlay. A second tap in that window lands on the overlay instead of editable text, so iOS does not start a word selection, and the overlay places the caret in the cell. Outside the window nothing covers the cell, so long press and long press drag keep working. Drags, long presses, and touches iOS takes over (`pointercancel`) never add it. The overlay goes away when the window ends, another cell is tapped, or the cell gets content, and the cell's inline `position` is restored with it. `TableCellNode.getDOMSlot` keeps the cell's children before the overlay with `withBefore`, so DOM child offsets still match the node's children. The overlay is never part of `createDOM`, so exported HTML is unchanged. - Drag caret (`LexicalTableIOSDragCaret`): during a long press drag across cells, iOS keeps moving the native caret while the table selection keeps clearing it, so it flickers. The root's `caret-color` is made transparent while a finger is down on a cell and restored (to any previous inline value) when the last finger lifts. This reduces the flicker; the caret iOS draws around its magnifier is not affected by `caret-color`. Both are registered from `registerTableSelectionObserver` on iOS only. Non-iOS behavior and DOM are unchanged. Closes meta#9347 ## Test plan `IosEmptyCellOverlay.test.ts` and `IosTableDragCaret.test.ts` mock `IS_IOS`. ### Before With the sources at `main`: ``` $ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts × hides the caret while a finger is down on a cell × keeps hiding until the last finger lifts × restores a caret-color that was already set × a touch tap in an empty cell covers it for the double-tap window × restores the cell position when the overlay goes away × only the most recently tapped cell is covered × a second tap on the overlay places the caret in its cell × keeps managed children before the overlay and drops it once the cell has text Tests 8 failed | 8 passed (16) ``` On an iPhone (iOS Safari) with the playground: double tapping an empty cell flashes the cells above and the end of the paragraph above the table; a long press drag across cells starting from an empty cell paints the cells with the caret flickering throughout. ### After ``` $ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts Tests 16 passed (16) $ pnpm exec vitest run packages/lexical-table Test Files 30 passed (30) Tests 287 passed (287) $ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser Test Files 7 passed (7) Tests 58 passed (58) $ pnpm exec vitest run scripts/__tests__/unit/treeShakingSource.test.ts Tests 342 passed (342) $ pnpm run tsc # 0 errors $ pnpm run flow # Found 0 errors ``` On the same iPhone with the playground: double tapping an empty cell (repeated, at varying speeds) shows no highlight; a single tap places the caret and opens the keyboard; typing (including Korean) works; a long press in an empty cell keeps the native behavior; a long press drag starting from an empty cell paints the cells, with the caret flicker reduced but not gone; tapping the start cell right after a drag places the caret normally. An on-device event log confirmed the second tap of a double tap lands on the overlay. Not exercised: iPadOS, Android (code is iOS-only), and hardware keyboard navigation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it already holds the caret
## Description
Double tapping an empty table cell on iOS still selected past the table,
back to the text above it, when the caret was already in that cell. In that
case iOS sends no pointerup for the first tap until the second tap has
started (its touchend arrives 1ms before the next touchstart), so the
overlay that the previous commit adds on pointerup came too late. Whether a
double tap worked depended on whether the cell had been tapped before.
A touch pointerdown on the cell that holds a collapsed DOM selection now
covers it right away. Other taps still cover the cell on pointerup, so long
press and drag behavior elsewhere is unchanged.
Cleanup in the same files, with no behavior change:
- The overlay's update listener and state live in the root listener and
only check the covered cell, instead of scanning every dirty element on
every update. The saved inline `position` is kept in that state rather
than in the overlay's dataset.
- The empty cell check is shared as `$isCellEmpty` in `LexicalTableUtils`
with the existing double tap repair in `LexicalTableSelectionHelpers`,
and the tap slop is the existing `TAP_SLOP`, moved to `constants`.
- `createEmptyCellOverlay` moved into the overlay module; `TableCellNode`
keeps only what `getDOMSlot` needs.
- The drag caret touch listeners are passive.
## Test plan
### Before
The new test with the pointerdown branch disabled:
```
$ npx vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts
× a touch on the cell holding the caret covers it on pointerdown 7ms
AssertionError: expected null not to be null // Object.is equality
Tests 1 failed | 12 passed (13)
```
### After
```
$ npx vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts
Test Files 1 passed (1)
Tests 13 passed (13)
$ npx vitest run packages/lexical-table
Test Files 30 passed (30)
Tests 289 passed (289)
```
The 5 unhandled errors in the `packages/lexical-table` run
(`LexicalEvents.ts:792`) also occur without this change.
Checked by hand on an iPhone against the playground: double tapping an
empty cell that already holds the caret keeps the caret in the cell; long
pressing an empty cell still shows the Paste menu, and the caret is visible
again once the menu closes. Other browsers were not exercised; the change
only runs on iOS.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… changedTouches list
## Description
The synthetic `touchend` events in `IosTableDragCaret.test.ts` and
`IosEmptyCellOverlay.test.ts` had no `changedTouches`, so Lexical's own iOS
`touchend` listener (`LexicalEvents.ts:792`) threw on them. The tests still
passed, but the `packages/lexical-table` run reported 5 unhandled errors that
`main` does not have. The events now carry an empty `changedTouches` list.
## Test plan
### Before
```
$ pnpm exec vitest run packages/lexical-table
❯ HTMLDivElement.<anonymous> packages/lexical/src/LexicalEvents.ts:792:29 (x5)
Test Files 30 passed (30)
Tests 289 passed (289)
```
### After
```
$ pnpm exec vitest run packages/lexical-table
Test Files 30 passed (30)
Tests 289 passed (289)
```
No unhandled errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests
## Description
No intended behavior change, apart from one tap no longer building the
overlay twice.
- A tap on the cell that holds the caret covered it on `pointerdown`, then
its own `pointerup` removed and rebuilt the overlay (and the cell
`position` around it). That `pointerup` now only restarts the window.
- The cell lookup from an event target is shared by `pointerdown` and
`pointerup`; the overlay state drops the cell, which is the overlay's
parent.
- Listeners are registered with `registerEventListeners` /
`registerEventListener` and `mergeRegister`, so the drag caret listeners
are removed with the same options they were added with.
- `TableCellNode.getDOMSlot` inlines its single-use overlay lookup.
- Comments are shorter, and the one claiming a positioned cell stops a long
press drag is corrected: the playground theme already positions cells.
- The overlay tests are consolidated (same behaviors, fewer near-duplicate
cases), use the existing `PointerEvent` mock, and find cells from the DOM
instead of tracking keys. A check that one tap keeps a single overlay is
added.
## Test plan
### Before
```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts -t "holding the caret" # with the restart branch removed
× the cell holding the caret is covered on pointerdown
Tests 1 failed | 9 skipped (10)
```
### After
```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
Tests 15 passed (15)
$ pnpm exec vitest run packages/lexical-table
Test Files 30 passed (30)
Tests 286 passed (286)
$ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser
Tests 58 passed (58)
$ pnpm exec vitest run scripts/__tests__/unit/treeShakingSource.test.ts
Tests 342 passed (342)
$ pnpm run tsc # 0 errors
$ pnpm run flow # Found 0 errors
```
Not re-checked on a device; the only runtime difference is the overlay no
longer being rebuilt on the same tap's pointerup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thanks for the suggestion, I've reworked the PR along those lines. The per-cell wrapper and the other changes to the selection helpers are gone; the #9267 repair is unchanged apart from sharing an empty-cell helper.
Verified on an iPhone with the playground: no highlight on repeated double taps, single tap still places the caret and opens the keyboard, typing (including Korean) works, and long press and long press drag behave as before. |
…overlay test ## Description `main` removed the deprecated unchecked type arguments on node getters (meta#9479), so `getFirstChildOrThrow<ElementNode>()` in `IosEmptyCellOverlay.test.ts` no longer type-checks and the `integrity` CI job failed on this PR. The test now gets a cell's paragraph through a small `$paragraph()` helper that narrows with `$isElementNode` and `assert`. ## Test plan ### Before With `main` merged into the branch: ``` $ pnpm run tsc packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(72,33): error TS2558: Expected 0 type arguments, but got 1. packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(73,12): error TS2339: Property 'append' does not exist on type 'LexicalNode'. packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(199,39): error TS2558: Expected 0 type arguments, but got 1. packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(199,54): error TS2339: Property 'clear' does not exist on type 'LexicalNode'. packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(246,33): error TS2558: Expected 0 type arguments, but got 1. packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts(247,12): error TS2339: Property 'append' does not exist on type 'LexicalNode'. ``` ### After Each `ci-check` step run on its own (`tsc`, `tsc-test`, `tsc-scripts`, `tsc-extension`, `tsc-website`, `flow`, `prettier`, `lint`, `check-changesets`): all exit 0. ``` $ pnpm exec vitest run packages/lexical-table Test Files 31 passed (31) Tests 298 passed (298) $ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser Tests 58 passed (58) $ pnpm exec vitest run scripts/__tests__/unit/treeShakingSource.test.ts Tests 374 passed (374) ``` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into fix/ios-table-cell-editing-host
Description
On iOS, double tapping an empty table cell makes WebKit select back to the nearest preceding word, which lies outside the table. #9267 restores the caret after
selectionchange, but iOS paints the native highlight before the page is notified, so the preceding content still flashes on every double tap (#9347). The second tap dispatches no cancelable event, and page CSS does not affect the highlight.Empty cell overlay (
LexicalTableIOSEmptyCellOverlay.ts)contenteditable="false"overlay. A second tap in that window lands on the overlay instead of editable text, so iOS does not start a word selection, and the overlay places the caret in the cell.pointerup, after a short, still touch. When the cell already holds the caret, iOS withholds that tap'spointerupuntil it knows whether a second tap follows, so that cell is covered onpointerdowninstead.pointercancel), never adds it.positionis restored with it.TableCellNode.getDOMSlotkeeps the cell's children before the overlay withwithBefore, so DOM child offsets still match the node's children. The overlay is never part ofcreateDOM, so exported HTML is unchanged.Drag caret (
LexicalTableIOSDragCaret.ts)caret-coloris made transparent while a finger is down on a cell, and restored (to any previous inline value) when the last finger lifts.caret-color. I'm happy to split this into its own PR if you'd prefer.Both are registered from
registerTableSelectionObserveron iOS only. InLexicalTableSelectionHelpers, the #9267 repair now uses the shared$isCellEmpty(LexicalTableUtils) for its empty cell check, andTAP_SLOPmoved toconstantsso the overlay can share it; neither changes behavior. Non-iOS behavior and DOM are unchanged.Closes #9347
Test plan
IosEmptyCellOverlay.test.tsandIosTableDragCaret.test.tsmockIS_IOS.Before
ScreenRecording_10-12-2026.16-51-09_1.mov
On an iPhone (iOS Safari) with the playground: double tapping an empty cell flashes the cells above and the end of the paragraph above the table. A long press drag across cells starting from an empty cell paints the cells with the caret flickering throughout.
With the
lexical-tablesources atmain:After
ScreenRecording_10-23-2026.15-34-02_1.mov
On the same iPhone with the playground:
Not exercised: iPadOS, Android (the code is iOS-only), and hardware keyboard navigation.
🤖 Generated with Claude Code