@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParsedSpreadsheet,
|
|
3
|
+
ParsedSheet,
|
|
4
|
+
ParsedRow,
|
|
5
|
+
ParsedCell,
|
|
6
|
+
ParsedColumn,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
const MAX_ROWS = 10_000;
|
|
10
|
+
|
|
11
|
+
export async function parseCsvFile(url: string, signal?: AbortSignal): Promise<ParsedSpreadsheet> {
|
|
12
|
+
const response = await fetch(url, { signal });
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
const sheet = parseCsvText(text);
|
|
19
|
+
|
|
20
|
+
return { sheets: [sheet], activeSheetIndex: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseCsvText(text: string): ParsedSheet {
|
|
24
|
+
if (!text.trim()) {
|
|
25
|
+
return {
|
|
26
|
+
name: "Sheet1",
|
|
27
|
+
columns: [],
|
|
28
|
+
rows: [],
|
|
29
|
+
mergedCells: [],
|
|
30
|
+
totalRowCount: 0,
|
|
31
|
+
truncated: false,
|
|
32
|
+
defaultRowHeight: 20,
|
|
33
|
+
defaultColWidth: 8,
|
|
34
|
+
showGridLines: true,
|
|
35
|
+
conditionalFormats: [],
|
|
36
|
+
images: [],
|
|
37
|
+
rowOutlineLevels: new Map(),
|
|
38
|
+
colOutlineLevels: new Map(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const delimiter = detectDelimiter(text);
|
|
43
|
+
const rawRows = parseRows(text, delimiter);
|
|
44
|
+
|
|
45
|
+
const totalRowCount = rawRows.length;
|
|
46
|
+
const truncated = totalRowCount > MAX_ROWS;
|
|
47
|
+
const visibleRows = truncated ? rawRows.slice(0, MAX_ROWS) : rawRows;
|
|
48
|
+
|
|
49
|
+
// Determine column count from widest row
|
|
50
|
+
let colCount = 0;
|
|
51
|
+
for (const row of visibleRows) {
|
|
52
|
+
if (row.length > colCount) colCount = row.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Track max content width per column for auto-sizing
|
|
56
|
+
const maxContentWidth = Array.from<number>({ length: colCount }).fill(0);
|
|
57
|
+
|
|
58
|
+
const rows: ParsedRow[] = visibleRows.map((rawRow, i) => {
|
|
59
|
+
const cells: ParsedCell[] = [];
|
|
60
|
+
for (let c = 0; c < colCount; c++) {
|
|
61
|
+
const value = rawRow[c] ?? "";
|
|
62
|
+
cells.push({
|
|
63
|
+
column: c + 1,
|
|
64
|
+
value,
|
|
65
|
+
content: { type: "plain", text: value },
|
|
66
|
+
style: {},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Sample first 200 rows for width computation
|
|
70
|
+
if (i < 200 && value.length > maxContentWidth[c]) {
|
|
71
|
+
maxContentWidth[c] = value.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { index: i + 1, height: 20, cells, hidden: false };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const columns: ParsedColumn[] = [];
|
|
78
|
+
for (let c = 0; c < colCount; c++) {
|
|
79
|
+
const width = Math.max(8, Math.min(maxContentWidth[c] + 2, 40));
|
|
80
|
+
columns.push({ index: c + 1, width, hidden: false });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: "Sheet1",
|
|
85
|
+
columns,
|
|
86
|
+
rows,
|
|
87
|
+
mergedCells: [],
|
|
88
|
+
totalRowCount,
|
|
89
|
+
truncated,
|
|
90
|
+
defaultRowHeight: 20,
|
|
91
|
+
defaultColWidth: 8,
|
|
92
|
+
showGridLines: true,
|
|
93
|
+
conditionalFormats: [],
|
|
94
|
+
images: [],
|
|
95
|
+
rowOutlineLevels: new Map(),
|
|
96
|
+
colOutlineLevels: new Map(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function detectDelimiter(text: string): string {
|
|
101
|
+
// Check first few lines for tab vs comma prevalence
|
|
102
|
+
const sample = text.slice(0, 2000);
|
|
103
|
+
const tabs = (sample.match(/\t/g) ?? []).length;
|
|
104
|
+
const commas = (sample.match(/,/g) ?? []).length;
|
|
105
|
+
return tabs > commas ? "\t" : ",";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse CSV text into rows of string arrays.
|
|
110
|
+
* Handles quoted fields (RFC 4180), mixed line endings.
|
|
111
|
+
*/
|
|
112
|
+
function parseRows(text: string, delimiter: string): string[][] {
|
|
113
|
+
const rows: string[][] = [];
|
|
114
|
+
let currentRow: string[] = [];
|
|
115
|
+
let currentField = "";
|
|
116
|
+
let inQuotes = false;
|
|
117
|
+
let i = 0;
|
|
118
|
+
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
const char = text[i];
|
|
121
|
+
|
|
122
|
+
if (inQuotes) {
|
|
123
|
+
if (char === '"') {
|
|
124
|
+
// Check for escaped quote ("")
|
|
125
|
+
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
126
|
+
currentField += '"';
|
|
127
|
+
i += 2;
|
|
128
|
+
} else {
|
|
129
|
+
inQuotes = false;
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
currentField += char;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
if (char === '"') {
|
|
138
|
+
inQuotes = true;
|
|
139
|
+
i++;
|
|
140
|
+
} else if (char === delimiter) {
|
|
141
|
+
currentRow.push(currentField);
|
|
142
|
+
currentField = "";
|
|
143
|
+
i++;
|
|
144
|
+
} else if (char === "\r") {
|
|
145
|
+
// Handle \r\n and \r
|
|
146
|
+
currentRow.push(currentField);
|
|
147
|
+
currentField = "";
|
|
148
|
+
rows.push(currentRow);
|
|
149
|
+
currentRow = [];
|
|
150
|
+
i++;
|
|
151
|
+
if (i < text.length && text[i] === "\n") i++;
|
|
152
|
+
} else if (char === "\n") {
|
|
153
|
+
currentRow.push(currentField);
|
|
154
|
+
currentField = "";
|
|
155
|
+
rows.push(currentRow);
|
|
156
|
+
currentRow = [];
|
|
157
|
+
i++;
|
|
158
|
+
} else {
|
|
159
|
+
currentField += char;
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Push last field and row
|
|
166
|
+
if (currentField || currentRow.length > 0) {
|
|
167
|
+
currentRow.push(currentField);
|
|
168
|
+
rows.push(currentRow);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { findValidationForCell, validateCellValue } from "./data_validation";
|
|
3
|
+
import type { ParsedDataValidation } from "./ooxml_chart_types";
|
|
4
|
+
|
|
5
|
+
function makeDv(overrides: Partial<ParsedDataValidation> & { ref: string; type: ParsedDataValidation["type"] }): ParsedDataValidation {
|
|
6
|
+
return {
|
|
7
|
+
showDropdown: true,
|
|
8
|
+
...overrides,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("findValidationForCell", () => {
|
|
13
|
+
it("matches single cell ref", () => {
|
|
14
|
+
const dv = makeDv({ ref: "B2", type: "list", formula1: "A,B" });
|
|
15
|
+
expect(findValidationForCell([dv], 2, 2)).toBe(dv);
|
|
16
|
+
expect(findValidationForCell([dv], 1, 1)).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches range ref", () => {
|
|
20
|
+
const dv = makeDv({ ref: "A1:C5", type: "whole", operator: "greaterThan", formula1: "0" });
|
|
21
|
+
expect(findValidationForCell([dv], 3, 2)).toBe(dv);
|
|
22
|
+
expect(findValidationForCell([dv], 6, 1)).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles multi-range refs", () => {
|
|
26
|
+
const dv = makeDv({ ref: "A1:A5 C1:C5", type: "list", formula1: "X,Y" });
|
|
27
|
+
expect(findValidationForCell([dv], 3, 1)).toBe(dv);
|
|
28
|
+
expect(findValidationForCell([dv], 3, 3)).toBe(dv);
|
|
29
|
+
expect(findValidationForCell([dv], 3, 2)).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("validateCellValue", () => {
|
|
34
|
+
it("validates list membership", () => {
|
|
35
|
+
const dv = makeDv({ ref: "A1", type: "list", formula1: "Red,Green,Blue" });
|
|
36
|
+
expect(validateCellValue("Red", dv)).toBe(true);
|
|
37
|
+
expect(validateCellValue("Yellow", dv)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("validates whole number range (between)", () => {
|
|
41
|
+
const dv = makeDv({ ref: "A1", type: "whole", operator: "between", formula1: "1", formula2: "10" });
|
|
42
|
+
expect(validateCellValue("5", dv)).toBe(true);
|
|
43
|
+
expect(validateCellValue("0", dv)).toBe(false);
|
|
44
|
+
expect(validateCellValue("11", dv)).toBe(false);
|
|
45
|
+
expect(validateCellValue("3.5", dv)).toBe(false); // not integer
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("validates decimal greaterThan", () => {
|
|
49
|
+
const dv = makeDv({ ref: "A1", type: "decimal", operator: "greaterThan", formula1: "5.5" });
|
|
50
|
+
expect(validateCellValue("6.0", dv)).toBe(true);
|
|
51
|
+
expect(validateCellValue("5.5", dv)).toBe(false);
|
|
52
|
+
expect(validateCellValue("abc", dv)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("validates text length", () => {
|
|
56
|
+
const dv = makeDv({ ref: "A1", type: "textLength", operator: "lessThanOrEqual", formula1: "5" });
|
|
57
|
+
expect(validateCellValue("hi", dv)).toBe(true);
|
|
58
|
+
expect(validateCellValue("toolong", dv)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("accepts empty values", () => {
|
|
62
|
+
const dv = makeDv({ ref: "A1", type: "whole", operator: "greaterThan", formula1: "0" });
|
|
63
|
+
expect(validateCellValue("", dv)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes for missing operator (defaults to between)", () => {
|
|
67
|
+
const dv = makeDv({ ref: "A1", type: "whole", formula1: "1", formula2: "10" });
|
|
68
|
+
expect(validateCellValue("5", dv)).toBe(true);
|
|
69
|
+
expect(validateCellValue("15", dv)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("validates notBetween", () => {
|
|
73
|
+
const dv = makeDv({ ref: "A1", type: "decimal", operator: "notBetween", formula1: "1", formula2: "10" });
|
|
74
|
+
expect(validateCellValue("0.5", dv)).toBe(true);
|
|
75
|
+
expect(validateCellValue("5", dv)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("validates equal / notEqual", () => {
|
|
79
|
+
const dv1 = makeDv({ ref: "A1", type: "whole", operator: "equal", formula1: "42" });
|
|
80
|
+
expect(validateCellValue("42", dv1)).toBe(true);
|
|
81
|
+
expect(validateCellValue("43", dv1)).toBe(false);
|
|
82
|
+
|
|
83
|
+
const dv2 = makeDv({ ref: "A1", type: "whole", operator: "notEqual", formula1: "42" });
|
|
84
|
+
expect(validateCellValue("43", dv2)).toBe(true);
|
|
85
|
+
expect(validateCellValue("42", dv2)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true for date/time/custom types", () => {
|
|
89
|
+
const dv1 = makeDv({ ref: "A1", type: "date" });
|
|
90
|
+
expect(validateCellValue("anything", dv1)).toBe(true);
|
|
91
|
+
|
|
92
|
+
const dv2 = makeDv({ ref: "A1", type: "custom" });
|
|
93
|
+
expect(validateCellValue("anything", dv2)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Data Validation — Cell value validation logic
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
import type { ParsedDataValidation } from "./ooxml_chart_types";
|
|
6
|
+
import { colLettersToNum } from "./excel_utils";
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Find validation rule for a cell
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
/** Find the first data validation rule that applies to the given cell. */
|
|
13
|
+
export function findValidationForCell(
|
|
14
|
+
validations: ParsedDataValidation[],
|
|
15
|
+
row: number,
|
|
16
|
+
col: number,
|
|
17
|
+
): ParsedDataValidation | undefined {
|
|
18
|
+
for (const dv of validations) {
|
|
19
|
+
const ranges = dv.ref.split(/\s+/);
|
|
20
|
+
for (const range of ranges) {
|
|
21
|
+
// Single cell: "A1"
|
|
22
|
+
const singleMatch = range.match(/^([A-Z]+)(\d+)$/);
|
|
23
|
+
if (singleMatch) {
|
|
24
|
+
const c = colLettersToNum(singleMatch[1]);
|
|
25
|
+
const r = parseInt(singleMatch[2], 10);
|
|
26
|
+
if (r === row && c === col) return dv;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Range: "A1:C10"
|
|
30
|
+
const rangeMatch = range.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
|
|
31
|
+
if (rangeMatch) {
|
|
32
|
+
const sc = colLettersToNum(rangeMatch[1]);
|
|
33
|
+
const sr = parseInt(rangeMatch[2], 10);
|
|
34
|
+
const ec = colLettersToNum(rangeMatch[3]);
|
|
35
|
+
const er = parseInt(rangeMatch[4], 10);
|
|
36
|
+
if (row >= sr && row <= er && col >= sc && col <= ec) return dv;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Validate a cell value against a rule
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
/** Check whether a string value passes the validation rule. Returns true if valid. */
|
|
48
|
+
export function validateCellValue(
|
|
49
|
+
value: string,
|
|
50
|
+
dv: ParsedDataValidation,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (dv.type === "none") return true;
|
|
53
|
+
if (!value && dv.type !== "textLength") return true; // empty values pass (Excel behavior)
|
|
54
|
+
|
|
55
|
+
switch (dv.type) {
|
|
56
|
+
case "list": {
|
|
57
|
+
if (!dv.formula1) return true;
|
|
58
|
+
const items = dv.formula1.split(",").map((v) => v.trim().replace(/^"|"$/g, ""));
|
|
59
|
+
return items.includes(value);
|
|
60
|
+
}
|
|
61
|
+
case "whole": {
|
|
62
|
+
const num = Number(value);
|
|
63
|
+
if (!Number.isInteger(num)) return false;
|
|
64
|
+
return checkNumericOperator(num, dv);
|
|
65
|
+
}
|
|
66
|
+
case "decimal": {
|
|
67
|
+
const num = Number(value);
|
|
68
|
+
if (isNaN(num)) return false;
|
|
69
|
+
return checkNumericOperator(num, dv);
|
|
70
|
+
}
|
|
71
|
+
case "textLength": {
|
|
72
|
+
const len = value.length;
|
|
73
|
+
return checkNumericOperator(len, dv);
|
|
74
|
+
}
|
|
75
|
+
// date, time, custom — accept for now (would need formula engine)
|
|
76
|
+
case "date":
|
|
77
|
+
case "time":
|
|
78
|
+
case "custom":
|
|
79
|
+
return true;
|
|
80
|
+
default:
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Helpers
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
function checkNumericOperator(value: number, dv: ParsedDataValidation): boolean {
|
|
90
|
+
const f1 = dv.formula1 !== undefined ? Number(dv.formula1) : NaN;
|
|
91
|
+
const f2 = dv.formula2 !== undefined ? Number(dv.formula2) : NaN;
|
|
92
|
+
const op = dv.operator ?? "between";
|
|
93
|
+
|
|
94
|
+
switch (op) {
|
|
95
|
+
case "between":
|
|
96
|
+
return !isNaN(f1) && !isNaN(f2) && value >= f1 && value <= f2;
|
|
97
|
+
case "notBetween":
|
|
98
|
+
return !isNaN(f1) && !isNaN(f2) && (value < f1 || value > f2);
|
|
99
|
+
case "equal":
|
|
100
|
+
return !isNaN(f1) && value === f1;
|
|
101
|
+
case "notEqual":
|
|
102
|
+
return !isNaN(f1) && value !== f1;
|
|
103
|
+
case "greaterThan":
|
|
104
|
+
return !isNaN(f1) && value > f1;
|
|
105
|
+
case "lessThan":
|
|
106
|
+
return !isNaN(f1) && value < f1;
|
|
107
|
+
case "greaterThanOrEqual":
|
|
108
|
+
return !isNaN(f1) && value >= f1;
|
|
109
|
+
case "lessThanOrEqual":
|
|
110
|
+
return !isNaN(f1) && value <= f1;
|
|
111
|
+
default:
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { SelectionModel, normalizeRange } from "./selection_model";
|
|
2
|
+
import { CommandHistory } from "./command_history";
|
|
3
|
+
import type { Command } from "./command_history";
|
|
4
|
+
import type { SelectionEvent } from "./selection_model";
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// SelectionModel
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
describe("SelectionModel", () => {
|
|
11
|
+
test("starts at A1", () => {
|
|
12
|
+
const sm = new SelectionModel(100, 26);
|
|
13
|
+
expect(sm.getActiveCellRef()).toBe("A1");
|
|
14
|
+
expect(sm.getState().activeCell).toEqual({ row: 1, col: 1 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("click sets active cell", () => {
|
|
18
|
+
const sm = new SelectionModel(100, 26);
|
|
19
|
+
sm.click(5, 3);
|
|
20
|
+
expect(sm.getState().activeCell).toEqual({ row: 5, col: 3 });
|
|
21
|
+
expect(sm.getActiveCellRef()).toBe("C5");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("click clamps to bounds", () => {
|
|
25
|
+
const sm = new SelectionModel(10, 5);
|
|
26
|
+
sm.click(15, 8);
|
|
27
|
+
expect(sm.getState().activeCell).toEqual({ row: 10, col: 5 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("shift+click extends selection", () => {
|
|
31
|
+
const sm = new SelectionModel(100, 26);
|
|
32
|
+
sm.click(2, 2);
|
|
33
|
+
sm.click(5, 5, true);
|
|
34
|
+
const range = sm.getState().ranges[0];
|
|
35
|
+
expect(range.start).toEqual({ row: 2, col: 2 });
|
|
36
|
+
expect(range.end).toEqual({ row: 5, col: 5 });
|
|
37
|
+
expect(sm.getSelectionRef()).toBe("B2:E5");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ctrl+click adds range", () => {
|
|
41
|
+
const sm = new SelectionModel(100, 26);
|
|
42
|
+
sm.click(1, 1);
|
|
43
|
+
sm.click(3, 3, false, true);
|
|
44
|
+
expect(sm.getState().ranges).toHaveLength(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("move navigates active cell", () => {
|
|
48
|
+
const sm = new SelectionModel(100, 26);
|
|
49
|
+
sm.move(1, 0); // down
|
|
50
|
+
expect(sm.getState().activeCell).toEqual({ row: 2, col: 1 });
|
|
51
|
+
sm.move(0, 1); // right
|
|
52
|
+
expect(sm.getState().activeCell).toEqual({ row: 2, col: 2 });
|
|
53
|
+
sm.move(-1, 0); // up
|
|
54
|
+
expect(sm.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("move clamps at boundaries", () => {
|
|
58
|
+
const sm = new SelectionModel(10, 5);
|
|
59
|
+
sm.move(-1, 0); // try up from row 1
|
|
60
|
+
expect(sm.getState().activeCell.row).toBe(1);
|
|
61
|
+
sm.move(0, -1); // try left from col 1
|
|
62
|
+
expect(sm.getState().activeCell.col).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("shift+move extends selection", () => {
|
|
66
|
+
const sm = new SelectionModel(100, 26);
|
|
67
|
+
sm.click(3, 3);
|
|
68
|
+
sm.move(2, 2, true);
|
|
69
|
+
const range = sm.getState().ranges[0];
|
|
70
|
+
expect(range.start).toEqual({ row: 3, col: 3 });
|
|
71
|
+
expect(range.end).toEqual({ row: 5, col: 5 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("goTo moves to specific cell", () => {
|
|
75
|
+
const sm = new SelectionModel(100, 26);
|
|
76
|
+
sm.goTo(10, 5);
|
|
77
|
+
expect(sm.getState().activeCell).toEqual({ row: 10, col: 5 });
|
|
78
|
+
expect(sm.getSelectionRef()).toBe("E10");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("drag selection", () => {
|
|
82
|
+
const sm = new SelectionModel(100, 26);
|
|
83
|
+
sm.dragStart(2, 2);
|
|
84
|
+
expect(sm.getState().dragging).toBe(true);
|
|
85
|
+
sm.dragMove(5, 5);
|
|
86
|
+
const range = sm.getState().ranges[0];
|
|
87
|
+
expect(range.end).toEqual({ row: 5, col: 5 });
|
|
88
|
+
sm.dragEnd();
|
|
89
|
+
expect(sm.getState().dragging).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("edit mode", () => {
|
|
93
|
+
const sm = new SelectionModel(100, 26);
|
|
94
|
+
const events: SelectionEvent[] = [];
|
|
95
|
+
sm.onChange = (e) => events.push(e);
|
|
96
|
+
|
|
97
|
+
sm.startEdit("Hello");
|
|
98
|
+
expect(sm.getState().editing).toBe(true);
|
|
99
|
+
expect(sm.getState().editText).toBe("Hello");
|
|
100
|
+
|
|
101
|
+
sm.updateEditText("Hello World");
|
|
102
|
+
expect(sm.getState().editText).toBe("Hello World");
|
|
103
|
+
|
|
104
|
+
sm.commitEdit();
|
|
105
|
+
expect(sm.getState().editing).toBe(false);
|
|
106
|
+
expect(events.find((e) => e.type === "edit_end")).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("escape cancels edit", () => {
|
|
110
|
+
const sm = new SelectionModel(100, 26);
|
|
111
|
+
const events: SelectionEvent[] = [];
|
|
112
|
+
sm.onChange = (e) => events.push(e);
|
|
113
|
+
|
|
114
|
+
sm.startEdit("Hello");
|
|
115
|
+
sm.cancelEdit();
|
|
116
|
+
|
|
117
|
+
expect(sm.getState().editing).toBe(false);
|
|
118
|
+
const endEvent = events.find((e) => e.type === "edit_end");
|
|
119
|
+
expect(endEvent).toBeDefined();
|
|
120
|
+
if (endEvent?.type === "edit_end") {
|
|
121
|
+
expect(endEvent.commit).toBe(false);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("click during edit commits the edit", () => {
|
|
126
|
+
const sm = new SelectionModel(100, 26);
|
|
127
|
+
const events: SelectionEvent[] = [];
|
|
128
|
+
sm.onChange = (e) => events.push(e);
|
|
129
|
+
|
|
130
|
+
sm.startEdit("Test");
|
|
131
|
+
sm.click(5, 5);
|
|
132
|
+
|
|
133
|
+
const endEvent = events.find((e) => e.type === "edit_end");
|
|
134
|
+
expect(endEvent).toBeDefined();
|
|
135
|
+
expect(sm.getState().activeCell).toEqual({ row: 5, col: 5 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("fires events on changes", () => {
|
|
139
|
+
const sm = new SelectionModel(100, 26);
|
|
140
|
+
const events: SelectionEvent[] = [];
|
|
141
|
+
sm.onChange = (e) => events.push(e);
|
|
142
|
+
|
|
143
|
+
sm.click(3, 3);
|
|
144
|
+
expect(events.some((e) => e.type === "active_cell_changed")).toBe(true);
|
|
145
|
+
expect(events.some((e) => e.type === "selection_changed")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("selectRange sets multi-cell selection", () => {
|
|
149
|
+
const sm = new SelectionModel(100, 26);
|
|
150
|
+
sm.selectRange(2, 2, 5, 4);
|
|
151
|
+
expect(sm.getState().activeCell).toEqual({ row: 2, col: 2 });
|
|
152
|
+
const range = sm.getState().ranges[0];
|
|
153
|
+
expect(range.start).toEqual({ row: 2, col: 2 });
|
|
154
|
+
expect(range.end).toEqual({ row: 5, col: 4 });
|
|
155
|
+
expect(sm.getSelectionRef()).toBe("B2:D5");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("selectRange clamps to bounds", () => {
|
|
159
|
+
const sm = new SelectionModel(10, 5);
|
|
160
|
+
sm.selectRange(1, 1, 20, 10);
|
|
161
|
+
expect(sm.getState().activeCell).toEqual({ row: 1, col: 1 });
|
|
162
|
+
const range = sm.getState().ranges[0];
|
|
163
|
+
expect(range.end).toEqual({ row: 10, col: 5 });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("selectRange commits editing first", () => {
|
|
167
|
+
const sm = new SelectionModel(100, 26);
|
|
168
|
+
const events: SelectionEvent[] = [];
|
|
169
|
+
sm.onChange = (e) => events.push(e);
|
|
170
|
+
sm.startEdit("test");
|
|
171
|
+
sm.selectRange(2, 2, 5, 4);
|
|
172
|
+
expect(sm.getState().editing).toBe(false);
|
|
173
|
+
expect(events.some((e) => e.type === "edit_end")).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("selectRange fires both events", () => {
|
|
177
|
+
const sm = new SelectionModel(100, 26);
|
|
178
|
+
const events: SelectionEvent[] = [];
|
|
179
|
+
sm.onChange = (e) => events.push(e);
|
|
180
|
+
sm.selectRange(2, 2, 5, 4);
|
|
181
|
+
expect(events.some((e) => e.type === "active_cell_changed")).toBe(true);
|
|
182
|
+
expect(events.some((e) => e.type === "selection_changed")).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("normalizeRange", () => {
|
|
187
|
+
test("normalizes reversed range", () => {
|
|
188
|
+
const range = normalizeRange({
|
|
189
|
+
start: { row: 5, col: 5 },
|
|
190
|
+
end: { row: 1, col: 1 },
|
|
191
|
+
});
|
|
192
|
+
expect(range.start).toEqual({ row: 1, col: 1 });
|
|
193
|
+
expect(range.end).toEqual({ row: 5, col: 5 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("keeps already-normalized range", () => {
|
|
197
|
+
const range = normalizeRange({
|
|
198
|
+
start: { row: 1, col: 1 },
|
|
199
|
+
end: { row: 5, col: 5 },
|
|
200
|
+
});
|
|
201
|
+
expect(range.start).toEqual({ row: 1, col: 1 });
|
|
202
|
+
expect(range.end).toEqual({ row: 5, col: 5 });
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// CommandHistory
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
describe("CommandHistory", () => {
|
|
211
|
+
function makeCommand(
|
|
212
|
+
desc: string,
|
|
213
|
+
onExecute: () => void,
|
|
214
|
+
onUndo: () => void,
|
|
215
|
+
): Command {
|
|
216
|
+
return { description: desc, execute: onExecute, undo: onUndo };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test("execute adds to undo stack", () => {
|
|
220
|
+
const h = new CommandHistory();
|
|
221
|
+
let value = 0;
|
|
222
|
+
h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
|
|
223
|
+
expect(value).toBe(1);
|
|
224
|
+
expect(h.canUndo).toBe(true);
|
|
225
|
+
expect(h.canRedo).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("undo reverses command", () => {
|
|
229
|
+
const h = new CommandHistory();
|
|
230
|
+
let value = 0;
|
|
231
|
+
h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
|
|
232
|
+
h.undo();
|
|
233
|
+
expect(value).toBe(0);
|
|
234
|
+
expect(h.canUndo).toBe(false);
|
|
235
|
+
expect(h.canRedo).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("redo re-applies command", () => {
|
|
239
|
+
const h = new CommandHistory();
|
|
240
|
+
let value = 0;
|
|
241
|
+
h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
|
|
242
|
+
h.undo();
|
|
243
|
+
h.redo();
|
|
244
|
+
expect(value).toBe(1);
|
|
245
|
+
expect(h.canUndo).toBe(true);
|
|
246
|
+
expect(h.canRedo).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("new command clears redo stack", () => {
|
|
250
|
+
const h = new CommandHistory();
|
|
251
|
+
let value = 0;
|
|
252
|
+
h.execute(makeCommand("set 1", () => { value = 1; }, () => { value = 0; }));
|
|
253
|
+
h.undo();
|
|
254
|
+
h.execute(makeCommand("set 2", () => { value = 2; }, () => { value = 0; }));
|
|
255
|
+
expect(h.canRedo).toBe(false);
|
|
256
|
+
expect(value).toBe(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("multiple undo/redo", () => {
|
|
260
|
+
const h = new CommandHistory();
|
|
261
|
+
const log: number[] = [];
|
|
262
|
+
h.execute(makeCommand("push 1", () => log.push(1), () => log.pop()));
|
|
263
|
+
h.execute(makeCommand("push 2", () => log.push(2), () => log.pop()));
|
|
264
|
+
h.execute(makeCommand("push 3", () => log.push(3), () => log.pop()));
|
|
265
|
+
expect(log).toEqual([1, 2, 3]);
|
|
266
|
+
|
|
267
|
+
h.undo();
|
|
268
|
+
expect(log).toEqual([1, 2]);
|
|
269
|
+
h.undo();
|
|
270
|
+
expect(log).toEqual([1]);
|
|
271
|
+
h.redo();
|
|
272
|
+
expect(log).toEqual([1, 2]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("max size trims oldest entries", () => {
|
|
276
|
+
const h = new CommandHistory(3);
|
|
277
|
+
for (let i = 0; i < 5; i++) {
|
|
278
|
+
h.execute(makeCommand(`cmd ${i}`, () => {}, () => {}));
|
|
279
|
+
}
|
|
280
|
+
expect(h.undoSize).toBe(3);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("undo/redo return descriptions", () => {
|
|
284
|
+
const h = new CommandHistory();
|
|
285
|
+
h.execute(makeCommand("Set value", () => {}, () => {}));
|
|
286
|
+
expect(h.undoDescription).toBe("Set value");
|
|
287
|
+
const desc = h.undo();
|
|
288
|
+
expect(desc).toBe("Set value");
|
|
289
|
+
expect(h.redoDescription).toBe("Set value");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("clear empties stacks", () => {
|
|
293
|
+
const h = new CommandHistory();
|
|
294
|
+
h.execute(makeCommand("cmd", () => {}, () => {}));
|
|
295
|
+
h.clear();
|
|
296
|
+
expect(h.canUndo).toBe(false);
|
|
297
|
+
expect(h.canRedo).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("undo on empty returns undefined", () => {
|
|
301
|
+
const h = new CommandHistory();
|
|
302
|
+
expect(h.undo()).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("redo on empty returns undefined", () => {
|
|
306
|
+
const h = new CommandHistory();
|
|
307
|
+
expect(h.redo()).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|