Skip to content

Add table CRDT with block-based cells and cross-cell selection#93

Merged
hackerwins merged 22 commits into
mainfrom
feat/docs-table-crdt
Mar 29, 2026
Merged

Add table CRDT with block-based cells and cross-cell selection#93
hackerwins merged 22 commits into
mainfrom
feat/docs-table-crdt

Conversation

@hackerwins

@hackerwins hackerwins commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replace JSON-stringified tableData with Yorkie Tree node hierarchy and make table cells full mini-documents supporting paragraphs, lists, headings, and rich formatting.

Data Model (Phase A)

  • TableCell.inlines: Inline[]TableCell.blocks: Block[]
  • YorkieDocStore serializes tables as row → cell → block → inline → text tree nodes
  • Per-cell concurrent editing via Yorkie Tree CRDT (no more last-writer-wins)

Cell-Internal Multi-Block Editing

  • Enter splits blocks within cells, Backspace merges them
  • cellBlockIndex field in DocPosition tracks cursor block within cell
  • Arrow keys navigate across cell block boundaries (Left/Right/Up/Down)
  • Mouse click and drag selection use Y-coordinate for correct block targeting

Cell-Aware Document Operations

  • Bold, Italic, Underline, Strikethrough, Font Color, BG Color — all toggle correctly in cells
  • getStyleAtCursor / getSelectionStyle read from cell block inlines
  • Find/Replace searches cell content, replace works in cells
  • Text alignment (⌘⇧L/E/R/J) works inside cells
  • Clear formatting, Insert/Remove link — cell-aware routing
  • List toggle (⌘⇧7/8), indent/outdent (⌘]/[) with bullet/number rendering
  • Block type dropdown shows correct type for cell blocks

Cross-Cell & Cross-Table Selection

  • Drag from cell A to cell B → cell-range selection (whole cells highlighted)
  • Drag outside table → block-range selection (table + external content)
  • Shift+Arrow supports cross-cell and cross-table selection
  • Cell-range style application (Bold etc.) applies to all cells in range
  • Delete in cell-range clears selected cell content
  • Right-click preserves cell-range selection for context menu

Test plan

  • pnpm verify:fast passes (lint + all unit tests)
  • Unit tests for splitBlockInCell, mergeBlocksInCell, getCellBlockTextLength
  • Enter/Backspace/Delete across cell blocks
  • All inline styles toggle correctly in cells
  • Find/Replace finds and replaces cell content
  • List markers render in cells with correct numbering
  • Cross-cell drag selection highlights cell range
  • Shift+Arrow extends selection across cells and out of table
  • Context menu works with cell-range selection preserved

🤖 Generated with Claude Code

hackerwins and others added 2 commits March 29, 2026 18:24
Replace TableCell.inlines with TableCell.blocks so each cell holds a
mini-document (Block[]) instead of flat Inline[]. This is Phase A of
the table CRDT work — the data model and store layer change that
enables per-cell concurrent editing via Yorkie Tree nodes.

Key changes:
- TableCell.inlines → TableCell.blocks (paragraph block per cell)
- Doc *InCell methods operate on cell.blocks[0]
- YorkieDocStore serializes tables as row→cell→block tree nodes
  instead of JSON-stringified tableData attribute
- Table layout wraps cell blocks instead of cell inlines
- All tests updated for the new cell structure

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Mark all Phase A steps as complete and add lessons learned.

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

coderabbitai Bot commented Mar 29, 2026

Copy link
Copy Markdown

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Table cells now store nested blocks (each with inlines) instead of a flat inlines array; a cellBlockIndex coordinate was added and propagated through the document model, selection/editor, layout, renderer, Yorkie serialization, exports, and tests.

Changes

Cohort / File(s) Summary
Design Docs Metadata
docs/design/docs-table-crdt.md, docs/design/docs-table-ui.md, docs/design/docs-tables.md, docs/design/docs-wordprocessor-roadmap.md
Updated front‑matter target-version values.
Task Docs
docs/tasks/active/20260329-table-crdt-phase-a-lessons.md, docs/tasks/active/20260329-table-crdt-phase-a-todo.md
Added lessons file; marked many phase‑A todo items complete.
Public API
packages/docs/src/index.ts
Added exports: TableCellRange (type) and getCellText (runtime helper).
Core Types
packages/docs/src/model/types.ts
Replaced TableCell.inlines with TableCell.blocks: Block[]; added DocPosition.cellBlockIndex?: number; added TableCellRange; createTableCell() initializes a paragraph block; added exported getCellText(cell); extended SearchMatch with cellAddress/cellBlockIndex.
Document Model
packages/docs/src/model/document.ts
Refactored cell operations to target cell.blocks[cellBlockIndex]; introduced splitBlockInCell, mergeBlocksInCell, getCellBlockTextLength; updated search to emit cellAddress/cellBlockIndex; merge/split/normalize logic adapted to blocks.
Editor & Selection
packages/docs/src/view/text-editor.ts, packages/docs/src/view/selection.ts, packages/docs/src/view/peer-cursor.ts, packages/docs/src/view/editor.ts, packages/docs/src/view/find-replace.ts
Selection and cursor state carry cellBlockIndex; selection normalization and selected-text extraction handle table cell ranges and per-block offsets; editor input/IME/keyboard/mouse, formatting, block-type, link operations, find/replace, and peer-cursor resolution updated to be block-aware.
Layout
packages/docs/src/view/table-layout.ts
Added LayoutTableCell.blockBoundaries: number[]; added layoutCellBlocks() to layout per-cell blocks and compute line offsets; computeTableLayout uses block-based lines and boundaries.
Rendering
packages/docs/src/view/table-renderer.ts
Per-cell rendering now draws list markers per block using blockBoundaries; numbering and marker placement computed per block.
Yorkie Serialization
packages/frontend/src/app/docs/yorkie-doc-store.ts
Switched table serialization from monolithic tableData JSON to hierarchical tree nodes (block(table)→row→cell→block...); added cell style serialize/parse and tree-node→row/cell helpers; updated build/tree parsing accordingly.
Tests
packages/docs/test/...
packages/docs/test/model/types.test.ts, packages/docs/test/model/table.test.ts, packages/docs/test/view/table-layout.test.ts, packages/docs/test/view/table-selection.test.ts
Updated tests to use blocks[*].inlines[*]; added tests for block‑level cell operations (split/merge/insert/get length); updated layout/selection tests to reflect new shapes.

Sequence Diagram(s)

sequenceDiagram
  participant Editor as Editor (UI)
  participant Doc as Doc Model
  participant Yorkie as YorkieDocStore
  participant Renderer as Renderer/View

  Editor->>Doc: insertTextInCell(cellAddr, offset, text, cellBlockIndex)
  Note over Doc: modify target cell.blocks[cellBlockIndex].inlines
  Doc->>Yorkie: update table subtree (row → cell → block...)
  Yorkie->>Yorkie: serialize cell styles & block children
  Yorkie->>Renderer: notify change / provide updated tree
  Renderer->>Editor: render updated layout (uses blockBoundaries)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐇
I burrow through blocks where paragraphs rest,
nibble inlines, then stitch them at best.
Split and merge, serialize rows with delight,
I hop indices and render markers just right.
A happy rabbit cheers the table rewrite! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% 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 PR title accurately reflects the main objective: transitioning TableCell from inline-based to block-based architecture and introducing cross-cell selection capability, both of which are central to the changeset.

✏️ 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/docs-table-crdt

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 Mar 29, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 105.6s

Lane Status Duration
sheets:build ✅ pass 13.9s
docs:build ✅ pass 6.5s
verify:fast ✅ pass 49.5s
frontend:build ✅ pass 15.2s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.5s
cli:build ✅ pass 1.9s
verify:entropy ✅ pass 13.9s

Verification: verify:integration

Result: ✅ PASS

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/tasks/active/20260329-table-crdt-phase-a-todo.md (1)

730-741: ⚠️ Potential issue | 🟡 Minor

Manual smoke-test completion status appears premature.

Line 730 marks the smoke test as done ([x]), but the PR objectives still say these manual checks are pending. Please either uncheck this step or attach the concrete test run results.

🤖 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 730 -
741, The manual smoke-test checkbox in the "Step 3: Manual smoke test" checklist
is marked complete ([x]) while PR objectives still list these checks as pending;
either revert the checklist mark to unchecked ([ ]) or attach concrete evidence
of the test run (screenshots/logs) and update the PR objectives to reflect
completion; specifically update the "Step 3: Manual smoke test" line and any
related PR objective text mentioning these manual checks so they are consistent.
packages/docs/src/model/document.ts (2)

597-619: ⚠️ Potential issue | 🔴 Critical

mergeCells() still drops content outside the first block.

The content check now looks at the whole cell via getCellText(cell), but the merge only copies cell.blocks[0].inlines into topLeft.blocks[0]. A covered cell whose text lives in block 2+ will be cleared without that content being moved, and even non-empty first blocks lose their block type/style when flattened into the top-left paragraph. Please merge cloned non-empty blocks, or reject multi-block merges until that path exists.

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

In `@packages/docs/src/model/document.ts` around lines 597 - 619, mergeCells()
currently only copies cell.blocks[0].inlines into topLeft, dropping any text in
blocks[1+] and losing block types/styles; change the logic in the nested loop
that handles a non-empty cell (where getCellText(cell) > 0) to either (A) clone
and append all non-empty blocks from cell.blocks into topLeft.blocks (preserving
each block's type, style and inlines) — i.e., push deep-cloned block objects
instead of flattening everything into topLeft.blocks[0].inlines, then normalize
via this.normalizeInlinesArray on each affected block or the whole blocks array
— or (B) detect when cell.blocks.length > 1 and reject/abort the merge with a
clear error/return (reject multi-block merges) so you don't silently drop
content; make sure to use generateBlockId only when creating the covered
placeholder and do not mutate the original source blocks (use cloning) and keep
references to functions/vars from the diff: mergeCells(), getCellText(cell),
topLeft.blocks, cell.blocks, normalizeInlinesArray.

405-490: ⚠️ Potential issue | 🟠 Major

Make the cell edit helpers block-aware.

These methods now hard-code tableCell.blocks[0], so once a cell contains more than one block, offsets past the first block will be applied to the wrong content and later blocks become effectively uneditable. If multi-block editing is not landing in this PR, please at least fail fast on blocks.length !== 1 instead of silently mutating block 0.

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

In `@packages/docs/src/model/document.ts` around lines 405 - 490, The cell-edit
helpers (insertTextInCell, deleteTextInCell, applyCellInlineStyle) assume the
target is always tableCell.blocks[0], which breaks or silently mutates when a
cell has multiple blocks; add a guard at the start of each method to detect
tableCell.blocks.length !== 1 and fail fast (throw an Error or return after
logging) with a clear message referencing the blockId and cell so callers know
multi-block cells are unsupported, or alternatively implement full multi-block
logic by mapping offsets across tableCell.blocks instead; for the quick fix
update insertTextInCell, deleteTextInCell and applyCellInlineStyle to check
tableCell.blocks.length !== 1 and abort before touching blocks[0].
🧹 Nitpick comments (5)
packages/docs/test/view/table-selection.test.ts (1)

27-27: Extract repeated cell-text flattening into a local test helper.

These assertions are correct, but the same extraction chain is duplicated many times. A helper keeps intent clearer and reduces maintenance noise.

♻️ Suggested cleanup
 describe('Table cell selection helpers', () => {
   let doc: Doc;
   let tableBlockId: string;
+  const readCellText = (blockId: string, rowIndex: number, colIndex: number): string => {
+    const block = doc.getBlock(blockId);
+    return block.tableData!.rows[rowIndex].cells[colIndex].blocks
+      .flatMap((blockItem) => blockItem.inlines)
+      .map((inlineItem) => inlineItem.text)
+      .join('');
+  };

@@
-      const block = doc.getBlock(tableBlockId);
-      const text = block.tableData!.rows[0].cells[0].blocks.flatMap(b => b.inlines).map(i => i.text).join('');
+      const text = readCellText(tableBlockId, 0, 0);
       expect(text).toBe('hello');

Also applies to: 33-34, 43-44, 51-52, 59-60, 67-68, 73-74

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

In `@packages/docs/test/view/table-selection.test.ts` at line 27, Extract the
repeated flattening chain used to get a cell's text
(block.tableData!.rows[0].cells[0].blocks.flatMap(b => b.inlines).map(i =>
i.text).join('')) into a small local test helper (e.g., getCellText or
extractCellText) and replace each duplicate occurrence in the test with calls to
that helper; update usages referenced in this file (table-selection.test.ts) for
the other indices mentioned (lines around the duplicates) so the tests call
getCellText(block, rowIndex, colIndex) or similar instead of repeating the
flatMap/map/join chain.
docs/tasks/active/20260329-table-crdt-phase-a-todo.md (1)

720-731: Add/perform the archive step when final verification is fully complete.

Once these final verification items are truly done, move this task out of docs/tasks/active using the standard archive flow to keep task state consistent.

Based on learnings: “Before marking a task done: (1) Update docs/design/ if architecture changed, (2) Run pnpm verify:fast and confirm pass, (3) Archive with pnpm tasks:archive && pnpm tasks:index.”

🤖 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 720 -
731, Update the task so the final step archives it when all verifications and
docs updates are complete: ensure you run the checks (pnpm verify:fast and node
scripts/verify-entropy.mjs), update any changed architecture in docs/design/,
then perform the standard archive flow with pnpm tasks:archive && pnpm
tasks:index and move the task out of docs/tasks/active (the file
docs/tasks/active/20260329-table-crdt-phase-a-todo.md) to reflect the completed
state.
packages/frontend/src/app/docs/yorkie-doc-store.ts (1)

240-255: Consider adding validation for empty column widths.

If the cols attribute is missing or empty, columnWidths will be an empty array, which could cause issues in layout computations that assume at least one column exists. Consider adding a fallback or validation.

🛡️ Optional defensive fallback
     const cols = (attrs.cols ?? '').split(',').map(Number).filter(n => !isNaN(n));
+    // Fallback: infer column count from first row if cols attribute is malformed
+    const colCount = cols.length > 0 ? cols.length : (rows[0]?.cells.length ?? 1);
+    const validCols = cols.length === colCount ? cols : Array(colCount).fill(1 / colCount);
     return {
       id: attrs.id ?? '',
       type: 'table',
       inlines: [],
       style: parseBlockStyle(attrs),
-      tableData: { rows, columnWidths: cols },
+      tableData: { rows, columnWidths: validCols },
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/docs/yorkie-doc-store.ts` around lines 240 - 255,
The table branch currently parses attrs.cols into cols and may produce an empty
columnWidths array; add validation in the blockType === 'table' path (the code
that builds tableData) to detect when parsed cols is empty and provide a
sensible fallback (e.g., derive column count from parsed rows via treeNodeToRow
or default to [1] or a single-column width) before returning { tableData: {
rows, columnWidths: cols } }; keep existing fields (id, type, inlines, style via
parseBlockStyle) and ensure columnWidths is never empty to avoid downstream
layout issues.
packages/docs/src/view/text-editor.ts (1)

2289-2310: Note potential future semantic mismatch for multi-block cells.

The offset computation here flattens all blocks (cell.blocks.flatMap(b => b.inlines)), but the offset is then used with insertTextInCell/deleteTextInCell which only operate on blocks[0] (as shown in packages/docs/src/model/document.ts lines 481, 405-420).

This is safe currently because createTableCell() always creates exactly one block, and there's no mechanism to add additional blocks to cells yet. However, if Phase B introduces multi-block cell editing, this will need reconciliation to avoid offset misalignment.

🤖 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 2289 - 2310, The offset
computation flattens all blocks (cell.blocks.flatMap(...)) but
insertTextInCell/deleteTextInCell operate only on blocks[0], which will cause
misalignment if cells become multi-block; change the offset calculation used
before cursor.moveTo to compute length only for the first block (e.g., sum
inlines of cell.blocks[0]) or alternatively update the editing functions
(insertTextInCell/deleteTextInCell) to accept and correctly resolve multi-block
offsets; reference cell.blocks, cursor.moveTo, insertTextInCell,
deleteTextInCell and createTableCell to locate the related logic and keep cursor
offsets and editor operations consistent.
packages/docs/src/model/document.ts (1)

613-613: Reuse createEmptyBlock() for cleared cells.

Both reset sites rebuild the same empty paragraph literal by hand. Using the existing helper keeps the table-cell placeholder block aligned with any future block-shape changes.

♻️ Suggested cleanup
-        cell.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
+        cell.blocks = [createEmptyBlock()];
...
-        covered.blocks = [{ id: generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }];
+        covered.blocks = [createEmptyBlock()];

Also applies to: 647-647

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

In `@packages/docs/src/model/document.ts` at line 613, Replace the inline empty
paragraph literal used to reset table cells with the existing helper
createEmptyBlock() so cleared cells use the canonical block shape; specifically,
in the assignment where cell.blocks is set to [{ id: generateBlockId(), type:
'paragraph', inlines: [{ text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE
} }] (and the similar occurrence around the other mentioned site) call
createEmptyBlock() (or createEmptyBlock().wrapped/array form as appropriate)
instead of rebuilding the literal, preserving the same semantics but
centralizing the shape in createEmptyBlock().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md`:
- Around line 730-741: The manual smoke-test checkbox in the "Step 3: Manual
smoke test" checklist is marked complete ([x]) while PR objectives still list
these checks as pending; either revert the checklist mark to unchecked ([ ]) or
attach concrete evidence of the test run (screenshots/logs) and update the PR
objectives to reflect completion; specifically update the "Step 3: Manual smoke
test" line and any related PR objective text mentioning these manual checks so
they are consistent.

In `@packages/docs/src/model/document.ts`:
- Around line 597-619: mergeCells() currently only copies cell.blocks[0].inlines
into topLeft, dropping any text in blocks[1+] and losing block types/styles;
change the logic in the nested loop that handles a non-empty cell (where
getCellText(cell) > 0) to either (A) clone and append all non-empty blocks from
cell.blocks into topLeft.blocks (preserving each block's type, style and
inlines) — i.e., push deep-cloned block objects instead of flattening everything
into topLeft.blocks[0].inlines, then normalize via this.normalizeInlinesArray on
each affected block or the whole blocks array — or (B) detect when
cell.blocks.length > 1 and reject/abort the merge with a clear error/return
(reject multi-block merges) so you don't silently drop content; make sure to use
generateBlockId only when creating the covered placeholder and do not mutate the
original source blocks (use cloning) and keep references to functions/vars from
the diff: mergeCells(), getCellText(cell), topLeft.blocks, cell.blocks,
normalizeInlinesArray.
- Around line 405-490: The cell-edit helpers (insertTextInCell,
deleteTextInCell, applyCellInlineStyle) assume the target is always
tableCell.blocks[0], which breaks or silently mutates when a cell has multiple
blocks; add a guard at the start of each method to detect
tableCell.blocks.length !== 1 and fail fast (throw an Error or return after
logging) with a clear message referencing the blockId and cell so callers know
multi-block cells are unsupported, or alternatively implement full multi-block
logic by mapping offsets across tableCell.blocks instead; for the quick fix
update insertTextInCell, deleteTextInCell and applyCellInlineStyle to check
tableCell.blocks.length !== 1 and abort before touching blocks[0].

---

Nitpick comments:
In `@docs/tasks/active/20260329-table-crdt-phase-a-todo.md`:
- Around line 720-731: Update the task so the final step archives it when all
verifications and docs updates are complete: ensure you run the checks (pnpm
verify:fast and node scripts/verify-entropy.mjs), update any changed
architecture in docs/design/, then perform the standard archive flow with pnpm
tasks:archive && pnpm tasks:index and move the task out of docs/tasks/active
(the file docs/tasks/active/20260329-table-crdt-phase-a-todo.md) to reflect the
completed state.

In `@packages/docs/src/model/document.ts`:
- Line 613: Replace the inline empty paragraph literal used to reset table cells
with the existing helper createEmptyBlock() so cleared cells use the canonical
block shape; specifically, in the assignment where cell.blocks is set to [{ id:
generateBlockId(), type: 'paragraph', inlines: [{ text: '', style: {} }], style:
{ ...DEFAULT_BLOCK_STYLE } }] (and the similar occurrence around the other
mentioned site) call createEmptyBlock() (or createEmptyBlock().wrapped/array
form as appropriate) instead of rebuilding the literal, preserving the same
semantics but centralizing the shape in createEmptyBlock().

In `@packages/docs/src/view/text-editor.ts`:
- Around line 2289-2310: The offset computation flattens all blocks
(cell.blocks.flatMap(...)) but insertTextInCell/deleteTextInCell operate only on
blocks[0], which will cause misalignment if cells become multi-block; change the
offset calculation used before cursor.moveTo to compute length only for the
first block (e.g., sum inlines of cell.blocks[0]) or alternatively update the
editing functions (insertTextInCell/deleteTextInCell) to accept and correctly
resolve multi-block offsets; reference cell.blocks, cursor.moveTo,
insertTextInCell, deleteTextInCell and createTableCell to locate the related
logic and keep cursor offsets and editor operations consistent.

In `@packages/docs/test/view/table-selection.test.ts`:
- Line 27: Extract the repeated flattening chain used to get a cell's text
(block.tableData!.rows[0].cells[0].blocks.flatMap(b => b.inlines).map(i =>
i.text).join('')) into a small local test helper (e.g., getCellText or
extractCellText) and replace each duplicate occurrence in the test with calls to
that helper; update usages referenced in this file (table-selection.test.ts) for
the other indices mentioned (lines around the duplicates) so the tests call
getCellText(block, rowIndex, colIndex) or similar instead of repeating the
flatMap/map/join chain.

In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 240-255: The table branch currently parses attrs.cols into cols
and may produce an empty columnWidths array; add validation in the blockType ===
'table' path (the code that builds tableData) to detect when parsed cols is
empty and provide a sensible fallback (e.g., derive column count from parsed
rows via treeNodeToRow or default to [1] or a single-column width) before
returning { tableData: { rows, columnWidths: cols } }; keep existing fields (id,
type, inlines, style via parseBlockStyle) and ensure columnWidths is never empty
to avoid downstream layout issues.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f11ceceb-c0f9-4a02-9976-56c54340e3eb

📥 Commits

Reviewing files that changed from the base of the PR and between 4a07637 and aebb3a6.

📒 Files selected for processing (17)
  • docs/design/docs-table-crdt.md
  • docs/design/docs-table-ui.md
  • docs/design/docs-tables.md
  • docs/design/docs-wordprocessor-roadmap.md
  • docs/tasks/active/20260329-table-crdt-phase-a-lessons.md
  • docs/tasks/active/20260329-table-crdt-phase-a-todo.md
  • packages/docs/src/index.ts
  • packages/docs/src/model/document.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/table-layout.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/model/table.test.ts
  • packages/docs/test/model/types.test.ts
  • packages/docs/test/view/table-layout.test.ts
  • packages/docs/test/view/table-selection.test.ts
  • packages/frontend/src/app/docs/yorkie-doc-store.ts

Enable Enter key to split a block into two paragraphs within a table
cell, and Backspace at a block boundary to merge them back. This
makes cell-internal blocks fully functional for basic editing.

Key changes:
- Add cellBlockIndex to DocPosition for per-block cursor tracking
- Add splitBlockInCell, mergeBlocksInCell, getCellBlockTextLength
  to Doc class
- Update all cell editing handlers (Enter, Backspace, Delete, Input,
  IME composition) to pass cellBlockIndex
- Add blockBoundaries to LayoutTableCell for click-to-block mapping
- Update resolvePositionPixel to render cursor in correct cell block
- Arrow keys navigate across cell block boundaries

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
packages/docs/src/view/peer-cursor.ts (1)

78-117: ⚠️ Potential issue | 🟡 Minor

Wrapped table-cell carets still ignore lineAffinity.

This branch always picks the first line whose character count covers position.offset, so a caret parked at a wrap boundary resolves to the previous visual line even when the stored affinity is forward. Mirror the non-table path’s affinity handling here to keep carets stable on wrapped cell text. Based on learnings: In packages/docs/src/view/*, when computing selection boundaries and wrap-aware cursor/navigation behavior, include and consistently thread lineAffinity through the relevant helpers.

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

In `@packages/docs/src/view/peer-cursor.ts` around lines 78 - 117, The caret
placement loop in peer-cursor.ts always selects the first visual line whose char
count >= position.offset, ignoring position.lineAffinity; update the loop inside
the block that iterates li from startLine to endLine so that when
offsetRemaining exactly equals the cumulative chars at a wrap boundary you
choose the next visual line if position.lineAffinity === "forward" (mirror the
non-table path) and keep the current visual line if affinity is backward; use
the existing position.lineAffinity (or similar field) when computing which line
to treat as the cursor line and ensure any helper that consumes the resolved
line/offset also receives the affinity so wrapped-cell cursor/navigation remains
stable.
packages/docs/src/view/text-editor.ts (3)

1299-1317: ⚠️ Potential issue | 🟠 Major

ArrowLeft/ArrowRight still bypass the new block-aware movement.

moveLeft() / moveRight() now know how to cross cell block boundaries, but handleArrow()’s table-cell branch still compares against the whole-cell length and never looks at cellBlockIndex. In practice, ArrowLeft at the start of block 1 jumps to the previous cell, and ArrowRight at the end of block 0 walks past the current block instead of entering the next one.

Also applies to: 1893-1921, 1928-1939

🤖 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 1299 - 1317,
handleArrow's table-cell branch currently compares pos.offset against the full
cellLen and ignores cellBlockIndex, so ArrowLeft/ArrowRight bypass block-aware
movement; update the table-cell logic in handleArrow to consult
pos.cellBlockIndex and the current block length (not whole cellLen), and use the
block-aware helpers (moveLeft/moveRight or moveWordLeft/moveWordRight) or fall
back to moveToPrevCell/moveToNextCell when at a block boundary — adjust the
branches around moveWordLeft/moveWordRight, moveToPrevCell/moveToNextCell, and
the newPos assignments so arrows enter/leave blocks correctly, and still call
selection.setRange(null) and requestRender() when crossing cells.

259-280: ⚠️ Potential issue | 🔴 Critical

A few cell-editing paths still assume a flat cell offset space.

The refactor updated compositionend, but the live IME path and deleteSelection() still call the cell APIs without cellBlockIndex, and end.offset - start.offset only makes sense when both endpoints are in the same cell block. Composing or deleting text in cell.blocks[1+] can therefore mutate the wrong block or remove unrelated content.

Also applies to: 1600-1605

🤖 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 259 - 280, The cell edit
paths still treat cell offsets as flat—fix the IME/live-edit and
deleteSelection() code that calls deleteTextInCell/insertTextInCell (and
computes lengths from end.offset - start.offset) to use the position's
cellBlockIndex and per-block offsets: when startPosition.cellAddress exists,
compute the target block index (startPosition.cellBlockIndex) and the
intra-block offset (e.g., offset within that cell block) and pass that plus the
cellBlockIndex into the cell-specific APIs instead of the flat offset; also
guard length calculations so you only use end.offset - start.offset when both
positions refer to the same cell block (end.cellBlockIndex ===
start.cellBlockIndex), otherwise iterate or target the correct block(s) in the
cell. Ensure updates around composition.currentLength and compPos preserve
cellBlockIndex and intra-block offsets when setting the new composition
position.

2198-2250: ⚠️ Potential issue | 🟠 Major

Cell click/drag hit-testing needs Y as well as X.

resolveOffsetInCell*() picks a block/offset by walking every laid-out line in order and comparing only horizontal distance. In a wrapped cell or multi-block cell, clicking the second paragraph with the same x as the first still resolves into the first line/block, so cursor placement and drag selection land in the wrong block.

🤖 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 2198 - 2250, The hit-test
ignores vertical position so clicks in later wrapped lines or paragraphs map to
the first line; compute logicalY in resolveOffsetInCell (using e.clientY,
container.getBoundingClientRect().top, container.scrollTop and getScaleFactor())
and pass it into resolveOffsetInCellAtX (add a logicalY parameter), then inside
resolveOffsetInCellAtX compute cellOriginY (use
paginatedLayout.pageSetup.margins.top + tl.rowYOffsets[cellAddr.rowIndex] or the
table's row Y offsets) and localY = logicalY - cellOriginY; while iterating
cell.lines subtract each line's height (or use line.height/line.metrics) from
localY to find the target line index, and only run the per-run X measurement for
that specific line (leave other logic intact and return offset + i and
currentBlockIndex once the correct line and run position is found).
🤖 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/model/document.ts`:
- Around line 684-701: The merge logic uses getCellText(cell) to detect
non-empty content but only copies from cell.blocks[0], dropping text in
cell.blocks[1+] and flattening everything into topLeft.blocks[0]; update the
merge to iterate over all source blocks in each covered cell (cell.blocks) and
append each block as a distinct block to topLeft.blocks (preserving block order
and their inlines), then mark the original cell as covered (setting blocks to
the placeholder) as before and finally call this.normalizeInlinesArray on each
resulting topLeft block if needed; reference functions/values to change:
getCellText, cell.blocks, topLeft.blocks, generateBlockId,
normalizeInlinesArray.

In `@packages/docs/src/view/table-layout.ts`:
- Around line 144-173: layoutCellBlocks currently treats every Block as plain
inline text by calling layoutCellInlines; update it to perform block-aware
layout like the main document flow: inspect block.type (e.g., "heading",
"paragraph", "list"), headingLevel, listKind, listLevel and apply the
corresponding block layout logic (styles, font sizes, indentation, list markers,
and spacing) instead of only inlines, by reusing or delegating to the existing
block-aware layout functions used for the main flow (e.g., the layoutBlock /
layoutBlocks or flow layout helpers) and ensure blockBoundaries and line y
offsets are computed the same way; also update the related usage that currently
calls layoutCellInlines (the other occurrence referenced around
layoutCellInlines) so list items and headings inside cells render with proper
indentation and spacing.

---

Outside diff comments:
In `@packages/docs/src/view/peer-cursor.ts`:
- Around line 78-117: The caret placement loop in peer-cursor.ts always selects
the first visual line whose char count >= position.offset, ignoring
position.lineAffinity; update the loop inside the block that iterates li from
startLine to endLine so that when offsetRemaining exactly equals the cumulative
chars at a wrap boundary you choose the next visual line if
position.lineAffinity === "forward" (mirror the non-table path) and keep the
current visual line if affinity is backward; use the existing
position.lineAffinity (or similar field) when computing which line to treat as
the cursor line and ensure any helper that consumes the resolved line/offset
also receives the affinity so wrapped-cell cursor/navigation remains stable.

In `@packages/docs/src/view/text-editor.ts`:
- Around line 1299-1317: handleArrow's table-cell branch currently compares
pos.offset against the full cellLen and ignores cellBlockIndex, so
ArrowLeft/ArrowRight bypass block-aware movement; update the table-cell logic in
handleArrow to consult pos.cellBlockIndex and the current block length (not
whole cellLen), and use the block-aware helpers (moveLeft/moveRight or
moveWordLeft/moveWordRight) or fall back to moveToPrevCell/moveToNextCell when
at a block boundary — adjust the branches around moveWordLeft/moveWordRight,
moveToPrevCell/moveToNextCell, and the newPos assignments so arrows enter/leave
blocks correctly, and still call selection.setRange(null) and requestRender()
when crossing cells.
- Around line 259-280: The cell edit paths still treat cell offsets as flat—fix
the IME/live-edit and deleteSelection() code that calls
deleteTextInCell/insertTextInCell (and computes lengths from end.offset -
start.offset) to use the position's cellBlockIndex and per-block offsets: when
startPosition.cellAddress exists, compute the target block index
(startPosition.cellBlockIndex) and the intra-block offset (e.g., offset within
that cell block) and pass that plus the cellBlockIndex into the cell-specific
APIs instead of the flat offset; also guard length calculations so you only use
end.offset - start.offset when both positions refer to the same cell block
(end.cellBlockIndex === start.cellBlockIndex), otherwise iterate or target the
correct block(s) in the cell. Ensure updates around composition.currentLength
and compPos preserve cellBlockIndex and intra-block offsets when setting the new
composition position.
- Around line 2198-2250: The hit-test ignores vertical position so clicks in
later wrapped lines or paragraphs map to the first line; compute logicalY in
resolveOffsetInCell (using e.clientY, container.getBoundingClientRect().top,
container.scrollTop and getScaleFactor()) and pass it into
resolveOffsetInCellAtX (add a logicalY parameter), then inside
resolveOffsetInCellAtX compute cellOriginY (use
paginatedLayout.pageSetup.margins.top + tl.rowYOffsets[cellAddr.rowIndex] or the
table's row Y offsets) and localY = logicalY - cellOriginY; while iterating
cell.lines subtract each line's height (or use line.height/line.metrics) from
localY to find the target line index, and only run the per-run X measurement for
that specific line (leave other logic intact and return offset + i and
currentBlockIndex once the correct line and run position is found).
🪄 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: 101f78d6-a20f-4136-aed4-f3c8d1d923b1

📥 Commits

Reviewing files that changed from the base of the PR and between aebb3a6 and 8181cbf.

📒 Files selected for processing (7)
  • packages/docs/src/model/document.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/peer-cursor.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/table-layout.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/model/table.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/src/view/selection.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/docs/test/model/table.test.ts
  • packages/docs/src/model/types.ts

Comment thread packages/docs/src/model/document.ts Outdated
Comment on lines +144 to +173
function layoutCellBlocks(
blocks: Block[],
ctx: CanvasRenderingContext2D,
maxWidth: number,
): { lines: LayoutLine[]; blockBoundaries: number[] } {
if (blocks.length === 0) {
const defaultHeight = ptToPx(Theme.defaultFontSize) * 1.5;
return {
lines: [{ runs: [], y: 0, height: defaultHeight, width: 0 }],
blockBoundaries: [0],
};
}

const allLines: LayoutLine[] = [];
const blockBoundaries: number[] = [];

for (const block of blocks) {
blockBoundaries.push(allLines.length);
const blockLines = layoutCellInlines(block.inlines, ctx, maxWidth);
allLines.push(...blockLines);
}

// Recalculate cumulative y offsets
let y = 0;
for (const line of allLines) {
line.y = y;
y += line.height;
}

return { lines: allLines, blockBoundaries };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Cell blocks are still laid out as plain inline text.

layoutCellBlocks() ignores block.type, headingLevel, listKind, listLevel, and block-level indentation/spacing, so headings and list items inside cells will render like plain paragraphs. If table cells are now mini-documents, this needs the same block-aware layout path as the main document flow.

Also applies to: 223-226

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

In `@packages/docs/src/view/table-layout.ts` around lines 144 - 173,
layoutCellBlocks currently treats every Block as plain inline text by calling
layoutCellInlines; update it to perform block-aware layout like the main
document flow: inspect block.type (e.g., "heading", "paragraph", "list"),
headingLevel, listKind, listLevel and apply the corresponding block layout logic
(styles, font sizes, indentation, list markers, and spacing) instead of only
inlines, by reusing or delegating to the existing block-aware layout functions
used for the main flow (e.g., the layoutBlock / layoutBlocks or flow layout
helpers) and ensure blockBoundaries and line y offsets are computed the same
way; also update the related usage that currently calls layoutCellInlines (the
other occurrence referenced around layoutCellInlines) so list items and headings
inside cells render with proper indentation and spacing.

hackerwins and others added 4 commits March 29, 2026 18:55
Document-level operations (search, Bold/Italic) were only working on
top-level blocks, ignoring table cell content entirely. The cell-aware
methods existed but were never connected to the toolbar/keyboard paths.

- searchText now descends into table cell blocks, returning matches
  with cellAddress and cellBlockIndex
- toggleStyle and EditorAPI.applyStyle check cellAddress and route to
  applyCellInlineStyle instead of applyInlineStyle
- Find/Replace highlights and replace operations work within cells
- SearchMatch type extended with cellAddress and cellBlockIndex fields

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
getStyleAtCursor() and getSelectionStyle() were reading from the
top-level table block's empty inlines instead of the cell's block
content. This caused all toggle operations (bold, italic, underline,
etc.) to always apply but never remove — the current state was always
read as undefined/false.

- getStyleAtCursor: read from cell block inlines when cellAddress set
- getSelectionStyle: same fix for toolbar button state display
- clearFormatting: route to applyCellInlineStyle for cells
- insertLink/removeLink/getLinkAtCursor: cell-aware routing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Table cells can now contain list-item, heading, and other block types.
Previously, block-type operations (list toggle, indent/outdent, style
dropdown) only worked on top-level blocks and ignored cell content.

- Add Doc.setBlockTypeInCell() for changing cell block types
- Route getBlockType/setBlockType/toggleList/indent/outdent through
  cellAddress when cursor is in a table cell
- Layout: reserve list indent space in layoutCellBlocks
- Render: draw bullet/number markers for list-item blocks in cells
- Keyboard shortcuts (⌘⇧7/8, ⌘]/[) work inside cells

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
handleArrow had a separate cell-specific branch that bypassed
moveLeft/moveRight and constructed positions without cellBlockIndex.
This caused arrow left/right to jump between blocks incorrectly —
the cursor would land at a flat offset in the first block instead
of the correct block.

Now delegates to moveLeft/moveRight which properly handle block
boundaries and preserve cellBlockIndex. Also adds cellBlockIndex: 0
to up/down cell navigation positions.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/docs/src/view/text-editor.ts (2)

1949-1995: ⚠️ Potential issue | 🔴 Critical

Arrow navigation still loses cellBlockIndex.

These helpers are block-aware, but handleArrow() still bypasses them for table cells, compares pos.offset against whole-cell lengths, and rebuilds positions without cellBlockIndex. ArrowLeft/ArrowRight from any non-first/non-last internal block therefore jumps to the wrong place or exits the cell early. The word-mod path needs the same block-scoped treatment.


2255-2305: ⚠️ Potential issue | 🔴 Critical

Cell hit-testing needs the pointer Y coordinate.

This resolver only receives X and then scans lines from the top. In a wrapped paragraph or multi-block cell, clicking or dragging on a lower visual line resolves into the first line with the same X position, so caret placement and drag selection are incorrect.

🤖 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 2255 - 2305, The resolver
only uses X so clicks map to the top-most line; change resolveOffsetInCell to
also compute logicalY from e.clientY (similar to logicalX) and pass it into
resolveOffsetInCellAtX, then in resolveOffsetInCellAtX compute localY for the
cell (using the page Y offset / margins and the table/row Y offset for
cellAddr.rowIndex) and use localY to pick the correct line index instead of
scanning from the top—i.e., determine lineIdx by comparing localY against each
line's vertical position or accumulated line heights, then only iterate runs in
that single visual line to measure X and return the correct offset and
cellBlockIndex (keep using cell.blockBoundaries/currentBlockIndex logic when
mapping lineIdx to block index).
🤖 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/model/document.ts`:
- Around line 539-555: splitBlockInCell always creates newBlock with type
'paragraph', dropping list semantics (listKind/listLevel) when splitting a cell;
update splitBlockInCell to preserve list metadata and exit-behavior like
splitBlock: detect if targetBlock has listKind/listLevel and copy those fields
(and any list-specific behavior such as empty-list-item exit) onto the newBlock
and maintain the same type if it's a list-item, ensure
targetBlock.inlines/afterInlines handling remains the same, and reuse the same
logic splitBlock uses for empty-list-item exit so splitting inside list-items in
cells preserves list semantics and exit behavior.

In `@packages/docs/src/view/text-editor.ts`:
- Around line 947-975: The cell-aware delete logic only handles single-character
Backspace/Delete; update handleWordBackspace, handleWordDelete, and
handleLineBackspace to detect when cursor.position.cellAddress is set and, in
that case, operate on the cell-specific APIs (use
this.doc.getCellBlockTextLength, this.doc.deleteTextInCell,
this.doc.mergeBlocksInCell) instead of this.doc.getBlock / this.doc.deleteText,
and adjust cursor moves to set/maintain cellAddress and cellBlockIndex (mirror
the approach in the existing single-char backspace branch). Ensure each handler
preserves the original shortcut semantics (word/line deletion or merge) but
applies them inside the cell, calls markDirty(pos.blockId), requestRender(), and
invalidateLayout() where appropriate.
- Around line 200-220: The IME live update path in handleInput() is not passing
startPosition.cellBlockIndex into the cell-aware edits and cursor updates;
update handleInput() so that when it calls deleteTextInCell(...) and
insertTextInCell(...) it forwards startPosition.cellBlockIndex (or 0/undefined
consistently) just like the commit path does, and ensure the resulting
cursor/selection calculation uses that same cellBlockIndex when constructing the
new DocPosition (matching how endPos is built in the commit code) so previews
and final commits target the correct cell block.

---

Outside diff comments:
In `@packages/docs/src/view/text-editor.ts`:
- Around line 2255-2305: The resolver only uses X so clicks map to the top-most
line; change resolveOffsetInCell to also compute logicalY from e.clientY
(similar to logicalX) and pass it into resolveOffsetInCellAtX, then in
resolveOffsetInCellAtX compute localY for the cell (using the page Y offset /
margins and the table/row Y offset for cellAddr.rowIndex) and use localY to pick
the correct line index instead of scanning from the top—i.e., determine lineIdx
by comparing localY against each line's vertical position or accumulated line
heights, then only iterate runs in that single visual line to measure X and
return the correct offset and cellBlockIndex (keep using
cell.blockBoundaries/currentBlockIndex logic when mapping lineIdx to block
index).
🪄 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: 4627345c-551a-4fa5-815e-e1e7655a104b

📥 Commits

Reviewing files that changed from the base of the PR and between 8181cbf and a9f4a3c.

📒 Files selected for processing (5)
  • packages/docs/src/model/document.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/find-replace.ts
  • packages/docs/src/view/text-editor.ts

Comment thread packages/docs/src/model/document.ts
Comment thread packages/docs/src/view/text-editor.ts
Comment thread packages/docs/src/view/text-editor.ts
hackerwins and others added 6 commits March 29, 2026 19:21
Selection across multiple blocks within a cell (e.g. selecting from
block 0 to block 1) was broken: the selection range disappeared when
start and end offsets matched, and styles only applied to one block.

Root cause: normalizeRange, hasSelection, and computeSelectionRects
compared only offset values without considering cellBlockIndex.

- normalizeRange: compare cellBlockIndex before offset
- hasSelection: treat different cellBlockIndex as valid selection
- computeSelectionRects: different cellBlockIndex is not empty
- getSelectedText: concatenate text across cell blocks
- Add applyCellStyleToRange helper for cross-block style application

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
resolveOffsetInCell only used X coordinate, iterating all cell lines
top-to-bottom and returning the first X match. For cells with
identical text in multiple blocks (e.g. "asdf" in both), clicks on
the second block always landed in the first.

Now uses Y coordinate to first determine which line was clicked, then
resolves X within that specific line. The Y is mapped to cell-local
coordinates via the paginated layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Cell single-click set selection to null, which prevented drag
selection from starting — handleMouseMove early-returned when
selection.range was null.

- Set anchor position on cell click (same as non-cell click)
- Use Y-aware resolution for drag selection within cells

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Up/Down in cells skipped cell-internal blocks entirely, jumping
straight to the cell above/below or exiting the table. Now moves
between blocks within the same cell first, then to adjacent cells.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
moveVertical used paginatedPixelToPosition which returned the table
block ID with a character offset — but table blocks have empty
inlines, so the cursor landed at an invalid position.

Now detects when the result targets a table block and enters the
appropriate cell: Down enters first cell (0,0), Up enters last cell.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The canvas container's mousedown handler captured all clicks within
the container div, including clicks on context menu buttons rendered
inside the same container. This moved the cursor to the click
position and prevented the menu action from executing.

Now skips mousedown handling when the target is a button, input, or
element inside a menu role container.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
packages/docs/src/model/document.ts (2)

753-770: ⚠️ Potential issue | 🔴 Critical

Merging cells still loses every source block after the first.

Line 756 only copies cell.blocks[0] into topLeft.blocks[0], even though Line 753 checks getCellText(cell) across the whole cell. Content in cell.blocks[1+] disappears, and the surviving content is flattened into the first destination block. Please append each source block as its own block on topLeft.blocks instead of splicing only the first block’s inlines.

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

In `@packages/docs/src/model/document.ts` around lines 753 - 770, The merge
routine is only copying cell.blocks[0] into topLeft.blocks[0], which discards
blocks at cell.blocks[1+]; instead iterate over all blocks in cell.blocks and
for each append a corresponding block object to topLeft.blocks (preserving or
cloning id/type/style/inlines, using generateBlockId() and DEFAULT_BLOCK_STYLE
as needed for any missing fields) so each source block becomes its own block in
topLeft.blocks rather than flattening all inlines into the first block; keep the
subsequent "Mark as covered" assignment to cell.blocks and the final
normalizeInlinesArray(topLeft.blocks[0].inlines) step (or adjust normalization
to run on each block if desired).

539-555: ⚠️ Potential issue | 🟠 Major

Preserve list-item behavior when splitting a cell block.

Lines 548-555 still hard-code the new block to paragraph, and this path never handles the empty-list-item exit case. Pressing Enter inside a list item in a table cell will drop listKind/listLevel instead of matching splitBlock().

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

In `@packages/docs/src/model/document.ts` around lines 539 - 555, When creating
the new block after splitting a cell, don't hard-code type:'paragraph' or drop
list metadata; set the new block's type to targetBlock.type and copy
list-related fields (e.g., listKind, listLevel) from targetBlock so list items
inside table cells preserve list behavior, and implement the empty-list-item
exit logic used by splitBlock() (i.e., when splitting an empty list item follow
the same exit-to-paragraph behavior as splitBlock) rather than unconditionally
producing a paragraph without list metadata; locate this change around the code
that builds newBlock (uses generateBlockId(), buildInlinesFromSplit(),
getStyleAtOffset()) and update the newBlock construction to mirror
splitBlock()'s handling of listKind/listLevel and empty-item exits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 869-879: The current branch uses doc.applyCellInlineStyle once
with anchor.cellBlockIndex, which misapplies href when the normalized selection
spans multiple blocks; instead split the normalized selection across the cell's
blocks and call doc.applyCellInlineStyle per-block with the correct start/end
offsets and per-block index (use anchor.cellBlockIndex ?? 0 as the base), then
call markDirty(anchor.blockId) and render() once after applying to all affected
blocks; locate the code around anchor.cellAddress, selection.getNormalizedRange,
normalized.start/end, and replace the single applyCellInlineStyle call with a
loop that applies the href to each block segment within the normalized range.
- Around line 375-378: setSearchMatches currently builds auto-scroll rects from
block-only ranges, so table-cell matches (which now carry cellAddress and
cellBlockIndex) won't scroll correctly; update setSearchMatches (and any helper
that computes active-match scroll rects) to thread the full range shape
including anchor.cellAddress, anchor.cellBlockIndex, focus.cellAddress, and
focus.cellBlockIndex when constructing the auto-scroll/visible-rect for each
match so scrolling uses the cell-aware coordinates for table hits; ensure any
calls that create the match object (the place that previously set anchor/focus
with only blockId/offset) are changed to pass through the new
cellAddress/cellBlockIndex fields so active-match navigation scrolls table cells
into view.

In `@packages/docs/src/view/table-renderer.ts`:
- Around line 148-174: The list counter logic in table-renderer.ts increments
listCounters for every list-item regardless of kind, causing ordered lists to
continue numbering across unordered entries; modify the loop around listCounters
so that you only compute/increment/set the counter when cellBlock.listKind ===
'ordered' (using cellBlock.listLevel/listLevel as the key) and when
cellBlock.listKind === 'unordered' delete/reset the counter for that specific
level so subsequent ordered items restart; keep the existing deeper-level
cleanup logic (for ... of listCounters) and use UNORDERED_MARKERS when choosing
markers for unordered items.

---

Duplicate comments:
In `@packages/docs/src/model/document.ts`:
- Around line 753-770: The merge routine is only copying cell.blocks[0] into
topLeft.blocks[0], which discards blocks at cell.blocks[1+]; instead iterate
over all blocks in cell.blocks and for each append a corresponding block object
to topLeft.blocks (preserving or cloning id/type/style/inlines, using
generateBlockId() and DEFAULT_BLOCK_STYLE as needed for any missing fields) so
each source block becomes its own block in topLeft.blocks rather than flattening
all inlines into the first block; keep the subsequent "Mark as covered"
assignment to cell.blocks and the final
normalizeInlinesArray(topLeft.blocks[0].inlines) step (or adjust normalization
to run on each block if desired).
- Around line 539-555: When creating the new block after splitting a cell, don't
hard-code type:'paragraph' or drop list metadata; set the new block's type to
targetBlock.type and copy list-related fields (e.g., listKind, listLevel) from
targetBlock so list items inside table cells preserve list behavior, and
implement the empty-list-item exit logic used by splitBlock() (i.e., when
splitting an empty list item follow the same exit-to-paragraph behavior as
splitBlock) rather than unconditionally producing a paragraph without list
metadata; locate this change around the code that builds newBlock (uses
generateBlockId(), buildInlinesFromSplit(), getStyleAtOffset()) and update the
newBlock construction to mirror splitBlock()'s handling of listKind/listLevel
and empty-item exits.
🪄 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: 67d9f878-4342-4a87-b197-bea04ecaff32

📥 Commits

Reviewing files that changed from the base of the PR and between a9f4a3c and ebf1a5e.

📒 Files selected for processing (6)
  • packages/docs/src/model/document.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/table-layout.ts
  • packages/docs/src/view/table-renderer.ts
  • packages/docs/src/view/text-editor.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/src/view/selection.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/docs/src/view/table-layout.ts
  • packages/docs/src/view/text-editor.ts

Comment on lines +375 to +378
{
anchor: { blockId: match.blockId, offset: match.startOffset, cellAddress: match.cellAddress, cellBlockIndex: match.cellBlockIndex },
focus: { blockId: match.blockId, offset: match.endOffset, cellAddress: match.cellAddress, cellBlockIndex: match.cellBlockIndex },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Thread this same range shape into active-match scrolling.

These highlight rects now include cellAddress and cellBlockIndex, but setSearchMatches() still computes its auto-scroll rects from a block-only range. Table hits can highlight correctly yet fail to scroll into view when navigating search results.

Possible fix
-        const rects = computeSelectionRects(
-          { anchor: { blockId: match.blockId, offset: match.startOffset }, focus: { blockId: match.blockId, offset: match.endOffset } },
+        const rects = computeSelectionRects(
+          {
+            anchor: {
+              blockId: match.blockId,
+              offset: match.startOffset,
+              cellAddress: match.cellAddress,
+              cellBlockIndex: match.cellBlockIndex,
+            },
+            focus: {
+              blockId: match.blockId,
+              offset: match.endOffset,
+              cellAddress: match.cellAddress,
+              cellBlockIndex: match.cellBlockIndex,
+            },
+          },
🤖 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 375 - 378, setSearchMatches
currently builds auto-scroll rects from block-only ranges, so table-cell matches
(which now carry cellAddress and cellBlockIndex) won't scroll correctly; update
setSearchMatches (and any helper that computes active-match scroll rects) to
thread the full range shape including anchor.cellAddress, anchor.cellBlockIndex,
focus.cellAddress, and focus.cellBlockIndex when constructing the
auto-scroll/visible-rect for each match so scrolling uses the cell-aware
coordinates for table hits; ensure any calls that create the match object (the
place that previously set anchor/focus with only blockId/offset) are changed to
pass through the new cellAddress/cellBlockIndex fields so active-match
navigation scrolls table cells into view.

Comment thread packages/docs/src/view/editor.ts
Comment thread packages/docs/src/view/table-renderer.ts
hackerwins and others added 2 commits March 29, 2026 20:06
When drag-selecting within a cell and crossing into another cell, the
selection switches from text mode to cell-range mode, highlighting
entire cells. When the mouse leaves the table, the selection falls
back to block-range mode.

Key changes:
- Add TableCellRange type and DocRange.tableCellRange field
- normalizeRange accepts cell-range selections
- updateDragSelection detects cell boundary crossing via
  resolveTableCellClick and switches to cell-range mode
- buildCellRangeRects renders full-cell highlight rectangles
- deleteSelection clears cell content for cell-range selections
- getSelectedText returns tab/newline-separated cell text

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Hide cursor when tableCellRange is active (no blinking caret)
- Add applyStyleToCellRange: applies inline style to all blocks
  in all cells within the selected range
- toggleStyle and applyStyle check tableCellRange before falling
  back to single-cell style routing

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/docs/src/view/selection.ts (1)

385-390: ⚠️ Potential issue | 🟠 Major

Return type should be NormalizedRange | null to expose tableCellRange to callers.

The method returns the result of normalizeRange() which is NormalizedRange | null, but the declared return type { start: DocPosition; end: DocPosition } | null omits the tableCellRange property. This prevents callers from type-safely accessing tableCellRange for cell-range selections, even though the property is present at runtime.

As shown in the relevant code snippet from text-editor.ts (lines 1816-1831), callers need to check normalized.tableCellRange to branch on cell-range mode.

🔧 Proposed fix
   getNormalizedRange(
     layout: DocumentLayout,
-  ): { start: DocPosition; end: DocPosition } | null {
+  ): NormalizedRange | null {
     if (!this.range || !this.hasSelection()) return null;
     return normalizeRange(this.range, layout);
   }
🤖 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 385 - 390, The return type
of getNormalizedRange should be changed to NormalizedRange | null so callers can
access the tableCellRange property safely; update the method signature
getNormalizedRange(layout: DocumentLayout): NormalizedRange | null and leave the
implementation returning normalizeRange(this.range, layout) unchanged so callers
(e.g., code that checks normalized.tableCellRange in text-editor.ts) can
type-safely branch on cell-range selections.
🤖 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 330-342: The loop uses tablePageY !== 0 to break the outer loop
which fails when a table legitimately sits at Y=0; change to an explicit found
flag (e.g., foundTable) or similar boolean: set foundTable = true when
pl.blockIndex === blockIndex && pl.lineIndex === 0, assign tablePageY and
tableRowBaseY using getPageYOffset(...) and tl.rowYOffsets[0], break the inner
loop, and then break the outer loop when foundTable is true; reference symbols:
tablePageY, tableRowBaseY, paginatedLayout.pages, pl.blockIndex, pl.lineIndex,
getPageYOffset, tl.rowYOffsets.

---

Outside diff comments:
In `@packages/docs/src/view/selection.ts`:
- Around line 385-390: The return type of getNormalizedRange should be changed
to NormalizedRange | null so callers can access the tableCellRange property
safely; update the method signature getNormalizedRange(layout: DocumentLayout):
NormalizedRange | null and leave the implementation returning
normalizeRange(this.range, layout) unchanged so callers (e.g., code that checks
normalized.tableCellRange in text-editor.ts) can type-safely branch on
cell-range selections.
🪄 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: 9eaf3b58-2cca-4c81-849f-3ce3a1bb009e

📥 Commits

Reviewing files that changed from the base of the PR and between ebf1a5e and d3995f7.

📒 Files selected for processing (4)
  • packages/docs/src/index.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/text-editor.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/docs/src/index.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/text-editor.ts

Comment thread packages/docs/src/view/selection.ts
hackerwins and others added 6 commits March 29, 2026 20:19
When dragging from inside a cell to outside the table, select all
cells instead of dropping into block-range mode. When dragging from
a paragraph across a table block, the table is highlighted as a
whole (all cells selected).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
applyBlockStyle and handleAlign only operated on top-level blocks,
ignoring cell-internal blocks. Cell layout also didn't apply
horizontal alignment to line runs.

- Add Doc.applyBlockStyleInCell for cell block styles
- Route EditorAPI.applyBlockStyle and handleAlign through cellAddress
- Export applyAlignment from layout.ts and use in layoutCellBlocks
- Alignment shortcuts (⌘⇧L/E/R/J) work inside cells

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1. splitBlockInCell preserves list-item type and exit behavior
2. IME live update passes cellBlockIndex in handleInput composition
3. mergeCells appends all blocks from covered cells, not just first
4. insertLink uses applyCellStyleToRange for cross-block support
5. buildCellRangeRects uses found flag instead of tablePageY !== 0
6. List marker counter only increments for ordered items, resets on
   unordered to prevent 1. • 3. numbering

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Right-clicking while a cell-range selection was active triggered
handleMouseDown which reset the cursor and selection. Now skips
mousedown for right-click (button 2) when tableCellRange is set,
so the context menu opens with the selection preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When dragging from inside a cell to outside the table, the selection
highlighted all table cells but excluded external paragraphs. The
tableCellRange mode locked the focus inside the table.

Now switches to block-range selection (no tableCellRange) with the
anchor at the table block start and focus at the external position.
buildRects already highlights table blocks as full-cell rects within
a block-range selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Shift+Arrow in cells only supported text selection within the same
cell. Crossing cell boundaries or exiting the table was ignored.

- Shift+Left/Right at cell boundary: sets tableCellRange for
  cross-cell selection
- Shift+Left at first cell / Shift+Right at last cell: exits table
  with block-range selection including external content
- Shift+Up/Down across cells: sets tableCellRange when cellAddress
  differs between anchor and focus

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@hackerwins hackerwins changed the title Change TableCell to block-based containers for CRDT collaboration Add table CRDT with block-based cells and cross-cell selection Mar 29, 2026
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