| 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 | |