@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/README.md +43 -229
- package/package.json +4 -2
- package/src/draw.js +12 -3
- package/src/elements.js +1 -0
- package/src/engine.d.ts +49 -2
- package/src/engine.js +169 -2
- package/src/find.js +37 -0
- package/src/grid.js +101 -2
- package/src/index.d.ts +86 -4
- package/src/style.css +154 -1
- package/src/xlsx_editor.js +1531 -56
- package/wasm/xlsx_engine.d.ts +68 -1
- package/wasm/xlsx_engine.js +211 -1
- package/wasm/xlsx_engine_bg.wasm +0 -0
- package/wasm/xlsx_engine_bg.wasm.d.ts +13 -0
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`,
|
|
136
|
-
* or `''` for an empty cell with a format; `style` indexes
|
|
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(
|
|
137
|
-
const cols = Math.min(
|
|
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.
|
|
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
|
+
}
|