@dimarborda/xlsx-editor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/find.js ADDED
@@ -0,0 +1,37 @@
1
+ // Searching cell texts as Excel does: `*` any text, `?` one character, `~` takes either literally.
2
+ // No DOM here, so it is testable in Node.
3
+
4
+ /**
5
+ * The search as a global regular expression; null for an empty search
6
+ * @param {string} query
7
+ * @param {{matchCase?: boolean, wholeCell?: boolean}} [options]
8
+ */
9
+ export function searchPattern(query, { matchCase = false, wholeCell = false } = {}) {
10
+ if (!query) return null;
11
+ // With the `u` flag only characters with a meaning may be escaped
12
+ const literal = c => c.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&');
13
+ let source = '';
14
+ const chars = [...query];
15
+ for (let i = 0; i < chars.length; i++) {
16
+ const c = chars[i];
17
+ if (c === '~' && '*?~'.includes(chars[i + 1] ?? ' ')) source += literal(chars[++i]);
18
+ else if (c === '*') source += '[\\s\\S]*';
19
+ else if (c === '?') source += '[\\s\\S]';
20
+ else source += literal(c);
21
+ }
22
+ return new RegExp(wholeCell ? `^(?:${source})$` : source, matchCase ? 'gu' : 'giu');
23
+ }
24
+
25
+ /** Whether the pattern is in a text (a global expression remembers where it stopped: not here) */
26
+ export function matches(pattern, text) {
27
+ pattern.lastIndex = 0;
28
+ const found = pattern.test(text);
29
+ pattern.lastIndex = 0;
30
+ return found;
31
+ }
32
+
33
+ /** The text with what the pattern finds replaced, taken literally */
34
+ export function replaceMatches(pattern, text, replacement) {
35
+ pattern.lastIndex = 0;
36
+ return text.replace(pattern, found => (found === '' ? '' : replacement));
37
+ }
package/src/grid.js CHANGED
@@ -12,6 +12,11 @@ export function colWidthToPx(width, mdw) {
12
12
  return Math.trunc(((256 * width + Math.trunc(128 / mdw)) / 256) * mdw);
13
13
  }
14
14
 
15
+ /** Pixels to the width in characters `colWidthToPx` turns back into those pixels */
16
+ export function pxToColWidth(px, mdw) {
17
+ return Math.round((px / mdw) * 256) / 256;
18
+ }
19
+
15
20
  /**
16
21
  * Default column width in pixels: `defaultColWidth` when the sheet sets it, otherwise
17
22
  * `baseColWidth` characters (8 by default) plus 5 pixels of padding, rounded up to a multiple
@@ -147,3 +152,87 @@ export function sheetAxes(layout, used, mdw, defaultRowPt = 15) {
147
152
  const cols = Math.min(MAX_COLS, Math.max(lastCol, used.maxCol) + 12);
148
153
  return { rows: new Axis(rows, rowPx, rowSizes), cols: new Axis(Math.max(cols, 26), colPx, colSizes) };
149
154
  }
155
+
156
+ /**
157
+ * What Excel's status bar says about the cells of a range: how many have something and the sum,
158
+ * average, smallest and largest of the numbers. Hidden rows and columns do not count, and any
159
+ * error leaves only the count (as Excel does). `style` is the format of the first number. `null`
160
+ * when the range is empty.
161
+ * @param {Map<number, {kind: string, style: number, number?: number}>} cells by `row * 16385 + col`
162
+ * @param {{first: {row: number, col: number}, last: {row: number, col: number}}} range
163
+ * @param {{rows?: Axis, cols?: Axis}} [axes]
164
+ */
165
+ export function rangeStats(cells, { first, last }, { rows, cols } = {}) {
166
+ let count = 0;
167
+ let numbers = 0;
168
+ let sum = 0;
169
+ let min = Infinity;
170
+ let max = -Infinity;
171
+ let error = false;
172
+ let style = 0;
173
+ let firstAt = Infinity;
174
+ const visit = (cell, row, col) => {
175
+ if (!cell || !cell.kind || rows?.size(row) === 0 || cols?.size(col) === 0) return;
176
+ count++;
177
+ if (cell.kind === 'e') error = true;
178
+ if (cell.number === undefined) return;
179
+ numbers++;
180
+ sum += cell.number;
181
+ min = Math.min(min, cell.number);
182
+ max = Math.max(max, cell.number);
183
+ const at = row * 16_385 + col;
184
+ if (at < firstAt) {
185
+ firstAt = at;
186
+ style = cell.style;
187
+ }
188
+ };
189
+ // Whichever is smaller: the cells of the range or those of the sheet
190
+ if ((last.row - first.row + 1) * (last.col - first.col + 1) <= cells.size) {
191
+ for (let r = first.row; r <= last.row; r++) {
192
+ for (let c = first.col; c <= last.col; c++) visit(cells.get(r * 16_385 + c), r, c);
193
+ }
194
+ } else {
195
+ for (const [at, cell] of cells) {
196
+ const r = Math.floor(at / 16_385);
197
+ const c = at - r * 16_385;
198
+ if (r >= first.row && r <= last.row && c >= first.col && c <= last.col) visit(cell, r, c);
199
+ }
200
+ }
201
+ if (!count) return null;
202
+ if (error || !numbers) return { count, numbers: error ? 0 : numbers, sum: null, average: null, min: null, max: null, style };
203
+ return { count, numbers, sum, average: sum / numbers, min, max, style };
204
+ }
205
+
206
+ /**
207
+ * Where Ctrl+arrow lands from the line `from`, as Excel does: to the end of the block of filled
208
+ * cells it is in, else to the next filled cell, else to the end of the sheet
209
+ * @param {(line: number) => boolean} filled
210
+ * @param {number} from
211
+ * @param {1 | -1} step
212
+ * @param {number} limit last line of the sheet
213
+ */
214
+ export function dataEdge(filled, from, step, limit) {
215
+ const inside = i => i >= 1 && i <= limit;
216
+ if (!inside(from + step)) return from;
217
+ let i = from;
218
+ if (filled(from) && filled(from + step)) {
219
+ while (inside(i + step) && filled(i + step)) i += step;
220
+ return i;
221
+ }
222
+ for (i = from + step; inside(i); i += step) if (filled(i)) return i;
223
+ return step < 0 ? 1 : limit;
224
+ }
225
+
226
+ /**
227
+ * The far end of the run of numbers next to the line `from`, going by `step`; null when the next
228
+ * line has none. Autosum takes that run.
229
+ * @param {(line: number) => boolean} isNumber
230
+ * @param {number} from
231
+ * @param {1 | -1} step
232
+ * @param {number} limit last line of the sheet
233
+ */
234
+ export function numberRun(isNumber, from, step, limit) {
235
+ let end = null;
236
+ for (let i = from + step; i >= 1 && i <= limit && isNumber(i); i += step) end = i;
237
+ return end;
238
+ }
package/src/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface XlsxEditorOptions {
11
11
  tabs?: boolean;
12
12
  /** Bar above the grid with the selected cell and its content; default true */
13
13
  formulaBar?: boolean;
14
+ /** Bar below the tabs with the count, sum and average of the selection; default true */
15
+ statusBar?: boolean;
14
16
  /** A viewer: cells are selected and copied but not edited; default false */
15
17
  readOnly?: boolean;
16
18
  /** Where `xlsx_engine_bg.wasm` is served from, when the bundler does not serve it */
@@ -30,6 +32,41 @@ export interface Selection {
30
32
  active: CellPosition;
31
33
  /** Text the active cell shows */
32
34
  text: string;
35
+ /** Whole rows, whole columns or the whole sheet selected from the headers */
36
+ whole: 'row' | 'col' | 'all' | null;
37
+ }
38
+
39
+ /** What the status bar says about the selection */
40
+ export interface SelectionStats {
41
+ /** Cells that have something */
42
+ count: number;
43
+ /** Of them, numbers (dates count, as serial numbers) */
44
+ numbers: number;
45
+ /** `null` without numbers, or when an error is selected */
46
+ sum: number | null;
47
+ average: number | null;
48
+ min: number | null;
49
+ max: number | null;
50
+ }
51
+
52
+ export interface FindOptions {
53
+ matchCase?: boolean;
54
+ /** Only cells whose whole text matches */
55
+ wholeCell?: boolean;
56
+ /** The active sheet (default) or every worksheet */
57
+ scope?: 'sheet' | 'workbook';
58
+ /** Look in the formula of formula cells instead of the text they show */
59
+ inFormulas?: boolean;
60
+ }
61
+
62
+ export interface FindHit {
63
+ sheet: number;
64
+ sheetName: string;
65
+ ref: string;
66
+ row: number;
67
+ col: number;
68
+ /** The text that matched */
69
+ text: string;
33
70
  }
34
71
 
35
72
  /** Cells changed from the grid (typing, deleting, pasting, cutting) or by undo and redo */
@@ -39,9 +76,9 @@ export interface ChangeDetail {
39
76
  index: number;
40
77
  /** Range changed, e.g. `B3` or `A1:C4` */
41
78
  range: string;
42
- /** Every cell of `range` with its new value (`null` when empty) */
79
+ /** Every cell of `range` with its new value (`null` when empty); empty above 200 000 cells */
43
80
  cells: { ref: string; value: CellValue }[];
44
- source: 'edit' | 'clear' | 'paste' | 'cut' | 'undo' | 'redo';
81
+ source: 'edit' | 'clear' | 'paste' | 'cut' | 'move' | 'insert' | 'delete' | 'fill' | 'replace' | 'undo' | 'redo';
45
82
  }
46
83
 
47
84
  export interface XlsxEditorEventMap {
@@ -49,7 +86,7 @@ export interface XlsxEditorEventMap {
49
86
  sheetchange: CustomEvent<{ index: number; name: string }>;
50
87
  selectionchange: CustomEvent<Selection>;
51
88
  change: CustomEvent<ChangeDetail>;
52
- /** A change the engine refused (e.g. overwriting the source of a shared formula) */
89
+ /** A change the engine refused (e.g. moving a row through merged cells) */
53
90
  error: CustomEvent<{ error: Error }>;
54
91
  }
55
92
 
@@ -65,10 +102,55 @@ export class XlsxEditor extends EventTarget {
65
102
  readonly sheetIndex: number;
66
103
  readonly sheetName: string | null;
67
104
  readonly selection: Selection | null;
105
+ /** Count, sum, average, smallest and largest of the selected cells; `null` when they are empty */
106
+ readonly selectionStats: SelectionStats | null;
68
107
  /** Without editing from the grid (selecting and copying still work) */
69
108
  readOnly: boolean;
70
109
  readonly canUndo: boolean;
71
110
  readonly canRedo: boolean;
111
+ /**
112
+ * Moves `count` whole rows from `first` before the row `before` (numbered as they are now), as
113
+ * one undo step, and selects them. False when nothing changed: it stays where it is, it is
114
+ * read-only, or the engine refused (an `error` event says why).
115
+ */
116
+ moveRows(first: number, count: number, before: number): boolean;
117
+ /** Moves whole columns, by number (see `moveRows`) */
118
+ moveColumns(first: number, count: number, before: number): boolean;
119
+ /** Inserts rows before the row `before`, as one undo step, and selects them; false if refused */
120
+ insertRows(before: number, count?: number): boolean;
121
+ /** Inserts columns, by number (see `insertRows`) */
122
+ insertColumns(before: number, count?: number): boolean;
123
+ /** Deletes rows from `first`, as one undo step; false if refused */
124
+ deleteRows(first: number, count?: number): boolean;
125
+ /** Deletes columns, by number (see `deleteRows`) */
126
+ deleteColumns(first: number, count?: number): boolean;
127
+ /** Fits columns to their content, as one undo step; false if they are empty, read-only or refused. Needs the editor: it measures the text as drawn */
128
+ autofitColumns(first: number, count?: number): boolean;
129
+ /** Fits rows to their content (empty ones go back to the automatic height), as one undo step; false if read-only or refused */
130
+ autofitRows(first: number, count?: number): boolean;
131
+ /** Hides `count` rows from `first`, as one undo step */
132
+ hideRows(first: number, count?: number): boolean;
133
+ /** Shows hidden rows among the `count` from `first` */
134
+ showRows(first: number, count?: number): boolean;
135
+ /** Hides columns, by number */
136
+ hideColumns(first: number, count?: number): boolean;
137
+ /** Shows hidden columns, by number */
138
+ showColumns(first: number, count?: number): boolean;
139
+ /**
140
+ * Fills `target` (`A1:A20`) from the selected cells, which must be at one of its ends, as one
141
+ * undo step, and selects it. False if refused.
142
+ */
143
+ fill(target: string, options?: { series?: boolean }): boolean;
144
+ /** Where a text is, row by row: `*` is any text, `?` one character and `~` takes them literally */
145
+ find(query: string, options?: FindOptions): FindHit[];
146
+ /**
147
+ * Replaces a text in cells with text and in formulas (never in numbers or dates), as one undo
148
+ * step; returns how many cells changed. Formatted runs of a rich text become plain.
149
+ */
150
+ replaceAll(query: string, replacement: string, options?: Omit<FindOptions, 'inFormulas'>): number;
151
+ /** Opens the search panel, with the row to replace when asked */
152
+ openFind(options?: { replace?: boolean }): void;
153
+ closeFind(): void;
72
154
  /** Undoes the last change; false when there is nothing to undo */
73
155
  undo(): boolean;
74
156
  /** Redoes the last undone change */
@@ -105,7 +187,7 @@ export class XlsxEditor extends EventTarget {
105
187
  addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
106
188
  }
107
189
 
108
- /** `<xlsx-editor src="…" locale zoom sheet tabs formula-bar readonly wasm-url blank quiet>` */
190
+ /** `<xlsx-editor src="…" locale zoom sheet tabs formula-bar status-bar readonly wasm-url blank quiet>` */
109
191
  export class XlsxEditorElement extends HTMLElement {
110
192
  /** The editor, once the `ready` event fired */
111
193
  editor: XlsxEditor | null;
package/src/style.css CHANGED
@@ -27,7 +27,8 @@ xlsx-editor {
27
27
  }
28
28
 
29
29
  .xlsx-editor-bar[hidden],
30
- .xlsx-editor-tabs[hidden] {
30
+ .xlsx-editor-tabs[hidden],
31
+ .xlsx-editor-status[hidden] {
31
32
  display: none;
32
33
  }
33
34
 
@@ -159,3 +160,155 @@ xlsx-editor {
159
160
  opacity: 0.5;
160
161
  cursor: default;
161
162
  }
163
+
164
+ /* Below the tabs: what went wrong on the left, the sum and average of the selection on the right */
165
+ .xlsx-editor-status {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 1.5em;
169
+ min-height: 22px;
170
+ padding: 0 10px;
171
+ border-top: 1px solid var(--xlsx-border, #d1d5db);
172
+ background: var(--xlsx-tabs-bg, #f3f4f6);
173
+ font-size: 12px;
174
+ color: #4b5563;
175
+ white-space: nowrap;
176
+ }
177
+
178
+ .xlsx-editor-status-message {
179
+ flex: 1 1 auto;
180
+ min-width: 0;
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ color: #b91c1c;
184
+ }
185
+
186
+ .xlsx-editor-status-stats {
187
+ flex: 0 0 auto;
188
+ display: flex;
189
+ gap: 1.25em;
190
+ font-variant-numeric: tabular-nums;
191
+ }
192
+
193
+ /* Right click on cells and headers */
194
+ .xlsx-editor-menu {
195
+ position: fixed;
196
+ z-index: 1000;
197
+ display: flex;
198
+ flex-direction: column;
199
+ min-width: 14rem;
200
+ padding: 4px;
201
+ background: #ffffff;
202
+ border: 1px solid var(--xlsx-border, #d1d5db);
203
+ border-radius: var(--xlsx-radius, 6px);
204
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
205
+ font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
206
+ color: #1f2937;
207
+ }
208
+
209
+ .xlsx-editor-menu[hidden] {
210
+ display: none;
211
+ }
212
+
213
+ .xlsx-editor-menu-item {
214
+ display: flex;
215
+ justify-content: space-between;
216
+ gap: 2rem;
217
+ padding: 5px 10px;
218
+ border: 0;
219
+ border-radius: 4px;
220
+ background: transparent;
221
+ font: inherit;
222
+ color: inherit;
223
+ text-align: left;
224
+ white-space: nowrap;
225
+ cursor: pointer;
226
+ }
227
+
228
+ .xlsx-editor-menu-item:hover,
229
+ .xlsx-editor-menu-item:focus-visible {
230
+ outline: none;
231
+ background: rgba(33, 115, 70, 0.12);
232
+ }
233
+
234
+ .xlsx-editor-menu-keys {
235
+ color: #6b7280;
236
+ }
237
+
238
+ .xlsx-editor-menu-line {
239
+ height: 1px;
240
+ margin: 4px 6px;
241
+ background: var(--xlsx-border, #d1d5db);
242
+ }
243
+
244
+ /* Search panel, over the top right corner of the grid */
245
+ .xlsx-editor-find {
246
+ position: absolute;
247
+ z-index: 3;
248
+ top: 6px;
249
+ right: 22px;
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: 6px;
253
+ width: min(22rem, calc(100% - 44px));
254
+ padding: 8px;
255
+ background: #ffffff;
256
+ border: 1px solid var(--xlsx-border, #d1d5db);
257
+ border-radius: var(--xlsx-radius, 6px);
258
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
259
+ font-size: 12px;
260
+ }
261
+
262
+ .xlsx-editor-find[hidden],
263
+ .xlsx-editor-find-row[hidden] {
264
+ display: none;
265
+ }
266
+
267
+ .xlsx-editor-find-row {
268
+ display: flex;
269
+ align-items: center;
270
+ flex-wrap: wrap;
271
+ gap: 6px;
272
+ }
273
+
274
+ .xlsx-editor-find input[type="text"] {
275
+ flex: 1 1 6rem;
276
+ min-width: 0;
277
+ padding: 3px 6px;
278
+ border: 1px solid var(--xlsx-border, #d1d5db);
279
+ border-radius: 4px;
280
+ font: inherit;
281
+ }
282
+
283
+ .xlsx-editor-find input[type="text"]:focus {
284
+ outline: 2px solid var(--xlsx-accent, #217346);
285
+ outline-offset: -1px;
286
+ }
287
+
288
+ .xlsx-editor-find button,
289
+ .xlsx-editor-find select {
290
+ padding: 3px 7px;
291
+ border: 1px solid var(--xlsx-border, #d1d5db);
292
+ border-radius: 4px;
293
+ background: #ffffff;
294
+ font: inherit;
295
+ color: inherit;
296
+ cursor: pointer;
297
+ }
298
+
299
+ .xlsx-editor-find button:hover {
300
+ background: rgba(33, 115, 70, 0.12);
301
+ }
302
+
303
+ .xlsx-editor-find label {
304
+ display: inline-flex;
305
+ align-items: center;
306
+ gap: 3px;
307
+ cursor: pointer;
308
+ }
309
+
310
+ .xlsx-editor-find-count {
311
+ margin-left: auto;
312
+ color: #4b5563;
313
+ font-variant-numeric: tabular-nums;
314
+ }