Skip to content

Commit f53a3dd

Browse files
committed
[FIX] selection: allow to de-select a zone
Before this commit: - Ctrl+Click on a selected cell had no effect. - Users could add to the selection but couldn't remove cells or zones. Issue Example: 1. Select range A1:C3. 2. Ctrl+Click on B2. 3. Expected: Selection splits into A1:C1, A2, C2, A3:C3. 4. Previously, Ctrl+Click did not deselect B2. After this commit: - Ctrl+Click on a selected cell now removes it. - The selection splits into separate zones as needed. Technical Changes: - UpdateSelection is now triggered on mouseup, Allowing precise detection of Ctrl+Click behavior. - The selection logic removes the zone from any existing selection zones. - Splits overlapping zones into non-overlapping parts. Task: 4647187
1 parent c043827 commit f53a3dd

File tree

8 files changed

+254
-11
lines changed

8 files changed

+254
-11
lines changed

src/components/grid/grid.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ export class Grid extends Component<Props, SpreadsheetChildEnv> {
508508
if (this.paintFormatStore.isActive) {
509509
this.paintFormatStore.pasteFormat(this.env.model.getters.getSelectedZones());
510510
}
511+
this.env.model.selection.updateSelection();
511512
};
512513
this.dragNDropGrid.start(ev, onMouseMove, onMouseUp);
513514
}

src/components/headers_overlay/headers_overlay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ abstract class AbstractResizer extends Component<ResizerProps, SpreadsheetChildE
292292
this.state.isSelecting = false;
293293
this.lastSelectedElementIndex = null;
294294
this._computeGrabDisplay(ev);
295+
this.env.model.selection.updateSelection();
295296
};
296297
this.dragNDropGrid.start(ev, mouseMoveSelect, mouseUpSelect);
297298
}

src/helpers/zones.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,13 @@ export function isZoneInside(smallZone: Zone, biggerZone: Zone): boolean {
373373
return isEqual(union(biggerZone, smallZone), biggerZone);
374374
}
375375

376+
/**
377+
* Check if a zone is already in the list of zones.
378+
*/
379+
export function isZoneAlreadyInZones(zone: Zone, zones: Zone[]): boolean {
380+
return zones.some((z) => isEqual(z, zone));
381+
}
382+
376383
export function zoneToDimension(zone: Zone): ZoneDimension {
377384
return {
378385
numberOfRows: zone.bottom - zone.top + 1,
@@ -691,3 +698,38 @@ export function mergeContiguousZones(zones: Zone[]) {
691698
}
692699
return mergedZones;
693700
}
701+
702+
/**
703+
* Splits zone z2 by removing the overlapping zone z1 (fully inside z2).
704+
* Returns the remaining parts of z2 that don't overlap with z1.
705+
*
706+
* Diagram:
707+
* ┌──────────── z2 ─────────────┐
708+
* │ 1 │
709+
* │--------─────────────--------│
710+
* │ 2 | z1 | 3 │
711+
* │--------─────────────--------│
712+
* │ 4 │
713+
* └─────────────────────────────┘
714+
*
715+
* Input:
716+
* z1 = { top: 2, bottom: 3, left: 2, right: 3 }
717+
* z2 = { top: 1, bottom: 4, left: 1, right: 4 }
718+
*
719+
* Output:
720+
* [
721+
* { top: 4, bottom: 4, left: 1, right: 4 }, // bottom
722+
* { top: 2, bottom: 3, left: 4, right: 4 }, // right
723+
* { top: 2, bottom: 3, left: 1, right: 1 }, // left
724+
* { top: 1, bottom: 1, left: 1, right: 4 } // top
725+
* ]
726+
*/
727+
export function splitZone(z1: Zone, z2: Zone): Zone[] {
728+
const zones: Zone[] = [];
729+
if (z1.bottom < z2.bottom) zones.push({ ...z2, top: z1.bottom + 1 });
730+
if (z1.right < z2.right)
731+
zones.push({ ...z2, left: z1.right + 1, top: z1.top, bottom: z1.bottom });
732+
if (z1.left > z2.left) zones.push({ ...z2, right: z1.left - 1, top: z1.top, bottom: z1.bottom });
733+
if (z1.top > z2.top) zones.push({ ...z2, bottom: z1.top - 1 });
734+
return zones;
735+
}

src/plugins/ui_stateful/selection.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
clip,
66
deepCopy,
77
isEqual,
8+
isZoneAlreadyInZones,
9+
isZoneInside,
810
positionToZone,
11+
splitZone,
912
uniqueZones,
1013
updateSelectionOnDeletion,
1114
updateSelectionOnInsertion,
@@ -74,6 +77,7 @@ export class GridSelectionPlugin extends UIPlugin {
7477
},
7578
zones: [{ top: 0, left: 0, bottom: 0, right: 0 }],
7679
};
80+
private isCurrentZoneInsideZones: boolean = false;
7781
private selectedFigureId: UID | null = null;
7882
private sheetsData: { [sheet: string]: SheetInfo } = {};
7983
private moveClient: (position: ClientPosition) => void;
@@ -111,24 +115,55 @@ export class GridSelectionPlugin extends UIPlugin {
111115
}
112116

113117
private handleEvent(event: SelectionEvent) {
114-
const anchor = event.anchor;
115-
let zones: Zone[] = [];
118+
let anchor = event.anchor;
119+
let zones: Zone[] = [...this.gridSelection.zones];
116120
switch (event.mode) {
117121
case "overrideSelection":
118122
zones = [anchor.zone];
119123
break;
120124
case "updateAnchor":
121-
zones = [...this.gridSelection.zones];
122-
const index = zones.findIndex((z: Zone) => isEqual(z, event.previousAnchor.zone));
123-
if (index >= 0) {
124-
zones[index] = anchor.zone;
125+
const prevZone = event.previousAnchor.zone;
126+
if (!isEqual(prevZone, anchor.zone)) {
127+
this.isCurrentZoneInsideZones =
128+
isZoneAlreadyInZones(anchor.zone, zones) && zones.length > 2;
129+
const index = zones.findIndex((z: Zone) => isEqual(z, prevZone));
130+
if (index >= 0) {
131+
zones[index] = anchor.zone;
132+
}
125133
}
126134
break;
127135
case "newAnchor":
128-
zones = [...this.gridSelection.zones, anchor.zone];
136+
this.isCurrentZoneInsideZones =
137+
isZoneAlreadyInZones(anchor.zone, zones) && zones.length > 1;
138+
zones.push(anchor.zone);
139+
break;
140+
case "updateSelection":
141+
const currentZone = this.gridSelection.anchor.zone;
142+
let newAnchorZone: Zone | undefined;
143+
if (this.isCurrentZoneInsideZones) {
144+
zones = zones.filter((zone) => !isEqual(zone, currentZone));
145+
newAnchorZone = zones.at(-1);
146+
} else {
147+
const zoneToSplit = zones.find(
148+
(zone) => isZoneInside(currentZone, zone) && !isEqual(currentZone, zone)
149+
);
150+
if (zoneToSplit) {
151+
const splittedZones = splitZone(currentZone, zoneToSplit);
152+
zones = zones
153+
.filter((z) => !isEqual(z, currentZone) && !isEqual(z, zoneToSplit))
154+
.concat(splittedZones);
155+
newAnchorZone = splittedZones.at(-1);
156+
}
157+
}
158+
if (newAnchorZone) {
159+
anchor = {
160+
cell: { col: newAnchorZone.left, row: newAnchorZone.top },
161+
zone: newAnchorZone,
162+
};
163+
}
129164
break;
130165
}
131-
this.setSelectionMixin(event.anchor, zones);
166+
this.setSelectionMixin(anchor, zones);
132167
/** Any change to the selection has to be reflected in the selection processor. */
133168
this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor));
134169
const { col, row } = this.gridSelection.anchor.cell;

src/selection_stream/selection_stream_processor.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ interface SelectionProcessor {
4444
moveAnchorCell(direction: Direction, step: SelectionStep): DispatchResult;
4545
setAnchorCorner(col: number, row: number): DispatchResult;
4646
addCellToSelection(col: number, row: number): DispatchResult;
47+
updateSelection(): DispatchResult;
4748
resizeAnchorZone(direction: Direction, step: SelectionStep): DispatchResult;
4849
selectColumn(index: number, mode: SelectionEvent["mode"]): DispatchResult;
4950
selectRow(index: number, mode: SelectionEvent["mode"]): DispatchResult;
@@ -210,6 +211,20 @@ export class SelectionStreamProcessorImpl implements SelectionStreamProcessor {
210211
});
211212
}
212213

214+
/**
215+
* update the current selection.
216+
*/
217+
updateSelection(): DispatchResult {
218+
return this.processEvent({
219+
options: {
220+
scrollIntoView: false,
221+
unbounded: true,
222+
},
223+
anchor: this.anchor,
224+
mode: "updateSelection",
225+
});
226+
}
227+
213228
/**
214229
* Increase or decrease the size of the current anchor zone.
215230
* The anchor cell remains where it is. It's the opposite side
@@ -291,7 +306,10 @@ export class SelectionStreamProcessorImpl implements SelectionStreamProcessor {
291306
});
292307
}
293308

294-
selectColumn(index: HeaderIndex, mode: SelectionEvent["mode"]): DispatchResult {
309+
selectColumn(
310+
index: HeaderIndex,
311+
mode: Exclude<SelectionEvent["mode"], "updateSelection">
312+
): DispatchResult {
295313
const sheetId = this.getters.getActiveSheetId();
296314
const bottom = this.getters.getNumberRows(sheetId) - 1;
297315
let zone = { left: index, right: index, top: 0, bottom };
@@ -318,7 +336,10 @@ export class SelectionStreamProcessorImpl implements SelectionStreamProcessor {
318336
});
319337
}
320338

321-
selectRow(index: HeaderIndex, mode: SelectionEvent["mode"]): DispatchResult {
339+
selectRow(
340+
index: HeaderIndex,
341+
mode: Exclude<SelectionEvent["mode"], "updateSelection">
342+
): DispatchResult {
322343
const sheetId = this.getters.getActiveSheetId();
323344
const right = this.getters.getNumberCols(sheetId) - 1;
324345
let zone = { top: index, bottom: index, left: 0, right };

src/types/event_stream/selection_events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ export type SelectionEventOptions = {
88
export interface SelectionEvent {
99
anchor: AnchorZone;
1010
previousAnchor: AnchorZone;
11-
mode: "newAnchor" | "overrideSelection" | "updateAnchor";
11+
mode: "newAnchor" | "overrideSelection" | "updateAnchor" | "updateSelection";
1212
options: SelectionEventOptions;
1313
}

tests/sheet/selection_plugin.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
setSelection,
4141
setViewportOffset,
4242
undo,
43+
updateSelection,
4344
} from "../test_helpers/commands_helpers";
4445
import {
4546
getActivePosition,
@@ -1418,3 +1419,141 @@ describe("Multiple selection updates after insertion and deletion", () => {
14181419
]);
14191420
});
14201421
});
1422+
1423+
describe("Grid selection updates zones correctly when deselecting zone", () => {
1424+
let model: Model;
1425+
1426+
beforeEach(() => {
1427+
model = new Model({ sheets: [{ colNumber: 5, rowNumber: 5 }] });
1428+
});
1429+
1430+
test("can deselect a single cell", () => {
1431+
selectCell(model, "A1");
1432+
let selection = model.getters.getSelection();
1433+
expect(selection.zones.length).toBe(1);
1434+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1435+
1436+
addCellToSelection(model, "B2");
1437+
updateSelection(model);
1438+
selection = model.getters.getSelection();
1439+
expect(selection.zones.length).toBe(2);
1440+
expect(selection.anchor.cell).toEqual(toCartesian("B2"));
1441+
1442+
addCellToSelection(model, "B2");
1443+
updateSelection(model);
1444+
selection = model.getters.getSelection();
1445+
expect(selection.zones.length).toBe(1);
1446+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1447+
});
1448+
1449+
test("can deselect a cell from a zone", () => {
1450+
setSelection(model, ["A1:C3"]);
1451+
let selection = model.getters.getSelection();
1452+
expect(selection.zones.length).toBe(1);
1453+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1454+
1455+
addCellToSelection(model, "B2");
1456+
updateSelection(model);
1457+
selection = model.getters.getSelection();
1458+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1459+
expect(selection.zones).toEqual([
1460+
{ left: 0, right: 2, top: 2, bottom: 2 },
1461+
{ left: 2, right: 2, top: 1, bottom: 1 },
1462+
{ left: 0, right: 0, top: 1, bottom: 1 },
1463+
{ left: 0, right: 2, top: 0, bottom: 0 },
1464+
]);
1465+
});
1466+
1467+
test("can deselect sub-zone from a larger zone", () => {
1468+
setSelection(model, ["A1:C4"]);
1469+
let selection = model.getters.getSelection();
1470+
expect(selection.zones.length).toBe(1);
1471+
1472+
addCellToSelection(model, "B2");
1473+
setAnchorCorner(model, "B3");
1474+
updateSelection(model);
1475+
selection = model.getters.getSelection();
1476+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1477+
expect(selection.zones).toEqual([
1478+
{ left: 0, right: 2, top: 3, bottom: 3 }, // bottom
1479+
{ left: 2, right: 2, top: 1, bottom: 2 }, // right
1480+
{ left: 0, right: 0, top: 1, bottom: 2 }, // left
1481+
{ left: 0, right: 2, top: 0, bottom: 0 }, // top
1482+
]);
1483+
});
1484+
1485+
test("can deselect merged cell from selection", () => {
1486+
merge(model, "A1:A2");
1487+
1488+
setSelection(model, ["A1:B3"]);
1489+
let selection = model.getters.getSelection();
1490+
expect(selection.zones.length).toBe(1);
1491+
1492+
addCellToSelection(model, "A1");
1493+
updateSelection(model);
1494+
selection = model.getters.getSelection();
1495+
expect(selection.anchor.cell).toEqual(toCartesian("B1"));
1496+
expect(selection.zones).toEqual([
1497+
{ left: 0, right: 1, top: 2, bottom: 2 }, // right
1498+
{ left: 1, right: 1, top: 0, bottom: 1 }, // bottom
1499+
]);
1500+
});
1501+
1502+
test("re-selecting a zone remove it from selection", () => {
1503+
setSelection(model, ["A1"]);
1504+
let selection = model.getters.getSelection();
1505+
expect(selection.zones.length).toBe(1);
1506+
1507+
addCellToSelection(model, "B2");
1508+
setAnchorCorner(model, "B3");
1509+
updateSelection(model);
1510+
selection = model.getters.getSelection();
1511+
expect(selection.anchor.cell).toEqual(toCartesian("B2"));
1512+
expect(selection.zones).toEqual([
1513+
{ left: 0, right: 0, top: 0, bottom: 0 }, // right
1514+
{ left: 1, right: 1, top: 1, bottom: 2 }, // bottom
1515+
]);
1516+
1517+
addCellToSelection(model, "B2");
1518+
setAnchorCorner(model, "B3");
1519+
updateSelection(model); // [B2, B3] zone remove from zones
1520+
selection = model.getters.getSelection();
1521+
expect(selection.anchor.cell).toEqual(toCartesian("A1"));
1522+
expect(selection.zones.length).toBe(1);
1523+
});
1524+
1525+
test("re-selecting a row removes it from selection", () => {
1526+
selectRow(model, 0, "overrideSelection");
1527+
let selection = model.getters.getSelection();
1528+
expect(selection.zones[0]).toEqual(toZone("A1:E1"));
1529+
1530+
selectRow(model, 1, "newAnchor");
1531+
updateSelection(model);
1532+
selection = model.getters.getSelection();
1533+
expect(selection.zones.length).toEqual(2);
1534+
1535+
selectRow(model, 0, "newAnchor");
1536+
updateSelection(model);
1537+
selection = model.getters.getSelection();
1538+
expect(selection.zones.length).toEqual(1);
1539+
expect(selection.zones[0]).toEqual(toZone("A2:E2"));
1540+
});
1541+
1542+
test("re-selecting a column removes it from selection", () => {
1543+
selectColumn(model, 0, "overrideSelection");
1544+
let selection = model.getters.getSelection();
1545+
expect(selection.zones.length).toBe(1);
1546+
expect(selection.zones[0]).toEqual(toZone("A1:A5"));
1547+
1548+
selectColumn(model, 1, "newAnchor");
1549+
updateSelection(model);
1550+
selection = model.getters.getSelection();
1551+
expect(selection.zones.length).toEqual(2);
1552+
1553+
selectColumn(model, 0, "newAnchor");
1554+
updateSelection(model);
1555+
selection = model.getters.getSelection();
1556+
expect(selection.zones.length).toEqual(1);
1557+
expect(selection.zones[0]).toEqual(toZone("B1:B5"));
1558+
});
1559+
});

tests/test_helpers/commands_helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,10 @@ export function addCellToSelection(model: Model, xc: string): DispatchResult {
856856
return model.selection.addCellToSelection(col, row);
857857
}
858858

859+
export function updateSelection(model: Model): DispatchResult {
860+
return model.selection.updateSelection();
861+
}
862+
859863
/**
860864
* Move a conditianal formatting rule
861865
*/

0 commit comments

Comments
 (0)