Fix table row splitting: click, cursor, selection, and rendering#145
Conversation
Cursor navigation in shared documents crashes with "Block not found" when arrow keys reference blocks deleted by remote collaborators, or when stale blockParentMap data points to removed table rows. Three fixes: - getBlockType() uses findBlock (non-throwing) with paragraph fallback to prevent toolbar render crashes - handleKeyDown guards against stale cursor blockId, resetting to the first block when the referenced block no longer exists - handleArrow wrapped in try-catch to recover from stale blockParentMap data by invalidating layout for the next keypress - moveVertical cross-page jump now handles the case where the adjacent page only contains a tall table block, entering the table's last/first cell instead of returning the same position Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Describes the approach for splitting table rows at page boundaries so that tall rows (with nested tables, images, long text) render continuously instead of leaving blank space. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add cross-reference to the new row-splitting spec and summarize the key changes to the pagination algorithm. Also register the new design doc in the README index. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
9-task plan covering pagination, rendering, coordinate mapping, navigation, nested table recursion, and rowSpan handling. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extend the PageLine interface with optional metadata for table row splitting across page boundaries. No behaviour change yet. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a table row is split across pages, each fragment now gets its own TableRenderRange entry instead of being deduped. The rendering pipeline clips cell content to the fragment's visible area and re-draws borders at fragment boundaries so each page shows a clean rectangular table section. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The strict intersection approach failed for multi-column rows where columns have different line heights (no common breakpoints exist). The new findRowSplitHeight takes a target available height and returns the maximum safe split point by clamping to each cell's largest breakpoint within the available space, then taking the minimum. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The existing border rendering pass already draws cell borders within the clip region. The extra fragment border code drew redundant vertical lines that appeared as visual artifacts between columns. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The forward sweep in computeTableRangeForPageLine was including split fragment rows in the non-split render pass, causing the same row to render twice — once without split clipping and once with it. Stop the sweep before split fragments so each gets its own isolated render pass. Also remove debug logging from table-renderer. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a cell's content fits entirely within the available height, it imposes no constraint on the split point. Only cells with content exceeding the available height limit the split height. This prevents short header cells from forcing tiny 26px fragments. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a cell contains a nested table, findRowSplitHeight now walks the nested table's rows to find finer-grained split points instead of treating the entire nested table as an atomic unit. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
collectTableCellImageRects was not accounting for rowSplitOffset, causing image selection handles to appear offset from the actual image in split row continuations. Subtract splitOffset from cellY and skip images outside the visible fragment range. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
findRowSplitHeight was manually summing line heights starting from DEFAULT_CELL_PADDING, which could diverge from the layout engine's actual line.y values (which include block margins and spacing). Use the pre-computed line.y + line.height from LayoutTableCell.lines for accurate breakpoint positions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolveOffsetInCellAtXY cached row→page info via Map.set, which overwrote with the last fragment when a row was split across pages. Clicks on the first fragment used the second fragment's page Y, causing the resolved line to be off by one. Now picks the fragment whose visible range contains the click Y and subtracts the splitOffset from the absolute line Y calculation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolveLineAbsoluteY returned the first PageLine match for a split row, using the wrong page's Y offset. Now picks the fragment whose visible range contains the line's row-relative Y position, and subtracts splitOffset for correct absolute coordinates. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolvePositionPixel used the first PageLine match for a split row, returning coordinates from the wrong page. Now picks the fragment whose visible range contains the cursor line's Y position and subtracts splitOffset. Applies to both top-level and nested table cell paths. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Arrow Up/Down in table cells previously jumped between blocks, skipping visual lines within wrapped text. Add moveCellLine() that finds the current visual line in the cell's layout and moves to the adjacent line. Falls back to block/cell navigation when at the first/last line. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The up path used getParentTableBlock() to gate the layoutTable lookup, which always returned truthy for cell-internal blocks, causing tl to be undefined and skipping moveCellLine entirely. Use the same direct layoutTable lookup as the down path. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolveTableCellClick used pl.line.height (full row height) for hit testing, causing the first fragment to claim the entire row's click area. Split fragments now use pl.rowSplitHeight for their visible height so clicks land on the correct fragment. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Split table rows had multiple interrelated bugs that prevented clicking, cursor placement, and cell selection from working correctly when rows span page boundaries. Click resolution: - Fix labeled break in innerTableAbsY loop so the first matching fragment is used instead of being overwritten by later fragments - Account for split offset in nested table inner row resolution - Clamp target line to visible fragment when click falls in empty space below the last visible line - Return the correct block for deeper-nesting fallthrough instead of always returning blocks[0] - Guard against empty tables (rows=0) in resolveTableCellClick - Use rowSplitHeight in paginatedPixelToPosition so small split fragments don't claim the space of subsequent lines Cursor rendering (peer-cursor.ts): - Extract findSplitFragment helper to deduplicate fragment selection logic between nested and non-nested paths - Add straddling match for nested tables crossing split boundaries - Clamp cursor Y to fragment top when straddling produces a negative offset Selection highlighting: - Render nested table backgrounds in the first pass (renderTableBackgrounds) so they don't cover selection highlights - Support split row fragments in cell-range selection rects for both top-level and nested tables - Use labeled break in baseY computation for nested tables Other fixes: - Replace all content with empty paragraph on Cmd+A delete instead of leaving an empty table shell Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 40 minutes and 4 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThis PR implements table row splitting across page boundaries. It extends the pagination data model with Changes
Sequence Diagram(s)sequenceDiagram
participant Paginator as Paginator<br/>(pagination.ts)
participant LayoutUtil as Layout Util<br/>(table-layout.ts)
participant Renderer as Renderer<br/>(doc-canvas.ts)
participant Canvas as Canvas<br/>(table-renderer.ts)
participant Editor as Editor/Cursor<br/>(text-editor.ts)
Paginator->>LayoutUtil: findRowSplitHeight(row, availableHeight)
LayoutUtil-->>Paginator: safe split height or 0
alt Row fits on page
Paginator->>Paginator: emit single PageLine
else Row spans pages
Paginator->>Paginator: compute fragment 1 (rowSplitOffset=0)
Paginator->>Paginator: emit PageLine with rowSplitHeight
Paginator->>Paginator: compute fragment 2 (rowSplitOffset>0)
Paginator->>Paginator: emit PageLine with rowSplitHeight
end
Renderer->>Canvas: renderTableBackgrounds(rowSplitOffset, rowSplitHeight)
Canvas->>Canvas: ctx.clip() vertical slice
Canvas-->>Renderer: fragment background drawn
Renderer->>Canvas: renderTableContent(rowSplitOffset, rowSplitHeight)
Canvas->>Canvas: ctx.save() + ctx.translate(-rowSplitOffset)
Canvas->>Canvas: render content + borders
Canvas->>Canvas: ctx.restore()
Canvas-->>Renderer: fragment content drawn
Editor->>Editor: click at position (x, y)
Editor->>Editor: find PageLine fragment where<br/>y ∈ [splitOffset, splitOffset + splitHeight)
Editor->>Editor: adjust coordinates by -splitOffset
Editor-->>Editor: cell position resolved
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Resolve conflicts in peer-cursor.ts and selection.ts: keep split-row-aware fragment selection (findSplitFragment helper) over the non-split findPageLine approach from main. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Verification: verify:selfResult: ✅ PASS in 116.4s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
packages/docs/src/view/peer-cursor.ts (1)
161-165:⚠️ Potential issue | 🟠 MajorMake nested cursor Y relative to the outer row before fragment lookup.
yOffsetInTableincludes the top-level table’srowYOffsets[rowIndex], butfindSplitFragment()compares against row-localrowSplitOffset/rowSplitHeight. Nested cursors in any outer row after row 0 can resolve to the wrong fragment and render too low.🐛 Proposed fix
const outerRowIndex = nestingPath[0].rowIndex; - const cursorInRow = yOffsetInTable + targetLayout.runLineY; + const outerRowTop = lb.layoutTable.rowYOffsets[outerRowIndex] ?? 0; + const cursorInRow = yOffsetInTable + targetLayout.runLineY - outerRowTop;} if (outerCellData.blocks[bIdx]?.id === nextTableId) { - lineEndInRow = yOffsetInTable + outerCell.lines[li].height; + const outerPadding = outerCellData.style?.padding ?? 4; + const outerLineTop = + (lb.layoutTable.rowYOffsets[outerSeg.rowIndex] ?? 0) + + outerPadding + + outerCell.lines[li].y; + lineEndInRow = outerLineTop + outerCell.lines[li].height - outerRowTop; break; }Also applies to: 270-306
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/peer-cursor.ts` around lines 161 - 165, yOffsetInTable includes the outer table’s tl.rowYOffsets[rowIndex], causing nested cursors to use absolute table Y when calling findSplitFragment(); compute a row-local Y before fragment lookup by subtracting tl.rowYOffsets[rowIndex] (e.g. let yOffsetInRow = yOffsetInTable - tl.rowYOffsets[rowIndex]) and pass that row-local value to findSplitFragment()/any comparisons against rowSplitOffset/rowSplitHeight; apply the same fix in the other nested-cursor fragment-lookup block around the 270-306 region where the same pattern occurs.packages/docs/src/view/image-selection-overlay.ts (1)
359-365:⚠️ Potential issue | 🟠 MajorFilter nested-table image rects by the active split fragment.
Direct image runs are skipped when outside
[rowSplitOffset, rowSplitOffset + rowSplitHeight), but nested tables are always traversed. In a split outer row, this emits every nested image for every fragment and can overwrite the image’s map entry with coordinates from the wrong page fragment.Thread the visible fragment window into
collectNestedTableImageRects()or skip recursion when the nested-table line is outside the fragment.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/image-selection-overlay.ts` around lines 359 - 365, The nested-table traversal ignores the active split fragment, causing nested image rects from other fragments to be emitted; update the call site in the block that checks line.nestedTable and innerBlock to pass the current visible fragment window (rowSplitOffset and rowSplitHeight or equivalent bounds) into collectNestedTableImageRects, and modify collectNestedTableImageRects to accept these bounds and filter emitted rects (or skip recursion) when the nested table line falls outside [rowSplitOffset, rowSplitOffset + rowSplitHeight). Use the symbols collectNestedTableImageRects, line.nestedTable, innerBlock.tableData, rowSplitOffset and rowSplitHeight to locate and update both the caller and the callee so nested images are only collected for the active fragment.packages/docs/src/view/text-editor.ts (1)
2661-2692:⚠️ Potential issue | 🟠 MajorHandle full-document deletion before the same-block branch.
For a document containing only a table block,
startBlockIdx === endBlockIdx, so Cmd+A Delete takes the same-blockdeleteText(..., 0)path and leaves the table shell. Move the full-selection replacement check above the same-block branch.Suggested restructuring
const { start, end } = normalized; const startBlockIdx = this.doc.getBlockIndex(start.blockId); const endBlockIdx = this.doc.getBlockIndex(end.blockId); + const blocks = this.doc.document.blocks; + const lastBlock = this.doc.getBlock(end.blockId); + const isFullSelection = startBlockIdx === 0 + && endBlockIdx === blocks.length - 1 + && start.offset === 0 + && end.offset === getBlockTextLength(lastBlock); + + if (isFullSelection) { + const emptyBlock = createBlock(); + this.doc.insertBlockAt(0, emptyBlock); + const remaining = this.doc.document.blocks.length; + for (let i = remaining - 1; i > 0; i--) { + this.doc.deleteBlockByIndex(i); + } + this.cursor.moveTo({ blockId: emptyBlock.id, offset: 0 }); + this.selection.setRange(null); + this.requestRender(); + return true; + } if (startBlockIdx === endBlockIdx) { // Same block — mark dirty for incremental layout this.doc.deleteText(start, end.offset - start.offset); this.markDirty(start.blockId); } else { // Multi-block structural change — force full layout recompute this.invalidateLayout(); - - // Check if entire document is selected (all blocks from first to last, - // at offset 0 and end). Replace with a single empty paragraph. - const blocks = this.doc.document.blocks; const firstBlock = this.doc.getBlock(start.blockId); - const lastBlock = this.doc.getBlock(end.blockId); - const isFullSelection = startBlockIdx === 0 - && endBlockIdx === blocks.length - 1 - && start.offset === 0 - && end.offset === getBlockTextLength(lastBlock); - - if (isFullSelection) { - // Replace everything with a single empty paragraph - const emptyBlock = createBlock(); - this.doc.insertBlockAt(0, emptyBlock); - // Remove all original blocks (now shifted to indices 1..length-1) - const remaining = this.doc.document.blocks.length; - for (let i = remaining - 1; i > 0; i--) { - this.doc.deleteBlockByIndex(i); - } - this.cursor.moveTo({ blockId: emptyBlock.id, offset: 0 }); - this.selection.setRange(null); - this.requestRender(); - return true; - }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/text-editor.ts` around lines 2661 - 2692, The full-document selection replacement must be detected and handled before the same-block branch so a single-block document (e.g., a lone table) doesn't take the deleteText path; move the isFullSelection check (using startBlockIdx, endBlockIdx, start.offset === 0, end.offset === getBlockTextLength(lastBlock), and blocks.length) to run before the if (startBlockIdx === endBlockIdx) branch, and when true perform the same replacement steps (createBlock(), this.doc.insertBlockAt(0, emptyBlock), remove remaining blocks via this.doc.deleteBlockByIndex, move cursor via this.cursor.moveTo, clear selection via this.selection.setRange(null), this.requestRender(), and return true) so full-document deletion consistently replaces the whole document with a single empty paragraph regardless of block count.packages/docs/src/view/table-renderer.ts (1)
516-524:⚠️ Potential issue | 🟠 MajorDraw borders at the fragment bounds when a row is split.
With split clipping, continuation fragments translate the row content upward, but the border code still draws at the original row top/bottom. Those lines are clipped out for middle/continuation fragments, so the page fragment may not get the promised top/bottom borders.
Suggested adjustment
- const x = tableX + columnXOffsets[c]; - const y = tableY + rowYOffsets[visibleStart]; + const x = tableX + columnXOffsets[c]; + const y = isSplit && r === pageStart + ? tableY + rowYOffsets[pageStart] + rowSplitOffset! + : tableY + rowYOffsets[visibleStart]; + const borderHeight = isSplit && r === pageStart + ? rowSplitHeight! + : visibleHeight; // Use theme-aware default border color so borders adapt to dark mode const themeBorder: BorderStyle = { ...DEFAULT_BORDER_STYLE, color: Theme.defaultColor }; drawBorder(ctx, cell.style?.borderTop ?? themeBorder, x, y, x + cellWidth, y); - drawBorder(ctx, cell.style?.borderBottom ?? themeBorder, x, y + visibleHeight, x + cellWidth, y + visibleHeight); - drawBorder(ctx, cell.style?.borderLeft ?? themeBorder, x, y, x, y + visibleHeight); - drawBorder(ctx, cell.style?.borderRight ?? themeBorder, x + cellWidth, y, x + cellWidth, y + visibleHeight); + drawBorder(ctx, cell.style?.borderBottom ?? themeBorder, x, y + borderHeight, x + cellWidth, y + borderHeight); + drawBorder(ctx, cell.style?.borderLeft ?? themeBorder, x, y, x, y + borderHeight); + drawBorder(ctx, cell.style?.borderRight ?? themeBorder, x + cellWidth, y, x + cellWidth, y + borderHeight);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/table-renderer.ts` around lines 516 - 524, Borders are being drawn at the original row bounds so when rows are split across fragments the continuation fragments miss the top/bottom borders; update the border draw calls to use the fragment-adjusted vertical coordinates instead of the original row top/bottom. Specifically, compute the fragment-local top (e.g., fragmentTop = tableY + rowYOffsets[visibleStart] + fragmentTranslationY or use the already computed y for the fragment) and call drawBorder with that fragmentTop and fragmentTop + visibleHeight (using symbols tableX, columnXOffsets[c], cellWidth, visibleStart, visibleHeight, and drawBorder) so borders render at the fragment bounds for split rows.packages/docs/src/view/selection.ts (1)
378-386:⚠️ Potential issue | 🟠 MajorMake nested cell selection use the clicked split fragment too.
The
isNestedbranch still usesfindPageLine(...), which returns the first outer-row fragment. For a nested table inside a split outer row, selection rects for continuation-page content can be rendered on the first fragment instead of the fragment containingnestedYOff + runLineY.Suggested direction
if (isNested) { const outerRow = resolved.outerRowIndex; - const found = findPageLine(paginatedLayout, blockIndex, outerRow); - if (!found) return undefined; - return found.pageY + found.pageLine.y + nestedYOff - + tl.rowYOffsets[ownerRow] - + (runLineY - tl.rowYOffsets[ownerRow]); + const lineInOuterRow = nestedYOff + runLineY; + let bestResult: number | undefined; + for (const page of paginatedLayout.pages) { + const pageY = getPageYOffset(paginatedLayout, page.pageIndex); + for (const pl of page.lines) { + if (pl.blockIndex !== blockIndex || pl.lineIndex !== outerRow) continue; + const splitOffset = pl.rowSplitOffset ?? 0; + const visibleHeight = pl.rowSplitHeight ?? pl.line.height; + const absY = pageY + pl.y + lineInOuterRow - splitOffset; + if ( + pl.rowSplitHeight === undefined || + (lineInOuterRow >= splitOffset && lineInOuterRow < splitOffset + visibleHeight) + ) { + return absY; + } + bestResult = absY; + } + } + return bestResult; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/selection.ts` around lines 378 - 386, The isNested branch in resolveLineAbsoluteY currently uses findPageLine(blockIndex, outerRow) which returns the first outer-row fragment and causes continuation-page content to be placed on the wrong fragment; change the logic in resolveLineAbsoluteY to compute the target nested Y (const targetNestedY = nestedYOff + runLineY) and search the paginatedLayout for the outer-row fragment whose pageLine vertical span contains that target (i.e. fragment.pageLine.y <= targetNestedY < fragment.pageLine.y + fragment.pageLine.height or equivalent), then use that fragment's pageY and pageLine.y when computing the absolute Y instead of the first found fragment; refer to symbols resolveLineAbsoluteY, isNested, resolved.outerRowIndex, nestedYOff, runLineY, paginatedLayout, blockIndex and replace the single findPageLine call with a search that selects the fragment containing targetNestedY.
🧹 Nitpick comments (1)
packages/docs/test/view/table-row-split.test.ts (1)
40-51: Strengthen this test to catch unsafe multi-cell split heights.
toBeGreaterThanOrEqual(0)passes even whenfindRowSplitHeight()returns a height that cuts through one cell’s line. Add a mismatched-breakpoint case and assert the result is0or a boundary that is safe for every non-merged cell.🧪 Example test direction
- it('handles multi-column rows with different line heights', () => { + it('does not split multi-column rows at a mid-line height', () => { const block = createTableBlock(1, 2); const td = block.tableData!; - td.rows[0].cells[0].blocks[0].inlines = [{ text: 'A', style: {} }]; - td.rows[0].cells[1].blocks[0].inlines = [{ text: 'B', style: {} }]; + td.rows[0].cells[0].blocks = [ + { id: 'a1', type: 'paragraph', inlines: [{ text: 'A1', style: { fontSize: 16 } }], style: {} }, + { id: 'a2', type: 'paragraph', inlines: [{ text: 'A2', style: { fontSize: 16 } }], style: {} }, + ]; + td.rows[0].cells[1].blocks = [ + { id: 'b1', type: 'paragraph', inlines: [{ text: 'B1', style: { fontSize: 24 } }], style: {} }, + { id: 'b2', type: 'paragraph', inlines: [{ text: 'B2', style: { fontSize: 24 } }], style: {} }, + ]; const ctx = stubCtx(); const layout = computeTableLayout(td, 'tbl', ctx, 200); - const splitH = findRowSplitHeight(layout, 0, 100); + const splitH = findRowSplitHeight(layout, 0, 60); - // Should return a safe height (min of per-cell breakpoints) - expect(splitH).toBeGreaterThanOrEqual(0); + expect(splitH).toBe(0); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/view/table-row-split.test.ts` around lines 40 - 51, The test currently only asserts splitH >= 0 which won't catch unsafe splits through a cell's line; update the test that uses createTableBlock, computeTableLayout and findRowSplitHeight to include a mismatched-breakpoint scenario where two side-by-side cells have different line heights (e.g., one cell with single short inline and the other with a taller inline) using stubCtx so that their per-cell breakpoints differ, then call findRowSplitHeight for a split that would cut one cell’s line and assert the returned split height is 0 (or the safe boundary that avoids cutting any non-merged cell) to ensure the function rejects unsafe multi-cell splits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/docs/docs-pagination.md`:
- Around line 423-424: Update the sentence about merged-cell support to reflect
the actual shipped behavior: clarify that full rowSpan/colSpan support is not
implemented yet (mark as "limited support" or "future work") because the
split-height helper does not account for row-spanning owners from earlier rows
and the task plan still treats rowSpan splitting as separate work; reference the
split-height helper and the task plan behavior so readers know the limitation
and that proper multi-row span handling is pending.
In `@docs/tasks/active/20260419-table-row-splitting-todo.md`:
- Around line 63-66: The fenced command blocks containing git commands (e.g.,
the snippet starting with "git add packages/docs/src/view/pagination.ts" and
"git commit -m ...", and the other similar git command blocks) are missing
language tags; update each triple-backtick fence that surrounds git command
snippets to use the shell language tag (```shell) so markdownlint recognizes
them—search for fences that contain "git add", "git commit", and other git
commands and replace the opening ``` with ```shell for each occurrence.
- Line 1: Create the paired lessons file named
docs/tasks/active/20260419-table-row-splitting-lessons.md next to the existing
todo file (docs/tasks/active/20260419-table-row-splitting-todo.md) and populate
it with a concise summary of what was implemented, the key design decisions
(including reasons and trade-offs), notable pitfalls and how they were resolved,
testing/validation steps taken, and any follow-up actions or open questions so
future readers can understand rationale and lessons learned from the
table-row-splitting implementation.
In `@packages/docs/src/view/doc-canvas.ts`:
- Around line 56-61: computeTableRangeForPageLine() currently extends
endRowIndex across later rows even when the starting page line (pl) is a split
fragment; modify the inner loop that scans following lines (where it checks
nextPl.blockIndex === pl.blockIndex) to also break if the current pl is a split
fragment (pl.rowSplitOffset !== undefined) but the candidate nextPl is not a
split fragment (nextPl.rowSplitOffset === undefined), preventing normal rows
from being swept into the split-fragment render pass; apply the same guard to
the identical loops referenced (the other occurrences around lines 100-110 and
477-480) so collectTableRenderRanges()/computeTableRangeForPageLine() stop
before non-split rows when starting from a split fragment.
In `@packages/docs/src/view/table-layout.ts`:
- Around line 629-682: The code currently uses a global DEFAULT_CELL_PADDING and
can produce split heights that slice mid-line; change it to compute each cell's
safe breakpoint using that cell's actual padding (e.g., const cellPadding =
cell.padding ?? DEFAULT_CELL_PADDING) and ensure per-cell bestBp is always
chosen at line or nested-row boundaries (only assign bestBp when a full line end
or a nested safe innerSplit boundary is found, never a mid-line value), then
treat cells whose content entirely fits as non-constraining and compute the
returned minSafe as the minimum across only those per-cell safe breakpoints;
update references in this block (layout.cells[rowIndex], cell.merged,
cell.lines, line.y, line.height, line.nestedTable, nt.rowHeights,
nt.rowYOffsets, findRowSplitHeight) accordingly.
In `@packages/docs/src/view/text-editor.ts`:
- Around line 2463-2475: The current loop determining currentLine ignores the
caret's lineAffinity and treats an offset equal to the end of a visual run as
belonging to the previous wrapped line; update the logic in this block (the loop
using remaining, pos.offset, currentLine and layoutCell.lines[li].runs) to
consider pos.lineAffinity (or accept a lineAffinity parameter) so that when
remaining === lineChars you choose the next visual line for Forward affinity and
the current/previous line for Backward affinity; also ensure the same
lineAffinity value is threaded into any higher-level selection/text-editor
helpers that call this routine so wrap-boundary offsets consistently resolve to
the intended visual line.
---
Outside diff comments:
In `@packages/docs/src/view/image-selection-overlay.ts`:
- Around line 359-365: The nested-table traversal ignores the active split
fragment, causing nested image rects from other fragments to be emitted; update
the call site in the block that checks line.nestedTable and innerBlock to pass
the current visible fragment window (rowSplitOffset and rowSplitHeight or
equivalent bounds) into collectNestedTableImageRects, and modify
collectNestedTableImageRects to accept these bounds and filter emitted rects (or
skip recursion) when the nested table line falls outside [rowSplitOffset,
rowSplitOffset + rowSplitHeight). Use the symbols collectNestedTableImageRects,
line.nestedTable, innerBlock.tableData, rowSplitOffset and rowSplitHeight to
locate and update both the caller and the callee so nested images are only
collected for the active fragment.
In `@packages/docs/src/view/peer-cursor.ts`:
- Around line 161-165: yOffsetInTable includes the outer table’s
tl.rowYOffsets[rowIndex], causing nested cursors to use absolute table Y when
calling findSplitFragment(); compute a row-local Y before fragment lookup by
subtracting tl.rowYOffsets[rowIndex] (e.g. let yOffsetInRow = yOffsetInTable -
tl.rowYOffsets[rowIndex]) and pass that row-local value to
findSplitFragment()/any comparisons against rowSplitOffset/rowSplitHeight; apply
the same fix in the other nested-cursor fragment-lookup block around the 270-306
region where the same pattern occurs.
In `@packages/docs/src/view/selection.ts`:
- Around line 378-386: The isNested branch in resolveLineAbsoluteY currently
uses findPageLine(blockIndex, outerRow) which returns the first outer-row
fragment and causes continuation-page content to be placed on the wrong
fragment; change the logic in resolveLineAbsoluteY to compute the target nested
Y (const targetNestedY = nestedYOff + runLineY) and search the paginatedLayout
for the outer-row fragment whose pageLine vertical span contains that target
(i.e. fragment.pageLine.y <= targetNestedY < fragment.pageLine.y +
fragment.pageLine.height or equivalent), then use that fragment's pageY and
pageLine.y when computing the absolute Y instead of the first found fragment;
refer to symbols resolveLineAbsoluteY, isNested, resolved.outerRowIndex,
nestedYOff, runLineY, paginatedLayout, blockIndex and replace the single
findPageLine call with a search that selects the fragment containing
targetNestedY.
In `@packages/docs/src/view/table-renderer.ts`:
- Around line 516-524: Borders are being drawn at the original row bounds so
when rows are split across fragments the continuation fragments miss the
top/bottom borders; update the border draw calls to use the fragment-adjusted
vertical coordinates instead of the original row top/bottom. Specifically,
compute the fragment-local top (e.g., fragmentTop = tableY +
rowYOffsets[visibleStart] + fragmentTranslationY or use the already computed y
for the fragment) and call drawBorder with that fragmentTop and fragmentTop +
visibleHeight (using symbols tableX, columnXOffsets[c], cellWidth, visibleStart,
visibleHeight, and drawBorder) so borders render at the fragment bounds for
split rows.
In `@packages/docs/src/view/text-editor.ts`:
- Around line 2661-2692: The full-document selection replacement must be
detected and handled before the same-block branch so a single-block document
(e.g., a lone table) doesn't take the deleteText path; move the isFullSelection
check (using startBlockIdx, endBlockIdx, start.offset === 0, end.offset ===
getBlockTextLength(lastBlock), and blocks.length) to run before the if
(startBlockIdx === endBlockIdx) branch, and when true perform the same
replacement steps (createBlock(), this.doc.insertBlockAt(0, emptyBlock), remove
remaining blocks via this.doc.deleteBlockByIndex, move cursor via
this.cursor.moveTo, clear selection via this.selection.setRange(null),
this.requestRender(), and return true) so full-document deletion consistently
replaces the whole document with a single empty paragraph regardless of block
count.
---
Nitpick comments:
In `@packages/docs/test/view/table-row-split.test.ts`:
- Around line 40-51: The test currently only asserts splitH >= 0 which won't
catch unsafe splits through a cell's line; update the test that uses
createTableBlock, computeTableLayout and findRowSplitHeight to include a
mismatched-breakpoint scenario where two side-by-side cells have different line
heights (e.g., one cell with single short inline and the other with a taller
inline) using stubCtx so that their per-cell breakpoints differ, then call
findRowSplitHeight for a split that would cut one cell’s line and assert the
returned split height is 0 (or the safe boundary that avoids cutting any
non-merged cell) to ensure the function rejects unsafe multi-cell splits.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9e2bb14b-fa88-47f2-b0b8-de99ffc5b8df
📒 Files selected for processing (13)
docs/design/README.mddocs/design/docs/docs-pagination.mddocs/design/docs/docs-table-row-splitting.mddocs/tasks/active/20260419-table-row-splitting-todo.mdpackages/docs/src/view/doc-canvas.tspackages/docs/src/view/image-selection-overlay.tspackages/docs/src/view/pagination.tspackages/docs/src/view/peer-cursor.tspackages/docs/src/view/selection.tspackages/docs/src/view/table-layout.tspackages/docs/src/view/table-renderer.tspackages/docs/src/view/text-editor.tspackages/docs/test/view/table-row-split.test.ts
| @@ -0,0 +1,940 @@ | |||
| # Table Row Splitting Implementation Plan | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify that both active task files exist.
fd -a '20260419-table-row-splitting-(todo|lessons)\.md$' docs/tasks/activeRepository: wafflebase/wafflebase
Length of output: 138
Create the paired 20260419-table-row-splitting-lessons.md file.
This task requires a paired lessons file per the coding guidelines for non-trivial tasks. Currently only the todo file exists; add docs/tasks/active/20260419-table-row-splitting-lessons.md to document the key learnings and decisions from this implementation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/tasks/active/20260419-table-row-splitting-todo.md` at line 1, Create the
paired lessons file named
docs/tasks/active/20260419-table-row-splitting-lessons.md next to the existing
todo file (docs/tasks/active/20260419-table-row-splitting-todo.md) and populate
it with a concise summary of what was implemented, the key design decisions
(including reasons and trade-offs), notable pitfalls and how they were resolved,
testing/validation steps taken, and any follow-up actions or open questions so
future readers can understand rationale and lessons learned from the
table-row-splitting implementation.
| ``` | ||
| git add packages/docs/src/view/pagination.ts | ||
| git commit -m "Add rowSplitOffset/rowSplitHeight fields to PageLine" | ||
| ``` |
There was a problem hiding this comment.
Add shell language tags to command fences.
markdownlint flags these fenced command blocks as missing a language. Use ```shell for the git command snippets.
Also applies to: 185-188, 387-390, 548-550, 616-618, 658-660, 810-812, 902-904, 937-939
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 63-63: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/tasks/active/20260419-table-row-splitting-todo.md` around lines 63 - 66,
The fenced command blocks containing git commands (e.g., the snippet starting
with "git add packages/docs/src/view/pagination.ts" and "git commit -m ...", and
the other similar git command blocks) are missing language tags; update each
triple-backtick fence that surrounds git command snippets to use the shell
language tag (```shell) so markdownlint recognizes them—search for fences that
contain "git add", "git commit", and other git commands and replace the opening
``` with ```shell for each occurrence.
| const padding = DEFAULT_CELL_PADDING; | ||
| const cells = layout.cells[rowIndex]; | ||
| if (!cells || cells.length === 0) return 0; | ||
|
|
||
| let minSafe = availableHeight; | ||
| let hasCells = false; | ||
|
|
||
| for (let c = 0; c < cells.length; c++) { | ||
| const cell = cells[c]; | ||
| if (cell.merged) continue; | ||
| hasCells = true; | ||
|
|
||
| // Use the layout engine's actual line.y values (which include any | ||
| // block margins and spacing) instead of re-summing heights manually. | ||
| // Breakpoints are at padding + line.y + line.height for each line. | ||
| let bestBp = 0; | ||
| let allFit = true; | ||
| for (const line of cell.lines) { | ||
| const lineEnd = padding + line.y + line.height; | ||
| if (line.nestedTable) { | ||
| // Recurse into nested table: each row boundary is a breakpoint | ||
| const nt = line.nestedTable; | ||
| const ntBase = padding + line.y; // Y of nested table top within row | ||
| for (let nr = 0; nr < nt.rowHeights.length; nr++) { | ||
| const rowEnd = ntBase + nt.rowYOffsets[nr] + nt.rowHeights[nr]; | ||
| if (rowEnd <= availableHeight) { | ||
| bestBp = rowEnd; | ||
| } else { | ||
| const nestedAvail = availableHeight - ntBase - nt.rowYOffsets[nr]; | ||
| if (nestedAvail > 0) { | ||
| const innerSplit = findRowSplitHeight(nt, nr, nestedAvail); | ||
| if (innerSplit > 0) { | ||
| bestBp = ntBase + nt.rowYOffsets[nr] + innerSplit; | ||
| } | ||
| } | ||
| allFit = false; | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| if (lineEnd <= availableHeight) { | ||
| bestBp = lineEnd; | ||
| } else { | ||
| allFit = false; | ||
| } | ||
| } | ||
| if (!allFit) break; | ||
| } | ||
| if (!allFit) { | ||
| minSafe = Math.min(minSafe, bestBp); | ||
| } | ||
| // When allFit, this cell's content ends before availableHeight, | ||
| // so splitting at any height >= bestBp is safe — no constraint. | ||
| } |
There was a problem hiding this comment.
Return only split heights that are safe for every cell.
Math.min() across per-cell breakpoints can return a height that is mid-line for another cell. This also hardcodes DEFAULT_CELL_PADDING, so custom cell padding shifts the real safe boundaries. Both cases can split through visible content.
🐛 Directional fix
export interface LayoutTableCell {
lines: LayoutLine[];
blockBoundaries: number[];
width: number;
height: number;
+ padding: number;
merged: boolean;
}- cellRow.push({ lines, blockBoundaries, width: cellWidth, height: cellHeight, merged: false });
+ cellRow.push({ lines, blockBoundaries, width: cellWidth, height: cellHeight, padding, merged: false });- const padding = DEFAULT_CELL_PADDING;
const cells = layout.cells[rowIndex];
if (!cells || cells.length === 0) return 0;
- let minSafe = availableHeight;
+ const candidates = new Set<number>();
+ const perCellBreaks: number[][] = [];
let hasCells = false;
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
if (cell.merged) continue;
hasCells = true;
+ const padding = cell.padding;
+ const breaks: number[] = [];
- let bestBp = 0;
- let allFit = true;
for (const line of cell.lines) {
const lineEnd = padding + line.y + line.height;
+ if (lineEnd <= availableHeight) candidates.add(lineEnd);
+ breaks.push(lineEnd);
+ // Preserve the nested-table breakpoint handling here, pushing each
+ // nested safe boundary into both `breaks` and `candidates`.
+ }
+ perCellBreaks.push(breaks);
+ }
+
+ if (!hasCells) return 0;
+
+ const safe = [...candidates]
+ .filter((h) => h <= availableHeight)
+ .filter((h) => perCellBreaks.every((breaks) => breaks.includes(h) || h >= (breaks.at(-1) ?? 0)))
+ .sort((a, b) => a - b);
+
+ return safe.at(-1) ?? 0;
- ...
- }
- }
-
- return hasCells ? minSafe : 0;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/docs/src/view/table-layout.ts` around lines 629 - 682, The code
currently uses a global DEFAULT_CELL_PADDING and can produce split heights that
slice mid-line; change it to compute each cell's safe breakpoint using that
cell's actual padding (e.g., const cellPadding = cell.padding ??
DEFAULT_CELL_PADDING) and ensure per-cell bestBp is always chosen at line or
nested-row boundaries (only assign bestBp when a full line end or a nested safe
innerSplit boundary is found, never a mid-line value), then treat cells whose
content entirely fits as non-constraining and compute the returned minSafe as
the minimum across only those per-cell safe breakpoints; update references in
this block (layout.cells[rowIndex], cell.merged, cell.lines, line.y,
line.height, line.nestedTable, nt.rowHeights, nt.rowYOffsets,
findRowSplitHeight) accordingly.
- Respect lineAffinity in moveCellLine so ArrowUp/Down at wrap boundaries picks the correct visual line - Subtract outerRowTop from cursorInRow in nested table cursor rendering so rows after row 0 resolve to the correct fragment - Prevent split-fragment render pass from sweeping subsequent non-split rows into its clipped draw - Use per-cell padding in findRowSplitHeight instead of hardcoded DEFAULT_CELL_PADDING - Update docs-pagination.md to reflect that merged-cell splitting is not yet implemented Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
chart-pivot-range-shift (#147), table-row-splitting (#145), and table-copy-paste (#140) are all merged. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Key changes
Row splitting (
pagination.ts,table-layout.ts,table-renderer.ts,doc-canvas.ts):PageLinewithrowSplitOffset/rowSplitHeightmetadataClick resolution (
text-editor.ts):break findInnerTableY) so nested table Y computation stops at the correct fragmentblocks[0]Cursor rendering (
peer-cursor.ts):findSplitFragmenthelper for fragment selectionSelection highlighting (
selection.ts,table-renderer.ts):Other (
text-editor.ts):Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests