Add table support to Docs editor#92
Conversation
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]>
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]>
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]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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]>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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]>
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]>
There was a problem hiding this comment.
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.tsexcept (1)", but change area#6(lines 569-586) also touchespackages/docs/src/view/selection.ts. Consider updating to: "All changes are inpackages/docs/src/view/text-editor.tsexcept (1) and (6) which touchpackages/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
textorplaintext:📝 Suggested fix
-``` +```text Document → Block[] → Block.type = 'table', Block.tableData = TableDataApply 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.mdaround 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 usetext instead of plainso 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
inpackages/docs/src/view/text-editor.tsexcept (1)" to mention that change
area#6also 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.tsexcept (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 usetext instead of plainso
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 -->
| deleteTableRow: () => { | ||
| const ca = cursor.position.cellAddress; | ||
| if (!ca) return; | ||
| docStore.snapshot(); | ||
| doc.deleteRow(cursor.position.blockId, ca.rowIndex); | ||
| invalidateLayout(); | ||
| render(); |
There was a problem hiding this comment.
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.
| // 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; | ||
| } |
There was a problem hiding this comment.
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.
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]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
docs/tasks/active/20260329-table-crdt-phase-a-todo.md (3)
669-693: ReusegetCellTexthelper instead of duplicating logic.The
getCellTextandgetCellTextLengthimplementations intext-editor.tsduplicate the text extraction logic already defined intypes.ts(Task 1, Step 3, lines 96-98).Since
getCellText(cell: TableCell)is being exported fromtypes.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/getCellTextLengthByIdto 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
colsvalues to remove NaN entries. Ifattrs.colsis malformed or empty,columnWidthscould 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 === 0and 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.
parseBorderStylereturnsundefinedfor invalid input (line 440), and thestylefield is castas '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
editByPathusage 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
BlockParentMapconcept 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
📒 Files selected for processing (3)
docs/design/README.mddocs/design/docs-table-crdt.mddocs/tasks/active/20260329-table-crdt-phase-a-todo.md
✅ Files skipped from review due to trivial changes (1)
- docs/design/README.md
| # 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. | ||
|
|
There was a problem hiding this comment.
🧩 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/.
| - [ ] **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(); | ||
| } | ||
| ``` |
There was a problem hiding this comment.
🛠️ 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.
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Fix hardcoded default block and empty block ID in treeNodeToCell.
Line 541 has two issues:
-
Hardcoded block style instead of using
DEFAULT_BLOCK_STYLE:style: { alignment: 'left', lineHeight: 1.5, marginTop: 0, marginBottom: 0, textIndent: 0, marginLeft: 0 }
-
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]>
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
packages/docs/src/view/doc-canvas.tspackages/docs/src/view/editor.tspackages/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
| 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, |
There was a problem hiding this comment.
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.
Summary
Features
Table Engine (packages/docs)
TableData,TableCell,CellStyle,BorderStyletypesFrontend UI (packages/frontend)
__docsEditorconsole accessDesign Docs
docs/design/docs-tables.md— Table engine design with extensibility pathdocs/design/docs-table-ui.md— Frontend UI designTest plan
pnpm verify:fastpasses🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests