Add table CRDT with block-based cells and cross-cell selection#93
Conversation
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]>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughTable cells now store nested Changes
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Verification: verify:selfResult: ✅ PASS in 105.6s
Verification: verify:integrationResult: ✅ PASS |
There was a problem hiding this comment.
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 | 🟡 MinorManual 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 copiescell.blocks[0].inlinesintotopLeft.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 | 🟠 MajorMake 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 onblocks.length !== 1instead 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/activeusing 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) Runpnpm verify:fastand confirm pass, (3) Archive withpnpm 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
colsattribute is missing or empty,columnWidthswill 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 withinsertTextInCell/deleteTextInCellwhich only operate onblocks[0](as shown inpackages/docs/src/model/document.tslines 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: ReusecreateEmptyBlock()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
📒 Files selected for processing (17)
docs/design/docs-table-crdt.mddocs/design/docs-table-ui.mddocs/design/docs-tables.mddocs/design/docs-wordprocessor-roadmap.mddocs/tasks/active/20260329-table-crdt-phase-a-lessons.mddocs/tasks/active/20260329-table-crdt-phase-a-todo.mdpackages/docs/src/index.tspackages/docs/src/model/document.tspackages/docs/src/model/types.tspackages/docs/src/view/selection.tspackages/docs/src/view/table-layout.tspackages/docs/src/view/text-editor.tspackages/docs/test/model/table.test.tspackages/docs/test/model/types.test.tspackages/docs/test/view/table-layout.test.tspackages/docs/test/view/table-selection.test.tspackages/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]>
There was a problem hiding this comment.
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 | 🟡 MinorWrapped 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: Inpackages/docs/src/view/*, when computing selection boundaries and wrap-aware cursor/navigation behavior, include and consistently threadlineAffinitythrough 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 | 🟠 MajorArrowLeft/ArrowRight still bypass the new block-aware movement.
moveLeft()/moveRight()now know how to cross cell block boundaries, buthandleArrow()’s table-cell branch still compares against the whole-cell length and never looks atcellBlockIndex. 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 | 🔴 CriticalA few cell-editing paths still assume a flat cell offset space.
The refactor updated
compositionend, but the live IME path anddeleteSelection()still call the cell APIs withoutcellBlockIndex, andend.offset - start.offsetonly makes sense when both endpoints are in the same cell block. Composing or deleting text incell.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 | 🟠 MajorCell 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 samexas 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
📒 Files selected for processing (7)
packages/docs/src/model/document.tspackages/docs/src/model/types.tspackages/docs/src/view/peer-cursor.tspackages/docs/src/view/selection.tspackages/docs/src/view/table-layout.tspackages/docs/src/view/text-editor.tspackages/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
| 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 }; |
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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 | 🔴 CriticalArrow navigation still loses
cellBlockIndex.These helpers are block-aware, but
handleArrow()still bypasses them for table cells, comparespos.offsetagainst whole-cell lengths, and rebuilds positions withoutcellBlockIndex. 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 | 🔴 CriticalCell 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
📒 Files selected for processing (5)
packages/docs/src/model/document.tspackages/docs/src/model/types.tspackages/docs/src/view/editor.tspackages/docs/src/view/find-replace.tspackages/docs/src/view/text-editor.ts
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]>
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (2)
packages/docs/src/model/document.ts (2)
753-770:⚠️ Potential issue | 🔴 CriticalMerging cells still loses every source block after the first.
Line 756 only copies
cell.blocks[0]intotopLeft.blocks[0], even though Line 753 checksgetCellText(cell)across the whole cell. Content incell.blocks[1+]disappears, and the surviving content is flattened into the first destination block. Please append each source block as its own block ontopLeft.blocksinstead 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 | 🟠 MajorPreserve 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 droplistKind/listLevelinstead of matchingsplitBlock().🤖 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
📒 Files selected for processing (6)
packages/docs/src/model/document.tspackages/docs/src/view/editor.tspackages/docs/src/view/selection.tspackages/docs/src/view/table-layout.tspackages/docs/src/view/table-renderer.tspackages/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
| { | ||
| 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 }, | ||
| }, |
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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 | 🟠 MajorReturn type should be
NormalizedRange | nullto exposetableCellRangeto callers.The method returns the result of
normalizeRange()which isNormalizedRange | null, but the declared return type{ start: DocPosition; end: DocPosition } | nullomits thetableCellRangeproperty. This prevents callers from type-safely accessingtableCellRangefor 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 checknormalized.tableCellRangeto 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
📒 Files selected for processing (4)
packages/docs/src/index.tspackages/docs/src/model/types.tspackages/docs/src/view/selection.tspackages/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
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]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Replace JSON-stringified
tableDatawith 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[]row → cell → block → inline → texttree nodesCell-Internal Multi-Block Editing
cellBlockIndexfield inDocPositiontracks cursor block within cellCell-Aware Document Operations
getStyleAtCursor/getSelectionStyleread from cell block inlinesCross-Cell & Cross-Table Selection
Test plan
pnpm verify:fastpasses (lint + all unit tests)🤖 Generated with Claude Code