@svgrid/grid 3.0.2 → 3.0.4
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 +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
- package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
- package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
- package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
- package/dist/cdn/chart-CKJRCPOk.js +0 -1652
package/src/clipboard.test.ts
CHANGED
|
@@ -77,6 +77,11 @@ function makeCtx(opts: FakeOptions = {}) {
|
|
|
77
77
|
_rows: buildRows(),
|
|
78
78
|
selectionRange: { anchor: null, focus: null },
|
|
79
79
|
selectionRanges: [] as Array<{ anchor: any; focus: any }>,
|
|
80
|
+
history: [] as any[],
|
|
81
|
+
historyPtr: -1,
|
|
82
|
+
historyVersion: 0,
|
|
83
|
+
historyGroupId: undefined as string | undefined,
|
|
84
|
+
UNDO_LIMIT: 200,
|
|
80
85
|
// Mirrors the real controller's getSelectionRects: committed ranges + active.
|
|
81
86
|
getSelectionRects(): Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> {
|
|
82
87
|
const rects: Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> = []
|
|
@@ -436,6 +441,77 @@ describe('onFillPointerUp / applyFillPattern', () => {
|
|
|
436
441
|
expect(ctx.selectionRange.focus).toEqual({ rowIndex: 3, colIndex: 0 })
|
|
437
442
|
})
|
|
438
443
|
|
|
444
|
+
it('records the drag as one grouped history entry, so Ctrl+Z takes it all back', () => {
|
|
445
|
+
// The fill used to write through writeCellRaw and push nothing: a drag
|
|
446
|
+
// over twenty rows was invisible to undo.
|
|
447
|
+
const ctx = makeCtx({
|
|
448
|
+
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
|
449
|
+
data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }],
|
|
450
|
+
})
|
|
451
|
+
ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 3, targetCol: 0 }
|
|
452
|
+
createClipboard(ctx).onFillPointerUp()
|
|
453
|
+
expect(ctx.history).toHaveLength(2)
|
|
454
|
+
expect(ctx.history[0]).toMatchObject({ rowId: '2', columnId: 'a', field: 'a', before: 0, after: 3 })
|
|
455
|
+
expect(ctx.history[1]).toMatchObject({ rowId: '3', columnId: 'a', field: 'a', before: 0, after: 4 })
|
|
456
|
+
expect(ctx.history[0].groupId).toBeDefined()
|
|
457
|
+
expect(ctx.history[0].groupId).toBe(ctx.history[1].groupId)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('asks processCellForFill first and falls back to the pattern when it declines', () => {
|
|
461
|
+
// A spreadsheet moves a formula's references by the distance filled;
|
|
462
|
+
// the pattern rules would read "=A1+B1" as "Item 1" and write "=A1+B2".
|
|
463
|
+
const seen: Array<{ value: unknown; delta: { rows: number; cols: number } }> = []
|
|
464
|
+
const ctx = makeCtx({
|
|
465
|
+
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
|
|
466
|
+
data: [{ a: '=A1+B1' }, { a: '' }, { a: '' }],
|
|
467
|
+
})
|
|
468
|
+
ctx.props.processCellForFill = ({ value, delta }: { value: unknown; delta: { rows: number; cols: number } }) => {
|
|
469
|
+
seen.push({ value, delta })
|
|
470
|
+
return typeof value === 'string' && value.startsWith('=') ? `${value}@${delta.rows}` : undefined
|
|
471
|
+
}
|
|
472
|
+
ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 0, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 2, targetCol: 0 }
|
|
473
|
+
createClipboard(ctx).onFillPointerUp()
|
|
474
|
+
expect(ctx.internalData[1].a).toBe('=A1+B1@1')
|
|
475
|
+
expect(ctx.internalData[2].a).toBe('=A1+B1@2')
|
|
476
|
+
expect(seen.map((s) => s.delta.rows)).toEqual([1, 2])
|
|
477
|
+
|
|
478
|
+
// Declining leaves the series to the pattern.
|
|
479
|
+
const numeric = makeCtx({
|
|
480
|
+
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
|
481
|
+
data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }],
|
|
482
|
+
})
|
|
483
|
+
numeric.props.processCellForFill = () => undefined
|
|
484
|
+
numeric.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 3, targetCol: 0 }
|
|
485
|
+
createClipboard(numeric).onFillPointerUp()
|
|
486
|
+
expect(numeric.internalData[2].a).toBe(3)
|
|
487
|
+
expect(numeric.internalData[3].a).toBe(4)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('double-click fills down as far as the neighbouring column has data', () => {
|
|
491
|
+
const ctx = makeCtx({
|
|
492
|
+
columns: [
|
|
493
|
+
{ id: 'a', field: 'a', editable: true, editorType: 'text' },
|
|
494
|
+
{ id: 'b', field: 'b', editable: true, editorType: 'number' },
|
|
495
|
+
],
|
|
496
|
+
data: [{ a: 'x', b: 1 }, { a: 'y', b: 0 }, { a: 'z', b: 0 }, { a: '', b: 0 }],
|
|
497
|
+
})
|
|
498
|
+
ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 1 }, focus: { rowIndex: 0, colIndex: 1 } }
|
|
499
|
+
createClipboard(ctx).fillDownToNeighbour()
|
|
500
|
+
// Column a runs to row 3 (index 2); the fill stops there.
|
|
501
|
+
expect(ctx.internalData.map((r: any) => r.b)).toEqual([1, 1, 1, 0])
|
|
502
|
+
expect(ctx.selectionRange.focus).toEqual({ rowIndex: 2, colIndex: 1 })
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('double-click with nothing beside the selection fills nothing', () => {
|
|
506
|
+
const ctx = makeCtx({
|
|
507
|
+
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
|
508
|
+
data: [{ a: 1 }, { a: 0 }, { a: 0 }],
|
|
509
|
+
})
|
|
510
|
+
ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
|
|
511
|
+
createClipboard(ctx).fillDownToNeighbour()
|
|
512
|
+
expect(ctx.internalData.map((r: any) => r.a)).toEqual([1, 0, 0])
|
|
513
|
+
})
|
|
514
|
+
|
|
439
515
|
it('fills upward, reverse-extrapolating the pattern', () => {
|
|
440
516
|
const ctx = makeCtx({
|
|
441
517
|
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
|
@@ -617,6 +693,59 @@ describe('copySelectionToClipboard', () => {
|
|
|
617
693
|
expect(writeText).toHaveBeenCalledWith('a\tb\na0\tb0\na1\tb1')
|
|
618
694
|
})
|
|
619
695
|
|
|
696
|
+
it('writes text and HTML through the copy event when clipboardHtml supplies markup', () => {
|
|
697
|
+
// The copy event is the first path: execCommand('copy') fires it and the
|
|
698
|
+
// one-shot listener puts both types on it, attributes intact.
|
|
699
|
+
const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
|
|
700
|
+
ctx.props.clipboardHtml = ({ text }: { text: string }) => `<table data-x="1"><tr><td>${text}</td></tr></table>`
|
|
701
|
+
ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 1 } }
|
|
702
|
+
const set = vi.fn()
|
|
703
|
+
// jsdom has no execCommand; this one fires the copy event the way a browser's does.
|
|
704
|
+
;(document as unknown as { execCommand: (name: string) => boolean }).execCommand = () => {
|
|
705
|
+
const ev = new Event('copy', { cancelable: true }) as ClipboardEvent
|
|
706
|
+
Object.defineProperty(ev, 'clipboardData', { value: { setData: set } })
|
|
707
|
+
document.dispatchEvent(ev)
|
|
708
|
+
return true
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
createClipboard(ctx).copySelectionToClipboard()
|
|
712
|
+
expect(set).toHaveBeenCalledWith('text/plain', 'a0\tb0')
|
|
713
|
+
expect(set).toHaveBeenCalledWith('text/html', '<table data-x="1"><tr><td>a0\tb0</td></tr></table>')
|
|
714
|
+
expect(writeText).not.toHaveBeenCalled()
|
|
715
|
+
} finally {
|
|
716
|
+
delete (document as unknown as { execCommand?: unknown }).execCommand
|
|
717
|
+
}
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('falls back to the async write with both types when the copy event is refused', () => {
|
|
721
|
+
const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
|
|
722
|
+
ctx.props.clipboardHtml = () => '<b>x</b>'
|
|
723
|
+
ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
|
|
724
|
+
const write = vi.fn().mockResolvedValue(undefined)
|
|
725
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText, write }, configurable: true })
|
|
726
|
+
;(document as unknown as { execCommand: (name: string) => boolean }).execCommand = () => false
|
|
727
|
+
class FakeItem { constructor(public parts: Record<string, Blob>) {} }
|
|
728
|
+
;(globalThis as Record<string, unknown>).ClipboardItem = FakeItem
|
|
729
|
+
try {
|
|
730
|
+
createClipboard(ctx).copySelectionToClipboard()
|
|
731
|
+
expect(write).toHaveBeenCalledTimes(1)
|
|
732
|
+
const item = write.mock.calls[0]![0][0] as FakeItem
|
|
733
|
+
expect(Object.keys(item.parts)).toEqual(['text/plain', 'text/html'])
|
|
734
|
+
expect(writeText).not.toHaveBeenCalled()
|
|
735
|
+
} finally {
|
|
736
|
+
delete (document as unknown as { execCommand?: unknown }).execCommand
|
|
737
|
+
delete (globalThis as Record<string, unknown>).ClipboardItem
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('writes text alone when clipboardHtml returns nothing', () => {
|
|
742
|
+
const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
|
|
743
|
+
ctx.props.clipboardHtml = () => null
|
|
744
|
+
ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
|
|
745
|
+
createClipboard(ctx).copySelectionToClipboard()
|
|
746
|
+
expect(writeText).toHaveBeenCalledWith('a0')
|
|
747
|
+
})
|
|
748
|
+
|
|
620
749
|
it('runs each value through processCellForClipboard', () => {
|
|
621
750
|
const ctx = makeCtx({
|
|
622
751
|
data: [{ a: 'a0', b: 'b0' }],
|
|
@@ -738,6 +867,28 @@ describe('clearSelectedCells', () => {
|
|
|
738
867
|
)
|
|
739
868
|
})
|
|
740
869
|
|
|
870
|
+
it('records the clear in the history as one grouped step per cell', () => {
|
|
871
|
+
// Delete over a block used to leave no trace: the values were gone and
|
|
872
|
+
// Ctrl+Z skipped past them to whatever edit came before.
|
|
873
|
+
const ctx = makeCtx({
|
|
874
|
+
columns: [
|
|
875
|
+
{ id: 'a', field: 'a', editable: true, editorType: 'text' },
|
|
876
|
+
{ id: 'b', field: 'b', editable: true, editorType: 'text' },
|
|
877
|
+
],
|
|
878
|
+
data: [{ a: 'x', b: 'y' }],
|
|
879
|
+
})
|
|
880
|
+
ctx.selectionRange = {
|
|
881
|
+
anchor: { rowIndex: 0, colIndex: 0 },
|
|
882
|
+
focus: { rowIndex: 0, colIndex: 1 },
|
|
883
|
+
}
|
|
884
|
+
createClipboard(ctx).clearSelectedCells()
|
|
885
|
+
expect(ctx.history).toHaveLength(2)
|
|
886
|
+
expect(ctx.history[0]).toMatchObject({ rowId: '0', columnId: 'a', field: 'a', before: 'x', after: '' })
|
|
887
|
+
expect(ctx.history[0].groupId).toBeDefined()
|
|
888
|
+
expect(ctx.history[1].groupId).toBe(ctx.history[0].groupId)
|
|
889
|
+
expect(ctx.historyPtr).toBe(1)
|
|
890
|
+
})
|
|
891
|
+
|
|
741
892
|
it('clears a number cell via parseEditorValue coercion', () => {
|
|
742
893
|
const ctx = makeCtx({
|
|
743
894
|
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
package/src/clipboard.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
isGroupRow,
|
|
19
19
|
toolPanelHeaderLabel,
|
|
20
20
|
} from "./cell-values";
|
|
21
|
+
import { pushHistory, nextGroupId, runHistoryGroup, type HistoryStep } from "./history";
|
|
21
22
|
|
|
22
23
|
export function createClipboard<
|
|
23
24
|
TFeatures extends TableFeatures = TableFeatures,
|
|
@@ -84,7 +85,91 @@ export function createClipboard<
|
|
|
84
85
|
/** Apply the pattern fill on pointerup. Each NEW row (or column) is
|
|
85
86
|
* filled from a pattern derived from the matching column (or row) of
|
|
86
87
|
* the source. Handles all four drag directions. */
|
|
88
|
+
/**
|
|
89
|
+
* Write one filled cell and remember it, so the drag undoes as a unit.
|
|
90
|
+
* `writeCellRaw` keeps no history of its own; the fill used to call it in
|
|
91
|
+
* a loop and push nothing, which left Ctrl+Z blind to a whole drag.
|
|
92
|
+
*/
|
|
93
|
+
function writeFilled(steps: HistoryStep[], rowIndex: number, columnId: string, value: unknown) {
|
|
94
|
+
const row = ctx.allRows[rowIndex];
|
|
95
|
+
const column = ctx.findColumnById(columnId);
|
|
96
|
+
const field = column?.columnDef.field;
|
|
97
|
+
if (!row || field == null) return;
|
|
98
|
+
const before = readCellRaw(rowIndex, columnId);
|
|
99
|
+
writeCellRaw(rowIndex, columnId, value);
|
|
100
|
+
if (before !== value) steps.push({ rowId: row.id, columnId, field, before, after: value });
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
function applyFillPattern() {
|
|
104
|
+
const steps: HistoryStep[] = [];
|
|
105
|
+
runHistoryGroup(ctx, () => applyFillCells(steps));
|
|
106
|
+
if (steps.length) pushHistory(ctx, steps, nextGroupId());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The value for the i-th filled cell: the consumer's, when its hook has
|
|
111
|
+
* one for the source cell the target cycles back to, else the pattern's.
|
|
112
|
+
* `sourceStart` is the row (or column) of the first source value and
|
|
113
|
+
* `targetStart` that of the first target; `step` is -1 filling upward.
|
|
114
|
+
*/
|
|
115
|
+
function filledValue(
|
|
116
|
+
sources: unknown[],
|
|
117
|
+
pattern: unknown[],
|
|
118
|
+
i: number,
|
|
119
|
+
sourceStart: number,
|
|
120
|
+
targetStart: number,
|
|
121
|
+
step: 1 | -1,
|
|
122
|
+
axis: "rows" | "cols",
|
|
123
|
+
rowIndex: number,
|
|
124
|
+
columnId: string,
|
|
125
|
+
): unknown {
|
|
126
|
+
const hook = ctx.props.processCellForFill;
|
|
127
|
+
if (hook && sources.length) {
|
|
128
|
+
const k = i % sources.length;
|
|
129
|
+
const distance = (targetStart + step * i) - (sourceStart + step * k);
|
|
130
|
+
const delta = axis === "rows" ? { rows: distance, cols: 0 } : { rows: 0, cols: distance };
|
|
131
|
+
const decided = hook({ value: sources[k], delta, rowIndex, columnId });
|
|
132
|
+
if (decided !== undefined) return decided;
|
|
133
|
+
}
|
|
134
|
+
return pattern[i];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Double-click on the fill handle: fill down as far as the column beside
|
|
139
|
+
* the selection has data, the way Excel's does. The column to the left
|
|
140
|
+
* decides; when it is blank below the selection (or there is none) the
|
|
141
|
+
* one to the right does. Nothing to follow, nothing filled.
|
|
142
|
+
*/
|
|
143
|
+
function fillDownToNeighbour() {
|
|
144
|
+
const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
|
|
145
|
+
if (!anchor) return;
|
|
146
|
+
const focus = ctx.selectionRange.focus ?? anchor;
|
|
147
|
+
const minRow = Math.min(anchor.rowIndex, focus.rowIndex);
|
|
148
|
+
const maxRow = Math.max(anchor.rowIndex, focus.rowIndex);
|
|
149
|
+
const minCol = Math.min(anchor.colIndex, focus.colIndex);
|
|
150
|
+
const maxCol = Math.max(anchor.colIndex, focus.colIndex);
|
|
151
|
+
const filled = (r: number, c: number) => {
|
|
152
|
+
const col = ctx.allColumns[c];
|
|
153
|
+
if (!col) return false;
|
|
154
|
+
const v = readCellRaw(r, col.id);
|
|
155
|
+
return v != null && v !== "";
|
|
156
|
+
};
|
|
157
|
+
const extent = (c: number): number => {
|
|
158
|
+
let r = maxRow;
|
|
159
|
+
while (r + 1 < ctx.allRows.length && filled(r + 1, c)) r += 1;
|
|
160
|
+
return r;
|
|
161
|
+
};
|
|
162
|
+
let target = minCol > 0 ? extent(minCol - 1) : maxRow;
|
|
163
|
+
if (target === maxRow) target = extent(maxCol + 1);
|
|
164
|
+
if (target <= maxRow) return;
|
|
165
|
+
ctx.fillDrag = {
|
|
166
|
+
sourceMinRow: minRow, sourceMaxRow: maxRow, sourceMinCol: minCol, sourceMaxCol: maxCol,
|
|
167
|
+
targetRow: target, targetCol: maxCol,
|
|
168
|
+
};
|
|
169
|
+
applyFillPattern();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function applyFillCells(steps: HistoryStep[]) {
|
|
88
173
|
const d = ctx.fillDrag;
|
|
89
174
|
if (!d) return;
|
|
90
175
|
// Clear fillDrag FIRST so a thrown error doesn't leave the grid
|
|
@@ -118,7 +203,8 @@ export function createClipboard<
|
|
|
118
203
|
for (let i = 0; i < targetRows; i += 1) {
|
|
119
204
|
const targetRow = d.sourceMaxRow + 1 + i;
|
|
120
205
|
if (ctx.isCellEditableAt(targetRow, c))
|
|
121
|
-
|
|
206
|
+
writeFilled(steps, targetRow, column.id,
|
|
207
|
+
filledValue(sourceColValues, fills, i, d.sourceMinRow, d.sourceMaxRow + 1, 1, "rows", targetRow, column.id));
|
|
122
208
|
}
|
|
123
209
|
}
|
|
124
210
|
if (newMinRow < d.sourceMinRow) {
|
|
@@ -129,7 +215,8 @@ export function createClipboard<
|
|
|
129
215
|
for (let i = 0; i < targetRows; i += 1) {
|
|
130
216
|
const targetRow = d.sourceMinRow - 1 - i;
|
|
131
217
|
if (ctx.isCellEditableAt(targetRow, c))
|
|
132
|
-
|
|
218
|
+
writeFilled(steps, targetRow, column.id,
|
|
219
|
+
filledValue(reversed, fills, i, d.sourceMaxRow, d.sourceMinRow - 1, -1, "rows", targetRow, column.id));
|
|
133
220
|
}
|
|
134
221
|
}
|
|
135
222
|
}
|
|
@@ -150,7 +237,8 @@ export function createClipboard<
|
|
|
150
237
|
const targetCol = d.sourceMaxCol + 1 + i;
|
|
151
238
|
const col = ctx.allColumns[targetCol];
|
|
152
239
|
if (col && ctx.isCellEditableAt(r, targetCol))
|
|
153
|
-
|
|
240
|
+
writeFilled(steps, r, col.id,
|
|
241
|
+
filledValue(sourceRowValues, fills, i, d.sourceMinCol, d.sourceMaxCol + 1, 1, "cols", r, col.id));
|
|
154
242
|
}
|
|
155
243
|
}
|
|
156
244
|
if (newMinCol < d.sourceMinCol) {
|
|
@@ -161,7 +249,8 @@ export function createClipboard<
|
|
|
161
249
|
const targetCol = d.sourceMinCol - 1 - i;
|
|
162
250
|
const col = ctx.allColumns[targetCol];
|
|
163
251
|
if (col && ctx.isCellEditableAt(r, targetCol))
|
|
164
|
-
|
|
252
|
+
writeFilled(steps, r, col.id,
|
|
253
|
+
filledValue(reversed, fills, i, d.sourceMaxCol, d.sourceMinCol - 1, -1, "cols", r, col.id));
|
|
165
254
|
}
|
|
166
255
|
}
|
|
167
256
|
}
|
|
@@ -618,17 +707,10 @@ export function createClipboard<
|
|
|
618
707
|
}
|
|
619
708
|
}
|
|
620
709
|
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
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
|
-
}
|
|
710
|
+
// The whole move is ONE undo entry. It used to be one step per changed
|
|
711
|
+
// cell, so walking a 15x20 move back took 300 presses of Ctrl+Z and blew
|
|
712
|
+
// past UNDO_LIMIT, evicting every step behind it.
|
|
713
|
+
pushHistory(ctx, steps, nextGroupId());
|
|
632
714
|
|
|
633
715
|
// Follow the values: the range stays selected where it landed, so a
|
|
634
716
|
// second drag can chain off the first the way it does in a spreadsheet.
|
|
@@ -679,6 +761,40 @@ export function createClipboard<
|
|
|
679
761
|
legacyCopyText(text);
|
|
680
762
|
}
|
|
681
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Text and HTML together. The `copy` event is the first path: a listener
|
|
766
|
+
* armed for this one call puts both types on the event's clipboardData
|
|
767
|
+
* while the legacy textarea copy runs, which works in a secure and an
|
|
768
|
+
* insecure context alike and keeps every attribute of the HTML. Only when
|
|
769
|
+
* the browser refuses that does the async `ClipboardItem` write run, and
|
|
770
|
+
* text alone after that.
|
|
771
|
+
*/
|
|
772
|
+
function writeClipboard(text: string, html: string) {
|
|
773
|
+
if (typeof document !== "undefined") {
|
|
774
|
+
let armed = true;
|
|
775
|
+
const onCopy = (event: ClipboardEvent) => {
|
|
776
|
+
if (!armed || !event.clipboardData) return;
|
|
777
|
+
armed = false;
|
|
778
|
+
event.clipboardData.setData("text/plain", text);
|
|
779
|
+
event.clipboardData.setData("text/html", html);
|
|
780
|
+
event.preventDefault();
|
|
781
|
+
};
|
|
782
|
+
document.addEventListener("copy", onCopy, { once: true, capture: true });
|
|
783
|
+
const ok = legacyCopyText(text);
|
|
784
|
+
document.removeEventListener("copy", onCopy, { capture: true });
|
|
785
|
+
if (ok && !armed) return;
|
|
786
|
+
}
|
|
787
|
+
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
|
|
788
|
+
const item = new ClipboardItem({
|
|
789
|
+
"text/plain": new Blob([text], { type: "text/plain" }),
|
|
790
|
+
"text/html": new Blob([html], { type: "text/html" }),
|
|
791
|
+
});
|
|
792
|
+
void navigator.clipboard.write([item]).catch(() => writeClipboardText(text));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
writeClipboardText(text);
|
|
796
|
+
}
|
|
797
|
+
|
|
682
798
|
function legacyCopyText(text: string): boolean {
|
|
683
799
|
if (typeof document === "undefined") return false;
|
|
684
800
|
const active = document.activeElement as HTMLElement | null;
|
|
@@ -759,7 +875,13 @@ export function createClipboard<
|
|
|
759
875
|
}
|
|
760
876
|
blocks.push(lines.join("\n"));
|
|
761
877
|
}
|
|
762
|
-
|
|
878
|
+
const text = blocks.join("\n\n");
|
|
879
|
+
const toHtml = ctx.props.clipboardHtml as
|
|
880
|
+
| ((params: { rects: typeof rects; text: string }) => string | null | undefined)
|
|
881
|
+
| undefined;
|
|
882
|
+
const html = toHtml?.({ rects, text });
|
|
883
|
+
if (html) writeClipboard(text, html);
|
|
884
|
+
else writeClipboardText(text);
|
|
763
885
|
}
|
|
764
886
|
|
|
765
887
|
/**
|
|
@@ -786,6 +908,10 @@ export function createClipboard<
|
|
|
786
908
|
newValue: unknown
|
|
787
909
|
row: TData
|
|
788
910
|
}> = [];
|
|
911
|
+
// One history step per cleared cell, grouped, so Delete over a block is
|
|
912
|
+
// one Ctrl+Z. Clearing used to record nothing at all: the values were
|
|
913
|
+
// gone and undo skipped straight past them to whatever came before.
|
|
914
|
+
const steps: HistoryStep[] = [];
|
|
789
915
|
const dataIndexOf = createDataIndexLookup(next);
|
|
790
916
|
for (let r = startRow; r <= endRow; r += 1) {
|
|
791
917
|
const row = ctx.allRows[r];
|
|
@@ -813,6 +939,7 @@ export function createClipboard<
|
|
|
813
939
|
const cleared = parseEditorValue(editorType, "");
|
|
814
940
|
updated[field] = cleared;
|
|
815
941
|
rowChanged = true;
|
|
942
|
+
steps.push({ rowId: row.id, columnId: column.id, field, before: oldValue, after: cleared });
|
|
816
943
|
changes.push({
|
|
817
944
|
rowIndex: dataIndex,
|
|
818
945
|
columnId: column.id,
|
|
@@ -829,6 +956,7 @@ export function createClipboard<
|
|
|
829
956
|
if (mutated) {
|
|
830
957
|
ctx.internalData = next;
|
|
831
958
|
ctx.grid.store.setState((prev: any) => ({ ...prev }));
|
|
959
|
+
pushHistory(ctx, steps, nextGroupId());
|
|
832
960
|
// Fire onCellValueChange per cleared cell - same as paste's writeCellRaw -
|
|
833
961
|
// so consumers (formula engines, autosave) recompute. Clear IS a value
|
|
834
962
|
// change; without this, a HyperFormula-backed grid wouldn't re-evaluate.
|
|
@@ -857,6 +985,7 @@ export function createClipboard<
|
|
|
857
985
|
startFillDrag,
|
|
858
986
|
onFillPointerMove,
|
|
859
987
|
onFillPointerUp,
|
|
988
|
+
fillDownToNeighbour,
|
|
860
989
|
isOnMoveGrabStrip,
|
|
861
990
|
startMoveDrag,
|
|
862
991
|
onMovePointerMove,
|
package/src/column-resize.ts
CHANGED
|
@@ -27,6 +27,9 @@ export type ColumnResizeOptions = {
|
|
|
27
27
|
getWidth: (columnId: string) => number
|
|
28
28
|
/** Called on every animation frame of the drag, and once at the end. */
|
|
29
29
|
onResize: (columnId: string, width: number) => void
|
|
30
|
+
/** Called once when a drag ends or an arrow key has resized, with the
|
|
31
|
+
* width the gesture started from: what an undo puts back. */
|
|
32
|
+
onResizeEnd?: (columnId: string, width: number, startWidth: number) => void
|
|
30
33
|
/** Accessible name for the handle. Defaults to the column id. */
|
|
31
34
|
label?: (columnId: string) => string
|
|
32
35
|
/**
|
|
@@ -90,6 +93,7 @@ export function columnResize(node: HTMLElement, opts: ColumnResizeOptions) {
|
|
|
90
93
|
// Commit the final width even if the last frame was cancelled mid-flight.
|
|
91
94
|
pendingWidth = Math.max(minWidth(), drag.startWidth + (e.clientX - drag.startX))
|
|
92
95
|
commit()
|
|
96
|
+
current.onResizeEnd?.(drag.columnId, pendingWidth, drag.startWidth)
|
|
93
97
|
drag.handle.classList.remove('is-resizing')
|
|
94
98
|
syncAria(drag.handle, drag.columnId)
|
|
95
99
|
try {
|
|
@@ -144,8 +148,10 @@ export function columnResize(node: HTMLElement, opts: ColumnResizeOptions) {
|
|
|
144
148
|
if (!columnId || current.canResize?.(columnId) === false) return
|
|
145
149
|
e.preventDefault()
|
|
146
150
|
e.stopPropagation()
|
|
147
|
-
const
|
|
151
|
+
const start = current.getWidth(columnId)
|
|
152
|
+
const next = Math.max(minWidth(), Math.round(start + delta))
|
|
148
153
|
current.onResize(columnId, next)
|
|
154
|
+
current.onResizeEnd?.(columnId, next, start)
|
|
149
155
|
syncAria(t, columnId)
|
|
150
156
|
}
|
|
151
157
|
|
package/src/columns.ts
CHANGED
|
@@ -161,6 +161,8 @@ export function createColumns<
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
function getColumnWidth(columnId: string) {
|
|
164
|
+
// Collapsed: no room at all, whatever width it keeps for later.
|
|
165
|
+
if (ctx.collapsedColumns?.[columnId]) return 0;
|
|
164
166
|
const fitted = ctx.fittedColumnWidths?.[columnId];
|
|
165
167
|
if (fitted !== undefined && ctx.columnWidths[columnId] === undefined)
|
|
166
168
|
return fitted;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command context handed to registered keyboard shortcuts.
|
|
3
|
+
*
|
|
4
|
+
* The interesting part is `setCellValue`: it is the only writer a command
|
|
5
|
+
* touches, so if it does not record history then every sheet command ships
|
|
6
|
+
* un-undoable no matter how carefully `cmd.batch` groups. These tests hold
|
|
7
|
+
* that behaviour down, along with the index-space contract (display indices
|
|
8
|
+
* in, `readCellRaw` / `writeCellRaw` out) that the getters exist for.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { buildCommandContext } from './command-context'
|
|
12
|
+
import { undoHistory, type HistoryStep } from './history'
|
|
13
|
+
|
|
14
|
+
function makeCtx(values: Record<string, unknown> = {}) {
|
|
15
|
+
const store: Record<string, unknown> = { ...values }
|
|
16
|
+
const key = (r: number, columnId: string) => `${r}:${columnId}`
|
|
17
|
+
const ctx: any = {
|
|
18
|
+
allRows: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }],
|
|
19
|
+
allColumns: [
|
|
20
|
+
{ id: 'a', columnDef: { field: 'a' } },
|
|
21
|
+
{ id: 'b', columnDef: { field: 'b' } },
|
|
22
|
+
],
|
|
23
|
+
history: [] as HistoryStep[],
|
|
24
|
+
historyPtr: -1,
|
|
25
|
+
historyVersion: 0,
|
|
26
|
+
historyGroupId: undefined as string | undefined,
|
|
27
|
+
UNDO_LIMIT: 200,
|
|
28
|
+
grid: { getState: () => ({ activeCell: { rowIndex: 0, colIndex: 0 } }) },
|
|
29
|
+
getSelectionRects: () => [],
|
|
30
|
+
readCellRaw: (r: number, columnId: string) => store[key(r, columnId)],
|
|
31
|
+
writeCellRaw: vi.fn((r: number, columnId: string, value: unknown) => {
|
|
32
|
+
if (store[key(r, columnId)] === value) return
|
|
33
|
+
store[key(r, columnId)] = value
|
|
34
|
+
}),
|
|
35
|
+
applyHistoryStep: vi.fn((s: HistoryStep, direction: 'undo' | 'redo') => {
|
|
36
|
+
store[key(Number(s.rowId.slice(1)) - 1, s.columnId)] =
|
|
37
|
+
direction === 'undo' ? s.before : s.after
|
|
38
|
+
}),
|
|
39
|
+
setActiveCell: vi.fn(),
|
|
40
|
+
setSelection: vi.fn(),
|
|
41
|
+
extendSelection: vi.fn(),
|
|
42
|
+
scrollActiveCellIntoView: vi.fn(),
|
|
43
|
+
gridRootEl: { focus: vi.fn() },
|
|
44
|
+
pasteFromClipboard: vi.fn(async () => {}),
|
|
45
|
+
copySelectionToClipboard: vi.fn(),
|
|
46
|
+
cutSelectionToClipboard: vi.fn(async () => {}),
|
|
47
|
+
buildApi: () => ({}),
|
|
48
|
+
}
|
|
49
|
+
return { ctx, store, key }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('setCellValue records history', () => {
|
|
53
|
+
it('pushes one step per changed cell', () => {
|
|
54
|
+
const { ctx } = makeCtx({ '0:a': 1 })
|
|
55
|
+
const cmd = buildCommandContext(ctx, false)
|
|
56
|
+
cmd.setCellValue(0, 0, 9)
|
|
57
|
+
expect(ctx.history).toEqual([
|
|
58
|
+
{ rowId: 'r1', columnId: 'a', field: 'a', before: 1, after: 9 },
|
|
59
|
+
])
|
|
60
|
+
expect(ctx.historyPtr).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('pushes nothing when the value is unchanged', () => {
|
|
64
|
+
const { ctx } = makeCtx({ '0:a': 1 })
|
|
65
|
+
const cmd = buildCommandContext(ctx, false)
|
|
66
|
+
cmd.setCellValue(0, 0, 1)
|
|
67
|
+
expect(ctx.history).toEqual([])
|
|
68
|
+
expect(ctx.historyPtr).toBe(-1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('pushes nothing for a column index that does not exist', () => {
|
|
72
|
+
const { ctx } = makeCtx()
|
|
73
|
+
const cmd = buildCommandContext(ctx, false)
|
|
74
|
+
cmd.setCellValue(0, 99, 'x')
|
|
75
|
+
expect(ctx.writeCellRaw).not.toHaveBeenCalled()
|
|
76
|
+
expect(ctx.history).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reads `before` from the cell rather than from the new value', () => {
|
|
80
|
+
const { ctx } = makeCtx({ '1:b': 'old' })
|
|
81
|
+
const cmd = buildCommandContext(ctx, false)
|
|
82
|
+
cmd.setCellValue(1, 1, 'new')
|
|
83
|
+
expect(ctx.history[0]).toMatchObject({ rowId: 'r2', before: 'old', after: 'new' })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('cmd.canEdit', () => {
|
|
88
|
+
it('asks the editing layer, in display index space', () => {
|
|
89
|
+
const { ctx } = makeCtx()
|
|
90
|
+
ctx.isCellEditableAt = vi.fn((r: number, c: number) => !(r === 1 && c === 0))
|
|
91
|
+
const cmd = buildCommandContext(ctx, false)
|
|
92
|
+
expect(cmd.canEdit!(0, 0)).toBe(true)
|
|
93
|
+
expect(cmd.canEdit!(1, 0)).toBe(false)
|
|
94
|
+
expect(ctx.isCellEditableAt).toHaveBeenCalledWith(1, 0)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('says yes when the controller has no editing layer', () => {
|
|
98
|
+
const { ctx } = makeCtx()
|
|
99
|
+
expect(buildCommandContext(ctx, false).canEdit!(2, 1)).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('cmd.focus', () => {
|
|
104
|
+
it('focuses the grid root without scrolling it', () => {
|
|
105
|
+
const { ctx } = makeCtx()
|
|
106
|
+
buildCommandContext(ctx, false).focus()
|
|
107
|
+
expect(ctx.gridRootEl.focus).toHaveBeenCalledWith({ preventScroll: true })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('is a no-op before the grid has mounted', () => {
|
|
111
|
+
const { ctx } = makeCtx()
|
|
112
|
+
ctx.gridRootEl = null
|
|
113
|
+
expect(() => buildCommandContext(ctx, false).focus()).not.toThrow()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('cmd.recordUndo', () => {
|
|
118
|
+
it('puts a non-cell step in the history that undo and redo call back', () => {
|
|
119
|
+
const { ctx } = makeCtx()
|
|
120
|
+
const cmd = buildCommandContext(ctx, false)
|
|
121
|
+
const undo = vi.fn()
|
|
122
|
+
const redo = vi.fn()
|
|
123
|
+
cmd.recordUndo(undo, redo)
|
|
124
|
+
expect(ctx.history).toHaveLength(1)
|
|
125
|
+
expect(ctx.history[0].custom).toEqual({ undo, redo })
|
|
126
|
+
undoHistory(ctx)
|
|
127
|
+
expect(ctx.applyHistoryStep).toHaveBeenCalledWith(expect.objectContaining({ custom: { undo, redo } }), 'undo')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('joins the batch it is recorded inside, so cells and formats undo together', () => {
|
|
131
|
+
const { ctx } = makeCtx({ '0:a': 1 })
|
|
132
|
+
const cmd = buildCommandContext(ctx, false)
|
|
133
|
+
cmd.batch(() => {
|
|
134
|
+
cmd.setCellValue(0, 0, 2)
|
|
135
|
+
cmd.recordUndo(() => {}, () => {})
|
|
136
|
+
})
|
|
137
|
+
expect(ctx.history).toHaveLength(2)
|
|
138
|
+
expect(ctx.history[0].groupId).toBeDefined()
|
|
139
|
+
expect(ctx.history[1].groupId).toBe(ctx.history[0].groupId)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('cmd.editor', () => {
|
|
144
|
+
it('is the open inline editor element, and null when nothing is being edited', () => {
|
|
145
|
+
const { ctx } = makeCtx()
|
|
146
|
+
const root = document.createElement('div')
|
|
147
|
+
ctx.gridRootEl = root
|
|
148
|
+
expect(buildCommandContext(ctx, false).editor).toBeNull()
|
|
149
|
+
const input = document.createElement('input')
|
|
150
|
+
input.className = 'sv-grid-cell-editor'
|
|
151
|
+
root.appendChild(input)
|
|
152
|
+
expect(buildCommandContext(ctx, true).editor).toBe(input)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('cmd.paste', () => {
|
|
157
|
+
it('routes to the grid clipboard paste, the same path Ctrl+V takes', async () => {
|
|
158
|
+
const { ctx } = makeCtx()
|
|
159
|
+
await buildCommandContext(ctx, false).paste()
|
|
160
|
+
expect(ctx.pasteFromClipboard).toHaveBeenCalledTimes(1)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('cmd.copy and cmd.cut', () => {
|
|
165
|
+
it('route to the selection copy and cut, the paths Ctrl+C and Ctrl+X take', async () => {
|
|
166
|
+
const { ctx } = makeCtx()
|
|
167
|
+
const cmd = buildCommandContext(ctx, false)
|
|
168
|
+
cmd.copy()
|
|
169
|
+
expect(ctx.copySelectionToClipboard).toHaveBeenCalledTimes(1)
|
|
170
|
+
await cmd.cut()
|
|
171
|
+
expect(ctx.cutSelectionToClipboard).toHaveBeenCalledTimes(1)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('cmd.batch groups the steps it writes', () => {
|
|
176
|
+
it('makes a multi-cell command ONE undo', () => {
|
|
177
|
+
const { ctx, store } = makeCtx({ '0:a': 1, '1:a': 2, '2:a': 3 })
|
|
178
|
+
const cmd = buildCommandContext(ctx, false)
|
|
179
|
+
cmd.batch(() => {
|
|
180
|
+
cmd.setCellValue(0, 0, 'x')
|
|
181
|
+
cmd.setCellValue(1, 0, 'x')
|
|
182
|
+
cmd.setCellValue(2, 0, 'x')
|
|
183
|
+
})
|
|
184
|
+
const ids = ctx.history.map((s: HistoryStep) => s.groupId)
|
|
185
|
+
expect(ids).toHaveLength(3)
|
|
186
|
+
expect(new Set(ids).size).toBe(1)
|
|
187
|
+
expect(ids[0]).toBeTruthy()
|
|
188
|
+
|
|
189
|
+
expect(undoHistory(ctx)).toBe(true)
|
|
190
|
+
expect([store['0:a'], store['1:a'], store['2:a']]).toEqual([1, 2, 3])
|
|
191
|
+
expect(ctx.historyPtr).toBe(-1)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('leaves a lone write outside a batch ungrouped', () => {
|
|
195
|
+
const { ctx } = makeCtx({ '0:a': 1 })
|
|
196
|
+
const cmd = buildCommandContext(ctx, false)
|
|
197
|
+
cmd.setCellValue(0, 0, 'x')
|
|
198
|
+
expect(ctx.history[0].groupId).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|