oficina / sheets Public

Turn off the grid library's right-click menu

Right-clicking a cell opened jsuites' stock context menu: "Delete selected
rows", "Add comments", "Copy...", "Paste...", "Save as..." and an "About"
advert. Those items act on the library's own model and bypass the app's
write, comment and export paths. grid.js never configured contextMenu, so
the library's defaults applied.

contextMenu now returns false, which the library treats as "no menu" and
leaves the event alone, so the browser's own menu shows. An app menu built
from the app's own commands is a possible later step; the Edit menu already
carries all of them. grid.js stays at the 300-line cap: three worksheet
flags share one line to make room.

Test: tools/drive/contextmenu.mjs right-clicks a cell in Chromium and
WebKit against the real rendered page and assets, and asserts no library
menu is shown, the event is not swallowed, no write is sent and there are
no page errors. It fails on main (the stock menu opens) and passes here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev af36460302fc939f7372f3fc5672cc5eb264259d parent 336aa0d
3 files changed, +65 −6
  • internal/sheets/static/grid.js +3 −3
  • internal/sheets/static/sheets.css +5 −3
  • tools/drive/contextmenu.mjs +57 −0

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.089s
ok  	amadan.net/oficina/sheets/internal/sheets	10.293s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.008s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.053s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.002s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.764s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.282s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	49.545s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.059s
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js
index b6c2f0b..47aa92c 100644
--- a/internal/sheets/static/grid.js
+++ b/internal/sheets/static/grid.js
@@ -176,6 +176,8 @@
var opts = {
/* Our engine computes; the client paints. See the file comment. */
parseFormulas: false,
+ /* The library's right-click items bypass our write paths. false: browser menu. */
+ contextMenu: function () { return false; },
/* Every handler below is a SPREADSHEET-level option, not a
* worksheet one. Nested inside worksheets[0] they are accepted
@@ -217,9 +219,7 @@
minDimensions: [cols, rows],
tableOverflow: true,
columnSorting: false, /* sorting is a later slice; a half-done sort is worse than none */
- columnDrag: false,
- allowInsertRow: false,
- allowInsertColumn: false
+ columnDrag: false, allowInsertRow: false, allowInsertColumn: false
}]
};
/* The resize callbacks are SPREADSHEET-level options, like the
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index 294690c..2529259 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -836,9 +836,11 @@ form[data-doc-title]:not(:focus-within) button {
/* ── The right-click menu ────────────────────────────────────────────
*
* Not Jspreadsheet's: jsuites', which it binds to `contextmenu` whether
- * or not the host app configures one. So it exists here, it is reachable
- * on every cell, and vendored it is #fff with #555 text — a white card
- * dropped on a dark grid, and the last unthemed surface on this screen.
+ * or not the host app configures one. grid.js turns it off (its stock
+ * items bypass our write paths), so today it never opens. The restatement
+ * stays because the element is still mounted, and TestVendorPaintIsRestated
+ * lists it: vendored it is #fff with #555 text — a white card on a dark
+ * grid — should anything ever open it again.
*
* It is `position: fixed` and mounted inside the grid, so it is scoped
* the same way everything else here is. */
diff --git a/tools/drive/contextmenu.mjs b/tools/drive/contextmenu.mjs
new file mode 100644
index 0000000..38eaee9
--- /dev/null
+++ b/tools/drive/contextmenu.mjs
@@ -0,0 +1,57 @@
+// Right-clicking a cell must not open the grid library's stock context menu
+// (B7). Its items ("Delete selected rows", "Add comments", "Copy...",
+// "Paste...", "Save as...", "About") act on the library's own model and
+// bypass the app's write, comment and export paths. The app configures
+// contextMenu to return false, so the browser's own menu shows instead.
+// Real server-rendered page and assets, mocked transport; no live data.
+// SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture -count=1
+// PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/contextmenu.mjs
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT || 'playwright');
+const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
+const origin = 'https://sheets.test';
+for (const name of ['chromium', 'webkit']) {
+ const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {};
+ const browser = await pw[name].launch(options);
+ try {
+ const page = await browser.newPage({viewport:{width:1440,height:1000}});
+ const errors = []; let writes = [];
+ page.on('pageerror', e=>errors.push(e.message));
+ await page.route(origin+'/**', async route => {
+ const req = route.request(), path = new URL(req.url()).pathname;
+ if (path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType:
+ path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'});
+ if (path.endsWith('/stream')) return route.fulfill({body:'',contentType:'text/event-stream'});
+ if (req.method() !== 'GET') writes.push(path);
+ if (path.startsWith('/api/')) return route.fulfill({json:{version:1,cells:[
+ {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'}]}});
+ return route.fulfill({body:fixture.html,contentType:'text/html'});
+ });
+ await page.goto(origin+'/sheets/1');
+ await page.waitForFunction(()=>window.SheetsGrid?.worksheet());
+ const cell = page.locator('td[data-x="0"][data-y="0"]');
+ await cell.click();
+ writes = [];
+ await cell.click({button:'right'});
+ const shown = await page.evaluate(()=>[...document.querySelectorAll('.jcontextmenu')]
+ .filter(m=>getComputedStyle(m).display!=='none' && m.offsetHeight>0 && m.children.length>0).map(m=>m.innerText));
+ assert.deepEqual(shown, [], `${name}: the library context menu opened: ${JSON.stringify(shown)}`);
+ for (const label of ['Delete selected rows','Add comments','Save as','About']) {
+ assert.equal(await page.getByText(label).filter({visible:true}).count(), 0, `${name}: "${label}" is on screen`);
+ }
+ // Dispatched as a real contextmenu event; defaultPrevented tells us whether
+ // anything claimed it (the library's menu does, the browser's does not).
+ const prevented = await cell.evaluate(td => {
+ const e = new MouseEvent('contextmenu', {bubbles:true, cancelable:true, button:2, clientX:td.getBoundingClientRect().x+5, clientY:td.getBoundingClientRect().y+5});
+ td.dispatchEvent(e);
+ return e.defaultPrevented;
+ });
+ assert.equal(prevented, false, `${name}: something swallowed the right-click instead of leaving the browser menu`);
+ assert.deepEqual(writes, [], `${name}: right-click sent writes`);
+ assert.deepEqual(errors, [], `${name}: page errors`);
+ console.log(`${name}: no library context menu`);
+ } finally {
+ await browser.close();
+ }
+}