| 1 | /* The grid. |
| 2 | * |
| 3 | * It paints strings the server computed and never evaluates a formula |
| 4 | * itself. parseFormulas is off below, deliberately: our engine and |
| 5 | * Jspreadsheet's would disagree on the first SUM(1,2,"3") — the |
| 6 | * divergence the formula corpus already documents — and the |
| 7 | * disagreement would be invisible, because both would look like a |
| 8 | * number in a cell. |
| 9 | * |
| 10 | * Cell data holds DISPLAY strings; the raw input lives in a side map. |
| 11 | * That way the grid shows 42 and the formula bar shows =6*7, which is |
| 12 | * what a spreadsheet does. The alternative — raw in the data — shows |
| 13 | * you =6*7 in every cell and is not a spreadsheet. |
| 14 | * |
| 15 | * The chrome above and below the grid is not wired here. This module |
| 16 | * publishes window.SheetsGrid and menubar.js and commands.js build on |
| 17 | * it, because everything they add — a menu bar, a toolbar, shortcuts, |
| 18 | * format writes — would otherwise put this file past the 300-line cap |
| 19 | * and, worse, put the selection model and the button wiring in one |
| 20 | * place where only the first has any reason to be subtle. |
| 21 | */ |
| 22 | (function () { |
| 23 | 'use strict'; |
| 24 | |
| 25 | var surface = document.querySelector('[data-sheet-surface]'); |
| 26 | if (!surface || typeof jspreadsheet !== 'function') return; |
| 27 | |
| 28 | var sheetId = surface.dataset.sheetId; |
| 29 | var tabId = surface.dataset.tabId; |
| 30 | var rows = parseInt(surface.dataset.rows, 10) || 100; |
| 31 | var cols = parseInt(surface.dataset.cols, 10) || 26; |
| 32 | var base = '/api/sheets/' + sheetId + '/tabs/' + tabId; |
| 33 | var host = surface.querySelector('[data-sheet-grid]'); |
| 34 | |
| 35 | /* paint.js, or no-ops. Resolved once so no call site guards, and so a |
| 36 | * module that failed to load costs styling and not the grid. */ |
| 37 | var noop = function () {}; |
| 38 | var paint = window.SheetsPaint || { bind: noop, repaint: noop, sweep: noop }; |
| 39 | |
| 40 | /* A per-tab id so this tab can recognise its own edit coming back |
| 41 | * over the stream. Without it the tab you typed in repaints the cell |
| 42 | * you are still editing. */ |
| 43 | var clientId = 'c' + Math.random().toString(36).slice(2) + Date.now().toString(36); |
| 44 | |
| 45 | /* meta is everything known about a cell that is not its painted |
| 46 | * string: "r,c" -> {raw, f, n}. One map rather than three parallel |
| 47 | * ones, because every path that adds a cell has to add it to all of |
| 48 | * them and every path that clears has to clear all of them — and the |
| 49 | * one that forgets is invisible until a format outlives its cell. */ |
| 50 | var meta = Object.create(null); |
| 51 | var styles = Object.create(null); /* format key -> the style */ |
| 52 | var version = 0; /* the newest version painted */ |
| 53 | var ws = null; |
| 54 | var sel = null; /* {r,c,r2,c2}; r,c is the anchor */ |
| 55 | var applying = false; /* true while we write into the grid ourselves */ |
| 56 | var listeners = []; |
| 57 | |
| 58 | function key(r, c) { return r + ',' + c; } |
| 59 | function at(r, c) { return meta[key(r, c)] || {}; } |
| 60 | |
| 61 | function blank() { |
| 62 | var grid = [], r, c, row; |
| 63 | for (r = 0; r < rows; r++) { |
| 64 | for (row = [], c = 0; c < cols; c++) row.push(''); |
| 65 | grid.push(row); |
| 66 | } |
| 67 | return grid; |
| 68 | } |
| 69 | |
| 70 | /* applyBatch paints a server response. |
| 71 | * |
| 72 | * Out-of-order paints are discarded BY VERSION, not by arrival (spec |
| 73 | * §7). Without this, optimistic painting plus last-write-wins |
| 74 | * produces a visibly wrong grid under two concurrent editors — the |
| 75 | * exact bug the optimism is meant to hide. |
| 76 | * |
| 77 | * A format write does not advance the version (nothing is appended to |
| 78 | * the event log for it), so the equal case must pass, not be |
| 79 | * discarded — hence `<` and not `<=`. */ |
| 80 | function applyBatch(payload) { |
| 81 | if (!payload || !payload.cells) return; |
| 82 | if (payload.version && payload.version < version) return; |
| 83 | if (payload.version) version = payload.version; |
| 84 | |
| 85 | /* MERGE, never replace. A batch carries only the formats it |
| 86 | * mentions, so replacing would drop every style outside the edited |
| 87 | * cell the first time somebody typed into an imported sheet. */ |
| 88 | if (payload.formats) { |
| 89 | Object.keys(payload.formats).forEach(function (k) { |
| 90 | styles[k] = payload.formats[k]; |
| 91 | }); |
| 92 | } |
| 93 | |
| 94 | /* Layout arrives with the batch it belongs to, so a column widened |
| 95 | * in another tab widens here in the same frame the edit lands. */ |
| 96 | if (window.SheetsSizes) window.SheetsSizes.apply(ws, payload.sizes); |
| 97 | |
| 98 | applying = true; |
| 99 | try { |
| 100 | payload.cells.forEach(function (cell) { |
| 101 | if (cell.r >= rows || cell.c >= cols) return; |
| 102 | meta[key(cell.r, cell.c)] = { raw: cell.raw || '', f: cell.f, n: cell.n }; |
| 103 | ws.setValueFromCoords(cell.c, cell.r, cell.display, true); |
| 104 | paint.repaint(cell.r, cell.c); |
| 105 | }); |
| 106 | } finally { |
| 107 | applying = false; |
| 108 | } |
| 109 | if (payload.resync) refetch(); |
| 110 | announce(); |
| 111 | } |
| 112 | |
| 113 | function refetch() { |
| 114 | fetch(base + '/cells?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| 115 | .then(function (r) { return r.ok ? r.json() : null; }) |
| 116 | .then(function (payload) { |
| 117 | if (!payload) return; |
| 118 | /* A snapshot supersedes everything, including formats a cell |
| 119 | * lost while this tab was disconnected — those cells are simply |
| 120 | * absent from it, so nothing below would ever unpaint them. */ |
| 121 | version = 0; |
| 122 | meta = Object.create(null); |
| 123 | styles = Object.create(null); |
| 124 | paint.sweep(host); |
| 125 | applyBatch(payload); |
| 126 | }) |
| 127 | .catch(function () { /* the stream's own reconnect will try again */ }); |
| 128 | } |
| 129 | |
| 130 | /* post sends one change and paints what comes back. |
| 131 | * |
| 132 | * One function for all three — a cell, a format patch, a size — |
| 133 | * because all three are the same exchange: send a change, receive the |
| 134 | * batch it produced, paint that. Three copies of it is three places |
| 135 | * for the version check or the failure path to drift. |
| 136 | * |
| 137 | * The response is applied even though the grid may already show the |
| 138 | * typed text, because the server owns the displayed string: it is |
| 139 | * what turns "=6*7" into "42", and what corrects a value the engine |
| 140 | * read differently from how it looked. |
| 141 | * |
| 142 | * Every failure refetches. The write may or may not have landed, and |
| 143 | * refetching is the only honest answer — the alternative is leaving |
| 144 | * the grid showing something the server never accepted. */ |
| 145 | function post(path, body) { |
| 146 | body.client = clientId; |
| 147 | return fetch(base + path, { |
| 148 | method: 'POST', |
| 149 | credentials: 'same-origin', |
| 150 | headers: { 'Content-Type': 'application/json' }, |
| 151 | body: JSON.stringify(body) |
| 152 | }).then(function (resp) { |
| 153 | if (!resp.ok) { refetch(); return false; } |
| 154 | return resp.json().then(function (payload) { applyBatch(payload); return true; }); |
| 155 | }).catch(function () { refetch(); return false; }); |
| 156 | } |
| 157 | |
| 158 | function write(r, c, text) { |
| 159 | return post('/cells', { r: r, c: c, raw: text }); |
| 160 | } |
| 161 | |
| 162 | function announce() { |
| 163 | listeners.forEach(function (fn) { fn(sel); }); |
| 164 | } |
| 165 | |
| 166 | function boot(payload) { |
| 167 | var data = blank(); |
| 168 | (payload.cells || []).forEach(function (cell) { |
| 169 | if (cell.r >= rows || cell.c >= cols) return; |
| 170 | data[cell.r][cell.c] = cell.display; |
| 171 | meta[key(cell.r, cell.c)] = { raw: cell.raw || '', f: cell.f, n: cell.n }; |
| 172 | }); |
| 173 | if (payload.formats) styles = payload.formats; |
| 174 | version = payload.version || 0; |
| 175 | |
| 176 | var opts = { |
| 177 | parseFormulas: false, /* Our engine computes; the client paints. See the file comment. */ |
| 178 | /* Library menu and Ctrl+S CSV bypass our write/export paths. false: browser menu. */ |
| 179 | contextMenu: function () { return false; }, allowExport: false, |
| 180 | |
| 181 | /* Every handler below is a SPREADSHEET-level option, not a |
| 182 | * worksheet one. Nested inside worksheets[0] they are accepted |
| 183 | * without complaint and never called — the grid renders, the |
| 184 | * console stays clean, and no edit is ever sent anywhere. That |
| 185 | * is how they were first written here, and only driving two real |
| 186 | * browser tabs found it. */ |
| 187 | onchange: function (instance, cell, c, r, value) { |
| 188 | if (applying) return; |
| 189 | write(parseInt(r, 10), parseInt(c, 10), value === null ? '' : String(value)); |
| 190 | }, |
| 191 | onselection: function (instance, x1, y1, x2, y2) { |
| 192 | sel = { r: parseInt(y1, 10), c: parseInt(x1, 10), |
| 193 | r2: parseInt(y2, 10), c2: parseInt(x2, 10) }; |
| 194 | announce(); |
| 195 | }, |
| 196 | /* The in-cell editor should show the formula, not its result. |
| 197 | * The input does not exist yet when this fires, so it is filled |
| 198 | * on the next frame — best effort, because the formula bar is |
| 199 | * the reliable path and this is the convenience. */ |
| 200 | oneditionstart: function (instance, td, c, r) { |
| 201 | var text = at(parseInt(r, 10), parseInt(c, 10)).raw; |
| 202 | if (!text) return; |
| 203 | requestAnimationFrame(function () { |
| 204 | var input = td.querySelector('input, textarea'); |
| 205 | if (input) { input.value = text; input.select(); } |
| 206 | }); |
| 207 | }, |
| 208 | |
| 209 | worksheets: [{ |
| 210 | data: data, |
| 211 | /* What "no alignment" means; see sheets.css's alignment block. |
| 212 | * A WORKSHEET option — the exact mirror of the handlers above, |
| 213 | * which are spreadsheet-level. Each is silently ignored in the |
| 214 | * other's position: accepted, never read. */ |
| 215 | defaultColAlign: 'left', |
| 216 | minDimensions: [cols, rows], |
| 217 | tableOverflow: true, |
| 218 | columnSorting: false, /* sorting is a later slice; a half-done sort is worse than none */ |
| 219 | /* Each of these changes the library's model only, never the server's, and then every |
| 220 | * later write lands at shifted coordinates (header + Delete, row drag). */ |
| 221 | columnDrag: false, rowDrag: false, allowInsertRow: false, allowInsertColumn: false, |
| 222 | allowDeleteRow: false, allowDeleteColumn: false |
| 223 | }] |
| 224 | }; |
| 225 | /* The resize callbacks are SPREADSHEET-level options, like the |
| 226 | * handlers above. sizes.js adds them here, on the way in — nested |
| 227 | * a level deeper they are accepted in silence and never called. */ |
| 228 | if (window.SheetsSizes) { |
| 229 | window.SheetsSizes.extend(opts, { |
| 230 | base: base, client: clientId, |
| 231 | mayWrite: surface.dataset.mayWrite === '1', |
| 232 | paint: applyBatch |
| 233 | }); |
| 234 | } |
| 235 | var sheet = jspreadsheet(host, opts); |
| 236 | ws = sheet.worksheets ? sheet.worksheets[0] : sheet[0]; |
| 237 | if (window.SheetsSizes) window.SheetsSizes.apply(ws, payload.sizes); |
| 238 | |
| 239 | /* The library keeps its own client-side undo stack and its document |
| 240 | * keydown is registered before ours. Left on, Ctrl-Z reverts |
| 241 | * locally, then our handler posts the server undo and refetches: |
| 242 | * the net state is right and the grid flashes on the way. Undo is |
| 243 | * the server's (spec §7) — there is one undo, not two that can |
| 244 | * disagree — so the local stack is switched off rather than raced. */ |
| 245 | ws.ignoreHistory = true; |
| 246 | |
| 247 | /* paint.js owns the td and the drawing; this says what a cell is. */ |
| 248 | paint.bind(ws, function (r, c) { |
| 249 | var m = at(r, c); |
| 250 | return { style: styles[m.f], n: m.n }; |
| 251 | }); |
| 252 | |
| 253 | /* Paint the formats the snapshot arrived with. Deferred a frame: |
| 254 | * the records array is populated during construction, and painting |
| 255 | * inside the same tick reaches a table the library has not finished |
| 256 | * laying out. */ |
| 257 | requestAnimationFrame(function () { |
| 258 | Object.keys(meta).forEach(function (k) { |
| 259 | var parts = k.split(','); |
| 260 | paint.repaint(parseInt(parts[0], 10), parseInt(parts[1], 10)); |
| 261 | }); |
| 262 | }); |
| 263 | |
| 264 | if (window.SheetsLive) window.SheetsLive.start(sheetId, tabId, clientId, applyBatch, refetch); |
| 265 | document.dispatchEvent(new CustomEvent('sheets:ready')); |
| 266 | } |
| 267 | |
| 268 | /* The API the chrome is built on. Deliberately small: everything the |
| 269 | * menu bar and toolbar need to read the selection, change a cell and |
| 270 | * change a format, and nothing that would let them paint a string the |
| 271 | * server did not render. */ |
| 272 | window.SheetsGrid = { |
| 273 | write: write, |
| 274 | post: post, |
| 275 | refetch: refetch, |
| 276 | rows: rows, |
| 277 | cols: cols, |
| 278 | mayWrite: surface.dataset.mayWrite === '1', |
| 279 | selection: function () { return sel; }, |
| 280 | rawAt: function (r, c) { return at(r, c).raw || ''; }, |
| 281 | styleAt: function (r, c) { return styles[at(r, c).f] || null; }, |
| 282 | worksheet: function () { return ws; }, |
| 283 | focus: function () { if (host) host.focus(); }, |
| 284 | onSelection: function (fn) { listeners.push(fn); if (sel) fn(sel); } |
| 285 | }; |
| 286 | |
| 287 | fetch(base + '/cells?rows=' + rows + '&cols=' + cols, { credentials: 'same-origin' }) |
| 288 | .then(function (r) { |
| 289 | if (!r.ok) throw new Error('window'); |
| 290 | return r.json(); |
| 291 | }) |
| 292 | .then(boot) |
| 293 | .catch(function () { |
| 294 | var note = document.querySelector('[data-sheet-live]'); |
| 295 | if (note) { |
| 296 | note.dataset.state = 'off'; |
| 297 | note.textContent = 'Could not load this sheet. Reload, or use the table view.'; |
| 298 | } |
| 299 | }); |
| 300 | }()); |
| 301 | |