@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,100 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Formula Function-name Autocomplete
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Given a formula string and cursor position, return matching Excel function
|
|
6
|
+
// names that should be offered as completions. The active token is the
|
|
7
|
+
// alphabetic identifier ending at the cursor; the user is "in formula mode"
|
|
8
|
+
// when the text starts with `=`.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
const FUNCTION_NAMES = [
|
|
12
|
+
// Math & trig
|
|
13
|
+
"ABS","ACOS","ACOSH","ASIN","ASINH","ATAN","ATAN2","ATANH","CEILING","COS","COSH","DEGREES","EVEN","EXP","FACT","FACTDOUBLE","FLOOR","GCD","INT","LCM","LN","LOG","LOG10","MOD","MROUND","ODD","PI","POWER","PRODUCT","QUOTIENT","RADIANS","RAND","RANDBETWEEN","ROUND","ROUNDDOWN","ROUNDUP","SEC","SECH","SIGN","SIN","SINH","SQRT","SUBTOTAL","SUM","SUMIF","SUMIFS","SUMPRODUCT","SUMSQ","TAN","TANH","TRUNC",
|
|
14
|
+
// Statistical
|
|
15
|
+
"AVERAGE","AVERAGEA","AVERAGEIF","AVERAGEIFS","COUNT","COUNTA","COUNTBLANK","COUNTIF","COUNTIFS","LARGE","MAX","MAXA","MAXIFS","MEDIAN","MIN","MINA","MINIFS","MODE","PERCENTILE","QUARTILE","RANK","SLOPE","SMALL","STDEV","STDEVA","STDEVP","VAR","VARA","VARP",
|
|
16
|
+
// Logical
|
|
17
|
+
"AND","FALSE","IF","IFERROR","IFNA","IFS","NOT","OR","SWITCH","TRUE","XOR",
|
|
18
|
+
// Lookup & reference
|
|
19
|
+
"ADDRESS","CHOOSE","COLUMN","COLUMNS","HLOOKUP","HYPERLINK","INDEX","INDIRECT","LOOKUP","MATCH","OFFSET","ROW","ROWS","TRANSPOSE","VLOOKUP","XLOOKUP","XMATCH",
|
|
20
|
+
// Text
|
|
21
|
+
"CHAR","CLEAN","CODE","CONCAT","CONCATENATE","DOLLAR","EXACT","FIND","FIXED","LEFT","LEN","LOWER","MID","PROPER","REPLACE","REPT","RIGHT","SEARCH","SUBSTITUTE","T","TEXT","TEXTJOIN","TRIM","UNICHAR","UNICODE","UPPER","VALUE",
|
|
22
|
+
// Date & time
|
|
23
|
+
"DATE","DATEDIF","DATEVALUE","DAY","DAYS","DAYS360","EDATE","EOMONTH","HOUR","ISOWEEKNUM","MINUTE","MONTH","NETWORKDAYS","NOW","SECOND","TIME","TIMEVALUE","TODAY","WEEKDAY","WEEKNUM","WORKDAY","YEAR","YEARFRAC",
|
|
24
|
+
// Information
|
|
25
|
+
"CELL","ERROR.TYPE","INFO","ISBLANK","ISERR","ISERROR","ISEVEN","ISFORMULA","ISLOGICAL","ISNA","ISNONTEXT","ISNUMBER","ISODD","ISREF","ISTEXT","N","NA","TYPE",
|
|
26
|
+
// Financial (most common)
|
|
27
|
+
"FV","IRR","NPER","NPV","PMT","PPMT","PV","RATE","XIRR","XNPV",
|
|
28
|
+
// Engineering (common)
|
|
29
|
+
"BIN2DEC","DEC2BIN","DEC2HEX","HEX2DEC",
|
|
30
|
+
// Database
|
|
31
|
+
"DAVERAGE","DCOUNT","DCOUNTA","DGET","DMAX","DMIN","DPRODUCT","DSTDEV","DSUM","DVAR",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface FormulaSuggestion {
|
|
35
|
+
/** The full function name (e.g. "VLOOKUP"). */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Substring of `name` that matched the user's input. */
|
|
38
|
+
matchStart: number;
|
|
39
|
+
matchEnd: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FormulaSuggestResult {
|
|
43
|
+
suggestions: FormulaSuggestion[];
|
|
44
|
+
/** Start index in the input where the active token begins. The caller uses
|
|
45
|
+
* this when accepting a suggestion to splice in the function name. */
|
|
46
|
+
tokenStart: number;
|
|
47
|
+
/** Cursor position (== end of token). */
|
|
48
|
+
tokenEnd: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute function-name suggestions based on a partial token at the cursor.
|
|
53
|
+
* Returns an empty list when the text isn't a formula (`=` prefix) or when
|
|
54
|
+
* the cursor is not on an alphabetic identifier.
|
|
55
|
+
*/
|
|
56
|
+
export function getFormulaSuggestions(
|
|
57
|
+
text: string,
|
|
58
|
+
cursor: number,
|
|
59
|
+
limit = 8,
|
|
60
|
+
): FormulaSuggestResult {
|
|
61
|
+
const empty: FormulaSuggestResult = { suggestions: [], tokenStart: cursor, tokenEnd: cursor };
|
|
62
|
+
if (!text.startsWith("=")) return empty;
|
|
63
|
+
|
|
64
|
+
// Walk backwards from the cursor while we see alphabetic chars or '.'
|
|
65
|
+
// (Excel function names like ERROR.TYPE include dot).
|
|
66
|
+
let start = cursor;
|
|
67
|
+
while (start > 0) {
|
|
68
|
+
const c = text[start - 1];
|
|
69
|
+
if (!/[A-Za-z.]/.test(c)) break;
|
|
70
|
+
start--;
|
|
71
|
+
}
|
|
72
|
+
if (start === cursor) return empty;
|
|
73
|
+
|
|
74
|
+
const token = text.slice(start, cursor).toUpperCase();
|
|
75
|
+
if (token.length === 0) return empty;
|
|
76
|
+
|
|
77
|
+
const suggestions: FormulaSuggestion[] = [];
|
|
78
|
+
for (const name of FUNCTION_NAMES) {
|
|
79
|
+
if (name.startsWith(token) && name !== token) {
|
|
80
|
+
suggestions.push({ name, matchStart: 0, matchEnd: token.length });
|
|
81
|
+
if (suggestions.length >= limit) break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { suggestions, tokenStart: start, tokenEnd: cursor };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Apply a chosen suggestion to the text + cursor, returning the new pair.
|
|
89
|
+
* Inserts an opening parenthesis after the function name and positions the
|
|
90
|
+
* cursor inside it — Excel's behavior on autocomplete acceptance.
|
|
91
|
+
*/
|
|
92
|
+
export function applyFormulaSuggestion(
|
|
93
|
+
text: string,
|
|
94
|
+
suggestion: FormulaSuggestion,
|
|
95
|
+
range: { start: number; end: number },
|
|
96
|
+
): { text: string; cursor: number } {
|
|
97
|
+
const inserted = `${suggestion.name}(`;
|
|
98
|
+
const newText = text.slice(0, range.start) + inserted + text.slice(range.end);
|
|
99
|
+
return { text: newText, cursor: range.start + inserted.length };
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseExcelFromZip } from "./excel_parser";
|
|
3
|
+
|
|
4
|
+
const enc = (s: string) => new TextEncoder().encode(s);
|
|
5
|
+
|
|
6
|
+
// Minimal two-sheet workbook: one visible, one hidden.
|
|
7
|
+
const WORKBOOK = `<?xml version="1.0"?>
|
|
8
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
9
|
+
<sheets>
|
|
10
|
+
<sheet name="Visible" sheetId="1" r:id="rId1"/>
|
|
11
|
+
<sheet name="Secret" sheetId="2" state="hidden" r:id="rId2"/>
|
|
12
|
+
</sheets>
|
|
13
|
+
</workbook>`;
|
|
14
|
+
|
|
15
|
+
const RELS = `<?xml version="1.0"?>
|
|
16
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
17
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
18
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
|
|
19
|
+
</Relationships>`;
|
|
20
|
+
|
|
21
|
+
const sheetXml = (cell: string) => `<?xml version="1.0"?>
|
|
22
|
+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
23
|
+
<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>${cell}</t></is></c></row></sheetData>
|
|
24
|
+
</worksheet>`;
|
|
25
|
+
|
|
26
|
+
describe("parseExcelFromZip — hidden sheets", () => {
|
|
27
|
+
const wb = parseExcelFromZip({
|
|
28
|
+
"xl/workbook.xml": enc(WORKBOOK),
|
|
29
|
+
"xl/_rels/workbook.xml.rels": enc(RELS),
|
|
30
|
+
"xl/worksheets/sheet1.xml": enc(sheetXml("public")),
|
|
31
|
+
"xl/worksheets/sheet2.xml": enc(sheetXml("classified")),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("includes hidden sheets instead of dropping them", () => {
|
|
35
|
+
expect(wb.sheets.map((s) => s.name)).toEqual(["Visible", "Secret"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("tags only the hidden sheet via the `hidden` flag", () => {
|
|
39
|
+
const visible = wb.sheets.find((s) => s.name === "Visible")!;
|
|
40
|
+
const secret = wb.sheets.find((s) => s.name === "Secret")!;
|
|
41
|
+
expect(visible.hidden).toBeFalsy();
|
|
42
|
+
expect(secret.hidden).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses the hidden sheet's cell data", () => {
|
|
46
|
+
const secret = wb.sheets.find((s) => s.name === "Secret")!;
|
|
47
|
+
expect(secret.rows[0]?.cells[0]?.value).toBe("classified");
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// @lotics/xlsx — public barrel
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// The curated public surface of the package. New code should import from
|
|
6
|
+
// this barrel; existing subpath imports continue to work for backward
|
|
7
|
+
// compatibility but are considered legacy.
|
|
8
|
+
//
|
|
9
|
+
// Internals NOT re-exported here (canvas renderer, OOXML parser internals,
|
|
10
|
+
// formula engine internals) remain reachable via subpath imports for
|
|
11
|
+
// embedders that explicitly need them, but they are not part of the
|
|
12
|
+
// stability contract.
|
|
13
|
+
|
|
14
|
+
// -----------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// -----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ParsedSpreadsheet,
|
|
20
|
+
ParsedSheet,
|
|
21
|
+
ParsedCell,
|
|
22
|
+
ParsedColumn,
|
|
23
|
+
ParsedRow,
|
|
24
|
+
ParsedImage,
|
|
25
|
+
CellStyle,
|
|
26
|
+
BorderStyle,
|
|
27
|
+
CellContent,
|
|
28
|
+
RichTextPart,
|
|
29
|
+
RichTextFont,
|
|
30
|
+
GradientData,
|
|
31
|
+
MergedCellRange,
|
|
32
|
+
FreezePane,
|
|
33
|
+
PageSetup,
|
|
34
|
+
PrintTitles,
|
|
35
|
+
HeaderFooter,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
export { PasswordProtectedError } from "./types";
|
|
39
|
+
|
|
40
|
+
// -----------------------------------------------------------------------------
|
|
41
|
+
// Workbook model & cell storage
|
|
42
|
+
// -----------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
WorkbookModel,
|
|
46
|
+
SheetModel,
|
|
47
|
+
StyleRegistry,
|
|
48
|
+
CellMap,
|
|
49
|
+
loadWorkbookFromSnapshot,
|
|
50
|
+
materializeDisplayValue,
|
|
51
|
+
} from "./workbook_model";
|
|
52
|
+
|
|
53
|
+
export type { CellModel, CellValue, ChangeEvent } from "./workbook_model";
|
|
54
|
+
|
|
55
|
+
// -----------------------------------------------------------------------------
|
|
56
|
+
// Color utilities — canonical `#RRGGBB` everywhere
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
normalizeColor,
|
|
61
|
+
hexToArgb,
|
|
62
|
+
hexToOoxmlRgb,
|
|
63
|
+
prefixHex,
|
|
64
|
+
applyTint,
|
|
65
|
+
} from "./ooxml_color";
|
|
66
|
+
|
|
67
|
+
// -----------------------------------------------------------------------------
|
|
68
|
+
// Cell reference / coordinate utilities
|
|
69
|
+
// -----------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
refToRowCol,
|
|
73
|
+
rowColToRef,
|
|
74
|
+
colLettersToNum,
|
|
75
|
+
colNumToLetters,
|
|
76
|
+
parseNameBoxInput,
|
|
77
|
+
buildNamedRangeExpr,
|
|
78
|
+
packRef,
|
|
79
|
+
unpackRef,
|
|
80
|
+
packRowCol,
|
|
81
|
+
unpackRowCol,
|
|
82
|
+
} from "./excel_utils";
|
|
83
|
+
|
|
84
|
+
export type { ParsedInput } from "./excel_utils";
|
|
85
|
+
|
|
86
|
+
// -----------------------------------------------------------------------------
|
|
87
|
+
// Style helpers
|
|
88
|
+
// -----------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export { allBorders, bottomBorder, topBorder } from "./style_helpers";
|
|
91
|
+
|
|
92
|
+
// -----------------------------------------------------------------------------
|
|
93
|
+
// I/O — parse from .xlsx, export to .xlsx
|
|
94
|
+
// -----------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
parseExcelBuffer,
|
|
98
|
+
parseExcelFromZip,
|
|
99
|
+
} from "./excel_parser";
|
|
100
|
+
|
|
101
|
+
export { exportWorkbook } from "./xlsx_writer";
|
|
102
|
+
|
|
103
|
+
export { unzipXlsx } from "./ooxml_zip";
|
|
104
|
+
|
|
105
|
+
// -----------------------------------------------------------------------------
|
|
106
|
+
// Spreadsheet commands (mutations with undo/redo support)
|
|
107
|
+
// -----------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
setCellValueCommand,
|
|
111
|
+
setCellStyleCommand,
|
|
112
|
+
setRangeStyleCommand,
|
|
113
|
+
setRangeNumFmtCommand,
|
|
114
|
+
deleteCellCommand,
|
|
115
|
+
clearRangeCommand,
|
|
116
|
+
mergeCellsCommand,
|
|
117
|
+
unmergeCellsCommand,
|
|
118
|
+
insertRowsCommand,
|
|
119
|
+
deleteRowsCommand,
|
|
120
|
+
insertColsCommand,
|
|
121
|
+
deleteColsCommand,
|
|
122
|
+
setColWidthCommand,
|
|
123
|
+
setRowHeightCommand,
|
|
124
|
+
setNamedRangeCommand,
|
|
125
|
+
addSheetCommand,
|
|
126
|
+
deleteSheetCommand,
|
|
127
|
+
renameSheetCommand,
|
|
128
|
+
batchCommand,
|
|
129
|
+
setHiddenRowsCommand,
|
|
130
|
+
setHiddenColsCommand,
|
|
131
|
+
sortColumnCommand,
|
|
132
|
+
multiSortCommand,
|
|
133
|
+
setFreezePanesCommand,
|
|
134
|
+
setZoomCommand,
|
|
135
|
+
addConditionalFormatCommand,
|
|
136
|
+
deleteConditionalFormatCommand,
|
|
137
|
+
reorderSheetsCommand,
|
|
138
|
+
insertCellsShiftDownCommand,
|
|
139
|
+
// Merge-range and formula-range adjusters used by the placeholder engine.
|
|
140
|
+
// Public so external template engines can call them; the row/col commands
|
|
141
|
+
// call them internally too.
|
|
142
|
+
adjustMergedCellsForRowInsert,
|
|
143
|
+
adjustMergedCellsForRowDelete,
|
|
144
|
+
adjustMergedCellsForColInsert,
|
|
145
|
+
adjustMergedCellsForColDelete,
|
|
146
|
+
expandFormulaRangesForRowInsert,
|
|
147
|
+
} from "./spreadsheet_commands";
|
|
148
|
+
|
|
149
|
+
export type { SortSpec } from "./spreadsheet_commands";
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { SelectionModel } from "./selection_model";
|
|
2
|
+
import { CommandHistory } from "./command_history";
|
|
3
|
+
import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
|
|
4
|
+
import { handleKeyDown, type KeyboardNavContext } from "./keyboard_nav";
|
|
5
|
+
import type { ClipboardData } from "./clipboard";
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Helpers
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
function makeWorkbook(): WorkbookModel {
|
|
12
|
+
const styles = new StyleRegistry();
|
|
13
|
+
const sheet = new SheetModel("Sheet1");
|
|
14
|
+
// Add some test data
|
|
15
|
+
sheet.set("A1", "Hello");
|
|
16
|
+
sheet.set("B1", 42);
|
|
17
|
+
sheet.set("A2", "World");
|
|
18
|
+
sheet.set("C3", 100);
|
|
19
|
+
return new WorkbookModel({
|
|
20
|
+
sheets: [sheet],
|
|
21
|
+
activeSheetIndex: 0,
|
|
22
|
+
styles,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeContext(overrides?: Partial<KeyboardNavContext>): KeyboardNavContext {
|
|
27
|
+
const workbook = overrides?.workbook ?? makeWorkbook();
|
|
28
|
+
const selection = overrides?.selection ?? new SelectionModel(100, 26);
|
|
29
|
+
return {
|
|
30
|
+
selection,
|
|
31
|
+
workbook,
|
|
32
|
+
sheetIndex: 0,
|
|
33
|
+
history: new CommandHistory(),
|
|
34
|
+
engine: undefined,
|
|
35
|
+
clipboardData: undefined,
|
|
36
|
+
setClipboardData: () => {},
|
|
37
|
+
onStartEdit: () => {},
|
|
38
|
+
onRedraw: () => {},
|
|
39
|
+
pageSize: 20,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeKey(key: string, opts?: {
|
|
45
|
+
ctrlKey?: boolean;
|
|
46
|
+
metaKey?: boolean;
|
|
47
|
+
shiftKey?: boolean;
|
|
48
|
+
altKey?: boolean;
|
|
49
|
+
}): KeyboardEvent {
|
|
50
|
+
// Use a plain object — no DOM KeyboardEvent constructor in Node
|
|
51
|
+
return {
|
|
52
|
+
key,
|
|
53
|
+
ctrlKey: opts?.ctrlKey ?? false,
|
|
54
|
+
metaKey: opts?.metaKey ?? false,
|
|
55
|
+
shiftKey: opts?.shiftKey ?? false,
|
|
56
|
+
altKey: opts?.altKey ?? false,
|
|
57
|
+
} as unknown as KeyboardEvent;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Tests
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
describe("handleKeyDown", () => {
|
|
65
|
+
describe("arrow key navigation", () => {
|
|
66
|
+
test("ArrowDown moves active cell down", () => {
|
|
67
|
+
const ctx = makeContext();
|
|
68
|
+
const handled = handleKeyDown(makeKey("ArrowDown"), ctx);
|
|
69
|
+
expect(handled).toBe(true);
|
|
70
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("ArrowUp moves active cell up", () => {
|
|
74
|
+
const ctx = makeContext();
|
|
75
|
+
ctx.selection.click(3, 3);
|
|
76
|
+
const handled = handleKeyDown(makeKey("ArrowUp"), ctx);
|
|
77
|
+
expect(handled).toBe(true);
|
|
78
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 3 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("ArrowRight moves active cell right", () => {
|
|
82
|
+
const ctx = makeContext();
|
|
83
|
+
const handled = handleKeyDown(makeKey("ArrowRight"), ctx);
|
|
84
|
+
expect(handled).toBe(true);
|
|
85
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("ArrowLeft moves active cell left", () => {
|
|
89
|
+
const ctx = makeContext();
|
|
90
|
+
ctx.selection.click(1, 3);
|
|
91
|
+
const handled = handleKeyDown(makeKey("ArrowLeft"), ctx);
|
|
92
|
+
expect(handled).toBe(true);
|
|
93
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Shift+Arrow extends selection", () => {
|
|
97
|
+
const ctx = makeContext();
|
|
98
|
+
ctx.selection.click(3, 3);
|
|
99
|
+
handleKeyDown(makeKey("ArrowDown", { shiftKey: true }), ctx);
|
|
100
|
+
const range = ctx.selection.getState().ranges[0];
|
|
101
|
+
expect(range.start).toEqual({ row: 3, col: 3 });
|
|
102
|
+
expect(range.end).toEqual({ row: 4, col: 3 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("Tab and Enter", () => {
|
|
107
|
+
test("Tab moves right", () => {
|
|
108
|
+
const ctx = makeContext();
|
|
109
|
+
const handled = handleKeyDown(makeKey("Tab"), ctx);
|
|
110
|
+
expect(handled).toBe(true);
|
|
111
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("Shift+Tab moves left", () => {
|
|
115
|
+
const ctx = makeContext();
|
|
116
|
+
ctx.selection.click(1, 3);
|
|
117
|
+
handleKeyDown(makeKey("Tab", { shiftKey: true }), ctx);
|
|
118
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("Enter moves down", () => {
|
|
122
|
+
const ctx = makeContext();
|
|
123
|
+
const handled = handleKeyDown(makeKey("Enter"), ctx);
|
|
124
|
+
expect(handled).toBe(true);
|
|
125
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("Shift+Enter moves up", () => {
|
|
129
|
+
const ctx = makeContext();
|
|
130
|
+
ctx.selection.click(3, 1);
|
|
131
|
+
handleKeyDown(makeKey("Enter", { shiftKey: true }), ctx);
|
|
132
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("Home key", () => {
|
|
137
|
+
test("Home moves to column 1", () => {
|
|
138
|
+
const ctx = makeContext();
|
|
139
|
+
ctx.selection.click(5, 10);
|
|
140
|
+
handleKeyDown(makeKey("Home"), ctx);
|
|
141
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 5, col: 1 });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Ctrl+Home moves to A1", () => {
|
|
145
|
+
const ctx = makeContext();
|
|
146
|
+
ctx.selection.click(5, 10);
|
|
147
|
+
handleKeyDown(makeKey("Home", { ctrlKey: true }), ctx);
|
|
148
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 1 });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("Page Up/Down", () => {
|
|
153
|
+
test("PageDown moves by pageSize rows", () => {
|
|
154
|
+
const ctx = makeContext({ pageSize: 10 });
|
|
155
|
+
const handled = handleKeyDown(makeKey("PageDown"), ctx);
|
|
156
|
+
expect(handled).toBe(true);
|
|
157
|
+
expect(ctx.selection.getState().activeCell.row).toBe(11);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("PageUp moves up by pageSize rows", () => {
|
|
161
|
+
const ctx = makeContext({ pageSize: 10 });
|
|
162
|
+
ctx.selection.click(20, 1);
|
|
163
|
+
handleKeyDown(makeKey("PageUp"), ctx);
|
|
164
|
+
expect(ctx.selection.getState().activeCell.row).toBe(10);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("Delete and Backspace", () => {
|
|
169
|
+
test("Delete clears cell value via command", () => {
|
|
170
|
+
const ctx = makeContext();
|
|
171
|
+
ctx.selection.click(1, 1); // A1 has "Hello"
|
|
172
|
+
handleKeyDown(makeKey("Delete"), ctx);
|
|
173
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("Backspace clears cell and starts edit", () => {
|
|
177
|
+
const editCalls: string[] = [];
|
|
178
|
+
const ctx = makeContext({
|
|
179
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
180
|
+
});
|
|
181
|
+
ctx.selection.click(1, 1);
|
|
182
|
+
handleKeyDown(makeKey("Backspace"), ctx);
|
|
183
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
|
|
184
|
+
expect(editCalls).toContain("");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("F2 — edit mode", () => {
|
|
189
|
+
test("F2 starts edit with existing cell value", () => {
|
|
190
|
+
const editCalls: string[] = [];
|
|
191
|
+
const ctx = makeContext({
|
|
192
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
193
|
+
});
|
|
194
|
+
ctx.selection.click(1, 1); // A1 = "Hello"
|
|
195
|
+
handleKeyDown(makeKey("F2"), ctx);
|
|
196
|
+
expect(editCalls).toContain("Hello");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("F2 on formula cell shows = prefix", () => {
|
|
200
|
+
const workbook = makeWorkbook();
|
|
201
|
+
workbook.sheets[0].set("D1", null, undefined, undefined, "SUM(A1:B1)");
|
|
202
|
+
const editCalls: string[] = [];
|
|
203
|
+
const ctx = makeContext({
|
|
204
|
+
workbook,
|
|
205
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
206
|
+
});
|
|
207
|
+
ctx.selection.click(1, 4); // D1
|
|
208
|
+
handleKeyDown(makeKey("F2"), ctx);
|
|
209
|
+
expect(editCalls).toContain("=SUM(A1:B1)");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("printable character starts edit", () => {
|
|
214
|
+
test("typing a letter starts edit", () => {
|
|
215
|
+
const editCalls: string[] = [];
|
|
216
|
+
const ctx = makeContext({
|
|
217
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
218
|
+
});
|
|
219
|
+
handleKeyDown(makeKey("a"), ctx);
|
|
220
|
+
expect(editCalls).toContain("a");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("typing a digit starts edit", () => {
|
|
224
|
+
const editCalls: string[] = [];
|
|
225
|
+
const ctx = makeContext({
|
|
226
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
227
|
+
});
|
|
228
|
+
handleKeyDown(makeKey("5"), ctx);
|
|
229
|
+
expect(editCalls).toContain("5");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("ctrl+key is not printable", () => {
|
|
233
|
+
const editCalls: string[] = [];
|
|
234
|
+
const ctx = makeContext({
|
|
235
|
+
onStartEdit: (text) => editCalls.push(text ?? ""),
|
|
236
|
+
});
|
|
237
|
+
handleKeyDown(makeKey("a", { ctrlKey: true }), ctx);
|
|
238
|
+
// Ctrl+A is select all, not printable edit
|
|
239
|
+
expect(editCalls).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("Ctrl+Z/Y — undo/redo", () => {
|
|
244
|
+
test("Ctrl+Z undoes last command", () => {
|
|
245
|
+
const ctx = makeContext();
|
|
246
|
+
ctx.selection.click(1, 1);
|
|
247
|
+
// Delete cell via command
|
|
248
|
+
handleKeyDown(makeKey("Delete"), ctx);
|
|
249
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
|
|
250
|
+
// Undo
|
|
251
|
+
handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
|
|
252
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeDefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("Ctrl+Y redoes undone command", () => {
|
|
256
|
+
const ctx = makeContext();
|
|
257
|
+
ctx.selection.click(1, 1);
|
|
258
|
+
handleKeyDown(makeKey("Delete"), ctx);
|
|
259
|
+
handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
|
|
260
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeDefined();
|
|
261
|
+
handleKeyDown(makeKey("y", { ctrlKey: true }), ctx);
|
|
262
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("Ctrl+Shift+Z redoes", () => {
|
|
266
|
+
const ctx = makeContext();
|
|
267
|
+
ctx.selection.click(1, 1);
|
|
268
|
+
handleKeyDown(makeKey("Delete"), ctx);
|
|
269
|
+
handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
|
|
270
|
+
handleKeyDown(makeKey("z", { ctrlKey: true, shiftKey: true }), ctx);
|
|
271
|
+
expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("Ctrl+C/X/V — clipboard", () => {
|
|
276
|
+
test("Ctrl+C copies selection to clipboard buffer", () => {
|
|
277
|
+
let storedClipboard: ClipboardData | undefined;
|
|
278
|
+
const ctx = makeContext({
|
|
279
|
+
setClipboardData: (d) => { storedClipboard = d; },
|
|
280
|
+
});
|
|
281
|
+
ctx.selection.click(1, 1);
|
|
282
|
+
handleKeyDown(makeKey("c", { ctrlKey: true }), ctx);
|
|
283
|
+
expect(storedClipboard).toBeDefined();
|
|
284
|
+
expect(storedClipboard!.cells[0][0]?.displayValue).toBe("Hello");
|
|
285
|
+
expect(storedClipboard!.isCut).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("Ctrl+X sets isCut flag", () => {
|
|
289
|
+
let storedClipboard: ClipboardData | undefined;
|
|
290
|
+
const ctx = makeContext({
|
|
291
|
+
setClipboardData: (d) => { storedClipboard = d; },
|
|
292
|
+
});
|
|
293
|
+
ctx.selection.click(1, 1);
|
|
294
|
+
handleKeyDown(makeKey("x", { ctrlKey: true }), ctx);
|
|
295
|
+
expect(storedClipboard!.isCut).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("Ctrl+B/I/U — style toggling", () => {
|
|
300
|
+
test("Ctrl+B toggles bold", () => {
|
|
301
|
+
const ctx = makeContext();
|
|
302
|
+
ctx.selection.click(1, 1);
|
|
303
|
+
handleKeyDown(makeKey("b", { ctrlKey: true }), ctx);
|
|
304
|
+
const cell = ctx.workbook.sheets[0].getCell("A1");
|
|
305
|
+
expect(cell).toBeDefined();
|
|
306
|
+
const style = ctx.workbook.styles.get(cell!.styleIndex);
|
|
307
|
+
expect(style.fontBold).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("Ctrl+I toggles italic", () => {
|
|
311
|
+
const ctx = makeContext();
|
|
312
|
+
ctx.selection.click(1, 1);
|
|
313
|
+
handleKeyDown(makeKey("i", { ctrlKey: true }), ctx);
|
|
314
|
+
const cell = ctx.workbook.sheets[0].getCell("A1");
|
|
315
|
+
const style = ctx.workbook.styles.get(cell!.styleIndex);
|
|
316
|
+
expect(style.fontItalic).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("Ctrl+A — select all", () => {
|
|
321
|
+
test("selects used range", () => {
|
|
322
|
+
const ctx = makeContext();
|
|
323
|
+
handleKeyDown(makeKey("a", { ctrlKey: true }), ctx);
|
|
324
|
+
const range = ctx.selection.getState().ranges[0];
|
|
325
|
+
// Used range is A1:C3 based on test data
|
|
326
|
+
expect(range.start).toEqual({ row: 1, col: 1 });
|
|
327
|
+
expect(range.end).toEqual({ row: 3, col: 3 });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("editing mode keys", () => {
|
|
332
|
+
test("Enter during edit commits and moves down", () => {
|
|
333
|
+
const ctx = makeContext();
|
|
334
|
+
ctx.selection.startEdit("test");
|
|
335
|
+
expect(ctx.selection.getState().editing).toBe(true);
|
|
336
|
+
handleKeyDown(makeKey("Enter"), ctx);
|
|
337
|
+
expect(ctx.selection.getState().editing).toBe(false);
|
|
338
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("Tab during edit commits and moves right", () => {
|
|
342
|
+
const ctx = makeContext();
|
|
343
|
+
ctx.selection.startEdit("test");
|
|
344
|
+
handleKeyDown(makeKey("Tab"), ctx);
|
|
345
|
+
expect(ctx.selection.getState().editing).toBe(false);
|
|
346
|
+
expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("Escape during edit cancels", () => {
|
|
350
|
+
const ctx = makeContext();
|
|
351
|
+
ctx.selection.startEdit("test");
|
|
352
|
+
handleKeyDown(makeKey("Escape"), ctx);
|
|
353
|
+
expect(ctx.selection.getState().editing).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("regular key during edit returns false (input handles it)", () => {
|
|
357
|
+
const ctx = makeContext();
|
|
358
|
+
ctx.selection.startEdit("test");
|
|
359
|
+
const handled = handleKeyDown(makeKey("a"), ctx);
|
|
360
|
+
expect(handled).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("Ctrl+Arrow — jump to data edge", () => {
|
|
365
|
+
test("Ctrl+ArrowDown jumps to last data cell in column", () => {
|
|
366
|
+
const ctx = makeContext();
|
|
367
|
+
ctx.selection.click(1, 1); // A1 has data
|
|
368
|
+
handleKeyDown(makeKey("ArrowDown", { ctrlKey: true }), ctx);
|
|
369
|
+
// A1="Hello", A2="World", A3 is empty → should jump to A2
|
|
370
|
+
expect(ctx.selection.getState().activeCell.row).toBe(2);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("Ctrl+ArrowRight jumps to last data cell in row", () => {
|
|
374
|
+
const ctx = makeContext();
|
|
375
|
+
ctx.selection.click(1, 1); // Row 1: A1, B1 have data
|
|
376
|
+
handleKeyDown(makeKey("ArrowRight", { ctrlKey: true }), ctx);
|
|
377
|
+
expect(ctx.selection.getState().activeCell.col).toBe(2); // B1
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("unhandled keys", () => {
|
|
382
|
+
test("Alt key returns false", () => {
|
|
383
|
+
const ctx = makeContext();
|
|
384
|
+
const handled = handleKeyDown(makeKey("a", { altKey: true }), ctx);
|
|
385
|
+
expect(handled).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("Escape outside editing returns false", () => {
|
|
389
|
+
const ctx = makeContext();
|
|
390
|
+
const handled = handleKeyDown(makeKey("Escape"), ctx);
|
|
391
|
+
expect(handled).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|