Skip to content

Commit 95f45b8

Browse files
committed
feat: merge cell undo/redo
1 parent 488cdae commit 95f45b8

File tree

19 files changed

+1737
-162
lines changed

19 files changed

+1737
-162
lines changed

.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
// Eslint
33
"eslint.format.enable": true,
44
"eslint.validate": ["typescript", "javascript", "javascriptreact", "typescriptreact"],
5+
"eslint.workingDirectories": [
6+
{ "mode": "auto" }
7+
],
8+
"eslint.nodePath": "common/autoinstallers/lint/node_modules",
59
// Formatter
610
"javascript.format.enable": false,
711
"typescript.format.enable": false,

packages/vtable-plugins/__tests__/history/history-plugin.test.ts

Lines changed: 310 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ const RESIZE_ROW = 'resize_row';
1313
const RESIZE_ROW_END = 'resize_row_end';
1414
const RESIZE_COLUMN = 'resize_column';
1515
const RESIZE_COLUMN_END = 'resize_column_end';
16+
const INITIALIZED = 'initialized';
17+
const MERGE_CELLS = 'merge_cells';
18+
const UNMERGE_CELLS = 'unmerge_cells';
1619

1720
jest.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

155269
function 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+
210471
test('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+
316622
test('update_record undo restores previous snapshot values', () => {
317623
const env = createVTableSheetEnv();
318624
const { table } = createTableStub(env);

0 commit comments

Comments
 (0)