@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,364 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Selection Model
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Manages spreadsheet selection state: active cell, selected ranges,
|
|
6
|
+
// drag state, and fill handle.
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
import { rowColToRef } from "./excel_utils";
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export interface CellRef {
|
|
16
|
+
row: number;
|
|
17
|
+
col: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CellRange {
|
|
21
|
+
start: CellRef;
|
|
22
|
+
end: CellRef;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SelectionState {
|
|
26
|
+
/** The active cell (where keyboard input goes). */
|
|
27
|
+
activeCell: CellRef;
|
|
28
|
+
/** Selected ranges. At least one (containing activeCell). */
|
|
29
|
+
ranges: CellRange[];
|
|
30
|
+
/** Whether a drag is in progress. */
|
|
31
|
+
dragging: boolean;
|
|
32
|
+
/** Whether a fill-handle drag is in progress. */
|
|
33
|
+
fillDragging: boolean;
|
|
34
|
+
/** The range being extended to during fill drag. */
|
|
35
|
+
fillTarget?: CellRange;
|
|
36
|
+
/** Edit mode: inline cell editing. */
|
|
37
|
+
editing: boolean;
|
|
38
|
+
/** The text being edited (only valid when editing=true). */
|
|
39
|
+
editText: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SelectionEvent =
|
|
43
|
+
| { type: "active_cell_changed"; cell: CellRef }
|
|
44
|
+
| { type: "selection_changed"; ranges: CellRange[] }
|
|
45
|
+
| { type: "edit_start"; cell: CellRef; text: string }
|
|
46
|
+
| { type: "edit_end"; cell: CellRef; text: string; commit: boolean }
|
|
47
|
+
| { type: "fill_drag"; target: CellRange };
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// SelectionModel
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
export class SelectionModel {
|
|
54
|
+
private state: SelectionState;
|
|
55
|
+
private maxRow: number;
|
|
56
|
+
private maxCol: number;
|
|
57
|
+
onChange: ((event: SelectionEvent) => void) | undefined;
|
|
58
|
+
|
|
59
|
+
constructor(maxRow: number, maxCol: number) {
|
|
60
|
+
this.maxRow = maxRow;
|
|
61
|
+
this.maxCol = maxCol;
|
|
62
|
+
this.state = {
|
|
63
|
+
activeCell: { row: 1, col: 1 },
|
|
64
|
+
ranges: [{ start: { row: 1, col: 1 }, end: { row: 1, col: 1 } }],
|
|
65
|
+
dragging: false,
|
|
66
|
+
fillDragging: false,
|
|
67
|
+
editing: false,
|
|
68
|
+
editText: "",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get current state (read-only snapshot). */
|
|
73
|
+
getState(): Readonly<SelectionState> {
|
|
74
|
+
return this.state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Get the A1-style reference for the active cell. */
|
|
78
|
+
getActiveCellRef(): string {
|
|
79
|
+
return rowColToRef(this.state.activeCell.row, this.state.activeCell.col);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get the A1-style reference for the selection range (e.g., "A1:C3"). */
|
|
83
|
+
getSelectionRef(): string {
|
|
84
|
+
const range = this.state.ranges[0];
|
|
85
|
+
if (!range) return this.getActiveCellRef();
|
|
86
|
+
const nr = normalizeRange(range);
|
|
87
|
+
if (nr.start.row === nr.end.row && nr.start.col === nr.end.col) {
|
|
88
|
+
return rowColToRef(nr.start.row, nr.start.col);
|
|
89
|
+
}
|
|
90
|
+
return `${rowColToRef(nr.start.row, nr.start.col)}:${rowColToRef(nr.end.row, nr.end.col)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ===========================================================================
|
|
94
|
+
// Mouse interactions
|
|
95
|
+
// ===========================================================================
|
|
96
|
+
|
|
97
|
+
/** Single click: set active cell and selection. */
|
|
98
|
+
click(row: number, col: number, shiftKey = false, ctrlKey = false): void {
|
|
99
|
+
if (this.state.editing) {
|
|
100
|
+
this.commitEdit();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
row = clamp(row, 1, this.maxRow);
|
|
104
|
+
col = clamp(col, 1, this.maxCol);
|
|
105
|
+
|
|
106
|
+
if (shiftKey) {
|
|
107
|
+
// Extend selection from active cell to clicked cell
|
|
108
|
+
const active = this.state.activeCell;
|
|
109
|
+
this.state.ranges = [{
|
|
110
|
+
start: { ...active },
|
|
111
|
+
end: { row, col },
|
|
112
|
+
}];
|
|
113
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (ctrlKey) {
|
|
118
|
+
// Add new range
|
|
119
|
+
this.state.ranges.push({
|
|
120
|
+
start: { row, col },
|
|
121
|
+
end: { row, col },
|
|
122
|
+
});
|
|
123
|
+
this.state.activeCell = { row, col };
|
|
124
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
125
|
+
this.emit({ type: "active_cell_changed", cell: this.state.activeCell });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Simple click
|
|
130
|
+
this.state.activeCell = { row, col };
|
|
131
|
+
this.state.ranges = [{ start: { row, col }, end: { row, col } }];
|
|
132
|
+
this.emit({ type: "active_cell_changed", cell: this.state.activeCell });
|
|
133
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Start drag selection. */
|
|
137
|
+
dragStart(row: number, col: number): void {
|
|
138
|
+
this.state.dragging = true;
|
|
139
|
+
this.click(row, col);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Continue drag. */
|
|
143
|
+
dragMove(row: number, col: number): void {
|
|
144
|
+
if (!this.state.dragging) return;
|
|
145
|
+
row = clamp(row, 1, this.maxRow);
|
|
146
|
+
col = clamp(col, 1, this.maxCol);
|
|
147
|
+
const active = this.state.activeCell;
|
|
148
|
+
this.state.ranges = [{ start: { ...active }, end: { row, col } }];
|
|
149
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** End drag. */
|
|
153
|
+
dragEnd(): void {
|
|
154
|
+
this.state.dragging = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ===========================================================================
|
|
158
|
+
// Keyboard navigation
|
|
159
|
+
// ===========================================================================
|
|
160
|
+
|
|
161
|
+
/** Move active cell by delta. */
|
|
162
|
+
move(dRow: number, dCol: number, shiftKey = false): void {
|
|
163
|
+
if (this.state.editing) {
|
|
164
|
+
this.commitEdit();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const active = this.state.activeCell;
|
|
168
|
+
const newRow = clamp(active.row + dRow, 1, this.maxRow);
|
|
169
|
+
const newCol = clamp(active.col + dCol, 1, this.maxCol);
|
|
170
|
+
|
|
171
|
+
if (shiftKey) {
|
|
172
|
+
// Extend selection
|
|
173
|
+
const range = this.state.ranges[0] ?? { start: { ...active }, end: { ...active } };
|
|
174
|
+
this.state.ranges = [{ start: range.start, end: { row: newRow, col: newCol } }];
|
|
175
|
+
this.state.activeCell = { row: newRow, col: newCol };
|
|
176
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
177
|
+
} else {
|
|
178
|
+
this.state.activeCell = { row: newRow, col: newCol };
|
|
179
|
+
this.state.ranges = [{ start: { row: newRow, col: newCol }, end: { row: newRow, col: newCol } }];
|
|
180
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
181
|
+
}
|
|
182
|
+
this.emit({ type: "active_cell_changed", cell: this.state.activeCell });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Move to a specific cell. */
|
|
186
|
+
goTo(row: number, col: number): void {
|
|
187
|
+
if (this.state.editing) this.commitEdit();
|
|
188
|
+
row = clamp(row, 1, this.maxRow);
|
|
189
|
+
col = clamp(col, 1, this.maxCol);
|
|
190
|
+
this.state.activeCell = { row, col };
|
|
191
|
+
this.state.ranges = [{ start: { row, col }, end: { row, col } }];
|
|
192
|
+
this.emit({ type: "active_cell_changed", cell: this.state.activeCell });
|
|
193
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Programmatically select a multi-cell range. Active cell is set to top-left. */
|
|
197
|
+
selectRange(startRow: number, startCol: number, endRow: number, endCol: number): void {
|
|
198
|
+
if (this.state.editing) this.commitEdit();
|
|
199
|
+
startRow = clamp(startRow, 1, this.maxRow);
|
|
200
|
+
startCol = clamp(startCol, 1, this.maxCol);
|
|
201
|
+
endRow = clamp(endRow, 1, this.maxRow);
|
|
202
|
+
endCol = clamp(endCol, 1, this.maxCol);
|
|
203
|
+
this.state.activeCell = { row: startRow, col: startCol };
|
|
204
|
+
this.state.ranges = [{ start: { row: startRow, col: startCol }, end: { row: endRow, col: endCol } }];
|
|
205
|
+
this.emit({ type: "active_cell_changed", cell: this.state.activeCell });
|
|
206
|
+
this.emit({ type: "selection_changed", ranges: this.state.ranges });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ===========================================================================
|
|
210
|
+
// Cell editing
|
|
211
|
+
// ===========================================================================
|
|
212
|
+
|
|
213
|
+
/** Enter edit mode on the active cell. */
|
|
214
|
+
startEdit(initialText?: string): void {
|
|
215
|
+
if (this.state.editing) return;
|
|
216
|
+
this.state.editing = true;
|
|
217
|
+
this.state.editText = initialText ?? "";
|
|
218
|
+
this.emit({
|
|
219
|
+
type: "edit_start",
|
|
220
|
+
cell: this.state.activeCell,
|
|
221
|
+
text: this.state.editText,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Update the edit text. */
|
|
226
|
+
updateEditText(text: string): void {
|
|
227
|
+
this.state.editText = text;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Commit the edit (Enter). */
|
|
231
|
+
commitEdit(): void {
|
|
232
|
+
if (!this.state.editing) return;
|
|
233
|
+
this.state.editing = false;
|
|
234
|
+
this.emit({
|
|
235
|
+
type: "edit_end",
|
|
236
|
+
cell: this.state.activeCell,
|
|
237
|
+
text: this.state.editText,
|
|
238
|
+
commit: true,
|
|
239
|
+
});
|
|
240
|
+
this.state.editText = "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Cancel the edit (Escape). */
|
|
244
|
+
cancelEdit(): void {
|
|
245
|
+
if (!this.state.editing) return;
|
|
246
|
+
this.state.editing = false;
|
|
247
|
+
this.emit({
|
|
248
|
+
type: "edit_end",
|
|
249
|
+
cell: this.state.activeCell,
|
|
250
|
+
text: this.state.editText,
|
|
251
|
+
commit: false,
|
|
252
|
+
});
|
|
253
|
+
this.state.editText = "";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ===========================================================================
|
|
257
|
+
// Fill handle drag
|
|
258
|
+
// ===========================================================================
|
|
259
|
+
|
|
260
|
+
/** Start a fill-handle drag from the current selection. */
|
|
261
|
+
fillDragStart(): void {
|
|
262
|
+
this.state.fillDragging = true;
|
|
263
|
+
this.state.fillTarget = undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Update the fill target during a fill drag. Extends from the active selection
|
|
267
|
+
* to the drag position, constrained to row or column based on drag direction. */
|
|
268
|
+
fillDragMove(row: number, col: number): void {
|
|
269
|
+
if (!this.state.fillDragging) return;
|
|
270
|
+
row = clamp(row, 1, this.maxRow);
|
|
271
|
+
col = clamp(col, 1, this.maxCol);
|
|
272
|
+
|
|
273
|
+
const range = this.state.ranges[0];
|
|
274
|
+
if (!range) return;
|
|
275
|
+
const nr = normalizeRange(range);
|
|
276
|
+
|
|
277
|
+
// Determine drag direction: compare distance from selection edge
|
|
278
|
+
const dRow = row > nr.end.row ? row - nr.end.row : nr.start.row > row ? nr.start.row - row : 0;
|
|
279
|
+
const dCol = col > nr.end.col ? col - nr.end.col : nr.start.col > col ? nr.start.col - col : 0;
|
|
280
|
+
|
|
281
|
+
if (dRow === 0 && dCol === 0) {
|
|
282
|
+
this.state.fillTarget = undefined;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (dRow >= dCol) {
|
|
287
|
+
// Extend vertically: keep same columns as selection
|
|
288
|
+
if (row > nr.end.row) {
|
|
289
|
+
this.state.fillTarget = {
|
|
290
|
+
start: { row: nr.end.row + 1, col: nr.start.col },
|
|
291
|
+
end: { row, col: nr.end.col },
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
this.state.fillTarget = {
|
|
295
|
+
start: { row, col: nr.start.col },
|
|
296
|
+
end: { row: nr.start.row - 1, col: nr.end.col },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Extend horizontally: keep same rows as selection
|
|
301
|
+
if (col > nr.end.col) {
|
|
302
|
+
this.state.fillTarget = {
|
|
303
|
+
start: { row: nr.start.row, col: nr.end.col + 1 },
|
|
304
|
+
end: { row: nr.end.row, col },
|
|
305
|
+
};
|
|
306
|
+
} else {
|
|
307
|
+
this.state.fillTarget = {
|
|
308
|
+
start: { row: nr.start.row, col },
|
|
309
|
+
end: { row: nr.end.row, col: nr.start.col - 1 },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** End fill drag. Returns the fill target range and resets state. */
|
|
316
|
+
fillDragEnd(): CellRange | undefined {
|
|
317
|
+
const target = this.state.fillTarget;
|
|
318
|
+
this.state.fillDragging = false;
|
|
319
|
+
this.state.fillTarget = undefined;
|
|
320
|
+
if (target) {
|
|
321
|
+
this.emit({ type: "fill_drag", target });
|
|
322
|
+
}
|
|
323
|
+
return target;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
// Bounds
|
|
328
|
+
// ===========================================================================
|
|
329
|
+
|
|
330
|
+
updateBounds(maxRow: number, maxCol: number): void {
|
|
331
|
+
this.maxRow = maxRow;
|
|
332
|
+
this.maxCol = maxCol;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ===========================================================================
|
|
336
|
+
// Event emission
|
|
337
|
+
// ===========================================================================
|
|
338
|
+
|
|
339
|
+
private emit(event: SelectionEvent): void {
|
|
340
|
+
this.onChange?.(event);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// =============================================================================
|
|
345
|
+
// Helpers
|
|
346
|
+
// =============================================================================
|
|
347
|
+
|
|
348
|
+
function clamp(v: number, min: number, max: number): number {
|
|
349
|
+
return Math.max(min, Math.min(max, v));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Normalize a range so start <= end. */
|
|
353
|
+
export function normalizeRange(range: CellRange): CellRange {
|
|
354
|
+
return {
|
|
355
|
+
start: {
|
|
356
|
+
row: Math.min(range.start.row, range.end.row),
|
|
357
|
+
col: Math.min(range.start.col, range.end.col),
|
|
358
|
+
},
|
|
359
|
+
end: {
|
|
360
|
+
row: Math.max(range.start.row, range.end.row),
|
|
361
|
+
col: Math.max(range.start.col, range.end.col),
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|