diff --git a/client/js/main.js b/client/js/main.js index cffb10d..c75a8e0 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -1223,7 +1223,7 @@ async function enterSession(invitationText, name, notebook) { session.start(); // For looking at the session from the browser's console during development. Object.assign(window, { kokkoja: { - session, renderer, writer, loadImage, textEditor, + session, renderer, writer, loadImage, textEditor, logText, notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage }, } }); } diff --git a/client/js/pen_input.js b/client/js/pen_input.js index 962959b..d7a1838 100644 --- a/client/js/pen_input.js +++ b/client/js/pen_input.js @@ -16,6 +16,31 @@ const ERASER_BUTTON = 32; */ const NOMINAL_PRESSURE = 0.4; +/** + * A press this soon after the window gained focus is taken to be the + * click that brought the window to the front, and draws nothing (Andy, + * 2026-10-11): clicking the page to return from the terminal, with the + * Pen chosen, left a stray dot. Measured on Hollis (Firefox, Linux): + * the window's focus event fires about 4 ms before such a press, the + * document already reporting focus, so neither document.hasFocus() nor + * the pointer's movement (a pen hovers either way) can tell it from an + * ordinary press; only the time since the focus can. A quarter second + * is far longer than that press and far shorter than any deliberate + * stroke made as a separate act after focusing the window. + */ +export const FOCUS_SETTLE_MS = 250; + +/** + * The end-to-end tests turn the focusing-click rule off with + * `?no_focus_settle` in the address: a browser driven by WebDriver gains + * window focus on the first pointer action after every load or tab + * switch, so it cannot tell such a switch from a click that raised the + * window, and the rule would refuse legitimate test strokes. The rule + * itself is tested with a dispatched focus event in one tab. A real + * user never sets this, and it disables nothing but this nicety. + */ +const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle'); + /** * @typedef {object} PenTarget * @property {(clientX: number, clientY: number) => [number, number]} toPage @@ -100,6 +125,8 @@ export function attachPenInput(surface, target) { let pressY = 0; /** @type {PointerEvent | null} The eraser's press, not yet erased with. */ let eraseWaiting = null; + /** When the window last gained focus, for the focusing-click rule. */ + let lastFocusAt = -Infinity; /** The eraser's press erases now, if it was waiting. */ function eraseFromPress() { @@ -188,6 +215,10 @@ export function attachPenInput(surface, target) { if (activePointer !== null) return refuse(`pointer ${activePointer} still active`); if (event.target !== surface) return refuse('not on the page'); if (event.pointerType === 'pen') penSeen = true; + // A press that only brought the window to the front draws nothing. + if (focusSettleOn && performance.now() - lastFocusAt < FOCUS_SETTLE_MS) { + return refuse('it brought the window to the front'); + } // Once a pen has been used, a touch is taken to be the resting hand. if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand'); if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button'); @@ -325,7 +356,7 @@ export function attachPenInput(surface, target) { // learned on his machine before the rule is written (a window focus // is faked by the test harness on every tab switch, so it cannot be // learned here). The press trace already carries "in front"/"behind". - window.addEventListener('focus', () => trace('window: focus')); + window.addEventListener('focus', () => { lastFocusAt = performance.now(); trace('window: focus'); }); window.addEventListener('blur', () => trace('window: blur')); document.addEventListener('visibilitychange', () => trace(`visibility: ${document.visibilityState}`)); diff --git a/docs/guest.md b/docs/guest.md index 65a85fc..cc177a5 100644 --- a/docs/guest.md +++ b/docs/guest.md @@ -81,7 +81,9 @@ from the palette joins the row and stays there in this browser. White is for painting over. The four lines to the right of the colours are the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure. -If a pen is in use, a hand resting on a touch screen is ignored. +If a pen is in use, a hand resting on a touch screen is ignored. A +click on the page that only brings the browser window to the front, when +you come back to it from another window, makes no mark. ### Text diff --git a/docs/implementation.md b/docs/implementation.md index 0534366..fa69338 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -283,7 +283,7 @@ the zoom step of the wheel and the zoom per 100 pixels of a pen drag (`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin by which the page grows (`page_model.js`), the tooltip delay (1 s, `DELAY_MS` in `tooltip.js`) and how long after one tooltip the next -button's comes at once (1 s, `WARM_MS` there), the hold that moves an image (600 ms, with 6 pixels of +button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js`), the hold that moves an image (600 ms, with 6 pixels of slack, `pen_input.js`), the reach of an image's corner (24 pixels, `renderer.js`), the smallest an image can be scaled to (1 cm, `page_model.js`), how often a dragged image's place is sent (40 ms, diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 3c40c5b..4a73843 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -570,12 +570,17 @@ class EndToEndTest(unittest.TestCase): cls.stop() raise - def address(self, name, join=None): + def address(self, name, join=None, focus_settle=False): """The Kokkoja address of a participant's own local program, with - its token, and an invitation to join if given.""" + its token, and an invitation to join if given. By default it + carries `?no_focus_settle`, which turns off the focusing-click + rule: a driven browser gains focus on the first draw after a load + or tab switch, which the rule would take for a focusing click + (see test_a_focusing_click_draws_nothing, which keeps it on).""" base, token = self.programs[name] + query = "" if focus_settle else "?no_focus_settle" joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}" - return f"{base}#token={token}{joining}" + return f"{base}{query}#token={token}{joining}" @classmethod def start(cls, command): @@ -708,7 +713,7 @@ class EndToEndTest(unittest.TestCase): # filled in, says where the notebooks are, and offers the installed # program to anyone who has it. guest_tab = firefox.new_tab() - firefox.go(link) + firefox.go(link.replace("#", "?no_focus_settle#", 1)) self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"), lambda v: v and v.startswith("ws://"), "the invitation in the start screen") self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"), @@ -1063,6 +1068,33 @@ class EndToEndTest(unittest.TestCase): firefox.switch_to(host_tab) firefox.go("about:blank") + def test_a_focusing_click_draws_nothing(self): + """Andy, 2026-10-11: clicking the page to bring the browser to the + front from the terminal, with the Pen chosen, left a stray dot. A + press within 250 ms of the window gaining focus draws nothing. + (The window focus is dispatched here: the test harness raises the + real window on the first draw after a tab switch, so a two-tab + test cannot feign the terminal case; this does it in one tab.)""" + firefox = self.firefox + firefox.go(self.address("Late", focus_settle=True)) + self.enter_name("Focuser", "start_session") + self.wait_until(lambda s: s["page"]["number"] == 1, "the page") + strokes = lambda: len(self.state()["layers"].get("Focuser", [])) + # The window gains focus, then a press right after: nothing drawn. + firefox.run("window.dispatchEvent(new Event('focus'));") + firefox.drag("#page", 200, 300, 400, 320) + time.sleep(0.4) + self.assertEqual(strokes(), 0) + self.assertIn("refused: it brought the window to the front", + firefox.run("return window.kokkoja.logText();")) + # Once the window has been focused a while, a stroke draws. + firefox.run("window.dispatchEvent(new Event('focus'));") + time.sleep(0.4) + firefox.drag("#page", 200, 400, 400, 420) + self.wait_until(lambda s: len(s["layers"].get("Focuser", [])) == 1, + "a stroke once the window had been focused a while") + firefox.go("about:blank") + def test_undo(self): """Andy, 2026-10-11: one's latest gesture is taken back, then the one before, by the Undo button, Ctrl+Z, or two quick taps of the @@ -2024,8 +2056,11 @@ class EndToEndTest(unittest.TestCase): self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) # A new session in the same notebook: the page the last session - # left there is kept, without anyone having asked. - firefox.go(self.programs["Teacher"][0]) + # left there is kept, without anyone having asked. (The param + # that turns off the focusing-click rule, as address() adds, so + # that drawing after this navigation is not taken for a focusing + # click by the driven browser.) + firefox.go(self.programs["Teacher"][0] + "?no_focus_settle") firefox.run("location.reload();") time.sleep(0.5) self.enter_name("Teacher", "start_session")