@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.
@@ -1,15 +1,38 @@
1
1
  // XlsxEditor: shows an Excel workbook on a canvas, as Excel draws it, and edits its cells. The
2
2
  // workbook is read, formatted and written by the Rust/WebAssembly engine (engine.js);
3
3
  // this class lays out the grid, scrolls it virtually (only the visible cells are drawn), keeps
4
- // frozen panes in place and handles selection, the in-cell editor, the clipboard, undo and tabs.
4
+ // frozen panes in place and handles selection, the in-cell editor, the clipboard, undo, tabs,
5
+ // moving rows and columns from their headers and the status bar.
5
6
  import { createWorkbook, initEngine, openWorkbook } from './engine.js';
6
- import { canvasFont, drawHeaders, drawRegion, key } from './draw.js';
7
- import { columnName, parseRange, sheetAxes } from './grid.js';
7
+ import { PAD, canvasFont, drawHeaders, drawRegion, key, wrapLines } from './draw.js';
8
+ import { Axis, MAX_COLS, MAX_ROWS, colWidthToPx, columnName, dataEdge, numberRun, parseRange, ptToPx, pxToColWidth, rangeStats, sheetAxes } from './grid.js';
9
+ import { matches, replaceMatches, searchPattern } from './find.js';
8
10
  import { editText, parseInput, parseTSV, toTSV } from './input.js';
9
11
 
10
12
  const COL_HEADER_H = 22;
11
13
  const SELECTION = '#217346';
12
14
  const MS_PER_DAY = 86_400_000;
15
+ /** Pixels the pointer travels on a selected header before it counts as dragging it */
16
+ const DRAG_START_PX = 4;
17
+ /** How close to the edge of the view a drag scrolls it, and how fast (pixels every 40 ms) */
18
+ const DRAG_SCROLL_EDGE_PX = 24;
19
+ const DRAG_SCROLL_STEP_PX = 24;
20
+ /** Above this many cells a `change` event leaves out their values, to stay fast */
21
+ const MAX_CHANGE_CELLS = 200_000;
22
+ /** How near the edge of a header a press resizes its row or column */
23
+ const RESIZE_EDGE_PX = 4;
24
+ /** Smallest size a drag leaves a row or column with */
25
+ const MIN_LINE_PX = 4;
26
+ /** How near the corner of the selection a press takes its fill handle */
27
+ const FILL_HANDLE_PX = 6;
28
+ /** Pause after typing in the search panel before looking (a search reads the whole sheet) */
29
+ const FIND_DELAY_MS = 150;
30
+ /** Largest row Excel allows, in points */
31
+ const MAX_ROW_PT = 409.5;
32
+ /** Largest column Excel allows, in characters */
33
+ const MAX_COL_CHARS = 255;
34
+ /** Seconds a message of the status bar stays */
35
+ const MESSAGE_SECONDS = 8;
13
36
 
14
37
  const refOf = cell => `${columnName(cell.col)}${cell.row}`;
15
38
  const rangeOf = (first, last) => (first.row === last.row && first.col === last.col ? refOf(first) : `${refOf(first)}:${refOf(last)}`);
@@ -44,7 +67,7 @@ export class XlsxEditor extends EventTarget {
44
67
  /**
45
68
  * Creates an editor inside `container` (which should have a height)
46
69
  * @param {HTMLElement} container
47
- * @param {{locale?: string, zoom?: number, tabs?: boolean, formulaBar?: boolean, readOnly?: boolean, wasmUrl?: string | URL}} [options]
70
+ * @param {{locale?: string, zoom?: number, tabs?: boolean, formulaBar?: boolean, statusBar?: boolean, readOnly?: boolean, wasmUrl?: string | URL}} [options]
48
71
  */
49
72
  static async create(container, options = {}) {
50
73
  await initEngine(options.wasmUrl);
@@ -64,6 +87,9 @@ export class XlsxEditor extends EventTarget {
64
87
  #scroller;
65
88
  #spacer;
66
89
  #tabsEl;
90
+ #statusEl;
91
+ #messageEl;
92
+ #statsEl;
67
93
  #observer;
68
94
  #frame = 0;
69
95
  #workbook = null;
@@ -78,8 +104,16 @@ export class XlsxEditor extends EventTarget {
78
104
  #edit = null; // { row, col, mode: 'enter' | 'edit', original } while a cell is being edited
79
105
  #clip = null; // { tsv, values, sheet, first, last, cut? } of the last copy, to paste losslessly
80
106
  #lastChange = null; // range of the last change, until the selection after it is recorded
81
-
82
- constructor(container, { locale, zoom = 1, tabs = true, formulaBar = true, readOnly = false } = {}) {
107
+ #drag = null; // { axis, first, count, before, cancel } while a header is dragged to move lines
108
+ #statusDirty = false;
109
+ #statusShown = null; // what the statistics of the status bar were computed from
110
+ #messageTimer = 0;
111
+ #gesture = null; // { cancel } while the edge of a header or the fill handle is dragged
112
+ #fillDrag = null; // { first, last } of the range the fill handle would fill
113
+ #find = null; // the search panel: its fields and the hits found, once it has been opened
114
+ #menu = null; // the open context menu: { close }
115
+
116
+ constructor(container, { locale, zoom = 1, tabs = true, formulaBar = true, statusBar = true, readOnly = false } = {}) {
83
117
  super();
84
118
  this.#container = container;
85
119
  this.locale = locale || defaultLocale();
@@ -98,7 +132,11 @@ export class XlsxEditor extends EventTarget {
98
132
  <textarea class="xlsx-editor-input" hidden spellcheck="false" autocomplete="off" aria-label="Editar celda"></textarea>
99
133
  <textarea class="xlsx-editor-clipboard" tabindex="-1" aria-hidden="true"></textarea>
100
134
  </div>
101
- <div class="xlsx-editor-tabs" role="tablist"${tabs ? '' : ' hidden'}></div>`;
135
+ <div class="xlsx-editor-tabs" role="tablist"${tabs ? '' : ' hidden'}></div>
136
+ <div class="xlsx-editor-status"${statusBar ? '' : ' hidden'}>
137
+ <span class="xlsx-editor-status-message" role="status"></span>
138
+ <span class="xlsx-editor-status-stats"></span>
139
+ </div>`;
102
140
  container.appendChild(root);
103
141
  this.#root = root;
104
142
  this.#bar = root.querySelector('.xlsx-editor-bar');
@@ -110,13 +148,22 @@ export class XlsxEditor extends EventTarget {
110
148
  this.#scroller = root.querySelector('.xlsx-editor-scroller');
111
149
  this.#spacer = root.querySelector('.xlsx-editor-spacer');
112
150
  this.#tabsEl = root.querySelector('.xlsx-editor-tabs');
151
+ this.#statusEl = root.querySelector('.xlsx-editor-status');
152
+ this.#messageEl = root.querySelector('.xlsx-editor-status-message');
153
+ this.#statsEl = root.querySelector('.xlsx-editor-status-stats');
113
154
  this.#input = root.querySelector('.xlsx-editor-input');
114
155
  this.#sink = root.querySelector('.xlsx-editor-clipboard');
115
156
 
116
- this.#scroller.addEventListener('scroll', () => this.render());
157
+ this.#scroller.addEventListener('scroll', () => {
158
+ this.#growOnScroll();
159
+ this.render();
160
+ });
117
161
  this.#scroller.addEventListener('pointerdown', e => this.#onPointerDown(e));
162
+ this.#scroller.addEventListener('pointermove', e => this.#updateCursor(e));
118
163
  this.#scroller.addEventListener('keydown', e => this.#onKeyDown(e));
119
- this.#scroller.addEventListener('dblclick', () => this.#beginEdit({ mode: 'edit' }));
164
+ this.#scroller.addEventListener('dblclick', e => this.#onDoubleClick(e));
165
+ this.#scroller.addEventListener('contextmenu', e => this.#onContextMenu(e));
166
+ this.addEventListener('change', () => this.#staleFind());
120
167
  this.#input.addEventListener('keydown', e => this.#onEditKey(e));
121
168
  this.#input.addEventListener('input', () => {
122
169
  this.#valueBox.value = this.#input.value;
@@ -211,6 +258,7 @@ export class XlsxEditor extends EventTarget {
211
258
  this.#clip = null;
212
259
  this.#workbook?.close();
213
260
  this.#workbook = workbook;
261
+ this.#staleFind();
214
262
  this.#styles = workbook.styleView();
215
263
  this.#sheetIndex = -1; // a new workbook starts at its first sheet, scrolled to the top
216
264
  const tabs = workbook.tabs;
@@ -238,8 +286,17 @@ export class XlsxEditor extends EventTarget {
238
286
  /** Reads the active sheet again from the workbook, after changing cells through `workbook` */
239
287
  reload() {
240
288
  if (!this.#workbook || this.#sheetIndex < 0) return;
289
+ this.#staleFind();
241
290
  this.#styles = this.#workbook.styleView();
242
- this.#loadSheet(this.#sheetIndex, { keepScroll: true });
291
+ // Sheets added or renamed through `workbook` show up in the tabs
292
+ this.#renderTabs();
293
+ this.#loadSheet(this.#validSheet(this.#sheetIndex), { keepScroll: true });
294
+ }
295
+
296
+ /** `index` if it is still a worksheet (after undoing an added sheet), else the first one */
297
+ #validSheet(index) {
298
+ const tabs = this.#workbook.tabs;
299
+ return tabs[index]?.kind === 'worksheet' ? index : tabs.findIndex(t => t.kind === 'worksheet');
243
300
  }
244
301
 
245
302
  /** @param {number} zoom 0.25–4 */
@@ -258,6 +315,7 @@ export class XlsxEditor extends EventTarget {
258
315
  select(ref) {
259
316
  const range = parseRange(ref);
260
317
  this.#commitEdit(null, { focus: false });
318
+ this.#extend(range.last.row, range.last.col);
261
319
  this.#setSelection(range.first, range.last, range.first);
262
320
  }
263
321
 
@@ -287,14 +345,144 @@ export class XlsxEditor extends EventTarget {
287
345
  return this.#workbook.area(target, { sheet: this.#sheetIndex, maxRows, locale: this.locale });
288
346
  }
289
347
 
290
- /** The selected range: `{ref, range, active, text}` or null */
348
+ /** The selected range: `{ref, range, active, text, whole}` or null */
291
349
  get selection() {
292
350
  if (!this.#selection) return null;
293
- const { first, last, active } = this.#selection;
351
+ const { first, last, active, whole = null } = this.#selection;
294
352
  const ref = `${columnName(first.col)}${first.row}`;
295
353
  const range = first.row === last.row && first.col === last.col ? ref : `${ref}:${columnName(last.col)}${last.row}`;
296
354
  const cell = this.#sheet?.cells.get(key(active.row, active.col));
297
- return { ref: `${columnName(active.col)}${active.row}`, range, active: { ...active }, text: cell?.text ?? '' };
355
+ return { ref: `${columnName(active.col)}${active.row}`, range, active: { ...active }, text: cell?.text ?? '', whole };
356
+ }
357
+
358
+ /**
359
+ * Count, sum, average, smallest and largest of the selected cells, as the status bar shows
360
+ * them; null when they are empty
361
+ */
362
+ get selectionStats() {
363
+ if (!this.#selection || !this.#sheet) return null;
364
+ const stats = rangeStats(this.#sheet.cells, this.#selection, this.#sheet);
365
+ if (!stats) return null;
366
+ const { style, ...numbers } = stats;
367
+ return numbers;
368
+ }
369
+
370
+ /**
371
+ * Moves `count` whole rows from `first` before the row `before` (numbered as they are now), as
372
+ * Excel's "cut and insert", in one undo step, and selects them. False when nothing changed: it
373
+ * stays where it is, the editor is read-only, or the engine refused (an `error` event says why).
374
+ */
375
+ moveRows(first, count, before) {
376
+ return this.#moveLines('row', first, count, before);
377
+ }
378
+
379
+ /** Moves whole columns, by number (see `moveRows`) */
380
+ moveColumns(first, count, before) {
381
+ return this.#moveLines('col', first, count, before);
382
+ }
383
+
384
+ /** Inserts `count` rows before the row `before`, as one undo step, and selects them; false if refused */
385
+ insertRows(before, count = 1) {
386
+ return this.#insertLines('row', before, count);
387
+ }
388
+
389
+ /** Inserts columns, by number (see `insertRows`) */
390
+ insertColumns(before, count = 1) {
391
+ return this.#insertLines('col', before, count);
392
+ }
393
+
394
+ /** Deletes `count` rows from `first`, as one undo step; false if refused */
395
+ deleteRows(first, count = 1) {
396
+ return this.#deleteLines('row', first, count);
397
+ }
398
+
399
+ /** Deletes columns, by number (see `deleteRows`) */
400
+ deleteColumns(first, count = 1) {
401
+ return this.#deleteLines('col', first, count);
402
+ }
403
+
404
+ /** Fits `count` columns from `first` to their content, as one undo step; empty ones stay as they are */
405
+ autofitColumns(first, count = 1) {
406
+ if (this.#readOnly || !this.#sheet) return false;
407
+ this.#commitEdit(null, { focus: false });
408
+ return this.#autofit('col', first, first + count - 1);
409
+ }
410
+
411
+ /** Fits `count` rows from `first` to their content, as one undo step; empty ones go back to the automatic height */
412
+ autofitRows(first, count = 1) {
413
+ if (this.#readOnly || !this.#sheet) return false;
414
+ this.#commitEdit(null, { focus: false });
415
+ return this.#autofit('row', first, first + count - 1);
416
+ }
417
+
418
+ /** Hides `count` rows from `first`, as one undo step */
419
+ hideRows(first, count = 1) {
420
+ return this.#setLinesHidden('row', first, first + count - 1, true);
421
+ }
422
+
423
+ /** Shows the hidden rows among the `count` from `first` */
424
+ showRows(first, count = 1) {
425
+ return this.#setLinesHidden('row', first, first + count - 1, false);
426
+ }
427
+
428
+ /** Hides columns, by number */
429
+ hideColumns(first, count = 1) {
430
+ return this.#setLinesHidden('col', first, first + count - 1, true);
431
+ }
432
+
433
+ /** Shows hidden columns, by number */
434
+ showColumns(first, count = 1) {
435
+ return this.#setLinesHidden('col', first, first + count - 1, false);
436
+ }
437
+
438
+ /**
439
+ * Fills `target` (`A1:A20`) from the selected cells, which must be at one of its ends, as one
440
+ * undo step, and selects it. False if refused (an `error` event says why).
441
+ * @param {string} target
442
+ * @param {{series?: boolean}} [options]
443
+ */
444
+ fill(target, { series = true } = {}) {
445
+ if (!this.#selection) return false;
446
+ const range = this.#selection;
447
+ return this.#fillRange(rangeOf(range.first, range.last), parseRange(target), series);
448
+ }
449
+
450
+ /**
451
+ * Where a text is, row by row: `*` is any text, `?` one character and `~` takes them literally
452
+ * @param {string} query
453
+ * @param {{matchCase?: boolean, wholeCell?: boolean, scope?: 'sheet' | 'workbook', inFormulas?: boolean}} [options]
454
+ */
455
+ find(query, options = {}) {
456
+ return this.#findHits(query, options).map(({ cell, ...hit }) => hit);
457
+ }
458
+
459
+ /**
460
+ * Replaces a text in cells with text and in formulas (never in numbers or dates), as one undo
461
+ * step; returns how many cells changed. Formatted runs of a rich text become plain.
462
+ */
463
+ replaceAll(query, replacement, options = {}) {
464
+ if (this.#readOnly || !this.#workbook) return 0;
465
+ const pattern = searchPattern(query, options);
466
+ return pattern ? this.#replaceHits(this.#findHits(query, { ...options, inFormulas: true }), pattern, replacement) : 0;
467
+ }
468
+
469
+ /** Opens the search panel, with the row to replace when asked */
470
+ openFind({ replace = false } = {}) {
471
+ const find = this.#findPanel();
472
+ find.el.hidden = false;
473
+ find.replaceRow.hidden = !replace || this.#readOnly;
474
+ find.replacing = !find.replaceRow.hidden;
475
+ find.hits = null;
476
+ find.query.focus({ preventScroll: true });
477
+ find.query.select();
478
+ this.#findStatus('');
479
+ }
480
+
481
+ closeFind() {
482
+ if (!this.#find || this.#find.el.hidden) return;
483
+ this.#find.el.hidden = true;
484
+ clearTimeout(this.#find.timer);
485
+ this.#scroller.focus({ preventScroll: true });
298
486
  }
299
487
 
300
488
  /** Undoes the last change made from the grid (or since the last `workbook.checkpoint()`) */
@@ -334,6 +522,11 @@ export class XlsxEditor extends EventTarget {
334
522
 
335
523
  destroy() {
336
524
  cancelAnimationFrame(this.#frame);
525
+ this.#drag?.cancel();
526
+ this.#gesture?.cancel();
527
+ this.#menu?.close();
528
+ if (this.#find) clearTimeout(this.#find.timer);
529
+ clearTimeout(this.#messageTimer);
337
530
  this.#observer.disconnect();
338
531
  for (const [type, listener] of this.#clipboardListeners) document.removeEventListener(type, listener);
339
532
  if (this.#fontsListener) document.fonts.removeEventListener('loadingdone', this.#fontsListener);
@@ -364,8 +557,8 @@ export class XlsxEditor extends EventTarget {
364
557
  const cells = new Map();
365
558
  let maxRow = 1;
366
559
  let maxCol = 1;
367
- for (const [row, col, style, text, kind, color] of sheet.cellViews(this.locale)) {
368
- cells.set(key(row, col), { style, text, kind, color });
560
+ for (const [row, col, style, text, kind, color, number] of sheet.cellViews(this.locale)) {
561
+ cells.set(key(row, col), { style, text, kind, color, number });
369
562
  if (text) {
370
563
  maxRow = Math.max(maxRow, row);
371
564
  maxCol = Math.max(maxCol, col);
@@ -384,10 +577,16 @@ export class XlsxEditor extends EventTarget {
384
577
  }
385
578
  }
386
579
  this.#mdw = this.#measureMdw();
387
- const { rows, cols } = sheetAxes(layout, { maxRow, maxCol }, this.#mdw);
580
+ let { rows, cols } = sheetAxes(layout, { maxRow, maxCol }, this.#mdw);
581
+ // Reading the same sheet again keeps the room scrolled into past its cells
582
+ if (this.#sheetIndex === index && this.#sheet) {
583
+ rows = rows.grown(this.#sheet.rows.count, MAX_ROWS);
584
+ cols = cols.grown(this.#sheet.cols.count, MAX_COLS);
585
+ }
388
586
  const frozen = layout.frozen ?? { rows: 0, cols: 0 };
389
587
  this.#sheet = {
390
588
  cells, merges, mergeAt, covered, rows, cols,
589
+ used: { maxRow, maxCol },
391
590
  gridlines: layout.gridlines,
392
591
  headings: layout.headings,
393
592
  frozen: { rows: Math.min(frozen.rows, rows.count - 1), cols: Math.min(frozen.cols, cols.count - 1) },
@@ -402,6 +601,9 @@ export class XlsxEditor extends EventTarget {
402
601
  this.#selection = { first: { row: 1, col: 1 }, last: { row: 1, col: 1 }, active: { row: 1, col: 1 } };
403
602
  if (start.row > 1 || start.col > 1) this.#selection = { first: start, last: start, active: start };
404
603
  }
604
+ this.#fitWholeSelection();
605
+ this.#statusDirty = true;
606
+ this.#statusShown = null;
405
607
  this.#updateTabs();
406
608
  this.#updateBar();
407
609
  this.render();
@@ -417,6 +619,45 @@ export class XlsxEditor extends EventTarget {
417
619
  return this.#sheet?.headings ? Math.round(COL_HEADER_H * this.zoom) : 0;
418
620
  }
419
621
 
622
+ /**
623
+ * Makes room up to row `row` and column `col` and some more, as Excel does when scrolling or
624
+ * moving past the end of the shown grid
625
+ */
626
+ #extend(row, col) {
627
+ const sheet = this.#sheet;
628
+ if (!sheet) return;
629
+ const rows = row + 50 > sheet.rows.count ? sheet.rows.grown(row + 200, MAX_ROWS) : sheet.rows;
630
+ const cols = col + 5 > sheet.cols.count ? sheet.cols.grown(col + 20, MAX_COLS) : sheet.cols;
631
+ if (rows === sheet.rows && cols === sheet.cols) return;
632
+ sheet.rows = rows;
633
+ sheet.cols = cols;
634
+ this.#fitWholeSelection();
635
+ this.#updateSpacer();
636
+ }
637
+
638
+ /** Whole rows or columns reach the end of the grid, also after it grows */
639
+ #fitWholeSelection() {
640
+ const sel = this.#selection;
641
+ if (!sel?.whole || !this.#sheet) return;
642
+ if (sel.whole !== 'row') {
643
+ sel.last = { ...sel.last, row: this.#sheet.rows.count };
644
+ sel.cursor = { ...sel.cursor, row: this.#sheet.rows.count };
645
+ }
646
+ if (sel.whole !== 'col') {
647
+ sel.last = { ...sel.last, col: this.#sheet.cols.count };
648
+ sel.cursor = { ...sel.cursor, col: this.#sheet.cols.count };
649
+ }
650
+ }
651
+
652
+ /** Grows the grid when the view comes near its bottom or right end */
653
+ #growOnScroll() {
654
+ const s = this.#scroller;
655
+ if (!this.#sheet) return;
656
+ const nearBottom = s.scrollTop + s.clientHeight * 2 >= s.scrollHeight;
657
+ const nearRight = s.scrollLeft + s.clientWidth * 2 >= s.scrollWidth;
658
+ if (nearBottom || nearRight) this.#extend(nearBottom ? this.#sheet.rows.count : 1, nearRight ? this.#sheet.cols.count : 1);
659
+ }
660
+
420
661
  #updateSpacer() {
421
662
  if (!this.#sheet) {
422
663
  this.#spacer.style.width = '0px';
@@ -436,6 +677,7 @@ export class XlsxEditor extends EventTarget {
436
677
  this.#frame = requestAnimationFrame(() => {
437
678
  this.#frame = 0;
438
679
  this.#draw();
680
+ if (this.#statusDirty) this.#updateStatus();
439
681
  });
440
682
  }
441
683
 
@@ -503,6 +745,47 @@ export class XlsxEditor extends EventTarget {
503
745
  if (this.#sheet.headings) {
504
746
  drawHeaders(ctx, { rows, cols, colSegs, rowSegs, rowHeaderW: gx, colHeaderH: gy, width, height, zoom, selection: this.#selection, columnName });
505
747
  }
748
+ if (this.#drag) this.#drawLineDrag(ctx, width, height, gx, gy);
749
+ if (this.#fillDrag) this.#drawFillDrag(ctx, width, height, gx, gy);
750
+ }
751
+
752
+ /** While the fill handle is dragged: the range it would fill */
753
+ #drawFillDrag(ctx, width, height, gx, gy) {
754
+ const { first, last } = this.#fillDrag;
755
+ const x = this.#linePos('col', first.col);
756
+ const y = this.#linePos('row', first.row);
757
+ ctx.save();
758
+ ctx.beginPath();
759
+ ctx.rect(gx, gy, width - gx, height - gy);
760
+ ctx.clip();
761
+ ctx.setLineDash([4, 3]);
762
+ ctx.lineWidth = 2;
763
+ ctx.strokeStyle = SELECTION;
764
+ ctx.strokeRect(Math.round(x), Math.round(y), Math.round(this.#linePos('col', last.col + 1) - x) - 1, Math.round(this.#linePos('row', last.row + 1) - y) - 1);
765
+ ctx.restore();
766
+ }
767
+
768
+ /** While a header is dragged: the lines it takes and the place they would go */
769
+ #drawLineDrag(ctx, width, height, gx, gy) {
770
+ const { axis, first, count, before } = this.#drag;
771
+ const isRow = axis === 'row';
772
+ const start = this.#linePos(axis, first);
773
+ const end = this.#linePos(axis, first + count);
774
+ ctx.save();
775
+ ctx.beginPath();
776
+ if (isRow) ctx.rect(0, gy, width, height - gy);
777
+ else ctx.rect(gx, 0, width - gx, height);
778
+ ctx.clip();
779
+ ctx.fillStyle = 'rgba(33, 115, 70, 0.18)';
780
+ if (isRow) ctx.fillRect(0, start, width, end - start);
781
+ else ctx.fillRect(start, 0, end - start, height);
782
+ if (before !== null) {
783
+ const at = Math.round(this.#linePos(axis, before));
784
+ ctx.fillStyle = SELECTION;
785
+ if (isRow) ctx.fillRect(0, at - 1, width, 3);
786
+ else ctx.fillRect(at - 1, 0, 3, height);
787
+ }
788
+ ctx.restore();
506
789
  }
507
790
 
508
791
  #drawSelection(ctx, rs, cs, clip) {
@@ -512,7 +795,8 @@ export class XlsxEditor extends EventTarget {
512
795
  const zoom = this.zoom;
513
796
  const xOf = c => cs.start + (cols.offset(c) - cols.offset(cs.from)) * zoom;
514
797
  const yOf = r => rs.start + (rows.offset(r) - rows.offset(rs.from)) * zoom;
515
- const range = this.#expandToMerges(sel.first, sel.last);
798
+ // Whole rows or columns are what was asked for, even under a merged title that goes beyond them
799
+ const range = sel.whole ? sel : this.#expandToMerges(sel.first, sel.last);
516
800
  const x = xOf(range.first.col);
517
801
  const y = yOf(range.first.row);
518
802
  const w = xOf(range.last.col + 1) - x;
@@ -523,7 +807,7 @@ export class XlsxEditor extends EventTarget {
523
807
  ctx.clip();
524
808
  if (range.first.row !== range.last.row || range.first.col !== range.last.col) {
525
809
  // Tint the range, leaving the active cell clear as Excel does
526
- const a = this.#expandToMerges(sel.active, sel.active);
810
+ const a = sel.whole ? { first: sel.active, last: sel.active } : this.#expandToMerges(sel.active, sel.active);
527
811
  const ax = xOf(a.first.col);
528
812
  const ay = yOf(a.first.row);
529
813
  ctx.beginPath();
@@ -537,7 +821,7 @@ export class XlsxEditor extends EventTarget {
537
821
  ctx.strokeRect(Math.round(x) - 1, Math.round(y) - 1, Math.round(w), Math.round(h));
538
822
  ctx.fillStyle = SELECTION;
539
823
  const handle = Math.max(5, Math.round(5 * zoom));
540
- ctx.fillRect(Math.round(x + w) - handle / 2 - 1, Math.round(y + h) - handle / 2 - 1, handle, handle);
824
+ if (!sel.whole) ctx.fillRect(Math.round(x + w) - handle / 2 - 1, Math.round(y + h) - handle / 2 - 1, handle, handle);
541
825
  ctx.restore();
542
826
  }
543
827
 
@@ -585,30 +869,663 @@ export class XlsxEditor extends EventTarget {
585
869
 
586
870
  /** The cell under a point of the canvas, or null over the headers */
587
871
  #cellAt(x, y) {
872
+ const { gx, gy } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
873
+ if (x < gx || y < gy) return null;
874
+ return { row: this.#lineAt('row', y), col: this.#lineAt('col', x) };
875
+ }
876
+
877
+ /** The row or column at a position of the canvas (clamped to the grid) */
878
+ #lineAt(axis, pos) {
588
879
  const { rows, cols, frozen } = this.#sheet;
589
880
  const zoom = this.zoom;
590
881
  const { gx, gy, frozenW, frozenH } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
591
- if (x < gx || y < gy) return null;
592
- const col = x < gx + frozenW
593
- ? cols.indexAt((x - gx) / zoom + cols.offset(1))
594
- : cols.indexAt(cols.offset(frozen.cols + 1) + this.#scroller.scrollLeft / zoom + (x - gx - frozenW) / zoom);
595
- const row = y < gy + frozenH
596
- ? rows.indexAt((y - gy) / zoom + rows.offset(1))
597
- : rows.indexAt(rows.offset(frozen.rows + 1) + this.#scroller.scrollTop / zoom + (y - gy - frozenH) / zoom);
598
- return { row, col };
882
+ if (axis === 'col') {
883
+ return pos < gx + frozenW
884
+ ? cols.indexAt((pos - gx) / zoom + cols.offset(1))
885
+ : cols.indexAt(cols.offset(frozen.cols + 1) + this.#scroller.scrollLeft / zoom + (pos - gx - frozenW) / zoom);
886
+ }
887
+ return pos < gy + frozenH
888
+ ? rows.indexAt((pos - gy) / zoom + rows.offset(1))
889
+ : rows.indexAt(rows.offset(frozen.rows + 1) + this.#scroller.scrollTop / zoom + (pos - gy - frozenH) / zoom);
890
+ }
891
+
892
+ /** Where a row or column starts on the canvas */
893
+ #linePos(axis, line) {
894
+ const { rows, cols, frozen } = this.#sheet;
895
+ const zoom = this.zoom;
896
+ const { gx, gy, frozenW, frozenH } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
897
+ if (axis === 'col') {
898
+ return line <= frozen.cols
899
+ ? gx + (cols.offset(line) - cols.offset(1)) * zoom
900
+ : gx + frozenW + (cols.offset(line) - cols.offset(frozen.cols + 1)) * zoom - this.#scroller.scrollLeft;
901
+ }
902
+ return line <= frozen.rows
903
+ ? gy + (rows.offset(line) - rows.offset(1)) * zoom
904
+ : gy + frozenH + (rows.offset(line) - rows.offset(frozen.rows + 1)) * zoom - this.#scroller.scrollTop;
905
+ }
906
+
907
+ /** The header under a point of the canvas: `{axis: 'row' | 'col', line}`, `{axis: 'all'}` or null */
908
+ #headerAt(x, y) {
909
+ const { gx, gy } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
910
+ if (x >= gx && y >= gy) return null;
911
+ if (x < gx && y < gy) return { axis: 'all' };
912
+ return y < gy ? { axis: 'col', line: this.#lineAt('col', x) } : { axis: 'row', line: this.#lineAt('row', y) };
913
+ }
914
+
915
+ /** The row or column whose end is at a point of a header: `{axis, line}`, or null */
916
+ #resizeEdge(x, y) {
917
+ if (this.#readOnly) return null;
918
+ const header = this.#headerAt(x, y);
919
+ if (!header || header.axis === 'all') return null;
920
+ const { axis, line } = header;
921
+ const sizes = axis === 'col' ? this.#sheet.cols : this.#sheet.rows;
922
+ const pos = axis === 'col' ? x : y;
923
+ const start = this.#linePos(axis, line);
924
+ if (start + sizes.size(line) * this.zoom - pos <= RESIZE_EDGE_PX) return { axis, line };
925
+ if (pos - start > RESIZE_EDGE_PX) return null;
926
+ // The start of a line is the end of the one before it that can be seen
927
+ let before = line - 1;
928
+ while (before >= 1 && sizes.size(before) === 0) before--;
929
+ return before >= 1 ? { axis, line: before } : null;
930
+ }
931
+
932
+ /** The lines a resize of `line` acts on: the selected ones when it is one of them */
933
+ #linesToResize(axis, line) {
934
+ const [a, b] = this.#selectedLines(axis);
935
+ return this.#selection.whole === axis && line >= a && line <= b ? [a, b] : [line, line];
936
+ }
937
+
938
+ /** Follows the pointer from the edge of a header, showing the new size as it changes */
939
+ #startResize({ axis, line }, e, box) {
940
+ const isRow = axis === 'row';
941
+ const sheet = this.#sheet;
942
+ const original = isRow ? sheet.rows : sheet.cols;
943
+ const [a, b] = this.#linesToResize(axis, line);
944
+ const posOf = ev => (isRow ? ev.clientY - box.top : ev.clientX - box.left);
945
+ const startPos = posOf(e);
946
+ const startSize = original.size(line);
947
+ const max = isRow ? Math.round(ptToPx(MAX_ROW_PT)) : colWidthToPx(MAX_COL_CHARS, this.#mdw);
948
+ let size = startSize;
949
+ const finish = () => {
950
+ window.removeEventListener('pointermove', move);
951
+ window.removeEventListener('pointerup', up);
952
+ window.removeEventListener('pointercancel', cancel);
953
+ this.#gesture = null;
954
+ this.#scroller.style.cursor = '';
955
+ };
956
+ const cancel = () => {
957
+ finish();
958
+ if (isRow) sheet.rows = original;
959
+ else sheet.cols = original;
960
+ this.#updateSpacer();
961
+ this.#updateBar();
962
+ this.render();
963
+ };
964
+ const move = ev => {
965
+ size = Math.min(max, Math.max(MIN_LINE_PX, Math.round(startSize + (posOf(ev) - startPos) / this.zoom)));
966
+ const custom = new Map(original.custom);
967
+ for (let i = a; i <= b; i++) custom.set(i, size);
968
+ const axisNow = new Axis(original.count, original.defaultSize, custom);
969
+ if (isRow) sheet.rows = axisNow;
970
+ else sheet.cols = axisNow;
971
+ this.#updateSpacer();
972
+ this.#nameBox.textContent = isRow ? `Alto: ${(size * 0.75).toFixed(2)}` : `Ancho: ${pxToColWidth(size, this.#mdw).toFixed(2)}`;
973
+ this.render();
974
+ };
975
+ const up = () => {
976
+ finish();
977
+ if (size === startSize) {
978
+ cancel();
979
+ return;
980
+ }
981
+ const sizes = [];
982
+ for (let i = a; i <= b; i++) sizes.push([i, size]);
983
+ this.#applySizes(axis, sizes);
984
+ };
985
+ this.#gesture = { cancel };
986
+ this.#scroller.style.cursor = isRow ? 'row-resize' : 'col-resize';
987
+ window.addEventListener('pointermove', move);
988
+ window.addEventListener('pointerup', up);
989
+ window.addEventListener('pointercancel', cancel);
990
+ }
991
+
992
+ /** Sets rows or columns to sizes in pixels, as one undo step: `[[line, px or null]]` */
993
+ #applySizes(axis, sizes) {
994
+ if (!sizes.length) return false;
995
+ try {
996
+ this.#workbook.checkpoint(this.#historyToken(null));
997
+ const sheet = this.#activeSheet();
998
+ // Runs of lines of one size are one call
999
+ for (let i = 0; i < sizes.length;) {
1000
+ const [first, size] = sizes[i];
1001
+ let j = i;
1002
+ while (j + 1 < sizes.length && sizes[j + 1][1] === size && sizes[j + 1][0] === sizes[j][0] + 1) j++;
1003
+ const last = sizes[j][0];
1004
+ if (axis === 'row') sheet.setRowHeight(`${first}:${last}`, size === null ? null : Math.round(size * 0.75 * 4) / 4);
1005
+ else if (size !== null) sheet.setColumnWidth(`${columnName(first)}:${columnName(last)}`, pxToColWidth(size, this.#mdw));
1006
+ i = j + 1;
1007
+ }
1008
+ } catch (error) {
1009
+ this.#fail(error);
1010
+ this.reload();
1011
+ return false;
1012
+ }
1013
+ this.#workbook.setSelectionAfter(this.#historyToken(null));
1014
+ this.reload();
1015
+ return true;
1016
+ }
1017
+
1018
+ /** Double click: on the edge of a header fits its lines to their content, in a cell edits it */
1019
+ #onDoubleClick(e) {
1020
+ if (!this.#sheet) return;
1021
+ const box = this.#scroller.getBoundingClientRect();
1022
+ const x = e.clientX - box.left;
1023
+ const y = e.clientY - box.top;
1024
+ if (!this.#headerAt(x, y)) {
1025
+ if (this.#fillHandleAt(x, y)) this.#fillDownToData();
1026
+ else this.#beginEdit({ mode: 'edit' });
1027
+ return;
1028
+ }
1029
+ const edge = this.#resizeEdge(x, y);
1030
+ if (edge) this.#autofit(edge.axis, ...this.#linesToResize(edge.axis, edge.line));
1031
+ }
1032
+
1033
+ /** Each of the lines `a..=b` as wide or tall as its content asks for */
1034
+ #autofit(axis, a, b) {
1035
+ const { cells, covered, mergeAt, rows, cols } = this.#sheet;
1036
+ const needed = new Map();
1037
+ const ctx = this.#ctx;
1038
+ ctx.save();
1039
+ for (const [at, cell] of cells) {
1040
+ if (!cell.text) continue;
1041
+ const r = Math.floor(at / 16_385);
1042
+ const c = at - r * 16_385;
1043
+ const line = axis === 'row' ? r : c;
1044
+ if (line < a || line > b || covered.has(at) || mergeAt.has(at)) continue;
1045
+ const xf = this.#styles.xfs[cell.style] || this.#styles.xfs[0];
1046
+ const font = xf?.font ?? this.#styles.defaultFont;
1047
+ ctx.font = canvasFont(font);
1048
+ const padding = PAD * 2 + (xf?.indent || 0) * 9;
1049
+ let size;
1050
+ if (axis === 'col') {
1051
+ // Wrapped text fits whatever the width: it does not ask for any
1052
+ if (xf?.wrap) continue;
1053
+ size = Math.ceil(ctx.measureText(cell.text).width + padding + 2);
1054
+ } else {
1055
+ const lines = xf?.wrap && cell.kind !== 'n' ? wrapLines(ctx, cell.text, Math.max(cols.size(c) - padding, 1)).length : 1;
1056
+ size = Math.ceil(lines * ptToPx(font.size || 11) * 1.22 + 4);
1057
+ }
1058
+ needed.set(line, Math.max(needed.get(line) ?? 0, size));
1059
+ }
1060
+ ctx.restore();
1061
+ const sizes = [];
1062
+ for (let line = a; line <= b; line++) {
1063
+ if ((axis === 'row' ? rows : cols).size(line) === 0) continue;
1064
+ const size = needed.get(line);
1065
+ // A row with nothing goes back to the automatic height; a column stays as it is
1066
+ if (size !== undefined) sizes.push([line, axis === 'row' ? Math.max(size, rows.defaultSize) : size]);
1067
+ else if (axis === 'row') sizes.push([line, null]);
1068
+ }
1069
+ return this.#applySizes(axis, sizes);
1070
+ }
1071
+
1072
+ /** The first and last lines of the selection along an axis */
1073
+ #selectedLines(axis) {
1074
+ const { first, last } = this.#selection;
1075
+ return axis === 'row' ? [first.row, last.row] : [first.col, last.col];
1076
+ }
1077
+
1078
+ #updateCursor(e) {
1079
+ if (!this.#sheet || e.buttons || !this.#selection) return;
1080
+ const box = this.#scroller.getBoundingClientRect();
1081
+ const x = e.clientX - box.left;
1082
+ const y = e.clientY - box.top;
1083
+ const header = x > this.#scroller.clientWidth || y > this.#scroller.clientHeight ? null : this.#headerAt(x, y);
1084
+ let cursor = '';
1085
+ const edge = header ? this.#resizeEdge(x, y) : null;
1086
+ if (edge) {
1087
+ cursor = edge.axis === 'col' ? 'col-resize' : 'row-resize';
1088
+ } else if (header) {
1089
+ cursor = 'pointer';
1090
+ if (!this.#readOnly && this.#selection.whole === header.axis && header.axis !== 'all') {
1091
+ const [a, b] = this.#selectedLines(header.axis);
1092
+ if (header.line >= a && header.line <= b) cursor = 'grab';
1093
+ }
1094
+ }
1095
+ if (!header && this.#fillHandleAt(x, y)) cursor = 'crosshair';
1096
+ this.#scroller.style.cursor = cursor;
1097
+ }
1098
+
1099
+ /** Whether a point of the canvas is on the handle at the corner of the selection */
1100
+ #fillHandleAt(x, y) {
1101
+ const sel = this.#selection;
1102
+ if (this.#readOnly || !sel || sel.whole || this.#edit) return false;
1103
+ const { last } = this.#expandToMerges(sel.first, sel.last);
1104
+ return Math.abs(x - this.#linePos('col', last.col + 1)) <= FILL_HANDLE_PX && Math.abs(y - this.#linePos('row', last.row + 1)) <= FILL_HANDLE_PX;
1105
+ }
1106
+
1107
+ /** Follows the pointer from the fill handle: the range grows along one axis, whichever it left by more */
1108
+ #startFillDrag(box) {
1109
+ const source = this.#expandToMerges(this.#selection.first, this.#selection.last);
1110
+ const { first, last } = source;
1111
+ const finish = () => {
1112
+ window.removeEventListener('pointermove', move);
1113
+ window.removeEventListener('pointerup', up);
1114
+ window.removeEventListener('pointercancel', cancel);
1115
+ this.#gesture = null;
1116
+ this.#scroller.style.cursor = '';
1117
+ const target = this.#fillDrag;
1118
+ this.#fillDrag = null;
1119
+ return target;
1120
+ };
1121
+ const cancel = () => {
1122
+ finish();
1123
+ this.render();
1124
+ };
1125
+ const move = ev => {
1126
+ const row = this.#lineAt('row', ev.clientY - box.top);
1127
+ const col = this.#lineAt('col', ev.clientX - box.left);
1128
+ const rows = row > last.row ? row - last.row : row < first.row ? row - first.row : 0;
1129
+ const cols = col > last.col ? col - last.col : col < first.col ? col - first.col : 0;
1130
+ let target = null;
1131
+ if (rows && Math.abs(rows) >= Math.abs(cols)) target = rows > 0 ? { first, last: { row, col: last.col } } : { first: { row, col: first.col }, last };
1132
+ else if (cols) target = cols > 0 ? { first, last: { row: last.row, col } } : { first: { row: first.row, col }, last };
1133
+ this.#fillDrag = target;
1134
+ if (target) {
1135
+ this.#extend(target.last.row, target.last.col);
1136
+ this.#scrollIntoView(rows && Math.abs(rows) >= Math.abs(cols) ? { row } : { col });
1137
+ }
1138
+ this.render();
1139
+ };
1140
+ const up = () => {
1141
+ const target = finish();
1142
+ if (target) this.#fillRange(rangeOf(first, last), target, true);
1143
+ this.render();
1144
+ };
1145
+ this.#gesture = { cancel };
1146
+ this.#scroller.style.cursor = 'crosshair';
1147
+ window.addEventListener('pointermove', move);
1148
+ window.addEventListener('pointerup', up);
1149
+ window.addEventListener('pointercancel', cancel);
1150
+ }
1151
+
1152
+ /** Fills `target` from the range `source`, as one undo step, and selects it */
1153
+ #fillRange(source, target, series) {
1154
+ if (this.#readOnly || !this.#sheet) return false;
1155
+ this.#commitEdit(null, { focus: false });
1156
+ const changed = rangeOf(target.first, target.last);
1157
+ try {
1158
+ this.#workbook.checkpoint(this.#historyToken(changed));
1159
+ this.#activeSheet().fill(source, changed, { series });
1160
+ } catch (error) {
1161
+ this.#fail(error);
1162
+ return false;
1163
+ }
1164
+ this.#clip = null;
1165
+ this.#lastChange = changed;
1166
+ this.reload();
1167
+ const { active } = this.#selection;
1168
+ this.#setSelection(target.first, target.last, active, { scroll: false });
1169
+ this.#recordSelectionAfter();
1170
+ this.#emitChange(changed, 'fill');
1171
+ return true;
1172
+ }
1173
+
1174
+ /** Double click on the handle: down as far as the data in the column beside the selection goes */
1175
+ #fillDownToData() {
1176
+ const { cells, used } = this.#sheet;
1177
+ const { first, last } = this.#expandToMerges(this.#selection.first, this.#selection.last);
1178
+ const filled = (row, col) => col >= 1 && !!cells.get(key(row, col))?.text;
1179
+ const beside = filled(last.row + 1, first.col - 1) ? first.col - 1 : last.col + 1;
1180
+ if (!filled(last.row + 1, beside)) return;
1181
+ let end = last.row + 1;
1182
+ while (end < used.maxRow && filled(end + 1, beside)) end++;
1183
+ this.#fillRange(rangeOf(first, last), { first, last: { row: end, col: last.col } }, true);
1184
+ }
1185
+
1186
+ /** Ctrl+D and Ctrl+R: copies the first row or column of the selection over the rest (with one, the one before) */
1187
+ #fillSelection(axis) {
1188
+ if (this.#readOnly || this.#selection.whole) return;
1189
+ const { first, last } = this.#selection;
1190
+ const down = axis === 'row';
1191
+ const single = down ? first.row === last.row : first.col === last.col;
1192
+ if (single && (down ? first.row : first.col) === 1) return;
1193
+ const from = single
1194
+ ? { first: down ? { row: first.row - 1, col: first.col } : { row: first.row, col: first.col - 1 }, last: down ? { row: first.row - 1, col: last.col } : { row: last.row, col: first.col - 1 } }
1195
+ : { first, last: down ? { row: first.row, col: last.col } : { row: last.row, col: first.col } };
1196
+ this.#fillRange(rangeOf(from.first, from.last), { first: from.first, last }, false);
1197
+ if (single) this.#setSelection(first, last, first, { scroll: false });
1198
+ }
1199
+
1200
+ /** Alt+=: the sum of the numbers next to the cell, to confirm; below or beside a selected range, written */
1201
+ #autosum() {
1202
+ if (this.#readOnly || this.#selection.whole) return;
1203
+ this.#commitEdit(null, { focus: false });
1204
+ const { cells, rows, cols } = this.#sheet;
1205
+ const isNumber = (row, col) => cells.get(key(row, col))?.number !== undefined;
1206
+ const isEmpty = (row, col) => !cells.get(key(row, col))?.kind;
1207
+ const { first, last, active } = this.#selection;
1208
+ if (first.row === last.row && first.col === last.col) {
1209
+ const { row, col } = active;
1210
+ const up = numberRun(r => isNumber(r, col), row, -1, rows.count);
1211
+ const left = up === null ? numberRun(c => isNumber(row, c), col, -1, cols.count) : null;
1212
+ const range = up !== null ? rangeOf({ row: up, col }, { row: row - 1, col }) : left !== null ? rangeOf({ row, col: left }, { row, col: col - 1 }) : '';
1213
+ this.#beginEdit({ mode: 'edit', text: `=SUM(${range})` });
1214
+ if (!range) this.#input.setSelectionRange(5, 5);
1215
+ return;
1216
+ }
1217
+ const sideways = first.row === last.row;
1218
+ const lastIsEmpty = sideways
1219
+ ? isEmpty(last.row, last.col)
1220
+ : Array.from({ length: last.col - first.col + 1 }, (_, i) => isEmpty(last.row, first.col + i)).every(Boolean);
1221
+ const lanes = sideways ? [first.row] : Array.from({ length: last.col - first.col + 1 }, (_, i) => first.col + i);
1222
+ const at = (sideways ? last.col : last.row) + (lastIsEmpty ? 0 : 1);
1223
+ const start = sideways ? first.col : first.row;
1224
+ const values = lanes.map(lane => {
1225
+ const cell = line => (sideways ? { row: lane, col: line } : { row: line, col: lane });
1226
+ let any = false;
1227
+ for (let line = start; line < at; line++) any ||= isNumber(cell(line).row, cell(line).col);
1228
+ if (!any || !isEmpty(cell(at).row, cell(at).col)) return undefined;
1229
+ return { formula: `SUM(${rangeOf(cell(start), cell(at - 1))})` };
1230
+ });
1231
+ if (values.every(v => v === undefined)) {
1232
+ this.#showMessage('No hay números que sumar, o el lugar de la suma ya tiene datos.');
1233
+ return;
1234
+ }
1235
+ const written = this.#write(sideways ? { row: first.row, col: at } : { row: at, col: first.col }, sideways ? [values] : [values], 'edit');
1236
+ if (written) {
1237
+ this.#setSelection(first, sideways ? { row: last.row, col: at } : { row: at, col: last.col }, active, { scroll: false });
1238
+ this.#recordSelectionAfter();
1239
+ }
1240
+ }
1241
+
1242
+ /** Hides or shows the lines `a..=b`, as one undo step */
1243
+ #setLinesHidden(axis, a, b, hidden) {
1244
+ if (this.#readOnly || !this.#sheet) return false;
1245
+ this.#commitEdit(null, { focus: false });
1246
+ try {
1247
+ this.#workbook.checkpoint(this.#historyToken(null));
1248
+ const sheet = this.#activeSheet();
1249
+ if (axis === 'row') sheet.setRowsHidden(`${a}:${b}`, hidden);
1250
+ else sheet.setColumnsHidden(`${columnName(a)}:${columnName(b)}`, hidden);
1251
+ this.#workbook.setSelectionAfter(this.#historyToken(null));
1252
+ } catch (error) {
1253
+ this.#fail(error);
1254
+ return false;
1255
+ }
1256
+ this.reload();
1257
+ return true;
1258
+ }
1259
+
1260
+ /** Whether any of the lines `a..=b` is hidden */
1261
+ #hasHidden(axis, a, b) {
1262
+ const { custom } = axis === 'row' ? this.#sheet.rows : this.#sheet.cols;
1263
+ for (const [line, size] of custom) if (size === 0 && line >= a && line <= b) return true;
1264
+ return false;
1265
+ }
1266
+
1267
+ // ---------- Search ----------
1268
+
1269
+ /** The hits found by the search panel are out of date */
1270
+ #staleFind() {
1271
+ if (this.#find) this.#find.hits = null;
1272
+ }
1273
+
1274
+ /**
1275
+ * Cells whose text matches, sheet by sheet and row by row. With `inFormulas` the formula counts
1276
+ * instead of the text it shows, and `cell` has the formula or text to replace in; numbers and
1277
+ * dates are then left out.
1278
+ */
1279
+ #findHits(query, { matchCase, wholeCell, scope = 'sheet', inFormulas = false } = {}) {
1280
+ const pattern = searchPattern(query, { matchCase, wholeCell });
1281
+ if (!pattern || !this.#workbook) return [];
1282
+ const tabs = this.#workbook.tabs;
1283
+ const indexes = scope === 'workbook' ? tabs.flatMap((t, i) => (t.kind === 'worksheet' && t.state === 'visible' ? [i] : [])) : [this.#sheetIndex];
1284
+ const hits = [];
1285
+ for (const index of indexes) {
1286
+ const sheet = this.#workbook.sheet(index);
1287
+ const found = [];
1288
+ if (inFormulas) {
1289
+ let maxRow = 0;
1290
+ let maxCol = 0;
1291
+ for (const [row, col, , text] of sheet.cellViews(this.locale)) {
1292
+ if (!text) continue;
1293
+ maxRow = Math.max(maxRow, row);
1294
+ maxCol = Math.max(maxCol, col);
1295
+ }
1296
+ if (!maxRow) continue;
1297
+ sheet.getCells(`A1:${columnName(maxCol)}${maxRow}`).forEach((cells, r) => cells.forEach((cell, c) => {
1298
+ const text = cell.formula ? `=${cell.formula}` : cell.type === 'string' ? cell.value : null;
1299
+ if (text && matches(pattern, text)) found.push({ row: r + 1, col: c + 1, text, cell });
1300
+ }));
1301
+ } else if (index === this.#sheetIndex) {
1302
+ for (const [at, cell] of this.#sheet.cells) {
1303
+ if (!cell.text || !matches(pattern, cell.text)) continue;
1304
+ const row = Math.floor(at / 16_385);
1305
+ found.push({ row, col: at - row * 16_385, text: cell.text });
1306
+ }
1307
+ } else {
1308
+ for (const [row, col, , text] of sheet.cellViews(this.locale)) if (text && matches(pattern, text)) found.push({ row, col, text });
1309
+ }
1310
+ found.sort((a, b) => a.row - b.row || a.col - b.col);
1311
+ for (const hit of found) hits.push({ sheet: index, sheetName: tabs[index].name, ref: refOf(hit), ...hit });
1312
+ }
1313
+ return hits;
1314
+ }
1315
+
1316
+ /** Writes the replacements, one block per sheet, as one undo step; returns how many cells changed */
1317
+ #replaceHits(hits, pattern, replacement) {
1318
+ if (!hits.length) return 0;
1319
+ this.#commitEdit(null, { focus: false });
1320
+ const bySheet = new Map();
1321
+ for (const hit of hits) {
1322
+ if (!bySheet.has(hit.sheet)) bySheet.set(hit.sheet, []);
1323
+ bySheet.get(hit.sheet).push(hit);
1324
+ }
1325
+ const blocks = [...bySheet].map(([index, found]) => {
1326
+ const first = { row: Math.min(...found.map(h => h.row)), col: Math.min(...found.map(h => h.col)) };
1327
+ const last = { row: Math.max(...found.map(h => h.row)), col: Math.max(...found.map(h => h.col)) };
1328
+ return { index, first, last, found };
1329
+ });
1330
+ const only = blocks.length === 1 && blocks[0].index === this.#sheetIndex ? rangeOf(blocks[0].first, blocks[0].last) : null;
1331
+ let written = false;
1332
+ try {
1333
+ this.#workbook.checkpoint(this.#historyToken(only));
1334
+ for (const { index, first, last, found } of blocks) {
1335
+ // Holes leave the cells between the hits as they are
1336
+ const rows = Array.from({ length: last.row - first.row + 1 }, () => new Array(last.col - first.col + 1));
1337
+ for (const hit of found) {
1338
+ const text = replaceMatches(pattern, hit.text, replacement);
1339
+ const value = hit.cell.formula ? { formula: text } : parseInput(text, this.locale).value;
1340
+ rows[hit.row - first.row][hit.col - first.col] = value === '' ? null : value;
1341
+ }
1342
+ this.#workbook.sheet(index).setValues(refOf(first), rows);
1343
+ written = true;
1344
+ }
1345
+ this.#workbook.setSelectionAfter(this.#historyToken(only));
1346
+ } catch (error) {
1347
+ // All or nothing, also when a later sheet refuses
1348
+ if (written) this.#workbook.undo();
1349
+ this.reload();
1350
+ this.#fail(error);
1351
+ return 0;
1352
+ }
1353
+ this.#clip = null;
1354
+ this.reload();
1355
+ for (const { index, first, last } of blocks) this.#emitChange(rangeOf(first, last), 'replace', this.#workbook.sheet(index));
1356
+ return hits.length;
1357
+ }
1358
+
1359
+ #findOptions() {
1360
+ const { matchCase, wholeCell, scope, inFormulas, replacing } = this.#find;
1361
+ return { matchCase: matchCase.checked, wholeCell: wholeCell.checked, scope: scope.value, inFormulas: inFormulas.checked || replacing };
1362
+ }
1363
+
1364
+ #findStatus(text) {
1365
+ this.#find.count.textContent = text;
1366
+ }
1367
+
1368
+ /** Goes to the next hit after the active cell (or that one, with `here`), or the one before */
1369
+ #findStep(step, { here = false } = {}) {
1370
+ const find = this.#find;
1371
+ const query = find.query.value;
1372
+ if (!query) {
1373
+ this.#findStatus('');
1374
+ return;
1375
+ }
1376
+ try {
1377
+ find.hits ??= this.#findHits(query, this.#findOptions());
1378
+ } catch (error) {
1379
+ this.#fail(error);
1380
+ return;
1381
+ }
1382
+ const { hits } = find;
1383
+ if (!hits.length) {
1384
+ this.#findStatus('Sin resultados');
1385
+ return;
1386
+ }
1387
+ const { row, col } = this.#selection.active;
1388
+ const compare = hit => hit.sheet - this.#sheetIndex || hit.row - row || hit.col - col;
1389
+ let at;
1390
+ if (step > 0) {
1391
+ at = hits.findIndex(hit => (here ? compare(hit) >= 0 : compare(hit) > 0));
1392
+ if (at < 0) at = 0;
1393
+ } else {
1394
+ at = hits.findLastIndex(hit => compare(hit) < 0);
1395
+ if (at < 0) at = hits.length - 1;
1396
+ }
1397
+ const hit = hits[at];
1398
+ if (hit.sheet !== this.#sheetIndex) this.setSheet(hit.sheet);
1399
+ this.select(hit.ref);
1400
+ this.#findStatus(`${at + 1} de ${hits.length}`);
1401
+ }
1402
+
1403
+ /** The replacement of the search panel on the active cell if it is a hit, and on to the next */
1404
+ #findReplaceOne() {
1405
+ const find = this.#find;
1406
+ const pattern = searchPattern(find.query.value, this.#findOptions());
1407
+ if (!pattern) return;
1408
+ find.hits ??= this.#findHits(find.query.value, this.#findOptions());
1409
+ const { row, col } = this.#selection.active;
1410
+ const hit = find.hits.find(h => h.sheet === this.#sheetIndex && h.row === row && h.col === col);
1411
+ if (hit) this.#replaceHits([hit], pattern, find.replacement.value);
1412
+ this.#findStep(1);
1413
+ }
1414
+
1415
+ #findReplaceAll() {
1416
+ const find = this.#find;
1417
+ const pattern = searchPattern(find.query.value, this.#findOptions());
1418
+ if (!pattern) return;
1419
+ const hits = this.#findHits(find.query.value, this.#findOptions());
1420
+ const done = this.#replaceHits(hits, pattern, find.replacement.value);
1421
+ this.#findStatus(done === 1 ? '1 reemplazo' : `${done} reemplazos`);
1422
+ }
1423
+
1424
+ /** The search panel, made the first time it is asked for */
1425
+ #findPanel() {
1426
+ if (this.#find) return this.#find;
1427
+ const el = document.createElement('div');
1428
+ el.className = 'xlsx-editor-find';
1429
+ el.setAttribute('role', 'search');
1430
+ el.innerHTML = `
1431
+ <div class="xlsx-editor-find-row">
1432
+ <input type="text" data-field="query" placeholder="Buscar" aria-label="Buscar" spellcheck="false" autocomplete="off" />
1433
+ <button type="button" data-action="previous" aria-label="Anterior" title="Anterior (Mayús+Enter)">‹</button>
1434
+ <button type="button" data-action="next" aria-label="Siguiente" title="Siguiente (Enter)">›</button>
1435
+ <button type="button" data-action="close" aria-label="Cerrar" title="Cerrar (Escape)">×</button>
1436
+ </div>
1437
+ <div class="xlsx-editor-find-row" data-field="replaceRow" hidden>
1438
+ <input type="text" data-field="replacement" placeholder="Reemplazar por" aria-label="Reemplazar por" spellcheck="false" autocomplete="off" />
1439
+ <button type="button" data-action="replace">Reemplazar</button>
1440
+ <button type="button" data-action="replaceAll">Todos</button>
1441
+ </div>
1442
+ <div class="xlsx-editor-find-row">
1443
+ <label><input type="checkbox" data-field="matchCase" /> Mayús/minús</label>
1444
+ <label><input type="checkbox" data-field="wholeCell" /> Celda completa</label>
1445
+ <label><input type="checkbox" data-field="inFormulas" /> En fórmulas</label>
1446
+ <select data-field="scope" aria-label="Dónde buscar">
1447
+ <option value="sheet">Esta hoja</option>
1448
+ <option value="workbook">Todo el libro</option>
1449
+ </select>
1450
+ <span class="xlsx-editor-find-count" role="status"></span>
1451
+ </div>`;
1452
+ const field = name => el.querySelector(`[data-field="${name}"]`);
1453
+ const find = {
1454
+ el,
1455
+ hits: null,
1456
+ timer: 0,
1457
+ replacing: false,
1458
+ query: field('query'),
1459
+ replacement: field('replacement'),
1460
+ replaceRow: field('replaceRow'),
1461
+ matchCase: field('matchCase'),
1462
+ wholeCell: field('wholeCell'),
1463
+ inFormulas: field('inFormulas'),
1464
+ scope: field('scope'),
1465
+ count: el.querySelector('.xlsx-editor-find-count'),
1466
+ };
1467
+ this.#find = find;
1468
+ // Native events of the inner fields are not the editor's, and its keys are not the grid's
1469
+ for (const type of ['change', 'input', 'selectionchange', 'keydown', 'copy', 'cut', 'paste']) el.addEventListener(type, e => e.stopPropagation());
1470
+ const search = () => {
1471
+ clearTimeout(find.timer);
1472
+ find.hits = null;
1473
+ find.timer = setTimeout(() => this.#findStep(1, { here: true }), FIND_DELAY_MS);
1474
+ };
1475
+ find.query.addEventListener('input', search);
1476
+ for (const option of [find.matchCase, find.wholeCell, find.inFormulas, find.scope]) option.addEventListener('change', search);
1477
+ const actions = {
1478
+ next: () => this.#findStep(1),
1479
+ previous: () => this.#findStep(-1),
1480
+ close: () => this.closeFind(),
1481
+ replace: () => this.#findReplaceOne(),
1482
+ replaceAll: () => this.#findReplaceAll(),
1483
+ };
1484
+ el.addEventListener('click', e => actions[e.target.closest?.('[data-action]')?.dataset.action]?.());
1485
+ el.addEventListener('keydown', e => {
1486
+ const mod = e.ctrlKey || e.metaKey;
1487
+ if (e.key === 'Escape') {
1488
+ e.preventDefault();
1489
+ this.closeFind();
1490
+ } else if (mod && e.key.toLowerCase() === 'f') {
1491
+ e.preventDefault();
1492
+ find.query.focus();
1493
+ find.query.select();
1494
+ } else if (e.key === 'Enter' && e.target === find.query) {
1495
+ e.preventDefault();
1496
+ clearTimeout(find.timer);
1497
+ this.#findStep(e.shiftKey ? -1 : 1);
1498
+ } else if (e.key === 'Enter' && e.target === find.replacement) {
1499
+ e.preventDefault();
1500
+ this.#findReplaceOne();
1501
+ }
1502
+ });
1503
+ this.#body.appendChild(el);
1504
+ return find;
599
1505
  }
600
1506
 
601
1507
  #onPointerDown(e) {
602
- if (!this.#sheet || e.button !== 0) return;
1508
+ // Ctrl+click is the right button on a Mac: `contextmenu` takes it
1509
+ if (!this.#sheet || e.button !== 0 || e.ctrlKey) return;
603
1510
  const box = this.#scroller.getBoundingClientRect();
604
1511
  const x = e.clientX - box.left;
605
1512
  const y = e.clientY - box.top;
606
1513
  // Clicks on the scrollbars scroll
607
1514
  if (x > this.#scroller.clientWidth || y > this.#scroller.clientHeight) return;
608
- const at = this.#cellAt(x, y);
609
- if (!at) return;
610
1515
  this.#commitEdit(null, { focus: false });
611
1516
  this.#scroller.focus({ preventScroll: true });
1517
+ const header = this.#headerAt(x, y);
1518
+ if (header) {
1519
+ const edge = this.#resizeEdge(x, y);
1520
+ if (edge) this.#startResize(edge, e, box);
1521
+ else this.#onHeaderDown(e, header, box);
1522
+ return;
1523
+ }
1524
+ if (!e.shiftKey && this.#fillHandleAt(x, y)) {
1525
+ this.#startFillDrag(box);
1526
+ return;
1527
+ }
1528
+ const at = this.#cellAt(x, y);
612
1529
  const anchor = e.shiftKey && this.#selection ? this.#selection.active : at;
613
1530
  this.#setSelection(anchor, at, anchor, { scroll: false });
614
1531
  const move = ev => {
@@ -623,11 +1540,486 @@ export class XlsxEditor extends EventTarget {
623
1540
  window.addEventListener('pointerup', up);
624
1541
  }
625
1542
 
1543
+ /**
1544
+ * A press on a header: selects its row or column (with Shift, up to it; dragging, the ones
1545
+ * passed over), or on a line already selected starts moving the selected ones
1546
+ */
1547
+ #onHeaderDown(e, { axis, line }, box) {
1548
+ if (axis === 'all') {
1549
+ this.#selectLines('all', 1, 1, 1);
1550
+ return;
1551
+ }
1552
+ const sel = this.#selection;
1553
+ const [a, b] = this.#selectedLines(axis);
1554
+ if (!this.#readOnly && !e.shiftKey && sel.whole === axis && line >= a && line <= b) {
1555
+ this.#startLineDrag(axis, a, b - a + 1, line, e, box);
1556
+ return;
1557
+ }
1558
+ const anchor = e.shiftKey ? (axis === 'row' ? sel.active.row : sel.active.col) : line;
1559
+ this.#selectLines(axis, anchor, line, anchor);
1560
+ const move = ev => this.#selectLines(axis, anchor, this.#lineAt(axis, axis === 'row' ? ev.clientY - box.top : ev.clientX - box.left), anchor);
1561
+ const up = () => {
1562
+ window.removeEventListener('pointermove', move);
1563
+ window.removeEventListener('pointerup', up);
1564
+ };
1565
+ window.addEventListener('pointermove', move);
1566
+ window.addEventListener('pointerup', up);
1567
+ }
1568
+
1569
+ /**
1570
+ * Follows the pointer from a selected header: past a few pixels the selected lines are being
1571
+ * moved (near the edges the view scrolls); a press that stays selects only the line pressed
1572
+ */
1573
+ #startLineDrag(axis, first, count, line, e, box) {
1574
+ const isRow = axis === 'row';
1575
+ const posOf = ev => (isRow ? ev.clientY - box.top : ev.clientX - box.left);
1576
+ const startPos = posOf(e);
1577
+ let lastPos = startPos;
1578
+ const aim = () => {
1579
+ // The nearest line between two: after the line under the pointer past its middle
1580
+ const under = this.#lineAt(axis, lastPos);
1581
+ const size = (isRow ? this.#sheet.rows : this.#sheet.cols).size(under) * this.zoom;
1582
+ const before = lastPos > this.#linePos(axis, under) + size / 2 ? under + 1 : under;
1583
+ this.#drag.before = before >= first && before <= first + count ? null : before;
1584
+ this.render();
1585
+ };
1586
+ const scrollTimer = setInterval(() => {
1587
+ if (!this.#drag) return;
1588
+ const { gx, gy, frozenW, frozenH } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
1589
+ const start = isRow ? gy + frozenH : gx + frozenW;
1590
+ const end = isRow ? this.#scroller.clientHeight : this.#scroller.clientWidth;
1591
+ let step = 0;
1592
+ if (lastPos < start + DRAG_SCROLL_EDGE_PX) step = -DRAG_SCROLL_STEP_PX;
1593
+ else if (lastPos > end - DRAG_SCROLL_EDGE_PX) step = DRAG_SCROLL_STEP_PX;
1594
+ if (!step) return;
1595
+ if (isRow) this.#scroller.scrollTop += step;
1596
+ else this.#scroller.scrollLeft += step;
1597
+ aim();
1598
+ }, 40);
1599
+ const finish = () => {
1600
+ clearInterval(scrollTimer);
1601
+ window.removeEventListener('pointermove', move);
1602
+ window.removeEventListener('pointerup', up);
1603
+ window.removeEventListener('pointercancel', cancel);
1604
+ this.#scroller.style.cursor = '';
1605
+ const drag = this.#drag;
1606
+ this.#drag = null;
1607
+ return drag;
1608
+ };
1609
+ const cancel = () => {
1610
+ finish();
1611
+ this.render();
1612
+ };
1613
+ const move = ev => {
1614
+ lastPos = posOf(ev);
1615
+ if (!this.#drag) {
1616
+ if (Math.abs(lastPos - startPos) < DRAG_START_PX) return;
1617
+ this.#drag = { axis, first, count, before: null, cancel };
1618
+ this.#scroller.style.cursor = 'grabbing';
1619
+ }
1620
+ aim();
1621
+ };
1622
+ const up = () => {
1623
+ const drag = finish();
1624
+ if (!drag) this.#selectLines(axis, line, line, line);
1625
+ else if (drag.before !== null) this.#moveLines(axis, first, count, drag.before);
1626
+ this.render();
1627
+ };
1628
+ window.addEventListener('pointermove', move);
1629
+ window.addEventListener('pointerup', up);
1630
+ window.addEventListener('pointercancel', cancel);
1631
+ }
1632
+
1633
+ /** Selects whole rows or columns from `a` to `b` (or the whole sheet, with `axis` `all`) */
1634
+ #selectLines(axis, a, b, activeLine) {
1635
+ const { rows, cols } = this.#sheet;
1636
+ const options = { scroll: false, whole: axis };
1637
+ if (axis === 'row') this.#setSelection({ row: a, col: 1 }, { row: b, col: cols.count }, { row: activeLine, col: 1 }, options);
1638
+ else if (axis === 'col') this.#setSelection({ row: 1, col: a }, { row: rows.count, col: b }, { row: 1, col: activeLine }, options);
1639
+ else this.#setSelection({ row: 1, col: 1 }, { row: rows.count, col: cols.count }, { row: 1, col: 1 }, options);
1640
+ }
1641
+
1642
+ /** The selected cells to act on: whole rows or columns only as far as the sheet has cells */
1643
+ #contentBounds() {
1644
+ const { first, last, whole } = this.#selection;
1645
+ if (!whole) return { first, last };
1646
+ const { maxRow, maxCol } = this.#sheet.used;
1647
+ return {
1648
+ first,
1649
+ last: {
1650
+ row: whole === 'row' ? last.row : Math.max(first.row, Math.min(last.row, maxRow)),
1651
+ col: whole === 'col' ? last.col : Math.max(first.col, Math.min(last.col, maxCol)),
1652
+ },
1653
+ };
1654
+ }
1655
+
1656
+ /** The cells copying or clearing act on: a plain range takes in the merged cells it touches */
1657
+ #actionBounds() {
1658
+ const bounds = this.#contentBounds();
1659
+ return this.#selection.whole ? bounds : this.#expandToMerges(bounds.first, bounds.last);
1660
+ }
1661
+
1662
+ /** Alt+arrow: the rows or columns of the selection one place up, down, left or right */
1663
+ #nudgeLines(axis, step) {
1664
+ const { whole } = this.#selection;
1665
+ if (whole && whole !== axis) return;
1666
+ const [first, last] = this.#selectedLines(axis);
1667
+ const before = step < 0 ? first - 1 : last + 2;
1668
+ if (before < 1 || before > (axis === 'row' ? MAX_ROWS : MAX_COLS) + 1) return;
1669
+ if (!this.#moveLines(axis, first, last - first + 1, before)) return;
1670
+ const [a, b] = this.#selectedLines(axis);
1671
+ const line = step < 0 ? a : b;
1672
+ this.#scrollIntoView(axis === 'row' ? { row: line } : { col: line });
1673
+ }
1674
+
1675
+ #moveLines(axis, first, count, before) {
1676
+ const last = first + count - 1;
1677
+ if (before >= first && before <= last + 1) return false;
1678
+ const isRow = axis === 'row';
1679
+ return this.#editLines(axis, Math.min(first, before), Math.max(last, before - 1), count, 'move', sheet => (isRow ? sheet.moveRows(first, count, before) : sheet.moveColumns(first, count, before)));
1680
+ }
1681
+
1682
+ #insertLines(axis, before, count) {
1683
+ const isRow = axis === 'row';
1684
+ return this.#editLines(axis, before, null, count, 'insert', sheet => (isRow ? sheet.insertRows(before, count) : sheet.insertColumns(before, count)));
1685
+ }
1686
+
1687
+ #deleteLines(axis, first, count) {
1688
+ const isRow = axis === 'row';
1689
+ return this.#editLines(axis, first, null, count, 'delete', sheet => (isRow ? sheet.deleteRows(first, count) : sheet.deleteColumns(first, count)));
1690
+ }
1691
+
1692
+ /**
1693
+ * One edit of whole lines as an undo step: `run` does it and returns where the lines start.
1694
+ * It changes the lines `from..=to`; `to` null reaches the end of what the sheet has (and of what
1695
+ * is pushed there). The lines end up selected.
1696
+ */
1697
+ #editLines(axis, from, to, count, source, run) {
1698
+ if (this.#readOnly || !this.#sheet) return false;
1699
+ this.#commitEdit(null, { focus: false });
1700
+ const isRow = axis === 'row';
1701
+ const { maxRow, maxCol } = this.#sheet.used;
1702
+ const end = Math.min(isRow ? MAX_ROWS : MAX_COLS, to ?? Math.max((isRow ? maxRow : maxCol) + count, from + count - 1));
1703
+ const changed = isRow ? rangeOf({ row: from, col: 1 }, { row: end, col: maxCol }) : rangeOf({ row: 1, col: from }, { row: maxRow, col: end });
1704
+ let start;
1705
+ try {
1706
+ this.#workbook.checkpoint(this.#historyToken(changed));
1707
+ start = run(this.#activeSheet());
1708
+ } catch (error) {
1709
+ this.#fail(error);
1710
+ return false;
1711
+ }
1712
+ this.#clip = null;
1713
+ this.#lastChange = changed;
1714
+ this.reload();
1715
+ this.#extend(isRow ? start + count : 1, isRow ? 1 : start + count);
1716
+ this.#selectLines(axis, start, start + count - 1, start);
1717
+ this.#recordSelectionAfter();
1718
+ this.#emitChange(changed, source);
1719
+ return true;
1720
+ }
1721
+
1722
+ // ---------- Context menu ----------
1723
+
1724
+ /** Right click: acts on what is selected, or on what is under the pointer when it is outside */
1725
+ #onContextMenu(e) {
1726
+ if (!this.#sheet || !this.#selection) return;
1727
+ const box = this.#scroller.getBoundingClientRect();
1728
+ const x = e.clientX - box.left;
1729
+ const y = e.clientY - box.top;
1730
+ if (x > this.#scroller.clientWidth || y > this.#scroller.clientHeight) return;
1731
+ e.preventDefault();
1732
+ this.#commitEdit(null, { focus: false });
1733
+ this.#scroller.focus({ preventScroll: true });
1734
+ const header = this.#headerAt(x, y);
1735
+ const { first, last, whole } = this.#selection;
1736
+ if (header?.axis === 'all') {
1737
+ this.#selectLines('all', 1, 1, 1);
1738
+ } else if (header) {
1739
+ const [a, b] = this.#selectedLines(header.axis);
1740
+ if (whole !== header.axis || header.line < a || header.line > b) this.#selectLines(header.axis, header.line, header.line, header.line);
1741
+ } else {
1742
+ const at = this.#cellAt(x, y);
1743
+ if (at.row < first.row || at.row > last.row || at.col < first.col || at.col > last.col) this.#setSelection(at, at, at, { scroll: false });
1744
+ }
1745
+ this.#openMenu(e.clientX, e.clientY);
1746
+ }
1747
+
1748
+ /** Shift+F10 or the menu key: the menu at the active cell */
1749
+ #openMenuAtCell() {
1750
+ const { active } = this.#selection;
1751
+ const box = this.#scroller.getBoundingClientRect();
1752
+ this.#openMenu(box.left + this.#linePos('col', active.col) + 8, box.top + this.#linePos('row', active.row) + 8);
1753
+ }
1754
+
1755
+ #menuItems() {
1756
+ const { whole } = this.#selection;
1757
+ const [r1, r2] = this.#selectedLines('row');
1758
+ const [c1, c2] = this.#selectedLines('col');
1759
+ const [rows, cols] = [r2 - r1 + 1, c2 - c1 + 1];
1760
+ const many = (n, one, several) => (n === 1 ? `una ${one}` : `${n} ${several}`);
1761
+ const shortcut = key => `${/Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '') ? '⌘' : 'Ctrl+'}${key}`;
1762
+ const items = [];
1763
+ if (!this.#readOnly) items.push({ label: 'Cortar', keys: shortcut('X'), run: () => this.#copyToSystem(true) });
1764
+ items.push({ label: 'Copiar', keys: shortcut('C'), run: () => this.#copyToSystem(false) });
1765
+ if (this.#readOnly) return items;
1766
+ items.push(
1767
+ { label: 'Pegar', keys: shortcut('V'), run: () => this.#pasteFromSystem() },
1768
+ { label: 'Borrar contenido', keys: 'Supr', run: () => this.#clearSelection() },
1769
+ );
1770
+ if (whole === 'all') {
1771
+ if (this.#hasHidden('row', r1, r2) || this.#hasHidden('col', c1, c2)) {
1772
+ items.push(null, {
1773
+ label: 'Mostrar filas y columnas ocultas',
1774
+ run: () => {
1775
+ this.#setLinesHidden('row', r1, r2, false);
1776
+ this.#setLinesHidden('col', c1, c2, false);
1777
+ },
1778
+ });
1779
+ }
1780
+ return items;
1781
+ }
1782
+ items.push(null);
1783
+ if (whole !== 'col') {
1784
+ items.push(
1785
+ { label: `Insertar ${many(rows, 'fila', 'filas')} arriba`, run: () => this.#insertLines('row', r1, rows) },
1786
+ { label: `Insertar ${many(rows, 'fila', 'filas')} abajo`, run: () => this.#insertLines('row', r2 + 1, rows) },
1787
+ );
1788
+ }
1789
+ if (whole !== 'row') {
1790
+ items.push(
1791
+ { label: `Insertar ${many(cols, 'columna', 'columnas')} a la izquierda`, run: () => this.#insertLines('col', c1, cols) },
1792
+ { label: `Insertar ${many(cols, 'columna', 'columnas')} a la derecha`, run: () => this.#insertLines('col', c2 + 1, cols) },
1793
+ );
1794
+ }
1795
+ items.push(null);
1796
+ if (whole !== 'col') items.push({ label: rows === 1 ? 'Eliminar la fila' : `Eliminar ${rows} filas`, run: () => this.#deleteLines('row', r1, rows) });
1797
+ if (whole !== 'row') items.push({ label: cols === 1 ? 'Eliminar la columna' : `Eliminar ${cols} columnas`, run: () => this.#deleteLines('col', c1, cols) });
1798
+ if (whole === 'col') items.push(null, { label: 'Autoajustar el ancho', run: () => this.#autofit('col', c1, c2) });
1799
+ if (whole === 'row') items.push(null, { label: 'Autoajustar el alto', run: () => this.#autofit('row', r1, r2) });
1800
+ if (whole === 'col') {
1801
+ items.push({ label: cols === 1 ? 'Ocultar la columna' : `Ocultar ${cols} columnas`, run: () => this.#setLinesHidden('col', c1, c2, true) });
1802
+ if (this.#hasHidden('col', c1, c2)) items.push({ label: 'Mostrar columnas ocultas', run: () => this.#setLinesHidden('col', c1, c2, false) });
1803
+ }
1804
+ if (whole === 'row') {
1805
+ items.push({ label: rows === 1 ? 'Ocultar la fila' : `Ocultar ${rows} filas`, run: () => this.#setLinesHidden('row', r1, r2, true) });
1806
+ if (this.#hasHidden('row', r1, r2)) items.push({ label: 'Mostrar filas ocultas', run: () => this.#setLinesHidden('row', r1, r2, false) });
1807
+ }
1808
+ if (!whole) items.push(null, { label: 'Autosuma', keys: 'Alt+=', run: () => this.#autosum() });
1809
+ return items;
1810
+ }
1811
+
1812
+ #openMenu(clientX, clientY) {
1813
+ this.#menu?.close();
1814
+ const menu = document.createElement('div');
1815
+ menu.className = 'xlsx-editor-menu';
1816
+ menu.setAttribute('role', 'menu');
1817
+ menu.setAttribute('aria-label', 'Acciones de la selección');
1818
+ const buttons = [];
1819
+ let close = () => {};
1820
+ for (const item of this.#menuItems()) {
1821
+ if (!item) {
1822
+ const line = document.createElement('div');
1823
+ line.className = 'xlsx-editor-menu-line';
1824
+ line.setAttribute('role', 'separator');
1825
+ menu.appendChild(line);
1826
+ continue;
1827
+ }
1828
+ const button = document.createElement('button');
1829
+ button.type = 'button';
1830
+ button.className = 'xlsx-editor-menu-item';
1831
+ button.setAttribute('role', 'menuitem');
1832
+ const label = document.createElement('span');
1833
+ label.textContent = item.label;
1834
+ button.appendChild(label);
1835
+ if (item.keys) {
1836
+ const keys = document.createElement('span');
1837
+ keys.className = 'xlsx-editor-menu-keys';
1838
+ keys.textContent = item.keys;
1839
+ button.appendChild(keys);
1840
+ }
1841
+ button.addEventListener('click', () => {
1842
+ close();
1843
+ this.#scroller.focus({ preventScroll: true });
1844
+ item.run();
1845
+ });
1846
+ menu.appendChild(button);
1847
+ buttons.push(button);
1848
+ }
1849
+ menu.addEventListener('contextmenu', e => e.preventDefault());
1850
+ menu.addEventListener('keydown', e => {
1851
+ e.stopPropagation();
1852
+ const at = buttons.indexOf(document.activeElement);
1853
+ const go = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: buttons.length - 1 }[e.key];
1854
+ if (go !== undefined) {
1855
+ e.preventDefault();
1856
+ buttons[(go + buttons.length) % buttons.length].focus();
1857
+ } else if (e.key === 'Escape' || e.key === 'Tab') {
1858
+ e.preventDefault();
1859
+ close();
1860
+ this.#scroller.focus({ preventScroll: true });
1861
+ }
1862
+ });
1863
+ const outside = e => {
1864
+ if (!menu.contains(e.target)) close();
1865
+ };
1866
+ close = () => {
1867
+ document.removeEventListener('pointerdown', outside, true);
1868
+ window.removeEventListener('blur', close);
1869
+ window.removeEventListener('resize', close);
1870
+ this.#scroller.removeEventListener('scroll', close);
1871
+ menu.remove();
1872
+ this.#menu = null;
1873
+ };
1874
+ document.addEventListener('pointerdown', outside, true);
1875
+ window.addEventListener('blur', close);
1876
+ window.addEventListener('resize', close);
1877
+ this.#scroller.addEventListener('scroll', close);
1878
+ this.#root.appendChild(menu);
1879
+ this.#menu = { close };
1880
+ // Inside the window, also near its edges
1881
+ const { width, height } = menu.getBoundingClientRect();
1882
+ menu.style.left = `${Math.max(4, Math.min(clientX, window.innerWidth - width - 4))}px`;
1883
+ menu.style.top = `${Math.max(4, Math.min(clientY, window.innerHeight - height - 4))}px`;
1884
+ buttons[0]?.focus({ preventScroll: true });
1885
+ }
1886
+
1887
+ /** Copy and cut from the menu: no clipboard event comes with them */
1888
+ #copyToSystem(cut) {
1889
+ let text = '';
1890
+ this.#onClipboard({ type: cut ? 'cut' : 'copy', preventDefault() {}, clipboardData: { setData: (_, t) => { text = t; } } });
1891
+ if (!navigator.clipboard?.writeText) {
1892
+ this.#showMessage('Este navegador no deja copiar desde el menú: usa el atajo de teclado.');
1893
+ return;
1894
+ }
1895
+ navigator.clipboard.writeText(text).catch(() => this.#showMessage('El navegador no dejó copiar desde el menú: usa el atajo de teclado.'));
1896
+ }
1897
+
1898
+ #pasteFromSystem() {
1899
+ if (!navigator.clipboard?.readText) {
1900
+ this.#showMessage('Este navegador no deja pegar desde el menú: usa el atajo de teclado.');
1901
+ return;
1902
+ }
1903
+ navigator.clipboard.readText().then(text => this.#paste(text), () => this.#showMessage('El navegador no dejó pegar desde el menú: usa el atajo de teclado.'));
1904
+ }
1905
+
1906
+ /** The engine refused a change: says why in the status bar and in an `error` event */
1907
+ #fail(error) {
1908
+ this.#showMessage(error.message);
1909
+ this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
1910
+ }
1911
+
1912
+ #showMessage(text) {
1913
+ clearTimeout(this.#messageTimer);
1914
+ this.#messageEl.textContent = text;
1915
+ this.#messageEl.title = text;
1916
+ if (text) this.#messageTimer = setTimeout(() => this.#showMessage(''), MESSAGE_SECONDS * 1000);
1917
+ }
1918
+
1919
+ /** The count, average and sum of the selection, as many cells as it has */
1920
+ #updateStatus() {
1921
+ this.#statusDirty = false;
1922
+ if (this.#statusEl.hidden) return;
1923
+ const sel = this.#selection;
1924
+ const several = sel && this.#sheet && (sel.first.row !== sel.last.row || sel.first.col !== sel.last.col);
1925
+ const stats = several ? rangeStats(this.#sheet.cells, sel, this.#sheet) : null;
1926
+ const shown = stats ? `${stats.count}|${stats.sum}|${stats.average}|${stats.style}` : '';
1927
+ if (shown === this.#statusShown) return;
1928
+ this.#statusShown = shown;
1929
+ const parts = [];
1930
+ if (stats) {
1931
+ let count = String(stats.count);
1932
+ try {
1933
+ count = new Intl.NumberFormat(this.locale).format(stats.count);
1934
+ } catch {
1935
+ // A language the browser does not know: plain digits
1936
+ }
1937
+ if (stats.sum === null) {
1938
+ parts.push(`Recuento: ${count}`);
1939
+ } else {
1940
+ const [average, sum] = this.#workbook.formatNumbers(stats.style, [stats.average, stats.sum], this.locale);
1941
+ parts.push(`Promedio: ${average}`, `Recuento: ${count}`, `Suma: ${sum}`);
1942
+ }
1943
+ }
1944
+ this.#statsEl.replaceChildren(...parts.map(text => {
1945
+ const part = document.createElement('span');
1946
+ part.textContent = text;
1947
+ return part;
1948
+ }));
1949
+ }
1950
+
626
1951
  #onKeyDown(e) {
1952
+ if (this.#drag || this.#gesture) {
1953
+ if (e.key === 'Escape') (this.#drag ?? this.#gesture).cancel();
1954
+ return;
1955
+ }
627
1956
  if (!this.#sheet || !this.#selection || e.isComposing) return;
1957
+ if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
1958
+ e.preventDefault();
1959
+ this.#openMenuAtCell();
1960
+ return;
1961
+ }
1962
+ if (this.#navigate(e)) {
1963
+ e.preventDefault();
1964
+ return;
1965
+ }
1966
+ if (e.altKey && e.code === 'Equal' && !e.ctrlKey && !e.metaKey) {
1967
+ // By the physical key: on a Mac, Option+= types another character
1968
+ e.preventDefault();
1969
+ this.#autosum();
1970
+ return;
1971
+ }
1972
+ if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && !this.#readOnly) {
1973
+ const nudge = { ArrowUp: ['row', -1], ArrowDown: ['row', 1], ArrowLeft: ['col', -1], ArrowRight: ['col', 1] }[e.key];
1974
+ if (nudge) {
1975
+ e.preventDefault();
1976
+ this.#nudgeLines(...nudge);
1977
+ return;
1978
+ }
1979
+ }
1980
+ if (e.key === ' ' && (e.ctrlKey || e.shiftKey) && !e.metaKey && !e.altKey) {
1981
+ // Ctrl+Space selects the columns of the selection and Shift+Space its rows, as in Excel
1982
+ e.preventDefault();
1983
+ const axis = e.ctrlKey && e.shiftKey ? 'all' : e.ctrlKey ? 'col' : 'row';
1984
+ const [a, b] = axis === 'all' ? [1, 1] : this.#selectedLines(axis);
1985
+ const { active } = this.#selection;
1986
+ const anchor = axis === 'row' ? active.row : active.col;
1987
+ this.#selectLines(axis, a === anchor ? a : b, a === anchor ? b : a, anchor);
1988
+ return;
1989
+ }
628
1990
  if ((e.ctrlKey || e.metaKey) && !e.altKey) {
629
1991
  const k = e.key.toLowerCase();
630
- if (k === 'z' || k === 'y') {
1992
+ const { whole } = this.#selection;
1993
+ if (k === 'a') {
1994
+ e.preventDefault();
1995
+ this.#selectLines('all', 1, 1, 1);
1996
+ } else if (k === 'f') {
1997
+ e.preventDefault();
1998
+ this.openFind();
1999
+ } else if (k === 'h' && e.ctrlKey && !this.#readOnly) {
2000
+ e.preventDefault();
2001
+ this.openFind({ replace: true });
2002
+ } else if (k === 'd' && !this.#readOnly) {
2003
+ e.preventDefault();
2004
+ this.#fillSelection('row');
2005
+ } else if (k === 'r' && e.ctrlKey && !e.shiftKey && !this.#readOnly) {
2006
+ // Cmd+R stays the browser's reload
2007
+ e.preventDefault();
2008
+ this.#fillSelection('col');
2009
+ } else if (e.ctrlKey && (e.code === 'Digit9' || e.code === 'Digit0') && !this.#readOnly) {
2010
+ // Ctrl+9 hides rows and Ctrl+0 columns; with Shift they show them again
2011
+ e.preventDefault();
2012
+ const axis = e.code === 'Digit9' ? 'row' : 'col';
2013
+ const [a, b] = this.#selectedLines(axis);
2014
+ if (this.#selection.whole === (axis === 'row' ? 'col' : 'row') && !e.shiftKey) return;
2015
+ this.#setLinesHidden(axis, a, b, !e.shiftKey);
2016
+ } else if ((k === '-' || k === '+' || (k === '=' && e.shiftKey)) && (whole === 'row' || whole === 'col') && !this.#readOnly) {
2017
+ // Ctrl+- and Ctrl++ on whole lines, as in Excel (on anything else they are the browser's zoom)
2018
+ e.preventDefault();
2019
+ const [a, b] = this.#selectedLines(whole);
2020
+ if (k === '-') this.#deleteLines(whole, a, b - a + 1);
2021
+ else this.#insertLines(whole, a, b - a + 1);
2022
+ } else if (k === 'z' || k === 'y') {
631
2023
  e.preventDefault();
632
2024
  if (k === 'y' || e.shiftKey) this.redo();
633
2025
  else this.undo();
@@ -677,10 +2069,73 @@ export class XlsxEditor extends EventTarget {
677
2069
  this.#move(dr, dc, e.shiftKey && e.key.startsWith('Arrow'));
678
2070
  }
679
2071
 
2072
+ /**
2073
+ * Keys that go far: Ctrl/Cmd+arrow to the edge of the data, Home to the start of the row,
2074
+ * Ctrl/Cmd+Home and End to the corners of the data, PageUp and PageDown a screen (with Alt,
2075
+ * sideways); with Shift they extend the selection. Returns whether the key was one of them.
2076
+ */
2077
+ #navigate(e) {
2078
+ const mod = e.ctrlKey || e.metaKey;
2079
+ const extend = e.shiftKey;
2080
+ const { rows, cols, frozen, cells, used } = this.#sheet;
2081
+ const from = extend ? this.#selection.cursor : this.#selection.active;
2082
+ let target = null;
2083
+ const arrow = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] }[e.key];
2084
+ if (arrow && mod && !e.altKey) {
2085
+ const filled = (row, col) => !!cells.get(key(row, col))?.kind && rows.size(row) !== 0 && cols.size(col) !== 0;
2086
+ target = arrow[0]
2087
+ ? { row: dataEdge(r => filled(r, from.col), from.row, arrow[0], MAX_ROWS), col: from.col }
2088
+ : { row: from.row, col: dataEdge(c => filled(from.row, c), from.col, arrow[1], MAX_COLS) };
2089
+ } else if (e.key === 'Home' && !e.altKey) {
2090
+ target = mod ? { row: frozen.rows + 1, col: frozen.cols + 1 } : { row: from.row, col: frozen.cols + 1 };
2091
+ } else if (e.key === 'End' && mod && !e.altKey) {
2092
+ target = { row: used.maxRow, col: used.maxCol };
2093
+ } else if ((e.key === 'PageDown' || e.key === 'PageUp') && !mod) {
2094
+ const step = e.key === 'PageDown' ? 1 : -1;
2095
+ const { gx, gy, frozenW, frozenH } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
2096
+ if (e.altKey) {
2097
+ const page = (this.#scroller.clientWidth - gx - frozenW) / this.zoom;
2098
+ // Going back rounds the other way, to come back to the line a page forward started from
2099
+ const col = cols.indexAt(cols.offset(from.col) + step * page + (step < 0 ? cols.defaultSize - 1 : 0));
2100
+ this.#extend(1, col);
2101
+ this.#scroller.scrollLeft += (this.#sheet.cols.offset(col) - cols.offset(from.col)) * this.zoom;
2102
+ target = { row: from.row, col };
2103
+ } else {
2104
+ const page = (this.#scroller.clientHeight - gy - frozenH) / this.zoom;
2105
+ // Past the shown grid when there is room to grow
2106
+ this.#extend(rows.indexAt(rows.offset(from.row) + page) + 1, 1);
2107
+ const axis = this.#sheet.rows;
2108
+ const row = axis.indexAt(axis.offset(from.row) + step * page + (step < 0 ? axis.defaultSize - 1 : 0));
2109
+ this.#scroller.scrollTop += (axis.offset(row) - axis.offset(from.row)) * this.zoom;
2110
+ target = { row, col: from.col };
2111
+ }
2112
+ }
2113
+ if (!target) return false;
2114
+ this.#extend(target.row, target.col);
2115
+ const anchor = extend ? this.#selection.active : target;
2116
+ this.#setSelection(anchor, target, anchor);
2117
+ return true;
2118
+ }
2119
+
680
2120
  /** Moves the active cell (or, with `extend`, the moving corner of the range) one step */
681
2121
  #move(dr, dc, extend = false) {
682
- const { rows, cols } = this.#sheet;
2122
+ const { whole } = this.#selection;
2123
+ if (extend && (whole === 'row' || whole === 'col')) {
2124
+ // Mayús+arrow along the lines selected takes one more or one less
2125
+ const step = whole === 'row' ? dr : dc;
2126
+ if (!step) return;
2127
+ const { active, cursor } = this.#selection;
2128
+ const anchor = whole === 'row' ? active.row : active.col;
2129
+ const limit = whole === 'row' ? this.#sheet.rows.count : this.#sheet.cols.count;
2130
+ const line = Math.min(Math.max((whole === 'row' ? cursor.row : cursor.col) + step, 1), limit);
2131
+ this.#selectLines(whole, anchor, line, anchor);
2132
+ this.#extend(whole === 'row' ? line : 1, whole === 'col' ? line : 1);
2133
+ this.#scrollIntoView(whole === 'row' ? { row: line } : { col: line });
2134
+ return;
2135
+ }
683
2136
  const from = extend ? this.#selection.cursor : this.#selection.active;
2137
+ this.#extend(from.row + Math.max(dr, 0), from.col + Math.max(dc, 0));
2138
+ const { rows, cols } = this.#sheet;
684
2139
  // Step over merged ranges and hidden rows or columns
685
2140
  const range = this.#expandToMerges(from, from);
686
2141
  let row = dr > 0 ? range.last.row + 1 : dr < 0 ? range.first.row - 1 : from.row;
@@ -693,16 +2148,18 @@ export class XlsxEditor extends EventTarget {
693
2148
  else this.#setSelection({ row, col }, { row, col }, { row, col });
694
2149
  }
695
2150
 
696
- #setSelection(a, b, active, { scroll = true } = {}) {
2151
+ #setSelection(a, b, active, { scroll = true, whole = null } = {}) {
697
2152
  const first = { row: Math.min(a.row, b.row), col: Math.min(a.col, b.col) };
698
2153
  const last = { row: Math.max(a.row, b.row), col: Math.max(a.col, b.col) };
699
2154
  const prev = this.#selection;
700
2155
  // `cursor` is the corner that moves when the range is extended with Shift
701
- this.#selection = { first, last, active: { ...active }, cursor: { ...b } };
2156
+ this.#selection = { first, last, active: { ...active }, cursor: { ...b }, whole };
702
2157
  if (scroll) this.#scrollIntoView(b);
2158
+ this.#statusDirty = true;
703
2159
  this.#updateBar();
704
2160
  this.render();
705
2161
  if (!prev || prev.first.row !== first.row || prev.first.col !== first.col || prev.last.row !== last.row || prev.last.col !== last.col) {
2162
+ if (this.#messageEl.textContent) this.#showMessage('');
706
2163
  this.dispatchEvent(new CustomEvent('selectionchange', { detail: this.selection }));
707
2164
  }
708
2165
  }
@@ -729,7 +2186,7 @@ export class XlsxEditor extends EventTarget {
729
2186
 
730
2187
  #updateBar() {
731
2188
  const sel = this.selection;
732
- this.#nameBox.textContent = sel ? sel.range : '';
2189
+ this.#nameBox.textContent = sel ? this.#selectionName(sel) : '';
733
2190
  if (this.#edit) return; // the bar shows what is being typed
734
2191
  let content = '';
735
2192
  if (sel && this.#workbook && this.#sheetIndex >= 0) {
@@ -741,6 +2198,14 @@ export class XlsxEditor extends EventTarget {
741
2198
  this.#valueBox.title = content;
742
2199
  }
743
2200
 
2201
+ /** What the name box shows: `B:D` and `2:5` for whole columns and rows */
2202
+ #selectionName(sel) {
2203
+ const { first, last } = this.#selection;
2204
+ if (sel.whole === 'col') return `${columnName(first.col)}:${columnName(last.col)}`;
2205
+ if (sel.whole === 'row') return `${first.row}:${last.row}`;
2206
+ return sel.range;
2207
+ }
2208
+
744
2209
  // ---------- Editing ----------
745
2210
 
746
2211
  #activeSheet() {
@@ -791,13 +2256,11 @@ export class XlsxEditor extends EventTarget {
791
2256
  /** Puts the editor over its cell, wide and tall enough for the text */
792
2257
  #placeInput() {
793
2258
  if (!this.#edit || !this.#sheet) return;
794
- const { rows, cols, frozen } = this.#sheet;
795
2259
  const zoom = this.zoom;
796
2260
  const width = this.#scroller.clientWidth;
797
2261
  const height = this.#scroller.clientHeight;
798
- const { gx, gy, frozenW, frozenH } = this.#panes(width, height);
799
- const xOf = c => (c <= frozen.cols ? gx + (cols.offset(c) - cols.offset(1)) * zoom : gx + frozenW + (cols.offset(c) - cols.offset(frozen.cols + 1)) * zoom - this.#scroller.scrollLeft);
800
- const yOf = r => (r <= frozen.rows ? gy + (rows.offset(r) - rows.offset(1)) * zoom : gy + frozenH + (rows.offset(r) - rows.offset(frozen.rows + 1)) * zoom - this.#scroller.scrollTop);
2262
+ const xOf = c => this.#linePos('col', c);
2263
+ const yOf = r => this.#linePos('row', r);
801
2264
  const at = { row: this.#edit.row, col: this.#edit.col };
802
2265
  const range = this.#expandToMerges(at, at);
803
2266
  const x = Math.round(xOf(range.first.col)) - 1;
@@ -845,9 +2308,9 @@ export class XlsxEditor extends EventTarget {
845
2308
  this.#closeEditor();
846
2309
  if (text !== edit.original || fill) {
847
2310
  const { value } = parseInput(text, this.locale);
848
- const sel = this.#selection;
849
- const first = fill ? sel.first : { row: edit.row, col: edit.col };
850
- const last = fill ? sel.last : first;
2311
+ const bounds = this.#contentBounds();
2312
+ const first = fill ? bounds.first : { row: edit.row, col: edit.col };
2313
+ const last = fill ? bounds.last : first;
851
2314
  const rows = Array.from({ length: last.row - first.row + 1 }, () => new Array(last.col - first.col + 1).fill(value));
852
2315
  this.#write(first, rows, 'edit');
853
2316
  }
@@ -898,7 +2361,7 @@ export class XlsxEditor extends EventTarget {
898
2361
  /** What the undo history remembers about a change: the sheet, the selection, the cells */
899
2362
  #historyToken(changed) {
900
2363
  const sel = this.selection;
901
- return JSON.stringify({ sheet: this.#sheetIndex, range: sel?.range, active: sel?.ref, changed });
2364
+ return JSON.stringify({ sheet: this.#sheetIndex, range: sel?.range, active: sel?.ref, whole: sel?.whole ?? null, changed });
902
2365
  }
903
2366
 
904
2367
  /** Once the selection has moved after a change, redo puts it back there */
@@ -919,7 +2382,7 @@ export class XlsxEditor extends EventTarget {
919
2382
  this.#workbook.checkpoint(this.#historyToken(changed));
920
2383
  this.#activeSheet().setValues(refOf(first), rows);
921
2384
  } catch (error) {
922
- this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
2385
+ this.#fail(error);
923
2386
  return null;
924
2387
  }
925
2388
  this.#lastChange = changed;
@@ -929,19 +2392,20 @@ export class XlsxEditor extends EventTarget {
929
2392
  return { first, last };
930
2393
  }
931
2394
 
932
- #emitChange(range, source) {
933
- const sheet = this.#activeSheet();
934
- const { first } = parseRange(range);
2395
+ #emitChange(range, source, sheet = this.#activeSheet()) {
2396
+ const { first, last } = parseRange(range);
935
2397
  const cells = [];
936
- sheet.getValues(range).forEach((row, r) => row.forEach((value, c) => {
937
- cells.push({ ref: refOf({ row: first.row + r, col: first.col + c }), value });
938
- }));
939
- this.dispatchEvent(new CustomEvent('change', { detail: { sheet: sheet.name, index: this.#sheetIndex, range, cells, source } }));
2398
+ if ((last.row - first.row + 1) * (last.col - first.col + 1) <= MAX_CHANGE_CELLS) {
2399
+ sheet.getValues(range).forEach((row, r) => row.forEach((value, c) => {
2400
+ cells.push({ ref: refOf({ row: first.row + r, col: first.col + c }), value });
2401
+ }));
2402
+ }
2403
+ this.dispatchEvent(new CustomEvent('change', { detail: { sheet: sheet.name, index: sheet.index, range, cells, source } }));
940
2404
  }
941
2405
 
942
2406
  /** Delete and Backspace: empties the selected cells, keeping their format */
943
2407
  #clearSelection(source = 'clear') {
944
- const { first, last } = this.#expandToMerges(this.#selection.first, this.#selection.last);
2408
+ const { first, last } = this.#actionBounds();
945
2409
  const rows = Array.from({ length: last.row - first.row + 1 }, () => new Array(last.col - first.col + 1).fill(null));
946
2410
  this.#clip = null;
947
2411
  this.#write(first, rows, source);
@@ -960,18 +2424,27 @@ export class XlsxEditor extends EventTarget {
960
2424
  token = null; // a checkpoint made from code with a selection of its own
961
2425
  }
962
2426
  this.#clip = null;
2427
+ this.#staleFind();
963
2428
  this.#styles = this.#workbook.styleView();
964
2429
  const tabs = this.#workbook.tabs;
2430
+ this.#renderTabs();
965
2431
  if (token && token.sheet !== this.#sheetIndex && tabs[token.sheet]?.kind === 'worksheet') {
966
2432
  this.#loadSheet(token.sheet);
967
2433
  this.dispatchEvent(new CustomEvent('sheetchange', { detail: { index: token.sheet, name: tabs[token.sheet].name } }));
968
2434
  } else {
969
- this.#loadSheet(this.#sheetIndex, { keepScroll: true });
2435
+ const index = this.#validSheet(this.#sheetIndex);
2436
+ this.#loadSheet(index, { keepScroll: index === this.#sheetIndex });
2437
+ if (index !== token?.sheet && tabs[index]) this.dispatchEvent(new CustomEvent('sheetchange', { detail: { index, name: tabs[index].name } }));
970
2438
  }
971
2439
  if (token?.range) {
972
2440
  const range = parseRange(token.range);
973
2441
  const active = token.active ? parseRange(token.active).first : range.first;
974
- this.#setSelection(range.first, range.last, active);
2442
+ const whole = token.whole ?? null;
2443
+ this.#setSelection(range.first, range.last, active, { whole, scroll: !whole });
2444
+ this.#fitWholeSelection();
2445
+ // Whole lines end far away: only their first one needs to be in view
2446
+ if (whole === 'row') this.#scrollIntoView({ row: active.row });
2447
+ if (whole === 'col') this.#scrollIntoView({ col: active.col });
975
2448
  }
976
2449
  if (token?.changed) this.#emitChange(token.changed, direction);
977
2450
  return true;
@@ -989,7 +2462,7 @@ export class XlsxEditor extends EventTarget {
989
2462
  }
990
2463
  if (e.type === 'cut' && this.#readOnly) return;
991
2464
  e.preventDefault();
992
- const { first, last } = this.#expandToMerges(this.#selection.first, this.#selection.last);
2465
+ const { first, last } = this.#actionBounds();
993
2466
  const texts = [];
994
2467
  for (let r = first.row; r <= last.row; r++) {
995
2468
  const row = [];
@@ -1016,7 +2489,9 @@ export class XlsxEditor extends EventTarget {
1016
2489
  if (!rows.length || (rows.length === 1 && rows[0].length === 1 && !text)) return;
1017
2490
  const width = Math.max(...rows.map(r => r.length));
1018
2491
  rows = rows.map(r => Array.from({ length: width }, (_, i) => (i < r.length ? r[i] : null)));
1019
- const { first, last } = this.#selection;
2492
+ const { first, whole } = this.#selection;
2493
+ // Whole rows or columns take the block from their first cell, without repeating it
2494
+ const last = whole ? first : this.#selection.last;
1020
2495
  const selH = last.row - first.row + 1;
1021
2496
  const selW = last.col - first.col + 1;
1022
2497
  if ((selH > rows.length || selW > width) && selH % rows.length === 0 && selW % width === 0) {