Skip to content

Smart Excel-Compatible Clipboard

ClipboardPlugin adds rich, Excel-compatible range copy and paste. It negotiates the best browser-visible representation in this order: the lossless RevoGrid payload, application handlers, Excel HTML, generic HTML tables, and TSV/plain text.

Use DataGridFormattingPlugin for the recommended setup. It installs one clipboard owner automatically, so copied values, formulas, formats, merged cells, and explicit row/column sizes share one canonical clipboard document:

import { DataGridFormattingPlugin } from '@revolist/revogrid-pro';
grid.plugins = [DataGridFormattingPlugin];

The clipboard codec only extracts browser-visible Excel declarations. Their normalization into DataGridCellFormat, storage, rendering, and conversion back to Excel HTML are owned by DataGridFormattingPlugin; clipboard code never applies cell CSS directly.

For the complete formatting model, cell/range/row/column scopes, precedence, Format Cells UI, state persistence, and workbook export, see Format Cells.

CapabilityRevoGrid → RevoGridExcel → RevoGridRevoGrid → Excel
ValuesTyped values through the rich payload, including serializable objectsExplicit numeric/boolean HTML values stay typed. When Excel supplies display-only Accounting, Percentage, or date text, the formatting plugin uses the accompanying number format to recover a canonical number or Excel serial dateNumbers, booleans, percentages, and dates are emitted as typed Excel HTML values; text remains text
FormulasA1 source, plus the evaluated cached value when FormulaPlugin is availableBrowser-visible A1 or R1C1 formulas normalize to FormulaPlugin-compatible A1. If Excel exposes only the displayed result, the formula source cannot be recoveredA1 source becomes Excel-relative R1C1 with a typed cached result when FormulaPlugin is available
Number and date formatsExact canonical preset or custom-code formatmso-number-format becomes a canonical { kind: 'code' } formatPresets and exact codes become Excel number-format declarations
Font and fillPreserved through DataGridCellFormat.appearanceAllowlisted font, emphasis, text color, and fill declarations are convertedCanonical typography and colors become Excel-compatible HTML styles
Alignment and text layoutHorizontal/vertical alignment, wrap, indent, rotation, and shrink-to-fitSupported Excel declarations are normalized into appearance optionsCanonical options are serialized back to Excel HTML
BordersPer-edge style, color, and widthSupported shorthand and longhand Excel borders are normalizedPer-edge borders are emitted; legacy uniform border options remain shorthand
Multiple rangesSparse offsets and gaps are lossless in the rich payloadMultiple browser-visible HTML tables are decoded as rangesEach range is emitted as an HTML table; rich RevoGrid geometry remains available to RevoGrid
Merged cellsMerge spans are carried and applied when every destination is writablerowspan and colspan become merge metadataMerge metadata becomes rowspan and colspan
Row heights and column widthsCarried in the rich document; destination RevoGrid resizing is not applied currentlyParsed and retained, but destination RevoGrid resizing is not applied currentlyExplicit sizes are emitted in the Excel HTML table
Application metadataNamespaced serializable contributors are losslessAvailable only when exposed by a registered handlerWritten by compatible registered handlers

Formatting is applied only after the existing provider-backed value paste is accepted. Readonly cells, rejected validation targets, canceled edits, and synthetic grouping rows do not receive formatting or merges.

Excel Desktop does not expose identical browser HTML on every platform. For example, Excel for macOS may omit x:num and copy $ 1,250.50, 18.0%, or 8/20/26 as display text accompanied by Accounting, Percentage, or Short Date format declarations. DataGridFormattingPlugin decodes Excel’s CSS escapes and named formats, converts those known display-only values to canonical numbers or Excel serial dates, and stores the imported number format as a DataGridCellFormat. Unknown or ambiguous text is left unchanged.

Excel’s browser HTML varies by platform, but the formatting owner covers every documented cell number-format category when a usable format declaration is present:

Excel categoryDisplay-only value recovery
GeneralKept as text unless Excel supplies an explicit typed attribute; visual text alone is ambiguous
NumberLocalized decimals, grouping, negative signs, parentheses, and scaling commas become numbers
Currency and AccountingLocale/currency tokens and aligned currency displays become numbers; the exact format code remains canonical formatting
PercentageThe displayed percentage is converted back to its underlying decimal value once per % token
FractionSimple, mixed, fixed-denominator, and negative fraction displays become numbers
ScientificLocalized mantissas and signed E exponents become numbers
DateNumeric and localized month-name dates become Excel serial dates using the format’s token order and locale
Time and Date & time12/24-hour times, seconds, fractional seconds, and combined date-time displays become serial day values
Elapsed time[h], [m], and [s] duration displays—including values beyond 24 hours—become serial day fractions
TextKept exactly as text, including leading zeros
SpecialPostal, phone, social-security, and comparable placeholder formats recover their numeric value while retaining the exact format code
CustomNumeric/date/time/text classification comes from the compiled code; four sections, conditions, colors, literals, spacing, fill, percentages, scaling, fractions, and scientific notation use the same engine as rendering

Explicit x:num and boolean attributes always take precedence. Excel error values and malformed, unsupported, or ambiguous display strings stay unchanged instead of being guessed. These categories follow Microsoft’s available number formats and custom number-format rules.

The following matrix was created as a real .xlsx workbook, opened in current Excel Desktop for macOS, copied through the macOS system clipboard, pasted into RevoGrid, and then copied from RevoGrid back into Excel. It is not an authored HTML fixture.

Native Excel clipboard format matrix pasted into RevoGrid

Use this list to compare an Excel range with its RevoGrid result. Native tested means the format was created in an actual .xlsx workbook and copied through Excel Desktop for macOS and the system clipboard—not merely passed to the HTML parser in a test.

Cell data or number formatSupportNative tested
General and plain numbersSupported; an explicit Excel numeric value stays numeric. Ambiguous display-only General text stays textYes
NumberSupported, including decimals, grouping, signs, parentheses, and scaling commasYes
Currency and AccountingSupported, including currency/locale tokens, aligned symbols, negative parentheses, and zero sectionsYes
PercentageSupported; the displayed percentage is restored to its decimal valueYes
ScientificSupported, including signed exponentsYes
FractionSupported, including simple, mixed, fixed-denominator, and negative fractionsYes
DateSupported for short, long, numeric, month-name, and localized datesYes
Time, DateTime, and elapsed timeSupported for 12/24-hour time, seconds, fractional seconds, combined values, and [h]/[m]/[s] durationsYes
TextSupported and emitted with Excel’s Text format so leading zeros and number-like strings remain textYes
SpecialSupported for numeric placeholder formats such as phone and postal masksPhone mask tested
CustomSupported for up to four sections, conditions, colors, literals, leading zeros, percentages, scaling, fractions, scientific notation, spacing, and fillPositive/negative/zero sections, leading zeros, scaling, literals, and localized date tested
BooleanSupported when Excel exposes Boolean typing; otherwise the displayed TRUE/FALSE is preserved as textConditional on Excel payload
FormulaSupported when the clipboard exposes A1/R1C1 source; otherwise only Excel’s displayed/cached result can be preservedRevoGrid → Excel tested; Excel for macOS omitted formula source
Appearance or structureSupportNative tested
FontFamily, size, bold, italic, underline, strike-through, and text colorFamily, size, bold, italic, and color tested
FillSolid browser-visible cell background colorYes
Alignment and layoutHorizontal/vertical alignment, wrapping, indentation, text rotation, and shrink-to-fitAlignment and wrapping tested
BordersPer-edge style, color, and widthDouble/per-edge border tested
Merged cellsSupported through HTML rowspan/colspan and the rich RevoGrid payloadAutomated copy/paste tested
Multiple rangesLossless between RevoGrid instances; multiple Excel HTML tables are acceptedAutomated copy/paste tested
Row heights and column widthsParsed and copied back to Excel HTML; applying imported sizes to the destination RevoGrid is not implemented yetMetadata emission tested
RevoGrid application metadataSupported through namespaced, serializable contributors in the rich payloadAutomated rich-payload tests

Unsupported or unavailable through browser clipboard

Section titled “Unsupported or unavailable through browser clipboard”
Excel featureCurrent behavior
Native BIFF/OLE clipboard recordsBrowsers do not expose them; RevoGrid uses HTML, plain text, and its versioned web custom format
Pictures, charts, shapes, and other floating objectsNot part of the cell-range clipboard contract
Comments, notes, threaded discussions, and attachmentsNot copied
Data validation rules, dropdown definitions, and non-serializable validator callbacksNot copied
Conditional-format rules, icon sets, data bars, and workbook themesRules are not copied; only browser-visible effective styles may be present in Excel HTML
Named ranges, tables, filters, pivot state, external links, and workbook relationshipsNot copied
Macros, VBA, ActiveX, and embedded objectsNot copied or executed
Formula source omitted by ExcelCannot be reconstructed safely; RevoGrid preserves the displayed/cached result and does not guess
Unsupported or malformed display-only valuesPreserved as text instead of being coerced
Destination RevoGrid row/column resizingSize metadata is retained, but the destination grid is not resized in the current version
Windows Excel native interoperabilitySupported through the same browser-visible contract, but not yet manually verified in this matrix

The pass deliberately records browser limits as limits. Excel for macOS exposed text/html, text/plain, text/rtf, and Files, but its HTML omitted the raw formula source and explicit numeric value for some display-only cells. RevoGrid therefore preserves the displayed formula result in that direction and only recovers a zero when the accompanying third number-format section identifies the display literally. RevoGrid does not guess an unavailable formula. Native Windows Excel remains a separate manual interoperability target because its browser-visible clipboard payload can differ.

One copy operation writes several representations because the destination—not the source—chooses which one it understands:

  1. application/x-revogrid-clipboard+json version 2 is the lossless RevoGrid-to-RevoGrid document.
  2. Registered application formats can override built-ins by priority.
  3. text/html carries the richest browser-visible Excel-compatible table.
  4. text/plain keeps the selection useful in editors and applications that do not understand structured clipboard formats.

On paste, RevoGrid negotiates in the same richest-first order. A malformed or failing handler emits clipboardformaterror and falls through to the next compatible representation instead of partially applying its result.

Copy writes an Excel-compatible HTML table alongside plain text and the lossless RevoGrid payload. DataGridFormattingPlugin converts its canonical options back to Excel number-format codes, font, fill, borders, alignment, wrapping, rotation, and indentation. The clipboard owner also emits formulas with typed cached results, merged-cell spans, and explicit row and column sizes.

Date and time application values remain unchanged in the grid. For Excel HTML only, the formatting plugin converts valid date-like values to Excel 1900-system serials and writes the matching number-format code. This lets Excel receive a real date cell instead of display text while keeping the application’s source model intact.

ClipboardPlugin can also be installed directly for values and custom formats. It emits a one-time dependency warning and omits formatting metadata when no formatting runtime is present.

Browsers expose text/html, text/plain, and permitted web custom formats; they do not expose Excel’s native BIFF/OLE clipboard records. RevoGrid therefore writes all compatible browser representations and adds the versioned application/x-revogrid-clipboard+json format for lossless grid-to-grid transfers. Pictures, charts, comments, validation callbacks, and other native-only Excel objects are outside the cell-range contract.

Handlers have stable IDs, MIME types, priorities, detection, decoding, and optional encoding. Higher priorities can override built-ins. A failing handler emits clipboardformaterror and negotiation continues with the next compatible format.

import type { ClipboardFormatHandler } from '@revolist/revogrid-pro';
const handler: ClipboardFormatHandler = {
id: 'acme-cells',
mimeTypes: ['application/x-acme-cells+json'],
priority: 2_000,
canRead: ({ transfer }) =>
transfer.types.includes('application/x-acme-cells+json'),
read: ({ transfer }) =>
JSON.parse(transfer.getData('application/x-acme-cells+json')),
write: ({ document }) => [
{
type: 'application/x-acme-cells+json',
data: JSON.stringify(document),
},
],
};
grid.useClipboard = {
formats: [handler],
maxCells: 100_000,
maxBytes: 10 * 1024 * 1024,
};

Runtime registration is disposable:

const clipboard = grid
.getPlugins()
.find((plugin) => plugin instanceof ClipboardPlugin);
const unregister = clipboard?.registerFormat(handler);

Serializable application metadata can be carried in the rich payload through registerMetadataContributor. Non-serializable editors, validators, and callbacks are intentionally never copied.

ClipboardJsonPlugin, its legacyJsonText option, and the $rv-parse_ plain-text marker were removed in v2.8. Use ClipboardPlugin directly, or install DataGridFormattingPlugin to include formatting support. Object-valued cells are preserved grid-to-grid through the versioned RevoGrid MIME payload; text/plain remains readable JSON/text for other applications.

// Before
import { ClipboardJsonPlugin } from '@revolist/revogrid-pro';
grid.plugins = [ClipboardJsonPlugin];
// After: values and custom clipboard formats
import { ClipboardPlugin } from '@revolist/revogrid-pro';
grid.plugins = [ClipboardPlugin];
// After: recommended when cell formatting is enabled
import { DataGridFormattingPlugin } from '@revolist/revogrid-pro';
grid.plugins = [DataGridFormattingPlugin];

Applications that persisted or manually generated $rv-parse_ strings must decode them before upgrading. The smart clipboard intentionally does not interpret marker text because ordinary user text must remain ordinary text.

Source code
TypeScriptts
import { defineCustomElements } from '@revolist/revogrid/loader';
import { DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { useRandomData, currentTheme } from '../composables/useRandomData';

const { createRandomData } = useRandomData();
const { isDark } = currentTheme();

defineCustomElements();

type DemoRow = {
  id: number;
  name: string;
  price: number;
  bool: boolean;
  json: Record<string, any>;
  new: Record<string, any> | null;
  notes: string;
};

function formatJson(value: any) {
  if (value == null) {
    return '';
  }
  if (typeof value !== 'object') {
    return String(value);
  }
  return JSON.stringify(value, null, 2);
}

function formatPastedValue(value: any) {
  if (!value) {
    return 'Paste JSON here';
  }
  if (typeof value === 'object') {
    return [value.name, value.emoji].filter(Boolean).join(' ') || 'Object pasted';
  }
  return String(value);
}

function formatClipboardText(value: any) {
  return JSON.stringify(value);
}

function getNextId(rows: DemoRow[]) {
  return rows.reduce((maxId, row) => Math.max(maxId, row.id), 0) + 1;
}

async function writeClipboardText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (_error) {
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.setAttribute('readonly', 'true');
    textarea.style.position = 'fixed';
    textarea.style.left = '-9999px';
    document.body.appendChild(textarea);
    textarea.select();
    const copied = document.execCommand('copy');
    textarea.remove();
    return copied;
  }
}

function buildSeedRows() {
  return createRandomData(30, { json: true }).map((row: any, index) => ({
    ...row,
    id: index + 1,
    new: null,
    notes: 'Ready',
  })) as DemoRow[];
}

function buildComplexRow(nextId: number): DemoRow {
  return {
    id: nextId,
    name: 'Dragonfruit 🎉',
    price: 32.5,
    bool: true,
    json: {
      name: 'Dragonfruit',
      emoji: '🎉',
      meta: {
        batch: 'interactive',
        labels: ['rich', 'tropical', 'complex'],
        nutrition: {
          sugar: 23,
          vitaminC: true,
        },
      },
    },
    new: null,
    notes: 'Pasted JSON will appear here',
  };
}

const root = document.getElementById('demo-wrapper');
if (root) {
  const container = document.createElement('section');
  container.style.display = 'grid';
  container.style.gridTemplateRows = 'auto auto minmax(0, 1fr)';
  container.style.alignItems = 'start';
  container.style.gap = '10px';
  container.style.padding = '4px 0';
  container.style.fontFamily = 'Arial, sans-serif';
  container.style.boxSizing = 'border-box';
  container.style.width = '100%';
  container.style.height = '100%';
  container.style.minHeight = '0';

  const toolbar = document.createElement('div');
  toolbar.style.display = 'flex';
  toolbar.style.gap = '8px';
  toolbar.style.flexWrap = 'wrap';
  toolbar.style.alignItems = 'center';
  toolbar.style.alignSelf = 'start';

  const statusStrip = document.createElement('div');
  statusStrip.style.display = 'flex';
  statusStrip.style.gap = '8px';
  statusStrip.style.flexWrap = 'wrap';
  statusStrip.style.alignItems = 'center';
  statusStrip.style.alignSelf = 'stretch';

  const summary = document.createElement('p');
  summary.style.margin = '0';
  summary.style.padding = '6px 10px';
  summary.style.border = '1px solid #d8e1ec';
  summary.style.borderRadius = '6px';
  summary.style.background = '#f4f7ff';
  summary.style.fontSize = '12px';
  summary.style.lineHeight = '1.35';
  summary.style.color = '#0f172a';
  summary.style.boxSizing = 'border-box';
  summary.style.flex = '1 1 320px';

  const copyStatus = document.createElement('p');
  copyStatus.style.margin = '0';
  copyStatus.style.padding = '6px 10px';
  copyStatus.style.border = '1px solid #d8e1ec';
  copyStatus.style.borderRadius = '6px';
  copyStatus.style.background = '#f2fbf4';
  copyStatus.style.fontSize = '12px';
  copyStatus.style.lineHeight = '1.35';
  copyStatus.style.color = '#064e3b';
  copyStatus.style.boxSizing = 'border-box';
  copyStatus.style.flex = '1 1 260px';

  const pasteStatus = document.createElement('p');
  pasteStatus.style.margin = '0';
  pasteStatus.style.padding = '6px 10px';
  pasteStatus.style.border = '1px solid #d8e1ec';
  pasteStatus.style.borderRadius = '6px';
  pasteStatus.style.background = '#fff7ed';
  pasteStatus.style.fontSize = '12px';
  pasteStatus.style.lineHeight = '1.35';
  pasteStatus.style.color = '#7c2d12';
  pasteStatus.style.boxSizing = 'border-box';
  pasteStatus.style.flex = '1 1 220px';

  const createButton = (label: string, onClick: () => void) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label;
    button.style.padding = '6px 12px';
    button.style.border = '1px solid #cbd5e1';
    button.style.borderRadius = '6px';
    button.style.background = '#fff';
    button.style.cursor = 'pointer';
    button.style.fontSize = '12px';
    button.style.fontFamily = 'inherit';
    button.style.lineHeight = '18px';
    button.style.height = '32px';
    button.style.minHeight = '0';
    button.style.alignSelf = 'center';
    button.style.flex = '0 0 auto';
    button.addEventListener('click', onClick);
    return button;
  };

  const grid = document.createElement('revo-grid');
  grid.style.minHeight = '0';
  grid.style.height = '100%';
  grid.style.alignSelf = 'stretch';
  grid.plugins = [DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin];
  grid.stretch = 'all';
  grid.theme = isDark() ? 'darkMaterial' : 'material';
  grid.hideAttribution = true;

  let sourceRows = buildSeedRows();

  const refreshGridSource = (nextRows: DemoRow[]) => {
    sourceRows = nextRows;
    grid.source = sourceRows;
    summary.textContent = `Rows: ${sourceRows.length} | Try copying JSON cells and pasting back into the 'Paste JSON here' column.`;
  };

  const syncCopyStatus = (message: string) => {
    copyStatus.textContent = `Copy: ${message}`;
  };

  const syncPasteStatus = (message: string) => {
    pasteStatus.textContent = `Paste: ${message}`;
  };

  const summarizeMatrix = (matrix: any[][]) => {
    if (!Array.isArray(matrix) || matrix.length === 0) {
      return 'No clipboard cells.';
    }
    return `${matrix.length} rows × ${matrix[0]?.length ?? 0} columns`;
  };

  const copyRowJson = async (value: any, rowId: number) => {
    const copied = await writeClipboardText(formatClipboardText(value));
    if (copied) {
      syncCopyStatus(`Copied row ${rowId} as JSON text. Use grid-cell copy for lossless object paste.`);
    } else {
      syncCopyStatus('Could not write to clipboard. Use keyboard copy in supported browsers.');
    }
  };

  const columns = [
    {
      name: 'ID',
      prop: 'id',
      size: 80,
    },
    {
      name: 'Source JSON (copy-ready)',
      prop: 'json',
      size: 360,
      cellParser: (model: any, column: any) => JSON.stringify(model[column.prop]),
      cellTemplate: (h: any, { value: jsonValue }: any) => {
        return h('pre', { class: 'bg-gray-200 border border-slate-300 rounded p-1 py-1 text-xs text-center inline-block' }, [
          h('code', { class: 'text-gray-900' }, formatJson(jsonValue)),
        ]);
      },
    },
    {
      name: 'Paste JSON here',
      prop: 'new',
      cellTemplate: (h: any, { value }: any) => {
        return h(
          'pre',
          { class: 'bg-slate-100 border border-slate-300 rounded p-1 py-1 mt-2 text-xs inline-block text-center' },
          [h('code', { class: 'text-slate-900' }, formatPastedValue(value))],
        );
      },
    },
    {
      name: 'State',
      prop: 'notes',
      size: 240,
    },
  ];

  grid.columns = columns;

  grid.addEventListener('beforecopyapply', (event: any) => {
    syncCopyStatus(summarizeMatrix(event.detail?.data));
  });

  grid.addEventListener('beforepasteapply', (event: any) => {
    syncPasteStatus(`Pasted ${summarizeMatrix(event.detail?.parsed)}.`);
  });

  toolbar.append(
    createButton('Refresh source', () => refreshGridSource(buildSeedRows())),
    createButton('Add complex row', () =>
      refreshGridSource([
        buildComplexRow(getNextId(sourceRows)),
        ...sourceRows,
      ]),
    ),
    createButton('Copy first JSON text', () => {
      const firstRow = sourceRows[0];
      if (firstRow) {
        void copyRowJson(firstRow.json, firstRow.id);
      }
    }),
    createButton('Clear pasted values', () =>
      refreshGridSource(
        sourceRows.map((row) => ({
          ...row,
          new: null,
          notes: 'Paste cell emptied',
        })),
      ),
    ),
  );

  statusStrip.append(summary, copyStatus, pasteStatus);
  container.append(toolbar, statusStrip, grid);
  root.appendChild(container);

  refreshGridSource(sourceRows);
  syncCopyStatus('Copy a JSON cell with Ctrl/Cmd+C for lossless grid paste, or copy JSON text for external apps.');
  syncPasteStatus('Paste into the target column to restore objects.');
}
Vuevue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { VGrid } from '@revolist/vue3-datagrid';
import { DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { useRandomData, currentThemeVue } from '../composables/useRandomData';

const { createRandomData } = useRandomData();
const { isDark } = currentThemeVue();

type DemoRow = {
  id: number;
  name: string;
  price: number;
  bool: boolean;
  json: Record<string, any>;
  new: Record<string, any> | null;
  notes: string;
};

const theme = computed(() => isDark.value ? 'darkMaterial' : 'material');
const copyStatus = ref('Copy: Use Ctrl/Cmd+C on JSON cells to inspect copy payload size.');
const pasteStatus = ref('Paste: Paste into "Paste JSON here" cells to see parsed objects.');
const formatJson = (value: any) => {
  if (value == null) {
    return '';
  }
  if (typeof value !== 'object') {
    return String(value);
  }
  return JSON.stringify(value, null, 2);
};

const formatPastedValue = (value: any) => {
  if (!value) {
    return 'Paste JSON here';
  }
  if (typeof value === 'object') {
    return [value.name, value.emoji].filter(Boolean).join(' ') || 'Object pasted';
  }
  return String(value);
};

const formatClipboardText = (value: any) => JSON.stringify(value);

const writeClipboardText = async (text: string) => {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (_error) {
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.setAttribute('readonly', 'true');
    textarea.style.position = 'fixed';
    textarea.style.left = '-9999px';
    document.body.appendChild(textarea);
    textarea.select();
    const copied = document.execCommand('copy');
    textarea.remove();
    return copied;
  }
};

const seedRows = () => {
  return createRandomData(30, { json: true }).map((row: any, index: number) => ({
    ...row,
    id: index + 1,
    new: null,
    notes: 'Ready',
  })) as DemoRow[];
};

const buildComplexRow = (nextId: number): DemoRow => ({
  id: nextId,
  name: 'Dragonfruit 🎉',
  price: 32.5,
  bool: true,
  json: {
    name: 'Dragonfruit',
    emoji: '🎉',
    meta: {
      batch: 'interactive',
      labels: ['rich', 'tropical', 'complex'],
      nutrition: {
        sugar: 23,
        vitaminC: true,
      },
    },
  },
  new: null,
  notes: 'Pasted JSON will appear here',
});

const rows = ref<DemoRow[]>(seedRows());

const summarizeMatrix = (matrix: any[][]) => {
  if (!Array.isArray(matrix) || matrix.length === 0) {
    return 'No clipboard cells.';
  }
  return `${matrix.length} rows × ${matrix[0]?.length ?? 0} columns`;
};

const copyToClipboard = async (value: any, rowId: number) => {
  const copied = await writeClipboardText(formatClipboardText(value));
  if (copied) {
    copyStatus.value = `Copied row ${rowId} as JSON text. Use grid-cell copy for lossless object paste.`;
  } else {
    copyStatus.value = 'Could not write to clipboard in this environment.';
  }
};

const copyJsonColumn = {
  name: 'Source JSON (copy-ready)',
  prop: 'json',
  size: 360,
  cellParser: (model: any, column: any) => JSON.stringify(model[column.prop]),
  cellTemplate: (h: any, { value: jsonValue }: any) => {
    return h('pre', { class: 'bg-gray-200 border border-slate-300 rounded p-1 py-1 mt-2 text-xs inline-block text-center' }, [
      h('code', { class: 'text-gray-900' }, formatJson(jsonValue)),
    ]);
  },
};

const columns = [
  {
    name: 'ID',
    prop: 'id',
    size: 80,
  },
  copyJsonColumn,
  {
    name: 'Paste JSON here',
    prop: 'new',
    cellTemplate: (h: any, { value }: any) =>
      h('pre', { class: 'bg-slate-100 border border-slate-300 rounded p-1 py-1 mt-2 text-xs inline-block text-center' }, [
        h('code', { class: 'text-slate-900' }, formatPastedValue(value)),
      ]),
  },
  {
    name: 'State',
    prop: 'notes',
    size: 240,
  },
];

const onCopy = (event: CustomEvent<any>) => {
  copyStatus.value = `Copy: ${summarizeMatrix(event.detail?.data)}`;
};

const onPaste = (event: CustomEvent<any>) => {
  pasteStatus.value = `Paste: ${summarizeMatrix(event.detail?.parsed)}.`;
};

const refreshData = () => {
  rows.value = seedRows();
};

const addComplexRow = () => {
  const nextId = rows.value.reduce((maxId, row) => Math.max(maxId, row.id), 0) + 1;
  rows.value = [buildComplexRow(nextId), ...rows.value];
};

const copyFirstJson = () => {
  const firstRow = rows.value[0];
  if (firstRow) {
    void copyToClipboard(firstRow.json, firstRow.id);
  }
};

const clearPasted = () => {
  rows.value = rows.value.map((row) => ({
    ...row,
    new: null,
    notes: 'Paste cell emptied',
  }));
};

const infoStyle = 'margin: 0; padding: 6px 10px; border: 1px solid #d8e1ec; border-radius: 6px; background: #f4f7ff; font-size: 12px; line-height: 1.35; color: #0f172a; box-sizing: border-box;';
const plugins = [DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin];
const buttonStyle =
  'padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; line-height: 18px; height: 32px; min-height: 0; align-self: center; flex: 0 0 auto; font-family: inherit;';
</script>

<template>
  <section style="display: grid; grid-template-rows: auto auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 4px 0; box-sizing: border-box; width: 100%; height: 100%; min-height: 0;">
    <div style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; align-self: start;">
      <button type="button" :style="buttonStyle" @click="refreshData">Refresh source</button>
      <button type="button" :style="buttonStyle" @click="addComplexRow">Add complex row</button>
      <button type="button" :style="buttonStyle" @click="copyFirstJson">Copy first JSON text</button>
      <button type="button" :style="buttonStyle" @click="clearPasted">Clear pasted values</button>
    </div>
    <div style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; align-self: stretch;">
      <p :style="`${infoStyle} flex: 1 1 320px;`">Rows: {{ rows.length }} | Try copying JSON cells and pasting back into the "Paste JSON here" column.</p>
      <p :style="`${infoStyle} background: #f2fbf4; color: #064e3b; flex: 1 1 260px;`">{{ copyStatus }}</p>
      <p :style="`${infoStyle} background: #fff7ed; color: #7c2d12; flex: 1 1 220px;`">{{ pasteStatus }}</p>
    </div>
    <VGrid
      :theme="theme"
      :source="rows"
      :columns="columns"
      :plugins="plugins"
      stretch="all"
      :hideAttribution="true"
      @beforecopyapply="onCopy"
      @beforepasteapply="onPaste"
      style="height: 100%; align-self: stretch;"
    />
  </section>
</template>
Reacttsx
import React, { useCallback, useMemo, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import { DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { useRandomData, currentTheme } from '../composables/useRandomData';

const ClipboardJsonDemo: React.FC = () => {
  const { createRandomData } = useRandomData();
  const theme = currentTheme().isDark() ? 'darkMaterial' : 'material';
  const [copyStatus, setCopyStatus] = useState('Copy: Use Ctrl/Cmd+C on JSON cells to inspect copy payload size.');
  const [pasteStatus, setPasteStatus] = useState('Paste: Paste into "Paste JSON here" cells to see parsed objects.');
  type DemoRow = {
    id: number;
    name: string;
    price: number;
    bool: boolean;
    json: Record<string, any>;
    new: Record<string, any> | null;
    notes: string;
  };

  const formatJson = useCallback((value: any) => {
    if (value == null) {
      return '';
    }
    if (typeof value !== 'object') {
      return String(value);
    }
    return JSON.stringify(value, null, 2);
  }, []);

  const formatPastedValue = useCallback((value: any) => {
    if (!value) {
      return 'Paste JSON here';
    }
    if (typeof value === 'object') {
      return [value.name, value.emoji].filter(Boolean).join(' ') || 'Object pasted';
    }
    return String(value);
  }, []);

  const formatClipboardText = useCallback((value: any) => JSON.stringify(value), []);

  const writeClipboardText = useCallback(async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (_error) {
      const textarea = document.createElement('textarea');
      textarea.value = text;
      textarea.setAttribute('readonly', 'true');
      textarea.style.position = 'fixed';
      textarea.style.left = '-9999px';
      document.body.appendChild(textarea);
      textarea.select();
      const copied = document.execCommand('copy');
      textarea.remove();
      return copied;
    }
  }, []);

  const seedRows = useCallback(() => {
    return createRandomData(30, { json: true }).map((row: any, index: number) => ({
      ...row,
      id: index + 1,
      new: null,
      notes: 'Ready',
    })) as DemoRow[];
  }, [createRandomData]);

  const buildComplexRow = useCallback((nextId: number): DemoRow => ({
    id: nextId,
    name: 'Dragonfruit 🎉',
    price: 32.5,
    bool: true,
    json: {
      name: 'Dragonfruit',
      emoji: '🎉',
      meta: {
        batch: 'interactive',
        labels: ['rich', 'tropical', 'complex'],
        nutrition: {
          sugar: 23,
          vitaminC: true,
        },
      },
    },
    new: null,
    notes: 'Pasted JSON will appear here',
  }), []);

  const [source, setSource] = useState<DemoRow[]>(() => seedRows());
  const plugins = useMemo(() => [DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin], []);

  const copyToClipboard = useCallback(async (value: any, rowId: number) => {
    const copied = await writeClipboardText(formatClipboardText(value));
    if (copied) {
      setCopyStatus(`Copied row ${rowId} as JSON text. Use grid-cell copy for lossless object paste.`);
    } else {
      setCopyStatus('Could not write to clipboard in this environment.');
    }
  }, [formatClipboardText, writeClipboardText]);

  const summarizeMatrix = useCallback((matrix: any[][]) => {
    if (!Array.isArray(matrix) || matrix.length === 0) {
      return 'No clipboard cells.';
    }
    return `${matrix.length} rows × ${matrix[0]?.length ?? 0} columns`;
  }, []);

  const handleCopy = useCallback((event: any) => {
    setCopyStatus(`Copy: ${summarizeMatrix(event.detail?.data)}`);
  }, [summarizeMatrix]);

  const handlePaste = useCallback((event: any) => {
    setPasteStatus(`Paste: ${summarizeMatrix(event.detail?.parsed)}.`);
  }, [summarizeMatrix]);

  const columns = useMemo(() => [
    {
      name: 'ID',
      prop: 'id',
      size: 80,
    },
    {
      name: 'Source JSON (copy-ready)',
      prop: 'json',
      size: 360,
      cellParser: (model: any, column: any) => JSON.stringify(model[column.prop]),
      cellTemplate: (h: any, { value: jsonValue }: any) => {
        return h(
          'pre',
          { style: { margin: 0 } },
          [h('code', { className: 'text-xs' }, formatJson(jsonValue))],
        );
      },
    },
    {
      name: 'Paste JSON here',
      prop: 'new',
      cellTemplate: (h: any, { value }: any) => {
        return h(
          'pre',
          { style: { margin: 0 } },
          [h('code', { className: 'text-xs' }, formatPastedValue(value))],
        );
      },
    },
    {
      name: 'State',
      prop: 'notes',
      size: 240,
    },
  ], [formatJson, formatPastedValue]);

  const refreshData = useCallback(() => {
    setSource(seedRows());
  }, [seedRows]);

  const addComplexRow = useCallback(() => {
    setSource((current) => {
      const nextId = current.reduce((maxId, row) => Math.max(maxId, row.id), 0) + 1;
      return [buildComplexRow(nextId), ...current];
    });
  }, [buildComplexRow]);

  const copyFirstJson = useCallback(() => {
    const firstRow = source[0];
    if (firstRow) {
      void copyToClipboard(firstRow.json, firstRow.id);
    }
  }, [copyToClipboard, source]);

  const clearPasted = useCallback(() => {
    setSource((current) =>
      current.map((row) => ({
        ...row,
        new: null,
        notes: 'Paste cell emptied',
      })),
    );
  }, []);

  const buttonStyles = {
    padding: '6px 12px',
    border: '1px solid #cbd5e1',
    borderRadius: '6px',
    background: '#fff',
    cursor: 'pointer',
    fontSize: '12px',
    lineHeight: '18px',
    height: '32px',
    minHeight: 0,
    alignSelf: 'center',
    flex: '0 0 auto',
  } as React.CSSProperties;

  const infoStyle: React.CSSProperties = {
    margin: 0,
    padding: '6px 10px',
    border: '1px solid #d8e1ec',
    borderRadius: '6px',
    background: '#f4f7ff',
    fontSize: '12px',
    lineHeight: 1.35,
    color: '#0f172a',
    boxSizing: 'border-box',
  };

  return (
    <div style={{
      display: 'grid',
      gridTemplateRows: 'auto auto minmax(0, 1fr)',
      alignItems: 'start',
      gap: '10px',
      padding: '4px 0',
      boxSizing: 'border-box',
      width: '100%',
      height: '100%',
      minHeight: 0,
    }}>
      <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', alignSelf: 'start' }}>
        <button style={buttonStyles} type="button" onClick={refreshData}>Refresh source</button>
        <button style={buttonStyles} type="button" onClick={addComplexRow}>Add complex row</button>
        <button style={buttonStyles} type="button" onClick={copyFirstJson}>Copy first JSON text</button>
        <button style={buttonStyles} type="button" onClick={clearPasted}>Clear pasted values</button>
      </div>
      <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', alignSelf: 'stretch' }}>
        <p style={{ ...infoStyle, flex: '1 1 320px' }}>Rows: {source.length} | Try copying JSON cells and pasting back into the "Paste JSON here" column.</p>
        <p style={{ ...infoStyle, background: '#f2fbf4', color: '#064e3b', flex: '1 1 260px' }}>{copyStatus}</p>
        <p style={{ ...infoStyle, background: '#fff7ed', color: '#7c2d12', flex: '1 1 220px' }}>{pasteStatus}</p>
      </div>
      <RevoGrid
        theme={theme}
        source={source}
        columns={columns}
        plugins={plugins}
        stretch="all"
        hideAttribution
        onBeforecopyapply={handleCopy}
        onBeforepasteapply={handlePaste}
        style={{ minHeight: 0, height: '100%', alignSelf: 'stretch' }}
      />
    </div>
  );
};

export default ClipboardJsonDemo;
Angularts
import { Component } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import { DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { useRandomData, currentTheme } from '../composables/useRandomData';

type DemoRow = {
  id: number;
  name: string;
  price: number;
  bool: boolean;
  json: Record<string, any>;
  new: Record<string, any> | null;
  notes: string;
};

@Component({
  selector: 'clipboard-json-grid',
  standalone: true,
  imports: [RevoGrid],
  template: `
    <section style="display: grid; grid-template-rows: auto auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 4px 0; box-sizing: border-box; width: 100%; height: 100%; min-height: 0;">
      <div style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; align-self: start;">
        <button type="button" (click)="refreshData()" style="padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; line-height: 18px; height: 32px; min-height: 0; align-self: center; flex: 0 0 auto; font-family: inherit;">Refresh source</button>
        <button type="button" (click)="addComplexRow()" style="padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; line-height: 18px; height: 32px; min-height: 0; align-self: center; flex: 0 0 auto; font-family: inherit;">Add complex row</button>
        <button type="button" (click)="copyFirstJson()" style="padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; line-height: 18px; height: 32px; min-height: 0; align-self: center; flex: 0 0 auto; font-family: inherit;">Copy first JSON text</button>
        <button type="button" (click)="clearPasted()" style="padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; line-height: 18px; height: 32px; min-height: 0; align-self: center; flex: 0 0 auto; font-family: inherit;">Clear pasted values</button>
      </div>
      <div style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; align-self: stretch;">
        <p style="margin: 0; padding: 6px 10px; border: 1px solid #d8e1ec; border-radius: 6px; background: #f4f7ff; font-size: 12px; line-height: 1.35; color: #0f172a; box-sizing: border-box; flex: 1 1 320px;">Rows: {{ source.length }} | Try copying JSON cells and pasting back into the 'Paste JSON here' column.</p>
        <p style="margin: 0; padding: 6px 10px; border: 1px solid #d8e1ec; border-radius: 6px; background: #f2fbf4; font-size: 12px; line-height: 1.35; color: #064e3b; box-sizing: border-box; flex: 1 1 260px;">{{ copyStatus }}</p>
        <p style="margin: 0; padding: 6px 10px; border: 1px solid #d8e1ec; border-radius: 6px; background: #fff7ed; font-size: 12px; line-height: 1.35; color: #7c2d12; box-sizing: border-box; flex: 1 1 220px;">{{ pasteStatus }}</p>
      </div>
      <revo-grid
        [theme]="theme"
        [source]="source"
        [columns]="columns"
        [plugins]="plugins"
        stretch="all"
        [hideAttribution]="true"
        (beforecopyapply)="onCopy($event)"
        (beforepasteapply)="onPaste($event)"
        style="min-height: 0; height: 100%; align-self: stretch;"
      ></revo-grid>
    </section>
  `,
})
export class ClipboardJsonGridComponent {
  theme = currentTheme().isDark() ? 'darkMaterial' : 'material';
  copyStatus = 'Copy: Use Ctrl/Cmd+C on JSON cells to inspect copy payload size.';
  pasteStatus = 'Paste: Paste into "Paste JSON here" cells to see parsed objects.';
  source: DemoRow[] = this.createSeedRows();
  plugins = [DataGridFormattingPlugin, ColumnStretchPlugin, RowOddPlugin];

  columns = [
    {
      name: 'ID',
      prop: 'id',
      size: 80,
    },
    {
      name: 'Source JSON (copy-ready)',
      prop: 'json',
      size: 360,
      cellParser: (model: any, column: any) => JSON.stringify(model[column.prop]),
      cellTemplate: (h: any, { value: jsonValue }: any) => {
        return h('pre', { class: 'bg-gray-200 border border-slate-300 rounded p-1 py-1 mt-2 text-xs inline-block text-center' }, [
          h('code', { class: 'text-gray-900' }, this.formatJson(jsonValue)),
        ]);
      },
    },
    {
      name: 'Paste JSON here',
      prop: 'new',
      cellTemplate: (h: any, data: any) => {
        return h('pre', { class: 'bg-slate-100 border border-slate-300 rounded p-1 py-1 mt-2 text-xs inline-block text-center' }, [
          h('code', { class: 'text-slate-900' }, this.formatPastedValue(data.value)),
        ]);
      },
    },
    {
      name: 'State',
      prop: 'notes',
      size: 240,
    },
  ];

  private createSeedRows() {
    return useRandomData().createRandomData(30, { json: true }).map((row: any, index: number) => ({
      ...row,
      id: index + 1,
      new: null,
      notes: 'Ready',
    })) as DemoRow[];
  }

  private createComplexRow(nextId: number): DemoRow {
    return {
      id: nextId,
      name: 'Dragonfruit 🎉',
      price: 32.5,
      bool: true,
      json: {
        name: 'Dragonfruit',
        emoji: '🎉',
        meta: {
          batch: 'interactive',
          labels: ['rich', 'tropical', 'complex'],
          nutrition: {
            sugar: 23,
            vitaminC: true,
          },
        },
      },
      new: null,
      notes: 'Pasted JSON will appear here',
    };
  }

  private formatJson(value: any) {
    if (value == null) {
      return '';
    }
    if (typeof value !== 'object') {
      return String(value);
    }
    return JSON.stringify(value, null, 2);
  }

  private formatPastedValue(value: any) {
    if (!value) {
      return 'Paste JSON here';
    }
    if (typeof value === 'object') {
      return [value.name, value.emoji].filter(Boolean).join(' ') || 'Object pasted';
    }
    return String(value);
  }

  private formatClipboardText(value: any) {
    return JSON.stringify(value);
  }

  private async writeClipboardText(text: string) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (_error) {
      const textarea = document.createElement('textarea');
      textarea.value = text;
      textarea.setAttribute('readonly', 'true');
      textarea.style.position = 'fixed';
      textarea.style.left = '-9999px';
      document.body.appendChild(textarea);
      textarea.select();
      const copied = document.execCommand('copy');
      textarea.remove();
      return copied;
    }
  }

  private summarizeMatrix(matrix: any[][]) {
    if (!Array.isArray(matrix) || matrix.length === 0) {
      return 'No clipboard cells.';
    }
    return `${matrix.length} rows × ${matrix[0]?.length ?? 0} columns`;
  }

  onCopy(event: CustomEvent<any>) {
    this.copyStatus = `Copy: ${this.summarizeMatrix(event.detail?.data)}`;
  }

  onPaste(event: CustomEvent<any>) {
    this.pasteStatus = `Paste: ${this.summarizeMatrix(event.detail?.parsed)}.`;
  }

  async copyRowJson(value: any, rowId: number) {
    const copied = await this.writeClipboardText(this.formatClipboardText(value));
    if (copied) {
      this.copyStatus = `Copied row ${rowId} as JSON text. Use grid-cell copy for lossless object paste.`;
    } else {
      this.copyStatus = 'Could not write to clipboard in this environment.';
    }
  }

  refreshData() {
    this.source = this.createSeedRows();
  }

  addComplexRow() {
    const nextId = this.source.reduce((maxId, row) => Math.max(maxId, row.id), 0) + 1;
    this.source = [this.createComplexRow(nextId), ...this.source];
  }

  copyFirstJson() {
    const firstRow = this.source[0];
    if (firstRow) {
      void this.copyRowJson(firstRow.json, firstRow.id);
    }
  }

  clearPasted() {
    this.source = this.source.map((row) => ({
      ...row,
      new: null,
      notes: 'Paste cell emptied',
    }));
  }
}