Skip to content

Add nested table support for Docs editor#128

Merged
hackerwins merged 27 commits into
mainfrom
feat/nested-tables
Apr 14, 2026
Merged

Add nested table support for Docs editor#128
hackerwins merged 27 commits into
mainfrom
feat/nested-tables

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add recursive table nesting in the Docs editor — cells can contain tables at any depth
  • Full editing support: click, cursor, selection, arrow navigation, Tab navigation, image resize
  • Character-level word break prevents text overflow in narrow cells
  • CRDT path resolution for Yorkie real-time collaboration with nested tables
  • Design doc, unit tests, and integration tests included

Changes (15 files, +1607/-279)

Core engine (data model, layout, rendering):

  • document.ts — recursive getBlock()/findBlock(), insertTableInCell(), deleteTableInCell()
  • table-layout.ts — recursive layoutCellBlocks(), resolveNestedTableLayout() helper, character-level word break
  • table-renderer.ts — recursive rendering for nested table content/backgrounds
  • layout.tsLayoutLine.nestedTable field
  • memory.ts — recursive block lookup in MemDocStore

Interaction (click, cursor, selection, navigation):

  • text-editor.ts — mouse click resolution, drag selection, arrow key navigation (all 4 directions), cell exit/entry for nested tables
  • peer-cursor.ts — cursor pixel position via nesting chain walk
  • selection.ts — selection normalization, line-by-line selection rects, cell-range highlight for nested tables
  • image-selection-overlay.ts — image rect collection for nested cells
  • editor.ts — insert/delete table in cells, image resize drag lookup

CRDT & design:

  • yorkie-doc-store.tsresolveTableTreePath() for nested table Yorkie Tree paths
  • Design docs updated (docs-tables.md, docs-table-crdt.md, docs-nested-tables.md)

Test plan

  • Insert table inside a table cell via toolbar
  • Click inside nested table cells — cursor appears at correct position
  • Drag selection within nested cell — line-by-line highlight
  • Double-click word selection in nested cell
  • Arrow keys (all 4 directions) navigate into/out of nested tables
  • Tab key cycles through inner table cells, then exits to outer table
  • Cell-range selection (drag across inner cells) highlights inner cells only
  • Image in nested cell — click shows resize handles
  • Long word in narrow nested cell — wraps at character boundary
  • Undo/redo works after nested table operations
  • pnpm verify:fast passes (558 tests)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Docs editor now supports inserting tables within table cells—create nested tables at any depth.
    • Full editing, navigation, and rendering for nested tables with seamless synchronization.
  • Tests

    • Added comprehensive test coverage for nested table operations and layout behavior.

hackerwins and others added 26 commits April 13, 2026 23:33
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]>
@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 41 minutes and 49 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 30508cd9-a240-4606-8b91-10677660986b

📥 Commits

Reviewing files that changed from the base of the PR and between 74d5c0c and f0fcdd2.

📒 Files selected for processing (4)
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/peer-cursor.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/table-layout.ts
📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Design & Documentation
docs/design/README.md, docs/design/docs/docs-nested-tables.md, docs/design/docs/docs-table-crdt.md, docs/design/docs/docs-tables.md, docs/tasks/active/20260413-nested-tables-todo.md
Added design specification for nested tables with recursive layout/rendering/CRDT requirements, updated existing design docs to mark nested tables as implemented (removing insertion blockers), and created comprehensive implementation task checklist with architecture details and affected components.
Document Model & Block Lookup
packages/docs/src/model/document.ts, packages/docs/src/store/memory.ts
Extended block lookup and retrieval to recursively search inside table cells via findBlockInCells helper; added public methods insertTableInCell and deleteTableInCell for nested table mutation; updated store persistence to traverse BlockParentMap for correct root-table updates.
Layout Computation
packages/docs/src/view/table-layout.ts, packages/docs/src/view/layout.ts
Made layoutCellBlocks and computeTableLayout mutually recursive; added nestedTable?: LayoutTable field to LayoutLine; introduced resolveNestedTableLayout API to map nested table block IDs to layout structures with accumulated coordinate offsets; added character-level word-breaking for over-wide text.
Rendering
packages/docs/src/view/table-renderer.ts, packages/docs/src/view/image-selection-overlay.ts
Updated table content rendering to detect and recursively render nested tables at computed coordinates; added nested table image rectangle collection with recursive coordinate transforms and merged-cell filtering.
Selection & Cursor
packages/docs/src/view/selection.ts, packages/docs/src/view/peer-cursor.ts
Extended selection rectangle computation to resolve nested tables and apply cumulative X/Y offsets from outer tables; updated peer cursor positioning to traverse nesting path, accumulate offsets, and split page-line resolution between outer and inner tables.
Editor & Navigation
packages/docs/src/view/editor.ts, packages/docs/src/view/text-editor.ts
Added logic to detect cursor inside table cell and route table insert/delete to nested methods; expanded arrow-key navigation to traverse into/out of nested tables with context-aware cell traversal; updated click-to-offset resolution to handle nested table rendering with recursive coordinate mapping.
CRDT Synchronization
packages/frontend/src/app/docs/yorkie-doc-store.ts
Introduced resolveTableTreePath to compute Yorkie tree edit paths for nested tables by recursively traversing block hierarchy; refactored table mutation methods (insertRow, deleteRow, insertColumn, deleteColumn, updateCell, updateAttrs) to use path-based edits targeting correct nested subtrees.
Tests
packages/docs/test/model/nested-table.test.ts, packages/docs/test/view/nested-table-layout.test.ts
Added comprehensive test suites validating nested table block lookup, blockParentMap construction, table insertion/deletion in cells, multi-level layout computation, and structural mutations within nested contexts.

Sequence Diagrams

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

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

Nested tables fit inside the cells, oh what a sight! 🐰
Recursive layouts bloom with coordinates so tight,
Each table knows its parent through the map we keep,
Renderers descend the nesting, coordinate stacks deep,
A rabbit's warren of data, tables within tables bright! 🌳✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add nested table support for Docs editor' clearly and concisely describes the main feature added in this PR—recursive nested table support—which aligns with the comprehensive changeset spanning design docs, model updates, layout, rendering, and editor interactions.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nested-tables

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 110.4s

Lane Status Duration
sheets:build ✅ pass 12.2s
docs:build ✅ pass 7.5s
verify:fast ✅ pass 55.8s
frontend:build ✅ pass 14.1s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.5s
cli:build ✅ pass 1.7s
verify:entropy ✅ pass 14.4s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented Apr 14, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai 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.

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 collectNestedTableImageRects function duplicates much of the logic from collectTableCellImageRects (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 — verify createTableCell() behavior.

Line 321 accesses outer.rows[0].cells[0].blocks[1] assuming the nested table is the second block. This relies on createTableCell() creating a default paragraph at index 0. If createTableCell() creates an empty blocks array, this test will fail with an undefined access.

Consider either:

  1. Explicitly documenting this assumption in a comment, or
  2. 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: Manual blockParentMap extension for 3-level nesting is fragile.

The test manually extends the map for level 2 (lines 1024-1030), but this duplicates the logic that buildParentMapRecursive should handle. If buildParentMapRecursive doesn't support arbitrary depth, the test is masking a real limitation.

Consider either:

  1. Making buildParentMapRecursive truly recursive (handle any depth), or
  2. 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 — hardcoded blocks[1] index.

Line 47 assumes the nested table is at index 1 in cell.blocks. If createTableCell() doesn't create a default paragraph at index 0, or if makeNestedTableData() 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.moveTo before recursion intentionally shifts the navigation context to the parent cell. This works correctly but relies on getCellInfo using the cursor's current blockId for 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] and lastCell.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 by createTableBlock, 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. Since createEmptyBlock() 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

📥 Commits

Reviewing files that changed from the base of the PR and between ca82303 and 74d5c0c.

📒 Files selected for processing (18)
  • docs/design/README.md
  • docs/design/docs/docs-nested-tables.md
  • docs/design/docs/docs-table-crdt.md
  • docs/design/docs/docs-tables.md
  • docs/tasks/active/20260413-nested-tables-todo.md
  • packages/docs/src/model/document.ts
  • packages/docs/src/store/memory.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/image-selection-overlay.ts
  • packages/docs/src/view/layout.ts
  • packages/docs/src/view/peer-cursor.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/table-layout.ts
  • packages/docs/src/view/table-renderer.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/model/nested-table.test.ts
  • packages/docs/test/view/nested-table-layout.test.ts
  • packages/frontend/src/app/docs/yorkie-doc-store.ts

Comment thread packages/docs/src/view/editor.ts
Comment thread packages/docs/src/view/peer-cursor.ts
Comment on lines +135 to +150
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment thread packages/docs/src/view/table-layout.ts Outdated
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]>
@hackerwins
hackerwins merged commit 32aa3be into main Apr 14, 2026
4 checks passed
@hackerwins
hackerwins deleted the feat/nested-tables branch April 14, 2026 05:02
hackerwins added a commit that referenced this pull request Apr 14, 2026
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]>
hackerwins added a commit that referenced this pull request Apr 14, 2026
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]>
hackerwins added a commit that referenced this pull request Apr 19, 2026
- random-axis-id (#127)
- axis-id-selection (#130)
- docs-image-editing Phase 1 (#121, #123)
- nested-tables (#128, #129)
- docs-mobile-toolbar (#132)
- release-v0.3.3 (#131)
- docs-image-editing lessons
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant