Skip to content

fix(selection): table cell and multiline selection rendering (SD-3328)#3688

Open
tupizz wants to merge 6 commits into
mainfrom
tadeu/sd-3328-bug-table-selection-highlight-disappears-over-empty-space
Open

fix(selection): table cell and multiline selection rendering (SD-3328)#3688
tupizz wants to merge 6 commits into
mainfrom
tadeu/sd-3328-bug-table-selection-highlight-disappears-over-empty-space

Conversation

@tupizz

@tupizz tupizz commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What

Fixes SD-3328. The selection highlight disappeared over empty space inside table cells. While tracking that down I found two more bugs on the same selection rendering path, so this PR groups the three related fixes.

Demo

CleanShot.2026-06-09.at.12.43.07.mp4

Background

Presentation mode draws its own selection overlay. It does not use the browser selection. The overlay rects come from computeSelectionRectsFromDom, which maps the ProseMirror selection to the painted DOM and reads getClientRects().

flowchart LR
  PM["PM selection (from, to)"] --> C[computeSelectionRectsFromDom]
  C --> R["DOM Range + getClientRects()"]
  R --> O[overlay rects]
Loading

1. Dragging into an empty paragraph in a table collapsed the selection (the reported bug)

prosemirror-tables normalizes a text selection whose endpoints resolve to different cells when the head sits at the start of its block (parentOffset === 0). An empty paragraph in a cell only ever has offset 0, so dragging a body selection onto one rewrote [44, 2026] to [44, 49] on dispatch. Text positions in a cell escape because their offset is greater than 0, which is why dragging through cell text worked but an empty paragraph killed it.

sequenceDiagram
  participant Drag
  participant PM as TextSelection.create
  participant Tables as prosemirror-tables
  Drag->>PM: create [44, 2026]
  PM-->>Drag: [44, 2026]
  Drag->>Tables: dispatch
  Tables-->>Drag: normalizes to [44, 49] (collapse)
Loading

Fix: a detector mirrors that exact condition. When a drag frame would collapse, the handler keeps the last good selection and resumes extending as soon as the head moves into cell text. The detector fails open on any unexpected document shape so it can never block a normal selection.

2. Interior lines of a multiline selection rendered blank

A multiline selection built one DOM Range across every line and trusted getClientRects(). Each .superdoc-line is absolutely positioned, and some Chrome builds return incomplete rects for a Range crossing those boxes: the first and last lines render, the interior lines collapse to a few slivers. intersectsNode does not catch it because the entries still report as intersected.

Fix: when a selection spans more than one line, compute rects per line. Interior lines use the line element box, which is reliable across browsers and gives the full width highlight normal selection shows. The first and last lines keep the precise Range so they respect the selection offset and first line indent.

3. Empty cell paragraphs resolved to no hit position

The geometry hit test returned null for an empty cell paragraph (no runs), which aborted the active drag and froze the selection. Now it falls back to the paragraph PM start so every empty cell resolves to a valid position.

Testing

  • New regression tests for each fix. The table detector test pins the behavior against the real prosemirror-tables plugin (it must flag exactly the selections that actually collapse).
  • All presentation editor tests pass (1139).
  • Verified each fix with a real drag against the dev server.

tupizz added 3 commits June 9, 2026 09:40
…sition (SD-3328)

An empty paragraph (blank line / spacer between bullets) in a table cell has no
runs and no measured lines, so the table hit-test fell through to a null result.
A null hit aborts an in-progress drag (EditorInputManager #handleDragSelectionAt
early-return), freezing/collapsing the selection while the pointer is over the
blank line. Derive the paragraph's PM start from its attrs so an empty cell
paragraph always resolves to a valid position, never null.

Also emit a content-width highlight band for blank lines on the selectionToRects
geometry path (fallback parity). Note: the live selection overlay uses the DOM
Range path (DomSelectionGeometry.getClientRects), so the blank-line band and the
interior-line render race on that path are addressed separately.
A multi-line selection built one DOM Range spanning every line and trusted
range.getClientRects(). Each .superdoc-line is absolutely positioned, and some
Chrome builds return incomplete rects for a range crossing those positioned
boxes: interior lines collapse to a few stray slivers while the first and last
lines render fully, leaving the middle of the selection visually unhighlighted.
intersectsNode does not flag it because the entries still report as intersected.

Compute rects per line when a selection spans more than one line. Interior
lines use the line element's own bounding box (reliable across browsers and the
full-width highlight normal selection shows); the first and last lines keep the
precise range so they respect the selection offset and first-line indent.
Single-line selections are unchanged.
Dragging a body text selection into (or through) a table collapsed the whole
selection. prosemirror-tables' normalizeSelection rewrites a TextSelection to the
anchor block's own bounds when its endpoints resolve to different cells and the
head sits at the start of its block (parentOffset 0). An empty paragraph inside a
cell is always at parentOffset 0, so dragging the head onto one rewrote e.g.
[44, 2026] to [44, 49] on dispatch, collapsing the selection to the first run.

Add selectionCollapsesAcrossTableCells to detect that exact frame (mirroring the
upstream condition) and skip dispatching the doomed selection in the drag handler,
preserving the last good selection. The selection resumes extending as soon as the
head moves into cell text. The detector fails open on any unexpected document
shape so it can never block a normal selection.
@linear-code

linear-code Bot commented Jun 9, 2026

Copy link
Copy Markdown

SD-3328

@tupizz tupizz marked this pull request as ready for review June 9, 2026 14:52
@tupizz tupizz requested a review from a team as a code owner June 9, 2026 14:52
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88450fadc3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@tupizz tupizz self-assigned this Jun 9, 2026
tupizz added 2 commits June 9, 2026 13:12
Dragging a selection from one table cell into another (notably backward, or
starting on an empty cell paragraph) broke: prosemirror-tables will not let a
text selection span cells, so it collapsed the selection, and the empty-para
guard then froze it. Cross-cell drags should be a cell-range selection, the way
Word and Google Docs behave.

Detect cross-cell drags from the resolved PM positions (reliable) rather than the
geometry hit-test (which misses empty cell paragraphs and leaves the cell anchor
unset). When the anchor and head land in different cells of the same table,
dispatch a CellSelection; within a single cell stays a text selection, and a
body-into-table drag keeps the empty-paragraph guard.

Also fix the cell-selection overlay so it renders without a geometry cell anchor:
it now locates the table block by document order instead of guessing an id like
`${tableStart}-table`, which never matched the real sequential ids.

Adds unit tests for the cross-cell resolvers (both directions, the empty-cell
case, and the same-cell / body-to-cell / different-table null cases).
Comment on lines +130 to +144
const docNode = $anchorCell.node(0);
let selectedTableIndex = -1;
let seenTables = 0;
docNode.descendants((node, pos) => {
if (node.type.name !== 'table') return true;
if (pos === tableStart) {
selectedTableIndex = seenTables;
return false;
}
seenTables += 1;
return true;
});
if (selectedTableIndex !== -1) {
const tableBlocks = blocks.filter((block) => block.kind === 'table') as TableBlock[];
tableBlock = tableBlocks[selectedTableIndex];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the descendants walk counts every table including nested ones (returning true descends into the node's children), but blocks.filter(kind === 'table') only holds top-level tables — nested tables live under their parent cell's blocks. so a nested table earlier in the doc desyncs selectedTableIndex and you index the wrong block, or get undefined and the overlay silently disappears. returning false after counting a table would keep both counts top-level.

also, this is the third copy of the "Nth table in doc order ↔ Nth table block" mapping (getCellPosFromTableHit and getTablePosFromHit do the inverse) — might be worth extracting one shared helper in TableSelectionUtilities, which would also be a natural single place for the nested-table fix. and since the existing overlay tests always pass a matching cellAnchorTableBlockId, this fallback path never runs in any test — a two-table doc with cellAnchorTableBlockId: null would cover it.

Comment on lines +585 to +606
/**
* SD-3328: an empty paragraph / blank line that the selection passes through is a
* zero-width slice (`pmStart === pmEnd`). `findLinesIntersectingRange` only yields
* such a line when `from < pos < to`, so it is genuinely spanned and must be
* highlighted. Emit a content-width band so the selection highlight stays
* continuous across the blank line — the same as selecting any text. Without this
* the band shows a gap and the highlight appears to "disappear" while a drag
* crosses a blank line (reported in body paragraphs and inside table cells).
*/
function pushEmptyLineSelectionBand(
rects: Rect[],
opts: { x: number; y: number; width: number; height: number; pageIndex: number },
): void {
rects.push({
x: opts.x,
y: opts.y,
width: Math.max(1, opts.width),
height: opts.height,
pageIndex: opts.pageIndex,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pushEmptyLineSelectionBand got inserted between the selectionToRects jsdoc and its signature, so the @param block is now orphaned from the exported function. moving the helper (and its doc) above the selectionToRects doc block fixes it.

while you're there: the helper only clamps width and pushes, but the emptyLineOffset math — the part most likely to drift — is copy-pasted at both call sites. consider moving that into the helper (pass measure/index/startLine), or just inlining the push.

Comment on lines +243 to +297
export function resolveCellContext(
doc: ProseMirrorNode | null,
pos: number,
): { cellPos: number; tablePos: number } | null {
if (!doc || !Number.isFinite(pos) || pos < 0 || pos > doc.content.size) return null;

let $pos: ReturnType<ProseMirrorNode['resolve']>;
try {
$pos = doc.resolve(pos);
} catch {
return null;
}

let cellDepth = -1;
let tableDepth = -1;
for (let depth = $pos.depth; depth > 0; depth--) {
const role = $pos.node(depth).type.spec.tableRole;
if (cellDepth === -1 && (role === 'cell' || role === 'header_cell')) {
cellDepth = depth;
}
if (cellDepth !== -1 && role === 'table') {
tableDepth = depth;
break;
}
}
if (cellDepth === -1 || tableDepth === -1) return null;

return { cellPos: $pos.before(cellDepth), tablePos: $pos.before(tableDepth) };
}

/**
* Determines whether a drag from `anchorPos` to `headPos` should be a cross-cell selection.
*
* SD-3328: The geometry trigger (`hitTestTable`) can miss empty cell paragraphs, leaving the cell
* anchor unset, so a drag across cells falls back to a plain text selection — which prosemirror-
* tables then collapses. Deriving the cells from the resolved PM positions is reliable. When both
* endpoints resolve to *different* cells of the *same* table, the caller should build a
* `CellSelection` (the cell-range highlight Word and Docs show) instead of a text selection.
*
* @returns Cell positions for `CellSelection.create`, or null when this is not a cross-cell drag
* (one endpoint outside any table, both in the same cell, or in different tables).
*/
export function resolveCrossCellSelection(
doc: ProseMirrorNode | null,
anchorPos: number,
headPos: number,
): { anchorCellPos: number; headCellPos: number } | null {
const anchor = resolveCellContext(doc, anchorPos);
const head = resolveCellContext(doc, headPos);
if (!anchor || !head) return null;
if (anchor.tablePos !== head.tablePos) return null; // different tables
if (anchor.cellPos === head.cellPos) return null; // same cell -> text selection
return { anchorCellPos: anchor.cellPos, headCellPos: head.cellPos };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

resolveCellContext is a hand-rolled version of cellAround($pos) from prosemirror-tables, and the same-table check duplicates its inSameTable — both are public exports, and cellAround/cellWrapping are already vendored in extensions/table/tableHelpers/. building on those would drop ~40 lines of traversal. same goes for the inline cellAncestor closure in SelectionHelpers.ts:204 (that one is cellWrapping). worth reusing, or was there a reason to avoid the prosemirror-tables helpers here?

Comment on lines +91 to +95
function dispatchKeepsHead(anchor, target) {
const doc = editor.state.doc;
const applied = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, anchor, target)));
return applied.selection.to === target;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dispatchKeepsHead compares selection.to to the target, which only holds for forward selections. all current cases are forward so this is fine today, but a backward-drag case added later would report a false collapse — a Math.max(anchor, target) or a short comment pinning the forward-only assumption would prevent that.

@luccas-harbour

Copy link
Copy Markdown
Contributor

hey @tupizz! nice work here, the selections are looking a lot better!

codex found this issue below and I can reproduce it in the browser. I can see that empty lines don't generate any selection rectangles and so you can't really tell that they are selected.

  • [P2] Preserve zero-width lines in DOM selection fallback — packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts:290-291
    When a mounted selection spans an empty paragraph/blank table-cell line, the DOM index entry for that line has pmStart === pmEnd. This new spansMultipleLines branch now forces such selections through collectClientRectsByLine, but that helper skips line groups where linePmEnd <= linePmStart, so the empty line still produces no rect and computeSelectionRectsFromDom returns a non-null, incomplete result instead of falling back to the layout rects that were just fixed. This affects the normal mounted-page path for selections crossing blank lines.

also, claude found a few more things that I added as inline comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants