@@ -13,13 +13,19 @@ const RESIZE_ROW = 'resize_row';
1313const RESIZE_ROW_END = 'resize_row_end' ;
1414const RESIZE_COLUMN = 'resize_column' ;
1515const RESIZE_COLUMN_END = 'resize_column_end' ;
16+ const INITIALIZED = 'initialized' ;
17+ const MERGE_CELLS = 'merge_cells' ;
18+ const UNMERGE_CELLS = 'unmerge_cells' ;
1619
1720jest . mock ( '@visactor/vtable' , ( ) => ( {
1821 TABLE_EVENT_TYPE : {
22+ INITIALIZED ,
1923 BEFORE_KEYDOWN ,
2024 CHANGE_CELL_VALUE ,
2125 CHANGE_CELL_VALUES ,
2226 PASTED_DATA : 'pasted_data' ,
27+ MERGE_CELLS ,
28+ UNMERGE_CELLS ,
2329 ADD_RECORD ,
2430 DELETE_RECORD ,
2531 UPDATE_RECORD ,
@@ -112,31 +118,139 @@ function createTableStub(env: any) {
112118 const changeCellValueCalls : any [ ] = [ ] ;
113119 const rowHeights = new Map < number , number > ( ) ;
114120 const colWidths = new Map < number , number > ( ) ;
121+ const updateCellContentCalls : any [ ] = [ ] ;
122+
123+ const getCustomMergeCellFunc = ( customMergeCell : any ) => {
124+ if ( typeof customMergeCell === 'function' ) {
125+ return customMergeCell ;
126+ }
127+ if ( Array . isArray ( customMergeCell ) ) {
128+ return ( col : number , row : number ) => {
129+ return customMergeCell . find ( item => {
130+ return (
131+ item . range . start . col <= col &&
132+ item . range . end . col >= col &&
133+ item . range . start . row <= row &&
134+ item . range . end . row >= row
135+ ) ;
136+ } ) ;
137+ } ;
138+ }
139+ return undefined ;
140+ } ;
141+
142+ const shiftRowHeightsOnInsert = ( rowIndex : number , count : number ) => {
143+ const next = new Map < number , number > ( ) ;
144+ rowHeights . forEach ( ( h , k ) => {
145+ next . set ( k >= rowIndex ? k + count : k , h ) ;
146+ } ) ;
147+ rowHeights . clear ( ) ;
148+ next . forEach ( ( v , k ) => rowHeights . set ( k , v ) ) ;
149+ } ;
150+
151+ const shiftRowHeightsOnDelete = ( rowIndex : number , count : number ) => {
152+ const sorted = Array . from ( { length : count } , ( _ , i ) => rowIndex + i ) . sort ( ( a , b ) => b - a ) ;
153+ sorted . forEach ( ri => {
154+ const next = new Map < number , number > ( ) ;
155+ rowHeights . forEach ( ( h , k ) => {
156+ if ( k === ri ) {
157+ return ;
158+ }
159+ next . set ( k > ri ? k - 1 : k , h ) ;
160+ } ) ;
161+ rowHeights . clear ( ) ;
162+ next . forEach ( ( v , k ) => rowHeights . set ( k , v ) ) ;
163+ } ) ;
164+ } ;
115165
116166 const table : any = {
117167 __vtableSheet : env . vtableSheet ,
118168 options : { columns : [ ] as any [ ] } ,
119169 records : [ ] as any [ ] ,
170+ transpose : false ,
171+ columnHeaderLevelCount : 1 ,
172+ rowHeaderLevelCount : 1 ,
173+ internalProps : {
174+ _heightResizedRowMap : new Set < number > ( ) ,
175+ _widthResizedColMap : new Set < number > ( ) ,
176+ customMergeCell : undefined
177+ } ,
178+ scenegraph : {
179+ updateCellContent : ( col : number , row : number ) => updateCellContentCalls . push ( [ col , row ] ) ,
180+ updateNextFrame : jest . fn ( )
181+ } ,
120182 editorManager : { editingEditor : null } ,
121183 changeCellValue : ( ...args : any [ ] ) => changeCellValueCalls . push ( args ) ,
122184 getCellOriginValue : ( _col : number , _row : number ) : any => undefined ,
185+ getCellValue : ( col : number , row : number ) => `${ col } ,${ row } ` ,
123186 getRowHeight : ( row : number ) => rowHeights . get ( row ) ?? 20 ,
124- setRowHeight : ( row : number , height : number ) => rowHeights . set ( row , height ) ,
187+ setRowHeight : ( row : number , height : number ) => {
188+ rowHeights . set ( row , height ) ;
189+ table . internalProps . _heightResizedRowMap . add ( row ) ;
190+ } ,
125191 getColWidth : ( col : number ) => colWidths . get ( col ) ?? 80 ,
126- setColWidth : ( col : number , width : number ) => colWidths . set ( col , width ) ,
192+ setColWidth : ( col : number , width : number ) => {
193+ colWidths . set ( col , width ) ;
194+ table . internalProps . _widthResizedColMap . add ( col ) ;
195+ } ,
196+ mergeCells : ( startCol : number , startRow : number , endCol : number , endRow : number ) => {
197+ if ( ! table . options . customMergeCell ) {
198+ table . options . customMergeCell = [ ] ;
199+ } else if ( typeof table . options . customMergeCell === 'function' ) {
200+ table . options . customMergeCell = [ ] ;
201+ }
202+ table . options . customMergeCell . push ( {
203+ text : table . getCellValue ( startCol , startRow ) ,
204+ range : {
205+ start : { col : startCol , row : startRow } ,
206+ end : { col : endCol , row : endRow }
207+ }
208+ } ) ;
209+ table . internalProps . customMergeCell = getCustomMergeCellFunc ( table . options . customMergeCell ) ;
210+ for ( let i = startCol ; i <= endCol ; i ++ ) {
211+ for ( let j = startRow ; j <= endRow ; j ++ ) {
212+ table . scenegraph . updateCellContent ( i , j ) ;
213+ }
214+ }
215+ table . scenegraph . updateNextFrame ( ) ;
216+ } ,
217+ unmergeCells : ( startCol : number , startRow : number , endCol : number , endRow : number ) => {
218+ if ( ! table . options . customMergeCell ) {
219+ table . options . customMergeCell = [ ] ;
220+ } else if ( typeof table . options . customMergeCell === 'function' ) {
221+ table . options . customMergeCell = [ ] ;
222+ }
223+ table . options . customMergeCell = table . options . customMergeCell . filter ( ( item : any ) => {
224+ const { start, end } = item . range ;
225+ return ! ( start . col === startCol && start . row === startRow && end . col === endCol && end . row === endRow ) ;
226+ } ) ;
227+ table . internalProps . customMergeCell = getCustomMergeCellFunc ( table . options . customMergeCell ) ;
228+ for ( let i = startCol ; i <= endCol ; i ++ ) {
229+ for ( let j = startRow ; j <= endRow ; j ++ ) {
230+ table . scenegraph . updateCellContent ( i , j ) ;
231+ }
232+ }
233+ table . scenegraph . updateNextFrame ( ) ;
234+ } ,
127235 addRecords : ( records : any [ ] , index ?: number ) => {
128236 if ( typeof index === 'number' ) {
237+ shiftRowHeightsOnInsert ( index + table . columnHeaderLevelCount , records . length ) ;
129238 table . records . splice ( index , 0 , ...records ) ;
130239 } else {
131240 table . records . push ( ...records ) ;
132241 }
133242 } ,
134243 addRecord : ( record : any , index : number ) => {
244+ shiftRowHeightsOnInsert ( index + table . columnHeaderLevelCount , 1 ) ;
135245 table . records . splice ( index , 0 , record ) ;
136246 } ,
137247 deleteRecords : ( indexs : number [ ] ) => {
138248 const sorted = indexs . slice ( ) . sort ( ( a , b ) => b - a ) ;
139- sorted . forEach ( i => table . records . splice ( i , 1 ) ) ;
249+ sorted . forEach ( i => {
250+ shiftRowHeightsOnDelete ( i + table . columnHeaderLevelCount , 1 ) ;
251+ table . internalProps . _heightResizedRowMap . delete ( i + table . columnHeaderLevelCount ) ;
252+ table . records . splice ( i , 1 ) ;
253+ } ) ;
140254 } ,
141255 updateRecords : ( records : any [ ] , indexs : number [ ] ) => {
142256 indexs . forEach ( ( idx , i ) => {
@@ -149,7 +263,7 @@ function createTableStub(env: any) {
149263 } ;
150264
151265 env . worksheet . tableInstance = table ;
152- return { table, changeCellValueCalls, rowHeights, colWidths } ;
266+ return { table, changeCellValueCalls, rowHeights, colWidths, updateCellContentCalls } ;
153267}
154268
155269function initPlugin ( plugin : any , table : any ) {
@@ -207,6 +321,153 @@ test('cell compression keeps latest newContent for same cell', () => {
207321 expect ( changeCellValueCalls [ changeCellValueCalls . length - 1 ] [ 2 ] ) . toBe ( 'B' ) ;
208322} ) ;
209323
324+ test ( 'change_cell_values does not push when content unchanged or empty cleared' , ( ) => {
325+ const env = createVTableSheetEnv ( ) ;
326+ const { table } = createTableStub ( env ) ;
327+ const plugin = new HistoryPlugin ( ) ;
328+ initPlugin ( plugin , table ) ;
329+
330+ plugin . run ( { row : 9 , col : 5 , currentValue : undefined } as any , CHANGE_CELL_VALUE , table ) ;
331+ plugin . run (
332+ { values : [ { row : 9 , col : 5 , currentValue : undefined , changedValue : '' } ] } as any ,
333+ CHANGE_CELL_VALUES ,
334+ table
335+ ) ;
336+
337+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 0 ) ;
338+ } ) ;
339+
340+ test ( 'resize_row_end does not push when height unchanged' , ( ) => {
341+ const env = createVTableSheetEnv ( ) ;
342+ const { table } = createTableStub ( env ) ;
343+ const plugin = new HistoryPlugin ( ) ;
344+ initPlugin ( plugin , table ) ;
345+
346+ plugin . run ( { row : 2 } as any , RESIZE_ROW , table ) ;
347+ plugin . run ( { row : 2 , rowHeight : 20 } as any , RESIZE_ROW_END , table ) ;
348+
349+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 0 ) ;
350+ } ) ;
351+
352+ test ( 'resize_column_end does not push when width unchanged' , ( ) => {
353+ const env = createVTableSheetEnv ( ) ;
354+ const { table } = createTableStub ( env ) ;
355+ const plugin = new HistoryPlugin ( ) ;
356+ initPlugin ( plugin , table ) ;
357+
358+ plugin . run ( { col : 2 } as any , RESIZE_COLUMN , table ) ;
359+ plugin . run ( { col : 2 , colWidths : [ 80 , 80 , 80 ] } as any , RESIZE_COLUMN_END , table ) ;
360+
361+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 0 ) ;
362+ } ) ;
363+
364+ test ( 'merge_cells pushes command and undo/redo restores merge config' , ( ) => {
365+ const env = createVTableSheetEnv ( ) ;
366+ const { table, updateCellContentCalls } = createTableStub ( env ) ;
367+ const plugin = new HistoryPlugin ( ) ;
368+ initPlugin ( plugin , table ) ;
369+
370+ table . mergeCells ( 0 , 1 , 1 , 2 ) ;
371+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
372+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 1 ) ;
373+ expect ( table . options . customMergeCell . length ) . toBe ( 1 ) ;
374+
375+ plugin . undo ( ) ;
376+ expect ( table . options . customMergeCell ) . toBeUndefined ( ) ;
377+ expect ( updateCellContentCalls . length ) . toBeGreaterThan ( 0 ) ;
378+
379+ plugin . redo ( ) ;
380+ expect ( Array . isArray ( table . options . customMergeCell ) ) . toBe ( true ) ;
381+ expect ( table . options . customMergeCell . length ) . toBe ( 1 ) ;
382+ } ) ;
383+
384+ test ( 'unmerge_cells pushes command and undo restores previous merge' , ( ) => {
385+ const env = createVTableSheetEnv ( ) ;
386+ const { table } = createTableStub ( env ) ;
387+ const plugin = new HistoryPlugin ( ) ;
388+ initPlugin ( plugin , table ) ;
389+
390+ table . mergeCells ( 0 , 1 , 1 , 2 ) ;
391+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
392+ table . unmergeCells ( 0 , 1 , 1 , 2 ) ;
393+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , UNMERGE_CELLS , table ) ;
394+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 2 ) ;
395+ expect ( table . options . customMergeCell . length ) . toBe ( 0 ) ;
396+
397+ plugin . undo ( ) ;
398+ expect ( table . options . customMergeCell . length ) . toBe ( 1 ) ;
399+ } ) ;
400+
401+ test ( 'merge_cells does not push when merge config unchanged' , ( ) => {
402+ const env = createVTableSheetEnv ( ) ;
403+ const table : any = {
404+ __vtableSheet : env . vtableSheet ,
405+ options : { columns : [ ] as any [ ] } ,
406+ internalProps : { } ,
407+ mergeCells : ( ) => { } ,
408+ unmergeCells : ( ) => { } ,
409+ editorManager : { editingEditor : null }
410+ } ;
411+ env . worksheet . tableInstance = table ;
412+
413+ const plugin = new HistoryPlugin ( ) ;
414+ initPlugin ( plugin , table ) ;
415+
416+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
417+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 0 ) ;
418+ } ) ;
419+
420+ test ( 'merge_cells does not push when customMergeCell function unchanged' , ( ) => {
421+ const env = createVTableSheetEnv ( ) ;
422+ const { table } = createTableStub ( env ) ;
423+ const plugin = new HistoryPlugin ( ) ;
424+
425+ const fn = ( _col : number , _row : number ) : any => undefined ;
426+ table . options . customMergeCell = fn ;
427+ table . internalProps . customMergeCell = fn ;
428+ initPlugin ( plugin , table ) ;
429+
430+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
431+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 0 ) ;
432+ } ) ;
433+
434+ test ( 'merge_cells pushes when merge config differs in range structure' , ( ) => {
435+ const env = createVTableSheetEnv ( ) ;
436+ const { table } = createTableStub ( env ) ;
437+ const plugin = new HistoryPlugin ( ) ;
438+
439+ initPlugin ( plugin , table ) ;
440+ ( plugin as any ) . prevMergeSnapshot = [ { text : 'x' } ] ;
441+
442+ table . options . customMergeCell = [
443+ {
444+ text : 'x' ,
445+ range : { start : { col : 0 , row : 1 } , end : { col : 1 , row : 2 } }
446+ }
447+ ] ;
448+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
449+ expect ( ( plugin as any ) . undoStack . length ) . toBe ( 1 ) ;
450+ } ) ;
451+
452+ test ( 'merge_cells restores old customMergeCell function on undo' , ( ) => {
453+ const env = createVTableSheetEnv ( ) ;
454+ const { table } = createTableStub ( env ) ;
455+ const plugin = new HistoryPlugin ( ) ;
456+
457+ const fn = ( _col : number , _row : number ) : any => undefined ;
458+ table . options . customMergeCell = fn ;
459+ table . internalProps . customMergeCell = fn ;
460+ initPlugin ( plugin , table ) ;
461+
462+ table . mergeCells ( 0 , 1 , 1 , 2 ) ;
463+ plugin . run ( { startCol : 0 , startRow : 1 , endCol : 1 , endRow : 2 } as any , MERGE_CELLS , table ) ;
464+ expect ( Array . isArray ( table . options . customMergeCell ) ) . toBe ( true ) ;
465+
466+ plugin . undo ( ) ;
467+ expect ( table . options . customMergeCell ) . toBe ( fn ) ;
468+ expect ( table . internalProps . customMergeCell ) . toBe ( fn ) ;
469+ } ) ;
470+
210471test ( 'before_keydown triggers undo/redo with ctrl+z / ctrl+y' , ( ) => {
211472 const env = createVTableSheetEnv ( ) ;
212473 const { table } = createTableStub ( env ) ;
@@ -313,6 +574,51 @@ test('delete_record undo restores correct order when deleting multiple rows', ()
313574 expect ( table . records . map ( ( r : any ) => r . name ) ) . toEqual ( [ 'Alice' , 'Bob' , 'Carol' , 'David' , 'Eve' ] ) ;
314575} ) ;
315576
577+ test ( 'delete_record undo restores resized row height' , ( ) => {
578+ const env = createVTableSheetEnv ( ) ;
579+ const { table } = createTableStub ( env ) ;
580+ const plugin = new HistoryPlugin ( ) ;
581+ initPlugin ( plugin , table ) ;
582+
583+ table . records = [ { a : 1 } , { a : 2 } , { a : 3 } , { a : 4 } ] ;
584+
585+ plugin . run ( { row : 3 } as any , RESIZE_ROW , table ) ;
586+ table . setRowHeight ( 3 , 50 ) ;
587+ plugin . run ( { row : 3 , rowHeight : 50 } as any , RESIZE_ROW_END , table ) ;
588+
589+ plugin . run ( { recordIndexs : [ 2 ] , records : [ { a : 3 } ] , rowIndexs : [ 3 ] , deletedCount : 1 } as any , DELETE_RECORD , table ) ;
590+ table . deleteRecords ( [ 2 ] ) ;
591+ expect ( table . getRowHeight ( 3 ) ) . toBe ( 20 ) ;
592+
593+ plugin . undo ( ) ;
594+ expect ( table . getRowHeight ( 3 ) ) . toBe ( 50 ) ;
595+ } ) ;
596+
597+ test ( 'delete_column undo restores resized col width' , ( ) => {
598+ const env = createVTableSheetEnv ( ) ;
599+ const { table, colWidths } = createTableStub ( env ) ;
600+ const plugin = new HistoryPlugin ( ) ;
601+
602+ table . options . columns = [ { field : 'a' } , { field : 'b' } , { field : 'c' } ] ;
603+ initPlugin ( plugin , table ) ;
604+
605+ plugin . run ( { col : 1 } as any , RESIZE_COLUMN , table ) ;
606+ table . setColWidth ( 1 , 120 ) ;
607+ plugin . run ( { col : 1 , colWidths : [ 80 , 120 , 80 ] } as any , RESIZE_COLUMN_END , table ) ;
608+
609+ table . internalProps . _widthResizedColMap . delete ( 1 ) ;
610+ colWidths . delete ( 1 ) ;
611+
612+ plugin . run (
613+ { deleteColIndexs : [ 1 ] , columns : table . options . columns , deletedColumns : [ table . options . columns [ 1 ] ] } as any ,
614+ DELETE_COLUMN ,
615+ table
616+ ) ;
617+ plugin . undo ( ) ;
618+
619+ expect ( table . getColWidth ( 1 ) ) . toBe ( 120 ) ;
620+ } ) ;
621+
316622test ( 'update_record undo restores previous snapshot values' , ( ) => {
317623 const env = createVTableSheetEnv ( ) ;
318624 const { table } = createTableStub ( env ) ;
0 commit comments