@dimarborda/xlsx-editor 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -232
- package/package.json +4 -2
- package/src/draw.js +12 -3
- package/src/elements.js +1 -0
- package/src/engine.d.ts +42 -2
- package/src/engine.js +132 -2
- package/src/find.js +37 -0
- package/src/grid.js +89 -0
- package/src/index.d.ts +86 -4
- package/src/style.css +154 -1
- package/src/xlsx_editor.js +1481 -51
- package/wasm/xlsx_engine.d.ts +57 -1
- package/wasm/xlsx_engine.js +180 -1
- package/wasm/xlsx_engine_bg.wasm +0 -0
- package/wasm/xlsx_engine_bg.wasm.d.ts +11 -0
package/src/xlsx_editor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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 (
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
841
|
-
const
|
|
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
|
|
891
|
-
const first = fill ?
|
|
892
|
-
const last = fill ?
|
|
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
|
|
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
|
|
976
|
-
const { first } = parseRange(range);
|
|
2395
|
+
#emitChange(range, source, sheet = this.#activeSheet()) {
|
|
2396
|
+
const { first, last } = parseRange(range);
|
|
977
2397
|
const cells = [];
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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,
|
|
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) {
|