Skip to content

Commit 9ef0df8

Browse files
authored
feat(selection): support multi-range selection with drag previews (#1287)
1 parent 4559db9 commit 9ef0df8

11 files changed

Lines changed: 340 additions & 70 deletions

cypress/e2e/example-excel-compatible-spreadsheet.cy.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries:
6060
cy.get('#myGrid [data-row=99] > .slick-cell.l26.r26.active').should('have.length', 1);
6161
});
6262

63-
it('should ignore pasted cells outside grid column bounds when clipboard data exceeds available grid columns', () => {
63+
it('should ignore pasted cells outside grid column bounds when clipboard data exceeds available grid columns', () => {
6464
cy.get('#myGrid .slick-viewport-top.slick-viewport-left').scrollTo(200, 0).wait(10);
6565
cy.get('#myGrid [data-row=0] .slick-cell.l22.r22').click();
6666

@@ -76,7 +76,32 @@ describe('Example - Excel-compatible spreadsheet and Cell Selection', { retries:
7676
});
7777

7878
cy.get('#myGrid [data-row=0] .slick-cell.l22.r22').should('have.text', 'p1');
79-
cy.get('#myGrid [data-row=0] .slick-cell.l26.r26').should('have.text', 'p5');
79+
cy.get('#myGrid [data-row=0] .slick-cell.l26.r26').should('have.text', 'p5');
80+
});
81+
82+
it('should preserve gaps when copying multiple non-contiguous ranges', () => {
83+
cy.window().then((win: any) => {
84+
const previousClipboardData = win.clipboardData;
85+
let copiedText = '';
86+
Object.defineProperty(win, 'clipboardData', {
87+
configurable: true,
88+
value: {
89+
setData: (_format: string, text: string) => { copiedText = text; }
90+
}
91+
});
92+
93+
const selectionModel = win.grid.getSelectionModel();
94+
selectionModel.setSelectedRanges([
95+
new win.Slick.Range(1, 1, 1, 2),
96+
new win.Slick.Range(2, 3, 2, 3)
97+
]);
98+
const copyEvent = new win.KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true, bubbles: true });
99+
Object.defineProperty(copyEvent, 'which', { value: 67 });
100+
win.grid.getCanvasNode().dispatchEvent(copyEvent);
101+
102+
expect(copiedText).to.eq('1\t2\t\r\n\t\t4\r\n');
103+
Object.defineProperty(win, 'clipboardData', { configurable: true, value: previousClipboardData });
80104
});
81105
});
82106
});
107+
});

cypress/e2e/example-plugin-hybridselectionmodel.cy.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,74 @@ describe('Example - Context Menu Plugin & Hybrid Selection Mode', () => {
7979
cy.get('#myGrid .slick-row[data-row="6"] .slick-cell.l0.r0').click({ shiftKey: true }).should('have.class', 'selected');
8080
cy.get('#myGrid .slick-cell.selected').should('have.length', 7 * 3);
8181
});
82+
83+
it('should add and toggle non-contiguous cell ranges with Ctrl-click', () => {
84+
cy.visit(`${Cypress.config('baseUrl')}/examples/example-plugin-hybridselectionmodel.html`);
85+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1').click();
86+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.l1.r1').click({ ctrlKey: true });
87+
88+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1').should('have.class', 'selected');
89+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.l1.r1').should('have.class', 'selected');
90+
cy.get('#myGrid .slick-row[data-row="2"] .slick-cell.l1.r1').should('not.have.class', 'selected');
91+
cy.get('#myGrid .slick-cell.selected').should('have.length', 2);
92+
93+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1').click({ ctrlKey: true });
94+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1').should('not.have.class', 'selected');
95+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.l1.r1').should('have.class', 'selected');
96+
cy.get('#myGrid .slick-cell.selected').should('have.length', 1);
97+
});
98+
99+
it('should split a rectangular cell range when Ctrl-clicking an interior cell', () => {
100+
cy.visit(`${Cypress.config('baseUrl')}/examples/example-plugin-hybridselectionmodel.html`);
101+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1').click();
102+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l1.r1')
103+
.find('.slick-drag-replace-handle')
104+
.trigger('mousedown', { which: 1, force: true });
105+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.l3.r3')
106+
.trigger('mousemove', 'bottomRight')
107+
.trigger('mouseup', 'bottomRight', { which: 1, force: true });
108+
cy.get('#myGrid .slick-cell.selected').should('have.length', 9);
109+
110+
cy.get('#myGrid .slick-row[data-row="2"] .slick-cell.l2.r2').click({ ctrlKey: true });
111+
cy.get('#myGrid .slick-row[data-row="2"] .slick-cell.l2.r2').should('not.have.class', 'selected');
112+
cy.get('#myGrid .slick-cell.selected').should('have.length', 8);
113+
cy.window().then((win: any) => {
114+
expect(win.grid.getSelectionModel().getSelectedRanges()).to.have.length(4);
115+
});
116+
});
117+
118+
it('should add a row range with Ctrl-drag without accumulating live preview ranges', () => {
119+
cy.visit(`${Cypress.config('baseUrl')}/examples/example-plugin-hybridselectionmodel.html`);
120+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.l0.r0').click();
121+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.l0.r0').as('secondRowCell');
122+
cy.get('@secondRowCell').trigger('mousedown', { which: 1, ctrlKey: true, force: true });
123+
cy.get('@secondRowCell').trigger('mousemove', 30, 10, { ctrlKey: true, force: true });
124+
cy.get('@secondRowCell').trigger('mousemove', 30, 52, { ctrlKey: true, force: true });
125+
126+
cy.window().then((win: any) => {
127+
const ranges = win.grid.getSelectionModel().getSelectedRanges();
128+
expect(ranges).to.have.length(2);
129+
expect(ranges[0]).to.include({ fromRow: 1, toRow: 1 });
130+
expect(ranges[1]).to.include({ fromRow: 3, toRow: 4 });
131+
});
132+
133+
cy.dragEnd('#myGrid');
134+
135+
cy.get('#myGrid .slick-row[data-row="1"] .slick-cell.selected').should('have.length', 7);
136+
cy.get('#myGrid .slick-row[data-row="2"] .slick-cell.selected').should('have.length', 0);
137+
cy.get('#myGrid .slick-row[data-row="3"] .slick-cell.selected').should('have.length', 7);
138+
cy.get('#myGrid .slick-row[data-row="4"] .slick-cell.selected').should('have.length', 7);
139+
cy.get('#myGrid .slick-cell.selected').should('have.length', 7 * 3);
140+
});
141+
142+
it('should synchronize the selected cell when selectActiveRow is disabled in cell mode', () => {
143+
cy.visit(`${Cypress.config('baseUrl')}/examples/example-plugin-hybridselectionmodel.html`);
144+
cy.window().then((win: any) => {
145+
const selectionModel = win.grid.getSelectionModel();
146+
selectionModel.setOptions({ selectionType: 'cell', selectActiveRow: false, selectActiveCell: true });
147+
win.grid.setActiveCell(5, 1, false, false);
148+
expect(selectionModel.getSelectedRanges()).to.have.length(1);
149+
expect(selectionModel.getSelectedRanges()[0]).to.include({ fromRow: 5, fromCell: 1, toRow: 5, toCell: 1 });
150+
});
151+
});
82152
});

examples/example-plugin-hybridselectionmodel.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ <h2>View Source:</h2>
328328
document.addEventListener("DOMContentLoaded", function () {
329329
dataView = new Slick.Data.DataView();
330330
grid = new Slick.Grid("#myGrid", dataView, columns, gridOptions);
331-
grid.setSelectionModel(new Slick.HybridSelectionModel({ selectActiveRow: true, rowSelectColumnIds: ['id'] }));
331+
grid.setSelectionModel(new Slick.HybridSelectionModel({ selectActiveRow: true, rowSelectColumnIds: ['id'], enableMultiSelection: true }));
332332
contextMenuPlugin = new Slick.Plugins.ContextMenu(contextMenuOptions);
333333
var columnpicker = new Slick.Controls.ColumnPicker(columns, grid, gridOptions);
334334

@@ -393,4 +393,4 @@ <h2>View Source:</h2>
393393
</script>
394394
</body>
395395

396-
</html>
396+
</html>

src/models/selectionModel.type.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { SlickEvent, SlickRange } from '../slick.core.js';
22
import type { SlickPlugin } from './index.js';
33

4-
export type SelectionModel = SlickPlugin & {
4+
export type SelectionModel<T = any> = SlickPlugin & {
55
refreshSelections: () => void;
66
onSelectedRangesChanged: SlickEvent<SlickRange[]>;
7-
getOptions: () => any;
7+
getOptions: () => T;
8+
setOptions: (options: Partial<T>) => void;
89
getSelectedRanges: () => SlickRange[];
910
setSelectedRanges: (ranges: SlickRange[], caller?: string, selectionMode?: string) => void;
1011
};

src/models/selectionModelOption.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export interface HybridSelectionModelOption {
2424
/** Defaults to False, should we select when dragging? */
2525
dragToSelect?: boolean;
2626

27+
/** Defaults to False, should Ctrl/Cmd interactions add or toggle multiple cell or row selection ranges? */
28+
enableMultiSelection?: boolean;
29+
2730
/**
2831
* Defaults to True, controls the visibility of the Excel-style cell selection drag handle.
2932
* Set to `false` to disable the handle, or to `'hover'` to show it only while hovering the selected cell.

src/plugins/slick.cellexternalcopymanager.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -400,40 +400,49 @@ export class SlickCellExternalCopyManager implements SlickPlugin {
400400
this.onCopyCells.notify({ ranges });
401401

402402
const columns = this._grid.getColumns();
403+
const fromRow = Math.min(...ranges.map((range) => range.fromRow));
404+
const fromCell = Math.min(...ranges.map((range) => range.fromCell));
405+
const toRow = Math.max(...ranges.map((range) => range.toRow));
406+
const toCell = Math.max(...ranges.map((range) => range.toCell));
403407
let clipText = '';
404408

405-
for (let rg = 0; rg < ranges.length; rg++) {
406-
const range = ranges[rg];
407-
const clipTextRows: string[] = [];
408-
for (let i = range.fromRow; i < range.toRow + 1; i++) {
409-
const clipTextCells: string[] = [];
410-
const dt = this._grid.getDataItem(i);
411-
412-
if (clipTextRows.length === 0 && this._options.includeHeaderWhenCopying) {
413-
const clipTextHeaders: string[] = [];
414-
for (let j = range.fromCell; j < range.toCell + 1; j++) {
415-
const colName: string = columns[j].name instanceof HTMLElement
416-
? (columns[j].name as HTMLElement).innerHTML
417-
: columns[j].name as string;
418-
if (colName.length > 0 && !columns[j].hidden) {
419-
clipTextHeaders.push(this.getHeaderValueForColumn(columns[j]) || '');
420-
}
409+
const clipTextRows: string[] = [];
410+
if (this._options.includeHeaderWhenCopying) {
411+
const clipTextHeaders: string[] = [];
412+
for (let j = fromCell; j <= toCell; j++) {
413+
const column = columns[j];
414+
const isSelectedColumn = ranges.some((range) => j >= range.fromCell && j <= range.toCell);
415+
if (column) {
416+
const colName: string = column.name instanceof HTMLElement
417+
? (column.name as HTMLElement).innerHTML
418+
: column.name as string;
419+
if (colName.length > 0 && !column.hidden) {
420+
clipTextHeaders.push(isSelectedColumn ? this.getHeaderValueForColumn(column) || '' : '');
421421
}
422-
clipTextRows.push(clipTextHeaders.join('\t'));
423422
}
423+
}
424+
clipTextRows.push(clipTextHeaders.join('\t'));
425+
}
424426

425-
for (let j = range.fromCell; j < range.toCell + 1; j++) {
426-
const colName: string = columns[j].name instanceof HTMLElement
427-
? (columns[j].name as HTMLElement).innerHTML
428-
: columns[j].name as string;
429-
if (colName.length > 0 && !columns[j].hidden) {
430-
clipTextCells.push(this.getDataItemValueForColumn(dt, columns[j], e));
427+
for (let i = fromRow; i <= toRow; i++) {
428+
const clipTextCells: string[] = [];
429+
const dt = this._grid.getDataItem(i);
430+
for (let j = fromCell; j <= toCell; j++) {
431+
const column = columns[j];
432+
if (column) {
433+
const colName: string = column.name instanceof HTMLElement
434+
? (column.name as HTMLElement).innerHTML
435+
: column.name as string;
436+
if (colName.length > 0 && !column.hidden) {
437+
clipTextCells.push(
438+
ranges.some((range) => range.contains(i, j)) ? this.getDataItemValueForColumn(dt, column, e) : ''
439+
);
431440
}
432441
}
433-
clipTextRows.push(clipTextCells.join('\t'));
434442
}
435-
clipText += clipTextRows.join('\r\n') + '\r\n';
443+
clipTextRows.push(clipTextCells.join('\t'));
436444
}
445+
clipText += clipTextRows.join('\r\n') + '\r\n';
437446

438447
if ((window as any).clipboardData) {
439448
(window as any).clipboardData.setData('Text', clipText);

src/plugins/slick.cellrangeselector.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export class SlickCellRangeSelector implements SlickPlugin {
1919
// public API
2020
pluginName = 'CellRangeSelector' as const;
2121
onBeforeCellRangeSelected = new SlickEvent<{ row: number; cell: number; }>('onBeforeCellRangeSelected');
22-
onCellRangeSelected = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; }>('onCellRangeSelected');
23-
onCellRangeSelecting = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; }>('onCellRangeSelecting');
22+
onCellRangeSelected = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; addToSelection?: boolean; }>('onCellRangeSelected');
23+
onCellRangeSelecting = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; addToSelection?: boolean; }>('onCellRangeSelecting');
2424

2525
// --
2626
// protected props
@@ -36,6 +36,7 @@ export class SlickCellRangeSelector implements SlickPlugin {
3636
protected _options: CellRangeSelectorOption;
3737
protected _selectionMode: string = CellSelectionMode.Select;
3838
protected _dragReplaceHandleActive = false;
39+
protected _addToSelection = false;
3940
protected _dragReplaceHandleCell: { row : number, cell: number } | null = null;
4041
protected _defaults = {
4142
autoScroll: true,
@@ -152,8 +153,12 @@ export class SlickCellRangeSelector implements SlickPlugin {
152153
}
153154
}
154155

155-
this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle');
156-
if (this._dragReplaceHandleActive) {
156+
this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle');
157+
this._addToSelection =
158+
!this._dragReplaceHandleActive &&
159+
this._grid.getSelectionModel()?.getOptions()?.enableMultiSelection === true &&
160+
(!!e.ctrlKey || !!e.metaKey);
161+
if (this._dragReplaceHandleActive) {
157162
this._dragReplaceHandleCell = this._grid.getCellFromEvent(e);
158163
} else {
159164
this._previousSelectedRange = null;
@@ -165,6 +170,12 @@ export class SlickCellRangeSelector implements SlickPlugin {
165170
}
166171

167172
protected handleDragStart(e: SlickEventData, dd: DragRowMove) {
173+
// Keep detecting the modifier during the drag as well as during mousedown.
174+
// This is important for browsers and synthetic pointer events that do not
175+
// preserve modifier flags on the initial event.
176+
if (!this._dragReplaceHandleActive && this._grid.getSelectionModel()?.getOptions()?.enableMultiSelection === true) {
177+
this._addToSelection ||= !!e.ctrlKey || !!e.metaKey;
178+
}
168179
let cell = this._grid.getCellFromEvent(e);
169180
if (this._dragReplaceHandleActive) { cell = this._dragReplaceHandleCell; }
170181
if (cell && this.onBeforeCellRangeSelected.notify(cell).getReturnValue() !== false && this._grid.canCellBeSelected(cell.row, cell.cell)) {
@@ -211,6 +222,9 @@ export class SlickCellRangeSelector implements SlickPlugin {
211222
}
212223

213224
const e = evt.getNativeEvent<MouseEvent>();
225+
if (!this._dragReplaceHandleActive && this._grid.getSelectionModel()?.getOptions()?.enableMultiSelection === true) {
226+
this._addToSelection ||= !!e?.ctrlKey || !!e?.metaKey;
227+
}
214228
if (this._options.autoScroll) {
215229
this._draggingMouseOffset = this.getMouseOffsetViewport(e, dd);
216230
if (this._draggingMouseOffset.isOutsideViewport) {
@@ -389,8 +403,10 @@ export class SlickCellRangeSelector implements SlickPlugin {
389403

390404
this._decorator.show(range, this._dragReplaceHandleActive);
391405
this.onCellRangeSelecting.notify({
392-
range, selectionMode: '',
393-
allowAutoEdit: false
406+
range,
407+
selectionMode: '',
408+
allowAutoEdit: false,
409+
...(this._addToSelection ? { addToSelection: true } : {})
394410
});
395411
}
396412
}
@@ -431,7 +447,13 @@ export class SlickCellRangeSelector implements SlickPlugin {
431447
dd.range.end.cell
432448
);
433449

434-
this.onCellRangeSelected.notify({ range: r, selectionMode: this._selectionMode, allowAutoEdit: (this._selectionMode === "SEL" && r.isSingleCell()) });
450+
this.onCellRangeSelected.notify({
451+
range: r,
452+
selectionMode: this._selectionMode,
453+
allowAutoEdit: (this._selectionMode === "SEL" && r.isSingleCell()),
454+
...(this._addToSelection ? { addToSelection: true } : {})
455+
});
456+
this._addToSelection = false;
435457
// keep the resulting range (not the raw drag range) so that a next drag-extend anchors on the real selection
436458
this._previousSelectedRange = SelectionUtils.normaliseDragRange({
437459
start: { row: r.fromRow, cell: r.fromCell },

src/plugins/slick.cellselectionmodel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export class SlickCellSelectionModel implements SelectionModel {
7373
return this._options;
7474
}
7575

76+
setOptions(options: Partial<CellSelectionModelOption>) {
77+
this._options = Utils.extend(true, {}, this._options ?? this._defaults, options);
78+
}
79+
7680
protected removeInvalidRanges(ranges: SlickRange_[]) {
7781
const result: SlickRange_[] = [];
7882

0 commit comments

Comments
 (0)