@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,891 @@
1
+ // =============================================================================
2
+ // Keyboard Navigation
3
+ // =============================================================================
4
+ //
5
+ // Pure TypeScript keyboard event handler for the spreadsheet editor.
6
+ // Translates KeyboardEvent into selection model, command, and clipboard actions.
7
+ // No DOM dependencies beyond the KeyboardEvent interface.
8
+ // =============================================================================
9
+
10
+ import type { SelectionModel } from "./selection_model";
11
+ import { normalizeRange } from "./selection_model";
12
+ import type { WorkbookModel } from "./workbook_model";
13
+ import type { CommandHistory } from "./command_history";
14
+ import type { FormulaEngine } from "./formula_engine";
15
+ import type { ClipboardData } from "./clipboard";
16
+ import {
17
+ setRangeStyleCommand,
18
+ clearRangeCommand,
19
+ setHiddenRowsCommand,
20
+ setHiddenColsCommand,
21
+ setCellValueCommand,
22
+ } from "./spreadsheet_commands";
23
+ import { copyRange, pasteRange, clipboardToText, clipboardToHtml } from "./clipboard";
24
+ import { rowColToRef } from "./excel_utils";
25
+
26
+ // =============================================================================
27
+ // Context
28
+ // =============================================================================
29
+
30
+ export interface KeyboardNavContext {
31
+ selection: SelectionModel;
32
+ workbook: WorkbookModel;
33
+ sheetIndex: number;
34
+ history: CommandHistory;
35
+ engine: FormulaEngine | undefined;
36
+ /** Internal clipboard buffer. */
37
+ clipboardData: ClipboardData | undefined;
38
+ setClipboardData: (data: ClipboardData | undefined) => void;
39
+ /** Callback to start inline cell editing. */
40
+ onStartEdit: (initialText?: string) => void;
41
+ /** Callback to request a canvas redraw. */
42
+ onRedraw: () => void;
43
+ /** Callback to open paste special dialog. */
44
+ onPasteSpecial?: () => void;
45
+ /** Callback to open find/replace bar. */
46
+ onFindReplace?: (replaceMode: boolean) => void;
47
+ /** Callback to save/download the workbook. */
48
+ onSave?: () => void;
49
+ /** Viewport size for page up/down (in rows). */
50
+ pageSize: number;
51
+ }
52
+
53
+ // =============================================================================
54
+ // Main handler
55
+ // =============================================================================
56
+
57
+ /**
58
+ * Handle a keydown event. Returns true if the event was consumed
59
+ * (caller should preventDefault).
60
+ */
61
+ export function handleKeyDown(
62
+ event: KeyboardEvent,
63
+ ctx: KeyboardNavContext,
64
+ ): boolean {
65
+ const { selection, workbook, sheetIndex, history } = ctx;
66
+ const state = selection.getState();
67
+ const ctrl = event.ctrlKey || event.metaKey;
68
+ const shift = event.shiftKey;
69
+
70
+ // If currently editing, only handle navigation keys that exit editing
71
+ if (state.editing) {
72
+ return handleEditingKeys(event, ctx);
73
+ }
74
+
75
+ // --- Ctrl shortcuts ---
76
+ if (ctrl) {
77
+ switch (event.key.toLowerCase()) {
78
+ case "z":
79
+ if (shift) {
80
+ history.redo();
81
+ } else {
82
+ history.undo();
83
+ }
84
+ ctx.onRedraw();
85
+ return true;
86
+
87
+ case "y":
88
+ history.redo();
89
+ ctx.onRedraw();
90
+ return true;
91
+
92
+ case "c":
93
+ handleCopy(ctx, false);
94
+ return true;
95
+
96
+ case "x":
97
+ handleCopy(ctx, true);
98
+ return true;
99
+
100
+ case "v":
101
+ if (shift && ctx.onPasteSpecial) {
102
+ ctx.onPasteSpecial();
103
+ } else {
104
+ handlePasteWithFallback(ctx);
105
+ }
106
+ return true;
107
+
108
+ case "b":
109
+ toggleStyle(ctx, "fontBold");
110
+ return true;
111
+
112
+ case "i":
113
+ toggleStyle(ctx, "fontItalic");
114
+ return true;
115
+
116
+ case "u":
117
+ toggleStyle(ctx, "fontUnderline");
118
+ return true;
119
+
120
+ case "a":
121
+ selectAll(ctx);
122
+ return true;
123
+
124
+ case "f":
125
+ ctx.onFindReplace?.(false);
126
+ return true;
127
+
128
+ case "h":
129
+ ctx.onFindReplace?.(true);
130
+ return true;
131
+
132
+ case "s":
133
+ ctx.onSave?.();
134
+ return true;
135
+
136
+ case "home":
137
+ selection.goTo(1, 1);
138
+ ctx.onRedraw();
139
+ return true;
140
+
141
+ case "end": {
142
+ // Ctrl+End → last cell of the used range (Excel parity).
143
+ const sheet = workbook.sheets[sheetIndex];
144
+ const used = sheet.getUsedRange();
145
+ const targetRow = Math.max(used.maxRow || 1, 1);
146
+ const targetCol = Math.max(used.maxCol || 1, 1);
147
+ selection.goTo(targetRow, targetCol);
148
+ ctx.onRedraw();
149
+ return true;
150
+ }
151
+
152
+ case ";": {
153
+ // Ctrl+; → insert today's date as YYYY-MM-DD (Excel parity).
154
+ // Excel writes the date as a serial; we write the rendered string,
155
+ // which our number-format engine will recognize as a date.
156
+ const today = formatToday();
157
+ commitToActiveCell(ctx, today);
158
+ return true;
159
+ }
160
+
161
+ case ":": {
162
+ // Ctrl+Shift+; (US layout) — same key code as ":" — inserts current
163
+ // time. We accept the bare ":" key as well for non-US layouts where
164
+ // ":" is its own key.
165
+ const time = formatNow();
166
+ commitToActiveCell(ctx, time);
167
+ return true;
168
+ }
169
+
170
+ case "9": {
171
+ // Ctrl+9 → hide selected rows.
172
+ const sheet = workbook.sheets[sheetIndex];
173
+ const range = state.ranges[0];
174
+ if (!range) return false;
175
+ const sr = Math.min(range.start.row, range.end.row);
176
+ const er = Math.max(range.start.row, range.end.row);
177
+ const newHidden = new Set(sheet.hiddenRows);
178
+ for (let r = sr; r <= er; r++) newHidden.add(r);
179
+ ctx.history.execute(setHiddenRowsCommand(workbook, sheetIndex, newHidden));
180
+ ctx.onRedraw();
181
+ return true;
182
+ }
183
+
184
+ case "0": {
185
+ // Ctrl+0 → hide selected columns.
186
+ const sheet = workbook.sheets[sheetIndex];
187
+ const range = state.ranges[0];
188
+ if (!range) return false;
189
+ const sc = Math.min(range.start.col, range.end.col);
190
+ const ec = Math.max(range.start.col, range.end.col);
191
+ const newHidden = new Set(sheet.hiddenCols);
192
+ for (let c = sc; c <= ec; c++) newHidden.add(c);
193
+ ctx.history.execute(setHiddenColsCommand(workbook, sheetIndex, newHidden));
194
+ ctx.onRedraw();
195
+ return true;
196
+ }
197
+ }
198
+ // Ctrl+Arrow: jump to edge of data region
199
+ if (isArrowKey(event.key)) {
200
+ handleCtrlArrow(event.key, ctx);
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+
206
+ // --- Non-ctrl keys ---
207
+ switch (event.key) {
208
+ case "ArrowUp":
209
+ selection.move(-1, 0, shift);
210
+ ctx.onRedraw();
211
+ return true;
212
+
213
+ case "ArrowDown":
214
+ selection.move(1, 0, shift);
215
+ ctx.onRedraw();
216
+ return true;
217
+
218
+ case "ArrowLeft":
219
+ selection.move(0, -1, shift);
220
+ ctx.onRedraw();
221
+ return true;
222
+
223
+ case "ArrowRight":
224
+ selection.move(0, 1, shift);
225
+ ctx.onRedraw();
226
+ return true;
227
+
228
+ case "Tab": {
229
+ // When the active cell is inside a multi-cell selection, wrap within
230
+ // the range — pressing Tab past the last column moves to the next
231
+ // row's first column. Excel and Sheets both do this; without it,
232
+ // "fill in this row" workflows break the second the user reaches the
233
+ // edge.
234
+ const range = state.ranges[0];
235
+ if (range && rangeIsMulti(range)) {
236
+ moveWithinRange(selection, range, "horizontal", shift);
237
+ } else {
238
+ selection.move(0, shift ? -1 : 1);
239
+ }
240
+ ctx.onRedraw();
241
+ return true;
242
+ }
243
+
244
+ case "Enter": {
245
+ const range = state.ranges[0];
246
+ if (range && rangeIsMulti(range)) {
247
+ moveWithinRange(selection, range, "vertical", shift);
248
+ } else {
249
+ selection.move(shift ? -1 : 1, 0);
250
+ }
251
+ ctx.onRedraw();
252
+ return true;
253
+ }
254
+
255
+ case "Escape":
256
+ return false;
257
+
258
+ case "Home":
259
+ selection.goTo(state.activeCell.row, 1);
260
+ ctx.onRedraw();
261
+ return true;
262
+
263
+ case "End":
264
+ // End key alone does nothing — handled in combo with arrow
265
+ return false;
266
+
267
+ case "PageUp":
268
+ selection.move(-ctx.pageSize, 0, shift);
269
+ ctx.onRedraw();
270
+ return true;
271
+
272
+ case "PageDown":
273
+ selection.move(ctx.pageSize, 0, shift);
274
+ ctx.onRedraw();
275
+ return true;
276
+
277
+ case "Delete":
278
+ handleDelete(ctx);
279
+ return true;
280
+
281
+ case "Backspace":
282
+ handleDelete(ctx);
283
+ ctx.onStartEdit("");
284
+ return true;
285
+
286
+ case "F2": {
287
+ const ref = rowColToRef(state.activeCell.row, state.activeCell.col);
288
+ const cell = workbook.sheets[sheetIndex].getCell(ref);
289
+ const existing = cell?.formula ? `=${cell.formula}` : cell?.displayValue ?? "";
290
+ ctx.onStartEdit(existing);
291
+ return true;
292
+ }
293
+ }
294
+
295
+ // Printable character → enter edit mode with that character (replace mode)
296
+ if (isPrintableKey(event)) {
297
+ ctx.onStartEdit(event.key);
298
+ return true;
299
+ }
300
+
301
+ return false;
302
+ }
303
+
304
+ // =============================================================================
305
+ // Keys while editing
306
+ // =============================================================================
307
+
308
+ function handleEditingKeys(
309
+ event: KeyboardEvent,
310
+ ctx: KeyboardNavContext,
311
+ ): boolean {
312
+ const { selection } = ctx;
313
+ const shift = event.shiftKey;
314
+
315
+ switch (event.key) {
316
+ case "Enter":
317
+ // Alt+Enter inserts a newline inside the cell (Excel parity); plain
318
+ // Enter commits and advances. The input element handles the literal
319
+ // newline insert; we just signal "don't commit" so the editor keeps
320
+ // focus.
321
+ if (event.altKey) {
322
+ return false;
323
+ }
324
+ selection.commitEdit();
325
+ selection.move(shift ? -1 : 1, 0);
326
+ ctx.onRedraw();
327
+ return true;
328
+
329
+ case "Tab":
330
+ selection.commitEdit();
331
+ selection.move(0, shift ? -1 : 1);
332
+ ctx.onRedraw();
333
+ return true;
334
+
335
+ case "Escape":
336
+ selection.cancelEdit();
337
+ ctx.onRedraw();
338
+ return true;
339
+
340
+ case "F4":
341
+ // F4 in edit mode cycles the reference type at the cursor:
342
+ // A1 → $A$1 → A$1 → $A1 → A1
343
+ // Returning false lets the input handle its native default; the actual
344
+ // cycle is implemented at the editor input layer (cell_editor.ts) since
345
+ // it needs cursor position. We just preventDefault here so the browser
346
+ // doesn't trigger Find Next.
347
+ return false;
348
+ }
349
+
350
+ // All other keys are handled by the input element itself
351
+ return false;
352
+ }
353
+
354
+ // =============================================================================
355
+ // F4 reference-type cycling (called by the cell editor input)
356
+ // =============================================================================
357
+
358
+ /**
359
+ * Cycle the reference at the given cursor position through the four absolute
360
+ * states. Returns the new text + new cursor position, or null if no reference
361
+ * is found at the cursor.
362
+ *
363
+ * A1 → $A$1
364
+ * $A$1 → A$1
365
+ * A$1 → $A1
366
+ * $A1 → A1
367
+ */
368
+ export function cycleReferenceAtCursor(
369
+ text: string,
370
+ cursor: number,
371
+ ): { text: string; cursor: number } | null {
372
+ // Match a cell reference touching the cursor. Walk backwards/forwards from
373
+ // the cursor to find a contiguous reference token.
374
+ const refRegex = /(\$?)([A-Za-z]+)(\$?)([0-9]+)/g;
375
+ let match: RegExpExecArray | null;
376
+ while ((match = refRegex.exec(text)) !== null) {
377
+ const start = match.index;
378
+ const end = start + match[0].length;
379
+ if (cursor < start || cursor > end) continue;
380
+ const colAbs = match[1] === "$";
381
+ const rowAbs = match[3] === "$";
382
+ let nextCol: boolean;
383
+ let nextRow: boolean;
384
+ if (!colAbs && !rowAbs) { nextCol = true; nextRow = true; }
385
+ else if (colAbs && rowAbs) { nextCol = false; nextRow = true; }
386
+ else if (!colAbs && rowAbs) { nextCol = true; nextRow = false; }
387
+ else { nextCol = false; nextRow = false; }
388
+ const newRef = `${nextCol ? "$" : ""}${match[2]}${nextRow ? "$" : ""}${match[4]}`;
389
+ const newText = text.slice(0, start) + newRef + text.slice(end);
390
+ return { text: newText, cursor: start + newRef.length };
391
+ }
392
+ return null;
393
+ }
394
+
395
+ // =============================================================================
396
+ // Copy / Cut (exported for toolbar use)
397
+ // =============================================================================
398
+
399
+ export function performCopy(ctx: KeyboardNavContext, isCut: boolean): void {
400
+ handleCopy(ctx, isCut);
401
+ }
402
+
403
+ export function performPaste(ctx: KeyboardNavContext): void {
404
+ handlePasteWithFallback(ctx);
405
+ }
406
+
407
+ function handleCopy(ctx: KeyboardNavContext, isCut: boolean): void {
408
+ const { selection, workbook, sheetIndex } = ctx;
409
+ const range = normalizeRange(selection.getState().ranges[0]);
410
+ const data = copyRange(
411
+ workbook,
412
+ sheetIndex,
413
+ range.start.row,
414
+ range.start.col,
415
+ range.end.row,
416
+ range.end.col,
417
+ isCut,
418
+ );
419
+ ctx.setClipboardData(data);
420
+
421
+ // Write to system clipboard
422
+ const text = clipboardToText(data);
423
+ const html = clipboardToHtml(data, workbook.styles);
424
+ writeToSystemClipboard(text, html);
425
+ }
426
+
427
+ // =============================================================================
428
+ // Paste
429
+ // =============================================================================
430
+
431
+ function handlePaste(ctx: KeyboardNavContext): void {
432
+ const { selection, workbook, sheetIndex, engine, history } = ctx;
433
+ const clipboard = ctx.clipboardData;
434
+ if (!clipboard) return;
435
+
436
+ const active = selection.getState().activeCell;
437
+ const targetSheet = workbook.sheets[sheetIndex];
438
+
439
+ // Snapshot target cells before paste so we can undo
440
+ const snapshot = new Map<string, { value: import("./workbook_model").CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string } | null>();
441
+ for (let r = 0; r < clipboard.rowCount; r++) {
442
+ for (let c = 0; c < clipboard.colCount; c++) {
443
+ const ref = rowColToRef(active.row + r, active.col + c);
444
+ const existing = targetSheet.getCell(ref);
445
+ snapshot.set(ref, existing ? {
446
+ value: existing.value,
447
+ displayValue: existing.displayValue,
448
+ formula: existing.formula,
449
+ styleIndex: existing.styleIndex,
450
+ numFmtCode: existing.numFmtCode,
451
+ } : null);
452
+ }
453
+ }
454
+
455
+ // Snapshot cut source cells if needed
456
+ const cutSnapshot = new Map<string, { value: import("./workbook_model").CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string }>();
457
+ if (clipboard.isCut) {
458
+ const srcSheet = workbook.sheets[clipboard.sourceSheet];
459
+ for (let r = 0; r < clipboard.rowCount; r++) {
460
+ for (let c = 0; c < clipboard.colCount; c++) {
461
+ const ref = rowColToRef(clipboard.origin.row + r, clipboard.origin.col + c);
462
+ const cell = srcSheet.getCell(ref);
463
+ if (cell) {
464
+ cutSnapshot.set(ref, {
465
+ value: cell.value,
466
+ displayValue: cell.displayValue,
467
+ formula: cell.formula,
468
+ styleIndex: cell.styleIndex,
469
+ numFmtCode: cell.numFmtCode,
470
+ });
471
+ }
472
+ }
473
+ }
474
+ }
475
+
476
+ const wasCut = clipboard.isCut;
477
+ const sourceSheetIndex = clipboard.sourceSheet;
478
+
479
+ const cmd = {
480
+ description: `Paste at ${rowColToRef(active.row, active.col)}`,
481
+ execute() {
482
+ const result = pasteRange(workbook, clipboard, sheetIndex, active.row, active.col);
483
+
484
+ if (wasCut) {
485
+ const srcSheet = workbook.sheets[sourceSheetIndex];
486
+ for (let r = 0; r < clipboard.rowCount; r++) {
487
+ for (let c = 0; c < clipboard.colCount; c++) {
488
+ const ref = rowColToRef(clipboard.origin.row + r, clipboard.origin.col + c);
489
+ srcSheet.deleteCell(ref);
490
+ }
491
+ }
492
+ }
493
+
494
+ if (engine) {
495
+ const sheet = workbook.sheets[sheetIndex];
496
+ for (const w of result.written) {
497
+ engine.onCellChanged(sheet.name, w.ref, w.formula);
498
+ }
499
+ }
500
+ },
501
+ undo() {
502
+ // Restore target cells from snapshot
503
+ for (const [ref, snap] of snapshot) {
504
+ if (snap) {
505
+ targetSheet.set(ref, snap.value, targetSheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
506
+ } else {
507
+ targetSheet.deleteCell(ref);
508
+ }
509
+ if (engine) {
510
+ engine.onCellChanged(targetSheet.name, ref, snap?.formula);
511
+ }
512
+ }
513
+
514
+ // Restore cut source cells
515
+ if (wasCut) {
516
+ const srcSheet = workbook.sheets[sourceSheetIndex];
517
+ for (const [ref, snap] of cutSnapshot) {
518
+ srcSheet.set(ref, snap.value, srcSheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
519
+ if (engine) {
520
+ engine.onCellChanged(srcSheet.name, ref, snap.formula);
521
+ }
522
+ }
523
+ }
524
+ },
525
+ };
526
+ history.execute(cmd);
527
+
528
+ if (wasCut) {
529
+ ctx.setClipboardData(undefined);
530
+ }
531
+
532
+ ctx.onRedraw();
533
+ }
534
+
535
+ // =============================================================================
536
+ // Delete / Clear
537
+ // =============================================================================
538
+
539
+ function handleDelete(ctx: KeyboardNavContext): void {
540
+ const { selection, workbook, sheetIndex, history, engine } = ctx;
541
+ const range = normalizeRange(selection.getState().ranges[0]);
542
+
543
+ const cmd = clearRangeCommand(
544
+ workbook,
545
+ engine,
546
+ sheetIndex,
547
+ range.start.row,
548
+ range.start.col,
549
+ range.end.row,
550
+ range.end.col,
551
+ );
552
+ history.execute(cmd);
553
+ ctx.onRedraw();
554
+ }
555
+
556
+ // =============================================================================
557
+ // Style toggling
558
+ // =============================================================================
559
+
560
+ type ToggleableBoolStyle = "fontBold" | "fontItalic";
561
+ type ToggleableUnderlineStyle = "fontUnderline";
562
+
563
+ function toggleStyle(
564
+ ctx: KeyboardNavContext,
565
+ prop: ToggleableBoolStyle | ToggleableUnderlineStyle,
566
+ ): void {
567
+ const { selection, workbook, sheetIndex, history } = ctx;
568
+ const range = normalizeRange(selection.getState().ranges[0]);
569
+ const active = selection.getState().activeCell;
570
+ const ref = rowColToRef(active.row, active.col);
571
+ const cell = workbook.sheets[sheetIndex].getCell(ref);
572
+ const style = workbook.styles.get(cell?.styleIndex ?? 0);
573
+
574
+ let override: Record<string, unknown>;
575
+ if (prop === "fontUnderline") {
576
+ override = { fontUnderline: style.fontUnderline ? undefined : "single" as const };
577
+ } else {
578
+ override = { [prop]: !style[prop] };
579
+ }
580
+
581
+ const cmd = setRangeStyleCommand(
582
+ workbook,
583
+ sheetIndex,
584
+ range.start.row,
585
+ range.start.col,
586
+ range.end.row,
587
+ range.end.col,
588
+ override,
589
+ );
590
+ history.execute(cmd);
591
+ ctx.onRedraw();
592
+ }
593
+
594
+ // =============================================================================
595
+ // Select All
596
+ // =============================================================================
597
+
598
+ function selectAll(ctx: KeyboardNavContext): void {
599
+ const { selection, workbook, sheetIndex } = ctx;
600
+ const sheet = workbook.sheets[sheetIndex];
601
+ const used = sheet.getUsedRange();
602
+ selection.click(1, 1);
603
+ selection.click(used.maxRow, used.maxCol, true);
604
+ ctx.onRedraw();
605
+ }
606
+
607
+ // =============================================================================
608
+ // Ctrl+Arrow — jump to data edge
609
+ // =============================================================================
610
+
611
+ function handleCtrlArrow(key: string, ctx: KeyboardNavContext): void {
612
+ const { selection, workbook, sheetIndex } = ctx;
613
+ const state = selection.getState();
614
+ const active = state.activeCell;
615
+ const sheet = workbook.sheets[sheetIndex];
616
+ const used = sheet.getUsedRange();
617
+
618
+ let targetRow = active.row;
619
+ let targetCol = active.col;
620
+
621
+ const hasData = (r: number, c: number): boolean => {
622
+ const ref = rowColToRef(r, c);
623
+ const cell = sheet.getCell(ref);
624
+ return cell !== undefined && cell.value !== null && cell.value !== "";
625
+ };
626
+
627
+ switch (key) {
628
+ case "ArrowUp":
629
+ targetRow = findEdge(active.row, -1, 1, used.maxRow, (r) => hasData(r, active.col));
630
+ break;
631
+ case "ArrowDown":
632
+ targetRow = findEdge(active.row, 1, 1, used.maxRow, (r) => hasData(r, active.col));
633
+ break;
634
+ case "ArrowLeft":
635
+ targetCol = findEdge(active.col, -1, 1, used.maxCol, (c) => hasData(active.row, c));
636
+ break;
637
+ case "ArrowRight":
638
+ targetCol = findEdge(active.col, 1, 1, used.maxCol, (c) => hasData(active.row, c));
639
+ break;
640
+ }
641
+
642
+ if (ctx.selection.getState().dragging) return;
643
+ selection.goTo(targetRow, targetCol);
644
+ ctx.onRedraw();
645
+ }
646
+
647
+ /**
648
+ * Find the edge of a data region.
649
+ * If current position has data, jump to the last consecutive data cell in the direction.
650
+ * If current position is empty, jump to the next data cell in the direction.
651
+ * If no data found, jump to the boundary.
652
+ */
653
+ function findEdge(
654
+ current: number,
655
+ direction: 1 | -1,
656
+ min: number,
657
+ max: number,
658
+ hasData: (pos: number) => boolean,
659
+ ): number {
660
+ const currentHasData = hasData(current);
661
+ let pos = current + direction;
662
+
663
+ if (currentHasData) {
664
+ // Skip consecutive data cells
665
+ while (pos >= min && pos <= max && hasData(pos)) {
666
+ pos += direction;
667
+ }
668
+ // Back up to last data cell (or stay if we hit a non-data immediately)
669
+ pos -= direction;
670
+ if (pos === current) {
671
+ // No consecutive data — jump to next data cell or boundary
672
+ pos = current + direction;
673
+ while (pos >= min && pos <= max && !hasData(pos)) {
674
+ pos += direction;
675
+ }
676
+ if (pos < min || pos > max) {
677
+ return direction === -1 ? min : max;
678
+ }
679
+ }
680
+ } else {
681
+ // Current empty — find next data cell
682
+ while (pos >= min && pos <= max && !hasData(pos)) {
683
+ pos += direction;
684
+ }
685
+ if (pos < min || pos > max) {
686
+ return direction === -1 ? min : max;
687
+ }
688
+ }
689
+
690
+ return pos;
691
+ }
692
+
693
+ // =============================================================================
694
+ // Helpers
695
+ // =============================================================================
696
+
697
+ // =============================================================================
698
+ // Tab/Enter wrap within a multi-cell selection range
699
+ // =============================================================================
700
+
701
+ function rangeIsMulti(range: import("./selection_model").CellRange): boolean {
702
+ return range.start.row !== range.end.row || range.start.col !== range.end.col;
703
+ }
704
+
705
+ /**
706
+ * Move the active cell inside an existing range with wrap-around. Direction
707
+ * is "horizontal" (Tab) or "vertical" (Enter). When `reverse` is true, wraps
708
+ * backwards; e.g. Shift+Tab from the top-left corner wraps to the
709
+ * bottom-right of the range.
710
+ */
711
+ function moveWithinRange(
712
+ selection: import("./selection_model").SelectionModel,
713
+ range: import("./selection_model").CellRange,
714
+ direction: "horizontal" | "vertical",
715
+ reverse: boolean,
716
+ ): void {
717
+ const sr = Math.min(range.start.row, range.end.row);
718
+ const er = Math.max(range.start.row, range.end.row);
719
+ const sc = Math.min(range.start.col, range.end.col);
720
+ const ec = Math.max(range.start.col, range.end.col);
721
+ const width = ec - sc + 1;
722
+ const height = er - sr + 1;
723
+ const total = width * height;
724
+
725
+ const { activeCell } = selection.getState();
726
+ // Linearize current position within the range, in row-major or col-major
727
+ // order depending on direction.
728
+ const rowIdx = activeCell.row - sr;
729
+ const colIdx = activeCell.col - sc;
730
+ let linear = direction === "horizontal"
731
+ ? rowIdx * width + colIdx
732
+ : colIdx * height + rowIdx;
733
+ linear = (linear + (reverse ? -1 : 1) + total) % total;
734
+
735
+ let newRow: number;
736
+ let newCol: number;
737
+ if (direction === "horizontal") {
738
+ newRow = sr + Math.floor(linear / width);
739
+ newCol = sc + (linear % width);
740
+ } else {
741
+ newCol = sc + Math.floor(linear / height);
742
+ newRow = sr + (linear % height);
743
+ }
744
+ selection.goTo(newRow, newCol);
745
+ }
746
+
747
+ // =============================================================================
748
+ // Date / Time insertion (Ctrl+; and Ctrl+:)
749
+ // =============================================================================
750
+
751
+ function formatToday(): string {
752
+ const d = new Date();
753
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
754
+ }
755
+
756
+ function formatNow(): string {
757
+ const d = new Date();
758
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
759
+ }
760
+
761
+ /**
762
+ * Commit a string to the active cell via setCellValueCommand. Used by Ctrl+;
763
+ * (date) and Ctrl+: (time) — both bypass edit mode and write directly.
764
+ */
765
+ function commitToActiveCell(ctx: KeyboardNavContext, text: string): void {
766
+ const { row, col } = ctx.selection.getState().activeCell;
767
+ ctx.history.execute(setCellValueCommand(ctx.workbook, ctx.engine, ctx.sheetIndex, row, col, text));
768
+ ctx.onRedraw();
769
+ }
770
+
771
+ function isArrowKey(key: string): boolean {
772
+ return key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight";
773
+ }
774
+
775
+ function isPrintableKey(event: KeyboardEvent): boolean {
776
+ // Single character, not a control/meta key
777
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
778
+ if (event.key.length !== 1) return false;
779
+ // Exclude non-printable ranges
780
+ const code = event.key.charCodeAt(0);
781
+ return code >= 0x20; // space and above
782
+ }
783
+
784
+ /**
785
+ * Paste from internal clipboard if available, otherwise read from system clipboard.
786
+ * System clipboard text is parsed as TSV and pasted as values.
787
+ */
788
+ async function handlePasteWithFallback(ctx: KeyboardNavContext): Promise<void> {
789
+ if (ctx.clipboardData) {
790
+ handlePaste(ctx);
791
+ return;
792
+ }
793
+
794
+ // No internal clipboard — try reading from system clipboard
795
+ const systemData = await readSystemClipboard();
796
+ if (!systemData) return;
797
+
798
+ const { selection, workbook, sheetIndex, history, engine } = ctx;
799
+ const active = selection.getState().activeCell;
800
+ const sheet = workbook.sheets[sheetIndex];
801
+
802
+ // Parse TSV into a grid of strings
803
+ const rows = systemData.split("\n").filter((line) => line.length > 0);
804
+ const grid = rows.map((row) => row.split("\t"));
805
+
806
+ // Snapshot target cells for undo
807
+ const snapshot = new Map<string, { value: import("./workbook_model").CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string } | null>();
808
+ for (let r = 0; r < grid.length; r++) {
809
+ for (let c = 0; c < grid[r].length; c++) {
810
+ const ref = rowColToRef(active.row + r, active.col + c);
811
+ const existing = sheet.getCell(ref);
812
+ snapshot.set(ref, existing ? {
813
+ value: existing.value,
814
+ displayValue: existing.displayValue,
815
+ formula: existing.formula,
816
+ styleIndex: existing.styleIndex,
817
+ numFmtCode: existing.numFmtCode,
818
+ } : null);
819
+ }
820
+ }
821
+
822
+ const cmd = {
823
+ description: `Paste from clipboard at ${rowColToRef(active.row, active.col)}`,
824
+ execute() {
825
+ for (let r = 0; r < grid.length; r++) {
826
+ for (let c = 0; c < grid[r].length; c++) {
827
+ const ref = rowColToRef(active.row + r, active.col + c);
828
+ const existing = sheet.getCell(ref);
829
+ const styleIndex = existing?.styleIndex ?? 0;
830
+ const numFmtCode = existing?.numFmtCode ?? "General";
831
+ const text = grid[r][c];
832
+ const value = inferValue(text);
833
+ sheet.set(ref, value, sheet.styles?.get(styleIndex), numFmtCode);
834
+ engine?.onCellChanged(sheet.name, ref, undefined);
835
+ }
836
+ }
837
+ },
838
+ undo() {
839
+ for (const [ref, snap] of snapshot) {
840
+ if (snap) {
841
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
842
+ } else {
843
+ sheet.deleteCell(ref);
844
+ }
845
+ engine?.onCellChanged(sheet.name, ref, snap?.formula);
846
+ }
847
+ },
848
+ };
849
+ history.execute(cmd);
850
+ ctx.onRedraw();
851
+ }
852
+
853
+ /** Infer a typed value from clipboard text. */
854
+ function inferValue(text: string): import("./workbook_model").CellValue {
855
+ if (!text) return null;
856
+ if (text === "TRUE") return true;
857
+ if (text === "FALSE") return false;
858
+ const num = Number(text);
859
+ if (!isNaN(num) && text.trim() !== "") return num;
860
+ return text;
861
+ }
862
+
863
+ async function readSystemClipboard(): Promise<string | null> {
864
+ try {
865
+ if (navigator?.clipboard?.readText) {
866
+ return await navigator.clipboard.readText();
867
+ }
868
+ } catch {
869
+ // Clipboard read may not be permitted
870
+ }
871
+ return null;
872
+ }
873
+
874
+ async function writeToSystemClipboard(text: string, html: string): Promise<void> {
875
+ try {
876
+ if (navigator?.clipboard?.write) {
877
+ const blob = new Blob([html], { type: "text/html" });
878
+ const textBlob = new Blob([text], { type: "text/plain" });
879
+ await navigator.clipboard.write([
880
+ new ClipboardItem({
881
+ "text/html": blob,
882
+ "text/plain": textBlob,
883
+ }),
884
+ ]);
885
+ } else if (navigator?.clipboard?.writeText) {
886
+ await navigator.clipboard.writeText(text);
887
+ }
888
+ } catch {
889
+ // Clipboard API may not be available in all contexts
890
+ }
891
+ }