@dimarborda/xlsx-editor 0.1.0 → 0.3.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/src/engine.js CHANGED
@@ -73,6 +73,12 @@ function columnNumber(col) {
73
73
  throw new TypeError(`Columna no válida: ${col} (usa una letra como 'B' o un número desde 1).`);
74
74
  }
75
75
 
76
+ /** A row or column number, or a count, for the engine */
77
+ function lineNumber(value, what) {
78
+ if (!Number.isInteger(value) || value < 0) throw new TypeError(`${what} debe ser un entero positivo: ${value}`);
79
+ return value;
80
+ }
81
+
76
82
  /** One worksheet of a workbook */
77
83
  export class Sheet {
78
84
  /**
@@ -96,6 +102,11 @@ export class Sheet {
96
102
  return this.getCell(ref).value;
97
103
  }
98
104
 
105
+ /** The cells of a range (`A1:C3`), by row: their type, value and formula, if any */
106
+ getCells(range) {
107
+ return JSON.parse(call(() => this.workbook._engine.range_json(this.index, range))).map(row => row.map(toCell));
108
+ }
109
+
99
110
  /** The values of a range (`A1:C3`), by row */
100
111
  getValues(range) {
101
112
  return JSON.parse(call(() => this.workbook._engine.range_json(this.index, range))).map(row => row.map(c => toCell(c).value));
@@ -132,8 +143,9 @@ export class Sheet {
132
143
 
133
144
  /**
134
145
  * Drawing API: every cell that shows something, with the text Excel shows for it in the
135
- * language `locale`, as `[row, col, style, text, kind, color?]` (`kind`: `n`, `s`, `b`, `e`
136
- * or `''` for an empty cell with a format; `style` indexes `workbook.styleView().xfs`)
146
+ * language `locale`, as `[row, col, style, text, kind, color?, number?]` (`kind`: `n`, `s`,
147
+ * `b`, `e` or `''` for an empty cell with a format; `style` indexes
148
+ * `workbook.styleView().xfs`; `number` is the value of `n` cells, dates as serial numbers)
137
149
  * @param {string} [locale]
138
150
  */
139
151
  cellViews(locale = 'en') {
@@ -183,6 +195,114 @@ export class Sheet {
183
195
  call(() => this.workbook._engine.set_column_width(this.index, columnNumber(a), columnNumber(b), width));
184
196
  }
185
197
 
198
+ /**
199
+ * Moves whole rows, as Excel's "cut and insert": `count` rows from `first` go before the row
200
+ * `before` (numbered as they are now). Formats, heights, merged cells, formulas of the whole
201
+ * workbook, defined names, charts, tables, validations, conditional formats, images and
202
+ * comments follow. Throws, and changes nothing, when it would tear merged cells, an array
203
+ * formula, a table or a filter. Returns where the first row ends up.
204
+ * @param {number} first
205
+ * @param {number} count
206
+ * @param {number} before
207
+ */
208
+ moveRows(first, count, before) {
209
+ const [a, n, b] = [lineNumber(first, 'La fila'), lineNumber(count, 'La cantidad'), lineNumber(before, 'El destino')];
210
+ return call(() => this.workbook._engine.move_rows(this.index, a, n, b));
211
+ }
212
+
213
+ /**
214
+ * Moves whole columns (see `moveRows`); `first` and `before` are letters (`'B'`) or numbers
215
+ * @param {string | number} first
216
+ * @param {number} count
217
+ * @param {string | number} before
218
+ */
219
+ moveColumns(first, count, before) {
220
+ const [a, n, b] = [columnNumber(first), lineNumber(count, 'La cantidad'), columnNumber(before)];
221
+ return call(() => this.workbook._engine.move_columns(this.index, a, n, b));
222
+ }
223
+
224
+ /**
225
+ * Inserts `count` empty rows before the row `before`, with the height and format of the one
226
+ * above. Formulas, names, charts and everything that `moveRows` follows are updated; ranges and
227
+ * merged cells that the new rows fall inside grow, also tables. Returns `before`.
228
+ * @param {number} before
229
+ * @param {number} [count]
230
+ */
231
+ insertRows(before, count = 1) {
232
+ const [b, n] = [lineNumber(before, 'La fila'), lineNumber(count, 'La cantidad')];
233
+ return call(() => this.workbook._engine.insert_rows(this.index, b, n));
234
+ }
235
+
236
+ /** Inserts empty columns before `before` (letters or number), like the one to their left */
237
+ insertColumns(before, count = 1) {
238
+ const [b, n] = [columnNumber(before), lineNumber(count, 'La cantidad')];
239
+ return call(() => this.workbook._engine.insert_columns(this.index, b, n));
240
+ }
241
+
242
+ /**
243
+ * Deletes `count` rows from `first`. What pointed only at them becomes `#REF!`; ranges and
244
+ * merged cells shrink, also tables. Returns `first`.
245
+ * @param {number} first
246
+ * @param {number} [count]
247
+ */
248
+ deleteRows(first, count = 1) {
249
+ const [a, n] = [lineNumber(first, 'La fila'), lineNumber(count, 'La cantidad')];
250
+ return call(() => this.workbook._engine.delete_rows(this.index, a, n));
251
+ }
252
+
253
+ /** Deletes columns from `first` (letters or number), see `deleteRows` */
254
+ deleteColumns(first, count = 1) {
255
+ const [a, n] = [columnNumber(first), lineNumber(count, 'La cantidad')];
256
+ return call(() => this.workbook._engine.delete_columns(this.index, a, n));
257
+ }
258
+
259
+ /**
260
+ * Height of a row or rows (`3`, `'3:5'`) in points; `null` goes back to the automatic height
261
+ * @param {string | number} rows
262
+ * @param {number | null} height
263
+ */
264
+ setRowHeight(rows, height) {
265
+ const [a, b = a] = typeof rows === 'string' ? rows.split(':').map(Number) : [rows];
266
+ if (height !== null && (typeof height !== 'number' || !Number.isFinite(height))) throw new TypeError('El alto debe ser un número de puntos o null.');
267
+ const [first, last] = [lineNumber(a, 'La fila'), lineNumber(b, 'La fila')];
268
+ call(() => this.workbook._engine.set_row_height(this.index, first, last, height ?? undefined));
269
+ }
270
+
271
+ /**
272
+ * Hides or shows rows (`3`, `'3:5'`); they keep their cells and height
273
+ * @param {string | number} rows
274
+ * @param {boolean} hidden
275
+ */
276
+ setRowsHidden(rows, hidden) {
277
+ const [a, b = a] = typeof rows === 'string' ? rows.split(':').map(Number) : [rows];
278
+ const [first, last] = [lineNumber(a, 'La fila'), lineNumber(b, 'La fila')];
279
+ call(() => this.workbook._engine.set_rows_hidden(this.index, first, last, !!hidden));
280
+ }
281
+
282
+ /**
283
+ * Hides or shows columns (`'B'`, `'B:D'`, `2`); they keep their cells and width
284
+ * @param {string | number} columns
285
+ * @param {boolean} hidden
286
+ */
287
+ setColumnsHidden(columns, hidden) {
288
+ const [a, b = a] = typeof columns === 'string' ? columns.split(':') : [columns];
289
+ const [first, last] = [columnNumber(a), columnNumber(b)];
290
+ call(() => this.workbook._engine.set_columns_hidden(this.index, first, last, !!hidden));
291
+ }
292
+
293
+ /**
294
+ * Fills `target` from `source`, the cells at one of its ends, as Excel's fill handle: formats
295
+ * repeat, formulas move their references, and with `series` (by default) numbers continue their
296
+ * trend, one date goes on day by day, texts ending in a number count on and names of months and
297
+ * days follow their order. Refuses to fill over merged cells.
298
+ * @param {string} source e.g. `A1:A2`
299
+ * @param {string} target e.g. `A1:A20`
300
+ * @param {{series?: boolean}} [options]
301
+ */
302
+ fill(source, target, { series = true } = {}) {
303
+ call(() => this.workbook._engine.fill_range(this.index, source, target, !!series));
304
+ }
305
+
186
306
  /** Merges a range into one cell; only its first cell may hold a value */
187
307
  mergeCells(range) {
188
308
  call(() => this.workbook._engine.merge_cells(this.index, range));
@@ -223,6 +343,16 @@ export class Workbook {
223
343
  return JSON.parse(call(() => this._engine.styles_view_json()));
224
344
  }
225
345
 
346
+ /**
347
+ * Numbers as a cell format shows them: `style` indexes `styleView().xfs`
348
+ * @param {number} style
349
+ * @param {number[]} values
350
+ * @param {string} [locale]
351
+ */
352
+ formatNumbers(style, values, locale = 'en') {
353
+ return call(() => this._engine.format_numbers(style, Float64Array.from(values), locale));
354
+ }
355
+
226
356
  /**
227
357
  * A worksheet by name (ignoring case) or by tab position, from 0
228
358
  * @param {string | number} nameOrIndex
@@ -241,6 +371,43 @@ export class Workbook {
241
371
  return new Sheet(this, index, all[index].name);
242
372
  }
243
373
 
374
+ /**
375
+ * Adds an empty worksheet after the tab `after` (name or position; at the end without it).
376
+ * Names follow Excel's rules: 1–31 characters, none of `[ ] : * ? / \`, unique ignoring case.
377
+ * Returns the new sheet.
378
+ * @param {string} name
379
+ * @param {string | number} [after]
380
+ */
381
+ addSheet(name, after) {
382
+ const index = call(() => this._engine.add_sheet(String(name), after === undefined || after === null ? undefined : this.#tabIndex(after)));
383
+ return this.sheet(index);
384
+ }
385
+
386
+ /**
387
+ * Renames a tab (name or position) and the references to it in formulas, defined names,
388
+ * data validations, conditional formats and charts; returns how many of those changed
389
+ * @param {string | number} sheet
390
+ * @param {string} name
391
+ */
392
+ renameSheet(sheet, name) {
393
+ return call(() => this._engine.rename_sheet(this.#tabIndex(sheet), String(name)));
394
+ }
395
+
396
+ /** Position of a tab by name (ignoring case) or position */
397
+ #tabIndex(nameOrIndex) {
398
+ const all = JSON.parse(this._engine.sheets_json());
399
+ if (typeof nameOrIndex === 'number') {
400
+ if (!Number.isInteger(nameOrIndex) || nameOrIndex < 0 || nameOrIndex >= all.length) {
401
+ throw new RangeError(`El libro tiene ${all.length} hoja(s); no existe la hoja ${nameOrIndex}.`);
402
+ }
403
+ return nameOrIndex;
404
+ }
405
+ let index = all.findIndex(s => s.name === nameOrIndex);
406
+ if (index < 0) index = all.findIndex(s => s.name.toLowerCase() === String(nameOrIndex).toLowerCase());
407
+ if (index < 0) throw new Error(`El libro no tiene ninguna hoja llamada '${nameOrIndex}'.`);
408
+ return index;
409
+ }
410
+
244
411
  /**
245
412
  * Replaces `{{FIELD}}` placeholders (also `{{ FIELD }}` and `{{FIELD: hint}}`) in every text
246
413
  * of the workbook; returns how many
package/src/find.js ADDED
@@ -0,0 +1,37 @@
1
+ // Searching cell texts as Excel does: `*` any text, `?` one character, `~` takes either literally.
2
+ // No DOM here, so it is testable in Node.
3
+
4
+ /**
5
+ * The search as a global regular expression; null for an empty search
6
+ * @param {string} query
7
+ * @param {{matchCase?: boolean, wholeCell?: boolean}} [options]
8
+ */
9
+ export function searchPattern(query, { matchCase = false, wholeCell = false } = {}) {
10
+ if (!query) return null;
11
+ // With the `u` flag only characters with a meaning may be escaped
12
+ const literal = c => c.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&');
13
+ let source = '';
14
+ const chars = [...query];
15
+ for (let i = 0; i < chars.length; i++) {
16
+ const c = chars[i];
17
+ if (c === '~' && '*?~'.includes(chars[i + 1] ?? ' ')) source += literal(chars[++i]);
18
+ else if (c === '*') source += '[\\s\\S]*';
19
+ else if (c === '?') source += '[\\s\\S]';
20
+ else source += literal(c);
21
+ }
22
+ return new RegExp(wholeCell ? `^(?:${source})$` : source, matchCase ? 'gu' : 'giu');
23
+ }
24
+
25
+ /** Whether the pattern is in a text (a global expression remembers where it stopped: not here) */
26
+ export function matches(pattern, text) {
27
+ pattern.lastIndex = 0;
28
+ const found = pattern.test(text);
29
+ pattern.lastIndex = 0;
30
+ return found;
31
+ }
32
+
33
+ /** The text with what the pattern finds replaced, taken literally */
34
+ export function replaceMatches(pattern, text, replacement) {
35
+ pattern.lastIndex = 0;
36
+ return text.replace(pattern, found => (found === '' ? '' : replacement));
37
+ }
package/src/grid.js CHANGED
@@ -12,6 +12,11 @@ export function colWidthToPx(width, mdw) {
12
12
  return Math.trunc(((256 * width + Math.trunc(128 / mdw)) / 256) * mdw);
13
13
  }
14
14
 
15
+ /** Pixels to the width in characters `colWidthToPx` turns back into those pixels */
16
+ export function pxToColWidth(px, mdw) {
17
+ return Math.round((px / mdw) * 256) / 256;
18
+ }
19
+
15
20
  /**
16
21
  * Default column width in pixels: `defaultColWidth` when the sheet sets it, otherwise
17
22
  * `baseColWidth` characters (8 by default) plus 5 pixels of padding, rounded up to a multiple
@@ -65,6 +70,12 @@ export class Axis {
65
70
  return this.offset(this.count + 1);
66
71
  }
67
72
 
73
+ /** The same axis with room for `count` rows/columns, up to `max` (sizes are kept) */
74
+ grown(count, max) {
75
+ const next = Math.min(max, Math.max(this.count, count));
76
+ return next === this.count ? this : new Axis(next, this.defaultSize, this.custom);
77
+ }
78
+
68
79
  /** The row/column under position `pos` (clamped to the grid) */
69
80
  indexAt(pos) {
70
81
  if (pos <= 0) return 1;
@@ -79,6 +90,10 @@ export class Axis {
79
90
  }
80
91
  }
81
92
 
93
+ /** Last row and column of a worksheet */
94
+ export const MAX_ROWS = 1_048_576;
95
+ export const MAX_COLS = 16_384;
96
+
82
97
  /** Column letters: 1 → A, 27 → AA */
83
98
  export function columnName(col) {
84
99
  let name = '';
@@ -133,7 +148,91 @@ export function sheetAxes(layout, used, mdw, defaultRowPt = 15) {
133
148
  lastRow = Math.max(lastRow, last.row);
134
149
  lastCol = Math.max(lastCol, last.col);
135
150
  }
136
- const rows = Math.min(1_048_576, Math.max(lastRow + 100, 200));
137
- const cols = Math.min(16_384, Math.max(lastCol, used.maxCol) + 12, 16_384);
151
+ const rows = Math.min(MAX_ROWS, Math.max(lastRow + 100, 200));
152
+ const cols = Math.min(MAX_COLS, Math.max(lastCol, used.maxCol) + 12);
138
153
  return { rows: new Axis(rows, rowPx, rowSizes), cols: new Axis(Math.max(cols, 26), colPx, colSizes) };
139
154
  }
155
+
156
+ /**
157
+ * What Excel's status bar says about the cells of a range: how many have something and the sum,
158
+ * average, smallest and largest of the numbers. Hidden rows and columns do not count, and any
159
+ * error leaves only the count (as Excel does). `style` is the format of the first number. `null`
160
+ * when the range is empty.
161
+ * @param {Map<number, {kind: string, style: number, number?: number}>} cells by `row * 16385 + col`
162
+ * @param {{first: {row: number, col: number}, last: {row: number, col: number}}} range
163
+ * @param {{rows?: Axis, cols?: Axis}} [axes]
164
+ */
165
+ export function rangeStats(cells, { first, last }, { rows, cols } = {}) {
166
+ let count = 0;
167
+ let numbers = 0;
168
+ let sum = 0;
169
+ let min = Infinity;
170
+ let max = -Infinity;
171
+ let error = false;
172
+ let style = 0;
173
+ let firstAt = Infinity;
174
+ const visit = (cell, row, col) => {
175
+ if (!cell || !cell.kind || rows?.size(row) === 0 || cols?.size(col) === 0) return;
176
+ count++;
177
+ if (cell.kind === 'e') error = true;
178
+ if (cell.number === undefined) return;
179
+ numbers++;
180
+ sum += cell.number;
181
+ min = Math.min(min, cell.number);
182
+ max = Math.max(max, cell.number);
183
+ const at = row * 16_385 + col;
184
+ if (at < firstAt) {
185
+ firstAt = at;
186
+ style = cell.style;
187
+ }
188
+ };
189
+ // Whichever is smaller: the cells of the range or those of the sheet
190
+ if ((last.row - first.row + 1) * (last.col - first.col + 1) <= cells.size) {
191
+ for (let r = first.row; r <= last.row; r++) {
192
+ for (let c = first.col; c <= last.col; c++) visit(cells.get(r * 16_385 + c), r, c);
193
+ }
194
+ } else {
195
+ for (const [at, cell] of cells) {
196
+ const r = Math.floor(at / 16_385);
197
+ const c = at - r * 16_385;
198
+ if (r >= first.row && r <= last.row && c >= first.col && c <= last.col) visit(cell, r, c);
199
+ }
200
+ }
201
+ if (!count) return null;
202
+ if (error || !numbers) return { count, numbers: error ? 0 : numbers, sum: null, average: null, min: null, max: null, style };
203
+ return { count, numbers, sum, average: sum / numbers, min, max, style };
204
+ }
205
+
206
+ /**
207
+ * Where Ctrl+arrow lands from the line `from`, as Excel does: to the end of the block of filled
208
+ * cells it is in, else to the next filled cell, else to the end of the sheet
209
+ * @param {(line: number) => boolean} filled
210
+ * @param {number} from
211
+ * @param {1 | -1} step
212
+ * @param {number} limit last line of the sheet
213
+ */
214
+ export function dataEdge(filled, from, step, limit) {
215
+ const inside = i => i >= 1 && i <= limit;
216
+ if (!inside(from + step)) return from;
217
+ let i = from;
218
+ if (filled(from) && filled(from + step)) {
219
+ while (inside(i + step) && filled(i + step)) i += step;
220
+ return i;
221
+ }
222
+ for (i = from + step; inside(i); i += step) if (filled(i)) return i;
223
+ return step < 0 ? 1 : limit;
224
+ }
225
+
226
+ /**
227
+ * The far end of the run of numbers next to the line `from`, going by `step`; null when the next
228
+ * line has none. Autosum takes that run.
229
+ * @param {(line: number) => boolean} isNumber
230
+ * @param {number} from
231
+ * @param {1 | -1} step
232
+ * @param {number} limit last line of the sheet
233
+ */
234
+ export function numberRun(isNumber, from, step, limit) {
235
+ let end = null;
236
+ for (let i = from + step; i >= 1 && i <= limit && isNumber(i); i += step) end = i;
237
+ return end;
238
+ }
package/src/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface XlsxEditorOptions {
11
11
  tabs?: boolean;
12
12
  /** Bar above the grid with the selected cell and its content; default true */
13
13
  formulaBar?: boolean;
14
+ /** Bar below the tabs with the count, sum and average of the selection; default true */
15
+ statusBar?: boolean;
14
16
  /** A viewer: cells are selected and copied but not edited; default false */
15
17
  readOnly?: boolean;
16
18
  /** Where `xlsx_engine_bg.wasm` is served from, when the bundler does not serve it */
@@ -30,6 +32,41 @@ export interface Selection {
30
32
  active: CellPosition;
31
33
  /** Text the active cell shows */
32
34
  text: string;
35
+ /** Whole rows, whole columns or the whole sheet selected from the headers */
36
+ whole: 'row' | 'col' | 'all' | null;
37
+ }
38
+
39
+ /** What the status bar says about the selection */
40
+ export interface SelectionStats {
41
+ /** Cells that have something */
42
+ count: number;
43
+ /** Of them, numbers (dates count, as serial numbers) */
44
+ numbers: number;
45
+ /** `null` without numbers, or when an error is selected */
46
+ sum: number | null;
47
+ average: number | null;
48
+ min: number | null;
49
+ max: number | null;
50
+ }
51
+
52
+ export interface FindOptions {
53
+ matchCase?: boolean;
54
+ /** Only cells whose whole text matches */
55
+ wholeCell?: boolean;
56
+ /** The active sheet (default) or every worksheet */
57
+ scope?: 'sheet' | 'workbook';
58
+ /** Look in the formula of formula cells instead of the text they show */
59
+ inFormulas?: boolean;
60
+ }
61
+
62
+ export interface FindHit {
63
+ sheet: number;
64
+ sheetName: string;
65
+ ref: string;
66
+ row: number;
67
+ col: number;
68
+ /** The text that matched */
69
+ text: string;
33
70
  }
34
71
 
35
72
  /** Cells changed from the grid (typing, deleting, pasting, cutting) or by undo and redo */
@@ -39,9 +76,9 @@ export interface ChangeDetail {
39
76
  index: number;
40
77
  /** Range changed, e.g. `B3` or `A1:C4` */
41
78
  range: string;
42
- /** Every cell of `range` with its new value (`null` when empty) */
79
+ /** Every cell of `range` with its new value (`null` when empty); empty above 200 000 cells */
43
80
  cells: { ref: string; value: CellValue }[];
44
- source: 'edit' | 'clear' | 'paste' | 'cut' | 'undo' | 'redo';
81
+ source: 'edit' | 'clear' | 'paste' | 'cut' | 'move' | 'insert' | 'delete' | 'fill' | 'replace' | 'undo' | 'redo';
45
82
  }
46
83
 
47
84
  export interface XlsxEditorEventMap {
@@ -49,7 +86,7 @@ export interface XlsxEditorEventMap {
49
86
  sheetchange: CustomEvent<{ index: number; name: string }>;
50
87
  selectionchange: CustomEvent<Selection>;
51
88
  change: CustomEvent<ChangeDetail>;
52
- /** A change the engine refused (e.g. overwriting the source of a shared formula) */
89
+ /** A change the engine refused (e.g. moving a row through merged cells) */
53
90
  error: CustomEvent<{ error: Error }>;
54
91
  }
55
92
 
@@ -65,10 +102,55 @@ export class XlsxEditor extends EventTarget {
65
102
  readonly sheetIndex: number;
66
103
  readonly sheetName: string | null;
67
104
  readonly selection: Selection | null;
105
+ /** Count, sum, average, smallest and largest of the selected cells; `null` when they are empty */
106
+ readonly selectionStats: SelectionStats | null;
68
107
  /** Without editing from the grid (selecting and copying still work) */
69
108
  readOnly: boolean;
70
109
  readonly canUndo: boolean;
71
110
  readonly canRedo: boolean;
111
+ /**
112
+ * Moves `count` whole rows from `first` before the row `before` (numbered as they are now), as
113
+ * one undo step, and selects them. False when nothing changed: it stays where it is, it is
114
+ * read-only, or the engine refused (an `error` event says why).
115
+ */
116
+ moveRows(first: number, count: number, before: number): boolean;
117
+ /** Moves whole columns, by number (see `moveRows`) */
118
+ moveColumns(first: number, count: number, before: number): boolean;
119
+ /** Inserts rows before the row `before`, as one undo step, and selects them; false if refused */
120
+ insertRows(before: number, count?: number): boolean;
121
+ /** Inserts columns, by number (see `insertRows`) */
122
+ insertColumns(before: number, count?: number): boolean;
123
+ /** Deletes rows from `first`, as one undo step; false if refused */
124
+ deleteRows(first: number, count?: number): boolean;
125
+ /** Deletes columns, by number (see `deleteRows`) */
126
+ deleteColumns(first: number, count?: number): boolean;
127
+ /** Fits columns to their content, as one undo step; false if they are empty, read-only or refused. Needs the editor: it measures the text as drawn */
128
+ autofitColumns(first: number, count?: number): boolean;
129
+ /** Fits rows to their content (empty ones go back to the automatic height), as one undo step; false if read-only or refused */
130
+ autofitRows(first: number, count?: number): boolean;
131
+ /** Hides `count` rows from `first`, as one undo step */
132
+ hideRows(first: number, count?: number): boolean;
133
+ /** Shows hidden rows among the `count` from `first` */
134
+ showRows(first: number, count?: number): boolean;
135
+ /** Hides columns, by number */
136
+ hideColumns(first: number, count?: number): boolean;
137
+ /** Shows hidden columns, by number */
138
+ showColumns(first: number, count?: number): boolean;
139
+ /**
140
+ * Fills `target` (`A1:A20`) from the selected cells, which must be at one of its ends, as one
141
+ * undo step, and selects it. False if refused.
142
+ */
143
+ fill(target: string, options?: { series?: boolean }): boolean;
144
+ /** Where a text is, row by row: `*` is any text, `?` one character and `~` takes them literally */
145
+ find(query: string, options?: FindOptions): FindHit[];
146
+ /**
147
+ * Replaces a text in cells with text and in formulas (never in numbers or dates), as one undo
148
+ * step; returns how many cells changed. Formatted runs of a rich text become plain.
149
+ */
150
+ replaceAll(query: string, replacement: string, options?: Omit<FindOptions, 'inFormulas'>): number;
151
+ /** Opens the search panel, with the row to replace when asked */
152
+ openFind(options?: { replace?: boolean }): void;
153
+ closeFind(): void;
72
154
  /** Undoes the last change; false when there is nothing to undo */
73
155
  undo(): boolean;
74
156
  /** Redoes the last undone change */
@@ -105,7 +187,7 @@ export class XlsxEditor extends EventTarget {
105
187
  addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
106
188
  }
107
189
 
108
- /** `<xlsx-editor src="…" locale zoom sheet tabs formula-bar readonly wasm-url blank quiet>` */
190
+ /** `<xlsx-editor src="…" locale zoom sheet tabs formula-bar status-bar readonly wasm-url blank quiet>` */
109
191
  export class XlsxEditorElement extends HTMLElement {
110
192
  /** The editor, once the `ready` event fired */
111
193
  editor: XlsxEditor | null;
package/src/style.css CHANGED
@@ -27,7 +27,8 @@ xlsx-editor {
27
27
  }
28
28
 
29
29
  .xlsx-editor-bar[hidden],
30
- .xlsx-editor-tabs[hidden] {
30
+ .xlsx-editor-tabs[hidden],
31
+ .xlsx-editor-status[hidden] {
31
32
  display: none;
32
33
  }
33
34
 
@@ -159,3 +160,155 @@ xlsx-editor {
159
160
  opacity: 0.5;
160
161
  cursor: default;
161
162
  }
163
+
164
+ /* Below the tabs: what went wrong on the left, the sum and average of the selection on the right */
165
+ .xlsx-editor-status {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 1.5em;
169
+ min-height: 22px;
170
+ padding: 0 10px;
171
+ border-top: 1px solid var(--xlsx-border, #d1d5db);
172
+ background: var(--xlsx-tabs-bg, #f3f4f6);
173
+ font-size: 12px;
174
+ color: #4b5563;
175
+ white-space: nowrap;
176
+ }
177
+
178
+ .xlsx-editor-status-message {
179
+ flex: 1 1 auto;
180
+ min-width: 0;
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ color: #b91c1c;
184
+ }
185
+
186
+ .xlsx-editor-status-stats {
187
+ flex: 0 0 auto;
188
+ display: flex;
189
+ gap: 1.25em;
190
+ font-variant-numeric: tabular-nums;
191
+ }
192
+
193
+ /* Right click on cells and headers */
194
+ .xlsx-editor-menu {
195
+ position: fixed;
196
+ z-index: 1000;
197
+ display: flex;
198
+ flex-direction: column;
199
+ min-width: 14rem;
200
+ padding: 4px;
201
+ background: #ffffff;
202
+ border: 1px solid var(--xlsx-border, #d1d5db);
203
+ border-radius: var(--xlsx-radius, 6px);
204
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
205
+ font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
206
+ color: #1f2937;
207
+ }
208
+
209
+ .xlsx-editor-menu[hidden] {
210
+ display: none;
211
+ }
212
+
213
+ .xlsx-editor-menu-item {
214
+ display: flex;
215
+ justify-content: space-between;
216
+ gap: 2rem;
217
+ padding: 5px 10px;
218
+ border: 0;
219
+ border-radius: 4px;
220
+ background: transparent;
221
+ font: inherit;
222
+ color: inherit;
223
+ text-align: left;
224
+ white-space: nowrap;
225
+ cursor: pointer;
226
+ }
227
+
228
+ .xlsx-editor-menu-item:hover,
229
+ .xlsx-editor-menu-item:focus-visible {
230
+ outline: none;
231
+ background: rgba(33, 115, 70, 0.12);
232
+ }
233
+
234
+ .xlsx-editor-menu-keys {
235
+ color: #6b7280;
236
+ }
237
+
238
+ .xlsx-editor-menu-line {
239
+ height: 1px;
240
+ margin: 4px 6px;
241
+ background: var(--xlsx-border, #d1d5db);
242
+ }
243
+
244
+ /* Search panel, over the top right corner of the grid */
245
+ .xlsx-editor-find {
246
+ position: absolute;
247
+ z-index: 3;
248
+ top: 6px;
249
+ right: 22px;
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: 6px;
253
+ width: min(22rem, calc(100% - 44px));
254
+ padding: 8px;
255
+ background: #ffffff;
256
+ border: 1px solid var(--xlsx-border, #d1d5db);
257
+ border-radius: var(--xlsx-radius, 6px);
258
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
259
+ font-size: 12px;
260
+ }
261
+
262
+ .xlsx-editor-find[hidden],
263
+ .xlsx-editor-find-row[hidden] {
264
+ display: none;
265
+ }
266
+
267
+ .xlsx-editor-find-row {
268
+ display: flex;
269
+ align-items: center;
270
+ flex-wrap: wrap;
271
+ gap: 6px;
272
+ }
273
+
274
+ .xlsx-editor-find input[type="text"] {
275
+ flex: 1 1 6rem;
276
+ min-width: 0;
277
+ padding: 3px 6px;
278
+ border: 1px solid var(--xlsx-border, #d1d5db);
279
+ border-radius: 4px;
280
+ font: inherit;
281
+ }
282
+
283
+ .xlsx-editor-find input[type="text"]:focus {
284
+ outline: 2px solid var(--xlsx-accent, #217346);
285
+ outline-offset: -1px;
286
+ }
287
+
288
+ .xlsx-editor-find button,
289
+ .xlsx-editor-find select {
290
+ padding: 3px 7px;
291
+ border: 1px solid var(--xlsx-border, #d1d5db);
292
+ border-radius: 4px;
293
+ background: #ffffff;
294
+ font: inherit;
295
+ color: inherit;
296
+ cursor: pointer;
297
+ }
298
+
299
+ .xlsx-editor-find button:hover {
300
+ background: rgba(33, 115, 70, 0.12);
301
+ }
302
+
303
+ .xlsx-editor-find label {
304
+ display: inline-flex;
305
+ align-items: center;
306
+ gap: 3px;
307
+ cursor: pointer;
308
+ }
309
+
310
+ .xlsx-editor-find-count {
311
+ margin-left: auto;
312
+ color: #4b5563;
313
+ font-variant-numeric: tabular-nums;
314
+ }