From a1f19ef94141967f7915a45d75475a911baac0e5 Mon Sep 17 00:00:00 2001 From: Aleksandr Grigorii Date: Sat, 11 Jul 2026 23:07:05 +0300 Subject: [PATCH] fix virtualized paste outside viewport --- .../cypress/integration/clipboard.spec.ts | 57 +++++++++++++++++++ packages/toast-ui.grid/src/dispatch/data.ts | 2 +- .../toast-ui.grid/src/dispatch/keyboard.ts | 3 + .../src/dispatch/lazyObservable.ts | 13 ++++- packages/toast-ui.grid/src/query/data.ts | 4 +- 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/toast-ui.grid/cypress/integration/clipboard.spec.ts b/packages/toast-ui.grid/cypress/integration/clipboard.spec.ts index e8108330a..7f30aaa50 100644 --- a/packages/toast-ui.grid/cypress/integration/clipboard.spec.ts +++ b/packages/toast-ui.grid/cypress/integration/clipboard.spec.ts @@ -1,3 +1,6 @@ +import GridEvent from '@/event/gridEvent'; +import { GridEventProps } from '@t/event'; + // @TODO if cypress issue is resolved, should add clipboard test case // https://github.com/cypress-io/cypress/issues/2386 @@ -37,3 +40,57 @@ // it('paste', () => {}); export {}; + +before(() => { + cy.visit('/dist'); +}); + +describe('paste with virtual scrolling', () => { + it('should update rows outside the viewport when data is filtered', () => { + const data = Array.from({ length: 20 }, (_, index) => ({ + type: index % 2 ? 'odd' : 'even', + value: `old-${index}`, + })); + + cy.createGrid({ + data, + bodyHeight: 130, + rowHeight: 40, + columns: [ + { name: 'type', filter: 'text' }, + { name: 'value', editor: 'text' }, + ], + }); + + cy.gridInstance().then((grid) => { + let beforeChangeCount = 0; + let afterChangeCount = 0; + const copiedData = Array.from({ length: 5 }, (_, index) => [`new-${index}`]); + + grid.on('beforeChange', (event: GridEventProps & GridEvent) => { + if (event.origin === 'paste') { + beforeChangeCount = event.changes!.length; + } + }); + grid.on('afterChange', (event: GridEventProps & GridEvent) => { + if (event.origin === 'paste') { + afterChangeCount = event.changes!.length; + } + }); + + grid.filter('type', [{ code: 'eq', value: 'even' }]); + grid.focus(0, 'value'); + grid.dispatch('paste', copiedData); + + [0, 2, 4, 6, 8].forEach((rowKey, index) => { + expect(grid.getValue(rowKey, 'value')).to.eq(`new-${index}`); + }); + expect(beforeChangeCount).to.eq(5); + expect(afterChangeCount).to.eq(5); + expect(grid.getModifiedRows().updatedRows).to.have.length(5); + }); + + cy.focusAndWait(8, 'value'); + cy.getCell(8, 'value').should('have.text', 'new-4'); + }); +}); diff --git a/packages/toast-ui.grid/src/dispatch/data.ts b/packages/toast-ui.grid/src/dispatch/data.ts index 796248254..fe442c0c9 100644 --- a/packages/toast-ui.grid/src/dispatch/data.ts +++ b/packages/toast-ui.grid/src/dispatch/data.ts @@ -341,7 +341,7 @@ export function setColumnValues( ) { if (checkCellState) { // @TODO: find more practical way to make observable - createObservableData(store, true); + createObservableData(store, { allRowRange: true }); } const { id, data, column } = store; data.rawData.forEach((targetRow, index) => { diff --git a/packages/toast-ui.grid/src/dispatch/keyboard.ts b/packages/toast-ui.grid/src/dispatch/keyboard.ts index a582b65d6..499d760ff 100644 --- a/packages/toast-ui.grid/src/dispatch/keyboard.ts +++ b/packages/toast-ui.grid/src/dispatch/keyboard.ts @@ -16,6 +16,7 @@ import { mapProp } from '../helper/common'; import { CellChange, Origin } from '@t/event'; import { updateAllSummaryValues } from './summary'; import { appendRows, updateHeights } from './data'; +import { createObservableData } from './lazyObservable'; type ChangeValueFn = () => number; interface ChangeInfo { @@ -261,6 +262,8 @@ export function paste(store: Store, copiedData: string[][]) { ); } + // Materialize the entire target range before filtered data snapshots are captured. + createObservableData(store, { rowRange: [rangeToPaste.row[0], endRowIndex + 1] }); applyCopiedData(store, copiedData, rangeToPaste); changeSelectionRange(selection, rangeToPaste, id); } diff --git a/packages/toast-ui.grid/src/dispatch/lazyObservable.ts b/packages/toast-ui.grid/src/dispatch/lazyObservable.ts index 51a2052a8..808d3f8c3 100644 --- a/packages/toast-ui.grid/src/dispatch/lazyObservable.ts +++ b/packages/toast-ui.grid/src/dispatch/lazyObservable.ts @@ -9,6 +9,11 @@ import { findRowByRowKey, findIndexByRowKey } from '../query/data'; import { createTreeRawRow } from '../store/helper/tree'; import { silentSplice } from '../helper/common'; +interface ObservableDataOptions { + allRowRange?: boolean; + rowRange?: Range; +} + function getDataToBeObservable( acc: OriginData, row: Row, @@ -117,8 +122,12 @@ export function fillMissingColumnData(column: Column, rawData: Row[]) { } } -export function createObservableData({ column, data, viewport, id }: Store, allRowRange = false) { - const rowRange: Range = allRowRange ? [0, data.rawData.length] : viewport.rowRange; +export function createObservableData( + { column, data, viewport, id }: Store, + { allRowRange = false, rowRange: targetRowRange }: ObservableDataOptions = {} +) { + const rowRange: Range = + targetRowRange ?? (allRowRange ? [0, data.rawData.length] : viewport.rowRange); const { treeColumnName } = column; const originData = data.filters && !allRowRange diff --git a/packages/toast-ui.grid/src/query/data.ts b/packages/toast-ui.grid/src/query/data.ts index 6cc0394b6..5d170198e 100644 --- a/packages/toast-ui.grid/src/query/data.ts +++ b/packages/toast-ui.grid/src/query/data.ts @@ -37,7 +37,7 @@ export function getCellAddressByIndex( export function isEditableCell(store: Store, rowIndex: number, columnName: string) { const { data, column } = store; - const { filteredIndex, filteredViewData } = data; + const { filteredIndex } = data; if (filteredIndex && isNil(filteredIndex[rowIndex])) { return false; @@ -47,7 +47,7 @@ export function isEditableCell(store: Store, rowIndex: number, columnName: strin const index = filteredIndex ? filteredIndex[rowIndex] : rowIndex; makeObservable({ store, rowIndex: index, silent: true }); - const { disabled, editable } = filteredViewData[rowIndex].valueMap[columnName]; + const { disabled, editable } = data.viewData[index].valueMap[columnName]; return !isHiddenColumn(column, columnName) && editable && !disabled; }