@lotics/xlsx 0.1.0
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/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- package/src/xml_entities.ts +23 -0
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Clipboard — Copy/Cut/Paste for Spreadsheet
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Handles copy, cut, and paste operations:
|
|
6
|
+
// - Stores cell data in an internal buffer for paste
|
|
7
|
+
// - Generates plain text and HTML for system clipboard
|
|
8
|
+
// - Adjusts formula references on paste (relative shift)
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
import type { CellValue, WorkbookModel } from "./workbook_model";
|
|
12
|
+
import { rowColToRef, colNumToLetters, colLettersToNum } from "./excel_utils";
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export interface ClipboardData {
|
|
19
|
+
/** Source sheet index. */
|
|
20
|
+
sourceSheet: number;
|
|
21
|
+
/** Source range (top-left). */
|
|
22
|
+
origin: { row: number; col: number };
|
|
23
|
+
/** Cell data grid (rows of cols). Undefined entries are empty cells. */
|
|
24
|
+
cells: (ClipboardCell | undefined)[][];
|
|
25
|
+
/** Number of rows in the selection. */
|
|
26
|
+
rowCount: number;
|
|
27
|
+
/** Number of columns in the selection. */
|
|
28
|
+
colCount: number;
|
|
29
|
+
/** Whether this was a cut (vs copy). Cut clears source on paste. */
|
|
30
|
+
isCut: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClipboardCell {
|
|
34
|
+
value: CellValue;
|
|
35
|
+
displayValue: string;
|
|
36
|
+
formula?: string;
|
|
37
|
+
styleIndex: number;
|
|
38
|
+
numFmtCode: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Copy
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
export function copyRange(
|
|
46
|
+
workbook: WorkbookModel,
|
|
47
|
+
sheetIndex: number,
|
|
48
|
+
startRow: number,
|
|
49
|
+
startCol: number,
|
|
50
|
+
endRow: number,
|
|
51
|
+
endCol: number,
|
|
52
|
+
isCut = false,
|
|
53
|
+
): ClipboardData {
|
|
54
|
+
const sheet = workbook.sheets[sheetIndex];
|
|
55
|
+
const rowCount = endRow - startRow + 1;
|
|
56
|
+
const colCount = endCol - startCol + 1;
|
|
57
|
+
const cells: (ClipboardCell | undefined)[][] = [];
|
|
58
|
+
|
|
59
|
+
for (let r = 0; r < rowCount; r++) {
|
|
60
|
+
const row: (ClipboardCell | undefined)[] = [];
|
|
61
|
+
for (let c = 0; c < colCount; c++) {
|
|
62
|
+
const ref = rowColToRef(startRow + r, startCol + c);
|
|
63
|
+
const cell = sheet.getCell(ref);
|
|
64
|
+
if (cell) {
|
|
65
|
+
row.push({
|
|
66
|
+
value: cell.value,
|
|
67
|
+
displayValue: cell.displayValue,
|
|
68
|
+
formula: cell.formula,
|
|
69
|
+
styleIndex: cell.styleIndex,
|
|
70
|
+
numFmtCode: cell.numFmtCode,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
row.push(undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
cells.push(row);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sourceSheet: sheetIndex,
|
|
81
|
+
origin: { row: startRow, col: startCol },
|
|
82
|
+
cells,
|
|
83
|
+
rowCount,
|
|
84
|
+
colCount,
|
|
85
|
+
isCut,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Paste
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
export interface PasteResult {
|
|
94
|
+
/** Cells that were written. Array of { ref, value, formula, styleIndex }. */
|
|
95
|
+
written: Array<{ ref: string; value: CellValue; formula?: string; styleIndex: number }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function pasteRange(
|
|
99
|
+
workbook: WorkbookModel,
|
|
100
|
+
clipboard: ClipboardData,
|
|
101
|
+
targetSheet: number,
|
|
102
|
+
targetRow: number,
|
|
103
|
+
targetCol: number,
|
|
104
|
+
): PasteResult {
|
|
105
|
+
const sheet = workbook.sheets[targetSheet];
|
|
106
|
+
const written: PasteResult["written"] = [];
|
|
107
|
+
|
|
108
|
+
const dRow = targetRow - clipboard.origin.row;
|
|
109
|
+
const dCol = targetCol - clipboard.origin.col;
|
|
110
|
+
|
|
111
|
+
for (let r = 0; r < clipboard.rowCount; r++) {
|
|
112
|
+
for (let c = 0; c < clipboard.colCount; c++) {
|
|
113
|
+
const src = clipboard.cells[r][c];
|
|
114
|
+
if (!src) continue;
|
|
115
|
+
|
|
116
|
+
const ref = rowColToRef(targetRow + r, targetCol + c);
|
|
117
|
+
let formula = src.formula;
|
|
118
|
+
|
|
119
|
+
// Adjust formula references for relative paste
|
|
120
|
+
if (formula && !clipboard.isCut) {
|
|
121
|
+
formula = adjustFormulaRefs(formula, dRow, dCol);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
sheet.set(ref, src.value, sheet.styles?.get(src.styleIndex), src.numFmtCode, formula);
|
|
125
|
+
written.push({ ref, value: src.value, formula, styleIndex: src.styleIndex });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { written };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =============================================================================
|
|
133
|
+
// Plain text export (for system clipboard)
|
|
134
|
+
// =============================================================================
|
|
135
|
+
|
|
136
|
+
export function clipboardToText(clipboard: ClipboardData): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
for (const row of clipboard.cells) {
|
|
139
|
+
const values = row.map((c) => c?.displayValue ?? "");
|
|
140
|
+
lines.push(values.join("\t"));
|
|
141
|
+
}
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// HTML export (for rich paste)
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
export function clipboardToHtml(clipboard: ClipboardData, styles: WorkbookModel["styles"]): string {
|
|
150
|
+
let html = "<table>";
|
|
151
|
+
for (const row of clipboard.cells) {
|
|
152
|
+
html += "<tr>";
|
|
153
|
+
for (const cell of row) {
|
|
154
|
+
if (!cell) {
|
|
155
|
+
html += "<td></td>";
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const style = styles.get(cell.styleIndex);
|
|
159
|
+
let css = "";
|
|
160
|
+
if (style.fontBold) css += "font-weight:bold;";
|
|
161
|
+
if (style.fontItalic) css += "font-style:italic;";
|
|
162
|
+
if (style.fontColor) css += `color:${style.fontColor};`;
|
|
163
|
+
if (style.backgroundColor) css += `background-color:${style.backgroundColor};`;
|
|
164
|
+
if (style.fontSize) css += `font-size:${style.fontSize}pt;`;
|
|
165
|
+
html += `<td${css ? ` style="${css}"` : ""}>${escapeHtml(cell.displayValue)}</td>`;
|
|
166
|
+
}
|
|
167
|
+
html += "</tr>";
|
|
168
|
+
}
|
|
169
|
+
html += "</table>";
|
|
170
|
+
return html;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Formula reference adjustment
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
/** Adjust cell references in a formula by the given row/col offsets. */
|
|
178
|
+
export function adjustFormulaRefs(formula: string, dRow: number, dCol: number): string {
|
|
179
|
+
// Match cell references like A1, $A1, A$1, $A$1, Sheet1!A1
|
|
180
|
+
return formula.replace(
|
|
181
|
+
/(\$?)([A-Z]+)(\$?)(\d+)/g,
|
|
182
|
+
(_match, colAbsolute: string, colLetters: string, rowAbsolute: string, rowStr: string) => {
|
|
183
|
+
const colNum = colLettersToNum(colLetters);
|
|
184
|
+
const rowNum = parseInt(rowStr, 10);
|
|
185
|
+
|
|
186
|
+
const newCol = colAbsolute ? colNum : colNum + dCol;
|
|
187
|
+
const newRow = rowAbsolute ? rowNum : rowNum + dRow;
|
|
188
|
+
|
|
189
|
+
if (newCol < 1 || newRow < 1) return "#REF!";
|
|
190
|
+
|
|
191
|
+
return `${colAbsolute}${colNumToLetters(newCol)}${rowAbsolute}${newRow}`;
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Adjust formula references for row insert/delete.
|
|
198
|
+
* Shifts row references >= `atRow` by `delta` (positive = insert, negative = delete).
|
|
199
|
+
* References to deleted rows become #REF!.
|
|
200
|
+
*/
|
|
201
|
+
export function adjustFormulaRefsForRowShift(
|
|
202
|
+
formula: string,
|
|
203
|
+
atRow: number,
|
|
204
|
+
delta: number,
|
|
205
|
+
): string {
|
|
206
|
+
return formula.replace(
|
|
207
|
+
/(\$?)([A-Z]+)(\$?)(\d+)/g,
|
|
208
|
+
(_match, colAbs: string, colLetters: string, rowAbs: string, rowStr: string) => {
|
|
209
|
+
const rowNum = parseInt(rowStr, 10);
|
|
210
|
+
if (rowNum < atRow) return _match; // unaffected
|
|
211
|
+
const newRow = rowNum + delta;
|
|
212
|
+
if (newRow < 1) return "#REF!";
|
|
213
|
+
return `${colAbs}${colLetters}${rowAbs}${newRow}`;
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Adjust formula references for column insert/delete.
|
|
220
|
+
* Shifts column references >= `atCol` by `delta`.
|
|
221
|
+
*/
|
|
222
|
+
export function adjustFormulaRefsForColShift(
|
|
223
|
+
formula: string,
|
|
224
|
+
atCol: number,
|
|
225
|
+
delta: number,
|
|
226
|
+
): string {
|
|
227
|
+
return formula.replace(
|
|
228
|
+
/(\$?)([A-Z]+)(\$?)(\d+)/g,
|
|
229
|
+
(_match, colAbs: string, colLetters: string, rowAbs: string, rowStr: string) => {
|
|
230
|
+
const colNum = colLettersToNum(colLetters);
|
|
231
|
+
if (colNum < atCol) return _match; // unaffected
|
|
232
|
+
const newCol = colNum + delta;
|
|
233
|
+
if (newCol < 1) return "#REF!";
|
|
234
|
+
return `${colAbs}${colNumToLetters(newCol)}${rowAbs}${rowStr}`;
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// =============================================================================
|
|
240
|
+
// Paste Special
|
|
241
|
+
// =============================================================================
|
|
242
|
+
|
|
243
|
+
export type PasteMode = "all" | "values" | "formulas" | "formats" | "transpose";
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Paste with a specific mode:
|
|
247
|
+
* - "all" — same as regular paste (values + formulas + styles)
|
|
248
|
+
* - "values" — paste only values (no formulas, keep target formatting)
|
|
249
|
+
* - "formulas" — paste values + formulas (no styles)
|
|
250
|
+
* - "formats" — paste only styles (no values)
|
|
251
|
+
* - "transpose" — swap rows/cols (values + formulas + styles)
|
|
252
|
+
*/
|
|
253
|
+
export function pasteSpecial(
|
|
254
|
+
workbook: WorkbookModel,
|
|
255
|
+
clipboard: ClipboardData,
|
|
256
|
+
targetSheet: number,
|
|
257
|
+
targetRow: number,
|
|
258
|
+
targetCol: number,
|
|
259
|
+
mode: PasteMode,
|
|
260
|
+
): PasteResult {
|
|
261
|
+
if (mode === "all") {
|
|
262
|
+
return pasteRange(workbook, clipboard, targetSheet, targetRow, targetCol);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sheet = workbook.sheets[targetSheet];
|
|
266
|
+
const written: PasteResult["written"] = [];
|
|
267
|
+
|
|
268
|
+
const dRow = targetRow - clipboard.origin.row;
|
|
269
|
+
const dCol = targetCol - clipboard.origin.col;
|
|
270
|
+
|
|
271
|
+
const rowCount = mode === "transpose" ? clipboard.colCount : clipboard.rowCount;
|
|
272
|
+
const colCount = mode === "transpose" ? clipboard.rowCount : clipboard.colCount;
|
|
273
|
+
|
|
274
|
+
for (let r = 0; r < rowCount; r++) {
|
|
275
|
+
for (let c = 0; c < colCount; c++) {
|
|
276
|
+
// For transpose, swap source row/col indices
|
|
277
|
+
const srcR = mode === "transpose" ? c : r;
|
|
278
|
+
const srcC = mode === "transpose" ? r : c;
|
|
279
|
+
const src = clipboard.cells[srcR][srcC];
|
|
280
|
+
|
|
281
|
+
const ref = rowColToRef(targetRow + r, targetCol + c);
|
|
282
|
+
|
|
283
|
+
if (mode === "values") {
|
|
284
|
+
// Paste only computed values — no formulas, keep target cell's style
|
|
285
|
+
if (!src) continue;
|
|
286
|
+
const existingCell = sheet.getCell(ref);
|
|
287
|
+
const styleIndex = existingCell?.styleIndex ?? 0;
|
|
288
|
+
const numFmtCode = existingCell?.numFmtCode ?? "General";
|
|
289
|
+
sheet.set(ref, src.value, sheet.styles?.get(styleIndex), numFmtCode);
|
|
290
|
+
written.push({ ref, value: src.value, formula: undefined, styleIndex });
|
|
291
|
+
} else if (mode === "formulas") {
|
|
292
|
+
// Paste values + formulas, but keep target cell's style
|
|
293
|
+
if (!src) continue;
|
|
294
|
+
const existingCell = sheet.getCell(ref);
|
|
295
|
+
const styleIndex = existingCell?.styleIndex ?? 0;
|
|
296
|
+
const numFmtCode = existingCell?.numFmtCode ?? "General";
|
|
297
|
+
let formula = src.formula;
|
|
298
|
+
if (formula && !clipboard.isCut) {
|
|
299
|
+
formula = adjustFormulaRefs(formula, dRow, dCol);
|
|
300
|
+
}
|
|
301
|
+
sheet.set(ref, src.value, sheet.styles?.get(styleIndex), numFmtCode, formula);
|
|
302
|
+
written.push({ ref, value: src.value, formula, styleIndex });
|
|
303
|
+
} else if (mode === "formats") {
|
|
304
|
+
// Paste only styles — keep target cell's value and formula
|
|
305
|
+
if (!src) continue;
|
|
306
|
+
const existingCell = sheet.getCell(ref);
|
|
307
|
+
const value = existingCell?.value ?? null;
|
|
308
|
+
const formula = existingCell?.formula;
|
|
309
|
+
sheet.set(ref, value, sheet.styles?.get(src.styleIndex), src.numFmtCode, formula);
|
|
310
|
+
written.push({ ref, value, formula, styleIndex: src.styleIndex });
|
|
311
|
+
} else {
|
|
312
|
+
// transpose: paste everything with swapped positions + adjusted refs
|
|
313
|
+
if (!src) continue;
|
|
314
|
+
let formula = src.formula;
|
|
315
|
+
if (formula && !clipboard.isCut) {
|
|
316
|
+
// Source cell was at (origin.row + srcR, origin.col + srcC),
|
|
317
|
+
// target is (targetRow + r, targetCol + c) where r=srcC, c=srcR.
|
|
318
|
+
// Per-cell delta accounts for the transposition.
|
|
319
|
+
const srcRow = clipboard.origin.row + srcR;
|
|
320
|
+
const srcCol = clipboard.origin.col + srcC;
|
|
321
|
+
const tgtRow = targetRow + r;
|
|
322
|
+
const tgtCol = targetCol + c;
|
|
323
|
+
formula = adjustFormulaRefs(formula, tgtRow - srcRow, tgtCol - srcCol);
|
|
324
|
+
}
|
|
325
|
+
sheet.set(ref, src.value, sheet.styles?.get(src.styleIndex), src.numFmtCode, formula);
|
|
326
|
+
written.push({ ref, value: src.value, formula, styleIndex: src.styleIndex });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { written };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// =============================================================================
|
|
335
|
+
// Helpers
|
|
336
|
+
// =============================================================================
|
|
337
|
+
|
|
338
|
+
function escapeHtml(s: string): string {
|
|
339
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
340
|
+
}
|
|
341
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Command History — Undo/Redo Stack
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Every spreadsheet mutation flows through the command pattern:
|
|
6
|
+
// - Each command knows how to execute and reverse itself
|
|
7
|
+
// - CommandHistory maintains an undo/redo stack
|
|
8
|
+
// - Maximum 100 entries (configurable)
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export interface Command {
|
|
16
|
+
/** Human-readable description for UI display. */
|
|
17
|
+
description: string;
|
|
18
|
+
/** Execute the command (apply the change). */
|
|
19
|
+
execute(): void;
|
|
20
|
+
/** Reverse the command (undo the change). */
|
|
21
|
+
undo(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// CommandHistory
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export class CommandHistory {
|
|
29
|
+
private undoStack: Command[] = [];
|
|
30
|
+
private redoStack: Command[] = [];
|
|
31
|
+
private maxSize: number;
|
|
32
|
+
|
|
33
|
+
constructor(maxSize = 100) {
|
|
34
|
+
this.maxSize = maxSize;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Execute a command and push it onto the undo stack. Clears the redo stack. */
|
|
38
|
+
execute(command: Command): void {
|
|
39
|
+
command.execute();
|
|
40
|
+
this.undoStack.push(command);
|
|
41
|
+
this.redoStack.length = 0;
|
|
42
|
+
|
|
43
|
+
// Trim undo stack if it exceeds max size
|
|
44
|
+
if (this.undoStack.length > this.maxSize) {
|
|
45
|
+
this.undoStack.shift();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Undo the last command. Returns the command description or undefined. */
|
|
50
|
+
undo(): string | undefined {
|
|
51
|
+
const command = this.undoStack.pop();
|
|
52
|
+
if (!command) return undefined;
|
|
53
|
+
command.undo();
|
|
54
|
+
this.redoStack.push(command);
|
|
55
|
+
return command.description;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Redo the last undone command. Returns the command description or undefined. */
|
|
59
|
+
redo(): string | undefined {
|
|
60
|
+
const command = this.redoStack.pop();
|
|
61
|
+
if (!command) return undefined;
|
|
62
|
+
command.execute();
|
|
63
|
+
this.undoStack.push(command);
|
|
64
|
+
return command.description;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether undo is available. */
|
|
68
|
+
get canUndo(): boolean {
|
|
69
|
+
return this.undoStack.length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether redo is available. */
|
|
73
|
+
get canRedo(): boolean {
|
|
74
|
+
return this.redoStack.length > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Description of the next undo action. */
|
|
78
|
+
get undoDescription(): string | undefined {
|
|
79
|
+
return this.undoStack[this.undoStack.length - 1]?.description;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Description of the next redo action. */
|
|
83
|
+
get redoDescription(): string | undefined {
|
|
84
|
+
return this.redoStack[this.redoStack.length - 1]?.description;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Clear all history. */
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.undoStack.length = 0;
|
|
90
|
+
this.redoStack.length = 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Number of undo entries. */
|
|
94
|
+
get undoSize(): number {
|
|
95
|
+
return this.undoStack.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Number of redo entries. */
|
|
99
|
+
get redoSize(): number {
|
|
100
|
+
return this.redoStack.length;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parseCsvText } from "./csv_parser";
|
|
2
|
+
|
|
3
|
+
describe("parseCsvText", () => {
|
|
4
|
+
test("parses basic comma-delimited CSV", () => {
|
|
5
|
+
const result = parseCsvText("Name,Age\nAlice,30\nBob,25");
|
|
6
|
+
|
|
7
|
+
expect(result.name).toBe("Sheet1");
|
|
8
|
+
expect(result.rows).toHaveLength(3);
|
|
9
|
+
expect(result.columns).toHaveLength(2);
|
|
10
|
+
|
|
11
|
+
expect(result.rows[0].cells[0].value).toBe("Name");
|
|
12
|
+
expect(result.rows[0].cells[1].value).toBe("Age");
|
|
13
|
+
expect(result.rows[1].cells[0].value).toBe("Alice");
|
|
14
|
+
expect(result.rows[1].cells[1].value).toBe("30");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("parses tab-delimited data", () => {
|
|
18
|
+
const result = parseCsvText("Name\tAge\nAlice\t30");
|
|
19
|
+
|
|
20
|
+
expect(result.rows).toHaveLength(2);
|
|
21
|
+
expect(result.rows[0].cells[0].value).toBe("Name");
|
|
22
|
+
expect(result.rows[0].cells[1].value).toBe("Age");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("handles quoted fields with commas", () => {
|
|
26
|
+
const result = parseCsvText('Name,Address\nAlice,"123 Main St, Apt 4"\nBob,"456 Oak Ave"');
|
|
27
|
+
|
|
28
|
+
expect(result.rows).toHaveLength(3);
|
|
29
|
+
expect(result.rows[1].cells[1].value).toBe("123 Main St, Apt 4");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("handles escaped quotes in quoted fields", () => {
|
|
33
|
+
const result = parseCsvText('Value\n"He said ""hello"""\n"Normal"');
|
|
34
|
+
|
|
35
|
+
expect(result.rows[1].cells[0].value).toBe('He said "hello"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("handles multiline quoted fields", () => {
|
|
39
|
+
const result = parseCsvText('A,B\n"line1\nline2",val');
|
|
40
|
+
|
|
41
|
+
expect(result.rows).toHaveLength(2);
|
|
42
|
+
expect(result.rows[1].cells[0].value).toBe("line1\nline2");
|
|
43
|
+
expect(result.rows[1].cells[1].value).toBe("val");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("handles \\r\\n line endings", () => {
|
|
47
|
+
const result = parseCsvText("A,B\r\n1,2\r\n3,4");
|
|
48
|
+
|
|
49
|
+
expect(result.rows).toHaveLength(3);
|
|
50
|
+
expect(result.rows[1].cells[0].value).toBe("1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("handles \\r line endings", () => {
|
|
54
|
+
const result = parseCsvText("A,B\r1,2\r3,4");
|
|
55
|
+
|
|
56
|
+
expect(result.rows).toHaveLength(3);
|
|
57
|
+
expect(result.rows[2].cells[0].value).toBe("3");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns empty sheet for empty input", () => {
|
|
61
|
+
const result = parseCsvText("");
|
|
62
|
+
|
|
63
|
+
expect(result.rows).toHaveLength(0);
|
|
64
|
+
expect(result.columns).toHaveLength(0);
|
|
65
|
+
expect(result.totalRowCount).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns empty sheet for whitespace-only input", () => {
|
|
69
|
+
const result = parseCsvText(" \n ");
|
|
70
|
+
|
|
71
|
+
expect(result.rows).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("handles ragged rows (different column counts)", () => {
|
|
75
|
+
const result = parseCsvText("A,B,C\n1,2\n3,4,5,6");
|
|
76
|
+
|
|
77
|
+
// Column count should be based on widest row
|
|
78
|
+
expect(result.columns).toHaveLength(4);
|
|
79
|
+
|
|
80
|
+
// Short row should have empty string for missing columns
|
|
81
|
+
expect(result.rows[1].cells[2].value).toBe("");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("computes column widths from content", () => {
|
|
85
|
+
const result = parseCsvText("ShortHeader,A much longer column header value\n1,2");
|
|
86
|
+
|
|
87
|
+
// First column: "ShortHeader" = 11 chars → 11 + 2 = 13
|
|
88
|
+
expect(result.columns[0].width).toBe(13);
|
|
89
|
+
// Second column: "A much longer column header value" = 33 chars → 33 + 2 = 35
|
|
90
|
+
expect(result.columns[1].width).toBe(35);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("cells have no styles (plain CSV)", () => {
|
|
94
|
+
const result = parseCsvText("A\n1");
|
|
95
|
+
|
|
96
|
+
expect(result.rows[0].cells[0].style).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("has no merged cells", () => {
|
|
100
|
+
const result = parseCsvText("A,B\n1,2");
|
|
101
|
+
|
|
102
|
+
expect(result.mergedCells).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("truncates at 10,000 rows", () => {
|
|
106
|
+
const lines = ["Col"];
|
|
107
|
+
for (let i = 0; i < 10_500; i++) {
|
|
108
|
+
lines.push(String(i));
|
|
109
|
+
}
|
|
110
|
+
const result = parseCsvText(lines.join("\n"));
|
|
111
|
+
|
|
112
|
+
expect(result.truncated).toBe(true);
|
|
113
|
+
expect(result.rows).toHaveLength(10_000);
|
|
114
|
+
expect(result.totalRowCount).toBe(10_501);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("single column CSV", () => {
|
|
118
|
+
const result = parseCsvText("Name\nAlice\nBob");
|
|
119
|
+
|
|
120
|
+
expect(result.columns).toHaveLength(1);
|
|
121
|
+
expect(result.rows).toHaveLength(3);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("single row CSV", () => {
|
|
125
|
+
const result = parseCsvText("A,B,C");
|
|
126
|
+
|
|
127
|
+
expect(result.rows).toHaveLength(1);
|
|
128
|
+
expect(result.rows[0].cells).toHaveLength(3);
|
|
129
|
+
});
|
|
130
|
+
});
|