From a64100cb4f30137735faa36ec772498d6aaccc4a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 10:48:44 +0200 Subject: [PATCH 1/2] feat(html): a tap opens a sheet cell's editor on a touch screen Opening a cell took a double click, which a phone gives up two taps for and still reports as two separate taps first. The reader has already entered the mode that edits, so on a coarse pointer the first tap now opens the editor; a fine pointer keeps the double click, because there a single click is how a cell is selected without editing it. The second of those taps was worse than useless: `spreadsheet.js` clears the pin when what is clicked is already pinned, so tapping a cell twice with a pause between dropped the selection. It now clears the pin only in a sheet that is not being edited, where that gesture is the only way to put a raised cell back. Checked with `test/browser/sheet` - 115 checks over the five pages, none failing, with the tap path driven through a `matchMedia` that answers as a phone does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FJJdfqpnVCKBNHXjAVxSou --- CHANGELOG.md | 7 +++++++ .../internal/html/frontend/sheet-editing.js | 20 ++++++++++++++++++- src/odr/internal/html/frontend/spreadsheet.js | 16 +++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e909d60..375008850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ The release run heads these entries with the version and opens a fresh it, as a second set of glyphs over the drawn ones. That layer now stays transparent under `::selection`. +- A tap opens a sheet cell's editor where the pointer is coarse, so a phone + needs one tap rather than a double tap. A mouse keeps the double click. + +- **Fix**: a second click on the pinned cell cleared the pin while the sheet + was being edited, so a reader who tapped a cell twice lost it. The pin is + now cleared only in a sheet that is not being edited. + ## v7.1.0 - 2026-09-19 - The pdf view reports the pending annotation count on diff --git a/src/odr/internal/html/frontend/sheet-editing.js b/src/odr/internal/html/frontend/sheet-editing.js index ad3c58eac..8ff44e773 100644 --- a/src/odr/internal/html/frontend/sheet-editing.js +++ b/src/odr/internal/html/frontend/sheet-editing.js @@ -405,12 +405,30 @@ } }); + /// A touch screen has no double click to spare: the first tap of one is a + /// tap of its own, and the reader is already in the mode that edits. So a + /// tap opens the editor where the pointer is coarse, and the double click + /// keeps the job where it is fine. + function tapEdits() { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches + ); + } + // A locked cell says so on the click, not on the double click. table.addEventListener("click", function (event) { var at = odr.editing.isEnabled() && overlay === null ? targetPosition(event) : null; - if (at !== null && odr.editing.lockAt(at.column, at.row) !== null) { + if (at === null) { + return; + } + if (odr.editing.lockAt(at.column, at.row) !== null) { odr.editing.refuseAt(at.column, at.row); + return; + } + if (tapEdits()) { + edit(at.column, at.row, null); } }); diff --git a/src/odr/internal/html/frontend/spreadsheet.js b/src/odr/internal/html/frontend/spreadsheet.js index 5b736904d..3500f6806 100644 --- a/src/odr/internal/html/frontend/spreadsheet.js +++ b/src/odr/internal/html/frontend/spreadsheet.js @@ -455,6 +455,16 @@ paint(); }); + /// Whether the sheet is in the mode that writes cells. The editor is a + /// script of its own, and a read-only view carries none of it. + function editingEnabled() { + return ( + odr.editing !== undefined && + typeof odr.editing.isEnabled === "function" && + odr.editing.isEnabled() + ); + } + table.addEventListener("click", function (event) { // Selecting inside what is raised must not put the cell back. if (raisedContent !== null && raisedContent.contains(event.target)) { @@ -468,9 +478,11 @@ // Clicking what is pinned clears it - but `detail` counts the clicks, and // the second of a double click is the reader selecting a word. Clearing - // the pin under that flickers the border off again. + // the pin under that flickers the border off again. While the sheet is + // edited the click belongs to the editor, which opens over that cell, so + // a second tap on it must not drop what the editor is about to use. if (cell === pinnedCell) { - if (event.detail <= 1) { + if (event.detail <= 1 && !editingEnabled()) { pin(-1, null, null); } return; From 4e555a07b7ac88ddd1b885e2eae07ddee7700c9e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:29:13 +0200 Subject: [PATCH 2/2] fix(html): only a data cell holds its pin on the second click The editor opens over a `td`, so a second click there must not drop the pin it reads. A row or column header opens none, and clicking it again is how a reader puts it back, so the guard now asks for a `td`. The sheet checks drive the tap path: `editing.html` answers `(pointer: coarse)` the way a phone does, and checks what one click does on a cell, on a locked cell and on a header. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149gFxhkvKTBQidz6brU8Vt --- CHANGELOG.md | 6 +-- .../internal/html/frontend/sheet-editing.js | 7 ++- src/odr/internal/html/frontend/spreadsheet.js | 9 ++-- test/browser/sheet/editing.html | 46 +++++++++++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 375008850..db2cd584e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,9 @@ The release run heads these entries with the version and opens a fresh - A tap opens a sheet cell's editor where the pointer is coarse, so a phone needs one tap rather than a double tap. A mouse keeps the double click. -- **Fix**: a second click on the pinned cell cleared the pin while the sheet - was being edited, so a reader who tapped a cell twice lost it. The pin is - now cleared only in a sheet that is not being edited. +- **Fix**: a second click on a pinned cell cleared the pin while the sheet was + being edited, so a reader who tapped a cell twice lost it. A cell now holds + its pin there; a row or column header still clears on that click. ## v7.1.0 - 2026-09-19 diff --git a/src/odr/internal/html/frontend/sheet-editing.js b/src/odr/internal/html/frontend/sheet-editing.js index 8ff44e773..89e69731a 100644 --- a/src/odr/internal/html/frontend/sheet-editing.js +++ b/src/odr/internal/html/frontend/sheet-editing.js @@ -405,10 +405,9 @@ } }); - /// A touch screen has no double click to spare: the first tap of one is a - /// tap of its own, and the reader is already in the mode that edits. So a - /// tap opens the editor where the pointer is coarse, and the double click - /// keeps the job where it is fine. + /// A coarse pointer opens the editor on one tap: a phone gives up the first + /// tap of a double tap as a click of its own anyway. A fine pointer keeps the + /// double click, because there a single click selects a cell without editing. function tapEdits() { return ( typeof window.matchMedia === "function" && diff --git a/src/odr/internal/html/frontend/spreadsheet.js b/src/odr/internal/html/frontend/spreadsheet.js index 3500f6806..2d6a0be8e 100644 --- a/src/odr/internal/html/frontend/spreadsheet.js +++ b/src/odr/internal/html/frontend/spreadsheet.js @@ -478,11 +478,12 @@ // Clicking what is pinned clears it - but `detail` counts the clicks, and // the second of a double click is the reader selecting a word. Clearing - // the pin under that flickers the border off again. While the sheet is - // edited the click belongs to the editor, which opens over that cell, so - // a second tap on it must not drop what the editor is about to use. + // the pin under that flickers the border off again. A cell in an edited + // sheet holds its pin too, because the editor opens over that cell and + // reads it. A header opens none, so it still clears. if (cell === pinnedCell) { - if (event.detail <= 1 && !editingEnabled()) { + var takenByEditor = editingEnabled() && cell.tagName === "TD"; + if (event.detail <= 1 && !takenByEditor) { pin(-1, null, null); } return; diff --git a/test/browser/sheet/editing.html b/test/browser/sheet/editing.html index f83729dc6..743f67b5d 100644 --- a/test/browser/sheet/editing.html +++ b/test/browser/sheet/editing.html @@ -372,6 +372,52 @@ ops().length === 0 && last().dirty === false ); + + // What a tap does. `tapEdits` reads `(pointer: coarse)`, so the check + // answers that query the way a phone does and puts the real one back. + var realMatchMedia = window.matchMedia; + function pointer(kind) { + window.matchMedia = function (query) { + return query === "(pointer: coarse)" + ? { matches: kind === "coarse" } + : realMatchMedia.call(window, query); + }; + } + + check("the mode is still on", odr.editing.isEnabled() === true); + pointer("fine"); + odr.sheet.pin(null); + click(cell(0, 0)); + check("a fine pointer selects a cell rather than opening it", editor() === null); + doubleClick(cell(0, 0)); + check("and opens it on the double click", editor() !== null); + press(editor(), "Escape"); + + pointer("coarse"); + click(cell(0, 0)); + check( + "a coarse pointer opens it on one click", + editor() !== null && editor().value === "a string that runs on" + ); + press(editor(), "Escape"); + + check("the locked cell is the one this uses", odr.editing.lockAt(1, 2) === "formula"); + click(cell(1, 2)); + check("a locked cell refuses the tap rather than opening", editor() === null); + + // A tap, a pause, a tap is two clicks of `detail: 1`, so the second one + // reaches the cell the first pinned. + pointer("fine"); + odr.sheet.pin({ column: 0, row: 0 }); + click(cell(0, 0)); + check("a second click holds the pin while the sheet is edited", odr.sheet.pinned() !== null); + var header = document.querySelectorAll(".odr-sheet-column-header")[1]; + click(header); + check("a header takes the pin", odr.sheet.pinned().column === 1); + click(header); + check("and a second click on it still clears the pin", odr.sheet.pinned() === null); + + window.matchMedia = realMatchMedia;