@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,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
|
+
});
|