oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

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