@svgrid/grid 2.6.22 → 2.6.23
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/dist/GridMenus.svelte +184 -6
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +48 -1
- package/dist/SvGrid.controller.svelte.js +214 -52
- package/dist/SvGrid.css +221 -0
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +145 -0
- package/dist/SvGrid.types.d.ts +105 -0
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
- package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
- package/dist/cdn/svgrid.js +14 -12
- package/dist/cdn/svgrid.svelte-external.js +14 -12
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +40 -7
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +129 -13
- package/package.json +1 -1
- package/src/GridMenus.svelte +184 -6
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +251 -61
- package/src/SvGrid.css +221 -0
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +145 -0
- package/src/SvGrid.types.ts +112 -0
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/grid-messages.ts +34 -0
- package/src/index.ts +13 -0
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +37 -7
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +131 -13
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
- package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/src/clipboard.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getCellKey,
|
|
14
14
|
} from "./SvGrid.helpers";
|
|
15
15
|
import {
|
|
16
|
+
createDataIndexLookup,
|
|
16
17
|
getColumnBaseValue,
|
|
17
18
|
isGroupRow,
|
|
18
19
|
toolPanelHeaderLabel,
|
|
@@ -22,43 +23,61 @@ export function createClipboard<
|
|
|
22
23
|
TFeatures extends TableFeatures = TableFeatures,
|
|
23
24
|
TData extends RowData = RowData,
|
|
24
25
|
>(ctx: any) {
|
|
26
|
+
/**
|
|
27
|
+
* `rowIndex` here is a DISPLAY index - a position in `allRows`, the space the
|
|
28
|
+
* selection rectangle, the fill drag and `isCellEditableAt` all work in.
|
|
29
|
+
* These two functions used to index `internalData` with it directly, which is
|
|
30
|
+
* the same row only while the grid is unsorted, unfiltered and unpaginated.
|
|
31
|
+
* Sort a column and the fill handle read its pattern from one row and wrote
|
|
32
|
+
* to another; enterprise bulk edit wrote to the wrong rows the same way.
|
|
33
|
+
*
|
|
34
|
+
* `row.original` IS the object in `data`, so identity resolves the slot no
|
|
35
|
+
* matter how the view is ordered.
|
|
36
|
+
*/
|
|
37
|
+
function rowDataAt(rowIndex: number): Record<string, unknown> | undefined {
|
|
38
|
+
return ctx.allRows[rowIndex]?.original as Record<string, unknown> | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/** Read the raw underlying value for the cell at (rowIndex, columnId)
|
|
26
42
|
* for pattern extraction. */
|
|
27
43
|
function readCellRaw(rowIndex: number, columnId: string): unknown {
|
|
28
|
-
const row =
|
|
44
|
+
const row = rowDataAt(rowIndex);
|
|
29
45
|
const column = ctx.findColumnById(columnId);
|
|
30
46
|
if (!row || !column?.columnDef.field) return undefined;
|
|
31
|
-
return
|
|
47
|
+
return row[column.columnDef.field];
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
/** Write a value into (rowIndex, columnId) without going through the
|
|
35
51
|
* edit lifecycle. Fires `onCellValueChange` per write so consumers
|
|
36
52
|
* can react (formula recompute, autosave, etc.). */
|
|
37
53
|
function writeCellRaw(rowIndex: number, columnId: string, value: unknown) {
|
|
38
|
-
const row =
|
|
54
|
+
const row = rowDataAt(rowIndex);
|
|
39
55
|
const column = ctx.findColumnById(columnId);
|
|
40
56
|
if (!row || !column?.columnDef.field) return;
|
|
41
57
|
const field = column.columnDef.field;
|
|
42
|
-
const oldValue =
|
|
58
|
+
const oldValue = row[field];
|
|
43
59
|
if (oldValue === value) return;
|
|
44
|
-
//
|
|
45
|
-
// recomputed `allRows` references the new row
|
|
46
|
-
//
|
|
47
|
-
// `editedCellValues`
|
|
48
|
-
const
|
|
60
|
+
// The slot in `data` backing this displayed row. Also read BEFORE swapping
|
|
61
|
+
// internalData: afterwards the recomputed `allRows` references the new row
|
|
62
|
+
// object, so an `original`-based lookup would miss and drop the edit out of
|
|
63
|
+
// `editedCellValues` (which getCellDisplayValue consults first).
|
|
64
|
+
const dataIndex = ctx.internalData.indexOf(row as TData);
|
|
65
|
+
if (dataIndex < 0) return;
|
|
66
|
+
const rowId = ctx.allRows[rowIndex]?.id;
|
|
49
67
|
const next = ctx.internalData.slice() as Array<TData>;
|
|
50
|
-
next[
|
|
68
|
+
next[dataIndex] = { ...row, [field]: value } as TData;
|
|
51
69
|
ctx.internalData = next;
|
|
52
70
|
if (rowId) {
|
|
53
71
|
const key = getCellKey(rowId, columnId);
|
|
54
72
|
ctx.editedCellValues = { ...ctx.editedCellValues, [key]: value };
|
|
55
73
|
}
|
|
56
74
|
ctx.props.onCellValueChange?.({
|
|
57
|
-
|
|
75
|
+
// The position in `props.data`, matching what the inline editor reports.
|
|
76
|
+
rowIndex: dataIndex,
|
|
58
77
|
columnId,
|
|
59
78
|
oldValue,
|
|
60
79
|
newValue: value,
|
|
61
|
-
row: next[
|
|
80
|
+
row: next[dataIndex] as TData,
|
|
62
81
|
});
|
|
63
82
|
}
|
|
64
83
|
|
|
@@ -71,6 +90,7 @@ export function createClipboard<
|
|
|
71
90
|
// Clear fillDrag FIRST so a thrown error doesn't leave the grid
|
|
72
91
|
// stuck tracking the pointer.
|
|
73
92
|
ctx.fillDrag = null;
|
|
93
|
+
stopEdgeScroll();
|
|
74
94
|
const newMinRow = Math.min(d.sourceMinRow, d.targetRow);
|
|
75
95
|
const newMaxRow = Math.max(d.sourceMaxRow, d.targetRow);
|
|
76
96
|
const newMinCol = Math.min(d.sourceMinCol, d.targetCol);
|
|
@@ -222,24 +242,121 @@ export function createClipboard<
|
|
|
222
242
|
// (`onWindowPointerMove` / `endDragSelection`) keep tracking.
|
|
223
243
|
}
|
|
224
244
|
|
|
225
|
-
/**
|
|
226
|
-
*
|
|
227
|
-
* and
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const el = document.elementFromPoint(
|
|
231
|
-
event.clientX,
|
|
232
|
-
event.clientY,
|
|
233
|
-
) as HTMLElement | null;
|
|
245
|
+
/** The grid cell under a viewport point, or null. Drags cannot read their
|
|
246
|
+
* target off the event: the pointer is over whatever `<td>` happens to be
|
|
247
|
+
* there, and during a fill/move drag the event target is the cell the drag
|
|
248
|
+
* STARTED on. */
|
|
249
|
+
function cellAtPoint(x: number, y: number): { row: number; col: number } | null {
|
|
250
|
+
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
|
234
251
|
const cell = el?.closest(
|
|
235
252
|
"td[data-svgrid-row][data-svgrid-col]",
|
|
236
253
|
) as HTMLElement | null;
|
|
237
|
-
if (!cell) return;
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
if (!Number.isFinite(
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
if (!cell) return null;
|
|
255
|
+
const row = Number(cell.dataset.svgridRow);
|
|
256
|
+
const col = Number(cell.dataset.svgridCol);
|
|
257
|
+
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
|
258
|
+
return { row, col };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- Edge auto-scroll -------------------------------------------------
|
|
262
|
+
// Shared by all three drags that extend a rectangle - fill, range move, and
|
|
263
|
+
// plain drag-select. Without it a drag can only reach cells that are already
|
|
264
|
+
// on screen, which on a grid the size this one is built for means most of
|
|
265
|
+
// them are unreachable.
|
|
266
|
+
|
|
267
|
+
/** How far from the viewport edge the scroll starts, in CSS pixels. */
|
|
268
|
+
const EDGE_BAND_PX = 40;
|
|
269
|
+
/** Fastest scroll, in pixels per frame, reached at the very edge. */
|
|
270
|
+
const EDGE_MAX_PX = 20;
|
|
271
|
+
|
|
272
|
+
let edgeFrame: number | null = null;
|
|
273
|
+
let edgePointer: { x: number; y: number } | null = null;
|
|
274
|
+
|
|
275
|
+
function stopEdgeScroll() {
|
|
276
|
+
if (edgeFrame != null) cancelAnimationFrame(edgeFrame);
|
|
277
|
+
edgeFrame = null;
|
|
278
|
+
edgePointer = null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Pixels to scroll this frame along one axis. Zero in the middle, ramping to
|
|
283
|
+
* EDGE_MAX_PX at the edge and staying there once the pointer leaves the
|
|
284
|
+
* container entirely - a linear ramp rather than a constant step, so nudging
|
|
285
|
+
* one row past the edge creeps instead of lurching.
|
|
286
|
+
*/
|
|
287
|
+
function edgeVelocity(pos: number, min: number, max: number) {
|
|
288
|
+
if (pos < min + EDGE_BAND_PX) {
|
|
289
|
+
const depth = Math.min(EDGE_BAND_PX, min + EDGE_BAND_PX - pos);
|
|
290
|
+
return -Math.ceil((depth / EDGE_BAND_PX) * EDGE_MAX_PX);
|
|
291
|
+
}
|
|
292
|
+
if (pos > max - EDGE_BAND_PX) {
|
|
293
|
+
const depth = Math.min(EDGE_BAND_PX, pos - (max - EDGE_BAND_PX));
|
|
294
|
+
return Math.ceil((depth / EDGE_BAND_PX) * EDGE_MAX_PX);
|
|
295
|
+
}
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Point the in-flight drag at whatever cell is under (x, y) now. */
|
|
300
|
+
function retargetDrag(x: number, y: number) {
|
|
301
|
+
const hit = cellAtPoint(x, y);
|
|
302
|
+
if (!hit) return;
|
|
303
|
+
if (ctx.moveDrag) {
|
|
304
|
+
if (hit.row === ctx.moveDrag.targetRow && hit.col === ctx.moveDrag.targetCol) return;
|
|
305
|
+
ctx.moveDrag = { ...ctx.moveDrag, targetRow: hit.row, targetCol: hit.col };
|
|
306
|
+
} else if (ctx.fillDrag) {
|
|
307
|
+
if (hit.row === ctx.fillDrag.targetRow && hit.col === ctx.fillDrag.targetCol) return;
|
|
308
|
+
ctx.fillDrag = { ...ctx.fillDrag, targetRow: hit.row, targetCol: hit.col };
|
|
309
|
+
} else if (ctx.isDraggingSelection) {
|
|
310
|
+
ctx.extendSelection(hit.row, hit.col);
|
|
311
|
+
ctx.setActiveCell(hit.row, hit.col);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function stepEdgeScroll() {
|
|
316
|
+
edgeFrame = null;
|
|
317
|
+
const scroller = ctx.scrollContainer as HTMLElement | null;
|
|
318
|
+
const p = edgePointer;
|
|
319
|
+
const dragging = ctx.moveDrag || ctx.fillDrag || ctx.isDraggingSelection;
|
|
320
|
+
if (!scroller || !p || !dragging) {
|
|
321
|
+
stopEdgeScroll();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const rect = scroller.getBoundingClientRect();
|
|
325
|
+
const dx = edgeVelocity(p.x, rect.left, rect.right);
|
|
326
|
+
const dy = edgeVelocity(p.y, rect.top, rect.bottom);
|
|
327
|
+
if (dx || dy) {
|
|
328
|
+
const fromX = scroller.scrollLeft;
|
|
329
|
+
const fromY = scroller.scrollTop;
|
|
330
|
+
scroller.scrollLeft += dx;
|
|
331
|
+
scroller.scrollTop += dy;
|
|
332
|
+
// Only re-target when the view actually moved. At either end the scroll
|
|
333
|
+
// clamps, and re-targeting then would fight a pointer that is parked
|
|
334
|
+
// still - the selection would flicker between two cells every frame.
|
|
335
|
+
if (scroller.scrollLeft !== fromX || scroller.scrollTop !== fromY) {
|
|
336
|
+
retargetDrag(p.x, p.y);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
edgeFrame = requestAnimationFrame(stepEdgeScroll);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Record where the pointer is and make sure the scroll loop is running. The
|
|
344
|
+
* loop keeps going after the pointer stops moving, which is the whole point:
|
|
345
|
+
* a user parks the cursor at the edge and expects the grid to keep coming.
|
|
346
|
+
*/
|
|
347
|
+
function trackEdgeScroll(event: PointerEvent) {
|
|
348
|
+
edgePointer = { x: event.clientX, y: event.clientY };
|
|
349
|
+
if (edgeFrame == null) edgeFrame = requestAnimationFrame(stepEdgeScroll);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Pointermove during fill-drag. */
|
|
353
|
+
function onFillPointerMove(event: PointerEvent) {
|
|
354
|
+
if (!ctx.fillDrag) return;
|
|
355
|
+
trackEdgeScroll(event);
|
|
356
|
+
const hit = cellAtPoint(event.clientX, event.clientY);
|
|
357
|
+
if (!hit) return;
|
|
358
|
+
if (hit.row === ctx.fillDrag.targetRow && hit.col === ctx.fillDrag.targetCol) return;
|
|
359
|
+
ctx.fillDrag = { ...ctx.fillDrag, targetRow: hit.row, targetCol: hit.col };
|
|
243
360
|
}
|
|
244
361
|
|
|
245
362
|
function onFillPointerUp() {
|
|
@@ -247,6 +364,282 @@ export function createClipboard<
|
|
|
247
364
|
applyFillPattern();
|
|
248
365
|
}
|
|
249
366
|
|
|
367
|
+
// ---- Range move / copy (drag the selection border) --------------------
|
|
368
|
+
|
|
369
|
+
/** How thick the draggable strip on a range border is, in CSS pixels.
|
|
370
|
+
* Anything further inside the cell keeps its old behaviour of starting a
|
|
371
|
+
* fresh selection, so this only claims the outer sliver. */
|
|
372
|
+
const MOVE_GRAB_PX = 4;
|
|
373
|
+
|
|
374
|
+
/** Is (clientX, clientY) on the grab strip of the range border that the
|
|
375
|
+
* cell at (rowIndex, colIndex) sits on? Returns false for any cell that
|
|
376
|
+
* is not on a range edge, so the common case costs one call to
|
|
377
|
+
* `getCellRangeEdges` - which itself bails immediately with no selection. */
|
|
378
|
+
function isOnMoveGrabStrip(
|
|
379
|
+
cell: HTMLElement,
|
|
380
|
+
rowIndex: number,
|
|
381
|
+
colIndex: number,
|
|
382
|
+
clientX: number,
|
|
383
|
+
clientY: number,
|
|
384
|
+
): boolean {
|
|
385
|
+
if (!ctx.moveCellsEffective) return false;
|
|
386
|
+
const edges = ctx.getCellRangeEdges(rowIndex, colIndex);
|
|
387
|
+
if (!edges) return false;
|
|
388
|
+
// One layout read per pointermove, and only once the pointer is over a
|
|
389
|
+
// cell that IS on a range edge. Reading a single rect is cheap unless
|
|
390
|
+
// the DOM is dirty, and the only write this path makes is a boolean flag
|
|
391
|
+
// that changes at most twice per border crossing.
|
|
392
|
+
const box = cell.getBoundingClientRect();
|
|
393
|
+
const x = clientX - box.left;
|
|
394
|
+
const y = clientY - box.top;
|
|
395
|
+
return (
|
|
396
|
+
(edges.top && y >= 0 && y <= MOVE_GRAB_PX) ||
|
|
397
|
+
(edges.bottom && box.height - y >= 0 && box.height - y <= MOVE_GRAB_PX) ||
|
|
398
|
+
(edges.left && x >= 0 && x <= MOVE_GRAB_PX) ||
|
|
399
|
+
(edges.right && box.width - x >= 0 && box.width - x <= MOVE_GRAB_PX)
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Border pointerdown - seed a range move. Returns false when the pointer
|
|
404
|
+
* was not on the grab strip, which is the caller's signal to fall through
|
|
405
|
+
* to normal selection behaviour. */
|
|
406
|
+
function startMoveDrag(
|
|
407
|
+
event: PointerEvent,
|
|
408
|
+
rowIndex: number,
|
|
409
|
+
colIndex: number,
|
|
410
|
+
): boolean {
|
|
411
|
+
if (event.button !== 0) return false;
|
|
412
|
+
// Mouse and pen only, the same policy drag-select uses (issue #23) and for
|
|
413
|
+
// a worse reason: this path DOES preventDefault, so a finger that happened
|
|
414
|
+
// to land within 4px of a selection border would start a move and kill the
|
|
415
|
+
// browser's native scroll for that gesture. There is no hover on touch
|
|
416
|
+
// either, so the border carries no affordance to find in the first place.
|
|
417
|
+
// Touch already has cut/paste for the same job.
|
|
418
|
+
if (event.pointerType === "touch") return false;
|
|
419
|
+
// `event.target`, not `currentTarget`: Svelte 5 delegates pointerdown to
|
|
420
|
+
// the root, and the strip test needs the cell's own rect - the target can
|
|
421
|
+
// be a span inside the cell, so walk up to the td.
|
|
422
|
+
const cell = (event.target as HTMLElement | null)?.closest?.(
|
|
423
|
+
"td[data-svgrid-row][data-svgrid-col]",
|
|
424
|
+
) as HTMLElement | null;
|
|
425
|
+
if (!cell) return false;
|
|
426
|
+
if (!isOnMoveGrabStrip(cell, rowIndex, colIndex, event.clientX, event.clientY))
|
|
427
|
+
return false;
|
|
428
|
+
const rect = moveSourceRect(rowIndex, colIndex);
|
|
429
|
+
if (!rect) return false;
|
|
430
|
+
event.stopPropagation();
|
|
431
|
+
// preventDefault stops the drag from rubber-banding a text selection over
|
|
432
|
+
// the rows it passes, which also means the grid never gets focus from the
|
|
433
|
+
// gesture - so take it explicitly, or the keyboard stops working after a
|
|
434
|
+
// move.
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
ctx.gridRootEl?.focus({ preventScroll: true });
|
|
437
|
+
ctx.moveDrag = {
|
|
438
|
+
...rect,
|
|
439
|
+
grabRow: rowIndex,
|
|
440
|
+
grabCol: colIndex,
|
|
441
|
+
targetRow: rowIndex,
|
|
442
|
+
targetCol: colIndex,
|
|
443
|
+
copy: event.ctrlKey || event.metaKey,
|
|
444
|
+
};
|
|
445
|
+
// No `setPointerCapture`, for the same reason the fill handle skips it:
|
|
446
|
+
// the drag finds its target cell with `elementFromPoint`, and capture
|
|
447
|
+
// would route every move back to the cell the drag started on.
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The selection rectangle that CONTAINS (rowIndex, colIndex), which is the
|
|
452
|
+
* one whose border the user grabbed. With several committed ranges the
|
|
453
|
+
* grabbed one is the one that moves, not whichever happens to be active. */
|
|
454
|
+
function moveSourceRect(rowIndex: number, colIndex: number) {
|
|
455
|
+
for (const rect of ctx.getSelectionRects()) {
|
|
456
|
+
if (
|
|
457
|
+
rowIndex >= rect.minRow &&
|
|
458
|
+
rowIndex <= rect.maxRow &&
|
|
459
|
+
colIndex >= rect.minCol &&
|
|
460
|
+
colIndex <= rect.maxCol
|
|
461
|
+
) {
|
|
462
|
+
return {
|
|
463
|
+
sourceMinRow: rect.minRow,
|
|
464
|
+
sourceMaxRow: rect.maxRow,
|
|
465
|
+
sourceMinCol: rect.minCol,
|
|
466
|
+
sourceMaxCol: rect.maxCol,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function onMovePointerMove(event: PointerEvent) {
|
|
474
|
+
const d = ctx.moveDrag;
|
|
475
|
+
if (!d) return;
|
|
476
|
+
trackEdgeScroll(event);
|
|
477
|
+
const hit = cellAtPoint(event.clientX, event.clientY);
|
|
478
|
+
// Track the modifier even when the pointer is between cells, so releasing
|
|
479
|
+
// Ctrl over a gap still flips a copy back to a move.
|
|
480
|
+
const copy = event.ctrlKey || event.metaKey;
|
|
481
|
+
if (!hit) {
|
|
482
|
+
if (copy !== d.copy) ctx.moveDrag = { ...d, copy };
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (hit.row === d.targetRow && hit.col === d.targetCol && copy === d.copy) return;
|
|
486
|
+
ctx.moveDrag = { ...d, targetRow: hit.row, targetCol: hit.col, copy };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function onMovePointerUp(event?: PointerEvent) {
|
|
490
|
+
if (!ctx.moveDrag) return;
|
|
491
|
+
// Take the modifier from the RELEASE. Reading it only on pointermove made
|
|
492
|
+
// "drag, then press Ctrl, then let go" a move - which is the natural way
|
|
493
|
+
// to do it, and is a copy in every spreadsheet.
|
|
494
|
+
if (event) {
|
|
495
|
+
const copy = event.ctrlKey || event.metaKey;
|
|
496
|
+
if (copy !== ctx.moveDrag.copy) ctx.moveDrag = { ...ctx.moveDrag, copy };
|
|
497
|
+
}
|
|
498
|
+
applyMoveRange();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Where a move would land, or null when the drag has not left the spot it
|
|
502
|
+
* started on. Shared by the drop and by the preview marquee so the outline
|
|
503
|
+
* can never promise a rectangle the drop would refuse. */
|
|
504
|
+
function moveDestRect(d: {
|
|
505
|
+
sourceMinRow: number;
|
|
506
|
+
sourceMaxRow: number;
|
|
507
|
+
sourceMinCol: number;
|
|
508
|
+
sourceMaxCol: number;
|
|
509
|
+
grabRow: number;
|
|
510
|
+
grabCol: number;
|
|
511
|
+
targetRow: number;
|
|
512
|
+
targetCol: number;
|
|
513
|
+
}) {
|
|
514
|
+
const dr = d.targetRow - d.grabRow;
|
|
515
|
+
const dc = d.targetCol - d.grabCol;
|
|
516
|
+
if (dr === 0 && dc === 0) return null;
|
|
517
|
+
const minRow = d.sourceMinRow + dr;
|
|
518
|
+
const maxRow = d.sourceMaxRow + dr;
|
|
519
|
+
const minCol = d.sourceMinCol + dc;
|
|
520
|
+
const maxCol = d.sourceMaxCol + dc;
|
|
521
|
+
if (minRow < 0 || minCol < 0) return null;
|
|
522
|
+
if (maxRow >= ctx.allRows.length) return null;
|
|
523
|
+
if (maxCol >= ctx.allColumns.length) return null;
|
|
524
|
+
return { minRow, maxRow, minCol, maxCol };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Commit a range move (or copy). Refuses the whole operation rather than
|
|
529
|
+
* applying the part that fits: a move that silently skipped a read-only or
|
|
530
|
+
* grouped destination cell would leave the range split across two places
|
|
531
|
+
* with no way to tell which half landed.
|
|
532
|
+
*/
|
|
533
|
+
function applyMoveRange() {
|
|
534
|
+
const d = ctx.moveDrag;
|
|
535
|
+
if (!d) return;
|
|
536
|
+
// Clear the drag FIRST so a thrown error can't leave the grid tracking
|
|
537
|
+
// the pointer forever, exactly as applyFillPattern does.
|
|
538
|
+
ctx.moveDrag = null;
|
|
539
|
+
stopEdgeScroll();
|
|
540
|
+
const dest = moveDestRect(d);
|
|
541
|
+
if (!dest) return;
|
|
542
|
+
|
|
543
|
+
const height = d.sourceMaxRow - d.sourceMinRow + 1;
|
|
544
|
+
const width = d.sourceMaxCol - d.sourceMinCol + 1;
|
|
545
|
+
|
|
546
|
+
// Every column the move touches must be field-backed on BOTH sides: a
|
|
547
|
+
// computed / accessor column has nowhere to write the value back to.
|
|
548
|
+
for (let c = 0; c < width; c += 1) {
|
|
549
|
+
if (!ctx.allColumns[d.sourceMinCol + c]?.columnDef.field) return;
|
|
550
|
+
if (!ctx.allColumns[dest.minCol + c]?.columnDef.field) return;
|
|
551
|
+
}
|
|
552
|
+
// Destination must be writable, and so must the source when the values
|
|
553
|
+
// are leaving it. Group rows fail this through isCellEditableAt.
|
|
554
|
+
for (let r = 0; r < height; r += 1) {
|
|
555
|
+
for (let c = 0; c < width; c += 1) {
|
|
556
|
+
if (!ctx.isCellEditableAt(dest.minRow + r, dest.minCol + c)) return;
|
|
557
|
+
if (
|
|
558
|
+
!d.copy &&
|
|
559
|
+
!ctx.isCellEditableAt(d.sourceMinRow + r, d.sourceMinCol + c)
|
|
560
|
+
)
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Snapshot the source before touching anything - source and destination
|
|
566
|
+
// overlap on any short drag, so reading lazily would move already-moved
|
|
567
|
+
// values a second time.
|
|
568
|
+
const values: unknown[][] = [];
|
|
569
|
+
for (let r = 0; r < height; r += 1) {
|
|
570
|
+
const rowValues: unknown[] = [];
|
|
571
|
+
for (let c = 0; c < width; c += 1) {
|
|
572
|
+
const column = ctx.allColumns[d.sourceMinCol + c];
|
|
573
|
+
rowValues.push(readCellRaw(d.sourceMinRow + r, column.id));
|
|
574
|
+
}
|
|
575
|
+
values.push(rowValues);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const steps: Array<{
|
|
579
|
+
rowId: string;
|
|
580
|
+
columnId: string;
|
|
581
|
+
field: string;
|
|
582
|
+
before: unknown;
|
|
583
|
+
after: unknown;
|
|
584
|
+
}> = [];
|
|
585
|
+
const write = (rowIndex: number, colIndex: number, value: unknown) => {
|
|
586
|
+
const column = ctx.allColumns[colIndex];
|
|
587
|
+
if (!column?.columnDef.field) return;
|
|
588
|
+
const before = readCellRaw(rowIndex, column.id);
|
|
589
|
+
if (before === value) return;
|
|
590
|
+
// Resolve the row id BEFORE the write: writeCellRaw swaps in a fresh
|
|
591
|
+
// row object, so an `original`-based lookup afterwards would miss.
|
|
592
|
+
const rowId = ctx.allRows[rowIndex]?.id;
|
|
593
|
+
writeCellRaw(rowIndex, column.id, value);
|
|
594
|
+
if (rowId) {
|
|
595
|
+
steps.push({
|
|
596
|
+
rowId,
|
|
597
|
+
columnId: column.id,
|
|
598
|
+
field: column.columnDef.field as string,
|
|
599
|
+
before,
|
|
600
|
+
after: value,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// Clear the whole source first, then write the destination. With the
|
|
606
|
+
// values already snapshotted this is correct even when the rectangles
|
|
607
|
+
// overlap: the destination write always lands after the clear.
|
|
608
|
+
if (!d.copy) {
|
|
609
|
+
for (let r = 0; r < height; r += 1) {
|
|
610
|
+
for (let c = 0; c < width; c += 1) {
|
|
611
|
+
write(d.sourceMinRow + r, d.sourceMinCol + c, null);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (let r = 0; r < height; r += 1) {
|
|
616
|
+
for (let c = 0; c < width; c += 1) {
|
|
617
|
+
write(dest.minRow + r, dest.minCol + c, values[r]![c]);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// One undo entry per changed cell, appended together so a single Ctrl+Z
|
|
622
|
+
// sequence walks the whole move back. Same shape and truncation rule as
|
|
623
|
+
// a whole-row commit.
|
|
624
|
+
if (steps.length) {
|
|
625
|
+
let hist = ctx.history.slice(0, ctx.historyPtr + 1);
|
|
626
|
+
for (const step of steps) hist.push(step);
|
|
627
|
+
if (hist.length > ctx.UNDO_LIMIT) hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
|
|
628
|
+
ctx.history = hist;
|
|
629
|
+
ctx.historyPtr = ctx.history.length - 1;
|
|
630
|
+
ctx.historyVersion += 1;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Follow the values: the range stays selected where it landed, so a
|
|
634
|
+
// second drag can chain off the first the way it does in a spreadsheet.
|
|
635
|
+
ctx.selectionRanges = [];
|
|
636
|
+
ctx.selectionRange = {
|
|
637
|
+
anchor: { rowIndex: dest.minRow, colIndex: dest.minCol },
|
|
638
|
+
focus: { rowIndex: dest.maxRow, colIndex: dest.maxCol },
|
|
639
|
+
};
|
|
640
|
+
ctx.setActiveCell(dest.minRow, dest.minCol);
|
|
641
|
+
}
|
|
642
|
+
|
|
250
643
|
function toggleBooleanCell(rowIndex: number, colIndex: number) {
|
|
251
644
|
const row = ctx.allRows[rowIndex];
|
|
252
645
|
const column = ctx.allColumns[colIndex];
|
|
@@ -393,15 +786,14 @@ export function createClipboard<
|
|
|
393
786
|
newValue: unknown
|
|
394
787
|
row: TData
|
|
395
788
|
}> = [];
|
|
789
|
+
const dataIndexOf = createDataIndexLookup(next);
|
|
396
790
|
for (let r = startRow; r <= endRow; r += 1) {
|
|
397
791
|
const row = ctx.allRows[r];
|
|
398
792
|
if (!row || isGroupRow(row)) continue;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
dataIndex >= next.length
|
|
404
|
-
) continue;
|
|
793
|
+
// By identity, not `Number(row.id)` - that is the array index only under
|
|
794
|
+
// the default getRowId, so a custom one cleared the wrong row.
|
|
795
|
+
const dataIndex = dataIndexOf(row);
|
|
796
|
+
if (dataIndex < 0) continue;
|
|
405
797
|
const originalRow = next[dataIndex];
|
|
406
798
|
if (!originalRow) continue;
|
|
407
799
|
const updated: Record<string, unknown> = {
|
|
@@ -465,6 +857,14 @@ export function createClipboard<
|
|
|
465
857
|
startFillDrag,
|
|
466
858
|
onFillPointerMove,
|
|
467
859
|
onFillPointerUp,
|
|
860
|
+
isOnMoveGrabStrip,
|
|
861
|
+
startMoveDrag,
|
|
862
|
+
onMovePointerMove,
|
|
863
|
+
onMovePointerUp,
|
|
864
|
+
trackEdgeScroll,
|
|
865
|
+
stopEdgeScroll,
|
|
866
|
+
applyMoveRange,
|
|
867
|
+
moveDestRect,
|
|
468
868
|
toggleBooleanCell,
|
|
469
869
|
copySelectionToClipboard,
|
|
470
870
|
clearSelectedCells,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cellDataType: 'dateString'` columns, which hold an ISO date STRING
|
|
3
|
+
* ('2026-06-27') rather than a Date or a timestamp.
|
|
4
|
+
*
|
|
5
|
+
* Two bugs lived here, both reported from the same demo:
|
|
6
|
+
*
|
|
7
|
+
* 1. Committing an edit ran through `parseEditorValue('date', ...)`, which
|
|
8
|
+
* does `new Date(v).toISOString()` - so '2026-12-25' was stored as
|
|
9
|
+
* '2026-12-25T01:00:00.000Z'. The cell then showed a timestamp next to
|
|
10
|
+
* neighbours showing plain dates. `dateString` resolves to
|
|
11
|
+
* `editorType: 'date'`, so nothing downstream knew the difference.
|
|
12
|
+
*
|
|
13
|
+
* 2. That conversion goes through UTC. A local midnight either side of the
|
|
14
|
+
* meridian lands on the previous or next day, so the stored CALENDAR DATE
|
|
15
|
+
* depended on where the user was sitting.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
import { parseEditorValue } from './editors/cell-editors'
|
|
19
|
+
import { toIsoDateLocal, usesRichDateFilter } from './SvGrid.helpers'
|
|
20
|
+
|
|
21
|
+
describe('parseEditorValue with dateOnly', () => {
|
|
22
|
+
it('keeps an ISO date exactly as picked', () => {
|
|
23
|
+
expect(parseEditorValue('date', '2026-12-25', { dateOnly: true })).toBe('2026-12-25')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('trims a datetime down to its calendar date', () => {
|
|
27
|
+
expect(parseEditorValue('date', '2026-12-25T09:30', { dateOnly: true })).toBe('2026-12-25')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('never sends the value through UTC', () => {
|
|
31
|
+
// The bug: a date whose UTC form is the day before. Whatever the runner's
|
|
32
|
+
// zone, the day the user picked has to be the day that is stored.
|
|
33
|
+
for (const raw of ['2026-01-01', '2026-12-31', '2026-06-15']) {
|
|
34
|
+
expect(parseEditorValue('date', raw, { dateOnly: true })).toBe(raw)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('uses local parts when the input is not already ISO', () => {
|
|
39
|
+
const d = new Date(2026, 11, 25, 23, 30) // local 25 Dec, late evening
|
|
40
|
+
const out = parseEditorValue('date', d.toString(), { dateOnly: true })
|
|
41
|
+
expect(out).toBe('2026-12-25')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns null for something that is not a date at all', () => {
|
|
45
|
+
expect(parseEditorValue('date', 'not a date', { dateOnly: true })).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('leaves the normal date editor alone - it still stores a timestamp', () => {
|
|
49
|
+
// `cellDataType: 'date'` columns hold Date values and DO want the ISO
|
|
50
|
+
// timestamp. Only dateString opts out.
|
|
51
|
+
const out = parseEditorValue('date', '2026-12-25')
|
|
52
|
+
expect(String(out)).toMatch(/^2026-12-2[45]T/)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('toIsoDateLocal', () => {
|
|
57
|
+
it('formats from local parts, not UTC', () => {
|
|
58
|
+
expect(toIsoDateLocal(new Date(2026, 0, 1, 0, 30))).toBe('2026-01-01')
|
|
59
|
+
expect(toIsoDateLocal(new Date(2026, 11, 31, 23, 30))).toBe('2026-12-31')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('is empty for no date', () => {
|
|
63
|
+
expect(toIsoDateLocal(null)).toBe('')
|
|
64
|
+
expect(toIsoDateLocal(undefined)).toBe('')
|
|
65
|
+
expect(toIsoDateLocal(new Date('nonsense'))).toBe('')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('usesRichDateFilter', () => {
|
|
70
|
+
it('is on for the date-ish editors', () => {
|
|
71
|
+
for (const t of ['date', 'datetime', 'time'] as const) {
|
|
72
|
+
expect(usesRichDateFilter(t), t).toBe(true)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('honours the -native opt-out, same as the cell editor', () => {
|
|
77
|
+
for (const t of ['date-native', 'datetime-native', 'time-native'] as const) {
|
|
78
|
+
expect(usesRichDateFilter(t), t).toBe(false)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('is off for everything else', () => {
|
|
83
|
+
for (const t of ['text', 'number', 'list', 'checkbox', undefined] as const) {
|
|
84
|
+
expect(usesRichDateFilter(t as never), String(t)).toBe(false)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/editing.test.ts
CHANGED
|
@@ -925,6 +925,31 @@ describe('clipboard fallbacks (non-paste branches only)', () => {
|
|
|
925
925
|
expect(ctx.internalData[1]).toMatchObject({ a: 'Z', b: 'W' })
|
|
926
926
|
})
|
|
927
927
|
|
|
928
|
+
it('pastes into the selected row when getRowId does not return the array index', () => {
|
|
929
|
+
// Regression: the row -> data-slot lookup was `Number(row.id)`, which is
|
|
930
|
+
// the array index only under the DEFAULT getRowId. With 1-based ids
|
|
931
|
+
// (`getRowId={(t) => String(t.id)}`) every paste landed one row BELOW the
|
|
932
|
+
// selection; with non-numeric ids it parsed to NaN and wrote nothing.
|
|
933
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
934
|
+
value: undefined,
|
|
935
|
+
configurable: true,
|
|
936
|
+
})
|
|
937
|
+
const { ctx, ed } = editingFor({
|
|
938
|
+
columns: [{ id: 'a', field: 'a', editorType: 'text', editable: true }],
|
|
939
|
+
data: [{ a: 'x0' }, { a: 'x1' }, { a: 'x2' }],
|
|
940
|
+
})
|
|
941
|
+
ctx._rows.forEach((r: any, i: number) => { r.id = String(i + 1) })
|
|
942
|
+
ctx.selectionRange = {
|
|
943
|
+
anchor: { rowIndex: 1, colIndex: 0 },
|
|
944
|
+
focus: { rowIndex: 1, colIndex: 0 },
|
|
945
|
+
}
|
|
946
|
+
ed.onGridPaste({
|
|
947
|
+
clipboardData: { getData: () => 'PASTED' },
|
|
948
|
+
preventDefault: vi.fn(),
|
|
949
|
+
} as unknown as ClipboardEvent)
|
|
950
|
+
expect(ctx.internalData.map((d: any) => d.a)).toEqual(['x0', 'PASTED', 'x2'])
|
|
951
|
+
})
|
|
952
|
+
|
|
928
953
|
it('onGridPaste bails while a cell is being edited (native editor paste wins)', () => {
|
|
929
954
|
Object.defineProperty(navigator, 'clipboard', {
|
|
930
955
|
value: undefined, // fallback path (Firefox / insecure) - would otherwise hijack
|