oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1/* Commands: what every menu item, toolbar button and shortcut does.
2 *
3 * One dispatch table, read by three callers — a click on a [data-cmd],
4 * a keyboard shortcut, and the toolbar's own state reflection. Three
5 * separate implementations of "make this bold" is how a menu item and
6 * its shortcut end up disagreeing, and only one of them gets fixed.
7 *
8 * Every control that needs this module is rendered hidden and revealed
9 * below. A button that does nothing because its script did not load is
10 * worse than no button, and this app has a scriptless path — the table
11 * view — to send people to instead.
12 */
13(function () {
14 'use strict';
15
16 var surface = document.querySelector('[data-sheet-surface]');
17 if (!surface) return;
18
19 function ready() {
20 var g = window.SheetsGrid;
21 if (!g) return;
22
23 /* ---- what a command does ---------------------------------------
24 *
25 * A toggle sends its TARGET state, worked out from the anchor cell,
26 * rather than sending "toggle". Over a mixed selection those differ:
27 * every spreadsheet makes the whole selection bold when any of it is
28 * not, and a per-cell flip would invert a checkerboard instead. */
29 var anchor = function () {
30 var s = g.selection();
31 return s ? (g.styleAt(s.r, s.c) || {}) : {};
32 };
33
34 /* format posts a patch for the current selection.
35 *
36 * It lives here rather than in grid.js because it is a command, and
37 * because the patch's shape — only the fields being changed, never
38 * the whole style — is a decision about what the buttons mean. See
39 * the Go side's formatPatch for why that distinction is the design
40 * and not an optimisation. */
41 function format(patch) {
42 var s = g.selection();
43 if (!s) return;
44 var body = { r1: s.r, c1: s.c, r2: s.r2, c2: s.c2 };
45 Object.keys(patch).forEach(function (k) { body[k] = patch[k]; });
46 g.post('/format', body);
47 }
48
49 var CMDS = {
50 undo: function () {
51 /* The same route the scriptless form posts to, so there is one
52 * undo and not two that can disagree. */
53 fetch('/sheets/' + surface.dataset.sheetId + '/undo', {
54 method: 'POST',
55 credentials: 'same-origin',
56 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
57 body: ''
58 }).then(g.refetch).catch(g.refetch);
59 },
60 bold: function () { format({ bold: !anchor().b }); },
61 italic: function () { format({ italic: !anchor().i }); },
62 align: function (v) { format({ align: anchor().a === v ? '' : v }); },
63 numfmt: function (v) { format({ numfmt: v }); },
64 fill: function (v) { format({ fill: v }); },
65 ink: function (v) { format({ ink: v }); },
66 'clear-format': function () {
67 format({ bold: false, italic: false, align: '', numfmt: '', fill: 'none', ink: 'none' });
68 },
69
70 clear: function () { eachSelected(function (r, c) { g.write(r, c, ''); }); },
71
72 /* Fill down and fill right are the menu equivalents of the grid's
73 * drag handle (AGENTS.md: every drag-only affordance has one).
74 * They copy the anchor's raw INPUT, not its displayed value, so
75 * filling a formula down fills the formula. */
76 'fill-down': function () { fillFrom(1, 0); },
77 'fill-right': function () { fillFrom(0, 1); },
78
79 'autofit-columns': function () { if (window.SheetsAutoFit) window.SheetsAutoFit(); },
80 'select-all': function () {
81 var ws = g.worksheet();
82 if (ws && ws.updateSelectionFromCoords) {
83 ws.updateSelectionFromCoords(0, 0, g.cols - 1, g.rows - 1);
84 }
85 },
86 /* clipboard.js, not the library. ws.copy() threw on every call
87 * and ws.copy(true) silently did nothing; both were dispatched,
88 * both rendered, and TestEveryMenuCommandIsImplemented was green
89 * throughout, because a command in the table is not a command
90 * that works. */
91 copy: function () { clip('copy'); },
92 cut: function () { clip('cut'); },
93 paste: function () { clip('paste'); },
94
95 /* Insert a function over the run of cells directly above the
96 * anchor, which is what somebody clicking "SUM" under a column of
97 * numbers means. An empty run inserts the call with no arguments
98 * rather than guessing a range that is not there. */
99 fn: function (name) {
100 var s = g.selection();
101 if (!s) return;
102 var top = s.r;
103 while (top > 0 && g.rawAt(top - 1, s.c) !== '') top--;
104 var arg = top < s.r ? colName(s.c) + (top + 1) + ':' + colName(s.c) + s.r : '';
105 g.write(s.r, s.c, '=' + name + '(' + arg + ')');
106 }
107 };
108
109 function clip(name) {
110 var c = window.SheetsClipboard;
111 if (c && c[name]) c[name]();
112 }
113
114 function colName(c) {
115 var out = '';
116 c += 1;
117 while (c > 0) { out = String.fromCharCode(65 + (c - 1) % 26) + out; c = Math.floor((c - 1) / 26); }
118 return out;
119 }
120
121 function eachSelected(fn) {
122 var s = g.selection();
123 if (!s) return;
124 for (var r = Math.min(s.r, s.r2); r <= Math.max(s.r, s.r2); r++) {
125 for (var c = Math.min(s.c, s.c2); c <= Math.max(s.c, s.c2); c++) fn(r, c);
126 }
127 }
128
129 function fillFrom(dr, dc) {
130 var s = g.selection();
131 if (!s) return;
132 var from = g.rawAt(s.r, s.c);
133 /* Over a selection, fill the rest of it from the anchor; over a
134 * single cell, fill the one next along — which is what the drag
135 * handle does with a one-cell drag. */
136 var lastR = Math.max(s.r, s.r2), lastC = Math.max(s.c, s.c2);
137 if (lastR === s.r && lastC === s.c) {
138 var r = Math.min(s.r + dr, g.rows - 1), c = Math.min(s.c + dc, g.cols - 1);
139 if (r !== s.r || c !== s.c) copyInto(r, c, from, s);
140 return;
141 }
142 eachSelected(function (r, c) {
143 if (r === s.r && c === s.c) return;
144 copyInto(r, c, from, s);
145 });
146 }
147
148 /* Spec §4.2: a filled cell is a COPIED cell, so it names where it
149 * came from and the server offsets its relative references. Without
150 * this, filling `=A1*2` down a column wrote `=A1*2` into every cell
151 * and the whole column showed one number. */
152 function copyInto(r, c, raw, from) {
153 g.post('/cells', { r: r, c: c, raw: raw, fromR: from.r, fromC: from.c });
154 }
155
156 function run(name, value) {
157 var fn = CMDS[name];
158 if (fn) fn(value);
159 }
160
161 /* ---- wiring ----------------------------------------------------- */
162
163 document.querySelectorAll('[data-needs-js]').forEach(function (el) {
164 el.hidden = false;
165 el.removeAttribute('data-needs-js');
166 });
167 document.querySelectorAll('[data-cmd][hidden]').forEach(function (el) {
168 el.hidden = false;
169 });
170
171 document.addEventListener('click', function (e) {
172 var el = e.target.closest('[data-cmd]');
173 if (!el || el.tagName === 'SUMMARY') return;
174 e.preventDefault();
175 run(el.dataset.cmd, el.dataset.value !== undefined ? el.dataset.value : el.dataset.fn);
176 var panel = el.closest('[data-tool-menu]');
177 if (!panel) return;
178 /* Closing the panel leaves focus on a button inside a closed
179 * <details>, which drops it to <body> — so the keyboard ends up
180 * at the top of the document after picking a colour. Hand it back
181 * to the grid, which is the same rule menubar.js applies to a
182 * menu item: the thing you asked for happened to a cell, so that
183 * is where you want to be next. */
184 panel.open = false;
185 g.focus();
186 });
187
188 /* The menu's resize forms. The size route takes one axis at a time
189 * and applies to the selection's anchor, which is the column or row
190 * the cursor is in — the same thing dragging its edge would do. */
191 document.querySelectorAll('[data-size-form]').forEach(function (form) {
192 form.addEventListener('submit', function (e) {
193 e.preventDefault();
194 var sel = g.selection();
195 var px = parseInt(form.querySelector('input[name=size]').value, 10);
196 if (!sel || !isFinite(px)) return;
197 var body = form.dataset.axis === 'col' ? { col: sel.c } : { row: sel.r };
198 body.size = px;
199 g.post('/size', body);
200 var menu = form.closest('details');
201 if (menu) menu.open = false;
202 g.focus();
203 });
204 });
205
206 /* ---- shortcuts ---------------------------------------------------
207 *
208 * Never while a cell editor, the formula bar or the name box has
209 * focus: there Ctrl+B belongs to the text field, and stealing it
210 * would make the editor feel broken.
211 *
212 * Ctrl+R is deliberately absent. It is reload in every browser, and
213 * fill right is not worth taking it. */
214 var KEYS = { b: 'bold', i: 'italic', d: 'fill-down', z: 'undo' };
215
216 document.addEventListener('keydown', function (e) {
217 var t = e.target;
218 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
219
220 /* Delete is deliberately NOT handled here. The vendored grid
221 * already clears the selection through its own setValue, which
222 * fires onchange and reaches write() — so handling it too posted
223 * every selected cell twice, and Backspace (which the library
224 * also handles) only once, which is worse than either. The menu
225 * item stays: it is the affordance, and it runs the same code the
226 * key does, once. */
227 if (!e.ctrlKey && !e.metaKey) return;
228 if (e.altKey || e.shiftKey) return;
229 var cmd = KEYS[e.key.toLowerCase()];
230 if (!cmd) return;
231 if (cmd !== 'undo' && !g.mayWrite) return;
232 e.preventDefault();
233 run(cmd);
234 });
235
236 /* ---- the toolbar reflects the anchor cell -------------------------
237 *
238 * A toolbar that does not show the selection's state is decoration:
239 * you cannot tell whether pressing B will bold or un-bold. */
240 var toggles = document.querySelectorAll('[data-tool][aria-pressed]');
241 var fillChip = document.querySelector('[data-fill-chip]');
242 var inkChip = document.querySelector('[data-ink-chip]');
243
244 g.onSelection(function () {
245 var st = anchor();
246 toggles.forEach(function (btn) {
247 var on = false;
248 if (btn.dataset.cmd === 'bold') on = !!st.b;
249 else if (btn.dataset.cmd === 'italic') on = !!st.i;
250 else if (btn.dataset.cmd === 'align') on = st.a === btn.dataset.value;
251 btn.setAttribute('aria-pressed', on ? 'true' : 'false');
252 });
253 chip(fillChip, st.fh, st.fc, '--sheet-h', '--sheet-c');
254 chip(inkChip, st.kh, st.kc, '--sheet-kh', '--sheet-kc');
255 });
256
257 function chip(el, h, c, hProp, cProp) {
258 if (!el) return;
259 if (typeof h !== 'number' || typeof c !== 'number') {
260 el.removeAttribute('data-set');
261 return;
262 }
263 el.style.setProperty(hProp, String(h));
264 el.style.setProperty(cProp, String(c));
265 el.setAttribute('data-set', '');
266 }
267
268 /* ---- the toolbar is one tab stop ---------------------------------
269 *
270 * Fourteen buttons, fourteen tab stops, is a strip a keyboard user
271 * has to walk through to reach the grid. Roving tabindex with arrow
272 * keys is the convention for a toolbar, and matches what the menu
273 * bar beside it does. */
274 var strip = document.querySelector('[data-toolbar]');
275 if (!strip) return;
276 var stops = Array.prototype.slice.call(strip.querySelectorAll('[data-tool]'));
277 if (!stops.length) return;
278 stops.forEach(function (el, i) { el.tabIndex = i === 0 ? 0 : -1; });
279
280 strip.addEventListener('keydown', function (e) {
281 if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
282 var i = stops.indexOf(e.target.closest('[data-tool]'));
283 if (i < 0) return;
284 e.preventDefault();
285 /* Jspreadsheet listens for arrows on document and would walk the
286 * cell cursor as well. */
287 e.stopPropagation();
288 var next = stops[(i + (e.key === 'ArrowRight' ? 1 : -1) + stops.length) % stops.length];
289 stops.forEach(function (el) { el.tabIndex = -1; });
290 next.tabIndex = 0;
291 next.focus();
292 });
293 }
294
295 if (window.SheetsGrid) ready();
296 else document.addEventListener('sheets:ready', ready, { once: true });
297}());
298