@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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. 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
+ }