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