Add Ranges type for multi-range selection support#48
Conversation
Introduce `Ranges = Range[]` type and 8 utility functions in coordinates.ts to represent multiple disjoint rectangular regions. This is phase 1 of multi-range support — type and utilities only, with Sheet selection model and UI integration to follow. Co-Authored-By: Claude Opus 4.6 <[email protected]>
📝 WalkthroughWalkthroughAdds a new exported Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(200,200,255,0.5)
participant User
end
rect rgba(200,255,200,0.5)
participant Worksheet
participant Overlay
participant FormulaBar
end
rect rgba(255,200,200,0.5)
participant SheetModel
participant Spreadsheet
end
User->>Worksheet: mouseDown (with/without Ctrl)
Worksheet->>SheetModel: addSelection / selectStart
User->>Worksheet: mouseMove / mouseUp
Worksheet->>SheetModel: addSelectionEnd / selectEnd
SheetModel->>Spreadsheet: notifySelectionChange
Spreadsheet->>Overlay: render(getRanges())
Spreadsheet->>FormulaBar: updateDisplay(getRanges(), activeRef)
Overlay->>User: draw selections (iterate ranges)
FormulaBar->>User: display active-range string
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
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 99.6s
Verification: verify:integrationResult: ✅ PASS |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
packages/sheet/src/model/core/coordinates.ts (1)
442-443: Make single-item return path clone-consistent inmergeOverlapping.Line 442 returns a shallow array copy for
length === 1, while later paths clone ranges. Returning[cloneRange(ranges[0])]keeps mutability behavior consistent.♻️ Proposed refactor
export function mergeOverlapping(ranges: Ranges): Ranges { - if (ranges.length <= 1) return [...ranges]; + if (ranges.length === 0) return []; + if (ranges.length === 1) return [cloneRange(ranges[0])];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheet/src/model/core/coordinates.ts` around lines 442 - 443, The single-item early return in mergeOverlapping currently returns a shallow reference [...ranges] which is inconsistent with later paths that return cloned ranges; change the early-return to return a cloned single item by using cloneRange(ranges[0]) so callers always receive independent Range objects; locate mergeOverlapping and replace the return [...ranges] for the length === 1 case with an array containing cloneRange(ranges[0]) to ensure consistent immutability behavior.packages/sheet/src/model/core/coordinates.test.ts (1)
120-152: AddparseRangesedge-case coverage for empty/invalid segments.Given this suite already defines serialization behavior for empty ranges (Line 110-112), please also assert parse behavior for
''and malformed comma segments (e.g.,'A1,,B2') to prevent regressions.🧪 Suggested tests
describe('parseRanges', () => { + it('should return empty array for empty string', () => { + expect(parseRanges('')).toEqual([]); + }); + + it('should throw for empty range segments', () => { + expect(() => parseRanges('A1,,B2')).toThrow(); + }); + it('should parse comma-separated ranges', () => { const result = parseRanges('A1:B2,C3:D4'); expect(result).toEqual([r(1, 1, 2, 2), r(3, 3, 4, 4)]); });As per coding guidelines, "Write tests for critical business logic and edge cases."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheet/src/model/core/coordinates.test.ts` around lines 120 - 152, Add tests covering parseRanges edge cases: assert parseRanges('') returns [] (empty array) and parseRanges('A1,,B2') treats empty/malformed segments gracefully (e.g., ignores empty segment and returns ranges for A1 and B2) and that round-tripping via toSrngFromRanges on the parsed result yields the expected normalized string ('' -> '' and 'A1,,B2' -> 'A1,B2'). Update the existing describe blocks that exercise parseRanges and toSrngFromRanges to include these assertions so regressions for empty/invalid segments are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/tasks/active/20260319-ranges-type-todo.md`:
- Around line 1-72: Create a paired lessons file named
20260319-ranges-type-lessons.md alongside the existing
20260319-ranges-type-todo.md and populate it with concise entries for each phase
(Phase 1, Phase 2, Phase 3) capturing key learnings, blockers encountered (e.g.,
changes to Range -> Ranges, serialization/parsing pitfalls), decisions made
(e.g., keeping backward-compatible single-range support), test outcomes
(reference the 33 unit tests and pnpm verify:fast), and next actions; ensure you
reference the task ID/date, mention the types and utilities added (Ranges,
toRanges, inRanges, isIntersectRanges, toRefsFromRanges, toSrngFromRanges,
parseRanges, mergeOverlapping, removeRange) and include short timestamps/status
notes so future reviewers can see progress and outcomes.
In `@packages/sheet/src/model/core/coordinates.ts`:
- Around line 427-435: parseRanges currently throws on empty input which breaks
the roundtrip with toSrngFromRanges (which returns '' for an empty set); update
parseRanges to handle empty or whitespace-only strings by returning an empty
Ranges array immediately (before splitting/processing). Locate the parseRanges
function and add a guard like checking s.trim() === '' and returning [] so
callers that expect the serialized empty selection (produced by
toSrngFromRanges) will parse back to an empty Ranges without invoking
parseRange/parseRef.
---
Nitpick comments:
In `@packages/sheet/src/model/core/coordinates.test.ts`:
- Around line 120-152: Add tests covering parseRanges edge cases: assert
parseRanges('') returns [] (empty array) and parseRanges('A1,,B2') treats
empty/malformed segments gracefully (e.g., ignores empty segment and returns
ranges for A1 and B2) and that round-tripping via toSrngFromRanges on the parsed
result yields the expected normalized string ('' -> '' and 'A1,,B2' -> 'A1,B2').
Update the existing describe blocks that exercise parseRanges and
toSrngFromRanges to include these assertions so regressions for empty/invalid
segments are caught.
In `@packages/sheet/src/model/core/coordinates.ts`:
- Around line 442-443: The single-item early return in mergeOverlapping
currently returns a shallow reference [...ranges] which is inconsistent with
later paths that return cloned ranges; change the early-return to return a
cloned single item by using cloneRange(ranges[0]) so callers always receive
independent Range objects; locate mergeOverlapping and replace the return
[...ranges] for the length === 1 case with an array containing
cloneRange(ranges[0]) to ensure consistent immutability behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 07c02be5-4173-4a6f-807d-63b2d73b5065
📒 Files selected for processing (5)
docs/tasks/active/20260319-ranges-type-todo.mdpackages/sheet/src/index.tspackages/sheet/src/model/core/coordinates.test.tspackages/sheet/src/model/core/coordinates.tspackages/sheet/src/model/core/types.ts
| # Add `Ranges` Type — Multi-Range Data Type | ||
|
|
||
| **Goal:** Introduce a `Ranges` type and utility functions to represent multiple disjoint rectangular regions, then incrementally integrate into the selection model and UI. | ||
|
|
||
| **Status:** Phase 1 DONE | ||
|
|
||
| **Context:** The current `Range = [Ref, Ref]` can only represent a single rectangle. Multi-range support is needed for Ctrl+click multi-selection, formula engine, conditional formatting, and chart data ranges. This task is broken into three phases to allow incremental delivery. | ||
|
|
||
| ## Design | ||
|
|
||
| ### Type Definition (`types.ts`) | ||
|
|
||
| ```typescript | ||
| type Ranges = Range[]; | ||
| ``` | ||
|
|
||
| - Empty array `[]` means "no selection" | ||
| - A single Range is representable as `[range]` (backward-compatible) | ||
|
|
||
| ### Utility Functions (`coordinates.ts`) | ||
|
|
||
| | Function | Signature | Description | | ||
| |----------|-----------|-------------| | ||
| | `toRanges` | `(...ranges: Range[]) => Ranges` | Creation helper; normalizes each Range | | ||
| | `inRanges` | `(ref: Ref, ranges: Ranges) => boolean` | Whether ref falls inside any Range | | ||
| | `isIntersectRanges` | `(a: Ranges, b: Ranges) => boolean` | Whether two Ranges overlap | | ||
| | `toRefsFromRanges` | `(ranges: Ranges) => Generator<Ref>` | Iterate all cells across all ranges | | ||
| | `toSrngFromRanges` | `(ranges: Ranges) => string` | Serialize to `"A1:A2,B1,B2:B3"` | | ||
| | `parseRanges` | `(s: string) => Ranges` | Parse comma-separated string | | ||
| | `mergeOverlapping` | `(ranges: Ranges) => Ranges` | Merge overlapping ranges | | ||
| | `removeRange` | `(ranges: Ranges, target: Range) => Ranges` | Remove a specific range | | ||
|
|
||
| ### Design Principles | ||
|
|
||
| - Reuse existing `Range` utilities (`inRange`, `isIntersect`, `toRefs`, etc.) internally | ||
| - Follow the existing pattern of adding functions to `coordinates.ts` | ||
| - Unit tests for every function | ||
|
|
||
| ## Phase 1: Type and Utility Functions (current scope) | ||
|
|
||
| - [x] Task 1: Add `Ranges` type definition and export in `types.ts` | ||
| - [x] Task 2: Add `toRanges`, `inRanges` to `coordinates.ts` | ||
| - [x] Task 3: Add `isIntersectRanges`, `toRefsFromRanges` to `coordinates.ts` | ||
| - [x] Task 4: Add `toSrngFromRanges`, `parseRanges` to `coordinates.ts` | ||
| - [x] Task 5: Add `mergeOverlapping`, `removeRange` to `coordinates.ts` | ||
| - [x] Task 6: Write unit tests for all functions (33 tests) | ||
| - [x] Task 7: Confirm `pnpm verify:fast` passes | ||
|
|
||
| ## Phase 2: Sheet Selection Model | ||
|
|
||
| Replace the single-range selection in Sheet with `Ranges` support. | ||
|
|
||
| - [ ] Replace `range?: Range` field with `ranges: Ranges` in `Sheet` | ||
| - [ ] Add `addSelection(ref: Ref)` method for Ctrl+click (appends a new range) | ||
| - [ ] Active cell follows the start of the last added range (Google Sheets behavior) | ||
| - [ ] Update `getRange()` / `getRangeOrActiveCell()` for backward compatibility | ||
| - [ ] Update selection-related methods (`selectRow`, `selectColumn`, etc.) | ||
| - [ ] Unit tests for multi-selection behavior | ||
|
|
||
| ## Phase 3: Frontend UI Integration | ||
|
|
||
| Wire multi-range selection into the frontend rendering and interaction layer. | ||
|
|
||
| - [ ] Handle Ctrl+click / Cmd+click events in `sheet-view.tsx` to call `addSelection()` | ||
| - [ ] Render multiple selection highlights on the Canvas | ||
| - [ ] Update selection-dependent UI (formula bar range display, context menu) | ||
| - [ ] Update copy/paste to operate on multiple ranges | ||
| - [ ] Interaction tests for multi-selection | ||
|
|
||
| ## Future Phases (out of scope) | ||
|
|
||
| - **Phase 4:** Data model expansion — formula engine multi-range arguments, conditional formatting with `Ranges`, chart data ranges |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify paired task docs exist for this slug.
fd -a '20260319-ranges-type-(todo|lessons)\.md' docs/tasks/activeRepository: wafflebase/wafflebase
Length of output: 130
Add the paired lessons file for this task.
This is a non-trivial task (3 phases with 18+ subtasks), and the coding guidelines require maintaining paired task files: 20260319-ranges-type-todo.md and 20260319-ranges-type-lessons.md. Only the -todo file is present. Create docs/tasks/active/20260319-ranges-type-lessons.md to document learnings, blockers, and outcomes as this task progresses through its phases.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/tasks/active/20260319-ranges-type-todo.md` around lines 1 - 72, Create a
paired lessons file named 20260319-ranges-type-lessons.md alongside the existing
20260319-ranges-type-todo.md and populate it with concise entries for each phase
(Phase 1, Phase 2, Phase 3) capturing key learnings, blockers encountered (e.g.,
changes to Range -> Ranges, serialization/parsing pitfalls), decisions made
(e.g., keeping backward-compatible single-range support), test outcomes
(reference the 33 unit tests and pnpm verify:fast), and next actions; ensure you
reference the task ID/date, mention the types and utilities added (Ranges,
toRanges, inRanges, isIntersectRanges, toRefsFromRanges, toSrngFromRanges,
parseRanges, mergeOverlapping, removeRange) and include short timestamps/status
notes so future reviewers can see progress and outcomes.
Migrate Sheet.range to Sheet.ranges (Ranges = Range[]) for multi-range selection support. Add addSelection() and addSelectionEnd() methods for Ctrl+click multi-selection. Backward-compatible: getRange() returns the last range, getRangeOrActiveCell() works with the last range. Co-Authored-By: Claude Opus 4.6 <[email protected]>
parseRanges('') now returns [] instead of throwing, fixing the
roundtrip with toSrngFromRanges([]). Add paired lessons file
per project conventions.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update overlay to iterate all ranges in Ranges array for both simple (no-freeze) and frozen rendering paths. The formula bar now displays comma-separated range labels (e.g. "A1:B2,C3") when multiple ranges are selected. Ctrl/Cmd+click in worksheet creates additive selections with proper drag support. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
1. moveInRange now navigates across all ranges: when reaching the end of one range, it wraps to the next/previous range. 2. Formula bar shows only the range containing the active cell instead of all ranges (which made the label too wide). 3. setRangeStyle and resetRangeStyleToDefault now apply to all ranges in a multi-selection, not just the last one. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Cover 3+ range cycling, fallback when active cell is outside all ranges, and verify no crashes in all movement directions. Co-Authored-By: Claude Opus 4.6 <[email protected]>
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 (1)
packages/sheet/src/view/worksheet.ts (1)
2892-2922:⚠️ Potential issue | 🟠 MajorPreserve context-menu clicks across all selected ranges.
With multi-range enabled, this still only preserves the active/last range, and
e.ctrlKey || e.metaKeyalso treats macOS Ctrl+click as additive selection. A context-menu click on an already selected secondary block can therefore rewrite the selection before the menu action runs.🔧 Suggested fix
- if (e.button === 2) { - const clickedRef = this.toRefFromMouse(e.offsetX, e.offsetY); - const currentRange = this.sheet!.getRangeOrActiveCell(); - if (inRange(clickedRef, currentRange)) { + const clickedRef = this.toRefFromMouse(e.offsetX, e.offsetY); + const isMacCtrlClick = + navigator.platform.includes('Mac') && + e.button === 0 && + e.ctrlKey && + !e.metaKey; + const currentRanges = this.sheet!.getRanges(); + const inExistingSelection = + currentRanges.length > 0 + ? currentRanges.some((range) => inRange(clickedRef, range)) + : inRange(clickedRef, this.sheet!.getRangeOrActiveCell()); + if (e.button === 2 || isMacCtrlClick) { + if (inExistingSelection) { return; } } @@ - const isMultiSelect = e.ctrlKey || e.metaKey; + const isMultiSelect = + e.metaKey || (e.ctrlKey && !navigator.platform.includes('Mac'));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheet/src/view/worksheet.ts` around lines 2892 - 2922, Right-click/context menu logic only checks the active range (this.sheet!.getRangeOrActiveCell()) and treats Ctrl+click the same as Cmd on macOS, so right-clicking an already-selected secondary range can replace selection; change the early-return to test whether clickedRef (from toRefFromMouse) lies inside any existing selection range (iterate the sheet's selection collection, e.g. this.sheet!.getSelections() / selection ranges) and return if so, and adjust the multi-select detection so macOS uses only metaKey for additive clicks (use platform check to make isMultiSelect true only for e.metaKey on macOS, otherwise e.ctrlKey || e.metaKey). Ensure the rest of the flow (addSelection/selectStart and addSelectionEnd/selectEnd) remains unchanged.
🧹 Nitpick comments (2)
packages/sheet/src/model/core/coordinates.ts (1)
444-462: Consider edge case: non-contiguous ranges with same coordinates but different order may not merge correctly.The
mergeOverlappingfunction sorts ranges by top-left corner (a[0].r, thena[0].c) and performs a single-pass merge. However, for rectangles that overlap but don't share the same starting row, a single pass may miss merges. Consider whether multi-pass or union-find would be more robust for complex overlapping scenarios.For example, three ranges where A overlaps B, and B overlaps C, but after sorting A comes before C — if A and B merge first, the result may not properly merge with C if C was processed before the merged result.
That said, for typical multi-selection use cases (disjoint Ctrl+click regions), this implementation should work correctly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheet/src/model/core/coordinates.ts` around lines 444 - 462, mergeOverlapping can miss transitive overlaps because sorting by top-left and one-pass merging (using isIntersect/isAdjacent + mergeRanges) doesn't guarantee that after merging two ranges the result will be compared with earlier/skipped ranges; change mergeOverlapping to compute transitive unions: either (A) run the merge pass repeatedly until no changes (keep using sorted, cloneRange, isIntersect/isAdjacent, mergeRanges) or (B) build connectivity by checking all pairs and unioning overlapping/adjacent ranges (union-find), then for each connected component merge its member ranges with mergeRanges; use the existing helpers (isIntersect, isAdjacent, mergeRanges, cloneRange) to form group merges so all transitive merges are captured.packages/sheet/src/view/overlay.ts (1)
454-504: Extract the per-range painter before these paths drift.The row/column/cell drawing branches are now duplicated in both the simple and frozen renderers. A small helper that paints one range for a given scroll context would keep future selection-style changes from diverging between the two paths.
As per coding guidelines, "Keep functions small and focused on a single responsibility".
Also applies to: 644-707
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheet/src/view/overlay.ts` around lines 454 - 504, The selection rendering logic inside renderSelectionsSimple duplicates per-range painting for 'row', 'column', and cell cases and should be extracted into a small helper (e.g., paintRangeForScroll or paintSelectionRange) that takes (ctx, range, scroll, selectionType, rowDim, colDim, port) and uses the existing helpers toBoundingRect and expandBoundingRect and theme colors from getThemeColor; move the fillStyle/strokeStyle/lineWidth + fillRect/strokeRect calls for the three branches into that helper and replace each per-range block in renderSelectionsSimple (and the similar code in the frozen renderer around lines 644-707) with a single call to the new helper to avoid duplication and keep functions focused.
🤖 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 `@packages/sheet/src/view/worksheet.ts`:
- Around line 2892-2922: Right-click/context menu logic only checks the active
range (this.sheet!.getRangeOrActiveCell()) and treats Ctrl+click the same as Cmd
on macOS, so right-clicking an already-selected secondary range can replace
selection; change the early-return to test whether clickedRef (from
toRefFromMouse) lies inside any existing selection range (iterate the sheet's
selection collection, e.g. this.sheet!.getSelections() / selection ranges) and
return if so, and adjust the multi-select detection so macOS uses only metaKey
for additive clicks (use platform check to make isMultiSelect true only for
e.metaKey on macOS, otherwise e.ctrlKey || e.metaKey). Ensure the rest of the
flow (addSelection/selectStart and addSelectionEnd/selectEnd) remains unchanged.
---
Nitpick comments:
In `@packages/sheet/src/model/core/coordinates.ts`:
- Around line 444-462: mergeOverlapping can miss transitive overlaps because
sorting by top-left and one-pass merging (using isIntersect/isAdjacent +
mergeRanges) doesn't guarantee that after merging two ranges the result will be
compared with earlier/skipped ranges; change mergeOverlapping to compute
transitive unions: either (A) run the merge pass repeatedly until no changes
(keep using sorted, cloneRange, isIntersect/isAdjacent, mergeRanges) or (B)
build connectivity by checking all pairs and unioning overlapping/adjacent
ranges (union-find), then for each connected component merge its member ranges
with mergeRanges; use the existing helpers (isIntersect, isAdjacent,
mergeRanges, cloneRange) to form group merges so all transitive merges are
captured.
In `@packages/sheet/src/view/overlay.ts`:
- Around line 454-504: The selection rendering logic inside
renderSelectionsSimple duplicates per-range painting for 'row', 'column', and
cell cases and should be extracted into a small helper (e.g.,
paintRangeForScroll or paintSelectionRange) that takes (ctx, range, scroll,
selectionType, rowDim, colDim, port) and uses the existing helpers
toBoundingRect and expandBoundingRect and theme colors from getThemeColor; move
the fillStyle/strokeStyle/lineWidth + fillRect/strokeRect calls for the three
branches into that helper and replace each per-range block in
renderSelectionsSimple (and the similar code in the frozen renderer around lines
644-707) with a single call to the new helper to avoid duplication and keep
functions focused.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a54f1d73-fddf-4807-b948-e1abb943ce6f
📒 Files selected for processing (10)
docs/tasks/active/20260319-ranges-type-lessons.mddocs/tasks/active/20260319-ranges-type-todo.mdpackages/sheet/src/model/core/coordinates.test.tspackages/sheet/src/model/core/coordinates.tspackages/sheet/src/model/worksheet/sheet.tspackages/sheet/src/view/formulabar.tspackages/sheet/src/view/overlay.tspackages/sheet/src/view/spreadsheet.tspackages/sheet/src/view/worksheet.tspackages/sheet/test/sheet/sheet.test.ts
✅ Files skipped from review due to trivial changes (1)
- docs/tasks/active/20260319-ranges-type-lessons.md
🚧 Files skipped from review as they are similar to previous changes (2)
- docs/tasks/active/20260319-ranges-type-todo.md
- packages/sheet/src/model/core/coordinates.test.ts
Summary
Rangestype (Range[]) and 8 utility functions (toRanges,inRanges,isIntersectRanges,toRefsFromRanges,toSrngFromRanges,parseRanges,mergeOverlapping,removeRange)range?: Range) withRangesin Sheet model, with backward-compatiblegetRange()returning the last rangeaddSelection()/addSelectionEnd()for Ctrl/Cmd+click multi-selectionsetRangeStyle,resetRangeStyleToDefault) apply to all rangesTest plan
pnpm verify:fastpasses (1079 sheet + 104 backend + 60 cli + 59 frontend tests)🤖 Generated with Claude Code