Add table copy-paste support in Docs editor#140
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Enable copying and pasting table cell ranges in the Docs editor. When a cell range is selected, the clipboard carries TableCell[][] data. Paste into a table overwrites cells from the cursor position (clamped to table bounds). Paste outside a table creates a new table block. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
getSelectedBlocks() was cloning table blocks without preserving tableData, causing tables to disappear when pasting content that spans across table blocks (e.g. select-all → copy → paste). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 45 minutes and 55 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR implements table cell range copy-paste functionality for the Docs editor by extending clipboard serialization with a Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Editor as Text Editor
participant Selection as Selection Handler
participant Clipboard as Clipboard Module
participant OS as OS Clipboard
User->>Editor: Copy table cell range
Editor->>Selection: Detect tableCellRange selection
Selection-->>Editor: Return table cell coordinates
Editor->>Clipboard: getSelectedTableCells()
Clipboard-->>Editor: Return TableCell[][]
Editor->>Clipboard: cloneTableCells(cells)
Clipboard-->>Editor: Return cloned TableCell[][]
Editor->>Clipboard: serializeClipboard({ blocks: [], tableCells })
Clipboard-->>Editor: Return JSON string
Editor->>OS: Set WAFFLEDOCS_MIME + text/plain
OS-->>User: Clipboard updated
sequenceDiagram
participant User
participant Editor as Text Editor
participant OS as OS Clipboard
participant Clipboard as Clipboard Module
participant Table as Table Block
User->>Editor: Paste with cursor in/outside table
Editor->>OS: Read WAFFLEDOCS_MIME
OS-->>Editor: Return JSON string
Editor->>Clipboard: deserializeClipboard(json)
Clipboard-->>Editor: Return ClipboardData with tableCells
alt tableCells present
Editor->>Editor: pasteTableCells(tableCells)
alt Cursor inside table
Editor->>Table: Calculate target cell coords (with clamping)
Editor->>Table: Update cells via doc.updateCellBlocks()
Editor->>Editor: Move cursor to last pasted cell
else Cursor outside table
Editor->>Table: createTableBlock(rows, cols)
Editor->>Table: Populate with cloned cells
Editor->>Editor: Move cursor to last pasted cell
end
else tableCells absent
Editor->>Editor: Use existing block insertion logic
end
Editor-->>User: Table cells pasted
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Verification: verify:selfResult: ✅ PASS in 121.3s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
packages/docs/src/view/text-editor.ts (1)
2607-2625: Consolidate withcloneTableCellsto avoid divergent deep-clone logic.This block duplicates most of
cloneTableCells's cell-clone body (style/spans/per-block regenerate-id + shallow inline/style copy). Calling the helper here keeps the deep-clone contract in one place and automatically benefits from any nested-tableData recursion added to it (see thecloneTableCellscomment).♻️ Suggested refactor
if (block.tableData) { cloned.tableData = { columnWidths: [...block.tableData.columnWidths], ...(block.tableData.rowHeights ? { rowHeights: [...block.tableData.rowHeights] } : {}), - rows: block.tableData.rows.map(row => ({ - cells: row.cells.map(cell => ({ - style: { ...cell.style }, - ...(cell.colSpan != null ? { colSpan: cell.colSpan } : {}), - ...(cell.rowSpan != null ? { rowSpan: cell.rowSpan } : {}), - blocks: cell.blocks.map(b => ({ - ...b, - id: generateBlockId(), - inlines: b.inlines.map(il => ({ text: il.text, style: { ...il.style } })), - style: { ...b.style }, - })), - })), - })), + rows: block.tableData.rows.map(row => ({ + cells: cloneTableCells([row.cells])[0], + })), }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/text-editor.ts` around lines 2607 - 2625, The table deep-clone code duplicates the cell-cloning logic; replace the duplicated mapping in the if (block.tableData) branch by calling the existing cloneTableCells helper so cloning logic is centralized. Specifically, set cloned.tableData.columnWidths and optional rowHeights as before, but use cloneTableCells(block.tableData.rows) (or the helper's expected input) to produce rows/cells instead of reimplementing cells/blocks/inline copying and generateBlockId usage; ensure compatibility with generateBlockId and the helper's contract so nested tableData recursion and id regeneration remain handled in one place.packages/docs/src/view/clipboard.ts (1)
51-65:cloneTableCellsdoes not deep-clone nested tabletableData.When a cell's
blockscontains a block withtype: 'table', the{ ...b, id, inlines, style }spread keeps the originaltableData(rows/cells/columnWidths/rowHeights) by reference. In the current PR flow, clipboard contents always pass throughJSON.stringify→JSON.parse, so this works end-to-end; but the helper is a public export andpasteTableCellscalls it per destination cell on already-deserialized data. A later refactor that usescloneTableCellson live in-memory cells — or any code that mutates a cloned cell's nested table — would silently mutate the source.Either deep-clone
tableDatainside the block mapper, or document thatcloneTableCellsis shallow w.r.t. nested tables and only safe after a JSON round-trip.♻️ Suggested fix
export function cloneTableCells(cells: TableCell[][]): TableCell[][] { - return cells.map(row => - row.map(cell => ({ - style: { ...cell.style }, - ...(cell.colSpan != null ? { colSpan: cell.colSpan } : {}), - ...(cell.rowSpan != null ? { rowSpan: cell.rowSpan } : {}), - blocks: cell.blocks.map(b => ({ - ...b, - id: generateBlockId(), - inlines: b.inlines.map(il => ({ text: il.text, style: { ...il.style } })), - style: { ...b.style }, - })), - })) - ); + const cloneBlock = (b: Block): Block => ({ + ...b, + id: generateBlockId(), + inlines: b.inlines.map(il => ({ text: il.text, style: { ...il.style } })), + style: { ...b.style }, + ...(b.tableData ? { + tableData: { + columnWidths: [...b.tableData.columnWidths], + ...(b.tableData.rowHeights ? { rowHeights: [...b.tableData.rowHeights] } : {}), + rows: b.tableData.rows.map(row => ({ + cells: row.cells.map(cell => ({ + style: { ...cell.style }, + ...(cell.colSpan != null ? { colSpan: cell.colSpan } : {}), + ...(cell.rowSpan != null ? { rowSpan: cell.rowSpan } : {}), + blocks: cell.blocks.map(cloneBlock), + })), + })), + }, + } : {}), + }); + return cells.map(row => + row.map(cell => ({ + style: { ...cell.style }, + ...(cell.colSpan != null ? { colSpan: cell.colSpan } : {}), + ...(cell.rowSpan != null ? { rowSpan: cell.rowSpan } : {}), + blocks: cell.blocks.map(cloneBlock), + })) + ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/clipboard.ts` around lines 51 - 65, cloneTableCells currently shallow-copies block objects so nested table blocks retain their original tableData by reference; update the block mapper inside cloneTableCells to deep-clone b.tableData when b.type === 'table' (e.g. use a safe deep-clone like structuredClone or a JSON round-trip) so returned cells have independent rows/cells/columnWidths/rowHeights, and keep existing id/inlines/style behavior; ensure this change is applied in the blocks: cell.blocks.map(...) path and verify pasteTableCells uses the updated clone behavior.packages/docs/test/view/clipboard.test.ts (1)
254-297: Optional: extendcloneTableCellstests to cover nested tables and merge spans.Current tests verify top-level deep-clone independence and ID regeneration, but not two behaviors worth pinning down:
- A cell whose
blockscontains a nestedtype: 'table'block — today the implementation uses{ ...b, id: …, inlines: …, style: … }, which keeps the nestedtableDatareference shared with the original. A test asserting that mutatingcells[0][0].blocks[0].tableData.rows[...]does not affect the clone would either confirm intent or catch a regression.colSpan/rowSpanpreservation on the clone (the implementation conditionally copies them).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/view/clipboard.test.ts` around lines 254 - 297, Add tests to cloneTableCells that assert nested table blocks are deep-copied (i.e., when a block with type: 'table' contains tableData.rows, mutating the original's tableData.rows does not change the cloned block's tableData) and that colSpan/rowSpan values on TableCell objects are preserved on the clone; target the cloneTableCells test suite, create a cell whose blocks[0] has type: 'table' with nested tableData and a cell having colSpan/rowSpan, call cloneTableCells(cells), then mutate the original nested tableData.rows and change original colSpan/rowSpan and assert the cloned nested tableData and cloned colSpan/rowSpan remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/docs/docs-table-copy-paste.md`:
- Line 47: Update the spec text to match the shipped implementation: replace the
L47 mention of serializeBlocks()/deserializeBlocks() with the actual new
functions serializeClipboard()/deserializeClipboard() and state they
accept/return the optional tableCells field; and for the cell-write behavior,
either change references to doc.updateCellBlocks() to describe the actual code
path that mutates td.rows[...] and calls
this.doc.updateBlockDirect(tableBlockId, tableBlock), or add a short note that
the implementation mutates td.rows and uses updateBlockDirect rather than a
dedicated updateCellBlocks API.
In `@docs/tasks/active/20260419-table-copy-paste-todo.md`:
- Line 1: Add the missing companion lessons file for the task: create
docs/tasks/active/20260419-table-copy-paste-lessons.md (paired with the existing
20260419-table-copy-paste-todo.md) and populate it with at least a minimal
lessons stub (title, brief summary, and any known follow-ups/retrospective
notes) to satisfy the project convention for non-trivial tasks.
In `@packages/docs/src/view/text-editor.ts`:
- Around line 2793-2852: pasteTableCells has three fixes: 1) Avoid per-cell
reactive writes — remove updateBlockDirect(tableBlockId, tableBlock) from inside
the inner loop in pasteTableCells and call
this.doc.updateBlockDirect(tableBlockId, tableBlock) once after all td mutations
(before this.invalidateLayout()). 2) Prevent wrong-context insertion — when
creating a new table because cellInfo is falsy, check the current edit context
(via this.getLayout().editContext or equivalent) and if it is not 'body' either
abort/short-circuit the paste or route insertion through the correct context API
instead of unconditionally calling this.doc.insertBlockAt(blockIdx + 1,
tableBlock); ensure insertBlockAt is invoked against the same context that
produced blockIdx. 3) Add defensive guards for empty rows/cols — compute cols as
Math.max(1, Math.max(...cells.map(r => r.length))) before calling
createTableBlock, and compute lastColIdx as Math.max(0, cells[cells.length -
1].length - 1) when computing lastCol so you never pass -1 into index math;
update references to createTableBlock, cloneTableCells, and cursor.moveTo
accordingly.
---
Nitpick comments:
In `@packages/docs/src/view/clipboard.ts`:
- Around line 51-65: cloneTableCells currently shallow-copies block objects so
nested table blocks retain their original tableData by reference; update the
block mapper inside cloneTableCells to deep-clone b.tableData when b.type ===
'table' (e.g. use a safe deep-clone like structuredClone or a JSON round-trip)
so returned cells have independent rows/cells/columnWidths/rowHeights, and keep
existing id/inlines/style behavior; ensure this change is applied in the blocks:
cell.blocks.map(...) path and verify pasteTableCells uses the updated clone
behavior.
In `@packages/docs/src/view/text-editor.ts`:
- Around line 2607-2625: The table deep-clone code duplicates the cell-cloning
logic; replace the duplicated mapping in the if (block.tableData) branch by
calling the existing cloneTableCells helper so cloning logic is centralized.
Specifically, set cloned.tableData.columnWidths and optional rowHeights as
before, but use cloneTableCells(block.tableData.rows) (or the helper's expected
input) to produce rows/cells instead of reimplementing cells/blocks/inline
copying and generateBlockId usage; ensure compatibility with generateBlockId and
the helper's contract so nested tableData recursion and id regeneration remain
handled in one place.
In `@packages/docs/test/view/clipboard.test.ts`:
- Around line 254-297: Add tests to cloneTableCells that assert nested table
blocks are deep-copied (i.e., when a block with type: 'table' contains
tableData.rows, mutating the original's tableData.rows does not change the
cloned block's tableData) and that colSpan/rowSpan values on TableCell objects
are preserved on the clone; target the cloneTableCells test suite, create a cell
whose blocks[0] has type: 'table' with nested tableData and a cell having
colSpan/rowSpan, call cloneTableCells(cells), then mutate the original nested
tableData.rows and change original colSpan/rowSpan and assert the cloned nested
tableData and cloned colSpan/rowSpan remain unchanged.
🪄 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: c83d75b9-10a5-4bee-8abf-16a81978ef92
📒 Files selected for processing (6)
docs/design/README.mddocs/design/docs/docs-table-copy-paste.mddocs/tasks/active/20260419-table-copy-paste-todo.mdpackages/docs/src/view/clipboard.tspackages/docs/src/view/text-editor.tspackages/docs/test/view/clipboard.test.ts
- Move updateBlockDirect() call outside inner loop (was called per-cell) - Add editContext !== 'body' guard to skip table paste in header/footer - Defensive Math.max(1, cols) and Math.max(0, lastColIdx) for empty rows - Update design doc to match shipped API names - Add lessons file per project convention Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
chart-pivot-range-shift (#147), table-row-splitting (#145), and table-copy-paste (#140) are all merged. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
TableCell[][]) with deep clone helpergetSelectedBlocks()now preservestableDatawhen copying blocks that span table boundarieshandleCopy/handleCut/handlePasteto support both cell-range and whole-block table operationsDetails
Cell-range copy-paste (new): When a
tableCellRangeselection is active, cells are serialized asTableCell[][]in the clipboard payload. Pasting into a table overwrites cells from the cursor position (clamped to table bounds). Pasting outside a table creates a new table block.Block-level table copy (bugfix):
getSelectedBlocks()was cloning table blocks withouttableData, causing tables to silently disappear on paste. Now deep-clones the full table structure including rows, cells, and nested block IDs.Test plan
cloneTableCellsdeep-clone tests (2 pass)🤖 Generated with Claude Code
Summary by CodeRabbit