Multi-Range Selection
MultiRangeSelectionPlugin adds spreadsheet-style disjoint range selection while
leaving the native RevoGrid active range in charge of focus, Shift extension, and
editing.
Use it when users need to compare, clear, or copy multiple non-contiguous regions from the same grid.
Source code
import { defineCustomElements } from '@revolist/revogrid/loader';
import {
CellFlashPlugin,
ColumnStretchPlugin,
EventManagerPlugin,
MultiRangeSelectionPlugin,
RowOddPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { columns, createPinnedTopRows, createRows, emptySelectionSummary, formatSelectedCells } from './data';
defineCustomElements();
const { isDark } = currentTheme();
export function load(parentSelector: string) {
const rows = createRows();
const pinnedTopRows = createPinnedTopRows();
const container = document.createElement('div');
container.className = 'grid gap-3';
container.style.gridTemplateRows = 'auto minmax(0, 1fr)';
container.style.height = '100%';
container.style.minHeight = '0';
const grid = document.createElement('revo-grid') as HTMLRevoGridElement;
grid.className = 'rounded-lg overflow-hidden';
grid.range = true;
grid.theme = isDark() ? 'darkMaterial' : 'material';
grid.hideAttribution = true;
grid.columns = columns;
grid.pinnedTopSource = pinnedTopRows;
grid.stretch = 'all';
grid.eventManager = {
applyEventsToSource: true,
};
grid.plugins = [
EventManagerPlugin,
MultiRangeSelectionPlugin,
CellFlashPlugin,
RowOddPlugin,
ColumnStretchPlugin,
];
grid.style.height = '100%';
grid.style.minHeight = '0';
grid.style.minWidth = '620px';
const panel = document.createElement('pre');
panel.className = 'rounded-lg border border-neutral-800 bg-neutral-950 p-3 text-xs text-neutral-100 overflow-auto';
panel.style.height = '66px';
panel.style.lineHeight = '14px';
panel.style.margin = '0';
panel.textContent = emptySelectionSummary;
const updateSummary = (event: Event) => {
const detail = (event as CustomEvent).detail;
panel.textContent = formatSelectedCells(detail?.ranges || [], rows, pinnedTopRows);
};
grid.addEventListener('multirangeselectionchange', updateSummary);
container.append(panel, grid);
document.querySelector(parentSelector)?.appendChild(container);
grid.source = rows;
return () => {
grid.removeEventListener('multirangeselectionchange', updateSummary);
container.remove();
};
}
import type { ColumnRegular, DataType } from '@revolist/revogrid';
import { cellFlashArrowTemplate, type MultiRangeSelectionRange } from '@revolist/revogrid-pro';
export const columns: ColumnRegular[] = [
{ name: 'Region', prop: 'region', size: 130, pin: 'colPinStart', sortable: true, flash: () => true },
{ name: 'Account', prop: 'account', size: 150, sortable: true, flash: () => true },
{ name: 'Product', prop: 'product', size: 150, sortable: true, flash: () => true },
{ name: 'Status', prop: 'status', size: 120, sortable: true, flash: () => true },
{ name: 'Owner', prop: 'owner', size: 130, sortable: true, flash: () => true },
{ name: 'Priority', prop: 'priority', size: 120, sortable: true, flash: () => true },
{ name: 'Quarter', prop: 'quarter', size: 110, sortable: true, flash: () => true },
{ name: 'Channel', prop: 'channel', size: 130, sortable: true, flash: () => true },
{
name: 'Amount',
prop: 'amount',
size: 110,
sortable: true,
flash: () => true,
cellTemplate: cellFlashArrowTemplate(),
},
{
name: 'Forecast',
prop: 'forecast',
size: 120,
sortable: true,
flash: () => true,
cellTemplate: cellFlashArrowTemplate(),
},
{ name: 'Probability', prop: 'probability', size: 130, sortable: true, flash: () => true },
{ name: 'Next Step', prop: 'nextStep', size: 160, sortable: true, flash: () => true },
];
export function createPinnedTopRows(): DataType[] {
return [
{
region: 'All',
account: 'Pinned top plan',
product: 'Portfolio',
status: 'Review',
owner: 'Ops',
priority: 'High',
quarter: 'FY',
channel: 'All',
amount: 15510,
forecast: 17170,
probability: '72%',
nextStep: 'Executive review',
},
];
}
export function createRows(): DataType[] {
return [
{
region: 'North',
account: 'Acme',
product: 'Scheduler',
status: 'Draft',
owner: 'Mia',
priority: 'Medium',
quarter: 'Q1',
channel: 'Partner',
amount: 1280,
forecast: 1460,
probability: '55%',
nextStep: 'Scope review',
},
{
region: 'North',
account: 'Beacon',
product: 'Pivot',
status: 'Approved',
owner: 'Noah',
priority: 'High',
quarter: 'Q2',
channel: 'Direct',
amount: 2140,
forecast: 2380,
probability: '80%',
nextStep: 'Contract',
},
{
region: 'West',
account: 'Cobalt',
product: 'Gantt',
status: 'Review',
owner: 'Ava',
priority: 'Low',
quarter: 'Q3',
channel: 'Marketplace',
amount: 980,
forecast: 1240,
probability: '40%',
nextStep: 'Demo',
},
{
region: 'West',
account: 'Delta',
product: 'Export',
status: 'Approved',
owner: 'Leo',
priority: 'High',
quarter: 'Q4',
channel: 'Direct',
amount: 3420,
forecast: 3650,
probability: '90%',
nextStep: 'Kickoff',
},
{
region: 'East',
account: 'Evergreen',
product: 'Validation',
status: 'Draft',
owner: 'Ivy',
priority: 'Medium',
quarter: 'Q1',
channel: 'Referral',
amount: 1650,
forecast: 1810,
probability: '60%',
nextStep: 'Pricing',
},
{
region: 'East',
account: 'Fabrikam',
product: 'Row Master',
status: 'Review',
owner: 'Owen',
priority: 'High',
quarter: 'Q2',
channel: 'Partner',
amount: 2870,
forecast: 3040,
probability: '70%',
nextStep: 'Security',
},
{
region: 'South',
account: 'Globex',
product: 'Filtering',
status: 'Approved',
owner: 'Eli',
priority: 'Medium',
quarter: 'Q3',
channel: 'Direct',
amount: 1960,
forecast: 2200,
probability: '75%',
nextStep: 'Launch',
},
{
region: 'South',
account: 'Harbor',
product: 'Clipboard',
status: 'Draft',
owner: 'Zoe',
priority: 'Low',
quarter: 'Q4',
channel: 'Marketplace',
amount: 1210,
forecast: 1390,
probability: '45%',
nextStep: 'Follow-up',
},
];
}
export const emptySelectionSummary = [
'Selected cells: 0',
'Use Ctrl/Cmd+click to add a range.',
].join('\n');
export function formatSelectedCells(
ranges: MultiRangeSelectionRange[] = [],
rows: DataType[] = [],
pinnedTopRows: DataType[] = [],
) {
const lines: string[] = [];
let count = 0;
const maxLines = 24;
const rowSources = {
rowPinStart: pinnedTopRows,
rgRow: rows,
rowPinEnd: [],
};
for (const selection of ranges) {
const rowSource = rowSources[selection.rowType] || [];
const columnSource = getColumnsByType(selection.colType);
for (let rowIndex = selection.range.y; rowIndex <= selection.range.y1; rowIndex++) {
for (let colIndex = selection.range.x; colIndex <= selection.range.x1; colIndex++) {
count++;
if (lines.length >= maxLines) {
continue;
}
const column = columnSource[colIndex];
const prop = column?.prop;
const value = prop ? rowSource[rowIndex]?.[prop] : undefined;
lines.push(
`${formatRowLabel(selection.rowType, rowIndex)} ${column?.name || `C${colIndex + 1}`} = ${formatCellValue(value)}`,
);
}
}
}
if (!count) {
return emptySelectionSummary;
}
const hidden = count - lines.length;
return [
`Selected cells: ${count}`,
...lines,
...(hidden > 0 ? [`+${hidden} more`] : []),
].join('\n');
}
function formatCellValue(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
return String(value);
}
function getColumnsByType(colType: MultiRangeSelectionRange['colType']) {
if (colType === 'colPinStart') {
return columns.filter(column => column.pin === 'colPinStart');
}
if (colType === 'colPinEnd') {
return columns.filter(column => column.pin === 'colPinEnd');
}
return columns.filter(column => !column.pin);
}
function formatRowLabel(rowType: MultiRangeSelectionRange['rowType'], rowIndex: number) {
if (rowType === 'rowPinStart') {
return `Top R${rowIndex + 1}`;
}
if (rowType === 'rowPinEnd') {
return `Bottom R${rowIndex + 1}`;
}
return `R${rowIndex + 1}`;
}
<template>
<div class="grid gap-3" style="grid-template-rows: auto minmax(0, 1fr); height: 100%; min-height: 0;">
<pre class="rounded-lg border border-neutral-800 bg-neutral-950 p-3 text-xs text-neutral-100 overflow-auto" style="max-height: 66px; min-height: 0; line-height: 14px; margin: 0;">{{ selectionSummary }}</pre>
<VGrid
class="rounded-lg overflow-hidden"
range
:theme="isDark ? 'darkMaterial' : 'material'"
:columns="columns"
:source="rows"
:pinnedTopSource="pinnedTopRows"
:plugins="plugins"
:event-manager="eventManager"
stretch="all"
hide-attribution
style="height: 100%; min-width: 620px;"
@multirangeselectionchange="onSelectionChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { VGrid } from '@revolist/vue3-datagrid';
import {
CellFlashPlugin,
ColumnStretchPlugin,
EventManagerPlugin,
MultiRangeSelectionPlugin,
RowOddPlugin,
} from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';
import { columns, createPinnedTopRows, createRows, emptySelectionSummary, formatSelectedCells } from './data';
const { isDark } = currentThemeVue();
const plugins = [
EventManagerPlugin,
MultiRangeSelectionPlugin,
CellFlashPlugin,
RowOddPlugin,
ColumnStretchPlugin,
];
const rows = ref(createRows());
const pinnedTopRows = ref(createPinnedTopRows());
const selectionSummary = ref(emptySelectionSummary);
const eventManager = computed(() => ({
applyEventsToSource: true,
}));
function onSelectionChange(event: CustomEvent) {
selectionSummary.value = formatSelectedCells(event.detail?.ranges || [], rows.value, pinnedTopRows.value);
}
</script>
import React, { useCallback, useMemo, useState } from 'react';
import { RevoGrid, type ColumnRegular, type DataType } from '@revolist/react-datagrid';
import {
CellFlashPlugin,
ColumnStretchPlugin,
type EventManagerConfig,
EventManagerPlugin,
MultiRangeSelectionPlugin,
RowOddPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { columns as baseColumns, createPinnedTopRows, createRows, emptySelectionSummary, formatSelectedCells } from './data';
export default function MultiRangeSelection() {
const { isDark } = currentTheme();
const theme = useMemo(() => (isDark() ? 'darkMaterial' : 'material'), [isDark]);
const columns = useMemo<ColumnRegular[]>(() => baseColumns, []);
const plugins = useMemo(
() => [
EventManagerPlugin,
MultiRangeSelectionPlugin,
CellFlashPlugin,
RowOddPlugin,
ColumnStretchPlugin,
],
[],
);
const eventManager = useMemo<EventManagerConfig>(
() => ({
applyEventsToSource: true,
}),
[],
);
const [rows] = useState<DataType[]>(createRows);
const [pinnedTopRows] = useState<DataType[]>(createPinnedTopRows);
const [selectionSummary, setSelectionSummary] = useState(emptySelectionSummary);
const handleSelectionChange = useCallback((event: CustomEvent) => {
setSelectionSummary(formatSelectedCells(event.detail?.ranges || [], rows, pinnedTopRows));
}, [rows, pinnedTopRows]);
return (
<div className="grid gap-3" style={{ gridTemplateRows: 'auto minmax(0, 1fr)', height: '100%', minHeight: 0 }}>
<pre
className="rounded-lg border border-neutral-800 bg-neutral-950 p-3 text-xs text-neutral-100 overflow-auto"
style={{ maxHeight: 66, minHeight: 0, lineHeight: '14px', margin: 0 }}
>
{selectionSummary}
</pre>
<RevoGrid
className="rounded-lg overflow-hidden"
range
theme={theme}
columns={columns}
source={rows}
pinnedTopSource={pinnedTopRows}
plugins={plugins}
eventManager={eventManager}
stretch="all"
hide-attribution
onMultirangeselectionchange={handleSelectionChange as any}
style={{ height: '100%', minWidth: 620 }}
/>
</div>
);
}
import { Component, NO_ERRORS_SCHEMA, ViewEncapsulation, type OnInit } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import type { DataType } from '@revolist/revogrid';
import {
CellFlashPlugin,
ColumnStretchPlugin,
type EventManagerConfig,
EventManagerPlugin,
MultiRangeSelectionPlugin,
RowOddPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { columns, createPinnedTopRows, createRows, emptySelectionSummary, formatSelectedCells } from './data';
@Component({
selector: 'multi-range-selection-grid',
standalone: true,
imports: [RevoGrid],
template: `
<div class="grid gap-3" style="grid-template-rows: auto minmax(0, 1fr); height: 100%; min-height: 0;">
<pre class="rounded-lg border border-neutral-800 bg-neutral-950 p-3 text-xs text-neutral-100 overflow-auto" style="max-height: 66px; min-height: 0; line-height: 14px; margin: 0;">{{ selectionSummary }}</pre>
<revo-grid
class="rounded-lg overflow-hidden"
[range]="true"
[theme]="theme"
[columns]="columns"
[source]="rows"
[pinnedTopSource]="pinnedTopRows"
[plugins]="plugins"
[eventManager]="eventManager"
[stretch]="'all'"
[hideAttribution]="true"
(multirangeselectionchange)="onSelectionChange($event)"
style="height: 100%; min-width: 620px;"
></revo-grid>
</div>
`,
schemas: [NO_ERRORS_SCHEMA],
encapsulation: ViewEncapsulation.None,
})
export class MultiRangeSelectionGridComponent implements OnInit {
theme = currentTheme().isDark() ? 'darkMaterial' : 'material';
columns = columns;
plugins = [
EventManagerPlugin,
MultiRangeSelectionPlugin,
CellFlashPlugin,
RowOddPlugin,
ColumnStretchPlugin,
];
eventManager: EventManagerConfig = {
applyEventsToSource: true,
};
rows: DataType[] = [];
pinnedTopRows: DataType[] = [];
selectionSummary = emptySelectionSummary;
ngOnInit() {
this.rows = createRows();
this.pinnedTopRows = createPinnedTopRows();
}
onSelectionChange(event: Event) {
const detail = (event as CustomEvent).detail;
this.selectionSummary = formatSelectedCells(detail?.ranges || [], this.rows, this.pinnedTopRows);
}
}
import { MultiRangeSelectionPlugin } from '@revolist/revogrid-pro';
const grid = document.createElement('revo-grid');grid.range = true;grid.plugins = [MultiRangeSelectionPlugin];Cross-viewport autofill is enabled by default when this plugin is installed. Users can drag the autofill handle between pinned and main row or column viewports, and the committed result is stored as multiple selected ranges. Disable only that drag integration with:
grid.multiRangeSelection = { crossViewportAutofill: false };User Behavior
Section titled “User Behavior”- Select a cell or range normally.
- Ctrl/Cmd+click another cell to keep the previous range and start a new active range.
- Shift+click or Shift+Arrow extends only the active range.
- Tab and Enter move the focused cell inside the active range.
- Delete and Backspace clear inactive ranges plus the active range.
- Copy includes inactive ranges plus the active range.
- Dragging the autofill handle across pinned/main viewport boundaries fills the visible target segments.
- Plain click clears inactive ranges and starts a normal selection.
Clipboard Shape
Section titled “Clipboard Shape”Multi-range copy preserves relative shape for nearby ranges in text/plain.
For example, diagonal selected cells are copied with blank cells between them so
external spreadsheets can keep the diagonal layout instead of stacking values
one after another. RevoGrid also writes a custom multi-range clipboard payload;
when the payload is pasted back into RevoGrid, only the selected cells are
edited, so the blank gaps do not clear existing destination values. Very large
sparse selections fall back to selected rectangular blocks to avoid huge
clipboard payloads. Rich text/html output mirrors the selected clipboard
shape.
Pinned and regular viewport coordinates are normalized into whole-grid visual order before copy, so a pinned-left cell followed by the first regular cell is copied as adjacent clipboard columns.
If only one range is selected, RevoGrid keeps its default copy behavior.
Programmatic API
Section titled “Programmatic API”Call the public methods on the plugin instance:
const plugin = (await grid.getPlugins()) .find(plugin => plugin instanceof MultiRangeSelectionPlugin);
plugin?.setSelectedRanges([ { rowType: 'rgRow', colType: 'rgCol', range: { x: 0, y: 0, x1: 1, y1: 1 } }, { rowType: 'rgRow', colType: 'rgCol', range: { x: 3, y: 0, x1: 3, y1: 1 } },]);
const ranges = plugin?.getSelectedRanges();plugin?.clearSelectedRanges();The grid also dispatches multirangeselectionchange whenever the selected ranges
change.
Rendering
Section titled “Rendering”Inactive cells receive:
- attribute:
multi-range-selection - class:
multi-range-selection-cell - edge classes for the range perimeter
The active range remains the native RevoGrid selection overlay.