@dimarborda/xlsx-editor 0.1.0 → 0.2.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 CHANGED
@@ -112,6 +112,7 @@ element.addEventListener('ready', async ({ detail: { editor } }) => {
112
112
  // Cambiar celdas con la API del motor y volver a dibujar
113
113
  editor.workbook.fillTemplate({ CLIENTE: 'Acme' });
114
114
  editor.workbook.sheet('Resumen').setValues('B2', [[1200, 0.25]]);
115
+ editor.workbook.addSheet('Notas'); // reload() también redibuja las pestañas
115
116
  editor.reload();
116
117
 
117
118
  editor.undo(); // también editor.redo(), editor.canUndo, editor.canRedo
@@ -167,6 +168,8 @@ En Node el motor se carga solo. En el navegador, los bundlers (Vite, webpack 5,
167
168
  | `hoja.setValue('B3', valor)` | Escribe texto, número, booleano, `Date` o una fórmula (`{ formula: '=SUM(A1:A3)' }`); `null` borra el valor y deja el formato. Las fórmulas que dependen de la celda se recalculan. |
168
169
  | `hoja.setValues('A2', filas)` | Escribe una matriz desde la celda indicada, creando filas y celdas en orden. `undefined` deja la celda como está. |
169
170
  | `wb.fillTemplate(valores)` | Sustituye `{{CAMPO}}` (se admiten espacios y una pista: `{{ CAMPO }}`, `{{CAMPO: pista}}`) en las cadenas compartidas y en las cadenas en línea; devuelve cuántas sustituyó. Las variables sin valor se quedan como están. |
171
+ | `wb.addSheet(nombre, después?)` | Agrega una hoja vacía detrás de la pestaña `después` (nombre o posición; al final sin ella) y la devuelve. El nombre sigue las reglas de Excel: de 1 a 31 caracteres, sin `[ ] : * ? / \`, sin apóstrofo al principio ni al final, distinto de `History` y de las demás hojas (sin distinguir mayúsculas). |
172
+ | `wb.renameSheet(hoja, nombre)` | Renombra una pestaña (nombre o posición) y las referencias a ella en fórmulas, nombres definidos, validaciones de datos, formato condicional y gráficos; devuelve cuántas cambió. Las referencias dentro de textos (`INDIRECT("Hoja1!A1")`) y a otros libros se quedan como están, igual que en Excel. |
170
173
  | `wb.markers({ kind })` | Marcadores del léxico estándar (el mismo de `@dimarborda/docx-editor`) en las celdas de texto de todas las hojas: `field` para `{{CLAVE}}` / `{clave}`, `pending` para `[…]` (contenido por completar). Cada uno con `sheet`, `sheetName`, `ref` (`B3`), `row`, `col`, `raw`, `key`, `hint`, `offset` y `length`. |
171
174
  | `wb.checkpoint(selección?)` | Abre un paso de deshacer: los cambios hasta el siguiente `checkpoint` se deshacen juntos. `selección` es un texto libre que `undo()` devuelve. `wb.setSelectionAfter(selección)` guarda la de después, que devuelve `redo()`. |
172
175
  | `wb.undo()` / `wb.redo()` | Deshace o rehace un paso: `{ selection, parts }`, o `null` si no hay nada. `wb.canUndo` / `wb.canRedo` dicen si se puede. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimarborda/xlsx-editor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Excel (.xlsx) editor for the browser and engine for Node.js: Rust + WebAssembly that edits workbooks without losing what it does not touch, with a canvas grid as a framework-agnostic web component",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/engine.d.ts CHANGED
@@ -243,6 +243,13 @@ export class Workbook {
243
243
  * Replaces `{{FIELD}}` placeholders (also `{{ FIELD }}` and `{{FIELD: hint}}`) in the shared and inline strings of the workbook and
244
244
  * returns how many were replaced. Unknown placeholders are left as they are.
245
245
  */
246
+ /** Adds an empty worksheet after the tab `after` (name or position; at the end without it) */
247
+ addSheet(name: string, after?: string | number): Sheet;
248
+ /**
249
+ * Renames a tab and the references to it in formulas, defined names, data validations,
250
+ * conditional formats and charts; returns how many of those changed
251
+ */
252
+ renameSheet(sheet: string | number, name: string): number;
246
253
  fillTemplate(values: Record<string, string | number | boolean | null | undefined>): number;
247
254
  /**
248
255
  * Markers of the standard lexicon (shared with @dimarborda/docx-editor) in the text cells of
package/src/engine.js CHANGED
@@ -241,6 +241,43 @@ export class Workbook {
241
241
  return new Sheet(this, index, all[index].name);
242
242
  }
243
243
 
244
+ /**
245
+ * Adds an empty worksheet after the tab `after` (name or position; at the end without it).
246
+ * Names follow Excel's rules: 1–31 characters, none of `[ ] : * ? / \`, unique ignoring case.
247
+ * Returns the new sheet.
248
+ * @param {string} name
249
+ * @param {string | number} [after]
250
+ */
251
+ addSheet(name, after) {
252
+ const index = call(() => this._engine.add_sheet(String(name), after === undefined || after === null ? undefined : this.#tabIndex(after)));
253
+ return this.sheet(index);
254
+ }
255
+
256
+ /**
257
+ * Renames a tab (name or position) and the references to it in formulas, defined names,
258
+ * data validations, conditional formats and charts; returns how many of those changed
259
+ * @param {string | number} sheet
260
+ * @param {string} name
261
+ */
262
+ renameSheet(sheet, name) {
263
+ return call(() => this._engine.rename_sheet(this.#tabIndex(sheet), String(name)));
264
+ }
265
+
266
+ /** Position of a tab by name (ignoring case) or position */
267
+ #tabIndex(nameOrIndex) {
268
+ const all = JSON.parse(this._engine.sheets_json());
269
+ if (typeof nameOrIndex === 'number') {
270
+ if (!Number.isInteger(nameOrIndex) || nameOrIndex < 0 || nameOrIndex >= all.length) {
271
+ throw new RangeError(`El libro tiene ${all.length} hoja(s); no existe la hoja ${nameOrIndex}.`);
272
+ }
273
+ return nameOrIndex;
274
+ }
275
+ let index = all.findIndex(s => s.name === nameOrIndex);
276
+ if (index < 0) index = all.findIndex(s => s.name.toLowerCase() === String(nameOrIndex).toLowerCase());
277
+ if (index < 0) throw new Error(`El libro no tiene ninguna hoja llamada '${nameOrIndex}'.`);
278
+ return index;
279
+ }
280
+
244
281
  /**
245
282
  * Replaces `{{FIELD}}` placeholders (also `{{ FIELD }}` and `{{FIELD: hint}}`) in every text
246
283
  * of the workbook; returns how many
package/src/grid.js CHANGED
@@ -65,6 +65,12 @@ export class Axis {
65
65
  return this.offset(this.count + 1);
66
66
  }
67
67
 
68
+ /** The same axis with room for `count` rows/columns, up to `max` (sizes are kept) */
69
+ grown(count, max) {
70
+ const next = Math.min(max, Math.max(this.count, count));
71
+ return next === this.count ? this : new Axis(next, this.defaultSize, this.custom);
72
+ }
73
+
68
74
  /** The row/column under position `pos` (clamped to the grid) */
69
75
  indexAt(pos) {
70
76
  if (pos <= 0) return 1;
@@ -79,6 +85,10 @@ export class Axis {
79
85
  }
80
86
  }
81
87
 
88
+ /** Last row and column of a worksheet */
89
+ export const MAX_ROWS = 1_048_576;
90
+ export const MAX_COLS = 16_384;
91
+
82
92
  /** Column letters: 1 → A, 27 → AA */
83
93
  export function columnName(col) {
84
94
  let name = '';
@@ -133,7 +143,7 @@ export function sheetAxes(layout, used, mdw, defaultRowPt = 15) {
133
143
  lastRow = Math.max(lastRow, last.row);
134
144
  lastCol = Math.max(lastCol, last.col);
135
145
  }
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);
146
+ const rows = Math.min(MAX_ROWS, Math.max(lastRow + 100, 200));
147
+ const cols = Math.min(MAX_COLS, Math.max(lastCol, used.maxCol) + 12);
138
148
  return { rows: new Axis(rows, rowPx, rowSizes), cols: new Axis(Math.max(cols, 26), colPx, colSizes) };
139
149
  }
@@ -4,7 +4,7 @@
4
4
  // frozen panes in place and handles selection, the in-cell editor, the clipboard, undo and tabs.
5
5
  import { createWorkbook, initEngine, openWorkbook } from './engine.js';
6
6
  import { canvasFont, drawHeaders, drawRegion, key } from './draw.js';
7
- import { columnName, parseRange, sheetAxes } from './grid.js';
7
+ import { MAX_COLS, MAX_ROWS, columnName, parseRange, sheetAxes } from './grid.js';
8
8
  import { editText, parseInput, parseTSV, toTSV } from './input.js';
9
9
 
10
10
  const COL_HEADER_H = 22;
@@ -113,7 +113,10 @@ export class XlsxEditor extends EventTarget {
113
113
  this.#input = root.querySelector('.xlsx-editor-input');
114
114
  this.#sink = root.querySelector('.xlsx-editor-clipboard');
115
115
 
116
- this.#scroller.addEventListener('scroll', () => this.render());
116
+ this.#scroller.addEventListener('scroll', () => {
117
+ this.#growOnScroll();
118
+ this.render();
119
+ });
117
120
  this.#scroller.addEventListener('pointerdown', e => this.#onPointerDown(e));
118
121
  this.#scroller.addEventListener('keydown', e => this.#onKeyDown(e));
119
122
  this.#scroller.addEventListener('dblclick', () => this.#beginEdit({ mode: 'edit' }));
@@ -239,7 +242,15 @@ export class XlsxEditor extends EventTarget {
239
242
  reload() {
240
243
  if (!this.#workbook || this.#sheetIndex < 0) return;
241
244
  this.#styles = this.#workbook.styleView();
242
- this.#loadSheet(this.#sheetIndex, { keepScroll: true });
245
+ // Sheets added or renamed through `workbook` show up in the tabs
246
+ this.#renderTabs();
247
+ this.#loadSheet(this.#validSheet(this.#sheetIndex), { keepScroll: true });
248
+ }
249
+
250
+ /** `index` if it is still a worksheet (after undoing an added sheet), else the first one */
251
+ #validSheet(index) {
252
+ const tabs = this.#workbook.tabs;
253
+ return tabs[index]?.kind === 'worksheet' ? index : tabs.findIndex(t => t.kind === 'worksheet');
243
254
  }
244
255
 
245
256
  /** @param {number} zoom 0.25–4 */
@@ -258,6 +269,7 @@ export class XlsxEditor extends EventTarget {
258
269
  select(ref) {
259
270
  const range = parseRange(ref);
260
271
  this.#commitEdit(null, { focus: false });
272
+ this.#extend(range.last.row, range.last.col);
261
273
  this.#setSelection(range.first, range.last, range.first);
262
274
  }
263
275
 
@@ -384,7 +396,12 @@ export class XlsxEditor extends EventTarget {
384
396
  }
385
397
  }
386
398
  this.#mdw = this.#measureMdw();
387
- const { rows, cols } = sheetAxes(layout, { maxRow, maxCol }, this.#mdw);
399
+ let { rows, cols } = sheetAxes(layout, { maxRow, maxCol }, this.#mdw);
400
+ // Reading the same sheet again keeps the room scrolled into past its cells
401
+ if (this.#sheetIndex === index && this.#sheet) {
402
+ rows = rows.grown(this.#sheet.rows.count, MAX_ROWS);
403
+ cols = cols.grown(this.#sheet.cols.count, MAX_COLS);
404
+ }
388
405
  const frozen = layout.frozen ?? { rows: 0, cols: 0 };
389
406
  this.#sheet = {
390
407
  cells, merges, mergeAt, covered, rows, cols,
@@ -417,6 +434,30 @@ export class XlsxEditor extends EventTarget {
417
434
  return this.#sheet?.headings ? Math.round(COL_HEADER_H * this.zoom) : 0;
418
435
  }
419
436
 
437
+ /**
438
+ * Makes room up to row `row` and column `col` and some more, as Excel does when scrolling or
439
+ * moving past the end of the shown grid
440
+ */
441
+ #extend(row, col) {
442
+ const sheet = this.#sheet;
443
+ if (!sheet) return;
444
+ const rows = row + 50 > sheet.rows.count ? sheet.rows.grown(row + 200, MAX_ROWS) : sheet.rows;
445
+ const cols = col + 5 > sheet.cols.count ? sheet.cols.grown(col + 20, MAX_COLS) : sheet.cols;
446
+ if (rows === sheet.rows && cols === sheet.cols) return;
447
+ sheet.rows = rows;
448
+ sheet.cols = cols;
449
+ this.#updateSpacer();
450
+ }
451
+
452
+ /** Grows the grid when the view comes near its bottom or right end */
453
+ #growOnScroll() {
454
+ const s = this.#scroller;
455
+ if (!this.#sheet) return;
456
+ const nearBottom = s.scrollTop + s.clientHeight * 2 >= s.scrollHeight;
457
+ const nearRight = s.scrollLeft + s.clientWidth * 2 >= s.scrollWidth;
458
+ if (nearBottom || nearRight) this.#extend(nearBottom ? this.#sheet.rows.count : 1, nearRight ? this.#sheet.cols.count : 1);
459
+ }
460
+
420
461
  #updateSpacer() {
421
462
  if (!this.#sheet) {
422
463
  this.#spacer.style.width = '0px';
@@ -679,8 +720,9 @@ export class XlsxEditor extends EventTarget {
679
720
 
680
721
  /** Moves the active cell (or, with `extend`, the moving corner of the range) one step */
681
722
  #move(dr, dc, extend = false) {
682
- const { rows, cols } = this.#sheet;
683
723
  const from = extend ? this.#selection.cursor : this.#selection.active;
724
+ this.#extend(from.row + Math.max(dr, 0), from.col + Math.max(dc, 0));
725
+ const { rows, cols } = this.#sheet;
684
726
  // Step over merged ranges and hidden rows or columns
685
727
  const range = this.#expandToMerges(from, from);
686
728
  let row = dr > 0 ? range.last.row + 1 : dr < 0 ? range.first.row - 1 : from.row;
@@ -962,11 +1004,14 @@ export class XlsxEditor extends EventTarget {
962
1004
  this.#clip = null;
963
1005
  this.#styles = this.#workbook.styleView();
964
1006
  const tabs = this.#workbook.tabs;
1007
+ this.#renderTabs();
965
1008
  if (token && token.sheet !== this.#sheetIndex && tabs[token.sheet]?.kind === 'worksheet') {
966
1009
  this.#loadSheet(token.sheet);
967
1010
  this.dispatchEvent(new CustomEvent('sheetchange', { detail: { index: token.sheet, name: tabs[token.sheet].name } }));
968
1011
  } else {
969
- this.#loadSheet(this.#sheetIndex, { keepScroll: true });
1012
+ const index = this.#validSheet(this.#sheetIndex);
1013
+ this.#loadSheet(index, { keepScroll: index === this.#sheetIndex });
1014
+ if (index !== token?.sheet && tabs[index]) this.dispatchEvent(new CustomEvent('sheetchange', { detail: { index, name: tabs[index].name } }));
970
1015
  }
971
1016
  if (token?.range) {
972
1017
  const range = parseRange(token.range);
@@ -7,6 +7,11 @@
7
7
  export class XlsxWorkbook {
8
8
  free(): void;
9
9
  [Symbol.dispose](): void;
10
+ /**
11
+ * Adds an empty worksheet after the tab at `after` (at the end without it); returns its
12
+ * tab position
13
+ */
14
+ add_sheet(name: string, after?: number | null): number;
10
15
  /**
11
16
  * Context of an area for an agent (see `Workbook::area_context`): `target` is a table or
12
17
  * defined name, an address with its sheet, or a range of the sheet at `sheet`
@@ -70,6 +75,10 @@ export class XlsxWorkbook {
70
75
  * The region around a cell (Excel's current region) as JSON
71
76
  */
72
77
  region_at_json(sheet: number, reference: string): string;
78
+ /**
79
+ * Renames the tab at `sheet` and the references to it; returns how many formulas changed
80
+ */
81
+ rename_sheet(sheet: number, name: string): number;
73
82
  /**
74
83
  * Width in characters of the columns `first..=last` (1 = A)
75
84
  */
@@ -125,6 +134,7 @@ export interface InitOutput {
125
134
  readonly memory: WebAssembly.Memory;
126
135
  readonly __wbg_xlsxworkbook_free: (a: number, b: number) => void;
127
136
  readonly main_js: () => void;
137
+ readonly xlsxworkbook_add_sheet: (a: number, b: number, c: number, d: number) => [number, number, number];
128
138
  readonly xlsxworkbook_area_context_json: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
129
139
  readonly xlsxworkbook_areas_json: (a: number, b: number) => [number, number, number, number];
130
140
  readonly xlsxworkbook_can_redo: (a: number) => number;
@@ -142,6 +152,7 @@ export interface InitOutput {
142
152
  readonly xlsxworkbook_recalculate: (a: number) => [number, number, number];
143
153
  readonly xlsxworkbook_redo_json: (a: number) => [number, number];
144
154
  readonly xlsxworkbook_region_at_json: (a: number, b: number, c: number, d: number) => [number, number, number, number];
155
+ readonly xlsxworkbook_rename_sheet: (a: number, b: number, c: number, d: number) => [number, number, number];
145
156
  readonly xlsxworkbook_set_column_width: (a: number, b: number, c: number, d: number, e: number) => [number, number];
146
157
  readonly xlsxworkbook_set_format_json: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
147
158
  readonly xlsxworkbook_set_selection_after: (a: number, b: number, c: number) => void;
@@ -20,6 +20,22 @@ export class XlsxWorkbook {
20
20
  const ptr = this.__destroy_into_raw();
21
21
  wasm.__wbg_xlsxworkbook_free(ptr, 0);
22
22
  }
23
+ /**
24
+ * Adds an empty worksheet after the tab at `after` (at the end without it); returns its
25
+ * tab position
26
+ * @param {string} name
27
+ * @param {number | null} [after]
28
+ * @returns {number}
29
+ */
30
+ add_sheet(name, after) {
31
+ const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
32
+ const len0 = WASM_VECTOR_LEN;
33
+ const ret = wasm.xlsxworkbook_add_sheet(this.__wbg_ptr, ptr0, len0, isLikeNone(after) ? Number.MAX_SAFE_INTEGER : (after) >>> 0);
34
+ if (ret[2]) {
35
+ throw takeFromExternrefTable0(ret[1]);
36
+ }
37
+ return ret[0] >>> 0;
38
+ }
23
39
  /**
24
40
  * Context of an area for an agent (see `Workbook::area_context`): `target` is a table or
25
41
  * defined name, an address with its sheet, or a range of the sheet at `sheet`
@@ -305,6 +321,21 @@ export class XlsxWorkbook {
305
321
  wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
306
322
  }
307
323
  }
324
+ /**
325
+ * Renames the tab at `sheet` and the references to it; returns how many formulas changed
326
+ * @param {number} sheet
327
+ * @param {string} name
328
+ * @returns {number}
329
+ */
330
+ rename_sheet(sheet, name) {
331
+ const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
332
+ const len0 = WASM_VECTOR_LEN;
333
+ const ret = wasm.xlsxworkbook_rename_sheet(this.__wbg_ptr, sheet, ptr0, len0);
334
+ if (ret[2]) {
335
+ throw takeFromExternrefTable0(ret[1]);
336
+ }
337
+ return ret[0] >>> 0;
338
+ }
308
339
  /**
309
340
  * Width in characters of the columns `first..=last` (1 = A)
310
341
  * @param {number} sheet
Binary file
@@ -3,6 +3,7 @@
3
3
  export const memory: WebAssembly.Memory;
4
4
  export const __wbg_xlsxworkbook_free: (a: number, b: number) => void;
5
5
  export const main_js: () => void;
6
+ export const xlsxworkbook_add_sheet: (a: number, b: number, c: number, d: number) => [number, number, number];
6
7
  export const xlsxworkbook_area_context_json: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
7
8
  export const xlsxworkbook_areas_json: (a: number, b: number) => [number, number, number, number];
8
9
  export const xlsxworkbook_can_redo: (a: number) => number;
@@ -20,6 +21,7 @@ export const xlsxworkbook_range_json: (a: number, b: number, c: number, d: numbe
20
21
  export const xlsxworkbook_recalculate: (a: number) => [number, number, number];
21
22
  export const xlsxworkbook_redo_json: (a: number) => [number, number];
22
23
  export const xlsxworkbook_region_at_json: (a: number, b: number, c: number, d: number) => [number, number, number, number];
24
+ export const xlsxworkbook_rename_sheet: (a: number, b: number, c: number, d: number) => [number, number, number];
23
25
  export const xlsxworkbook_set_column_width: (a: number, b: number, c: number, d: number, e: number) => [number, number];
24
26
  export const xlsxworkbook_set_format_json: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
25
27
  export const xlsxworkbook_set_selection_after: (a: number, b: number, c: number) => void;