Skip to content

Add table support to Docs editor#92

Merged
hackerwins merged 44 commits into
mainfrom
feat/docs-table-support
Mar 29, 2026
Merged

Add table support to Docs editor#92
hackerwins merged 44 commits into
mainfrom
feat/docs-table-support

Conversation

@hackerwins

@hackerwins hackerwins commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add table block type to Docs word processor with full editing support
  • Toolbar grid picker (5x5 expandable to 10x10) for table insertion
  • Right-click context menu with row/column operations, cell styling, and icons
  • IME (Korean/Japanese/Chinese) composition support inside table cells
  • Seamless arrow key navigation across paragraph/table boundaries
  • Yorkie CRDT persistence for table data

Features

Table Engine (packages/docs)

  • Data model: TableData, TableCell, CellStyle, BorderStyle types
  • 12 Doc manipulation methods (insert/delete row/column, merge/split, cell styling)
  • Canvas layout computation with cell word-wrap and row height calculation
  • Canvas rendering (cell backgrounds, styled text, theme-aware borders)
  • Row-level pagination (tables split at row boundaries across pages)
  • Cursor navigation: click, Tab, Shift+Tab, Arrow keys, Enter
  • Shift+Arrow text selection inside cells

Frontend UI (packages/frontend)

  • Toolbar table insert button with progressive grid picker
  • Context menu with icons and group labels (Row/Column sections)
  • Cell background color picker
  • Yorkie serialization/deserialization for table data persistence
  • Dev mode __docsEditor console access

Design Docs

  • docs/design/docs-tables.md — Table engine design with extensibility path
  • docs/design/docs-table-ui.md — Frontend UI design

Test plan

  • Unit tests for table types and factories
  • Unit tests for Doc table manipulation methods (13 tests)
  • Unit tests for table layout computation (4 tests)
  • pnpm verify:fast passes
  • Manual: insert table via toolbar grid picker
  • Manual: type text in cells, navigate with Tab/Arrow keys
  • Manual: right-click context menu operations
  • Manual: Korean IME input in table cells
  • Manual: table persists after page refresh
  • Manual: dark mode border colors

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full table support in Docs: insert tables, edit/delete rows & columns, merge/split cells, apply cell background and inline styling, multi-cell selection, and correct multi-page rendering.
    • Toolbar grid picker and right-click table menu for quick insertion and table actions.
    • Cell-aware keyboard/mouse navigation (Tab/Enter/arrows), selection, and IME composition handling inside cells.
  • Documentation

    • Comprehensive design and implementation specs, including collaboration/CRDT guidance and migration plans.
  • Tests

    • New unit tests covering table model, layout, rendering, and selection.

hackerwins and others added 29 commits March 29, 2026 12:49
Add table-related types (BorderStyle, CellStyle, TableCell, TableRow,
TableData, CellAddress, CellRange) and factory functions (createTableCell,
createTableBlock) to the docs data model. Update BlockType to include
'table', add tableData to Block, and cellAddress to DocPosition.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add 12 public methods and 3 private helpers to the Doc class for table
operations: insertTable, insertTextInCell, deleteTextInCell,
applyCellInlineStyle, insertRow, deleteRow, insertColumn, deleteColumn,
mergeCells, splitCell, applyCellStyle, setColumnWidth. Includes full
test coverage in table.test.ts (13 tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Introduce computeTableLayout() that converts table data model into
spatial coordinates: column pixel widths from ratios, word-wrapped
cell line layouts, row heights with rowSpan distribution, and
cumulative X/Y offsets. Integrate into computeLayout() so table
blocks produce LayoutBlock with layoutTable attached.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add table-renderer module that draws cell backgrounds, text with
vertical alignment, and borders using pre-computed layout data.
Integrate into doc-canvas to bypass normal text rendering for table
blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Split table blocks into per-row pseudo-lines during pagination so that
tables taller than a single page break at row boundaries instead of
overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Route keyboard and mouse input through table-cell-aware logic when
the cursor is inside a table. Tab/Shift+Tab moves between cells,
Enter moves to the cell below, arrow keys navigate within and across
cells, and text input/backspace/delete operate on cell inlines via
the Doc cell methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add table manipulation methods (insertTable, insertTableRow,
deleteTableRow, insertTableColumn, deleteTableColumn, mergeCells,
splitCell, applyTableCellStyle, isInTable, getCellAddress) to the
EditorAPI interface and implement them in initialize(). Export
LayoutTable and LayoutTableCell types from the package index.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Allows testing table APIs from browser console:
  __docsEditor.insertTable(3, 4)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When the cursor is inside a table cell (cellAddress present on
DocPosition), IME composition handlers now use insertTextInCell /
deleteTextInCell instead of insertText / deleteText. This applies to
handleCompositionEnd, the composition-active branch of handleInput,
and applyHangulResult (software Hangul assembler).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Replace Radix ContextMenu wrapper with a plain positioned overlay.
The Radix approach was blocking pointer events on the editor canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolvePositionPixel() now handles positions with cellAddress by
computing coordinates from the table layout (cell offset + text
measurement within cell lines). Previously it fell through to the
empty-line fallback, placing the cursor at (0,0).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Table borders now use Theme.defaultColor instead of hardcoded #000000,
so they adapt to dark mode (light borders on dark background).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Replace per-button onMouseEnter/onClick with coordinate math on the
grid container. Fixes off-by-one selection where clicking 1x1 would
produce a 2x3 table due to Radix focus management interfering with
individual button hover states.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The click handler was computing local coordinates relative to the
layout block's continuous Y position, but mouse coordinates are in
paginated canvas space (with page gaps and margins). Now correctly
resolves the page offset and margin to produce accurate local
coordinates within the table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Table data (rows, cells, columnWidths, styles) is now JSON-serialized
into a Yorkie Tree attribute on write and parsed back on read. Without
this, table blocks lost their content on page refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
At the start of a cell, ArrowLeft now moves to the previous cell.
At the end of a cell, ArrowRight now moves to the next cell.
Reuses existing moveToNextCell/moveToPrevCell helpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Group insert actions (link + table) together in the toolbar,
before the block styles separator.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Grid starts at 5x5 and expands as the cursor approaches the edge,
up to 10x10. Dropdown closes automatically after selecting a size.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveToNextCell/moveToPrevCell were not calling requestRender(),
so the cursor only appeared on the next blink interval (~530ms).
Now triggers an immediate render after cell transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveToNextCell now takes addRowAtEnd parameter. Tab passes true
(adds row at last cell), ArrowRight passes false (exits table to
the next block).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
ArrowRight at end of paragraph enters table's first cell.
ArrowLeft at start of paragraph enters table's last cell.
ArrowLeft at table's first cell exits to previous block.
ArrowRight at table's last cell exits to next block.
Enables continuous navigation: para1 → 1x1 → ... → 3x3 → para2.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Add tabler icons for each menu item (row/column insert/delete, split, palette, table off)
- Group items under "Row" / "Column" section labels
- Remove destructive red styling, use consistent muted icon style
- Cell background color picker toggles on click instead of always visible
- Cleaner layout with smaller min-width

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The table arrow handler was unconditionally clearing the selection.
Now extends the selection range when Shift is held, matching standard
text selection behavior within a cell.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Mar 29, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds table support across Docs: new table block/type and types, Doc APIs for cell/table edits, layout/pagination and canvas rendering, editor/IME/caret routing and hit-testing for cells, frontend UI (grid picker, context menu), Yorkie serialization adjustments, tests, and design/task docs.

Changes

Cohort / File(s) Summary
Design & Tasks
docs/design/README.md, docs/design/docs-tables.md, docs/design/docs-table-ui.md, docs/design/docs-table-crdt.md, docs/tasks/active/20260329-docs-tables-todo.md, docs/tasks/active/20260329-docs-table-ui-todo.md, docs/tasks/active/20260329-table-crdt-phase-a-todo.md
Added detailed design specs, CRDT migration doc, UI design, and multi-phase implementation/task plans for table model, layout, rendering, editor behavior, IME routing, and serialization.
Model Types & Exports
packages/docs/src/model/types.ts, packages/docs/src/index.ts
Introduced 'table' block type, Block.tableData, DocPosition.cellAddress, new table types/constants/factories (TableData, TableRow, TableCell, CellStyle, BorderStyle, CellAddress, CellRange, DEFAULT_*, createTable*) and re-exported layout types.
Document API (Doc)
packages/docs/src/model/document.ts
Added comprehensive Doc table APIs and helpers: insertTable, insertTextInCell, deleteTextInCell, applyCellInlineStyle, insertRow/deleteRow, insertColumn/deleteColumn, mergeCells/splitCell, applyCellStyle, setColumnWidth, plus inline normalization and cell inline helpers.
Layout Computation
packages/docs/src/view/table-layout.ts, packages/docs/src/view/layout.ts
New computeTableLayout producing per-column pixel widths, per-cell wrapped lines, merged-cell handling and rowSpan adjustments; layout attaches layoutTable to LayoutBlock for table blocks.
Pagination
packages/docs/src/view/pagination.ts
Pagination updated to paginate table blocks by rows (one PageLine per table row) using computed row heights and available content width.
Canvas Rendering
packages/docs/src/view/table-renderer.ts, packages/docs/src/view/doc-canvas.ts
Added renderTable (backgrounds, text with vertical alignment, per-run decorations, borders, span handling) and integrated it in doc-canvas to render table blocks once per block/page, skipping standard text-run rendering.
Editor & Text Input
packages/docs/src/view/editor.ts, packages/docs/src/view/text-editor.ts
Extended EditorAPI with table-edit methods, routed IME composition and input/delete to cell-scoped Doc methods when cellAddress is present; added cell-aware navigation (Tab/Enter/arrows), mouse hit-testing, drag selection, and Hangul composition handling.
Peer Cursor / Selection
packages/docs/src/view/peer-cursor.ts, packages/docs/src/view/selection.ts
resolvePositionPixel resolves caret inside table cells using layoutTable and per-run metrics; selection normalization/buildRects handle intra-cell ranges; selected-text extraction supports cell-internal ranges.
Frontend UI & Integration
packages/frontend/src/app/docs/table-grid-picker.tsx, packages/frontend/src/app/docs/docs-table-context-menu.tsx, packages/frontend/src/app/docs/docs-formatting-toolbar.tsx, packages/frontend/src/app/docs/docs-view.tsx
Added TableGridPicker (10×10 hover grid) and DocsTableContextMenu (right-click table actions), wired toolbar dropdown and mounted context menu in DocsView.
Store Serialization
packages/frontend/src/app/docs/yorkie-doc-store.ts
Serializes block.tableData to attrs.tableData JSON and omits inline children for table blocks; deserialization parses attrs.tableData back into block.tableData.
Tests
packages/docs/test/model/table.test.ts, packages/docs/test/model/types.test.ts, packages/docs/test/view/table-layout.test.ts, packages/docs/test/view/table-selection.test.ts
New tests covering type factories, Doc table APIs (insert/text/row/column/merge/split/style), computeTableLayout outputs (column widths, row heights, merged flags), and cell text insert/delete/selection behaviors.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor User
    participant Toolbar as DocsFormattingToolbar
    participant Grid as TableGridPicker
    participant Editor as EditorAPI
    participant Doc as DocModel
    participant Layout as LayoutEngine
    participant Renderer as CanvasRenderer
    participant Page as PageDisplay

    User->>Toolbar: open Insert table
    Toolbar->>Grid: show grid
    User->>Grid: select (rows,cols)
    Grid->>Editor: insertTable(rows, cols)
    Editor->>Doc: insertTable(blockIndex, rows, cols)
    Doc-->>Editor: table block id
    Editor->>Layout: invalidateLayout()
    Layout->>Layout: computeLayout() → computeTableLayout(...)
    Layout-->>Editor: LayoutBlock with layoutTable
    Editor->>Renderer: render()
    Renderer->>Renderer: paginateLayout() (table -> rows)
    Renderer->>Renderer: renderTable(ctx, tableData, layoutTable, x, y)
    Renderer->>Page: canvas updates
    Page->>User: table displayed
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through rows and sketched each cell,

A tiny grid to pick where new tables dwell,
Right-click menus, painted borders bright,
IME and caret find their snug light,
The rabbit stamps a table — tidy and well! 📋✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add table support to Docs editor' is concise, clear, and directly summarizes the main change—adding a complete table block implementation with editing support to the Docs word processor.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/docs-table-support

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.

hackerwins and others added 6 commits March 29, 2026 13:58
When anchor and focus both have cellAddress, normalizeRange,
buildRects, and getSelectedText now use cell-relative coordinates
via resolvePositionPixel from peer-cursor.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The drag handler was using paginatedPixelToPosition's block-level
offset as a cell-internal offset. Extract resolveOffsetInCellAtX
so both click and drag resolve mouse coordinates to cell offsets.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Fix Tab at last cell cursor out of range (capture row index
  before insertRow mutates the array)
- Preserve cellAddress on Hangul assembler final cursor position
- Add ArrowUp/Down exit from table at first/last row
- Add deleteTable() to EditorAPI with snapshot for undo support;
  context menu now uses cursor's block instead of first table
- Validate tableData shape after JSON.parse in YorkieDocStore
- Render page-split tables on all pages (row-range renderTable)
- Adjust rowSpan/colSpan when deleting rows/columns with merges
- Add docs-table-ui.md to design doc index

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@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: 5

🧹 Nitpick comments (2)
docs/design/docs-tables.md (2)

413-416: Minor: Update file location claim for accuracy.

Lines 414-415 state "All changes are in packages/docs/src/view/text-editor.ts except (1)", but change area #6 (lines 569-586) also touches packages/docs/src/view/selection.ts. Consider updating to: "All changes are in packages/docs/src/view/text-editor.ts except (1) and (6) which touch packages/docs/src/view/selection.ts."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/docs-tables.md` around lines 413 - 416, Update the sentence that
currently reads "All changes are in `packages/docs/src/view/text-editor.ts`
except (1)" to mention that change area `#6` also touches selection.ts;
specifically edit the paragraph around the list of seven areas (the line
referencing "except (1)") to: "All changes are in
`packages/docs/src/view/text-editor.ts` except (1) and (6) which touch
`packages/docs/src/view/selection.ts`", ensuring the updated text appears in the
same block that lists the seven change areas.

40-42: Add language specifiers to fenced code blocks.

The fenced code blocks on lines 40-42 and 59-61 should specify a language for better syntax highlighting and accessibility. Since these represent structural diagrams, consider using text or plaintext:

📝 Suggested fix
-```
+```text
 Document → Block[] → Block.type = 'table', Block.tableData = TableData

Apply the same pattern to line 59:
```diff
-```
+```text
 Document → Block[] → Block.type = 'table' → cells[] → Block[]
</details>


As per coding guidelines, this improves documentation quality in `docs/design/` markdown files.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/design/docs-tables.md around lines 40 - 42, Update the two fenced code
blocks that show the structural diagrams so they include a language specifier
for syntax highlighting (use "text"); specifically change the blocks containing
"Document → Block[] → Block.type = 'table', Block.tableData = TableData" and
"Document → Block[] → Block.type = 'table' → cells[] → Block[]" to use text instead of plain so the diagrams render with the suggested highlighting.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @packages/docs/src/view/doc-canvas.ts:

  • Around line 170-198: The current page-slice loop for table blocks only renders
    rows between startRowIndex and endRowIndex which drops cells whose rowspan
    begins above startRowIndex; update the logic in the block that computes
    startRowIndex/endRowIndex (the PageLine scan using pl.blockIndex and plIndex) to
    detect table cells in lb.block.tableData with rowSpan that start before
    startRowIndex but extend into the current slice, and either (A) expand
    startRowIndex to include the originating row so the whole spanning region is
    rendered on one page, or (B) pass metadata into renderTable (call site here and
    its signature) indicating which cells have span origins above the slice (e.g.,
    spanOriginRow < startRowIndex) so renderTable can draw the continued portion
    (background/borders/text) for those spanning cells; adjust
    lb.layoutTable.rowYOffsets usage when computing tableOriginY so renderTable can
    compute clipped geometry correctly.

In @packages/docs/src/view/editor.ts:

  • Around line 924-930: After deleting a table row/column the caret's cellAddress
    can point to a non-existent cell; update the caret to a valid location after
    calling doc.deleteRow/doc.deleteColumn. Specifically, after docStore.snapshot()
    and doc.deleteRow(cursor.position.blockId, ca.rowIndex) (and likewise for
    deleteColumn), compute a new cellAddress by clamping the rowIndex/columnIndex to
    the table's new bounds (e.g., max(0, min(oldIndex, newRowCount-1))) and assign
    it to cursor.position.cellAddress; if the table has no rows/columns left, move
    the caret out of the table block (mark isInTable() false) to a sensible fallback
    position (e.g., start of the block or next block). Then call invalidateLayout()
    and render(). Use the existing symbols cursor.position.cellAddress,
    doc.deleteRow, doc.deleteColumn, isInTable(), invalidateLayout(), render(), and
    docStore.snapshot() to locate and apply this fix.
  • Around line 949-954: mergeTableCells currently merges cells but doesn't update
    the caret, causing it to disappear if the current position became a merged
    placeholder; after calling doc.mergeCells(...) in mergeTableCells, compute the
    surviving/top-left cell address from the provided CellRange (e.g., range.start
    row/col) and update the cursor position to that surviving cell (use the existing
    cursor API such as cursor.position = { blockId: , ... } or
    cursor.moveTo(...) depending on available helpers) so resolvePositionPixel() can
    find a valid pixel position; keep the snapshot/invalidateLayout/render calls
    as-is and perform the cursor update immediately after doc.mergeCells(...) and
    before invalidateLayout()/render().

In @packages/docs/src/view/selection.ts:

  • Around line 119-130: The current same-cell branch uses a single rect from
    startPixel to endPixel which breaks for wrapped text; instead detect
    per-visual-line positions from resolvePositionPixel (use any line/fragment
    indices or metrics available on startPixel/endPixel or the cell's layout in
    paginatedLayout/layout) and emit one rectangle per wrapped line: first line from
    startPixel.x to lineEndX, middle full-width lines across the cell, and last line
    from lineStartX to endPixel.x; iterate from start line to end line and construct
    rects using each line's y, height, and correct x/width so negative widths are
    avoided (update the code around resolvePositionPixel/startPixel/endPixel
    handling to produce multiple rects).
  • Around line 23-35: The current same-cell branch returns null for ranges that
    cross cells or drops cellAddress when only one endpoint has it; instead,
    normalize cross-cell and mixed-cell/block ranges by ordering endpoints across
    blockId, then cellAddress (rowIndex, colIndex), then offset, and return {start,
    end} preserving each endpoint's cellAddress when present (compare range.anchor
    vs range.focus using blockId, cellAddress.rowIndex/colIndex, then offset to
    decide order). Also ensure the case where only one endpoint has cellAddress does
    not fall through to the block-only ordering that discards cellAddress—preserve
    cellAddress on that endpoint in the returned start/end.

Nitpick comments:
In @docs/design/docs-tables.md:

  • Around line 413-416: Update the sentence that currently reads "All changes are
    in packages/docs/src/view/text-editor.ts except (1)" to mention that change
    area #6 also touches selection.ts; specifically edit the paragraph around the
    list of seven areas (the line referencing "except (1)") to: "All changes are in
    packages/docs/src/view/text-editor.ts except (1) and (6) which touch
    packages/docs/src/view/selection.ts", ensuring the updated text appears in the
    same block that lists the seven change areas.
  • Around line 40-42: Update the two fenced code blocks that show the structural
    diagrams so they include a language specifier for syntax highlighting (use
    "text"); specifically change the blocks containing "Document → Block[] →
    Block.type = 'table', Block.tableData = TableData" and "Document → Block[] →
    Block.type = 'table' → cells[] → Block[]" to use text instead of plain so
    the diagrams render with the suggested highlighting.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `66950e26-6960-4a31-a6ef-4245636df3da`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between e4074069d3528226f13143793c733b643ecdf102 and 68137cb3ca742451ba153c7bab038003f90cc840.

</details>

<details>
<summary>📒 Files selected for processing (11)</summary>

* `docs/design/README.md`
* `docs/design/docs-tables.md`
* `packages/docs/src/model/document.ts`
* `packages/docs/src/view/doc-canvas.ts`
* `packages/docs/src/view/editor.ts`
* `packages/docs/src/view/selection.ts`
* `packages/docs/src/view/table-renderer.ts`
* `packages/docs/src/view/text-editor.ts`
* `packages/docs/test/view/table-selection.test.ts`
* `packages/frontend/src/app/docs/docs-table-context-menu.tsx`
* `packages/frontend/src/app/docs/yorkie-doc-store.ts`

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (2)</summary>

* docs/design/README.md
* packages/docs/src/model/document.ts

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* packages/frontend/src/app/docs/yorkie-doc-store.ts
* packages/docs/src/view/text-editor.ts

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment thread packages/docs/src/view/doc-canvas.ts
Comment on lines +924 to +930
deleteTableRow: () => {
const ca = cursor.position.cellAddress;
if (!ca) return;
docStore.snapshot();
doc.deleteRow(cursor.position.blockId, ca.rowIndex);
invalidateLayout();
render();

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

Row/column deletion needs to re-home the caret.

After deleteRow / deleteColumn, cursor.position.cellAddress is left untouched. Deleting the last row or column leaves the caret out of bounds, so isInTable() still returns true but caret resolution and subsequent typing target a cell that no longer exists.

Also applies to: 941-947

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/editor.ts` around lines 924 - 930, After deleting a
table row/column the caret's cellAddress can point to a non-existent cell;
update the caret to a valid location after calling
doc.deleteRow/doc.deleteColumn. Specifically, after docStore.snapshot() and
doc.deleteRow(cursor.position.blockId, ca.rowIndex) (and likewise for
deleteColumn), compute a new cellAddress by clamping the rowIndex/columnIndex to
the table's new bounds (e.g., max(0, min(oldIndex, newRowCount-1))) and assign
it to cursor.position.cellAddress; if the table has no rows/columns left, move
the caret out of the table block (mark isInTable() false) to a sensible fallback
position (e.g., start of the block or next block). Then call invalidateLayout()
and render(). Use the existing symbols cursor.position.cellAddress,
doc.deleteRow, doc.deleteColumn, isInTable(), invalidateLayout(), render(), and
docStore.snapshot() to locate and apply this fix.

Comment thread packages/docs/src/view/editor.ts
Comment thread packages/docs/src/view/selection.ts Outdated
Comment on lines +23 to +35
// Cell-internal selection: both positions in same cell
if (range.anchor.cellAddress && range.focus.cellAddress) {
if (range.anchor.blockId === range.focus.blockId &&
range.anchor.cellAddress.rowIndex === range.focus.cellAddress.rowIndex &&
range.anchor.cellAddress.colIndex === range.focus.cellAddress.colIndex) {
if (range.anchor.offset <= range.focus.offset) {
return { start: range.anchor, end: range.focus };
}
return { start: range.focus, end: range.anchor };
}
// Different cells — no valid intra-cell selection
return null;
}

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

Normalize ranges that leave the anchor cell.

This branch only handles the same-cell case. As soon as the range crosses into another cell you return null, and if only one endpoint has cellAddress the code falls through to the block-only ordering below, which drops the cell context. Drag/Shift+Arrow selections that cross a cell or leave the table will therefore lose their highlight/text payload instead of extending correctly.

🤖 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 23 - 35, The current
same-cell branch returns null for ranges that cross cells or drops cellAddress
when only one endpoint has it; instead, normalize cross-cell and
mixed-cell/block ranges by ordering endpoints across blockId, then cellAddress
(rowIndex, colIndex), then offset, and return {start, end} preserving each
endpoint's cellAddress when present (compare range.anchor vs range.focus using
blockId, cellAddress.rowIndex/colIndex, then offset to decide order). Also
ensure the case where only one endpoint has cellAddress does not fall through to
the block-only ordering that discards cellAddress—preserve cellAddress on that
endpoint in the returned start/end.

Comment thread packages/docs/src/view/selection.ts Outdated
hackerwins and others added 3 commits March 29, 2026 17:06
Describes the migration from JSON-stringified tableData to Yorkie
Tree node hierarchy (row → cell → block → inline → text) for
concurrent editing support and Block[] container cells.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Data model (TableCell.blocks) + YorkieDocStore tree node
serialization. Phase B (editor/layout) will follow separately.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@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: 3

🧹 Nitpick comments (6)
docs/tasks/active/20260329-table-crdt-phase-a-todo.md (3)

669-693: Reuse getCellText helper instead of duplicating logic.

The getCellText and getCellTextLength implementations in text-editor.ts duplicate the text extraction logic already defined in types.ts (Task 1, Step 3, lines 96-98).

Since getCellText(cell: TableCell) is being exported from types.ts (Task 7, Step 1), import and reuse it:

♻️ Refactor to eliminate duplication
+import { getCellText } from '../model/types.js';

   private getCellTextLength(blockId: string, cell: CellAddress): number {
     const block = this.doc.getBlock(blockId);
     if (!block.tableData) return 0;
     const row = block.tableData.rows[cell.rowIndex];
     if (!row) return 0;
     const tc = row.cells[cell.colIndex];
     if (!tc) return 0;
-    return tc.blocks.flatMap(b => b.inlines).reduce((s, i) => s + i.text.length, 0);
+    return getCellText(tc).length;
   }

   private getCellText(blockId: string, cell: CellAddress): string {
     const block = this.doc.getBlock(blockId);
     if (!block.tableData) return '';
     const row = block.tableData.rows[cell.rowIndex];
     if (!row) return '';
     const tc = row.cells[cell.colIndex];
     if (!tc) return '';
-    return tc.blocks.flatMap(b => b.inlines).map(i => i.text).join('');
+    return getCellText(tc);
   }

Or rename the private methods to getCellTextById / getCellTextLengthById to clarify they do lookup + extraction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 669 -
693, The private TextEditor methods getCellText and getCellTextLength duplicate
extraction logic — import the exported getCellText(TableCell) from types.ts and
reuse it: in TextEditor locate the two methods (references: getCellText,
getCellTextLength, CellAddress, this.doc.getBlock) and change them to fetch the
block/row/cell (via this.doc.getBlock and row.cells[cell.colIndex]) to obtain
the TableCell, then call the imported getCellText to return text and compute
length (or return its .length), or alternatively rename the current methods to
getCellTextById/getCellTextLengthById to clarify they perform lookup+delegation
before invoking the shared types.ts getCellText.

505-517: Validate that parsed tables have at least one column.

Line 509 filters cols values to remove NaN entries. If attrs.cols is malformed or empty, columnWidths could become an empty array, which would break table rendering (tables must have ≥1 column).

🛡️ Add validation or fallback
     const cols = (attrs.cols ?? '').split(',').map(Number).filter(n => !isNaN(n));
+    if (cols.length === 0) {
+      // Fallback: single column if cols attribute is malformed
+      cols.push(1.0);
+    }
     return {

Alternatively, reject the block entirely if cols.length === 0 and log a warning, but a fallback is more resilient to malformed data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 505 -
517, The table-parsing code can yield an empty columnWidths array when
attrs.cols is missing/malformed; update the block handling (the blockType ===
'table' branch where cols is computed from attrs.cols and tableData is returned)
to validate cols and ensure at least one column: if the filtered cols array is
empty, either set a safe default (e.g., [100] or [1]) before constructing
tableData or reject the block by returning null/undefined and logging a warning
via the existing logging mechanism; make the change where cols is created and
before returning the object so tableData.columnWidths always has length ≥ 1.

437-453: Add validation for border style parsing.

parseBorderStyle returns undefined for invalid input (line 440), and the style field is cast as 'solid' | 'none' without validation. If the serialized border has an invalid style value (e.g., "1,dashed,#000"), this will silently pass through.

🛡️ Recommended fix with validation
 function parseBorderStyle(value: string): BorderStyle | undefined {
   const parts = value.split(',');
   if (parts.length !== 3) return undefined;
+  const style = parts[1];
+  if (style !== 'solid' && style !== 'none') return undefined;
-  return { width: Number(parts[0]), style: parts[1] as 'solid' | 'none', color: parts[2] };
+  return { width: Number(parts[0]), style: style as 'solid' | 'none', color: parts[2] };
 }

This ensures only valid border styles are parsed. Malformed borders are treated as undefined (no border), which is a safe fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 437 -
453, The parseBorderStyle function lacks validation for the style token and
numeric width and can silently accept invalid values; update parseBorderStyle to
validate that parts.length === 3, the width parses to a finite number, the style
token is exactly 'solid' or 'none', and the color is non-empty—otherwise return
undefined; then in parseCellStyle only assign
borderTop/borderBottom/borderLeft/borderRight when parseBorderStyle(...) returns
a defined BorderStyle (do not cast strings to 'solid'|'none' without checking).
Use the function names parseBorderStyle and parseCellStyle and types
BorderStyle/CellStyle to locate and change the logic.
docs/design/docs-table-crdt.md (3)

206-216: Clarify the rationale for Enter key behavior.

The navigation table states that Enter inserts a new paragraph block within the cell rather than moving to the next cell/row. This is a departure from spreadsheet-style UX (where Enter typically moves down) and aligns with word-processor behavior.

Consider expanding the explanation to note:

  • This matches Google Docs table behavior (multi-paragraph cells)
  • Users can still navigate cells with Tab/Shift+Tab or Arrow keys
  • This is intentional to support rich cell content (lists, headings, multiple paragraphs)

The current note is sufficient but a brief rationale strengthens the design.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/docs-table-crdt.md` around lines 206 - 216, Update the Enter key
row in the navigation table to include a brief rationale: state that Enter
creates a new paragraph inside the current cell (matching word-processor
behavior and Google Docs tables to support multi-paragraph cells), note that
users still navigate between cells using Tab/Shift+Tab or Arrow keys (which rely
on BlockParentMap for cell context), and explicitly call out this choice is
intentional to support rich cell content like lists, headings, and multiple
paragraphs.

292-308: Consider adding a concrete editByPath example.

The Granular Updates table clearly maps operations to Yorkie Tree calls, but editByPath usage is Yorkie-specific and would benefit from a code example. For instance:

// Example: Insert row via editByPath
doc.update((root) => {
  const tableNode = root.getElementByID(blockId);
  tableNode.insertAfter(rowIndex, {
    type: 'row',
    attributes: {},
    children: /* cell nodes */
  });
});

This helps bridge the gap between the design and Yorkie API usage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/docs-table-crdt.md` around lines 292 - 308, Add a concrete Yorkie
editByPath example to the "Granular Updates" section: show a short, explicit
sequence using doc.update, locating the table node (e.g.,
getElementByID(blockId) or root lookup), then calling editByPath/insertAfter to
insert a row node with cell children and/or editByPath on inline/text nodes for
cell edits; reference the existing symbols updateBlock, DocStore, editByPath,
getElementByID, insertAfter so readers can map the prose to the code paths and
clarify where DocStore should emit these granular tree edits versus wholesale
updateBlock replacement.

183-204: Recommend adding BlockParentMap construction example.

The BlockParentMap concept is well-defined, but the design would benefit from a code snippet showing how it's built during layout computation. Consider adding:

// Example: Building BlockParentMap during table layout
function buildBlockParentMap(tableData: TableData, tableBlockId: string): Map<string, BlockCellInfo> {
  const map = new Map<string, BlockCellInfo>();
  for (let r = 0; r < tableData.rows.length; r++) {
    for (let c = 0; c < tableData.rows[r].cells.length; c++) {
      const cell = tableData.rows[r].cells[c];
      for (const block of cell.blocks) {
        map.set(block.id, { tableBlockId, rowIndex: r, colIndex: c });
      }
    }
  }
  return map;
}

This helps clarify when/how the cache is populated and invalidated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/docs-table-crdt.md` around lines 183 - 204, Add a short example
showing how BlockParentMap is constructed and cached during layout computation:
describe a function (e.g. buildBlockParentMap) that walks the TableData
rows/cells and maps each cell block.id to a BlockCellInfo { tableBlockId,
rowIndex, colIndex }, and mention storing the resulting Map on DocumentLayout
(invalidate/rebuild when the table block is dirty). Reference BlockParentMap,
BlockCellInfo, buildBlockParentMap and DocumentLayout so readers know where to
place and invalidate the cache.
🤖 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/tasks/active/20260329-table-crdt-phase-a-todo.md`:
- Around line 532-546: treeNodeToCell currently falls back to a hardcoded block
with an empty id; replace that fallback with the canonical factory so defaults
and IDs are correct: when blocks.length === 0 return createTableCell().blocks
(or specifically use createTableCell() for the whole cell) instead of the inline
literal so the block style uses DEFAULT_BLOCK_STYLE and block IDs are generated
properly; update treeNodeToCell to use createTableCell()/DEFAULT_BLOCK_STYLE
rather than the hardcoded style and empty id while keeping style:
parseCellStyle(attrs) and colSpan/rowSpan handling.
- Around line 218-288: In mergeCells and splitCell replace the duplicated inline
block literal (the array with generateBlockId(), type:'paragraph',
inlines:[{text:'', style:{}}], style:{...DEFAULT_BLOCK_STYLE}) with a call to
the existing factory createTableCell() so covered/split cells are created
consistently; update the references in mergeCells (where you set cell.blocks =
...) and splitCell (covered.blocks = ...) to use createTableCell(), then keep
the rest of the logic (colSpan/rowSpan deletions, normalizeInlinesArray,
this.store.updateBlock, this.refresh) unchanged.
- Around line 1-14: Create a new paired lessons file named
docs/tasks/active/20260329-table-crdt-phase-a-lessons.md that accompanies the
existing 20260329-table-crdt-phase-a-todo.md; the lessons file should capture
learnings, gotchas, decisions, and retrospective notes for the Phase A migration
(data model: TableCell.inlines → TableCell.blocks, Doc method changes for
*InCell to block-level ops, and YorkieDocStore serialization/deserialization to
tree nodes) and follow the repository’s documentation format for other paired
lessons files in docs/tasks/active/.

---

Nitpick comments:
In `@docs/design/docs-table-crdt.md`:
- Around line 206-216: Update the Enter key row in the navigation table to
include a brief rationale: state that Enter creates a new paragraph inside the
current cell (matching word-processor behavior and Google Docs tables to support
multi-paragraph cells), note that users still navigate between cells using
Tab/Shift+Tab or Arrow keys (which rely on BlockParentMap for cell context), and
explicitly call out this choice is intentional to support rich cell content like
lists, headings, and multiple paragraphs.
- Around line 292-308: Add a concrete Yorkie editByPath example to the "Granular
Updates" section: show a short, explicit sequence using doc.update, locating the
table node (e.g., getElementByID(blockId) or root lookup), then calling
editByPath/insertAfter to insert a row node with cell children and/or editByPath
on inline/text nodes for cell edits; reference the existing symbols updateBlock,
DocStore, editByPath, getElementByID, insertAfter so readers can map the prose
to the code paths and clarify where DocStore should emit these granular tree
edits versus wholesale updateBlock replacement.
- Around line 183-204: Add a short example showing how BlockParentMap is
constructed and cached during layout computation: describe a function (e.g.
buildBlockParentMap) that walks the TableData rows/cells and maps each cell
block.id to a BlockCellInfo { tableBlockId, rowIndex, colIndex }, and mention
storing the resulting Map on DocumentLayout (invalidate/rebuild when the table
block is dirty). Reference BlockParentMap, BlockCellInfo, buildBlockParentMap
and DocumentLayout so readers know where to place and invalidate the cache.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md`:
- Around line 669-693: The private TextEditor methods getCellText and
getCellTextLength duplicate extraction logic — import the exported
getCellText(TableCell) from types.ts and reuse it: in TextEditor locate the two
methods (references: getCellText, getCellTextLength, CellAddress,
this.doc.getBlock) and change them to fetch the block/row/cell (via
this.doc.getBlock and row.cells[cell.colIndex]) to obtain the TableCell, then
call the imported getCellText to return text and compute length (or return its
.length), or alternatively rename the current methods to
getCellTextById/getCellTextLengthById to clarify they perform lookup+delegation
before invoking the shared types.ts getCellText.
- Around line 505-517: The table-parsing code can yield an empty columnWidths
array when attrs.cols is missing/malformed; update the block handling (the
blockType === 'table' branch where cols is computed from attrs.cols and
tableData is returned) to validate cols and ensure at least one column: if the
filtered cols array is empty, either set a safe default (e.g., [100] or [1])
before constructing tableData or reject the block by returning null/undefined
and logging a warning via the existing logging mechanism; make the change where
cols is created and before returning the object so tableData.columnWidths always
has length ≥ 1.
- Around line 437-453: The parseBorderStyle function lacks validation for the
style token and numeric width and can silently accept invalid values; update
parseBorderStyle to validate that parts.length === 3, the width parses to a
finite number, the style token is exactly 'solid' or 'none', and the color is
non-empty—otherwise return undefined; then in parseCellStyle only assign
borderTop/borderBottom/borderLeft/borderRight when parseBorderStyle(...) returns
a defined BorderStyle (do not cast strings to 'solid'|'none' without checking).
Use the function names parseBorderStyle and parseCellStyle and types
BorderStyle/CellStyle to locate and change the logic.
🪄 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: b77f0bef-7f1b-4647-8660-8d275784b808

📥 Commits

Reviewing files that changed from the base of the PR and between 68137cb and 1b1e94a.

📒 Files selected for processing (3)
  • docs/design/README.md
  • docs/design/docs-table-crdt.md
  • docs/tasks/active/20260329-table-crdt-phase-a-todo.md
✅ Files skipped from review due to trivial changes (1)
  • docs/design/README.md

Comment on lines +1 to +14
# Table CRDT Phase A: Data Model + Store — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace JSON-stringified `tableData` with Yorkie Tree node hierarchy (`row → cell → block → inline → text`) and change `TableCell` from `Inline[]` to `Block[]` containers.

**Architecture:** Three layers of change: (1) data model types — `TableCell.inlines` → `TableCell.blocks`, (2) `Doc` class — replace `*InCell` methods with block-level operations that work on cell blocks, (3) `YorkieDocStore` — serialize/deserialize table as tree nodes instead of JSON attribute. This phase does NOT touch the editor or layout/rendering — those continue to work via a thin adapter that extracts `Inline[]` from the first block in each cell until Phase B updates them.

**Tech Stack:** TypeScript, Vitest, Yorkie Tree CRDT

**Spec:** [docs/design/docs-table-crdt.md](../../design/docs-table-crdt.md)

**Prerequisites:** Merge current `feat/docs-table-support` PR first. Start this work on a new branch.

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for the paired lessons.md file
fd -i "20260329-table-crdt-phase-a-lessons.md" docs/tasks/active/

Repository: wafflebase/wafflebase

Length of output: 47


Create the paired 20260329-table-crdt-phase-a-lessons.md file.

Per coding guidelines, non-trivial tasks must use paired files in docs/tasks/active/. This Phase A refactor (multi-package CRDT data model migration) requires a corresponding lessons file to capture learnings, gotchas, and retrospective notes alongside the todo file.

Create docs/tasks/active/20260329-table-crdt-phase-a-lessons.md.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 1 - 14,
Create a new paired lessons file named
docs/tasks/active/20260329-table-crdt-phase-a-lessons.md that accompanies the
existing 20260329-table-crdt-phase-a-todo.md; the lessons file should capture
learnings, gotchas, decisions, and retrospective notes for the Phase A migration
(data model: TableCell.inlines → TableCell.blocks, Doc method changes for
*InCell to block-level ops, and YorkieDocStore serialization/deserialization to
tree nodes) and follow the repository’s documentation format for other paired
lessons files in docs/tasks/active/.

Comment on lines +218 to +288
- [ ] **Step 4: Update `mergeCells` to use cell blocks**

Replace `cell.inlines` references with `cell.blocks`:

```typescript
mergeCells(blockId: string, range: CellRange): void {
const block = this.getBlock(blockId);
const td = block.tableData!;
const { start, end } = range;
const topLeft = td.rows[start.rowIndex].cells[start.colIndex];

const rowSpan = end.rowIndex - start.rowIndex + 1;
const colSpan = end.colIndex - start.colIndex + 1;

// Collect text from all cells in range (row-major, skip top-left)
for (let r = start.rowIndex; r <= end.rowIndex; r++) {
for (let c = start.colIndex; c <= end.colIndex; c++) {
if (r === start.rowIndex && c === start.colIndex) continue;
const cell = td.rows[r].cells[c];
const cellText = getCellText(cell);
if (cellText.length > 0) {
// Append non-empty inlines from the first block to top-left's first block
const srcBlock = cell.blocks[0];
if (srcBlock) {
topLeft.blocks[0].inlines.push(
...srcBlock.inlines.filter((i) => i.text.length > 0),
);
}
}
// Mark as covered
cell.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
cell.colSpan = 0;
cell.rowSpan = undefined;
}
}

topLeft.blocks[0].inlines = this.normalizeInlinesArray(topLeft.blocks[0].inlines);
topLeft.colSpan = colSpan;
topLeft.rowSpan = rowSpan;
this.store.updateBlock(blockId, block);
this.refresh();
}
```

- [ ] **Step 5: Update `splitCell` to use cell blocks**

```typescript
splitCell(blockId: string, cell: CellAddress): void {
const block = this.getBlock(blockId);
const td = block.tableData!;
const target = td.rows[cell.rowIndex].cells[cell.colIndex];
const rowSpan = target.rowSpan ?? 1;
const colSpan = target.colSpan ?? 1;

delete target.colSpan;
delete target.rowSpan;

for (let r = cell.rowIndex; r < cell.rowIndex + rowSpan; r++) {
for (let c = cell.colIndex; c < cell.colIndex + colSpan; c++) {
if (r === cell.rowIndex && c === cell.colIndex) continue;
const covered = td.rows[r].cells[c];
delete covered.colSpan;
delete covered.rowSpan;
covered.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
}
}

this.store.updateBlock(blockId, block);
this.refresh();
}
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace duplicated block creation with createTableCell() factory.

Lines 248 and 281 duplicate the same inline block creation:

cell.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];

Since createTableCell() already produces a cell with an empty paragraph block, use it consistently:

♻️ Refactor to use factory

For line 248 (mergeCells):

-        cell.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
+        const emptyCell = createTableCell();
+        cell.blocks = emptyCell.blocks;
         cell.colSpan = 0;

For line 281 (splitCell):

-        covered.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
+        const emptyCell = createTableCell();
+        covered.blocks = emptyCell.blocks;

This eliminates duplication and ensures consistency with the factory definition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 218 -
288, In mergeCells and splitCell replace the duplicated inline block literal
(the array with generateBlockId(), type:'paragraph', inlines:[{text:'',
style:{}}], style:{...DEFAULT_BLOCK_STYLE}) with a call to the existing factory
createTableCell() so covered/split cells are created consistently; update the
references in mergeCells (where you set cell.blocks = ...) and splitCell
(covered.blocks = ...) to use createTableCell(), then keep the rest of the logic
(colSpan/rowSpan deletions, normalizeInlinesArray, this.store.updateBlock,
this.refresh) unchanged.

Comment on lines +532 to +546
function treeNodeToCell(node: TreeNode): TableCell {
const el = node as ElementNode;
const attrs = (el.attributes ?? {}) as Record<string, string>;
const blocks = (el.children ?? [])
.filter((c) => c.type === 'block')
.map(treeNodeToBlock);
return {
blocks: blocks.length > 0
? blocks
: [{ id: '', type: 'paragraph', inlines: [{ text: '', style: {} }], style: { alignment: 'left', lineHeight: 1.5, marginTop: 0, marginBottom: 0, textIndent: 0, marginLeft: 0 } }],
style: parseCellStyle(attrs),
colSpan: attrs.colSpan ? Number(attrs.colSpan) : undefined,
rowSpan: attrs.rowSpan ? Number(attrs.rowSpan) : undefined,
};
}

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

Fix hardcoded default block and empty block ID in treeNodeToCell.

Line 541 has two issues:

  1. Hardcoded block style instead of using DEFAULT_BLOCK_STYLE:

    style: { alignment: 'left', lineHeight: 1.5, marginTop: 0, marginBottom: 0, textIndent: 0, marginLeft: 0 }
  2. Empty block ID (id: '') which could cause issues if block IDs must be unique/non-empty.

🔧 Proposed fix
   return {
     blocks: blocks.length > 0
       ? blocks
-      : [{ id: '', type: 'paragraph', inlines: [{ text: '', style: {} }], style: { alignment: 'left', lineHeight: 1.5, marginTop: 0, marginBottom: 0, textIndent: 0, marginLeft: 0 } }],
+      : [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }],
     style: parseCellStyle(attrs),

Alternatively, use createTableCell().blocks to ensure consistency with the factory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md` around lines 532 -
546, treeNodeToCell currently falls back to a hardcoded block with an empty id;
replace that fallback with the canonical factory so defaults and IDs are
correct: when blocks.length === 0 return createTableCell().blocks (or
specifically use createTableCell() for the whole cell) instead of the inline
literal so the block style uses DEFAULT_BLOCK_STYLE and block IDs are generated
properly; update treeNodeToCell to use createTableCell()/DEFAULT_BLOCK_STYLE
rather than the hardcoded style and empty id while keeping style:
parseCellStyle(attrs) and colSpan/rowSpan handling.

- Re-home cursor after deleteRow/deleteColumn to prevent
  out-of-bounds cellAddress
- Move cursor to top-left cell after mergeCells
- Reject mixed cellAddress selections in normalizeRange
  (one side in cell, other side outside)
- Support multi-line selection rects inside table cells
- Include rowSpan owner rows when rendering page-split tables

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@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: 1

🤖 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/selection.ts`:
- Around line 149-177: The selection horizontal bounds are computed from
tl.columnPixelWidths[colIndex], which clips wrapped selections inside
merged/owner cells; update the cellRightX (and cellLeftX if needed) calculation
in selection.ts to use the merged owner's laid-out box or full span width
instead of a single-column width: derive the owner start column and colspan from
start.cellAddress (or the corresponding
lb!.block.tableData.rows[rowIndex].cells[colIndex] metadata) and sum
tl.columnPixelWidths across the span or read the cell's layout box from the
laid-out table data to compute correct left/right X; ensure this fixes
negative-width first-rect cases when resolvePositionPixel places startPixel.x
past the first-column edge, and add a regression test that selects multiple
lines within a merged cell (multi-line wrapped selection) to validate the fix.
🪄 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: c74b53e3-a0ee-4fe4-9a1a-ccf83c762ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 1b1e94a and 50d6095.

📒 Files selected for processing (3)
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/selection.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/src/view/doc-canvas.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/docs/src/view/editor.ts

Comment on lines +149 to +177
const { rowIndex, colIndex } = start.cellAddress;
const cellPadding = lb!.block.tableData?.rows[rowIndex]?.cells[colIndex]?.style.padding ?? 4;
const cellLeftX = startPixel.x - (startPixel.x - (getPageXOffset(paginatedLayout, canvasWidth) + paginatedLayout.pageSetup.margins.left + tl.columnXOffsets[colIndex] + cellPadding));
const cellRightX = getPageXOffset(paginatedLayout, canvasWidth) + paginatedLayout.pageSetup.margins.left + tl.columnXOffsets[colIndex] + tl.columnPixelWidths[colIndex] - cellPadding;

const cellRects: Array<{ x: number; y: number; width: number; height: number }> = [];
// First line: from start to cell right edge
cellRects.push({
x: startPixel.x,
y: startPixel.y,
width: cellRightX - startPixel.x,
height: startPixel.height,
});
// Middle lines: full cell width
let midY = startPixel.y + startPixel.height;
while (midY < endPixel.y) {
cellRects.push({
x: cellLeftX,
y: midY,
width: cellRightX - cellLeftX,
height: startPixel.height, // approximate line height
});
midY += startPixel.height;
}
// Last line: from cell left edge to end
cellRects.push({
x: cellLeftX,
y: endPixel.y,
width: endPixel.x - cellLeftX,

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 merged owner’s actual bounds for wrapped cell selections.

Line 152 only uses tl.columnPixelWidths[colIndex] for cellRightX, so a wrapped selection inside a merged owner cell is clipped to the first column. Once resolvePositionPixel() places startPixel.x past that boundary, the first rect can even become negative-width. Please derive the horizontal bounds from the laid-out cell box or full span width, and add a regression test for a multi-line selection inside a merged cell.

🤖 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 149 - 177, The selection
horizontal bounds are computed from tl.columnPixelWidths[colIndex], which clips
wrapped selections inside merged/owner cells; update the cellRightX (and
cellLeftX if needed) calculation in selection.ts to use the merged owner's
laid-out box or full span width instead of a single-column width: derive the
owner start column and colspan from start.cellAddress (or the corresponding
lb!.block.tableData.rows[rowIndex].cells[colIndex] metadata) and sum
tl.columnPixelWidths across the span or read the cell's layout box from the
laid-out table data to compute correct left/right X; ensure this fixes
negative-width first-rect cases when resolvePositionPixel places startPixel.x
past the first-column edge, and add a regression test that selects multiple
lines within a merged cell (multi-line wrapped selection) to validate the fix.

@hackerwins
hackerwins merged commit 5c94b82 into main Mar 29, 2026
3 checks passed
@hackerwins
hackerwins deleted the feat/docs-table-support branch March 29, 2026 09:02
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