Skip to content

Add table copy-paste support in Docs editor#140

Merged
hackerwins merged 6 commits into
mainfrom
feat/table-copy-paste
Apr 19, 2026
Merged

Add table copy-paste support in Docs editor#140
hackerwins merged 6 commits into
mainfrom
feat/table-copy-paste

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add clipboard serialization for table cell ranges (TableCell[][]) with deep clone helper
  • Enable cell-range copy/cut/paste within and between tables (Phase 1)
  • Fix pre-existing bug: getSelectedBlocks() now preserves tableData when copying blocks that span table boundaries
  • Update handleCopy/handleCut/handlePaste to support both cell-range and whole-block table operations

Details

Cell-range copy-paste (new): When a tableCellRange selection is active, cells are serialized as TableCell[][] 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 without tableData, causing tables to silently disappear on paste. Now deep-clones the full table structure including rows, cells, and nested block IDs.

Test plan

  • Clipboard serialization round-trip tests (24 pass)
  • cloneTableCells deep-clone tests (2 pass)
  • Full docs test suite (570/570 pass)
  • Manual: select cell range → Ctrl+C → click different cell → Ctrl+V
  • Manual: select all (with table) → Ctrl+C → Ctrl+V → table preserved
  • Manual: Ctrl+X on cell range → cells cleared
  • Puppeteer verification of table data preservation through copy-paste cycle

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added table cell copy and paste functionality to Docs editor. Users can now copy and paste individual cells or ranges within tables. Pasted content can populate existing tables or create new table blocks with formatting and content preserved.

hackerwins and others added 5 commits April 19, 2026 13:22
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]>
@coderabbitai

coderabbitai Bot commented Apr 19, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

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

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 45 minutes and 55 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e39da859-2df3-4ce0-8ad3-42910330673f

📥 Commits

Reviewing files that changed from the base of the PR and between e1b473d and 2c0c8c3.

📒 Files selected for processing (3)
  • docs/design/docs/docs-table-copy-paste.md
  • docs/tasks/active/20260419-table-copy-paste-lessons.md
  • packages/docs/src/view/text-editor.ts
📝 Walkthrough

Walkthrough

This PR implements table cell range copy-paste functionality for the Docs editor by extending clipboard serialization with a tableCells field, updating copy/cut/paste handlers to detect and process table cell selections, and adding comprehensive tests for the new clipboard functionality.

Changes

Cohort / File(s) Summary
Documentation & Design
docs/design/README.md, docs/design/docs/docs-table-copy-paste.md, docs/tasks/active/20260419-table-copy-paste-todo.md
Added design specification for Phase 1 table cell copy-paste and implementation plan detailing clipboard payload extension, editor handler updates, and paste logic with bounds clamping.
Clipboard Serialization
packages/docs/src/view/clipboard.ts
Extended clipboard payload with optional tableCells: TableCell[][] field; introduced serializeClipboard and deserializeClipboard functions with versioned JSON fallback; added cloneTableCells helper to deep-clone cell grids while regenerating block IDs.
Text Editor Clipboard Handlers
packages/docs/src/view/text-editor.ts
Updated handleCopy and handleCut to detect table cell range selections and serialize via new clipboard functions; enhanced handlePaste to deserialize and route to pasteTableCells when clipboard contains table cells; added helpers getSelectedTableCells() and pasteTableCells() for extraction and insertion with bounds clamping and new table creation.
Clipboard Tests
packages/docs/test/view/clipboard.test.ts
Added round-trip serialization/deserialization tests for tableCells payload and mutation isolation tests for cloneTableCells to verify deep-cloning semantics and block ID regeneration.

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

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 A hop, a copy, a paste so true,
Table cells dance in rectangles new,
Clipboard whispers with blocks ID-cloned,
Bounds clamped tight, no collision's shown,
Rows and columns find their home! 📋✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 accurately and clearly summarizes the main change: adding table copy-paste support to the Docs editor, which is the central objective across all modified files.

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

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 121.3s

Lane Status Duration
sheets:build ✅ pass 14.0s
docs:build ✅ pass 8.2s
verify:fast ✅ pass 60.8s
frontend:build ✅ pass 16.0s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.8s
cli:build ✅ pass 1.8s
verify:entropy ✅ pass 15.4s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented Apr 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 17.88079% with 124 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/docs/src/view/text-editor.ts 0.00% 117 Missing ⚠️
packages/docs/src/view/clipboard.ts 79.41% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

@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 (3)
packages/docs/src/view/text-editor.ts (1)

2607-2625: Consolidate with cloneTableCells to 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 the cloneTableCells comment).

♻️ 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: cloneTableCells does not deep-clone nested table tableData.

When a cell's blocks contains a block with type: 'table', the { ...b, id, inlines, style } spread keeps the original tableData (rows/cells/columnWidths/rowHeights) by reference. In the current PR flow, clipboard contents always pass through JSON.stringifyJSON.parse, so this works end-to-end; but the helper is a public export and pasteTableCells calls it per destination cell on already-deserialized data. A later refactor that uses cloneTableCells on live in-memory cells — or any code that mutates a cloned cell's nested table — would silently mutate the source.

Either deep-clone tableData inside the block mapper, or document that cloneTableCells is 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: extend cloneTableCells tests 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:

  1. A cell whose blocks contains a nested type: 'table' block — today the implementation uses { ...b, id: …, inlines: …, style: … }, which keeps the nested tableData reference shared with the original. A test asserting that mutating cells[0][0].blocks[0].tableData.rows[...] does not affect the clone would either confirm intent or catch a regression.
  2. colSpan / rowSpan preservation 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

📥 Commits

Reviewing files that changed from the base of the PR and between 135d36a and e1b473d.

📒 Files selected for processing (6)
  • docs/design/README.md
  • docs/design/docs/docs-table-copy-paste.md
  • docs/tasks/active/20260419-table-copy-paste-todo.md
  • packages/docs/src/view/clipboard.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/view/clipboard.test.ts

Comment thread docs/design/docs/docs-table-copy-paste.md Outdated
Comment thread docs/tasks/active/20260419-table-copy-paste-todo.md
Comment thread packages/docs/src/view/text-editor.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]>
@hackerwins
hackerwins merged commit 0db208c into main Apr 19, 2026
4 checks passed
@hackerwins
hackerwins deleted the feat/table-copy-paste branch April 19, 2026 07:03
hackerwins added a commit that referenced this pull request Apr 22, 2026
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]>
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