feat(grid): columns you can widen, and widths that stay widened
Row.Height and Col.Width have been on the models since the first schema and nothing has ever written one. The consequence is on the first screen anybody opens: a column headed "Invoice Quarter" reads "Invoice Qua", and dragging it does not help because the drag has nowhere to be saved. A spreadsheet whose columns cannot be widened is not one. Sizes are stored against the stable row and column ids like everything else, so widening a column keeps the width on the column somebody widened. They ride along with the window response rather than needing a second round trip — they are layout, and a page that fetched them afterwards would visibly reflow every column once after load. A resize publishes like any other edit, so a column widened in one tab widens in every other tab watching the sheet. Two people looking at visibly different documents with no way to tell which is right is not a thing a live-synced app should allow. The floor is 16px and not zero, deliberately: a column dragged to nothing is one nobody can find again to drag back, and this app hides nothing and therefore unhides nothing. sizes.js guards against the echo. The library fires its resize callbacks for a programmatic setWidth exactly as it does for a drag, so without that guard an incoming size is posted straight back and two open tabs turn one person's drag into an endless round trip. grid.js's three near-identical fetch-then-paint blocks are now one post() — a cell, a format patch and a size are the same exchange — and format() moved to commands.js, where the commands are. That is what kept grid.js inside the 300-line cap without making any of it terse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182f7QfojuuqdS4Xd2fYFaL
11 files changed,
+542
−41
internal/sheets/app.go+1 −0internal/sheets/grid.go+4 −0internal/sheets/handlers_grid.go+9 −2internal/sheets/handlers_size.go+140 −0internal/sheets/handlers_size_test.go+116 −0internal/sheets/static/commands.js+22 −7internal/sheets/static/grid.js+39 −32internal/sheets/static/sizes.js+83 −0internal/sheets/store/handle_sizes.go+111 −0internal/sheets/templates/sheet.html+1 −0tools/drive/chrome.mjs+16 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 6b2aacd..e38b103 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -225,6 +225,7 @@ func (a *App) Router() http.Handler { |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/format", a.handleFormatPost) |
| + r.Post("/api/sheets/{sheetID}/tabs/{tabID}/size", a.handleSizePost) |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/stream", a.handleStream) |
| // The job status page, its polled fragment and its event |
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go| index 4f21475..96df309 100644 |
| --- a/internal/sheets/grid.go |
| +++ b/internal/sheets/grid.go |
| @@ -63,6 +63,10 @@ type gridResponse struct { |
| // Replacing would drop every style outside the edited cell the |
| // first time somebody typed into an imported sheet. |
| Formats styleTable `json:"formats,omitempty"` |
| + // Sizes is the tab's column widths and row heights, sent whole |
| + // rather than as a delta — see sizesResponse. Absent on a batch |
| + // that is not about layout, which is almost all of them. |
| + Sizes *wireSizes `json:"sizes,omitempty"` |
| } |
| // display turns a computed value into the string the grid paints. |
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go| index 5096f6b..cc2dcfa 100644 |
| --- a/internal/sheets/handlers_grid.go |
| +++ b/internal/sheets/handlers_grid.go |
| @@ -46,9 +46,16 @@ func (a *App) handleCellsGet(w http.ResponseWriter, r *http.Request) { |
| a.fail(w, r, err) |
| return |
| } |
| - writeJSON(w, http.StatusOK, gridResponse{ |
| + // Sizes ride along with the window rather than needing a second |
| + // round trip: they are layout, so a page that fetched them |
| + // afterwards would visibly reflow every column once. |
| + resp := gridResponse{ |
| Version: h.Version(), Rows: rows, Cols: cols, Cells: cells, Formats: styles, |
| - }) |
| + } |
| + if cw, rh, err := h.Sizes(); err == nil && (len(cw) > 0 || len(rh) > 0) { |
| + resp.Sizes = &wireSizes{Cols: intKeys(cw), Rows: intKeys(rh)} |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| } |
| // handleCellPost writes one cell and fans the result out. |
diff --git a/internal/sheets/handlers_size.go b/internal/sheets/handlers_size.go| new file mode 100644 |
| index 0000000..3bef977 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_size.go |
| @@ -0,0 +1,140 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// sizeWrite is the POST body: one column's width or one row's height. |
| +// |
| +// Exactly one of Col and Row is set, and the handler refuses both or |
| +// neither rather than picking. A request that names two things is a |
| +// client bug, and guessing which it meant is how the wrong column ends |
| +// up resized on the day somebody's serialiser starts emitting zeroes |
| +// for absent fields. |
| +type sizeWrite struct { |
| + Col *int `json:"col,omitempty"` |
| + Row *int `json:"row,omitempty"` |
| + Size int `json:"size"` |
| + Client string `json:"client"` |
| +} |
| + |
| +// handleSizePost stores a column width or a row height. |
| +// |
| +// It answers with a sizes payload and publishes the same one, so a |
| +// column widened in one tab widens in every other tab watching the |
| +// sheet — the same live-sync contract every other edit has. A layout |
| +// change that did not propagate would leave two people looking at |
| +// visibly different documents and no way to tell which was right. |
| +// |
| +// Like formatting, a resize writes no event and is therefore not |
| +// undoable. Same reason, same named limitation. |
| +func (a *App) handleSizePost(w http.ResponseWriter, r *http.Request) { |
| + if _, ok := a.actor(r); !ok { |
| + http.Error(w, "Sign in first.", http.StatusForbidden) |
| + return |
| + } |
| + h, ok := a.openTab(w, r, SheetEditor) |
| + if !ok { |
| + return |
| + } |
| + defer h.Release() |
| + |
| + var in sizeWrite |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil { |
| + http.Error(w, "Malformed request.", http.StatusBadRequest) |
| + return |
| + } |
| + if (in.Col == nil) == (in.Row == nil) { |
| + http.Error(w, "Name exactly one of col and row.", http.StatusBadRequest) |
| + return |
| + } |
| + |
| + var err error |
| + switch { |
| + case in.Col != nil: |
| + if *in.Col < 0 || *in.Col >= maxWindowCols*10 { |
| + http.Error(w, "Column out of range.", http.StatusBadRequest) |
| + return |
| + } |
| + var colID uint64 |
| + if colID, err = h.EnsureCol(*in.Col); err == nil { |
| + err = h.SetColWidth(colID, in.Size) |
| + } |
| + default: |
| + if *in.Row < 0 || *in.Row >= maxWindowRows*10 { |
| + http.Error(w, "Row out of range.", http.StatusBadRequest) |
| + return |
| + } |
| + var rowID uint64 |
| + if rowID, err = h.EnsureRow(*in.Row); err == nil { |
| + err = h.SetRowHeight(rowID, in.Size) |
| + } |
| + } |
| + if err != nil { |
| + // A size outside the bounds is the caller's fault and a storage |
| + // failure is ours. Both answer the same fixed string: the error |
| + // carries a coordinate the caller already knows and nothing |
| + // else, because it is one hop from a log line (AGENTS.md). |
| + http.Error(w, "That size could not be applied.", http.StatusBadRequest) |
| + return |
| + } |
| + |
| + resp, err := sizesResponse(h, in.Client) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.touchSheet(sheetIDFrom(r)) |
| + a.publish(tabIDFrom(r), resp) |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// sizesResponse builds a payload carrying the tab's sizes and no cells. |
| +// |
| +// The WHOLE size table, not just the one that changed. It is one small |
| +// map — only sized rows and columns appear in it — and sending all of |
| +// it means the client applies rather than merges, so a tab that missed |
| +// a frame while disconnected converges on the next one it sees instead |
| +// of staying one resize behind forever. |
| +func sizesResponse(h *store.Handle, client string) (gridResponse, error) { |
| + cols, rows, err := h.Sizes() |
| + if err != nil { |
| + return gridResponse{}, err |
| + } |
| + return gridResponse{ |
| + // Unchanged: a resize appends no event, so nothing has advanced |
| + // the tab's version. Sending the current one rather than zero |
| + // matters — a client discards a batch whose version trails what |
| + // it has painted. |
| + Version: h.Version(), |
| + Cells: []gridCell{}, |
| + Sizes: &wireSizes{Cols: intKeys(cols), Rows: intKeys(rows)}, |
| + Client: client, |
| + }, nil |
| +} |
| + |
| +// wireSizes is a tab's column widths and row heights on the wire. |
| +// |
| +// Keyed by position rendered as a string, because JSON object keys are |
| +// strings — the same shape and the same reason as the format table. |
| +type wireSizes struct { |
| + Cols map[string]int `json:"cols,omitempty"` |
| + Rows map[string]int `json:"rows,omitempty"` |
| +} |
| + |
| +func intKeys(m map[int]int) map[string]int { |
| + if len(m) == 0 { |
| + return nil |
| + } |
| + out := make(map[string]int, len(m)) |
| + for k, v := range m { |
| + // itoaPos, comments.go — a grid position rendered the same way |
| + // the A1 references are, so a key means the same thing on every |
| + // wire this app has. |
| + out[itoaPos(k)] = v |
| + } |
| + return out |
| +} |
diff --git a/internal/sheets/handlers_size_test.go b/internal/sheets/handlers_size_test.go| new file mode 100644 |
| index 0000000..f72e242 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_size_test.go |
| @@ -0,0 +1,116 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func postSize(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, body string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + path := fmt.Sprintf("/api/sheets/%d/tabs/%d/size", s.ID, s.TabID) |
| + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + return do(t, app, as(req, cookies)) |
| +} |
| + |
| +func TestAColumnWidthSurvivesAReload(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + rec := postSize(t, app, cookies, s, `{"col":2,"size":220}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST size = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + if g := decodeGrid(t, rec); g.Sizes == nil || g.Sizes.Cols["2"] != 220 { |
| + t.Fatalf("the write did not echo the width: %+v", g.Sizes) |
| + } |
| + |
| + // The window GET must carry it too. Fetching sizes separately would |
| + // make every page visibly reflow its columns once after load. |
| + read := decodeGrid(t, do(t, app, |
| + as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| + if read.Sizes == nil || read.Sizes.Cols["2"] != 220 { |
| + t.Errorf("the window does not carry the width: %+v", read.Sizes) |
| + } |
| +} |
| + |
| +func TestARowHeightIsStoredSeparatelyFromAColumnWidth(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + if rec := postSize(t, app, cookies, s, `{"col":0,"size":180}`); rec.Code != http.StatusOK { |
| + t.Fatalf("width = %d", rec.Code) |
| + } |
| + rec := postSize(t, app, cookies, s, `{"row":0,"size":48}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("height = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + g := decodeGrid(t, rec) |
| + if g.Sizes == nil { |
| + t.Fatal("no sizes came back") |
| + } |
| + // Both, from one response: the payload is the whole table, so a tab |
| + // that missed a frame converges on the next one it sees rather than |
| + // staying one resize behind for ever. |
| + if g.Sizes.Cols["0"] != 180 { |
| + t.Errorf("setting a row height lost the column width: %+v", g.Sizes.Cols) |
| + } |
| + if g.Sizes.Rows["0"] != 48 { |
| + t.Errorf("row height = %+v", g.Sizes.Rows) |
| + } |
| +} |
| + |
| +func TestSizeRefusesWhatItCannotStore(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + for name, body := range map[string]string{ |
| + // A column dragged to nothing is one nobody can find again to |
| + // drag back, and this app hides nothing and so unhides nothing. |
| + "zero width": `{"col":0,"size":0}`, |
| + "negative": `{"col":0,"size":-40}`, |
| + "absurdly wide": `{"col":0,"size":99999}`, |
| + "neither axis": `{"size":100}`, |
| + "both axes": `{"col":0,"row":0,"size":100}`, |
| + "column too far": `{"col":100000,"size":100}`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if rec := postSize(t, app, cookies, s, body); rec.Code != http.StatusBadRequest { |
| + t.Errorf("= %d, want 400: %s", rec.Code, rec.Body.String()) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestSizeIsRefusedForAViewer(t *testing.T) { |
| + app, owner := signIn(t) |
| + s, _ := newSheet(t, app, owner, "Budget") |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| + |
| + viewer := signInAs(t, app, "ada@elsewhere.example") |
| + // 404, never 403 — anything else lets somebody enumerate the team's |
| + // sheets by status code. |
| + if rec := postSize(t, app, viewer, s, `{"col":0,"size":200}`); rec.Code != http.StatusNotFound { |
| + t.Errorf("= %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// TestAnUnsizedTabSendsNoSizes keeps the common case free. |
| +// |
| +// Every sheet in this app has no sizes until somebody drags something, |
| +// so an empty object on every window response would be a claim about |
| +// layout nobody made — and the client would have to know that "{}" |
| +// means "leave the library's defaults alone". |
| +func TestAnUnsizedTabSendsNoSizes(t *testing.T) { |
| + app, cookies := signIn(t) |
| + _, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + g := decodeGrid(t, do(t, app, |
| + as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| + if g.Sizes != nil { |
| + t.Errorf("an untouched tab sent sizes: %+v", g.Sizes) |
| + } |
| +} |
diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js| index ce60bd0..d863dc3 100644 |
| --- a/internal/sheets/static/commands.js |
| +++ b/internal/sheets/static/commands.js |
| @@ -31,6 +31,21 @@ |
| return s ? (g.styleAt(s.r, s.c) || {}) : {}; |
| }; |
| + /* format posts a patch for the current selection. |
| + * |
| + * It lives here rather than in grid.js because it is a command, and |
| + * because the patch's shape — only the fields being changed, never |
| + * the whole style — is a decision about what the buttons mean. See |
| + * the Go side's formatPatch for why that distinction is the design |
| + * and not an optimisation. */ |
| + function format(patch) { |
| + var s = g.selection(); |
| + if (!s) return; |
| + var body = { r1: s.r, c1: s.c, r2: s.r2, c2: s.c2 }; |
| + Object.keys(patch).forEach(function (k) { body[k] = patch[k]; }); |
| + g.post('/format', body); |
| + } |
| + |
| var CMDS = { |
| undo: function () { |
| /* The same route the scriptless form posts to, so there is one |
| @@ -42,14 +57,14 @@ |
| body: '' |
| }).then(g.refetch).catch(g.refetch); |
| }, |
| - bold: function () { g.format({ bold: !anchor().b }); }, |
| - italic: function () { g.format({ italic: !anchor().i }); }, |
| - align: function (v) { g.format({ align: anchor().a === v ? '' : v }); }, |
| - numfmt: function (v) { g.format({ numfmt: v }); }, |
| - fill: function (v) { g.format({ fill: v }); }, |
| - ink: function (v) { g.format({ ink: v }); }, |
| + bold: function () { format({ bold: !anchor().b }); }, |
| + italic: function () { format({ italic: !anchor().i }); }, |
| + align: function (v) { format({ align: anchor().a === v ? '' : v }); }, |
| + numfmt: function (v) { format({ numfmt: v }); }, |
| + fill: function (v) { format({ fill: v }); }, |
| + ink: function (v) { format({ ink: v }); }, |
| 'clear-format': function () { |
| - g.format({ bold: false, italic: false, align: '', numfmt: '', fill: 'none', ink: 'none' }); |
| + format({ bold: false, italic: false, align: '', numfmt: '', fill: 'none', ink: 'none' }); |
| }, |
| clear: function () { eachSelected(function (r, c) { g.write(r, c, ''); }); }, |
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js| index 609adaf..e116254 100644 |
| --- a/internal/sheets/static/grid.js |
| +++ b/internal/sheets/static/grid.js |
| @@ -98,6 +98,10 @@ |
| }); |
| } |
| + /* Layout arrives with the batch it belongs to, so a column widened |
| + * in another tab widens here in the same frame the edit lands. */ |
| + if (window.SheetsSizes) window.SheetsSizes.apply(ws, payload.sizes); |
| + |
| applying = true; |
| try { |
| payload.cells.forEach(function (cell) { |
| @@ -132,37 +136,24 @@ |
| .catch(function () { /* the stream's own reconnect will try again */ }); |
| } |
| - /* write sends one cell and paints what comes back. |
| + /* post sends one change and paints what comes back. |
| + * |
| + * One function for all three — a cell, a format patch, a size — |
| + * because all three are the same exchange: send a change, receive the |
| + * batch it produced, paint that. Three copies of it is three places |
| + * for the version check or the failure path to drift. |
| * |
| - * The response is applied even though the grid already shows the |
| + * The response is applied even though the grid may already show the |
| * typed text, because the server owns the displayed string: it is |
| - * what turns "=6*7" into "42", and it is what corrects a value the |
| - * engine read differently from how it looked. */ |
| - function write(r, c, text) { |
| - return fetch(base + '/cells', { |
| - method: 'POST', |
| - credentials: 'same-origin', |
| - headers: { 'Content-Type': 'application/json' }, |
| - body: JSON.stringify({ r: r, c: c, raw: text, client: clientId }) |
| - }).then(function (resp) { |
| - if (!resp.ok) { refetch(); return; } |
| - return resp.json().then(applyBatch); |
| - }).catch(function () { |
| - /* The write may or may not have landed. Refetching is the only |
| - * honest answer: the alternative is leaving the grid showing |
| - * something the server never accepted. */ |
| - refetch(); |
| - }); |
| - } |
| - |
| - /* format posts a patch for the current selection. The caller passes |
| - * only the fields it means to change — see the Go side's formatPatch |
| - * for why that distinction is the whole design. */ |
| - function format(patch) { |
| - if (!sel) return Promise.resolve(); |
| - var body = { r1: sel.r, c1: sel.c, r2: sel.r2, c2: sel.c2, client: clientId }; |
| - Object.keys(patch).forEach(function (k) { body[k] = patch[k]; }); |
| - return fetch(base + '/format', { |
| + * what turns "=6*7" into "42", and what corrects a value the engine |
| + * read differently from how it looked. |
| + * |
| + * Every failure refetches. The write may or may not have landed, and |
| + * refetching is the only honest answer — the alternative is leaving |
| + * the grid showing something the server never accepted. */ |
| + function post(path, body) { |
| + body.client = clientId; |
| + return fetch(base + path, { |
| method: 'POST', |
| credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| @@ -173,6 +164,10 @@ |
| }).catch(refetch); |
| } |
| + function write(r, c, text) { |
| + return post('/cells', { r: r, c: c, raw: text }); |
| + } |
| + |
| function announce() { |
| listeners.forEach(function (fn) { fn(sel); }); |
| } |
| @@ -189,7 +184,7 @@ |
| if (payload.formats) styles = payload.formats; |
| version = payload.version || 0; |
| - var sheet = jspreadsheet(host, { |
| + var opts = { |
| /* Our engine computes; the client paints. See the file comment. */ |
| parseFormulas: false, |
| @@ -232,8 +227,20 @@ |
| allowInsertRow: false, |
| allowInsertColumn: false |
| }] |
| - }); |
| + }; |
| + /* The resize callbacks are SPREADSHEET-level options, like the |
| + * handlers above. sizes.js adds them here, on the way in — nested |
| + * a level deeper they are accepted in silence and never called. */ |
| + if (window.SheetsSizes) { |
| + window.SheetsSizes.extend(opts, { |
| + base: base, client: clientId, |
| + mayWrite: surface.dataset.mayWrite === '1', |
| + paint: applyBatch |
| + }); |
| + } |
| + var sheet = jspreadsheet(host, opts); |
| ws = sheet.worksheets ? sheet.worksheets[0] : sheet[0]; |
| + if (window.SheetsSizes) window.SheetsSizes.apply(ws, payload.sizes); |
| /* The library keeps its own client-side undo stack and its document |
| * keydown is registered before ours. Left on, Ctrl-Z reverts |
| @@ -264,7 +271,7 @@ |
| * server did not render. */ |
| window.SheetsGrid = { |
| write: write, |
| - format: format, |
| + post: post, |
| refetch: refetch, |
| rows: rows, |
| cols: cols, |
diff --git a/internal/sheets/static/sizes.js b/internal/sheets/static/sizes.js| new file mode 100644 |
| index 0000000..9c71226 |
| --- /dev/null |
| +++ b/internal/sheets/static/sizes.js |
| @@ -0,0 +1,83 @@ |
| +/* Column widths and row heights. |
| + * |
| + * Its own module for the same reason paint.js is: grid.js owns the |
| + * library's lifecycle and the selection, and everything else that needs |
| + * a hook there should arrive as one. It also keeps grid.js under the |
| + * 300-line cap without any of the three files having to be terse. |
| + * |
| + * extend() is the seam. grid.js hands it the options object on its way |
| + * into jspreadsheet(), because the resize callbacks are SPREADSHEET- |
| + * level options — nested a level deeper they are accepted in silence |
| + * and never called, which is a mistake this app has already made once |
| + * with onchange and which cost two real browser tabs to find. |
| + */ |
| +(function () { |
| + 'use strict'; |
| + |
| + /* True while we are applying sizes the server sent. The library fires |
| + * its resize callbacks for a programmatic setWidth just as it does |
| + * for a drag, so without this every incoming size is echoed straight |
| + * back — two tabs open, and one person's drag becomes an endless |
| + * round trip between them. */ |
| + var applying = false; |
| + |
| + function apply(ws, sizes) { |
| + if (!ws || !sizes) return; |
| + applying = true; |
| + try { |
| + each(sizes.cols, function (i, px) { if (ws.setWidth) ws.setWidth(i, px); }); |
| + each(sizes.rows, function (i, px) { if (ws.setHeight) ws.setHeight(i, px); }); |
| + } finally { |
| + applying = false; |
| + } |
| + } |
| + |
| + function each(map, fn) { |
| + if (!map) return; |
| + Object.keys(map).forEach(function (k) { |
| + var i = parseInt(k, 10); |
| + var px = map[k]; |
| + if (isFinite(i) && i >= 0 && typeof px === 'number' && px > 0) fn(i, px); |
| + }); |
| + } |
| + |
| + /* extend adds the two resize callbacks to the options grid.js is |
| + * about to construct the sheet with. |
| + * |
| + * ctx carries what this module cannot see: where to post, who we are, |
| + * whether this viewer may write, and how to paint a response. */ |
| + function extend(opts, ctx) { |
| + if (!ctx.mayWrite) return opts; |
| + |
| + opts.onresizecolumn = function (instance, col, width) { |
| + post(ctx, { col: parseInt(col, 10), size: Math.round(width) }); |
| + }; |
| + opts.onresizerow = function (instance, row, height) { |
| + post(ctx, { row: parseInt(row, 10), size: Math.round(height) }); |
| + }; |
| + return opts; |
| + } |
| + |
| + function post(ctx, body) { |
| + if (applying) return; |
| + if (!isFinite(body.size) || body.size <= 0) return; |
| + body.client = ctx.client; |
| + fetch(ctx.base + '/size', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/json' }, |
| + body: JSON.stringify(body) |
| + }).then(function (resp) { |
| + /* A refused resize is not repainted from the response — the |
| + * column is already the width the person dragged it to, and |
| + * snapping it back under their cursor is worse than leaving a |
| + * width the server did not keep. The next reload is the |
| + * correction, and a refusal here means a bound was hit, which is |
| + * visible in the drag itself. */ |
| + if (!resp.ok) return; |
| + return resp.json().then(function (payload) { ctx.paint(payload); }); |
| + }).catch(function () { /* the stream's own reconnect will resync */ }); |
| + } |
| + |
| + window.SheetsSizes = { apply: apply, extend: extend }; |
| +}()); |
diff --git a/internal/sheets/store/handle_sizes.go b/internal/sheets/store/handle_sizes.go| new file mode 100644 |
| index 0000000..2aff9d3 |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_sizes.go |
| @@ -0,0 +1,111 @@ |
| +package store |
| + |
| +import "fmt" |
| + |
| +// Column widths and row heights. |
| +// |
| +// Row.Height and Col.Width have been on the models since the first |
| +// schema and nothing has ever written one. The consequence is visible |
| +// on the first screen anybody opens: a column headed "Invoice Quarter" |
| +// shows "Invoice Qua", and no amount of dragging fixes it because the |
| +// drag has nowhere to be saved. A spreadsheet whose columns cannot be |
| +// widened is not one. |
| +// |
| +// Sizes are per TAB and stored against the stable row and column ids, |
| +// like everything else here — so widening a column and then inserting |
| +// one to its left keeps the width on the column somebody widened. |
| +// |
| +// Zero means "no size of its own", not "zero pixels". A tab with no |
| +// sizes at all is the common case and costs nothing: the client leaves |
| +// the library's defaults alone rather than being told the default |
| +// twice. |
| + |
| +// minCellSize and maxCellSize bound what may be stored. |
| +// |
| +// The floor is above zero on purpose: a column dragged to nothing is a |
| +// column nobody can find again to drag back, and the grid offers no |
| +// "unhide" because nothing here hides. The ceiling stops one column |
| +// from being wider than any screen, which has the same effect as |
| +// hiding every column after it. |
| +const ( |
| + minCellSize = 16 |
| + maxCellSize = 2000 |
| +) |
| + |
| +// Sizes reads a tab's column widths and row heights, keyed by grid |
| +// POSITION rather than stable id. |
| +// |
| +// Position, because the caller is painting a grid and has no use for |
| +// ids — the same reasoning as gridCell's R and C. Only non-zero entries |
| +// are returned, so the map is empty for the ordinary unsized tab. |
| +func (h *Handle) Sizes() (cols map[int]int, rows map[int]int, err error) { |
| + cols, rows = map[int]int{}, map[int]int{} |
| + |
| + var colRows []Col |
| + if err := h.db.Select("position", "width"). |
| + Where("tab_id = ? AND width > 0", h.tabID).Find(&colRows).Error; err != nil { |
| + return nil, nil, fmt.Errorf("store: read column widths: %w", err) |
| + } |
| + for _, c := range colRows { |
| + cols[c.Position] = c.Width |
| + } |
| + |
| + var rowRows []Row |
| + if err := h.db.Select("position", "height"). |
| + Where("tab_id = ? AND height > 0", h.tabID).Find(&rowRows).Error; err != nil { |
| + return nil, nil, fmt.Errorf("store: read row heights: %w", err) |
| + } |
| + for _, r := range rowRows { |
| + rows[r.Position] = r.Height |
| + } |
| + return cols, rows, nil |
| +} |
| + |
| +// SetColWidth stores a column's width in pixels. |
| +// |
| +// The column must already be part of the tab — callers reach it through |
| +// EnsureCol, the same way a write does. Sizing a column that does not |
| +// exist yet is a real thing to want (widen an empty column before |
| +// typing in it), and EnsureCol is what makes the coordinate real; doing |
| +// it here instead would put grid positions back into a method that |
| +// otherwise speaks only in stable ids. |
| +func (h *Handle) SetColWidth(colID uint64, width int) error { |
| + if err := checkSize(width); err != nil { |
| + return err |
| + } |
| + return h.updateSize(&Col{}, "width", colID, width, "column") |
| +} |
| + |
| +// SetRowHeight is SetColWidth's twin. |
| +func (h *Handle) SetRowHeight(rowID uint64, height int) error { |
| + if err := checkSize(height); err != nil { |
| + return err |
| + } |
| + return h.updateSize(&Row{}, "height", rowID, height, "row") |
| +} |
| + |
| +func checkSize(v int) error { |
| + if v < minCellSize || v > maxCellSize { |
| + return fmt.Errorf("store: size %d is outside %d..%d", v, minCellSize, maxCellSize) |
| + } |
| + return nil |
| +} |
| + |
| +// updateSize writes one size, scoped to this tab. |
| +// |
| +// Scoped to the tab as well as the id, not because the id is |
| +// ambiguous — it is a primary key — but because it makes the query |
| +// refuse to write to another tab's row if a caller ever passes an id |
| +// from the wrong handle. The id alone would happily do it. |
| +func (h *Handle) updateSize(model any, column string, id uint64, v int, what string) error { |
| + res := h.db.Model(model). |
| + Where("id = ? AND tab_id = ?", id, h.tabID). |
| + Update(column, v) |
| + if res.Error != nil { |
| + return fmt.Errorf("store: set %s size: %w", what, res.Error) |
| + } |
| + if res.RowsAffected == 0 { |
| + return fmt.Errorf("store: %s %d is not part of tab %d", what, id, h.tabID) |
| + } |
| + return nil |
| +} |
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html| index 375910c..5729403 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -11,6 +11,7 @@ |
| <script defer src="{{asset "static/vendor/jspreadsheet.js"}}"></script> |
| <script defer src="{{asset "static/live.js"}}"></script> |
| <script defer src="{{asset "static/paint.js"}}"></script> |
| +<script defer src="{{asset "static/sizes.js"}}"></script> |
| <script defer src="{{asset "static/grid.js"}}"></script> |
| <script defer src="{{asset "static/menubar.js"}}"></script> |
| <script defer src="{{asset "static/formulabar.js"}}"></script> |
diff --git a/tools/drive/chrome.mjs b/tools/drive/chrome.mjs| index 2d6add5..41a5bf1 100644 |
| --- a/tools/drive/chrome.mjs |
| +++ b/tools/drive/chrome.mjs |
| @@ -161,6 +161,22 @@ await page.waitForTimeout(250); |
| const jumped = await page.evaluate(() => { const s = window.SheetsGrid.selection(); return `${s.r},${s.c}`; }); |
| check('typing a reference moves the selection', jumped === '1,1', `selection is ${jumped}`); |
| +console.log('\n— column widths'); |
| +/* Resize through the library the way a drag does, then prove it survived |
| + * a reload: a width the server did not keep looks identical until the |
| + * page comes back. */ |
| +await page.evaluate(() => window.SheetsGrid.worksheet().setWidth(0, 220)); |
| +await page.waitForTimeout(500); |
| +await page.reload(); |
| +await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); |
| +await page.waitForTimeout(700); |
| +const w = await page.evaluate(() => { |
| + const td = document.querySelector('[data-sheet-grid] thead td[data-x="0"]') |
| + || window.SheetsGrid.worksheet().records[0][0].element; |
| + return Math.round(td.getBoundingClientRect().width); |
| +}); |
| +check('a column width survives a reload', Math.abs(w - 220) <= 3, `column A is ${w}px, want ~220`); |
| + |
| console.log('\n— export'); |
| const dl = await page.request.get(`${url}/export.xlsx`); |
| const buf = await dl.body(); |