Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
32 changes: 32 additions & 0 deletions src/noMenus.ts
Original file line number Diff line number Diff line change
@@ -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();
});
3 changes: 2 additions & 1 deletion src/uiShowcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
57 changes: 57 additions & 0 deletions test/noMenus.test.ts
Original file line number Diff line number Diff line change
@@ -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('<style>'), indexHtml.indexOf('</style>'));
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();
});
});
Loading