oficina / sheets Public

fix(clipboard): paste dropped cells, and no copy ever offset a formula

Two dead menu items, a lossy paste, and spec §4.2's translation pass
never wired. All four found by clicking every menu item in a real
browser (tools/drive/menus.mjs); none of them was visible to the suite.

**§4.2 was never implemented.** The spec rules: "Copy / drag-fill —
Translation pass at paste time. Relative refs are re-resolved by offset
from the paste anchor; absolute ($A$1) refs resolve to the same id.
Unavoidable — it is what relative means." calc.Translate has existed
with tests since the engine landed and had NO CALLER. So filling
`=A1*2` down B1:B3 wrote `=A1*2` into all three cells and the column
read 20, 20, 20 where it should read 20, 40, 60. Every Go test passed,
because every Go test wrote a cell rather than copying one.

The offset now arrives on the cell write itself — `fromR`/`fromC`,
pointers so that "copied from row 0" and "not a copy" are different
requests — and one code path stays responsible for a cell changing.
calc gains TranslateChecked beside Translate, because Render is happy
to render a reference that does not exist: ColLabel(-1) is the empty
string, so `=A1` copied one column left rendered as `=1`, a formula
silently replaced by the number one. Off-grid now writes #REF! as text,
which is what Excel shows; the difference from an error VALUE is real
and named in copypaste.go, since calc's grammar has no error literal.

**Cut, Copy and Paste are ours now.** ws.copy() threw on every call,
ws.copy(true) silently did nothing, and there was no Paste at all —
while TestEveryMenuCommandIsImplemented stayed green throughout, because
a command in the dispatch table is not a command that works.

Worse, the library's own Ctrl+V lost cells. Copying A1:B3 and pasting at
F1 lost F3; pasting two rows from outside lost the whole first row. The
survivors arrived through onchange and the casualties were written by a
path that does not fire it, so they never reached the server — and the
grid painted them anyway, so a pasted cell looked pasted until reload.

clipboard.js takes copy, cut and paste at the capture phase, stops the
library's handlers, and writes every cell through the same POST a
keystroke uses. TSV with proper quoting, so a cell holding a tab or a
newline round-trips instead of becoming two rows. A remembered origin,
claimed only on an exact match of the text we put on the clipboard, so
an in-app paste translates and a paste from anywhere else does not.

**One bug of my own, found the same way.** Writes go out six at a time;
their responses come back in whatever order the server finishes them,
and grid.js correctly discards a paint older than one already applied.
A six-cell paste landed five on screen — safely stored, never painted,
indistinguishable from lost. One refetch after the batch, and the reason
is written where the next person will change it.

Verified end to end in Chromium: tools/drive/clipboard.mjs, eight checks,
exits non-zero on any failure. `make test` green including -race.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
Paul Campbell pushed by paul@keymail.dev f2a753c6fe8b8680ccc789f461148fdded753806 parent 1434f46
9 files changed, +839 −6
  • internal/sheets/calc/translate.go +51 −0
  • internal/sheets/copypaste.go +69 −0
  • internal/sheets/handlers_copy_test.go +208 −0
  • internal/sheets/handlers_grid.go +47 −1
  • internal/sheets/static/clipboard.js +298 −0
  • internal/sheets/static/commands.js +23 −4
  • internal/sheets/static/sheets.css +20 −1
  • internal/sheets/templates/sheet.html +17 −0
  • tools/drive/clipboard.mjs +106 −0

CI passed — run details

Intent

Attributed to this commit: 361 g CO₂e (42547005 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on fix/clipboard-and-translation, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/internal/sheets/calc/translate.go b/internal/sheets/calc/translate.go
index 7421ab7..38d0a47 100644
--- a/internal/sheets/calc/translate.go
+++ b/internal/sheets/calc/translate.go
@@ -42,6 +42,57 @@ func Translate(n Node, dRow, dCol int) Node {
}
}
+// TranslateChecked is Translate plus the bounds check its callers cannot
+// perform afterwards, and the reason it exists is that Render is happy to
+// render a reference that does not exist.
+//
+// A relative reference can be offset off the top or the left of the grid:
+// =A1 copied one column left has nowhere to point, and Excel writes #REF!
+// there. Ours did something worse. ColLabel(-1) returns the EMPTY STRING
+// and RowPos+1 is 0, so Translate followed by Render turned `=A1` into `=1`
+// — a formula replaced by the number one, which reads as a value somebody
+// typed and is the quiet kind of wrong nothing downstream can detect.
+//
+// So the check belongs beside the translation rather than at the call site:
+// the caller has the source text and the offset, not the tree, and by the
+// time it has a string again the evidence is gone.
+//
+// ok is false if ANY reference landed off-grid, including one nested in a
+// range or a call's argument. The tree is still returned, for a caller that
+// wants to report which part failed; nothing should render it.
+func TranslateChecked(n Node, dRow, dCol int) (Node, bool) {
+ out := Translate(n, dRow, dCol)
+ return out, onGrid(out)
+}
+
+// onGrid reports whether every reference in n has a non-negative row and
+// column. It walks the same shapes Translate does, and deliberately in the
+// same order, so a node type added to one and not the other is a compile
+// error in Translate and a silently unchecked branch here — which is why
+// the default case returns true only for the leaf types that hold no
+// reference at all.
+func onGrid(n Node) bool {
+ switch t := n.(type) {
+ case *Ref:
+ return t.RowPos >= 0 && t.ColPos >= 0
+ case *Range:
+ return onGrid(t.From) && onGrid(t.To)
+ case *Binary:
+ return onGrid(t.Left) && onGrid(t.Right)
+ case *Unary:
+ return onGrid(t.X)
+ case *Call:
+ for _, a := range t.Args {
+ if !onGrid(a) {
+ return false
+ }
+ }
+ return true
+ default:
+ return true // Num, Str, Bool have no references
+ }
+}
+
// Render is the inverse of Parse for the node shapes Parse can produce:
// numbers, strings (re-quoted with `"` doubled), booleans, references and
// ranges (with both absolute markers preserved in position), unary minus,
diff --git a/internal/sheets/copypaste.go b/internal/sheets/copypaste.go
new file mode 100644
index 0000000..5658a14
--- /dev/null
+++ b/internal/sheets/copypaste.go
@@ -0,0 +1,69 @@
+package sheets
+
+import (
+ "strings"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+)
+
+// Copy and fill: spec §4.2's translation pass.
+//
+// > **Copy / drag-fill** — Translation pass at paste time. Relative refs
+// > are re-resolved by offset from the paste anchor; absolute (`$A$1`)
+// > refs resolve to the same id. Unavoidable — it is what relative
+// > means.
+//
+// This is the pass stable row and column identity does NOT remove, and
+// the spec says so in the same paragraph. calc.Translate has existed
+// since the engine landed and had no caller until now, so filling
+// `=A1*2` down a column wrote `=A1*2` into every cell: the grid showed
+// the same number three times and nothing in the suite disagreed,
+// because every test wrote a cell rather than copying one.
+//
+// The offset arrives on the cell write itself rather than through a
+// route of its own. A paste is n cell writes, each of which already
+// carries its destination; naming the cell it came from is one more
+// field on a body that is already explicit and already allowlisted, and
+// it keeps one code path for "a cell changed" rather than two that can
+// disagree about sealing, versioning or fan-out.
+
+// translateCopied rewrites a copied cell's raw input for its new home.
+//
+// dRow and dCol are the destination minus the origin. Everything that
+// is not a formula comes back untouched — a text cell reading "A1 and
+// B2" is not a reference and rewriting it would corrupt the one kind of
+// cell that cannot need this.
+//
+// A formula that will not parse also comes back untouched, which is
+// deliberate: the write then fails exactly as it would have if the same
+// text had been typed, rather than failing differently because it
+// arrived by paste.
+func translateCopied(raw string, dRow, dCol int) string {
+ if dRow == 0 && dCol == 0 {
+ return raw
+ }
+ if !strings.HasPrefix(raw, "=") {
+ return raw
+ }
+ n, err := calc.Parse(raw[1:])
+ if err != nil {
+ return raw
+ }
+ out, ok := calc.TranslateChecked(n, dRow, dCol)
+ if !ok {
+ // Off the top or the left of the grid. Excel writes #REF! and so
+ // do we, as LITERAL TEXT and not as an error value: calc's parser
+ // has no error literal, so there is no formula source that
+ // evaluates to calc.ErrRef, and inventing one is a change to the
+ // grammar rather than to this function.
+ //
+ // The difference is real and worth knowing: =SUM() over a cell
+ // holding the text "#REF!" treats it as text, where Excel would
+ // propagate the error. It is visible to the person either way,
+ // which is the property that matters most here, and the cell is
+ // written rather than skipped — a paste that silently drops a
+ // cell is the failure this whole change exists to remove.
+ return calc.ErrRef.String()
+ }
+ return "=" + calc.Render(out)
+}
diff --git a/internal/sheets/handlers_copy_test.go b/internal/sheets/handlers_copy_test.go
new file mode 100644
index 0000000..d39effe
--- /dev/null
+++ b/internal/sheets/handlers_copy_test.go
@@ -0,0 +1,208 @@
+package sheets
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+)
+
+// The tests for spec §4.2's translation pass — "Copy / drag-fill →
+// Translation pass at paste time. Relative refs are re-resolved by
+// offset from the paste anchor; absolute ($A$1) refs resolve to the
+// same id."
+//
+// calc.Translate has existed, with tests, since the engine landed. What
+// did not exist was any caller. Measured in a browser on 2026-09-03:
+// filling `=A1*2` down B1:B3 wrote `=A1*2` into all three cells and the
+// column read 20, 20, 20 where it should read 20, 40, 60. Every Go test
+// passed, because every Go test wrote a cell rather than copying one.
+//
+// So these tests are written at the HTTP boundary, which is where the
+// offset now arrives, and not at calc — calc was never wrong.
+
+// pasteInto POSTs a copied cell to (r, c), naming the cell it came from.
+func pasteInto(t *testing.T, app *App, cookies []*http.Cookie, cells string, r, c, fromR, fromC int, raw string) gridResponse {
+ t.Helper()
+ body := `{"r":` + strconv.Itoa(r) + `,"c":` + strconv.Itoa(c) +
+ `,"fromR":` + strconv.Itoa(fromR) + `,"fromC":` + strconv.Itoa(fromC) +
+ `,"raw":` + strconv.Quote(raw) + `}`
+ rec := postCell(t, app, cookies, cells, body)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("paste to %d,%d = %d: %s", r, c, rec.Code, rec.Body.String())
+ }
+ return decodeGrid(t, rec)
+}
+
+func TestCopyingAFormulaDownOffsetsItsRelativeReferences(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Fill")
+
+ for r, v := range []string{"10", "20", "30"} {
+ postCell(t, app, cookies, cells, `{"r":`+strconv.Itoa(r)+`,"c":0,"raw":"`+v+`"}`)
+ }
+ postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"=A1*2"}`)
+
+ // B1 filled down into B2 and B3 — one row and two rows below its
+ // origin, so A1 becomes A2 and A3.
+ for _, tc := range []struct {
+ row int
+ wantRaw, wantDisplay string
+ }{
+ {1, "=A2*2", "40"},
+ {2, "=A3*2", "60"},
+ } {
+ got := pasteInto(t, app, cookies, cells, tc.row, 1, 0, 1, "=A1*2")
+ if len(got.Cells) == 0 {
+ t.Fatalf("row %d: the paste returned no cells", tc.row)
+ }
+ cell := got.Cells[0]
+ if cell.Raw != tc.wantRaw {
+ t.Errorf("filled into row %d: raw = %q, want %q — the formula bar "+
+ "must show the reference the cell actually uses",
+ tc.row, cell.Raw, tc.wantRaw)
+ }
+ if cell.Display != tc.wantDisplay {
+ t.Errorf("filled into row %d: display = %q, want %q",
+ tc.row, cell.Display, tc.wantDisplay)
+ }
+ }
+}
+
+func TestCopyingAFormulaAcrossOffsetsTheColumn(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Across")
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`)
+ postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"9"}`)
+ postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1+1"}`)
+
+ got := pasteInto(t, app, cookies, cells, 1, 1, 1, 0, "=A1+1")
+ if got.Cells[0].Raw != "=B1+1" {
+ t.Errorf("copied one column right: raw = %q, want %q", got.Cells[0].Raw, "=B1+1")
+ }
+ if got.Cells[0].Display != "10" {
+ t.Errorf("display = %q, want %q", got.Cells[0].Display, "10")
+ }
+}
+
+func TestAnAbsoluteReferenceSurvivesTheCopy(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Absolute")
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"100"}`)
+ postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"3"}`)
+ postCell(t, app, cookies, cells, `{"r":1,"c":1,"raw":"5"}`)
+
+ // =$A$1*B1 copied down: the absolute half must not move and the
+ // relative half must. That is the whole meaning of the dollar sign,
+ // and a Translate that offset both would still pass a test written
+ // with only relative references in it.
+ got := pasteInto(t, app, cookies, cells, 1, 2, 0, 2, "=$A$1*B1")
+ if got.Cells[0].Raw != "=$A$1*B2" {
+ t.Errorf("raw = %q, want %q — $A$1 must stay put and B1 must move",
+ got.Cells[0].Raw, "=$A$1*B2")
+ }
+ if got.Cells[0].Display != "500" {
+ t.Errorf("display = %q, want %q", got.Cells[0].Display, "500")
+ }
+}
+
+func TestPastingInPlaceChangesNothing(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "InPlace")
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"4"}`)
+ got := pasteInto(t, app, cookies, cells, 1, 1, 1, 1, "=A1*2")
+ if got.Cells[0].Raw != "=A1*2" {
+ t.Errorf("a zero offset rewrote the formula: %q", got.Cells[0].Raw)
+ }
+}
+
+func TestALiteralIsCopiedVerbatim(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Literal")
+
+ // Text that looks like it might contain a reference. Only a cell
+ // whose raw input starts with "=" is a formula, and translating
+ // anything else would corrupt the one kind of cell that cannot
+ // possibly need it.
+ got := pasteInto(t, app, cookies, cells, 5, 5, 0, 0, "A1 and B2")
+ if got.Cells[0].Raw != "A1 and B2" {
+ t.Errorf("a text cell was translated: %q", got.Cells[0].Raw)
+ }
+}
+
+// TestCopyingAFormulaOffTheGridGivesREF pins the edge Excel names.
+//
+// =A1 copied one column left has nowhere to point. Rendering it anyway
+// is the dangerous outcome and was the one this code had before the
+// check: calc.ColLabel(-1) returns the empty string, so `=A1` rendered
+// as `=1` — a formula silently replaced by the number one, which looks
+// like a value somebody typed.
+func TestCopyingAFormulaOffTheGridGivesREF(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "OffGrid")
+
+ for _, tc := range []struct {
+ name string
+ r, c, fromR, fromC int
+ raw string
+ }{
+ {"one column left of column A", 0, 0, 0, 1, "=A1"},
+ {"one row above row 1", 0, 0, 1, 0, "=A1"},
+ {"inside a call", 0, 0, 2, 0, "=SUM(A1:A3)"},
+ } {
+ got := pasteInto(t, app, cookies, cells, tc.r, tc.c, tc.fromR, tc.fromC, tc.raw)
+ if len(got.Cells) == 0 {
+ t.Fatalf("%s: the paste returned no cells", tc.name)
+ }
+ if got.Cells[0].Display != "#REF!" {
+ t.Errorf("%s: display = %q, want %q", tc.name, got.Cells[0].Display, "#REF!")
+ }
+ }
+}
+
+// TestAnUntranslatableFormulaIsStillWritten is the other half of the
+// case above, and the reason it is a separate test: the failure mode
+// worth guarding is not "the cell says #REF!" but "the write was
+// refused and the cell kept whatever was in it", which would leave a
+// paste half-applied.
+func TestAnUntranslatableFormulaIsStillWritten(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Refused")
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"keep me"}`)
+ pasteInto(t, app, cookies, cells, 0, 0, 1, 0, "=A1")
+
+ read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ for _, c := range read.Cells {
+ if c.R == 0 && c.C == 0 {
+ if c.Display == "keep me" {
+ t.Fatal("the paste was refused and the old value survived — a paste " +
+ "that silently skips a cell is worse than one that writes #REF!")
+ }
+ return
+ }
+ }
+ t.Fatal("A1 is absent after the paste")
+}
+
+// TestAnOutOfRangeOriginIsRefused: fromR/fromC are attacker-controlled
+// like every other field on this route, and a wild offset would ask
+// calc to translate by millions and render a reference nothing can
+// parse back.
+func TestAnOutOfRangeOriginIsRefused(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Hostile")
+
+ for _, body := range []string{
+ `{"r":0,"c":0,"fromR":-1,"fromC":0,"raw":"=A1"}`,
+ `{"r":0,"c":0,"fromR":0,"fromC":-5,"raw":"=A1"}`,
+ `{"r":0,"c":0,"fromR":99999999,"fromC":0,"raw":"=A1"}`,
+ } {
+ if rec := postCell(t, app, cookies, cells, body); rec.Code != http.StatusBadRequest {
+ t.Errorf("%s = %d, want 400", body, rec.Code)
+ }
+ }
+}
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go
index cc2dcfa..526a2ce 100644
--- a/internal/sheets/handlers_grid.go
+++ b/internal/sheets/handlers_grid.go
@@ -28,6 +28,42 @@ type cellWrite struct {
C int `json:"c"`
Raw string `json:"raw"`
Client string `json:"client"`
+
+ // FromR and FromC name the cell this input was COPIED FROM, and
+ // turn an ordinary write into spec §4.2's translation pass: every
+ // relative reference in Raw is offset by the destination minus the
+ // origin before anything is stored. Absent — the ordinary case,
+ // somebody typing — nothing is translated.
+ //
+ // Pointers, so "copied from row 0" and "not a copy" are different
+ // requests. With plain ints they are the same zero value, and every
+ // paste into row 0 or column A would translate by its full offset
+ // from a cell nobody copied.
+ FromR *int `json:"fromR"`
+ FromC *int `json:"fromC"`
+}
+
+// origin validates the copy origin and returns the offset to apply.
+//
+// The bounds are the same ones the destination is checked against, and
+// for the same reason: both come from the request. An unbounded origin
+// asks calc to offset a reference by millions and Render to print a
+// column label with six letters in it, which parses back as a name.
+func (in cellWrite) origin() (dRow, dCol int, ok bool) {
+ if in.FromR == nil || in.FromC == nil {
+ return 0, 0, true
+ }
+ if !inCellRange(*in.FromR, *in.FromC) {
+ return 0, 0, false
+ }
+ return in.R - *in.FromR, in.C - *in.FromC, true
+}
+
+// inCellRange is the one definition of "a coordinate this route will
+// accept", used for the destination and for the copy origin. Two copies
+// of it is two places for the bound to drift.
+func inCellRange(r, c int) bool {
+ return r >= 0 && c >= 0 && r < maxWindowRows*10 && c < maxWindowCols*10
}
// handleCellsGet serves a window of a tab.
@@ -78,7 +114,7 @@ func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Malformed request.", http.StatusBadRequest)
return
}
- if in.R < 0 || in.C < 0 || in.R >= maxWindowRows*10 || in.C >= maxWindowCols*10 {
+ if !inCellRange(in.R, in.C) {
http.Error(w, "Cell out of range.", http.StatusBadRequest)
return
}
@@ -86,6 +122,16 @@ func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
return
}
+ // §4.2's translation pass, before the length check has any more
+ // work to do and before anything reaches the store: what is written
+ // and what the formula bar later shows must be the same string, so
+ // the rewrite happens here and not on the way back out.
+ dRow, dCol, ok := in.origin()
+ if !ok {
+ http.Error(w, "Cell out of range.", http.StatusBadRequest)
+ return
+ }
+ in.Raw = translateCopied(in.Raw, dRow, dCol)
// EnsureRow/EnsureCol so typing into a cell nothing has touched
// works: a tab starts with no rows or columns at all, and a grid
diff --git a/internal/sheets/static/clipboard.js b/internal/sheets/static/clipboard.js
new file mode 100644
index 0000000..7685175
--- /dev/null
+++ b/internal/sheets/static/clipboard.js
@@ -0,0 +1,298 @@
+/* Cut, copy and paste.
+ *
+ * Ours, not the vendored grid's, and that is a fix rather than a
+ * preference. Measured in a real browser on 2026-09-03:
+ *
+ * - the Copy menu item threw inside the library and did nothing;
+ * - the Cut menu item silently did nothing;
+ * - there was no Paste anywhere;
+ * - and the library's own Ctrl+V DROPPED CELLS. Copying A1:B3 and
+ * pasting at F1 lost F3; pasting two rows from outside the app lost
+ * the whole first row. The cells that survived arrived through
+ * onchange; the ones that vanished were written by a path that does
+ * not fire it, so they never reached the server and never existed.
+ *
+ * Silent data loss on paste is the worst of those by a distance, and it
+ * is invisible: the grid paints what the library set, so the cell looks
+ * pasted until somebody reloads.
+ *
+ * So this module takes the three clipboard events at the CAPTURE phase
+ * and stops them, and every cell it writes goes through the same POST a
+ * keystroke uses. One write path, one place for sealing, versioning and
+ * fan-out to happen.
+ *
+ * It also carries spec §4.2's translation pass. A pasted formula names
+ * the cell it came from, and the server offsets its relative references
+ * — so pasting `=A1*2` one row down stores `=A2*2`, which is what
+ * copying a formula has always meant.
+ */
+(function () {
+ 'use strict';
+
+ var surface = document.querySelector('[data-sheet-surface]');
+ if (!surface) return;
+
+ /* A paste is one POST per cell, so this is a real bound and not a
+ * defensive round number: it is the point past which the honest
+ * answer is "that is too much to paste" rather than a progress bar
+ * this app has nowhere to put. */
+ var MAX_CELLS = 2000;
+
+ /* How many writes are in flight at once. One at a time turns a
+ * 200-cell paste into 200 round trips end to end; all at once opens
+ * 200 sockets against a store whose writer pool is a single
+ * connection anyway. */
+ var LANES = 6;
+
+ function ready() {
+ var g = window.SheetsGrid;
+ if (!g) return;
+
+ var note = document.querySelector('[data-sheet-note]');
+
+ /* say is the only way this module talks. It exists because the menu
+ * has a Paste item and the menu cannot always paste: reading the
+ * clipboard from a button is a permission the browser may refuse,
+ * where Ctrl+V hands us the data with no permission at all. Told
+ * that, a person presses Ctrl+V; not told, the menu item is exactly
+ * the dead control this app has a rule against. */
+ var clearNote = null;
+ function say(text) {
+ if (!note) return;
+ note.textContent = text;
+ if (clearNote) clearTimeout(clearNote);
+ clearNote = setTimeout(function () { note.textContent = ''; }, 6000);
+ }
+
+ /* lastCopy is how a paste knows where it came from.
+ *
+ * The clipboard carries text and no provenance, so the origin is
+ * remembered here and claimed only on an EXACT match of the text we
+ * put there. Anything else — a paste from another app, another
+ * tab, or a clipboard that has moved on — has no origin and is
+ * pasted verbatim, which is right: text from outside has no
+ * references to translate. */
+ var lastCopy = null;
+
+ function box() {
+ var s = g.selection();
+ if (!s) return null;
+ return {
+ r1: Math.min(s.r, s.r2), c1: Math.min(s.c, s.c2),
+ r2: Math.max(s.r, s.r2), c2: Math.max(s.c, s.c2)
+ };
+ }
+
+ /* ---- TSV ---------------------------------------------------------
+ *
+ * Tab-separated, with the quoting every spreadsheet uses for a value
+ * holding a tab, a newline or a quote. Without it a single cell of
+ * wrapped text turns one row into two on the way back in, which is
+ * the same silent corruption in a new place. */
+ function encode(v) {
+ if (!/[\t\n\r"]/.test(v)) return v;
+ return '"' + v.replace(/"/g, '""') + '"';
+ }
+
+ function toTSV(b) {
+ var rows = [], r, c, row;
+ for (r = b.r1; r <= b.r2; r++) {
+ for (row = [], c = b.c1; c <= b.c2; c++) row.push(encode(g.rawAt(r, c)));
+ rows.push(row.join('\t'));
+ }
+ return rows.join('\n');
+ }
+
+ /* A character-at-a-time reader rather than a split: a quoted field
+ * may contain the very characters a split would break on, which is
+ * the whole reason it is quoted. */
+ function fromTSV(text) {
+ var rows = [], row = [], field = '', quoted = false, i = 0, ch;
+ for (; i < text.length; i++) {
+ ch = text[i];
+ if (quoted) {
+ if (ch !== '"') { field += ch; continue; }
+ if (text[i + 1] === '"') { field += '"'; i++; continue; }
+ quoted = false;
+ continue;
+ }
+ if (ch === '"' && field === '') { quoted = true; continue; }
+ if (ch === '\t') { row.push(field); field = ''; continue; }
+ if (ch === '\r') continue;
+ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
+ field += ch;
+ }
+ row.push(field);
+ rows.push(row);
+ /* A trailing newline is a line ending, not an empty last row —
+ * every editor writes one and pasting it would clear a row. */
+ if (rows.length > 1 && rows[rows.length - 1].length === 1 &&
+ rows[rows.length - 1][0] === '') rows.pop();
+ return rows;
+ }
+
+ /* ---- writing -----------------------------------------------------
+ *
+ * g.post rather than g.write, because a copied cell carries two more
+ * fields. The route is the same one a keystroke posts to.
+ *
+ * The refetch at the end is NOT belt and braces, and dropping it
+ * loses cells on screen. grid.js discards a paint whose version is
+ * older than one already applied — correct, and what keeps two
+ * editors from painting a stale grid — but writes in flight together
+ * come back in whatever order the server finishes them, so the last
+ * response to arrive can be the oldest. Measured: a six-cell paste
+ * landed five. Every cell was safely stored; one simply never
+ * painted, which looks identical to a cell that was never sent and
+ * is exactly the failure this module exists to remove.
+ *
+ * One snapshot after the batch, rather than one write at a time: a
+ * 200-cell paste done strictly in series is 200 sequential round
+ * trips, and this is one extra. */
+ function writeAll(jobs, done) {
+ if (!jobs.length) { if (done) done(); return; }
+ var next = 0, live = 0;
+ function pump() {
+ while (live < LANES && next < jobs.length) {
+ live++;
+ g.post('/cells', jobs[next++]).then(step, step);
+ }
+ if (live === 0 && next >= jobs.length) {
+ g.refetch();
+ if (done) done();
+ }
+ }
+ function step() { live--; pump(); }
+ pump();
+ }
+
+ /* ---- the commands ------------------------------------------------ */
+
+ function copyText() {
+ var b = box();
+ if (!b) return null;
+ var text = toTSV(b);
+ lastCopy = { text: text, r: b.r1, c: b.c1 };
+ return text;
+ }
+
+ function clearBox(b) {
+ var jobs = [], r, c;
+ for (r = b.r1; r <= b.r2; r++) {
+ for (c = b.c1; c <= b.c2; c++) {
+ if (g.rawAt(r, c) !== '') jobs.push({ r: r, c: c, raw: '' });
+ }
+ }
+ writeAll(jobs);
+ }
+
+ function paste(text) {
+ var b = box();
+ if (!b || !text) return;
+ var rows = fromTSV(text);
+ var from = (lastCopy && lastCopy.text === text) ? lastCopy : null;
+ var count = rows.length * (rows[0] ? rows[0].length : 0);
+ if (count > MAX_CELLS) {
+ say('That is ' + count + ' cells, and ' + MAX_CELLS + ' is the most this can paste at once.');
+ return;
+ }
+ var jobs = [], i, j, r, c, cell;
+ for (i = 0; i < rows.length; i++) {
+ for (j = 0; j < rows[i].length; j++) {
+ r = b.r1 + i;
+ c = b.c1 + j;
+ if (r >= g.rows || c >= g.cols) continue;
+ cell = { r: r, c: c, raw: rows[i][j] };
+ /* The origin is the SOURCE cell for this destination, so the
+ * server's offset is the same for every cell in the block —
+ * which is what makes a pasted block's formulas agree with
+ * each other. */
+ if (from) { cell.fromR = from.r + i; cell.fromC = from.c + j; }
+ jobs.push(cell);
+ }
+ }
+ writeAll(jobs, function () { g.focus(); });
+ }
+
+ /* ---- the events --------------------------------------------------
+ *
+ * Capture, on document, so this runs before the vendored grid's own
+ * document-level handlers and can stop them. stopImmediatePropagation
+ * and not stopPropagation: the library may well have registered on
+ * the same node, and only the first stops a listener sharing it.
+ *
+ * A text field is left entirely alone. Ctrl+C in the formula bar
+ * belongs to the formula bar, and taking it would make the one
+ * reliable way to read a formula unable to copy one. */
+ function inTextField(el) {
+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ||
+ el.isContentEditable);
+ }
+
+ function own(e) {
+ if (inTextField(e.target)) return false;
+ if (!e.clipboardData) return false;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ return true;
+ }
+
+ document.addEventListener('copy', function (e) {
+ if (!own(e)) return;
+ var text = copyText();
+ if (text !== null) e.clipboardData.setData('text/plain', text);
+ }, true);
+
+ document.addEventListener('cut', function (e) {
+ if (!own(e)) return;
+ if (!g.mayWrite) return;
+ var text = copyText(), b = box();
+ if (text === null) return;
+ e.clipboardData.setData('text/plain', text);
+ clearBox(b);
+ }, true);
+
+ document.addEventListener('paste', function (e) {
+ if (!own(e)) return;
+ if (!g.mayWrite) return;
+ paste(e.clipboardData.getData('text/plain'));
+ }, true);
+
+ /* ---- the menu ----------------------------------------------------
+ *
+ * The menu items cannot use the events above — those only fire for a
+ * real keystroke — so they go through the async Clipboard API.
+ * Writing is allowed from a click; reading may be refused, and that
+ * refusal is what `say` exists for. */
+ window.SheetsClipboard = {
+ copy: function () {
+ var text = copyText();
+ if (text === null || !navigator.clipboard) return;
+ navigator.clipboard.writeText(text).catch(function () {
+ say('This browser would not let the menu copy. Press Ctrl+C instead.');
+ });
+ },
+ cut: function () {
+ var text = copyText(), b = box();
+ if (text === null || !navigator.clipboard) return;
+ navigator.clipboard.writeText(text).then(function () {
+ clearBox(b);
+ }, function () {
+ say('This browser would not let the menu cut. Press Ctrl+X instead.');
+ });
+ },
+ paste: function () {
+ if (!navigator.clipboard || !navigator.clipboard.readText) {
+ say('This browser only pastes from the keyboard. Press Ctrl+V.');
+ return;
+ }
+ navigator.clipboard.readText().then(paste, function () {
+ say('Reading the clipboard needs permission this browser did not give. Press Ctrl+V instead.');
+ });
+ }
+ };
+ }
+
+ if (window.SheetsGrid) ready();
+ else document.addEventListener('sheets:ready', ready, { once: true });
+}());
diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js
index ea8efc9..6dfb90a 100644
--- a/internal/sheets/static/commands.js
+++ b/internal/sheets/static/commands.js
@@ -82,8 +82,14 @@
ws.updateSelectionFromCoords(0, 0, g.cols - 1, g.rows - 1);
}
},
- copy: function () { var ws = g.worksheet(); if (ws && ws.copy) ws.copy(); },
- cut: function () { var ws = g.worksheet(); if (ws && ws.copy) ws.copy(true); },
+ /* clipboard.js, not the library. ws.copy() threw on every call
+ * and ws.copy(true) silently did nothing; both were dispatched,
+ * both rendered, and TestEveryMenuCommandIsImplemented was green
+ * throughout, because a command in the table is not a command
+ * that works. */
+ copy: function () { clip('copy'); },
+ cut: function () { clip('cut'); },
+ paste: function () { clip('paste'); },
/* Insert a function over the run of cells directly above the
* anchor, which is what somebody clicking "SUM" under a column of
@@ -99,6 +105,11 @@
}
};
+ function clip(name) {
+ var c = window.SheetsClipboard;
+ if (c && c[name]) c[name]();
+ }
+
function colName(c) {
var out = '';
c += 1;
@@ -124,15 +135,23 @@
var lastR = Math.max(s.r, s.r2), lastC = Math.max(s.c, s.c2);
if (lastR === s.r && lastC === s.c) {
var r = Math.min(s.r + dr, g.rows - 1), c = Math.min(s.c + dc, g.cols - 1);
- if (r !== s.r || c !== s.c) g.write(r, c, from);
+ if (r !== s.r || c !== s.c) copyInto(r, c, from, s);
return;
}
eachSelected(function (r, c) {
if (r === s.r && c === s.c) return;
- g.write(r, c, from);
+ copyInto(r, c, from, s);
});
}
+ /* Spec §4.2: a filled cell is a COPIED cell, so it names where it
+ * came from and the server offsets its relative references. Without
+ * this, filling `=A1*2` down a column wrote `=A1*2` into every cell
+ * and the whole column showed one number. */
+ function copyInto(r, c, raw, from) {
+ g.post('/cells', { r: r, c: c, raw: raw, fromR: from.r, fromC: from.c });
+ }
+
function run(name, value) {
var fn = CMDS[name];
if (fn) fn(value);
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index de4a39e..b002926 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -496,12 +496,31 @@
font-size: var(--rst-fs-xs);
}
+/* The transient note. Empty almost always, and the app's only way of
+ * saying something happened or did not: today, that a browser refused
+ * the menu's clipboard read, or that a paste is larger than one write
+ * can carry.
+ *
+ * It takes the slack rather than the live indicator, which is why the
+ * auto margin moved here — otherwise a note appearing shoves the
+ * connection state sideways every time one is shown. Truncates rather
+ * than wrapping: this strip is 2rem and a two-line message would push
+ * the grid. */
+[data-sheet-note] {
+ align-self: center;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ margin-inline-start: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* The live indicator. It says connected or reconnecting and nothing
* else — presence is a later slice, and a dot that claims more than it
* knows is worse than no dot. */
[data-sheet-live] {
align-self: center;
- margin-inline-start: auto;
font-size: var(--rst-fs-xs);
color: var(--rst-tone-positive-fg);
}
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 358e59c..2902824 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -15,6 +15,7 @@
<script defer src="{{asset "static/grid.js"}}"></script>
<script defer src="{{asset "static/menubar.js"}}"></script>
<script defer src="{{asset "static/formulabar.js"}}"></script>
+<script defer src="{{asset "static/clipboard.js"}}"></script>
<script defer src="{{asset "static/commands.js"}}"></script>
{{end}}
@@ -102,6 +103,13 @@
<hr>
<button type="button" data-cmd="cut" hidden>Cut <kbd>Ctrl X</kbd></button>
<button type="button" data-cmd="copy" hidden>Copy <kbd>Ctrl C</kbd></button>
+{{/* Paste is here despite the browser being allowed to refuse a menu
+ read of the clipboard. It is not a dead control: clipboard.js
+ asks, and on a refusal says so in the status line below the grid
+ — which is more than the app could do for a person who does not
+ know Ctrl+V is the only way. The keyboard path needs no
+ permission at all, because a paste EVENT carries its own data. */}}
+<button type="button" data-cmd="paste" hidden>Paste <kbd>Ctrl V</kbd></button>
<button type="button" data-cmd="clear" hidden>Clear contents <kbd>Delete</kbd></button>
<hr>
{{/* Fill down and fill right are the menu equivalents of the grid's
@@ -254,6 +262,9 @@
<dt><kbd>Ctrl Z</kbd></dt><dd>Undo my last edit</dd>
<dt><kbd>Ctrl B</kbd></dt><dd>Bold</dd>
<dt><kbd>Ctrl I</kbd></dt><dd>Italic</dd>
+<dt><kbd>Ctrl X</kbd></dt><dd>Cut</dd>
+<dt><kbd>Ctrl C</kbd></dt><dd>Copy</dd>
+<dt><kbd>Ctrl V</kbd></dt><dd>Paste</dd>
<dt><kbd>Ctrl D</kbd></dt><dd>Fill down</dd>
<dt><kbd>Delete</kbd></dt><dd>Clear the selected cells</dd>
<dt><kbd>Enter</kbd></dt><dd>Commit and move down</dd>
@@ -379,6 +390,12 @@ read every cell, edit any of them, one page at a time.</p>
cell content a screen reader is trying to read. It is a glance,
and the selection itself is what announces. */}}
<span data-selection-size></span>
+{{/* The one place this app can say something transient. role="status"
+ is right here and wrong on the selection size beside it: this
+ changes when you ask for something and it did not happen, which
+ is exactly what a person needs read out, where the selection
+ size changes on every arrow key and would talk over the cell. */}}
+<span data-sheet-note role="status"></span>
<span data-sheet-live data-state="off" role="status">Connecting</span>
</div>
{{end}}
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
new file mode 100644
index 0000000..14e996a
--- /dev/null
+++ b/tools/drive/clipboard.mjs
@@ -0,0 +1,106 @@
+/* Drive cut, copy and paste in a real browser.
+ *
+ * The third of the drive scripts, and the one that had to exist: every
+ * bug it covers was invisible to the Go suite. The vendored grid's own
+ * paste dropped cells — a six-cell block landed five, an external
+ * two-row paste landed one — and the cells that vanished did so because
+ * the library wrote them by a path that never fires onchange, so they
+ * never reached the server at all.
+ *
+ * It also drives spec §4.2's translation pass end to end: fill `=A1*2`
+ * down and read `=A2*2` back out of the formula bar's own source.
+ *
+ * Exits non-zero on any failed check, so it can gate a deploy by hand.
+ * Not part of `make test`: it needs a browser and a running instance,
+ * and it needs clipboard permissions granted to the context.
+ *
+ * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log \
+ * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/clipboard.mjs
+ */
+import net from 'node:net';
+import fs from 'node:fs';
+const pw=await import(process.env.PLAYWRIGHT); const chromium=pw.chromium||(pw.default&&pw.default.chromium);
+const proxy=net.createServer(c=>{const u=net.connect(process.env.SHEETS_SOCK);c.pipe(u);u.pipe(c);c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());});
+await new Promise(r=>proxy.listen(8799,'127.0.0.1',r));
+const base='http://127.0.0.1:8799';
+const browser=await chromium.launch();
+const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']});
+const page=await ctx.newPage();
+const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});
+const fails=[]; const ok=n=>console.log(' ok '+n);
+const check=(n,c,d)=>c?ok(n):(fails.push(n),console.log(' FAIL '+n+' — '+d));
+await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
+await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
+await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
+await page.fill('#title','Clip2'); await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);
+await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
+const w=(r,c,v)=>page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]);
+const sel=(x1,y1,x2,y2)=>page.evaluate(([a,b,c,d])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(a,b,c,d),[x1,y1,x2,y2]);
+const raw=(r,c)=>page.evaluate(([r,c])=>window.SheetsGrid.rawAt(r,c),[r,c]);
+const shown=(r,c)=>page.evaluate(([r,c])=>window.SheetsGrid.worksheet().getValueFromCoords(c,r),[r,c]);
+
+for(const [r,c,v] of [[0,0,'10'],[1,0,'20'],[2,0,'30'],[0,1,'=A1*2']]) await w(r,c,v);
+await page.waitForTimeout(500);
+
+console.log('\n— fill down translates (spec 4.2)');
+await sel(1,0,1,2); await page.waitForTimeout(200);
+await page.click('[data-menubar] > details:nth-child(2) > summary');
+await page.click('[data-cmd="fill-down"]'); await page.waitForTimeout(900);
+check('B2 is =A2*2', await raw(1,1)==='=A2*2', 'got '+await raw(1,1));
+check('B3 shows 60', await shown(2,1)==='60', 'got '+await shown(2,1));
+
+console.log('\n— copy carries every cell and paste loses none');
+await page.evaluate(()=>window.SheetsGrid.focus());
+await sel(0,0,1,2); await page.waitForTimeout(200);
+await page.keyboard.press('Control+c'); await page.waitForTimeout(300);
+const clip=await page.evaluate(async()=>navigator.clipboard.readText());
+console.log(' clipboard:',JSON.stringify(clip));
+await sel(5,0,5,0); await page.waitForTimeout(200);
+await page.keyboard.press('Control+v'); await page.waitForTimeout(1400);
+const block=await page.evaluate(()=>{const g=window.SheetsGrid;const o={};
+ for(let r=0;r<3;r++)for(let c=5;c<7;c++){const v=g.rawAt(r,c); if(v!=='')o[r+','+c]=v;} return o;});
+console.log(' pasted:',JSON.stringify(block));
+check('all six cells arrived', Object.keys(block).length===6, Object.keys(block).length+' cells');
+check('the formula translated to =F3*2', block['2,6']==='=F3*2', 'got '+block['2,6']);
+
+console.log('\n— paste from outside loses no row');
+await page.evaluate(async()=>navigator.clipboard.writeText('p\tq\nr\ts'));
+await page.evaluate(()=>window.SheetsGrid.focus());
+await sel(9,0,9,0); await page.waitForTimeout(200);
+await page.keyboard.press('Control+v'); await page.waitForTimeout(1200);
+const ext=await page.evaluate(()=>{const g=window.SheetsGrid;const o={};
+ for(let r=0;r<3;r++)for(let c=9;c<12;c++){const v=g.rawAt(r,c); if(v!=='')o[r+','+c]=v;} return o;});
+console.log(' external:',JSON.stringify(ext));
+check('external paste keeps its first row', ext['0,9']==='p'&&ext['0,10']==='q', JSON.stringify(ext));
+
+console.log('\n— quoting survives a round trip');
+await w(20,0,'has\ttab and\nnewline'); await page.waitForTimeout(400);
+await page.evaluate(()=>window.SheetsGrid.focus());
+await sel(0,20,0,20); await page.waitForTimeout(200);
+await page.keyboard.press('Control+c'); await page.waitForTimeout(300);
+await sel(3,20,3,20); await page.waitForTimeout(200);
+await page.keyboard.press('Control+v'); await page.waitForTimeout(900);
+check('a cell holding a tab and a newline round-trips', await raw(20,3)==='has\ttab and\nnewline', JSON.stringify(await raw(20,3)));
+
+console.log('\n— cut clears the source');
+await sel(0,0,0,0); await page.waitForTimeout(200);
+await page.evaluate(()=>window.SheetsGrid.focus());
+await page.keyboard.press('Control+x'); await page.waitForTimeout(900);
+check('Ctrl+X emptied A1', await raw(0,0)==='', 'got '+JSON.stringify(await raw(0,0)));
+
+console.log('\n— the menu items');
+// B1 holds =A1*2 (sel takes col,row).
+await sel(1,0,1,0); await page.waitForTimeout(200);
+await page.click('[data-menubar] > details:nth-child(2) > summary');
+await page.click('[data-cmd="copy"]'); await page.waitForTimeout(500);
+check('menu Copy reaches the system clipboard', (await page.evaluate(async()=>navigator.clipboard.readText()))==='=A1*2', 'got '+await page.evaluate(async()=>navigator.clipboard.readText()));
+await sel(7,7,7,7); await page.waitForTimeout(200);
+await page.click('[data-menubar] > details:nth-child(2) > summary');
+await page.click('[data-cmd="paste"]'); await page.waitForTimeout(900);
+// H8 is 7 rows and 6 columns from B1, so A1 becomes G8.
+check('menu Paste writes a translated cell', await raw(7,7)==='=G8*2', 'got '+JSON.stringify(await raw(7,7)));
+console.log('\n note text:',JSON.stringify(await page.textContent('[data-sheet-note]')));
+console.log('ERRORS:',JSON.stringify([...new Set(errors)]));
+console.log(fails.length?('\nFAILURES: '+fails.join('; ')):'\nall checks passed');
+await browser.close(); proxy.close(); process.exit(fails.length?1:0);