Add nested table support for Docs editor#128
Conversation
Recursive table nesting for the Docs editor — data model, layout, rendering, editing, CRDT synchronization, and pagination design. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
8-task plan covering data model, layout, rendering, editing, navigation, CRDT, integration tests, and design doc updates. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add findBlockInCells() that recursively walks the BlockParentMap chain so getBlock()/findBlock() can locate blocks at any nesting depth. Update updateBlockInStore() to persist nested cell mutations by walking up to the root-level table block. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Make layoutCellBlocks() handle block.type === 'table' by calling computeTableLayout() recursively, merging inner blockParentMap entries into the outer map. Extend LayoutLine with a nestedTable field for use by the rendering layer. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a layout line carries a nestedTable, renderTableContent() now calls renderTableBackgrounds() and renderTableContent() recursively for the inner table before continuing to the next line. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add insertTableInCell() and deleteTableInCell() to Doc class, and update the editor's insertTable()/deleteTable() to detect cell context for nested table support. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Verify that BlockParentMap correctly scopes Tab/arrow navigation to the direct parent table: inner blocks resolve to the inner table, and the inner table block itself resolves to the outer cell. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Table operations (insert/delete row/column, update cell/attrs) now support nested tables by resolving a full tree path through the parent table hierarchy instead of assuming top-level body index. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extend MemDocStore.findBlockInAnyArray to recursively search table cells so that insertRow, insertColumn, mergeCells, splitCell and text editing all work on nested (inner) table blocks. Add three integration tests covering row/column ops, merge/split, and 2-level deep text editing on nested tables. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Mark the nested-table non-goal as implemented in both docs-tables.md and docs-table-crdt.md. Update the Allowed Cell Content table to show 'table' blocks are now permitted in cells, referencing docs-nested-tables.md for the full design. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
insertTableInCell() now returns the Block object instead of just the ID. The editor was calling getBlock(innerTableId) immediately after insertion, but the BlockParentMap hadn't been rebuilt yet (it's rebuilt during layout), so the lookup failed. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolveOffsetInCellAtXY() now detects when a line contains a nestedTable and resolves the click into the inner table's cell by computing inner row/column from coordinates and finding the character offset within the inner cell's text runs. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolvePositionPixel() now walks up the BlockParentMap chain to find the top-level table, then navigates down through nested LayoutTables accumulating coordinate offsets to compute the correct cursor pixel position at any nesting depth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
updateDragSelection() now walks the BlockParentMap chain to find the outermost table, so drag selection within nested cells correctly enters the table selection path instead of falling through to the "mouse left the table" branch. Added findOuterCellAddress() helper that maps a nested block to its cell address within a specific outer table. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
buildRects() now handles nested table cells where the direct parent table is not a top-level layout block. Uses pixel coordinates from resolvePositionPixel() (which already handles nesting) to compute selection rectangles. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
normalizeRange() walked up the BlockParentMap chain to find the outermost table ID when looking up layout block indices. Previously it searched layout.blocks with the inner table ID which returned -1, causing the selection to be discarded as invalid. Also fixes buildRects() fallback for nested cells using pixel coordinates from resolvePositionPixel(). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The nested table cursor logic inadvertently changed the Y coordinate formula for regular (non-nested) table cells by adding an extra rowYOffsets term. Split the code path: non-nested cells use the original formula, nested cells use the accumulated offset formula. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveRight()/moveLeft() in cell context now detect when the adjacent block is a nested table and enter its first/last cell respectively, matching the existing behavior for top-level table navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveToNextCell()/moveToPrevCell() now detect when the current table is nested inside a parent cell. Instead of calling getBlockIndex() (which fails for non-top-level blocks), they navigate to the next/ previous block in the parent cell or to the adjacent cell in the outer table. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
ArrowUp/Down now handle nested tables in three scenarios: - Adjacent block is a nested table: enter its first/last row - At table boundary (first/last row): exit to parent cell or outer table instead of calling getBlockIndex on inner table ID - getPositionBeforeTable/AfterTable: walk parent cell for nested tables instead of searching top-level document blocks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveToPrevCell() and getPrevCellLastPosition() now use lastPositionInCell() helper that recursively enters nested tables when the last block of a target cell is a table, landing on the innermost cell's last text block instead of the table block itself. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When dragging across cells within the same inner table, the cell-range is now set on the inner table (not the outermost table). Previously, dragging from inner(0,0) to inner(0,1) would create a cell-range on the outer table, highlighting the entire outer cell. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extract resolveNestedTableLayout() helper in table-layout.ts that walks the nesting chain to find a nested table's LayoutTable and accumulated coordinate offsets. buildCellRangeRects() uses it to compute correct highlight rectangles for inner table cell-range selections. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
collectTableCellImageRects() now recurses into nested tables via collectNestedTableImageRects(), so images inside nested table cells are added to the rect map and become clickable/selectable. Also fix the drag image LayoutRun lookup in editor.ts to use resolveNestedTableLayout() instead of layout.blocks.find(), so resize dragging works for images in nested cells. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a single word is wider than the cell width, split it into character-level chunks that fit within the available space. This prevents text from overflowing cell boundaries in both regular and nested table cells, matching Google Docs behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
buildRects() now uses resolveNestedTableLayout() to find the inner LayoutTableCell and compute correct cell boundaries (cellLeftX, cellRightX) and line Y positions with nested coordinate offsets. Replaces the bounding-box fallback with proper line-by-line selection rects matching the non-nested table path. 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 41 minutes and 49 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 (4)
📝 WalkthroughWalkthroughThis PR adds comprehensive support for recursively nested tables in the Docs editor. Changes include: document model methods for nested table mutation, extended block lookup to traverse table cells, recursive layout computation with coordinate tracking, updated rendering for nested contexts, modified editor navigation/selection for nested scopes, CRDT path resolution for nested tree mutations, and supporting test suites. Changes
Sequence DiagramssequenceDiagram
actor User
participant Editor as Editor
participant Doc as Document Model
participant Store as Memory Store
participant Layout as Layout Engine
participant Renderer as Table Renderer
User->>Editor: insertTable() with cursor in cell
Editor->>Editor: Check blockParentMap for cell context
Editor->>Doc: insertTableInCell(blockId, rows, cols)
Doc->>Doc: findBlockInCells() to locate parent cell
Doc->>Store: updateBlockInStore() for root table
Store->>Store: findBlockInTableCells() to persist nested structure
Doc-->>Editor: Return new table block
Editor->>Layout: computeTableLayout() for outer table
Layout->>Layout: layoutCellBlocks() → detects nested table
Layout->>Layout: computeTableLayout() for nested table
Layout->>Layout: Merge blockParentMaps, compute offsets
Layout-->>Editor: Return LayoutTable with LayoutLines containing nestedTable
Editor->>Renderer: renderTableContent()
Renderer->>Renderer: Detect line.nestedTable
Renderer->>Renderer: Compute nestedX/nestedY with accumulated offsets
Renderer->>Renderer: renderTableBackgrounds() for nested
Renderer->>Renderer: renderTableContent() for nested
Renderer-->>User: Render nested table at offset position
sequenceDiagram
actor User
participant Editor as Text Editor
participant CursorResolve as Cursor Resolution
participant BlockMap as BlockParentMap
participant Layout as Layout Engine
User->>Editor: Press arrow key inside nested table cell
Editor->>Editor: Check layout.blockParentMap for context
Editor->>CursorResolve: Determine if moving to/from nested table
CursorResolve->>BlockMap: Walk blockParentMap upward to find table hierarchy
BlockMap-->>CursorResolve: Return nesting path and outer table
CursorResolve->>Layout: resolveNestedTableLayout() for outer table
Layout-->>CursorResolve: Return LayoutTable, offsets, outer row index
CursorResolve->>CursorResolve: Navigate within nesting context (same cell vs different cell vs exit nested)
CursorResolve->>Editor: Return target block ID and position
Editor->>Editor: Update cursor and re-render
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 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 |
Verification: verify:selfResult: ✅ PASS in 110.4s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (9)
packages/docs/src/view/image-selection-overlay.ts (1)
386-451: Consider extracting shared logic to reduce duplication.The
collectNestedTableImageRectsfunction duplicates much of the logic fromcollectTableCellImageRects(merged cell filtering, row-span handling, vertical alignment checks, block boundary tracking, image rect collection). This increases maintenance burden if the coordinate math or filtering logic needs to change.A future refactor could extract the common per-cell image collection logic into a shared helper, with the only difference being the coordinate origin calculation.
The implementation is correct and the coordinate math properly mirrors
renderTableContent. The recursive call at lines 429-436 correctly handles arbitrary nesting depth.🤖 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 386 - 451, collectNestedTableImageRects duplicates much of the per-cell logic in collectTableCellImageRects (merged cell check, rowSpan handling, verticalAlign check, blockBoundary/currentBlockIdx logic, and image rect calculation); extract that shared per-cell image-collection logic into a helper (e.g., collectImageRectsForCell) that accepts the cell data, layoutCell, origin offsets (cellX, cellY or padding-adjusted origin), and the out Map, then call that helper from both collectTableCellImageRects and collectNestedTableImageRects and keep the recursive nested-table call inside the helper (or invoke the helper before recursing) so coordinate math is centralized and duplication removed.docs/tasks/active/20260413-nested-tables-todo.md (2)
319-330: Test assumes nested table is at index 1 — verifycreateTableCell()behavior.Line 321 accesses
outer.rows[0].cells[0].blocks[1]assuming the nested table is the second block. This relies oncreateTableCell()creating a default paragraph at index 0. IfcreateTableCell()creates an emptyblocksarray, this test will fail with an undefined access.Consider either:
- Explicitly documenting this assumption in a comment, or
- Using a more robust lookup:
cell.blocks.find(b => b.type === 'table')♻️ Suggested defensive approach
it('should include inner table cell blocks in blockParentMap', () => { const { outer } = makeNestedTableData(); - const innerTable = outer.rows[0].cells[0].blocks[1]; // index 1 = nested table + const innerTable = outer.rows[0].cells[0].blocks.find(b => b.type === 'table'); + expect(innerTable).toBeDefined(); const innerCellBlockId = innerTable.tableData!.rows[0].cells[0].blocks[0].id;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/tasks/active/20260413-nested-tables-todo.md` around lines 319 - 330, The test in makeNestedTableData assumes the nested table sits at outer.rows[0].cells[0].blocks[1], which breaks if createTableCell() no longer inserts a default paragraph; update the test to find the nested table defensively by searching the cell's blocks (e.g., use outer.rows[0].cells[0].blocks.find(b => b.type === 'table') or similar) instead of indexing, and keep references to computeTableLayout, makeNestedTableData, and innerTable.tableData access unchanged so the rest of the test still validates blockParentMap correctly.
1021-1030: ManualblockParentMapextension for 3-level nesting is fragile.The test manually extends the map for level 2 (lines 1024-1030), but this duplicates the logic that
buildParentMapRecursiveshould handle. IfbuildParentMapRecursivedoesn't support arbitrary depth, the test is masking a real limitation.Consider either:
- Making
buildParentMapRecursivetruly recursive (handle any depth), or- Adding a comment explaining why manual extension is necessary for the test
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/tasks/active/20260413-nested-tables-todo.md` around lines 1021 - 1030, The test is manually extending map for a 3rd-level table which hides a limitation in buildParentMapRecursive; update buildParentMapRecursive so it truly walks nested tables to arbitrary depth: traverse document nodes, when encountering a table block iterate its rows/cells and add each cell block id to the parent map with tableBlockId/rowIndex/colIndex, and recurse into any nested table blocks found (so the current manual extension around innermostTable is no longer needed); if you choose not to make it recursive, add a clear comment above the manual extension explaining why buildParentMapRecursive cannot handle depth >2 and reference buildParentMapRecursive and innermostTable so future readers know why the map is augmented manually.packages/docs/test/view/nested-table-layout.test.ts (1)
45-54: Same fragility as the todo.md — hardcodedblocks[1]index.Line 47 assumes the nested table is at index 1 in
cell.blocks. IfcreateTableCell()doesn't create a default paragraph at index 0, or ifmakeNestedTableData()pushes the table differently, this will break.♻️ Suggested defensive lookup
it('should include inner table cell blocks in blockParentMap', () => { const { outer } = makeNestedTableData(); - const innerTable = outer.rows[0].cells[0].blocks[1]; // index 1 = nested table + const innerTable = outer.rows[0].cells[0].blocks.find(b => b.type === 'table'); + if (!innerTable) throw new Error('Expected nested table in cell'); const innerCellBlockId = innerTable.tableData!.rows[0].cells[0].blocks[0].id;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/view/nested-table-layout.test.ts` around lines 45 - 54, The test is fragile because it assumes the nested table lives at blocks[1]; instead, locate the nested table defensively by searching the cell's blocks for the block with type 'table' (or where block.tableData is defined) rather than using a hardcoded index; in the test that calls makeNestedTableData() and then references outer.rows[0].cells[0].blocks[1], replace that lookup with a find on outer.rows[0].cells[0].blocks to get innerTable (e.g., block => block.type === 'table' || Boolean(block.tableData)), then extract innerCellBlockId from innerTable.tableData.rows[0].cells[0].blocks[0].id and proceed with the same assertions on computeTableLayout and layout.blockParentMap.packages/docs/src/view/text-editor.ts (4)
3556-3561: Depth-2 nesting limitation in click resolution.Click-to-character resolution falls back to offset 0 for tables nested 3+ levels deep. While the fallback is graceful, users may find cursor placement imprecise in deeply nested scenarios.
Consider extracting this resolution logic into a recursive helper if deeper nesting support is needed in the future.
🤖 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 3556 - 3561, The current click-to-character resolution in the block lookup falls back to returning { blockId: innerCellData.blocks[0].id, offset: 0 } when innerTargetLine?.nestedTable is true, which forces offset 0 for depth-2+ nested tables; refactor by extracting this logic into a recursive helper (e.g., resolveNestedClick or similar) that takes innerCellLayout, innerLineIdx, and innerCellData and walks nestedTable chains until it finds a non-nested line or computes a proper offset, then return the found blockId and computed offset instead of always returning offset 0; update callers that reference the inline fallback to use the new helper so deeper nesting is handled consistently.
1877-1892: Column fallback to 0 may feel jarring for users.When entering a nested table from a column index that exceeds the nested table's column count, the cursor jumps to column 0 rather than clamping to the last column. For example, navigating down from column 3 into a 2-column nested table lands at column 0 instead of column 1.
Consider clamping to
Math.min(arrowCellInfo.colIndex, td.columnWidths.length - 1)for more intuitive behavior:♻️ Suggested fix for column clamping
- const lastCell = td.rows[lastRow].cells[arrowCellInfo.colIndex < td.columnWidths.length ? arrowCellInfo.colIndex : 0]; + const col = Math.min(arrowCellInfo.colIndex, td.columnWidths.length - 1); + const lastCell = td.rows[lastRow].cells[col];Apply similar changes to lines 1902, 1957, 1978-1979, and 2005-2006.
🤖 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 1877 - 1892, The cursor column fallback currently uses "arrowCellInfo.colIndex < td.columnWidths.length ? arrowCellInfo.colIndex : 0" which jumps to column 0 when the incoming column exceeds the nested table's columns; change this to clamp the column to the last valid index using Math.min(arrowCellInfo.colIndex, td.columnWidths.length - 1) when computing lastCell (and similarly adjust the other occurrences noted), so lastCell = td.rows[lastRow].cells[ Math.min(arrowCellInfo.colIndex, td.columnWidths.length - 1) ] and ensure newPos offset logic remains the same; update the same pattern wherever you handle nested table entry (the other occurrences the reviewer listed).
3686-3691: Cursor state modification before recursive call is subtle but correct.The temporary
cursor.moveTobefore recursion intentionally shifts the navigation context to the parent cell. This works correctly but relies ongetCellInfousing the cursor's currentblockIdfor table context resolution.A clarifying comment explaining this context-switching pattern would aid future maintainers.
📝 Suggested comment addition
// No more blocks in parent cell — navigate to the next cell in the // outer table. Temporarily place the cursor on the first block of // the parent cell so getCellInfo resolves to the outer table context. + // This context switch allows the recursive call to navigate the + // parent table rather than the nested table we're exiting. this.cursor.moveTo({ blockId: parentCell.blocks[0].id, offset: 0 }); return this.moveToNextCell(addRowAtEnd);🤖 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 3686 - 3691, Add a clarifying inline comment before the cursor context-switch to explain why we call this.cursor.moveTo({ blockId: parentCell.blocks[0].id, offset: 0 }) prior to the recursive this.moveToNextCell(addRowAtEnd) call: state that the temporary move sets the cursor's blockId so getCellInfo resolves the outer table context (not the current nested cell), and that this is intentional and temporary to allow moveToNextCell to operate on the parent cell; reference the symbols cursor.moveTo, parentCell.blocks[0].id, getCellInfo, and moveToNextCell to make the rationale explicit for future maintainers.
2776-2784: Safe nested table entry assuming table invariants hold.The direct access to
td.rows[lastRow].cells[lastCol]andlastCell.blocks[lastCell.blocks.length - 1]relies on the invariant that tables always have at least one row, column, and block per cell. This is enforced bycreateTableBlock, but a defensive check could prevent cryptic errors if invariants are violated by external mutations.🤖 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 2776 - 2784, The code assumes table invariants in the nested-table branch (variables td, lastRow, lastCol, lastCell, lastCellBlock) and directly accesses td.rows[lastRow].cells[lastCol] and lastCell.blocks[lastCell.blocks.length - 1]; add defensive checks to ensure td.rows.length > 0, td.columnWidths.length > 0, td.rows[lastRow] and td.rows[lastRow].cells[lastCol] exist, and that lastCell.blocks.length > 0 before reading the final block; if any check fails, fall back to a safe value (e.g., return { blockId: prevBlock.id, offset: 0 } or similar) and otherwise return the intended { blockId: lastCellBlock.id, offset: getBlockTextLength(lastCellBlock) } so getBlockTextLength is only called on a valid block.packages/docs/src/model/document.ts (1)
633-637: Cell invariant correctly maintained, but code can be more efficient.The code ensures the cell retains at least one block after table deletion. However, using
createTableCell().blocks[0]creates a full cell object just to extract one block. SincecreateEmptyBlock()is already imported in this file, use it directly for better efficiency:♻️ Suggested optimization
// Ensure cell still has at least one block if (parentCell.blocks.length === 0) { - const emptyBlock = createTableCell().blocks[0]; + const emptyBlock = createEmptyBlock(); parentCell.blocks.push(emptyBlock); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/model/document.ts` around lines 633 - 637, The code maintains the cell invariant by pushing a block when parentCell.blocks is empty, but it currently instantiates a full cell via createTableCell().blocks[0]; replace that with the already-imported createEmptyBlock() to avoid creating an unnecessary cell object—locate the conditional that checks parentCell.blocks.length === 0 and push createEmptyBlock() onto parentCell.blocks instead of createTableCell().blocks[0].
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 1855-1862: The branch handling inserting a nested table into a
cell ignores the caret offset, causing trailing text to end up above the new
table; before calling doc.insertTableInCell(pos.blockId, rows, cols) split the
current block at the caret offset (reuse the same split logic used in the
top-level insertion flow on the nearby branch) so the insertion point is the
split tail, then call insertTableInCell and move the cursor to the first inner
cell block (cursor.moveTo({ blockId: firstCellBlock.id, offset: 0 })), finally
call invalidateLayout() and render(); alternatively, extend
doc.insertTableInCell to accept an offset and pass pos.offset if you prefer that
API change.
In `@packages/docs/src/view/peer-cursor.ts`:
- Around line 124-159: The cell cursor path in peer-cursor.ts currently treats
wrap-boundary offsets as belonging to the previous visual line unconditionally;
update the logic that walks cell.lines (the loop over li from startLine to
endLine that computes targetLineIdx/lineHeight/cursorX using position.offset) to
respect position.lineAffinity: when offsetRemaining exactly equals lineChars at
a wrap boundary, choose the next visual line if position.lineAffinity indicates
downstream (or choose the previous for upstream) instead of always taking the
previous line. Adjust the boundary comparison (the if (offsetRemaining <=
lineChars) / offsetRemaining -= lineChars logic) to account for
equality+lineAffinity, and ensure the chosen targetLineIdx and subsequent
character/run calculations use that decision; also thread lineAffinity through
any helper calls (e.g., resolvePositionPixel and any selection helpers used for
cell cursor/selection endpoints) so wrap-aware positions are consistent.
In `@packages/docs/src/view/selection.ts`:
- Around line 135-150: The same-cell ordering logic uses
anchorCellInfo.tableBlockId/focusCellInfo.tableBlockId which breaks for nested
tables; update the lookups to use the resolved outermost IDs (anchorTopId and
focusTopId) when computing block indices and ordering (i.e., replace any
layout.blocks.find/findIndex that uses anchorCellInfo.tableBlockId or
focusCellInfo.tableBlockId with anchorTopId/focusTopId), and apply the same fix
to the similar code at the other occurrence (the block handling around the
159-162 region); ensure anchorIdx and focusIdx are computed from
anchorTopId/focusTopId via layout.blocks.findIndex so nested-cell selections
normalize correctly.
In `@packages/docs/src/view/table-layout.ts`:
- Around line 516-545: resolveNestedTableLayout is computing yOffset using only
tl.rowYOffsets[rowIndex] + padding + nestedLine.y, which misses merged-row
redistribution and vertical alignment used by renderTableContent
(lineAbsoluteY). Update the yOffset computation to use the same absolute line
position used by renderTableContent — i.e., replace nestedLine.y with the
precomputed absolute/lineAbsoluteY (or recompute using tl.rowYOffsets +
merged-row redistribution + verticalAlignment logic) so that nested tables align
identically; modify resolveNestedTableLayout (references:
resolveNestedTableLayout, nestedLine, renderTableContent, tl.rowYOffsets,
tl.columnXOffsets) to derive yOffset from the same absolute line Y used when
rendering nested tables.
---
Nitpick comments:
In `@docs/tasks/active/20260413-nested-tables-todo.md`:
- Around line 319-330: The test in makeNestedTableData assumes the nested table
sits at outer.rows[0].cells[0].blocks[1], which breaks if createTableCell() no
longer inserts a default paragraph; update the test to find the nested table
defensively by searching the cell's blocks (e.g., use
outer.rows[0].cells[0].blocks.find(b => b.type === 'table') or similar) instead
of indexing, and keep references to computeTableLayout, makeNestedTableData, and
innerTable.tableData access unchanged so the rest of the test still validates
blockParentMap correctly.
- Around line 1021-1030: The test is manually extending map for a 3rd-level
table which hides a limitation in buildParentMapRecursive; update
buildParentMapRecursive so it truly walks nested tables to arbitrary depth:
traverse document nodes, when encountering a table block iterate its rows/cells
and add each cell block id to the parent map with
tableBlockId/rowIndex/colIndex, and recurse into any nested table blocks found
(so the current manual extension around innermostTable is no longer needed); if
you choose not to make it recursive, add a clear comment above the manual
extension explaining why buildParentMapRecursive cannot handle depth >2 and
reference buildParentMapRecursive and innermostTable so future readers know why
the map is augmented manually.
In `@packages/docs/src/model/document.ts`:
- Around line 633-637: The code maintains the cell invariant by pushing a block
when parentCell.blocks is empty, but it currently instantiates a full cell via
createTableCell().blocks[0]; replace that with the already-imported
createEmptyBlock() to avoid creating an unnecessary cell object—locate the
conditional that checks parentCell.blocks.length === 0 and push
createEmptyBlock() onto parentCell.blocks instead of
createTableCell().blocks[0].
In `@packages/docs/src/view/image-selection-overlay.ts`:
- Around line 386-451: collectNestedTableImageRects duplicates much of the
per-cell logic in collectTableCellImageRects (merged cell check, rowSpan
handling, verticalAlign check, blockBoundary/currentBlockIdx logic, and image
rect calculation); extract that shared per-cell image-collection logic into a
helper (e.g., collectImageRectsForCell) that accepts the cell data, layoutCell,
origin offsets (cellX, cellY or padding-adjusted origin), and the out Map, then
call that helper from both collectTableCellImageRects and
collectNestedTableImageRects and keep the recursive nested-table call inside the
helper (or invoke the helper before recursing) so coordinate math is centralized
and duplication removed.
In `@packages/docs/src/view/text-editor.ts`:
- Around line 3556-3561: The current click-to-character resolution in the block
lookup falls back to returning { blockId: innerCellData.blocks[0].id, offset: 0
} when innerTargetLine?.nestedTable is true, which forces offset 0 for depth-2+
nested tables; refactor by extracting this logic into a recursive helper (e.g.,
resolveNestedClick or similar) that takes innerCellLayout, innerLineIdx, and
innerCellData and walks nestedTable chains until it finds a non-nested line or
computes a proper offset, then return the found blockId and computed offset
instead of always returning offset 0; update callers that reference the inline
fallback to use the new helper so deeper nesting is handled consistently.
- Around line 1877-1892: The cursor column fallback currently uses
"arrowCellInfo.colIndex < td.columnWidths.length ? arrowCellInfo.colIndex : 0"
which jumps to column 0 when the incoming column exceeds the nested table's
columns; change this to clamp the column to the last valid index using
Math.min(arrowCellInfo.colIndex, td.columnWidths.length - 1) when computing
lastCell (and similarly adjust the other occurrences noted), so lastCell =
td.rows[lastRow].cells[ Math.min(arrowCellInfo.colIndex, td.columnWidths.length
- 1) ] and ensure newPos offset logic remains the same; update the same pattern
wherever you handle nested table entry (the other occurrences the reviewer
listed).
- Around line 3686-3691: Add a clarifying inline comment before the cursor
context-switch to explain why we call this.cursor.moveTo({ blockId:
parentCell.blocks[0].id, offset: 0 }) prior to the recursive
this.moveToNextCell(addRowAtEnd) call: state that the temporary move sets the
cursor's blockId so getCellInfo resolves the outer table context (not the
current nested cell), and that this is intentional and temporary to allow
moveToNextCell to operate on the parent cell; reference the symbols
cursor.moveTo, parentCell.blocks[0].id, getCellInfo, and moveToNextCell to make
the rationale explicit for future maintainers.
- Around line 2776-2784: The code assumes table invariants in the nested-table
branch (variables td, lastRow, lastCol, lastCell, lastCellBlock) and directly
accesses td.rows[lastRow].cells[lastCol] and
lastCell.blocks[lastCell.blocks.length - 1]; add defensive checks to ensure
td.rows.length > 0, td.columnWidths.length > 0, td.rows[lastRow] and
td.rows[lastRow].cells[lastCol] exist, and that lastCell.blocks.length > 0
before reading the final block; if any check fails, fall back to a safe value
(e.g., return { blockId: prevBlock.id, offset: 0 } or similar) and otherwise
return the intended { blockId: lastCellBlock.id, offset:
getBlockTextLength(lastCellBlock) } so getBlockTextLength is only called on a
valid block.
In `@packages/docs/test/view/nested-table-layout.test.ts`:
- Around line 45-54: The test is fragile because it assumes the nested table
lives at blocks[1]; instead, locate the nested table defensively by searching
the cell's blocks for the block with type 'table' (or where block.tableData is
defined) rather than using a hardcoded index; in the test that calls
makeNestedTableData() and then references outer.rows[0].cells[0].blocks[1],
replace that lookup with a find on outer.rows[0].cells[0].blocks to get
innerTable (e.g., block => block.type === 'table' || Boolean(block.tableData)),
then extract innerCellBlockId from
innerTable.tableData.rows[0].cells[0].blocks[0].id and proceed with the same
assertions on computeTableLayout and layout.blockParentMap.
🪄 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: ec08ec8d-8234-4c97-b80d-73a97d3670f1
📒 Files selected for processing (18)
docs/design/README.mddocs/design/docs/docs-nested-tables.mddocs/design/docs/docs-table-crdt.mddocs/design/docs/docs-tables.mddocs/tasks/active/20260413-nested-tables-todo.mdpackages/docs/src/model/document.tspackages/docs/src/store/memory.tspackages/docs/src/view/editor.tspackages/docs/src/view/image-selection-overlay.tspackages/docs/src/view/layout.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/model/nested-table.test.tspackages/docs/test/view/nested-table-layout.test.tspackages/frontend/src/app/docs/yorkie-doc-store.ts
| // For nested tables, walk up the blockParentMap chain to find the | ||
| // outermost table ID that exists in layout.blocks. | ||
| let anchorTopId = anchorCellInfo?.tableBlockId ?? range.anchor.blockId; | ||
| while (anchorTopId && layout.blocks.findIndex((lb) => lb.block.id === anchorTopId) === -1) { | ||
| const parentInfo = layout.blockParentMap.get(anchorTopId); | ||
| if (!parentInfo) break; | ||
| anchorTopId = parentInfo.tableBlockId; | ||
| } | ||
| let focusTopId = focusCellInfo?.tableBlockId ?? range.focus.blockId; | ||
| while (focusTopId && layout.blocks.findIndex((lb) => lb.block.id === focusTopId) === -1) { | ||
| const parentInfo = layout.blockParentMap.get(focusTopId); | ||
| if (!parentInfo) break; | ||
| focusTopId = parentInfo.tableBlockId; | ||
| } | ||
| const anchorIdx = layout.blocks.findIndex((lb) => lb.block.id === anchorTopId); | ||
| const focusIdx = layout.blocks.findIndex((lb) => lb.block.id === focusTopId); |
There was a problem hiding this comment.
Use the nested table context when ordering same-cell selections.
After walking anchorTopId and focusTopId up to the outermost table, the same-cell branch still does layout.blocks.find(anchorCellInfo.tableBlockId). For nested tables that lookup is undefined, so aCbi and fCbi both fall back to 0 and cross-block selections inside the same nested cell can normalize in the wrong direction.
Possible fix
- const tableBlock = layout.blocks.find((b) => b.block.id === anchorCellInfo.tableBlockId);
- const cell = tableBlock?.block.tableData?.rows[anchorCellInfo.rowIndex]?.cells[anchorCellInfo.colIndex];
+ const resolved = resolveNestedTableLayout(anchorCellInfo.tableBlockId, layout);
+ const cell =
+ resolved?.dataBlock.tableData?.rows[anchorCellInfo.rowIndex]
+ ?.cells[anchorCellInfo.colIndex];Also applies to: 159-162
🤖 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 135 - 150, The same-cell
ordering logic uses anchorCellInfo.tableBlockId/focusCellInfo.tableBlockId which
breaks for nested tables; update the lookups to use the resolved outermost IDs
(anchorTopId and focusTopId) when computing block indices and ordering (i.e.,
replace any layout.blocks.find/findIndex that uses anchorCellInfo.tableBlockId
or focusCellInfo.tableBlockId with anchorTopId/focusTopId), and apply the same
fix to the similar code at the other occurrence (the block handling around the
159-162 region); ensure anchorIdx and focusIdx are computed from
anchorTopId/focusTopId via layout.blocks.findIndex so nested-cell selections
normalize correctly.
1. editor.ts: Split block at cursor offset before inserting nested table, so trailing text moves below the table (not above it) 2. peer-cursor.ts: Apply lineAffinity at wrap boundaries in cell cursor path — forward affinity now advances to the next visual line instead of staying on the previous one 3. selection.ts: Use resolveNestedTableLayout() for same-cell block ordering so cross-block selections in nested cells normalize correctly 4. table-layout.ts: resolveNestedTableLayout() now uses computeMergedCellLineLayouts() for Y offset computation, accounting for merged-row redistribution and vertical alignment Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The importer was flattening nested tables to plain text paragraphs since nested table support didn't exist yet. Now that nested tables are fully supported (#128), import them as real table blocks to preserve document structure. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The importer was flattening nested tables to plain text paragraphs since nested table support didn't exist yet. Now that nested tables are fully supported (#128), import them as real table blocks to preserve document structure. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Changes (15 files, +1607/-279)
Core engine (data model, layout, rendering):
document.ts— recursivegetBlock()/findBlock(),insertTableInCell(),deleteTableInCell()table-layout.ts— recursivelayoutCellBlocks(),resolveNestedTableLayout()helper, character-level word breaktable-renderer.ts— recursive rendering for nested table content/backgroundslayout.ts—LayoutLine.nestedTablefieldmemory.ts— recursive block lookup inMemDocStoreInteraction (click, cursor, selection, navigation):
text-editor.ts— mouse click resolution, drag selection, arrow key navigation (all 4 directions), cell exit/entry for nested tablespeer-cursor.ts— cursor pixel position via nesting chain walkselection.ts— selection normalization, line-by-line selection rects, cell-range highlight for nested tablesimage-selection-overlay.ts— image rect collection for nested cellseditor.ts— insert/delete table in cells, image resize drag lookupCRDT & design:
yorkie-doc-store.ts—resolveTableTreePath()for nested table Yorkie Tree pathsdocs-tables.md,docs-table-crdt.md,docs-nested-tables.md)Test plan
pnpm verify:fastpasses (558 tests)🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Tests