beautiful-grid 1.0.16 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -583,6 +583,17 @@ const columns: BGridColumn<Item>[] = [
583
583
  ];
584
584
  ```
585
585
 
586
+ `createSelectEditorPlugin` options:
587
+ - `options`: List of `{ value, label, disabled? }` entries.
588
+ - `allowCustomValue?: boolean`: Set `true` to allow pasting arbitrary values not present in `options` (default: `false`, rejecting unlisted values with `parseValueFailed`). Automatically preserves numeric types when option values are numeric.
589
+ - `copyMode?: 'label' | 'value'`: Controls clipboard copy output (`'label'` by default when label is text, or `'value'`). Matching during paste prioritizes the configured `copyMode` to prevent label/value collisions.
590
+ - `allowEmpty?: boolean`: Set `true` to allow clearing cell values when pasting empty text (default: `false`).
591
+ - `emptyValue?: Value | null`: Value written when empty text is pasted with `allowEmpty: true` (default: `''`).
592
+ - `allowDisabledOptions?: boolean`: Set `true` to allow pasting options marked `disabled: true` (default: `false`, rejecting disabled options).
593
+ - `parseClipboardText?`: Override custom clipboard parsing and validation logic.
594
+ - `getClipboardText?`: Override custom clipboard text retrieval logic.
595
+ - `openOnMount?: boolean`: Automatically opens native picker on editor activation (default: `true`).
596
+
586
597
  #### Editor Icons (Lookup & Quick Triggers)
587
598
 
588
599
  Add inline trigger icons beside cell values using `editorIcon`:
@@ -649,16 +660,15 @@ Configure active cell handling and keyboard behavior with `cellNavigationOptions
649
660
 
650
661
  ---
651
662
 
652
- ### 5. Cell Selection & Clipboard (Excel-compatible)
663
+ ### 5. Cell Range Selection & Clipboard Integration
653
664
 
654
665
  Cell range selection and clipboard copy/paste are enabled by default.
655
666
 
656
667
  ```typescript jsx
657
668
  <BGrid
658
- width={800}
659
- height={400}
660
- columns={columns}
661
669
  data={data}
670
+ columns={columns}
671
+ editable
662
672
  rowKey='id'
663
673
  cellSelectionOptions={{
664
674
  enabled: true,
@@ -666,20 +676,24 @@ Cell range selection and clipboard copy/paste are enabled by default.
666
676
  clearOnEscape: true,
667
677
  clearOnOutsideClick: true,
668
678
  maxClipboardCells: 100000,
669
- maxClipboardTextLength: 8 * 1024 * 1024,
670
- onCopyError: error => console.warn('Copy skipped:', error),
671
- onPasteError: error => console.warn('Paste failed:', error),
672
- createRowOnPaste: ({ rowIndex, clipboardRow, columns }) => ({
679
+ createRowOnPaste: ({ rowIndex, clipboardRow }) => ({
680
+ status: BGridDataItemStatus.new,
673
681
  values: createNewRowModel(clipboardRow),
674
682
  }),
675
683
  }}
676
684
  />
677
685
  ```
678
686
 
679
- - `Ctrl+C` / `Cmd+C`: Copies selected cells as Tab-separated (`\t`) values and CRLF (`\r\n`) lines, directly pasteable into Excel or Google Sheets.
680
- - `Ctrl+V` / `Cmd+V`: Pastes clipboard matrix starting from the focused cell across editable columns.
681
- - `multiSelectFocusMode: 'last'` makes the last cell selected by a drag the active cell and the paste starting point. The default is `'first'` for backward compatibility.
682
- - Customize copied text on a per-column basis with `getClipboardText: ({ value, values }) => string`.
687
+ - `Ctrl+C` / `Cmd+C`: Copies selected cells as Tab-separated (`\t`) values and CRLF (`\r\n`) lines, directly pasteable into Excel or Google Sheets. Columns configured with `createSelectEditorPlugin` copy the display `label` by default.
688
+ - `Ctrl+V` / `Cmd+V`: Pastes clipboard values across editable columns:
689
+ - **Single cell fill**: Pasting a 1×1 value into a multi-cell selection fills every cell in the selected range.
690
+ - **Pattern tiling (Repeat)**: Pasting an $n \times m$ matrix into an $N \times M$ selection whose dimensions are exact integer multiples ($N \pmod n = 0$ and $M \pmod m = 0$) tiles/repeats the pattern across the entire selection.
691
+ - **Non-multiple fallback**: When the selection is not an exact multiple, the matrix pastes once starting at the top-left of the selected range (or focused cell if no range).
692
+ - `multiSelectFocusMode: 'last'` makes the last cell selected by a drag the active cell. The default is `'first'` for backward compatibility.
693
+ - **Paste validation & editor protection**:
694
+ - `createSelectEditorPlugin` validates pasted text against option values and string labels, mapping labels back to values with $O(1)$ indexed lookup.
695
+ - By default, unlisted values and disabled options are rejected and reported as `parseValueFailed` through `onPasteError`, preventing data corruption. Set `allowCustomValue: true` on the select editor plugin to allow arbitrary inputs.
696
+ - Customize copied text on a per-column basis with `getClipboardText: ({ value, values }) => string`, or restore/validate custom types with `parseClipboardText: (text, context) => parsedValue`.
683
697
 
684
698
  ---
685
699
 
@@ -1226,7 +1226,7 @@ function Table(props) {
1226
1226
  var rowHasNewLogicalCell = false;
1227
1227
  var columnIndexes = Array.from((_f = selectedCells.get(ri)) !== null && _f !== void 0 ? _f : []).sort(function (a, b) { return a - b; });
1228
1228
  columnIndexes.forEach(function (ci) {
1229
- var _a;
1229
+ var _a, _b, _c;
1230
1230
  var column = columns[ci];
1231
1231
  if (!column)
1232
1232
  return;
@@ -1240,8 +1240,10 @@ function Table(props) {
1240
1240
  rowHasNewLogicalCell = true;
1241
1241
  var logicalItem = (_a = data[logicalCell.cell.rowIndex]) !== null && _a !== void 0 ? _a : item;
1242
1242
  var value = (0, utils_1.getCellValueByRowKey)(column.key, logicalItem.values);
1243
- var clipboardValue = column.getClipboardText
1244
- ? column.getClipboardText({
1243
+ var pluginClipboardTextGetter = ((_b = column.editor) === null || _b === void 0 ? void 0 : _b.type) === 'plugin' ? column.editor.getClipboardText : undefined;
1244
+ var clipboardTextGetter = (_c = column.getClipboardText) !== null && _c !== void 0 ? _c : pluginClipboardTextGetter;
1245
+ var clipboardValue = clipboardTextGetter
1246
+ ? clipboardTextGetter({
1245
1247
  column: column,
1246
1248
  index: logicalCell.cell.rowIndex,
1247
1249
  columnIndex: ci,
@@ -1328,7 +1330,7 @@ function Table(props) {
1328
1330
  cellSelectionOptions === null || cellSelectionOptions === void 0 ? void 0 : cellSelectionOptions.onCopyError,
1329
1331
  ]);
1330
1332
  var pasteClipboardText = (0, react_1.useCallback)(function (text, clipboardTypes) {
1331
- var _a, _b;
1333
+ var _a, _b, _c, _d, _e, _f, _g;
1332
1334
  if (clipboardTypes === void 0) { clipboardTypes = []; }
1333
1335
  if (!cellSelectionEnabled || !editable || !activeCell || data.length === 0 || columns.length === 0) {
1334
1336
  return false;
@@ -1367,17 +1369,41 @@ function Table(props) {
1367
1369
  });
1368
1370
  return true;
1369
1371
  }
1370
- var startRowIndex = activeCell.rowIndex;
1371
- var startColumnIndex = activeCell.columnIndex;
1372
+ var primaryRange = cellSelectionRanges.length === 1 ? (0, cellSelectionGeometry_1.normalizeCellSelectionRange)(cellSelectionRanges[0]) : null;
1373
+ var selectionRowCount = primaryRange ? primaryRange.endRowIndex - primaryRange.startRowIndex + 1 : 1;
1374
+ var selectionColCount = primaryRange ? primaryRange.endColumnIndex - primaryRange.startColumnIndex + 1 : 1;
1375
+ // Determine fill rows and cols:
1376
+ // 1. Single cell (1x1) copied into multi-cell selection: fill the entire selection.
1377
+ // 2. nxm copied into exact multiple NxM selection: repeat pattern across selection.
1378
+ // 3. Otherwise: paste 1:1 matching matrix dimensions, anchored at selection top-left (or activeCell if no range).
1379
+ var isSingleClipboardCell = matrix.length === 1 && ((_c = matrix[0]) === null || _c === void 0 ? void 0 : _c.length) === 1;
1380
+ var matrixColCount = matrix.reduce(function (max, row) { return Math.max(max, row.length); }, 0);
1381
+ var isExactRowMultiple = primaryRange && matrix.length > 0 && selectionRowCount % matrix.length === 0;
1382
+ var isExactColMultiple = primaryRange &&
1383
+ matrixColCount > 0 &&
1384
+ selectionColCount % matrixColCount === 0;
1385
+ var canTileMatrix = primaryRange &&
1386
+ isExactRowMultiple &&
1387
+ isExactColMultiple &&
1388
+ (selectionRowCount > matrix.length || selectionColCount > matrixColCount);
1389
+ var fillRowCount = matrix.length;
1390
+ var fillColCount = matrixColCount;
1391
+ var startRowIndex = primaryRange ? primaryRange.startRowIndex : activeCell.rowIndex;
1392
+ var startColumnIndex = primaryRange ? primaryRange.startColumnIndex : activeCell.columnIndex;
1393
+ if (primaryRange && (isSingleClipboardCell || canTileMatrix)) {
1394
+ fillRowCount = selectionRowCount;
1395
+ fillColCount = selectionColCount;
1396
+ }
1372
1397
  var nextData = __spreadArray([], __read(data), false);
1373
1398
  var appendedRows = [];
1374
1399
  var changes = [];
1375
1400
  var createRowOnPaste = cellSelectionOptions === null || cellSelectionOptions === void 0 ? void 0 : cellSelectionOptions.createRowOnPaste;
1376
1401
  if (createRowOnPaste) {
1377
- var requiredRowCount = startRowIndex + matrix.length;
1402
+ var requiredRowCount = startRowIndex + fillRowCount;
1378
1403
  while (nextData.length < requiredRowCount) {
1379
1404
  var rowIndex = nextData.length;
1380
- var clipboardRow = matrix[rowIndex - startRowIndex];
1405
+ var matrixRowOffset = isSingleClipboardCell ? 0 : (rowIndex - startRowIndex) % matrix.length;
1406
+ var clipboardRow = matrix[matrixRowOffset];
1381
1407
  if (!clipboardRow)
1382
1408
  break;
1383
1409
  var createdItem = void 0;
@@ -1397,18 +1423,36 @@ function Table(props) {
1397
1423
  }
1398
1424
  var logicalResolutionData = __spreadArray([], __read(nextData), false);
1399
1425
  var pasteTargets = new Map();
1400
- matrix.forEach(function (clipboardRow, rowOffset) {
1426
+ for (var rowOffset = 0; rowOffset < fillRowCount; rowOffset++) {
1401
1427
  var rowIndex = startRowIndex + rowOffset;
1402
1428
  var item = logicalResolutionData[rowIndex];
1403
1429
  if (!item || item.status === types_1.BGridDataItemStatus.remove)
1404
- return;
1405
- clipboardRow.forEach(function (clipboardValue, columnOffset) {
1406
- var columnIndex = startColumnIndex + columnOffset;
1430
+ continue;
1431
+ var matrixRow = isSingleClipboardCell ? matrix[0] : matrix[rowOffset % matrix.length];
1432
+ if (!matrixRow || matrixRow.length === 0)
1433
+ continue;
1434
+ for (var colOffset = 0; colOffset < fillColCount; colOffset++) {
1435
+ var columnIndex = startColumnIndex + colOffset;
1407
1436
  var column = columns[columnIndex];
1408
1437
  // Pasting mutates row data without opening an editor, so only columns
1409
1438
  // explicitly opted into editing may receive clipboard values.
1410
1439
  if (!column || column.editable !== true)
1411
- return;
1440
+ continue;
1441
+ var clipboardValue = void 0;
1442
+ if (isSingleClipboardCell) {
1443
+ clipboardValue = (_e = (_d = matrix[0]) === null || _d === void 0 ? void 0 : _d[0]) !== null && _e !== void 0 ? _e : '';
1444
+ }
1445
+ else if (canTileMatrix) {
1446
+ var cellColOffset = matrixColCount > 0 ? colOffset % matrixColCount : 0;
1447
+ if (cellColOffset >= matrixRow.length)
1448
+ continue;
1449
+ clipboardValue = (_f = matrixRow[cellColOffset]) !== null && _f !== void 0 ? _f : '';
1450
+ }
1451
+ else {
1452
+ if (colOffset >= matrixRow.length)
1453
+ continue;
1454
+ clipboardValue = (_g = matrixRow[colOffset]) !== null && _g !== void 0 ? _g : '';
1455
+ }
1412
1456
  var logicalCell = (0, utils_1.resolveLogicalCell)(logicalResolutionData, props.cellMergeOptions, {
1413
1457
  rowIndex: rowIndex,
1414
1458
  columnIndex: columnIndex,
@@ -1417,7 +1461,7 @@ function Table(props) {
1417
1461
  return !logicalResolutionData[targetRowIndex] ||
1418
1462
  logicalResolutionData[targetRowIndex].status === types_1.BGridDataItemStatus.remove;
1419
1463
  })) {
1420
- return;
1464
+ continue;
1421
1465
  }
1422
1466
  var targetKey = "".concat(logicalCell.cell.rowIndex, ":").concat(columnIndex);
1423
1467
  var existingTarget = pasteTargets.get(targetKey);
@@ -1430,7 +1474,7 @@ function Table(props) {
1430
1474
  columnIndex: columnIndex,
1431
1475
  });
1432
1476
  }
1433
- return;
1477
+ continue;
1434
1478
  }
1435
1479
  pasteTargets.set(targetKey, {
1436
1480
  canonicalRowIndex: logicalCell.cell.rowIndex,
@@ -1439,14 +1483,14 @@ function Table(props) {
1439
1483
  clipboardValue: clipboardValue,
1440
1484
  conflicted: false,
1441
1485
  });
1442
- });
1443
- });
1486
+ }
1487
+ }
1444
1488
  var selectionStartRowIndex = Number.POSITIVE_INFINITY;
1445
1489
  var selectionEndRowIndex = Number.NEGATIVE_INFINITY;
1446
1490
  var selectionStartColumnIndex = Number.POSITIVE_INFINITY;
1447
1491
  var selectionEndColumnIndex = Number.NEGATIVE_INFINITY;
1448
1492
  pasteTargets.forEach(function (target) {
1449
- var _a;
1493
+ var _a, _b, _c, _d;
1450
1494
  if (target.conflicted)
1451
1495
  return;
1452
1496
  var column = columns[target.columnIndex];
@@ -1455,8 +1499,10 @@ function Table(props) {
1455
1499
  return;
1456
1500
  var currentValue = (0, utils_1.getCellValueByRowKey)(column.key, item.values);
1457
1501
  var nextValue = target.clipboardValue;
1458
- var textEditorParser = ((_a = column.editor) === null || _a === void 0 ? void 0 : _a.type) === 'text' ? column.editor.parseValue : undefined;
1459
- if (column.parseClipboardText || textEditorParser) {
1502
+ var pluginClipboardParser = ((_a = column.editor) === null || _a === void 0 ? void 0 : _a.type) === 'plugin' ? column.editor.parseClipboardText : undefined;
1503
+ var textEditorParser = ((_b = column.editor) === null || _b === void 0 ? void 0 : _b.type) === 'text' ? column.editor.parseValue : undefined;
1504
+ var clipboardParser = (_d = (_c = column.parseClipboardText) !== null && _c !== void 0 ? _c : pluginClipboardParser) !== null && _d !== void 0 ? _d : textEditorParser;
1505
+ if (clipboardParser) {
1460
1506
  try {
1461
1507
  var context = {
1462
1508
  index: target.canonicalRowIndex,
@@ -1467,9 +1513,7 @@ function Table(props) {
1467
1513
  value: currentValue,
1468
1514
  text: target.clipboardValue,
1469
1515
  };
1470
- nextValue = column.parseClipboardText
1471
- ? column.parseClipboardText(target.clipboardValue, context)
1472
- : textEditorParser(target.clipboardValue, context);
1516
+ nextValue = clipboardParser(target.clipboardValue, context);
1473
1517
  }
1474
1518
  catch (error) {
1475
1519
  notifyPasteError({
@@ -1533,6 +1577,7 @@ function Table(props) {
1533
1577
  }, [
1534
1578
  activeCell,
1535
1579
  cellSelectionEnabled,
1580
+ cellSelectionRanges,
1536
1581
  columns,
1537
1582
  data,
1538
1583
  editable,
@@ -32,12 +32,25 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __values = (this && this.__values) || function(o) {
36
+ var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
37
+ if (m) return m.call(o);
38
+ if (o && typeof o.length === "number") return {
39
+ next: function () {
40
+ if (o && i >= o.length) o = void 0;
41
+ return { value: o && o[i++], done: !o };
42
+ }
43
+ };
44
+ throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
45
+ };
35
46
  Object.defineProperty(exports, "__esModule", { value: true });
36
47
  exports.createSelectEditorPlugin = createSelectEditorPlugin;
37
48
  var React = __importStar(require("react"));
38
49
  var defineEditorPlugin_1 = require("./defineEditorPlugin");
39
50
  var getColumnId_1 = require("../utils/getColumnId");
40
51
  function createSelectEditorPlugin(options) {
52
+ var e_1, _a;
53
+ var _b, _c;
41
54
  function SelectEditor(_a) {
42
55
  var _b, _c;
43
56
  var value = _a.value, column = _a.column, commit = _a.commit, cancel = _a.cancel;
@@ -91,8 +104,93 @@ function createSelectEditorPlugin(options) {
91
104
  React.createElement("path", { d: 'm4 6 4 4 4-4', stroke: 'currentColor', strokeWidth: '1.5', strokeLinecap: 'round', strokeLinejoin: 'round' })))));
92
105
  }
93
106
  SelectEditor.displayName = "BGridSelectEditor(".concat(options.id, ")");
107
+ // Precompute lookup maps for O(1) clipboard parsing and copying
108
+ var valueMap = new Map();
109
+ var labelMap = new Map();
110
+ var valueToLabelMap = new Map();
111
+ var isNumericValue = options.options.some(function (opt) { return typeof opt.value === 'number'; });
112
+ try {
113
+ for (var _d = __values(options.options), _e = _d.next(); !_e.done; _e = _d.next()) {
114
+ var opt = _e.value;
115
+ var valKey = String(opt.value);
116
+ if (!valueMap.has(valKey)) {
117
+ valueMap.set(valKey, opt);
118
+ }
119
+ if (typeof opt.label === 'string' || typeof opt.label === 'number') {
120
+ var labelText = String(opt.label);
121
+ var trimmedLabel = labelText.trim();
122
+ if (!labelMap.has(trimmedLabel)) {
123
+ labelMap.set(trimmedLabel, opt);
124
+ }
125
+ if (!valueToLabelMap.has(opt.value)) {
126
+ valueToLabelMap.set(opt.value, labelText);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
132
+ finally {
133
+ try {
134
+ if (_e && !_e.done && (_a = _d.return)) _a.call(_d);
135
+ }
136
+ finally { if (e_1) throw e_1.error; }
137
+ }
138
+ var defaultParseClipboardText = function (text) {
139
+ var _a, _b, _c, _d;
140
+ var trimmed = text.trim();
141
+ // Check empty input
142
+ if (trimmed === '') {
143
+ var emptyOption = options.copyMode === 'value'
144
+ ? ((_a = valueMap.get('')) !== null && _a !== void 0 ? _a : labelMap.get(''))
145
+ : ((_b = labelMap.get('')) !== null && _b !== void 0 ? _b : valueMap.get(''));
146
+ if (emptyOption) {
147
+ if (emptyOption.disabled && !options.allowDisabledOptions) {
148
+ throw new Error("Disabled select option for editor \"".concat(options.id, "\""));
149
+ }
150
+ return emptyOption.value;
151
+ }
152
+ if (options.allowEmpty) {
153
+ return options.emptyValue !== undefined ? options.emptyValue : '';
154
+ }
155
+ }
156
+ // Match order depends on copyMode:
157
+ // If copyMode is 'label' (default), match by label first, then by value.
158
+ // If copyMode is 'value', match by value first, then by label.
159
+ var matched = options.copyMode === 'value'
160
+ ? ((_c = valueMap.get(trimmed)) !== null && _c !== void 0 ? _c : labelMap.get(trimmed))
161
+ : ((_d = labelMap.get(trimmed)) !== null && _d !== void 0 ? _d : valueMap.get(trimmed));
162
+ if (matched) {
163
+ if (matched.disabled && !options.allowDisabledOptions) {
164
+ throw new Error("Disabled select option \"".concat(text, "\" for editor \"").concat(options.id, "\""));
165
+ }
166
+ return matched.value;
167
+ }
168
+ if (options.allowCustomValue) {
169
+ if (isNumericValue) {
170
+ var num = Number(trimmed);
171
+ if (!Number.isNaN(num)) {
172
+ return num;
173
+ }
174
+ }
175
+ return trimmed;
176
+ }
177
+ throw new Error("Invalid select value \"".concat(text, "\" for editor \"").concat(options.id, "\""));
178
+ };
179
+ var defaultGetClipboardText = function (params) {
180
+ var value = params.value;
181
+ if (options.copyMode === 'value') {
182
+ return value;
183
+ }
184
+ var label = valueToLabelMap.get(value);
185
+ if (label !== undefined) {
186
+ return label;
187
+ }
188
+ return value;
189
+ };
94
190
  return (0, defineEditorPlugin_1.defineEditorPlugin)({
95
191
  id: options.id,
96
192
  component: SelectEditor,
193
+ parseClipboardText: (_b = options.parseClipboardText) !== null && _b !== void 0 ? _b : (function (text, _params) { return defaultParseClipboardText(text); }),
194
+ getClipboardText: (_c = options.getClipboardText) !== null && _c !== void 0 ? _c : defaultGetClipboardText,
97
195
  });
98
196
  }
@@ -1071,8 +1071,10 @@ function Table(props) {
1071
1071
  rowHasNewLogicalCell = true;
1072
1072
  const logicalItem = data[logicalCell.cell.rowIndex] ?? item;
1073
1073
  const value = getCellValueByRowKey(column.key, logicalItem.values);
1074
- const clipboardValue = column.getClipboardText
1075
- ? column.getClipboardText({
1074
+ const pluginClipboardTextGetter = column.editor?.type === 'plugin' ? column.editor.getClipboardText : undefined;
1075
+ const clipboardTextGetter = column.getClipboardText ?? pluginClipboardTextGetter;
1076
+ const clipboardValue = clipboardTextGetter
1077
+ ? clipboardTextGetter({
1076
1078
  column,
1077
1079
  index: logicalCell.cell.rowIndex,
1078
1080
  columnIndex: ci,
@@ -1180,17 +1182,41 @@ function Table(props) {
1180
1182
  });
1181
1183
  return true;
1182
1184
  }
1183
- const startRowIndex = activeCell.rowIndex;
1184
- const startColumnIndex = activeCell.columnIndex;
1185
+ const primaryRange = cellSelectionRanges.length === 1 ? normalizeCellSelectionRange(cellSelectionRanges[0]) : null;
1186
+ const selectionRowCount = primaryRange ? primaryRange.endRowIndex - primaryRange.startRowIndex + 1 : 1;
1187
+ const selectionColCount = primaryRange ? primaryRange.endColumnIndex - primaryRange.startColumnIndex + 1 : 1;
1188
+ // Determine fill rows and cols:
1189
+ // 1. Single cell (1x1) copied into multi-cell selection: fill the entire selection.
1190
+ // 2. nxm copied into exact multiple NxM selection: repeat pattern across selection.
1191
+ // 3. Otherwise: paste 1:1 matching matrix dimensions, anchored at selection top-left (or activeCell if no range).
1192
+ const isSingleClipboardCell = matrix.length === 1 && matrix[0]?.length === 1;
1193
+ const matrixColCount = matrix.reduce((max, row) => Math.max(max, row.length), 0);
1194
+ const isExactRowMultiple = primaryRange && matrix.length > 0 && selectionRowCount % matrix.length === 0;
1195
+ const isExactColMultiple = primaryRange &&
1196
+ matrixColCount > 0 &&
1197
+ selectionColCount % matrixColCount === 0;
1198
+ const canTileMatrix = primaryRange &&
1199
+ isExactRowMultiple &&
1200
+ isExactColMultiple &&
1201
+ (selectionRowCount > matrix.length || selectionColCount > matrixColCount);
1202
+ let fillRowCount = matrix.length;
1203
+ let fillColCount = matrixColCount;
1204
+ let startRowIndex = primaryRange ? primaryRange.startRowIndex : activeCell.rowIndex;
1205
+ let startColumnIndex = primaryRange ? primaryRange.startColumnIndex : activeCell.columnIndex;
1206
+ if (primaryRange && (isSingleClipboardCell || canTileMatrix)) {
1207
+ fillRowCount = selectionRowCount;
1208
+ fillColCount = selectionColCount;
1209
+ }
1185
1210
  const nextData = [...data];
1186
1211
  const appendedRows = [];
1187
1212
  const changes = [];
1188
1213
  const createRowOnPaste = cellSelectionOptions?.createRowOnPaste;
1189
1214
  if (createRowOnPaste) {
1190
- const requiredRowCount = startRowIndex + matrix.length;
1215
+ const requiredRowCount = startRowIndex + fillRowCount;
1191
1216
  while (nextData.length < requiredRowCount) {
1192
1217
  const rowIndex = nextData.length;
1193
- const clipboardRow = matrix[rowIndex - startRowIndex];
1218
+ const matrixRowOffset = isSingleClipboardCell ? 0 : (rowIndex - startRowIndex) % matrix.length;
1219
+ const clipboardRow = matrix[matrixRowOffset];
1194
1220
  if (!clipboardRow)
1195
1221
  break;
1196
1222
  let createdItem;
@@ -1210,25 +1236,43 @@ function Table(props) {
1210
1236
  }
1211
1237
  const logicalResolutionData = [...nextData];
1212
1238
  const pasteTargets = new Map();
1213
- matrix.forEach((clipboardRow, rowOffset) => {
1239
+ for (let rowOffset = 0; rowOffset < fillRowCount; rowOffset++) {
1214
1240
  const rowIndex = startRowIndex + rowOffset;
1215
1241
  const item = logicalResolutionData[rowIndex];
1216
1242
  if (!item || item.status === BGridDataItemStatus.remove)
1217
- return;
1218
- clipboardRow.forEach((clipboardValue, columnOffset) => {
1219
- const columnIndex = startColumnIndex + columnOffset;
1243
+ continue;
1244
+ const matrixRow = isSingleClipboardCell ? matrix[0] : matrix[rowOffset % matrix.length];
1245
+ if (!matrixRow || matrixRow.length === 0)
1246
+ continue;
1247
+ for (let colOffset = 0; colOffset < fillColCount; colOffset++) {
1248
+ const columnIndex = startColumnIndex + colOffset;
1220
1249
  const column = columns[columnIndex];
1221
1250
  // Pasting mutates row data without opening an editor, so only columns
1222
1251
  // explicitly opted into editing may receive clipboard values.
1223
1252
  if (!column || column.editable !== true)
1224
- return;
1253
+ continue;
1254
+ let clipboardValue;
1255
+ if (isSingleClipboardCell) {
1256
+ clipboardValue = matrix[0]?.[0] ?? '';
1257
+ }
1258
+ else if (canTileMatrix) {
1259
+ const cellColOffset = matrixColCount > 0 ? colOffset % matrixColCount : 0;
1260
+ if (cellColOffset >= matrixRow.length)
1261
+ continue;
1262
+ clipboardValue = matrixRow[cellColOffset] ?? '';
1263
+ }
1264
+ else {
1265
+ if (colOffset >= matrixRow.length)
1266
+ continue;
1267
+ clipboardValue = matrixRow[colOffset] ?? '';
1268
+ }
1225
1269
  const logicalCell = resolveLogicalCell(logicalResolutionData, props.cellMergeOptions, {
1226
1270
  rowIndex,
1227
1271
  columnIndex,
1228
1272
  });
1229
1273
  if (logicalCell.rowIndexes.some(targetRowIndex => !logicalResolutionData[targetRowIndex] ||
1230
1274
  logicalResolutionData[targetRowIndex].status === BGridDataItemStatus.remove)) {
1231
- return;
1275
+ continue;
1232
1276
  }
1233
1277
  const targetKey = `${logicalCell.cell.rowIndex}:${columnIndex}`;
1234
1278
  const existingTarget = pasteTargets.get(targetKey);
@@ -1241,7 +1285,7 @@ function Table(props) {
1241
1285
  columnIndex,
1242
1286
  });
1243
1287
  }
1244
- return;
1288
+ continue;
1245
1289
  }
1246
1290
  pasteTargets.set(targetKey, {
1247
1291
  canonicalRowIndex: logicalCell.cell.rowIndex,
@@ -1250,8 +1294,8 @@ function Table(props) {
1250
1294
  clipboardValue,
1251
1295
  conflicted: false,
1252
1296
  });
1253
- });
1254
- });
1297
+ }
1298
+ }
1255
1299
  let selectionStartRowIndex = Number.POSITIVE_INFINITY;
1256
1300
  let selectionEndRowIndex = Number.NEGATIVE_INFINITY;
1257
1301
  let selectionStartColumnIndex = Number.POSITIVE_INFINITY;
@@ -1265,8 +1309,10 @@ function Table(props) {
1265
1309
  return;
1266
1310
  const currentValue = getCellValueByRowKey(column.key, item.values);
1267
1311
  let nextValue = target.clipboardValue;
1312
+ const pluginClipboardParser = column.editor?.type === 'plugin' ? column.editor.parseClipboardText : undefined;
1268
1313
  const textEditorParser = column.editor?.type === 'text' ? column.editor.parseValue : undefined;
1269
- if (column.parseClipboardText || textEditorParser) {
1314
+ const clipboardParser = column.parseClipboardText ?? pluginClipboardParser ?? textEditorParser;
1315
+ if (clipboardParser) {
1270
1316
  try {
1271
1317
  const context = {
1272
1318
  index: target.canonicalRowIndex,
@@ -1277,9 +1323,7 @@ function Table(props) {
1277
1323
  value: currentValue,
1278
1324
  text: target.clipboardValue,
1279
1325
  };
1280
- nextValue = column.parseClipboardText
1281
- ? column.parseClipboardText(target.clipboardValue, context)
1282
- : textEditorParser(target.clipboardValue, context);
1326
+ nextValue = clipboardParser(target.clipboardValue, context);
1283
1327
  }
1284
1328
  catch (error) {
1285
1329
  notifyPasteError({
@@ -1340,6 +1384,7 @@ function Table(props) {
1340
1384
  }, [
1341
1385
  activeCell,
1342
1386
  cellSelectionEnabled,
1387
+ cellSelectionRanges,
1343
1388
  columns,
1344
1389
  data,
1345
1390
  editable,
@@ -53,8 +53,82 @@ export function createSelectEditorPlugin(options) {
53
53
  React.createElement("path", { d: 'm4 6 4 4 4-4', stroke: 'currentColor', strokeWidth: '1.5', strokeLinecap: 'round', strokeLinejoin: 'round' })))));
54
54
  }
55
55
  SelectEditor.displayName = `BGridSelectEditor(${options.id})`;
56
+ // Precompute lookup maps for O(1) clipboard parsing and copying
57
+ const valueMap = new Map();
58
+ const labelMap = new Map();
59
+ const valueToLabelMap = new Map();
60
+ const isNumericValue = options.options.some(opt => typeof opt.value === 'number');
61
+ for (const opt of options.options) {
62
+ const valKey = String(opt.value);
63
+ if (!valueMap.has(valKey)) {
64
+ valueMap.set(valKey, opt);
65
+ }
66
+ if (typeof opt.label === 'string' || typeof opt.label === 'number') {
67
+ const labelText = String(opt.label);
68
+ const trimmedLabel = labelText.trim();
69
+ if (!labelMap.has(trimmedLabel)) {
70
+ labelMap.set(trimmedLabel, opt);
71
+ }
72
+ if (!valueToLabelMap.has(opt.value)) {
73
+ valueToLabelMap.set(opt.value, labelText);
74
+ }
75
+ }
76
+ }
77
+ const defaultParseClipboardText = (text) => {
78
+ const trimmed = text.trim();
79
+ // Check empty input
80
+ if (trimmed === '') {
81
+ const emptyOption = options.copyMode === 'value'
82
+ ? (valueMap.get('') ?? labelMap.get(''))
83
+ : (labelMap.get('') ?? valueMap.get(''));
84
+ if (emptyOption) {
85
+ if (emptyOption.disabled && !options.allowDisabledOptions) {
86
+ throw new Error(`Disabled select option for editor "${options.id}"`);
87
+ }
88
+ return emptyOption.value;
89
+ }
90
+ if (options.allowEmpty) {
91
+ return options.emptyValue !== undefined ? options.emptyValue : '';
92
+ }
93
+ }
94
+ // Match order depends on copyMode:
95
+ // If copyMode is 'label' (default), match by label first, then by value.
96
+ // If copyMode is 'value', match by value first, then by label.
97
+ const matched = options.copyMode === 'value'
98
+ ? (valueMap.get(trimmed) ?? labelMap.get(trimmed))
99
+ : (labelMap.get(trimmed) ?? valueMap.get(trimmed));
100
+ if (matched) {
101
+ if (matched.disabled && !options.allowDisabledOptions) {
102
+ throw new Error(`Disabled select option "${text}" for editor "${options.id}"`);
103
+ }
104
+ return matched.value;
105
+ }
106
+ if (options.allowCustomValue) {
107
+ if (isNumericValue) {
108
+ const num = Number(trimmed);
109
+ if (!Number.isNaN(num)) {
110
+ return num;
111
+ }
112
+ }
113
+ return trimmed;
114
+ }
115
+ throw new Error(`Invalid select value "${text}" for editor "${options.id}"`);
116
+ };
117
+ const defaultGetClipboardText = (params) => {
118
+ const { value } = params;
119
+ if (options.copyMode === 'value') {
120
+ return value;
121
+ }
122
+ const label = valueToLabelMap.get(value);
123
+ if (label !== undefined) {
124
+ return label;
125
+ }
126
+ return value;
127
+ };
56
128
  return defineEditorPlugin({
57
129
  id: options.id,
58
130
  component: SelectEditor,
131
+ parseClipboardText: options.parseClipboardText ?? ((text, _params) => defaultParseClipboardText(text)),
132
+ getClipboardText: options.getClipboardText ?? defaultGetClipboardText,
59
133
  });
60
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beautiful-grid",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "A beautiful, powerful, open-source React Data Grid.",
5
5
  "homepage": "https://bgrid.axisj.com",
6
6
  "bugs": {
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { BGridPluginEditorConfig } from '../types';
2
+ import { BGridCellClipboardParseParams, BGridCellClipboardTextParams, BGridPluginEditorConfig } from '../types';
3
3
  export interface BGridSelectEditorOption<Value extends string | number> {
4
4
  value: Value;
5
5
  label: React.ReactNode;
@@ -11,5 +11,41 @@ export interface BGridSelectEditorPluginOptions<Value extends string | number> {
11
11
  ariaLabel?: string;
12
12
  placeholder?: string;
13
13
  openOnMount?: boolean;
14
+ /**
15
+ * Whether to allow pasting values that do not match any option value or label.
16
+ * Defaults to false (unmatched values throw an error and report parseValueFailed).
17
+ */
18
+ allowCustomValue?: boolean;
19
+ /**
20
+ * Clipboard text mode when copying cells using this editor.
21
+ * 'label' (default): copies the option's label if it is a string or number.
22
+ * 'value': copies the option's raw value.
23
+ */
24
+ copyMode?: 'label' | 'value';
25
+ /**
26
+ * Whether to allow clearing cell values by pasting empty or whitespace-only text.
27
+ * Defaults to false (unmatched empty text throws parseValueFailed).
28
+ * When true, pastes `emptyValue` into the cell.
29
+ */
30
+ allowEmpty?: boolean;
31
+ /**
32
+ * Value written when an empty text is pasted and `allowEmpty` is true.
33
+ * Defaults to undefined.
34
+ */
35
+ emptyValue?: Value | null;
36
+ /**
37
+ * Whether to allow pasting options that have `disabled: true`.
38
+ * Defaults to false (pasting a disabled option throws an error).
39
+ */
40
+ allowDisabledOptions?: boolean;
41
+ /**
42
+ * Custom clipboard parser for this editor.
43
+ * Throw an error to reject the paste for this cell.
44
+ */
45
+ parseClipboardText?: <T>(text: string, params: BGridCellClipboardParseParams<T>) => Value | unknown;
46
+ /**
47
+ * Custom clipboard text getter for this editor.
48
+ */
49
+ getClipboardText?: <T>(params: BGridCellClipboardTextParams<T>) => any;
14
50
  }
15
51
  export declare function createSelectEditorPlugin<T, Value extends string | number = string>(options: BGridSelectEditorPluginOptions<Value>): BGridPluginEditorConfig<T>;
package/types/types.d.ts CHANGED
@@ -122,6 +122,13 @@ export interface BGridPluginEditorConfig<T> {
122
122
  type: 'plugin';
123
123
  id: string;
124
124
  component: React.ComponentType<BGridEditorPluginProps<T>>;
125
+ getClipboardText?: (params: BGridCellClipboardTextParams<T>) => any;
126
+ /**
127
+ * Restores text/plain clipboard input to the editor's stored value type.
128
+ * Takes precedence over built-in fallback parser during multi-cell paste.
129
+ * Throw to keep the current cell value and report parseValueFailed through onPasteError.
130
+ */
131
+ parseClipboardText?: (text: string, params: BGridCellClipboardParseParams<T>) => unknown;
125
132
  }
126
133
  export type BGridCellEditorConfig<T> = BGridTextEditorConfig<T> | BGridCheckboxEditorConfig<T> | BGridPluginEditorConfig<T>;
127
134
  export interface BGridCellClipboardTextParams<T> {