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
9 files changed,
+839
−6
internal/sheets/calc/translate.go+51 −0internal/sheets/copypaste.go+69 −0internal/sheets/handlers_copy_test.go+208 −0internal/sheets/handlers_grid.go+47 −1internal/sheets/static/clipboard.js+298 −0internal/sheets/static/commands.js+23 −4internal/sheets/static/sheets.css+20 −1internal/sheets/templates/sheet.html+17 −0tools/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); |