diff --git a/index.html b/index.html index 0cbb2ed..512992f 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,17 @@ color:var(--pill-ink); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; } +/* A long press or a long drag selects nothing and opens none of the browser's menus ("Copy / Find in Page", "Save + image"), on every element, not only by way of the body's (noMenus.ts covers what CSS can't). Typing, links and the + texts meant to be copied keep theirs. */ +*, *::before, *::after{ + user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; -webkit-tap-highlight-color:transparent; +} +input, textarea, select, [contenteditable]:not([contenteditable="false"]), #korki .memo, #korki .memo *{ + user-select:text; -webkit-user-select:text; -webkit-touch-callout:default; +} +input[type="range"], input[type="checkbox"]{ user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; } +a[href]{ -webkit-touch-callout:default; } #game{ position:fixed; top:0; left:0; width:100%; height:100%; display:block; touch-action:none; } /* read out by screen readers, not shown */ .sr{ position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } diff --git a/src/main.ts b/src/main.ts index ca8c622..1438fa2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import './errors'; // must stay first: catches errors thrown while the other modules build the scene +import './noMenus'; import { batchStill } from './batch'; import { tableView, tipsy, tipsyK } from './casinoKit'; import { initCloud } from './cloud'; diff --git a/src/noMenus.ts b/src/noMenus.ts new file mode 100644 index 0000000..271cb3a --- /dev/null +++ b/src/noMenus.ts @@ -0,0 +1,32 @@ +// A long press or a long drag on a phone is play (the walking stick, holding Buy, stirring the pot), never the browser's +// own menus: no text gets selected, so no "Copy / Find in Page / Search with..." bar pops up over the game, and no +// "Save image" sheet or right-click menu either. index.html's CSS makes nothing selectable; this covers what CSS +// can't: iOS still runs its long-press and loupe gestures on a touch that starts on the play surface, and a browser +// may start a selection or a menu anyway. Text fields, links and the few texts meant to be copied keep theirs. + +/** Where the browser's own selecting and menus still belong: what's typed, links, and text meant to be copied. */ +export const FREE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), a[href], #korki .memo, #kitShow'; +/** Surfaces a finger drags on for a while: the game, turning the south door's find, the pot's game, drawing on a + * report's picture. */ +const SURFACES = '#game, #wayLook, #showPlay, #drawCanvas'; +/** Controls inside those surfaces, which still get their taps (clicks). */ +const CONTROLS = 'button, a, input, textarea, select, label'; + +const el = (t: EventTarget | null) => (t instanceof Element ? t : t instanceof Node ? t.parentElement : null); +const free = (t: EventTarget | null) => !!el(t)?.closest(FREE); + +document.addEventListener('contextmenu', e => { if (!free(e.target)) e.preventDefault(); }); +document.addEventListener('selectstart', e => { if (!free(e.target)) e.preventDefault(); }); +// A touch that starts on a play surface is the game's: taking its default stops iOS's long-press, loupe and menu +// there. The game reads pointer events, which still come; only the click after a tap doesn't, and nothing on these +// surfaces but their buttons wants one. +document.addEventListener('touchstart', e => { + const t = el(e.target); + if (t?.closest(SURFACES) && !t.closest(CONTROLS)) e.preventDefault(); +}, { passive: false }); +// and should a selection start anyway, it's let go at once, so no menu has anything to show +document.addEventListener('selectionchange', () => { + const s = document.getSelection(); + if (!s || s.isCollapsed || free(s.anchorNode) || free(document.activeElement)) return; + s.removeAllRanges(); +}); diff --git a/src/uiShowcase.ts b/src/uiShowcase.ts index b51af59..14beb61 100644 --- a/src/uiShowcase.ts +++ b/src/uiShowcase.ts @@ -86,7 +86,8 @@ function dialog(skin: Skin) { } const css = ` -#kitShow.k-full{ position:fixed; inset:0; z-index:2; display:block; overflow-y:auto; touch-action:pan-y; user-select:text; -webkit-user-select:text; +#kitShow.k-full, #kitShow.k-full :not(button, button *){ user-select:text; -webkit-user-select:text; } +#kitShow.k-full{ position:fixed; inset:0; z-index:2; display:block; overflow-y:auto; touch-action:pan-y; background:var(--bg); padding:calc(env(safe-area-inset-top,0px) + 250px) 16px 40px; box-sizing:border-box; } #kitShow h1{ margin:0 0 4px; font-size:28px; } #kitShow .lede{ margin:0 0 18px; font-weight:600; opacity:.8; } diff --git a/test/noMenus.test.ts b/test/noMenus.test.ts new file mode 100644 index 0000000..051c9b4 --- /dev/null +++ b/test/noMenus.test.ts @@ -0,0 +1,57 @@ +// A long press or drag on a phone opens none of the browser's menus (noMenus.ts and index.html's CSS). +import { describe, expect, it } from 'vitest'; +import indexHtml from '../index.html?raw'; +import '../src/noMenus'; + +const $ = (id: string) => document.getElementById(id)!; +/** Sends a cancellable event at an element, and says whether the game took its default. */ +const stopped = (target: EventTarget, type: string) => { + const e = new Event(type, { bubbles: true, cancelable: true }); + target.dispatchEvent(e); + return e.defaultPrevented; +}; + +describe('no browser menus over the game', () => { + it('makes nothing selectable or long-pressable but what is typed, links and the letter', () => { + const css = indexHtml.slice(indexHtml.indexOf('')); + expect(css).toMatch(/\*, \*::before, \*::after\{\s*user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; -webkit-tap-highlight-color:transparent;/); + expect(css).toMatch(/input, textarea, select, [^{]*#korki \.memo \*\{\s*user-select:text; -webkit-user-select:text; -webkit-touch-callout:default;/); + expect(css).toMatch(/a\[href\]\{ -webkit-touch-callout:default; \}/); + }); + + it('opens no right-click or long-press menu on the game, but does on the report box and links', () => { + expect(stopped($('game'), 'contextmenu')).toBe(true); + expect(stopped($('cash'), 'contextmenu')).toBe(true); + expect(stopped($('reportText'), 'contextmenu')).toBe(false); + expect(stopped($('kofi'), 'contextmenu')).toBe(false); + }); + + it('starts no selection in the HUD, but does in the report box and the letter', () => { + expect(stopped($('cash'), 'selectstart')).toBe(true); + expect(stopped($('cash').firstChild ?? $('cash'), 'selectstart')).toBe(true); + expect(stopped($('reportText'), 'selectstart')).toBe(false); + expect(stopped(document.querySelector('#korki .memo')!, 'selectstart')).toBe(false); + }); + + it("keeps a touch on the play surfaces the game's, and leaves their buttons their taps", () => { + expect(stopped($('game'), 'touchstart')).toBe(true); + expect(stopped($('wayLook'), 'touchstart')).toBe(true); + expect(stopped($('showPlay'), 'touchstart')).toBe(true); + expect(stopped($('wayPocket'), 'touchstart')).toBe(false); + expect(stopped($('showGo'), 'touchstart')).toBe(false); + expect(stopped($('gear'), 'touchstart')).toBe(false); // the HUD's buttons, and the panels' scrolling, are the browser's + }); + + it('lets go of a selection that starts anyway, but not one in the letter', () => { + const sel = document.getSelection()!; + sel.selectAllChildren($('cash')); + document.dispatchEvent(new Event('selectionchange')); + expect(sel.isCollapsed).toBe(true); + const memo = document.querySelector('#korki .memo')!; + memo.textContent = 'Dear chef'; + sel.selectAllChildren(memo); + document.dispatchEvent(new Event('selectionchange')); + expect(sel.toString()).toBe('Dear chef'); + sel.removeAllRanges(); + }); +});