Skip to content

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
TypeScriptts
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();
  };
}
Datats
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}`;
}
Vuevue
<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>
Reacttsx
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>
  );
}
Angularts
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 };
  1. Select a cell or range normally.
  2. Ctrl/Cmd+click another cell to keep the previous range and start a new active range.
  3. Shift+click or Shift+Arrow extends only the active range.
  4. Tab and Enter move the focused cell inside the active range.
  5. Delete and Backspace clear inactive ranges plus the active range.
  6. Copy includes inactive ranges plus the active range.
  7. Dragging the autofill handle across pinned/main viewport boundaries fills the visible target segments.
  8. Plain click clears inactive ranges and starts a normal selection.

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.

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.

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.