@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,558 @@
1
+ // =============================================================================
2
+ // Cell Editor — Inline DOM Overlay
3
+ // =============================================================================
4
+ //
5
+ // Manages a DOM <input> overlay positioned over the active cell on the canvas.
6
+ // The parent component orchestrates the lifecycle:
7
+ // - selection.startEdit() fires edit_start → CellEditor shows
8
+ // - selection.commitEdit() fires edit_end(commit=true) → CellEditor commits + hides
9
+ // - selection.cancelEdit() fires edit_end(commit=false) → CellEditor hides
10
+ //
11
+ // No React dependency — works with any DOM container.
12
+ // =============================================================================
13
+
14
+ import type { LayoutEngine } from "./canvas_layout";
15
+ import type { SelectionModel, SelectionEvent } from "./selection_model";
16
+ import { cycleReferenceAtCursor } from "./keyboard_nav";
17
+ import { getFormulaSuggestions, applyFormulaSuggestion } from "./formula_suggest";
18
+ import type { FormulaSuggestion } from "./formula_suggest";
19
+ import type { WorkbookModel } from "./workbook_model";
20
+ import type { CommandHistory } from "./command_history";
21
+ import type { FormulaEngine } from "./formula_engine";
22
+ import { setCellValueCommand } from "./spreadsheet_commands";
23
+ import { rowColToRef, refToRowCol } from "./excel_utils";
24
+ import { findValidationForCell, validateCellValue } from "./data_validation";
25
+
26
+ // =============================================================================
27
+ // Types
28
+ // =============================================================================
29
+
30
+ export interface CellEditorDeps {
31
+ workbook: WorkbookModel;
32
+ sheetIndex: number;
33
+ selection: SelectionModel;
34
+ history: CommandHistory;
35
+ engine: FormulaEngine | undefined;
36
+ layout: LayoutEngine;
37
+ /** Called when the editor text changes (for formula bar sync). */
38
+ onTextChange?: (text: string) => void;
39
+ /** Called when the editor finishes (for redraw). */
40
+ onDone?: () => void;
41
+ /** Called when data validation fails on commit. */
42
+ onValidationError?: (error: {
43
+ title: string;
44
+ message: string;
45
+ style: "stop" | "warning" | "information";
46
+ /** Called to retry editing (re-focus editor). */
47
+ onRetry: () => void;
48
+ }) => void;
49
+ }
50
+
51
+ // =============================================================================
52
+ // CellEditor
53
+ // =============================================================================
54
+
55
+ export class CellEditor {
56
+ // textarea so multi-line content (Alt+Enter) renders correctly and the
57
+ // editor can auto-grow vertically beyond the cell's natural height.
58
+ private input: HTMLTextAreaElement;
59
+ private container: HTMLElement;
60
+ private deps: CellEditorDeps;
61
+ private scrollX = 0;
62
+ private scrollY = 0;
63
+ private active = false;
64
+ /** Cell width at edit start — the editor stays at least this wide while
65
+ * growing horizontally with content. */
66
+ private baseCellW = 0;
67
+ private baseCellH = 0;
68
+ private acDropdown: HTMLDivElement;
69
+ private acItems: string[] = [];
70
+ private acIndex = -1;
71
+ /** When the autocomplete is showing function-name suggestions (formula
72
+ * mode), this holds the source data so acceptAutocomplete can splice in
73
+ * the chosen name correctly. Empty for column-value autocomplete. */
74
+ private fnSuggestions: FormulaSuggestion[] = [];
75
+ private fnTokenRange: { start: number; end: number } | null = null;
76
+
77
+ constructor(container: HTMLElement, deps: CellEditorDeps) {
78
+ this.container = container;
79
+ this.deps = deps;
80
+
81
+ this.input = document.createElement("textarea");
82
+ Object.assign(this.input.style, {
83
+ position: "absolute",
84
+ display: "none",
85
+ border: "2px solid #2563EB",
86
+ outline: "none",
87
+ padding: "0 3px",
88
+ margin: "0",
89
+ boxSizing: "border-box",
90
+ fontFamily: "Calibri, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
91
+ fontSize: "13px",
92
+ lineHeight: "1.2",
93
+ backgroundColor: "#FFFFFF",
94
+ zIndex: "10",
95
+ pointerEvents: "auto",
96
+ // Auto-grow visuals: no scrollbar, single-line by default, hidden
97
+ // overflow until we recompute size in handleInput.
98
+ resize: "none",
99
+ overflow: "hidden",
100
+ whiteSpace: "pre",
101
+ });
102
+ this.input.setAttribute("data-testid", "spreadsheet-cell-editor");
103
+ this.input.rows = 1;
104
+
105
+ this.input.addEventListener("input", this.handleInput);
106
+ this.input.addEventListener("keydown", this.handleKeyDown);
107
+ this.input.addEventListener("blur", this.handleBlur);
108
+
109
+ // Autocomplete dropdown
110
+ this.acDropdown = document.createElement("div");
111
+ Object.assign(this.acDropdown.style, {
112
+ position: "absolute",
113
+ display: "none",
114
+ backgroundColor: "#FFFFFF",
115
+ border: "1px solid #D1D5DB",
116
+ borderRadius: "4px",
117
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
118
+ zIndex: "20",
119
+ maxHeight: "150px",
120
+ overflowY: "auto",
121
+ fontSize: "13px",
122
+ fontFamily: "Calibri, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
123
+ });
124
+ this.acDropdown.setAttribute("data-testid", "spreadsheet-autocomplete");
125
+
126
+ container.appendChild(this.input);
127
+ container.appendChild(this.acDropdown);
128
+ }
129
+
130
+ /** Whether the editor is currently visible. */
131
+ get isActive(): boolean {
132
+ return this.active;
133
+ }
134
+
135
+ /** Get the current input value. */
136
+ get text(): string {
137
+ return this.input.value;
138
+ }
139
+
140
+ /**
141
+ * Handle a SelectionEvent from the selection model.
142
+ * Call this from the parent's onChange handler.
143
+ */
144
+ handleSelectionEvent(event: SelectionEvent): void {
145
+ if (event.type === "edit_start") {
146
+ this.show(event.text);
147
+ } else if (event.type === "edit_end") {
148
+ if (event.commit) {
149
+ this.commitValue(event.text);
150
+ }
151
+ this.hide();
152
+ }
153
+ }
154
+
155
+ /** Update scroll offsets (call on scroll). */
156
+ updateScroll(scrollX: number, scrollY: number): void {
157
+ this.scrollX = scrollX;
158
+ this.scrollY = scrollY;
159
+ if (this.active) {
160
+ this.positionInput();
161
+ }
162
+ }
163
+
164
+ /** Update dependencies (call on sheet change, layout rebuild, etc.). */
165
+ updateDeps(deps: Partial<CellEditorDeps>): void {
166
+ Object.assign(this.deps, deps);
167
+ }
168
+
169
+ /** Clean up DOM elements. */
170
+ destroy(): void {
171
+ this.input.removeEventListener("input", this.handleInput);
172
+ this.input.removeEventListener("keydown", this.handleKeyDown);
173
+ this.input.removeEventListener("blur", this.handleBlur);
174
+ if (this.input.parentElement === this.container) {
175
+ this.container.removeChild(this.input);
176
+ }
177
+ if (this.acDropdown.parentElement === this.container) {
178
+ this.container.removeChild(this.acDropdown);
179
+ }
180
+ }
181
+
182
+ // ===========================================================================
183
+ // Private — lifecycle
184
+ // ===========================================================================
185
+
186
+ private show(initialText: string): void {
187
+ this.active = true;
188
+ this.input.value = initialText;
189
+ this.positionInput();
190
+ this.input.style.display = "block";
191
+ requestAnimationFrame(() => {
192
+ this.input.focus();
193
+ this.input.setSelectionRange(initialText.length, initialText.length);
194
+ });
195
+ }
196
+
197
+ private hide(): void {
198
+ this.active = false;
199
+ this.input.style.display = "none";
200
+ this.input.value = "";
201
+ this.hideAutocomplete();
202
+ this.deps.onDone?.();
203
+ }
204
+
205
+ private positionInput(): void {
206
+ const { layout, selection, workbook, sheetIndex } = this.deps;
207
+ const { activeCell } = selection.getState();
208
+ const { row, col } = activeCell;
209
+
210
+ const freezeRow = layout.freeze.row;
211
+ const freezeCol = layout.freeze.col;
212
+
213
+ // Frozen cells don't scroll
214
+ const isFrozenRow = row <= freezeRow;
215
+ const isFrozenCol = col <= freezeCol;
216
+
217
+ const cellX = layout.colX[col] - (isFrozenCol ? 0 : this.scrollX);
218
+ const cellY = layout.rowY[row] - (isFrozenRow ? 0 : this.scrollY);
219
+ const cellW = layout.colW[col];
220
+ const cellH = layout.rowH[row];
221
+
222
+ // Remember the cell's natural box so the auto-grow logic in adjustSize()
223
+ // can clamp to a minimum and grow from there.
224
+ this.baseCellW = Math.max(cellW, 60);
225
+ this.baseCellH = cellH;
226
+
227
+ Object.assign(this.input.style, {
228
+ left: `${cellX}px`,
229
+ top: `${cellY}px`,
230
+ width: `${this.baseCellW}px`,
231
+ height: `${this.baseCellH}px`,
232
+ });
233
+
234
+ // Match cell font
235
+ const ref = rowColToRef(row, col);
236
+ const cell = workbook.sheets[sheetIndex].getCell(ref);
237
+ const style = workbook.styles.get(cell?.styleIndex ?? 0);
238
+ const fontSize = style.fontSize ?? 11;
239
+ const fontName = style.fontName ?? "Calibri, sans-serif";
240
+ const bold = style.fontBold ? "bold" : "normal";
241
+ const italic = style.fontItalic ? "italic" : "normal";
242
+ this.input.style.font = `${italic} ${bold} ${fontSize}px ${fontName}`;
243
+ this.input.style.color = style.fontColor ?? "#000000";
244
+
245
+ const hAlign = style.horizontalAlign;
246
+ this.input.style.textAlign = hAlign === "center" ? "center" : hAlign === "right" ? "right" : "left";
247
+ }
248
+
249
+ private commitValue(text: string): void {
250
+ const { workbook, sheetIndex, history, engine, selection } = this.deps;
251
+ const { activeCell } = selection.getState();
252
+
253
+ // Skip commit if the text hasn't changed from the cell's current content.
254
+ // This prevents formula cells from being re-committed on blur (which would
255
+ // set value=null and lose the cached result when no engine is present).
256
+ const ref = rowColToRef(activeCell.row, activeCell.col);
257
+ const cell = workbook.sheets[sheetIndex].getCell(ref);
258
+ if (cell) {
259
+ const currentText = cell.formula ? `=${cell.formula}` : String(cell.value ?? "");
260
+ if (text === currentText) return;
261
+ }
262
+
263
+ // Data validation check
264
+ const sheet = workbook.sheets[sheetIndex];
265
+ if (sheet.dataValidations.length > 0 && !text.startsWith("=")) {
266
+ const dv = findValidationForCell(sheet.dataValidations, activeCell.row, activeCell.col);
267
+ if (dv && !validateCellValue(text, dv)) {
268
+ const style = dv.errorStyle ?? "stop";
269
+ const title = dv.errorTitle ?? "Invalid Value";
270
+ const message = dv.errorMessage ?? "The value you entered is not valid.";
271
+ if (style === "stop") {
272
+ // Stop: block commit, notify parent
273
+ this.deps.onValidationError?.({
274
+ title,
275
+ message,
276
+ style,
277
+ onRetry: () => {
278
+ this.show(text);
279
+ },
280
+ });
281
+ return;
282
+ }
283
+ // Warning / Information: commit but notify
284
+ this.deps.onValidationError?.({
285
+ title,
286
+ message,
287
+ style,
288
+ onRetry: () => { this.show(text); },
289
+ });
290
+ }
291
+ }
292
+
293
+ const cmd = setCellValueCommand(
294
+ workbook,
295
+ engine,
296
+ sheetIndex,
297
+ activeCell.row,
298
+ activeCell.col,
299
+ text,
300
+ );
301
+ history.execute(cmd);
302
+ }
303
+
304
+ // ===========================================================================
305
+ // DOM event handlers (arrow functions for stable `this`)
306
+ // ===========================================================================
307
+
308
+ private handleInput = (): void => {
309
+ const text = this.input.value;
310
+ this.deps.selection.updateEditText(text);
311
+ this.deps.onTextChange?.(text);
312
+ this.updateAutocomplete(text);
313
+ this.adjustSize();
314
+ };
315
+
316
+ /**
317
+ * Auto-grow the textarea to fit its content. Width grows to the longest
318
+ * line (capped to viewport); height grows for newlines. Falls back to the
319
+ * cell's natural box on empty input.
320
+ */
321
+ private adjustSize(): void {
322
+ const value = this.input.value;
323
+ if (!value) {
324
+ this.input.style.width = `${this.baseCellW}px`;
325
+ this.input.style.height = `${this.baseCellH}px`;
326
+ return;
327
+ }
328
+ // Measure the widest line via a hidden span using the same font.
329
+ const measurer = document.createElement("span");
330
+ measurer.style.position = "absolute";
331
+ measurer.style.visibility = "hidden";
332
+ measurer.style.whiteSpace = "pre";
333
+ measurer.style.font = this.input.style.font;
334
+ measurer.textContent = value.split("\n").reduce((longest, line) =>
335
+ line.length > longest.length ? line : longest, "");
336
+ document.body.appendChild(measurer);
337
+ const textWidth = measurer.getBoundingClientRect().width;
338
+ document.body.removeChild(measurer);
339
+
340
+ const padX = 8;
341
+ const newW = Math.max(this.baseCellW, Math.ceil(textWidth) + padX);
342
+ // Cap at the viewport — long content gets a horizontal scrollbar in the
343
+ // cell rather than overflowing the page.
344
+ const containerWidth = this.container.getBoundingClientRect().width || 1200;
345
+ this.input.style.width = `${Math.min(newW, containerWidth - 20)}px`;
346
+
347
+ // Height: scrollHeight includes content height regardless of overflow,
348
+ // so resetting to 0 first lets it shrink as well as grow.
349
+ this.input.style.height = "0px";
350
+ const sh = this.input.scrollHeight;
351
+ this.input.style.height = `${Math.max(this.baseCellH, sh)}px`;
352
+ this.input.style.overflow = "hidden";
353
+ }
354
+
355
+ private handleKeyDown = (event: KeyboardEvent): void => {
356
+ // Autocomplete navigation
357
+ if (this.acItems.length > 0) {
358
+ if (event.key === "ArrowDown") {
359
+ event.preventDefault();
360
+ event.stopPropagation();
361
+ this.acIndex = Math.min(this.acIndex + 1, this.acItems.length - 1);
362
+ this.renderAutocomplete();
363
+ return;
364
+ }
365
+ if (event.key === "ArrowUp") {
366
+ event.preventDefault();
367
+ event.stopPropagation();
368
+ this.acIndex = Math.max(this.acIndex - 1, 0);
369
+ this.renderAutocomplete();
370
+ return;
371
+ }
372
+ if ((event.key === "Enter" || event.key === "Tab") && this.acIndex >= 0) {
373
+ event.preventDefault();
374
+ event.stopPropagation();
375
+ this.acceptAutocomplete(this.acItems[this.acIndex]);
376
+ return;
377
+ }
378
+ if (event.key === "Escape") {
379
+ this.hideAutocomplete();
380
+ event.stopPropagation();
381
+ return;
382
+ }
383
+ }
384
+ // F4 cycles the cell reference at the cursor through absolute states
385
+ // (A1 → $A$1 → A$1 → $A1 → A1) — Excel parity for formula authoring.
386
+ // Only active in formula mode; outside a formula F4 must fall through so
387
+ // the host can use it for its own shortcut (e.g., redo).
388
+ if (event.key === "F4" && this.input.value.startsWith("=")) {
389
+ event.preventDefault();
390
+ event.stopPropagation();
391
+ const cursor = this.input.selectionStart ?? this.input.value.length;
392
+ const result = cycleReferenceAtCursor(this.input.value, cursor);
393
+ if (result) {
394
+ this.input.value = result.text;
395
+ this.input.setSelectionRange(result.cursor, result.cursor);
396
+ this.deps.selection.updateEditText(result.text);
397
+ this.deps.onTextChange?.(result.text);
398
+ this.adjustSize();
399
+ }
400
+ return;
401
+ }
402
+ // Alt+Enter inserts a literal newline inside the cell (multi-line text).
403
+ if (event.key === "Enter" && event.altKey) {
404
+ event.preventDefault();
405
+ event.stopPropagation();
406
+ const start = this.input.selectionStart ?? this.input.value.length;
407
+ const end = this.input.selectionEnd ?? this.input.value.length;
408
+ const next = this.input.value.slice(0, start) + "\n" + this.input.value.slice(end);
409
+ this.input.value = next;
410
+ this.input.setSelectionRange(start + 1, start + 1);
411
+ this.deps.selection.updateEditText(next);
412
+ this.deps.onTextChange?.(next);
413
+ this.adjustSize();
414
+ return;
415
+ }
416
+ // Enter, Tab, Escape bubble up to the parent's keyboard handler
417
+ // which calls selection.commitEdit()/cancelEdit()/move().
418
+ // All other keys stay local to the input.
419
+ if (event.key !== "Enter" && event.key !== "Tab" && event.key !== "Escape") {
420
+ event.stopPropagation();
421
+ }
422
+ };
423
+
424
+ // ===========================================================================
425
+ // Autocomplete
426
+ // ===========================================================================
427
+
428
+ private updateAutocomplete(text: string): void {
429
+ // Formula mode: function-name suggestions based on the token at the cursor.
430
+ if (text.startsWith("=")) {
431
+ const cursor = this.input.selectionStart ?? text.length;
432
+ const { suggestions, tokenStart, tokenEnd } = getFormulaSuggestions(text, cursor);
433
+ if (suggestions.length === 0) {
434
+ this.hideAutocomplete();
435
+ return;
436
+ }
437
+ this.fnSuggestions = suggestions;
438
+ this.fnTokenRange = { start: tokenStart, end: tokenEnd };
439
+ this.acItems = suggestions.map((s) => s.name);
440
+ this.acIndex = -1;
441
+ this.renderAutocomplete();
442
+ return;
443
+ }
444
+ // Reset formula-mode state when not in a formula.
445
+ this.fnSuggestions = [];
446
+ this.fnTokenRange = null;
447
+ if (!text) {
448
+ this.hideAutocomplete();
449
+ return;
450
+ }
451
+ const { workbook, sheetIndex, selection } = this.deps;
452
+ const sheet = workbook.sheets[sheetIndex];
453
+ const col = selection.getState().activeCell.col;
454
+ const currentRow = selection.getState().activeCell.row;
455
+
456
+ // Collect unique non-empty string values from this column
457
+ const seen = new Set<string>();
458
+ for (const [ref, cell] of sheet.cells) {
459
+ const rc = refToRowCol(ref);
460
+ if (!rc || rc.col !== col || rc.row === currentRow) continue;
461
+ const dv = cell.displayValue;
462
+ if (dv && typeof cell.value === "string") seen.add(dv);
463
+ }
464
+
465
+ // Filter by case-insensitive prefix
466
+ const prefix = text.toLowerCase();
467
+ const matches = Array.from(seen)
468
+ .filter((v) => v.toLowerCase().startsWith(prefix) && v.toLowerCase() !== prefix)
469
+ .sort((a, b) => a.localeCompare(b))
470
+ .slice(0, 8);
471
+
472
+ if (matches.length === 0) {
473
+ this.hideAutocomplete();
474
+ return;
475
+ }
476
+
477
+ this.acItems = matches;
478
+ this.acIndex = -1;
479
+ this.renderAutocomplete();
480
+ }
481
+
482
+ private renderAutocomplete(): void {
483
+ this.acDropdown.innerHTML = "";
484
+ for (let i = 0; i < this.acItems.length; i++) {
485
+ const item = document.createElement("div");
486
+ item.textContent = this.acItems[i];
487
+ Object.assign(item.style, {
488
+ padding: "4px 8px",
489
+ cursor: "pointer",
490
+ backgroundColor: i === this.acIndex ? "#EFF6FF" : "transparent",
491
+ color: "#1F2937",
492
+ });
493
+ item.addEventListener("mousedown", (e) => {
494
+ e.preventDefault(); // prevent blur
495
+ this.acceptAutocomplete(this.acItems[i]);
496
+ });
497
+ item.addEventListener("mouseenter", () => {
498
+ this.acIndex = i;
499
+ this.renderAutocomplete();
500
+ });
501
+ this.acDropdown.appendChild(item);
502
+ }
503
+
504
+ // Position below the input
505
+ const inputRect = this.input.getBoundingClientRect();
506
+ const containerRect = this.container.getBoundingClientRect();
507
+ Object.assign(this.acDropdown.style, {
508
+ display: "block",
509
+ left: `${inputRect.left - containerRect.left}px`,
510
+ top: `${inputRect.bottom - containerRect.top}px`,
511
+ width: `${Math.max(inputRect.width, 120)}px`,
512
+ });
513
+ }
514
+
515
+ private hideAutocomplete(): void {
516
+ this.acItems = [];
517
+ this.acIndex = -1;
518
+ this.fnSuggestions = [];
519
+ this.fnTokenRange = null;
520
+ this.acDropdown.style.display = "none";
521
+ this.acDropdown.innerHTML = "";
522
+ }
523
+
524
+ private acceptAutocomplete(value: string): void {
525
+ // Formula-mode acceptance splices in the function name + "(" at the
526
+ // current token range. Column-value mode replaces the whole input.
527
+ if (this.fnSuggestions.length > 0 && this.fnTokenRange) {
528
+ const suggestion = this.fnSuggestions.find((s) => s.name === value);
529
+ if (suggestion) {
530
+ const { text, cursor } = applyFormulaSuggestion(
531
+ this.input.value,
532
+ suggestion,
533
+ this.fnTokenRange,
534
+ );
535
+ this.input.value = text;
536
+ this.deps.selection.updateEditText(text);
537
+ this.deps.onTextChange?.(text);
538
+ this.input.setSelectionRange(cursor, cursor);
539
+ this.hideAutocomplete();
540
+ this.adjustSize();
541
+ return;
542
+ }
543
+ }
544
+ this.input.value = value;
545
+ this.deps.selection.updateEditText(value);
546
+ this.deps.onTextChange?.(value);
547
+ this.hideAutocomplete();
548
+ // Move cursor to end
549
+ this.input.setSelectionRange(value.length, value.length);
550
+ this.adjustSize();
551
+ }
552
+
553
+ private handleBlur = (): void => {
554
+ if (this.active) {
555
+ this.deps.selection.commitEdit();
556
+ }
557
+ };
558
+ }
@@ -0,0 +1,145 @@
1
+ import {
2
+ copyRange,
3
+ pasteRange,
4
+ clipboardToText,
5
+ clipboardToHtml,
6
+ adjustFormulaRefs,
7
+ } from "./clipboard";
8
+ import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
9
+
10
+ function makeWorkbook(): WorkbookModel {
11
+ const styles = new StyleRegistry();
12
+ const sheet = new SheetModel("Sheet1", styles);
13
+ return new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
14
+ }
15
+
16
+ describe("copyRange", () => {
17
+ test("copies cell data", () => {
18
+ const wb = makeWorkbook();
19
+ wb.sheets[0].set("A1", 10);
20
+ wb.sheets[0].set("B1", 20);
21
+ wb.sheets[0].set("A2", 30);
22
+
23
+ const clip = copyRange(wb, 0, 1, 1, 2, 2);
24
+ expect(clip.rowCount).toBe(2);
25
+ expect(clip.colCount).toBe(2);
26
+ expect(clip.cells[0][0]?.value).toBe(10);
27
+ expect(clip.cells[0][1]?.value).toBe(20);
28
+ expect(clip.cells[1][0]?.value).toBe(30);
29
+ expect(clip.cells[1][1]).toBeUndefined(); // B2 is empty
30
+ });
31
+
32
+ test("copies formulas", () => {
33
+ const wb = makeWorkbook();
34
+ wb.sheets[0].set("A1", null, undefined, undefined, "SUM(B1:B5)");
35
+
36
+ const clip = copyRange(wb, 0, 1, 1, 1, 1);
37
+ expect(clip.cells[0][0]?.formula).toBe("SUM(B1:B5)");
38
+ });
39
+
40
+ test("isCut flag", () => {
41
+ const wb = makeWorkbook();
42
+ wb.sheets[0].set("A1", 10);
43
+
44
+ const copy = copyRange(wb, 0, 1, 1, 1, 1, false);
45
+ expect(copy.isCut).toBe(false);
46
+
47
+ const cut = copyRange(wb, 0, 1, 1, 1, 1, true);
48
+ expect(cut.isCut).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("pasteRange", () => {
53
+ test("pastes cell data to target", () => {
54
+ const wb = makeWorkbook();
55
+ wb.sheets[0].set("A1", 10);
56
+ wb.sheets[0].set("B1", 20);
57
+
58
+ const clip = copyRange(wb, 0, 1, 1, 1, 2);
59
+ const result = pasteRange(wb, clip, 0, 3, 1); // Paste to row 3
60
+
61
+ expect(wb.sheets[0].getCell("A3")?.value).toBe(10);
62
+ expect(wb.sheets[0].getCell("B3")?.value).toBe(20);
63
+ expect(result.written).toHaveLength(2);
64
+ });
65
+
66
+ test("adjusts formula references on copy-paste", () => {
67
+ const wb = makeWorkbook();
68
+ wb.sheets[0].set("A1", null, undefined, undefined, "SUM(B1:B5)");
69
+
70
+ const clip = copyRange(wb, 0, 1, 1, 1, 1);
71
+ pasteRange(wb, clip, 0, 3, 3); // Paste to C3 (dRow=2, dCol=2)
72
+
73
+ expect(wb.sheets[0].getCell("C3")?.formula).toBe("SUM(D3:D7)");
74
+ });
75
+
76
+ test("does not adjust formula on cut-paste", () => {
77
+ const wb = makeWorkbook();
78
+ wb.sheets[0].set("A1", null, undefined, undefined, "SUM(B1:B5)");
79
+
80
+ const clip = copyRange(wb, 0, 1, 1, 1, 1, true);
81
+ pasteRange(wb, clip, 0, 3, 3);
82
+
83
+ expect(wb.sheets[0].getCell("C3")?.formula).toBe("SUM(B1:B5)");
84
+ });
85
+ });
86
+
87
+ describe("clipboardToText", () => {
88
+ test("converts to tab-separated text", () => {
89
+ const wb = makeWorkbook();
90
+ wb.sheets[0].set("A1", "Hello");
91
+ wb.sheets[0].set("B1", "World");
92
+ wb.sheets[0].set("A2", 42);
93
+
94
+ const clip = copyRange(wb, 0, 1, 1, 2, 2);
95
+ const text = clipboardToText(clip);
96
+ expect(text).toBe("Hello\tWorld\n42\t");
97
+ });
98
+ });
99
+
100
+ describe("clipboardToHtml", () => {
101
+ test("generates HTML table", () => {
102
+ const wb = makeWorkbook();
103
+ const boldIdx = wb.styles.intern({ fontBold: true });
104
+ wb.sheets[0].set("A1", "Bold", wb.styles.get(boldIdx));
105
+ wb.sheets[0].set("B1", "Normal");
106
+
107
+ const clip = copyRange(wb, 0, 1, 1, 1, 2);
108
+ const html = clipboardToHtml(clip, wb.styles);
109
+
110
+ expect(html).toContain("<table>");
111
+ expect(html).toContain("font-weight:bold");
112
+ expect(html).toContain("Bold");
113
+ expect(html).toContain("Normal");
114
+ });
115
+ });
116
+
117
+ describe("adjustFormulaRefs", () => {
118
+ test("adjusts simple cell ref", () => {
119
+ expect(adjustFormulaRefs("A1+B2", 2, 3)).toBe("D3+E4");
120
+ });
121
+
122
+ test("adjusts range ref", () => {
123
+ expect(adjustFormulaRefs("SUM(A1:A10)", 5, 0)).toBe("SUM(A6:A15)");
124
+ });
125
+
126
+ test("preserves absolute column ($A)", () => {
127
+ expect(adjustFormulaRefs("$A1", 0, 3)).toBe("$A1");
128
+ });
129
+
130
+ test("preserves absolute row ($1)", () => {
131
+ expect(adjustFormulaRefs("A$1", 3, 0)).toBe("A$1");
132
+ });
133
+
134
+ test("preserves fully absolute ($A$1)", () => {
135
+ expect(adjustFormulaRefs("$A$1", 5, 5)).toBe("$A$1");
136
+ });
137
+
138
+ test("returns #REF! for out-of-bounds", () => {
139
+ expect(adjustFormulaRefs("A1", -1, 0)).toBe("#REF!");
140
+ });
141
+
142
+ test("handles mixed refs in formula", () => {
143
+ expect(adjustFormulaRefs("A1+$B$2+C3", 1, 1)).toBe("B2+$B$2+D4");
144
+ });
145
+ });