@dimarborda/xlsx-editor 0.2.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 { MAX_COLS, MAX_ROWS, 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,6 +148,9 @@ 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
 
@@ -118,8 +159,11 @@ export class XlsxEditor extends EventTarget {
118
159
  this.render();
119
160
  });
120
161
  this.#scroller.addEventListener('pointerdown', e => this.#onPointerDown(e));
162
+ this.#scroller.addEventListener('pointermove', e => this.#updateCursor(e));
121
163
  this.#scroller.addEventListener('keydown', e => this.#onKeyDown(e));
122
- 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());
123
167
  this.#input.addEventListener('keydown', e => this.#onEditKey(e));
124
168
  this.#input.addEventListener('input', () => {
125
169
  this.#valueBox.value = this.#input.value;
@@ -214,6 +258,7 @@ export class XlsxEditor extends EventTarget {
214
258
  this.#clip = null;
215
259
  this.#workbook?.close();
216
260
  this.#workbook = workbook;
261
+ this.#staleFind();
217
262
  this.#styles = workbook.styleView();
218
263
  this.#sheetIndex = -1; // a new workbook starts at its first sheet, scrolled to the top
219
264
  const tabs = workbook.tabs;
@@ -241,6 +286,7 @@ export class XlsxEditor extends EventTarget {
241
286
  /** Reads the active sheet again from the workbook, after changing cells through `workbook` */
242
287
  reload() {
243
288
  if (!this.#workbook || this.#sheetIndex < 0) return;
289
+ this.#staleFind();
244
290
  this.#styles = this.#workbook.styleView();
245
291
  // Sheets added or renamed through `workbook` show up in the tabs
246
292
  this.#renderTabs();
@@ -299,14 +345,144 @@ export class XlsxEditor extends EventTarget {
299
345
  return this.#workbook.area(target, { sheet: this.#sheetIndex, maxRows, locale: this.locale });
300
346
  }
301
347
 
302
- /** The selected range: `{ref, range, active, text}` or null */
348
+ /** The selected range: `{ref, range, active, text, whole}` or null */
303
349
  get selection() {
304
350
  if (!this.#selection) return null;
305
- const { first, last, active } = this.#selection;
351
+ const { first, last, active, whole = null } = this.#selection;
306
352
  const ref = `${columnName(first.col)}${first.row}`;
307
353
  const range = first.row === last.row && first.col === last.col ? ref : `${ref}:${columnName(last.col)}${last.row}`;
308
354
  const cell = this.#sheet?.cells.get(key(active.row, active.col));
309
- 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 });
310
486
  }
311
487
 
312
488
  /** Undoes the last change made from the grid (or since the last `workbook.checkpoint()`) */
@@ -346,6 +522,11 @@ export class XlsxEditor extends EventTarget {
346
522
 
347
523
  destroy() {
348
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);
349
530
  this.#observer.disconnect();
350
531
  for (const [type, listener] of this.#clipboardListeners) document.removeEventListener(type, listener);
351
532
  if (this.#fontsListener) document.fonts.removeEventListener('loadingdone', this.#fontsListener);
@@ -376,8 +557,8 @@ export class XlsxEditor extends EventTarget {
376
557
  const cells = new Map();
377
558
  let maxRow = 1;
378
559
  let maxCol = 1;
379
- for (const [row, col, style, text, kind, color] of sheet.cellViews(this.locale)) {
380
- 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 });
381
562
  if (text) {
382
563
  maxRow = Math.max(maxRow, row);
383
564
  maxCol = Math.max(maxCol, col);
@@ -405,6 +586,7 @@ export class XlsxEditor extends EventTarget {
405
586
  const frozen = layout.frozen ?? { rows: 0, cols: 0 };
406
587
  this.#sheet = {
407
588
  cells, merges, mergeAt, covered, rows, cols,
589
+ used: { maxRow, maxCol },
408
590
  gridlines: layout.gridlines,
409
591
  headings: layout.headings,
410
592
  frozen: { rows: Math.min(frozen.rows, rows.count - 1), cols: Math.min(frozen.cols, cols.count - 1) },
@@ -419,6 +601,9 @@ export class XlsxEditor extends EventTarget {
419
601
  this.#selection = { first: { row: 1, col: 1 }, last: { row: 1, col: 1 }, active: { row: 1, col: 1 } };
420
602
  if (start.row > 1 || start.col > 1) this.#selection = { first: start, last: start, active: start };
421
603
  }
604
+ this.#fitWholeSelection();
605
+ this.#statusDirty = true;
606
+ this.#statusShown = null;
422
607
  this.#updateTabs();
423
608
  this.#updateBar();
424
609
  this.render();
@@ -446,9 +631,24 @@ export class XlsxEditor extends EventTarget {
446
631
  if (rows === sheet.rows && cols === sheet.cols) return;
447
632
  sheet.rows = rows;
448
633
  sheet.cols = cols;
634
+ this.#fitWholeSelection();
449
635
  this.#updateSpacer();
450
636
  }
451
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
+
452
652
  /** Grows the grid when the view comes near its bottom or right end */
453
653
  #growOnScroll() {
454
654
  const s = this.#scroller;
@@ -477,6 +677,7 @@ export class XlsxEditor extends EventTarget {
477
677
  this.#frame = requestAnimationFrame(() => {
478
678
  this.#frame = 0;
479
679
  this.#draw();
680
+ if (this.#statusDirty) this.#updateStatus();
480
681
  });
481
682
  }
482
683
 
@@ -544,6 +745,47 @@ export class XlsxEditor extends EventTarget {
544
745
  if (this.#sheet.headings) {
545
746
  drawHeaders(ctx, { rows, cols, colSegs, rowSegs, rowHeaderW: gx, colHeaderH: gy, width, height, zoom, selection: this.#selection, columnName });
546
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();
547
789
  }
548
790
 
549
791
  #drawSelection(ctx, rs, cs, clip) {
@@ -553,7 +795,8 @@ export class XlsxEditor extends EventTarget {
553
795
  const zoom = this.zoom;
554
796
  const xOf = c => cs.start + (cols.offset(c) - cols.offset(cs.from)) * zoom;
555
797
  const yOf = r => rs.start + (rows.offset(r) - rows.offset(rs.from)) * zoom;
556
- 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);
557
800
  const x = xOf(range.first.col);
558
801
  const y = yOf(range.first.row);
559
802
  const w = xOf(range.last.col + 1) - x;
@@ -564,7 +807,7 @@ export class XlsxEditor extends EventTarget {
564
807
  ctx.clip();
565
808
  if (range.first.row !== range.last.row || range.first.col !== range.last.col) {
566
809
  // Tint the range, leaving the active cell clear as Excel does
567
- 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);
568
811
  const ax = xOf(a.first.col);
569
812
  const ay = yOf(a.first.row);
570
813
  ctx.beginPath();
@@ -578,7 +821,7 @@ export class XlsxEditor extends EventTarget {
578
821
  ctx.strokeRect(Math.round(x) - 1, Math.round(y) - 1, Math.round(w), Math.round(h));
579
822
  ctx.fillStyle = SELECTION;
580
823
  const handle = Math.max(5, Math.round(5 * zoom));
581
- 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);
582
825
  ctx.restore();
583
826
  }
584
827
 
@@ -626,30 +869,663 @@ export class XlsxEditor extends EventTarget {
626
869
 
627
870
  /** The cell under a point of the canvas, or null over the headers */
628
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) {
629
879
  const { rows, cols, frozen } = this.#sheet;
630
880
  const zoom = this.zoom;
631
881
  const { gx, gy, frozenW, frozenH } = this.#panes(this.#scroller.clientWidth, this.#scroller.clientHeight);
632
- if (x < gx || y < gy) return null;
633
- const col = x < gx + frozenW
634
- ? cols.indexAt((x - gx) / zoom + cols.offset(1))
635
- : cols.indexAt(cols.offset(frozen.cols + 1) + this.#scroller.scrollLeft / zoom + (x - gx - frozenW) / zoom);
636
- const row = y < gy + frozenH
637
- ? rows.indexAt((y - gy) / zoom + rows.offset(1))
638
- : rows.indexAt(rows.offset(frozen.rows + 1) + this.#scroller.scrollTop / zoom + (y - gy - frozenH) / zoom);
639
- 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;
640
1505
  }
641
1506
 
642
1507
  #onPointerDown(e) {
643
- 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;
644
1510
  const box = this.#scroller.getBoundingClientRect();
645
1511
  const x = e.clientX - box.left;
646
1512
  const y = e.clientY - box.top;
647
1513
  // Clicks on the scrollbars scroll
648
1514
  if (x > this.#scroller.clientWidth || y > this.#scroller.clientHeight) return;
649
- const at = this.#cellAt(x, y);
650
- if (!at) return;
651
1515
  this.#commitEdit(null, { focus: false });
652
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);
653
1529
  const anchor = e.shiftKey && this.#selection ? this.#selection.active : at;
654
1530
  this.#setSelection(anchor, at, anchor, { scroll: false });
655
1531
  const move = ev => {
@@ -664,11 +1540,486 @@ export class XlsxEditor extends EventTarget {
664
1540
  window.addEventListener('pointerup', up);
665
1541
  }
666
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
+
667
1951
  #onKeyDown(e) {
1952
+ if (this.#drag || this.#gesture) {
1953
+ if (e.key === 'Escape') (this.#drag ?? this.#gesture).cancel();
1954
+ return;
1955
+ }
668
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
+ }
669
1990
  if ((e.ctrlKey || e.metaKey) && !e.altKey) {
670
1991
  const k = e.key.toLowerCase();
671
- 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') {
672
2023
  e.preventDefault();
673
2024
  if (k === 'y' || e.shiftKey) this.redo();
674
2025
  else this.undo();
@@ -718,8 +2069,70 @@ export class XlsxEditor extends EventTarget {
718
2069
  this.#move(dr, dc, e.shiftKey && e.key.startsWith('Arrow'));
719
2070
  }
720
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
+
721
2120
  /** Moves the active cell (or, with `extend`, the moving corner of the range) one step */
722
2121
  #move(dr, dc, extend = false) {
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
+ }
723
2136
  const from = extend ? this.#selection.cursor : this.#selection.active;
724
2137
  this.#extend(from.row + Math.max(dr, 0), from.col + Math.max(dc, 0));
725
2138
  const { rows, cols } = this.#sheet;
@@ -735,16 +2148,18 @@ export class XlsxEditor extends EventTarget {
735
2148
  else this.#setSelection({ row, col }, { row, col }, { row, col });
736
2149
  }
737
2150
 
738
- #setSelection(a, b, active, { scroll = true } = {}) {
2151
+ #setSelection(a, b, active, { scroll = true, whole = null } = {}) {
739
2152
  const first = { row: Math.min(a.row, b.row), col: Math.min(a.col, b.col) };
740
2153
  const last = { row: Math.max(a.row, b.row), col: Math.max(a.col, b.col) };
741
2154
  const prev = this.#selection;
742
2155
  // `cursor` is the corner that moves when the range is extended with Shift
743
- this.#selection = { first, last, active: { ...active }, cursor: { ...b } };
2156
+ this.#selection = { first, last, active: { ...active }, cursor: { ...b }, whole };
744
2157
  if (scroll) this.#scrollIntoView(b);
2158
+ this.#statusDirty = true;
745
2159
  this.#updateBar();
746
2160
  this.render();
747
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('');
748
2163
  this.dispatchEvent(new CustomEvent('selectionchange', { detail: this.selection }));
749
2164
  }
750
2165
  }
@@ -771,7 +2186,7 @@ export class XlsxEditor extends EventTarget {
771
2186
 
772
2187
  #updateBar() {
773
2188
  const sel = this.selection;
774
- this.#nameBox.textContent = sel ? sel.range : '';
2189
+ this.#nameBox.textContent = sel ? this.#selectionName(sel) : '';
775
2190
  if (this.#edit) return; // the bar shows what is being typed
776
2191
  let content = '';
777
2192
  if (sel && this.#workbook && this.#sheetIndex >= 0) {
@@ -783,6 +2198,14 @@ export class XlsxEditor extends EventTarget {
783
2198
  this.#valueBox.title = content;
784
2199
  }
785
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
+
786
2209
  // ---------- Editing ----------
787
2210
 
788
2211
  #activeSheet() {
@@ -833,13 +2256,11 @@ export class XlsxEditor extends EventTarget {
833
2256
  /** Puts the editor over its cell, wide and tall enough for the text */
834
2257
  #placeInput() {
835
2258
  if (!this.#edit || !this.#sheet) return;
836
- const { rows, cols, frozen } = this.#sheet;
837
2259
  const zoom = this.zoom;
838
2260
  const width = this.#scroller.clientWidth;
839
2261
  const height = this.#scroller.clientHeight;
840
- const { gx, gy, frozenW, frozenH } = this.#panes(width, height);
841
- 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);
842
- 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);
843
2264
  const at = { row: this.#edit.row, col: this.#edit.col };
844
2265
  const range = this.#expandToMerges(at, at);
845
2266
  const x = Math.round(xOf(range.first.col)) - 1;
@@ -887,9 +2308,9 @@ export class XlsxEditor extends EventTarget {
887
2308
  this.#closeEditor();
888
2309
  if (text !== edit.original || fill) {
889
2310
  const { value } = parseInput(text, this.locale);
890
- const sel = this.#selection;
891
- const first = fill ? sel.first : { row: edit.row, col: edit.col };
892
- 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;
893
2314
  const rows = Array.from({ length: last.row - first.row + 1 }, () => new Array(last.col - first.col + 1).fill(value));
894
2315
  this.#write(first, rows, 'edit');
895
2316
  }
@@ -940,7 +2361,7 @@ export class XlsxEditor extends EventTarget {
940
2361
  /** What the undo history remembers about a change: the sheet, the selection, the cells */
941
2362
  #historyToken(changed) {
942
2363
  const sel = this.selection;
943
- 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 });
944
2365
  }
945
2366
 
946
2367
  /** Once the selection has moved after a change, redo puts it back there */
@@ -961,7 +2382,7 @@ export class XlsxEditor extends EventTarget {
961
2382
  this.#workbook.checkpoint(this.#historyToken(changed));
962
2383
  this.#activeSheet().setValues(refOf(first), rows);
963
2384
  } catch (error) {
964
- this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
2385
+ this.#fail(error);
965
2386
  return null;
966
2387
  }
967
2388
  this.#lastChange = changed;
@@ -971,19 +2392,20 @@ export class XlsxEditor extends EventTarget {
971
2392
  return { first, last };
972
2393
  }
973
2394
 
974
- #emitChange(range, source) {
975
- const sheet = this.#activeSheet();
976
- const { first } = parseRange(range);
2395
+ #emitChange(range, source, sheet = this.#activeSheet()) {
2396
+ const { first, last } = parseRange(range);
977
2397
  const cells = [];
978
- sheet.getValues(range).forEach((row, r) => row.forEach((value, c) => {
979
- cells.push({ ref: refOf({ row: first.row + r, col: first.col + c }), value });
980
- }));
981
- 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 } }));
982
2404
  }
983
2405
 
984
2406
  /** Delete and Backspace: empties the selected cells, keeping their format */
985
2407
  #clearSelection(source = 'clear') {
986
- const { first, last } = this.#expandToMerges(this.#selection.first, this.#selection.last);
2408
+ const { first, last } = this.#actionBounds();
987
2409
  const rows = Array.from({ length: last.row - first.row + 1 }, () => new Array(last.col - first.col + 1).fill(null));
988
2410
  this.#clip = null;
989
2411
  this.#write(first, rows, source);
@@ -1002,6 +2424,7 @@ export class XlsxEditor extends EventTarget {
1002
2424
  token = null; // a checkpoint made from code with a selection of its own
1003
2425
  }
1004
2426
  this.#clip = null;
2427
+ this.#staleFind();
1005
2428
  this.#styles = this.#workbook.styleView();
1006
2429
  const tabs = this.#workbook.tabs;
1007
2430
  this.#renderTabs();
@@ -1016,7 +2439,12 @@ export class XlsxEditor extends EventTarget {
1016
2439
  if (token?.range) {
1017
2440
  const range = parseRange(token.range);
1018
2441
  const active = token.active ? parseRange(token.active).first : range.first;
1019
- 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 });
1020
2448
  }
1021
2449
  if (token?.changed) this.#emitChange(token.changed, direction);
1022
2450
  return true;
@@ -1034,7 +2462,7 @@ export class XlsxEditor extends EventTarget {
1034
2462
  }
1035
2463
  if (e.type === 'cut' && this.#readOnly) return;
1036
2464
  e.preventDefault();
1037
- const { first, last } = this.#expandToMerges(this.#selection.first, this.#selection.last);
2465
+ const { first, last } = this.#actionBounds();
1038
2466
  const texts = [];
1039
2467
  for (let r = first.row; r <= last.row; r++) {
1040
2468
  const row = [];
@@ -1061,7 +2489,9 @@ export class XlsxEditor extends EventTarget {
1061
2489
  if (!rows.length || (rows.length === 1 && rows[0].length === 1 && !text)) return;
1062
2490
  const width = Math.max(...rows.map(r => r.length));
1063
2491
  rows = rows.map(r => Array.from({ length: width }, (_, i) => (i < r.length ? r[i] : null)));
1064
- 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;
1065
2495
  const selH = last.row - first.row + 1;
1066
2496
  const selW = last.col - first.col + 1;
1067
2497
  if ((selH > rows.length || selW > width) && selH % rows.length === 0 && selW % width === 0) {