feat(mobile): canvas-only mobile mode — pinch-zoom/pan/tap gestures + chrome-less editors

On a mobile device (or ?mobile=1) the editors run canvas-only with touch
gestures driving the view:

- touch-gestures.ts: pure recognizer (unit-tested) + DOM shim installed in
  boot preRun — one-finger drag → synthetic middle-drag (pan), pinch →
  synthetic wheel at the centroid (zoom-to-cursor), tap → left click.
  preRun registration order is what lets stopImmediatePropagation suppress
  the wx layer's single-finger→LEFT-drag touch mapping.
- kicadSetChrome(bool) embind: hides all AUI panes except DrawFrame + the
  menubar/status bar via generic wx APIs (kicad fork untouched); boot polls
  it after runtime init. Pairs with the wxwidgets IsShown layout fix.
- mobile-mode.ts: ?mobile=1/0 override or UA-CH/coarse-pointer autodetect;
  shell hides its overlays and the inherent-to-mobile preflight warnings.
- e2e: mobile-chromium project (Pixel 7) + 4 specs (chrome-less, tap,
  pinch, pan) with screenshot-invertibility assertions; also fixes
  tool-switch.spec's stale pre-scope-refactor URLs (was broken on main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-06 16:10:52 +02:00
commit dc7c60f723
13 changed files with 1039 additions and 24 deletions

View file

@ -1,5 +1,6 @@
import { Route, Routes } from "react-router-dom";
import { VersionBadge } from "@/components/VersionBadge";
import { isMobileMode } from "@/lib/mobile-mode";
import { HomePage } from "@/pages/HomePage";
import { LibToolPage } from "@/pages/LibToolPage";
import { ProjectView } from "@/pages/ProjectView";
@ -17,8 +18,9 @@ export default function App() {
<Route path="/:scope/projects/:name/*" element={<ToolPage />} />
<Route path="/:scope/libs/:name" element={<LibToolPage />} />
</Routes>
{/* Version + source link, bottom-right on every route (home + editor). */}
<VersionBadge />
{/* Version + source link, bottom-right on every route (home + editor).
Mobile mode is canvas-only no persistent overlays. */}
{!isMobileMode() && <VersionBadge />}
</>
);
}

View file

@ -83,6 +83,7 @@ import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
import type { SourceDescriptor } from "@/lib/project-source-shared";
import { SourceChip } from "@/components/SourceChip";
import { isMobileMode } from "@/lib/mobile-mode";
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
@ -689,6 +690,9 @@ export function WasmTool({
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false);
// Canvas-only mobile mode (features/mobile): the shell hides its persistent
// overlays, boot installs the touch-gesture shim + hides the editor chrome.
const mobileUi = React.useMemo(() => isMobileMode(), []);
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
@ -1020,6 +1024,7 @@ export function WasmTool({
// footprint_editor/symbol_editor load the pcbnew/eeschema bundle; the
// frame token tells its single_top launcher which editor frame to open.
frame: TOOL_FRAME[tool],
mobile: mobileUi,
});
// Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes.
@ -1308,13 +1313,16 @@ export function WasmTool({
)}
{/* Top-right overlay chips: who else is in this file (awareness roster) +
where this project lives / whether Save persists. */}
{ready && (peers.length > 0 || sourceDescriptor) && (
where this project lives / whether Save persists (chip hidden in
canvas-only mobile mode). */}
{ready && (peers.length > 0 || (sourceDescriptor && !mobileUi)) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && (
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
)}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
{sourceDescriptor && !mobileUi && (
<SourceChip descriptor={sourceDescriptor} />
)}
</div>
)}
@ -1365,20 +1373,22 @@ export function WasmTool({
</button>
)}
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
onClick={() => setShowLog((s) => !s)}
>
{showLog ? <ChevronDown size={14} /> : <ChevronUp size={14} />} console
({logs.length})
</button>
{showLog && (
<pre className="max-h-64 overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
{logs.join("\n")}
</pre>
)}
</div>
{!mobileUi && (
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
onClick={() => setShowLog((s) => !s)}
>
{showLog ? <ChevronDown size={14} /> : <ChevronUp size={14} />} console
({logs.length})
</button>
{showLog && (
<pre className="max-h-64 overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
{logs.join("\n")}
</pre>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { isMobileMode, type MobileModeWindow } from "./mobile-mode";
/**
* Mobile-mode resolution (features/mobile): the explicit `?mobile=` URL param
* always wins; otherwise fall back to device detection (UA-CH mobile flag, or
* coarse pointer + narrow viewport the same signals capabilities.ts warns on).
*/
function fakeWin(opts: {
search?: string;
uaMobile?: boolean;
coarse?: boolean;
narrow?: boolean;
noMatchMedia?: boolean;
}): MobileModeWindow {
return {
location: { search: opts.search ?? "" },
navigator: { userAgentData: opts.uaMobile === undefined ? undefined : { mobile: opts.uaMobile } },
matchMedia: opts.noMatchMedia
? undefined
: (query: string) => ({
matches: query.includes("pointer") ? (opts.coarse ?? false) : (opts.narrow ?? false),
}),
};
}
describe("isMobileMode", () => {
it("?mobile=1 forces mobile mode on a desktop device", () => {
expect(isMobileMode(fakeWin({ search: "?mobile=1" }))).toBe(true);
expect(isMobileMode(fakeWin({ search: "?foo=bar&mobile=true" }))).toBe(true);
});
it("?mobile=0 forces desktop mode on a mobile device", () => {
expect(
isMobileMode(fakeWin({ search: "?mobile=0", uaMobile: true, coarse: true, narrow: true })),
).toBe(false);
expect(isMobileMode(fakeWin({ search: "?mobile=false", uaMobile: true }))).toBe(false);
});
it("auto-detects via userAgentData.mobile", () => {
expect(isMobileMode(fakeWin({ uaMobile: true }))).toBe(true);
expect(isMobileMode(fakeWin({ uaMobile: false }))).toBe(false);
});
it("auto-detects via coarse pointer + narrow viewport", () => {
expect(isMobileMode(fakeWin({ coarse: true, narrow: true }))).toBe(true);
// a touch-screen desktop (coarse but wide) is NOT mobile
expect(isMobileMode(fakeWin({ coarse: true, narrow: false }))).toBe(false);
expect(isMobileMode(fakeWin({ coarse: false, narrow: true }))).toBe(false);
});
it("defaults to desktop when nothing is detectable", () => {
expect(isMobileMode(fakeWin({}))).toBe(false);
expect(isMobileMode(fakeWin({ noMatchMedia: true }))).toBe(false);
});
});

View file

@ -0,0 +1,35 @@
/**
* Mobile-mode resolution (features/mobile).
*
* In mobile mode the editor runs canvas-only: the web shell hides its overlays
* (version badge, source chip, console toggle), boot installs the touch-gesture
* shim, and the wasm hides the editor chrome (kicadSetChrome). One shared
* signal so every consumer agrees:
*
* - `?mobile=1` / `?mobile=0` (also true/false) override everything the
* deterministic switch for tests and for users on unusual devices.
* - otherwise auto-detect: UA-CH `userAgentData.mobile`, or coarse pointer +
* narrow viewport (the same signals capabilities.ts warns on).
*/
/** The window surface isMobileMode reads — narrow, so tests can fake it. */
export interface MobileModeWindow {
location: { search: string };
navigator?: { userAgentData?: { mobile?: boolean } };
matchMedia?: ((query: string) => { matches: boolean }) | undefined;
}
export function isMobileMode(
// same narrow-cast pattern as capabilities.ts: userAgentData is not in lib.dom
win: MobileModeWindow = window as unknown as MobileModeWindow,
): boolean {
const param = new URLSearchParams(win.location.search).get("mobile");
if (param === "0" || param === "false") return false;
if (param === "1" || param === "true") return true;
if (win.navigator?.userAgentData?.mobile === true) return true;
const mm = win.matchMedia;
if (typeof mm !== "function") return false;
return mm("(pointer: coarse)").matches && mm("(max-width: 900px)").matches;
}

View file

@ -3,6 +3,7 @@ import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BlockingDialog } from "./BlockingDialog";
import { probeCapabilities, type CapabilityReport } from "./capabilities";
import { isMobileMode } from "@/lib/mobile-mode";
/**
* Wraps the tool boot with a device-capability check (feature 0001). On mount it
@ -35,8 +36,19 @@ function readDismissed(key: string): boolean {
}
export function PreflightGate({ children }: { children: React.ReactNode }) {
// Probe once; capabilities don't change within a page load.
const [report] = React.useState<CapabilityReport>(() => probeCapabilities());
// Probe once; capabilities don't change within a page load. In mobile mode
// (features/mobile) the warnings inherent to BEING mobile are noise — the
// user is deliberately here — so drop them; real fatals still block.
const [report] = React.useState<CapabilityReport>(() => {
const r = probeCapabilities();
if (!isMobileMode()) return r;
return {
...r,
warnings: r.warnings.filter(
(w) => w.code !== "mobile" && w.code !== "small-screen",
),
};
});
const [override, setOverride] = React.useState(false);
const key = dismissKey(report);
const [bannerHidden, setBannerHidden] = React.useState(() => readDismissed(key));

View file

@ -21,6 +21,7 @@ import {
type LibsSource,
} from "./libs/source";
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
import { installTouchGestures } from "./touch-gestures";
/** The default user lib boot ensures exists, so there's a writable save target. */
const DEFAULT_USER_LIB_NAME = "My Symbols";
@ -77,6 +78,10 @@ export interface BootOptions {
* `--frame=<token>` in `Module.arguments`; parsed in single_top.cpp. Omitted
* the bundle's build-time default frame. See `TOOL_FRAME` in constants.ts. */
frame?: string;
/** Canvas-only mobile mode (features/mobile): install the touch-gesture shim
* (pinch-zoom / one-finger pan / tap-select) on the input canvas and hide the
* editor chrome (toolbars/panels/menubar) once the frame is up. */
mobile?: boolean;
}
let booted: { tool: Tool; promise: Promise<void> } | null = null;
@ -350,6 +355,13 @@ async function doBoot(opts: BootOptions): Promise<void> {
);
container.appendChild(canvas);
(w.Module as { canvas: HTMLCanvasElement }).canvas = canvas;
if (opts.mobile) {
// Mobile gestures (features/mobile). Installed HERE (preRun) on purpose:
// the shim's listeners must be registered before the wasm app's own touch
// callbacks so it can suppress the wx single-finger→LEFT-drag mapping.
installTouchGestures(canvas);
log("[boot] mobile: touch gestures installed");
}
log(`[boot] canvas created ${width}x${height}`);
};
@ -456,6 +468,32 @@ async function doBoot(opts: BootOptions): Promise<void> {
log("[boot] runtime initialized");
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
if (canvas) canvas.style.display = "block";
if (opts.mobile) {
// Hide the editor chrome (toolbars/panels/menubar) so the canvas fills
// the frame. kicadSetChrome (embind) returns false until the editor
// frame exists — main() builds it after runtime init — so poll.
const mod = w.Module as unknown as {
kicadSetChrome?: (show: boolean) => boolean;
};
const t0 = Date.now();
const tick = setInterval(() => {
let hidden = false;
try {
hidden = mod.kicadSetChrome?.(false) === true;
} catch (err) {
log(`[boot] mobile: kicadSetChrome failed: ${String(err)}`);
clearInterval(tick);
return;
}
if (hidden) {
log("[boot] mobile: editor chrome hidden");
clearInterval(tick);
} else if (Date.now() - t0 > 120_000) {
log("[boot] mobile: gave up waiting for the editor frame");
clearInterval(tick);
}
}, 300);
}
onStatus("");
},
// Resolve wasm + pthread worker against the asset base, not the SPA route.

View file

@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest";
import {
TouchGestureRecognizer,
type GestureAction,
type TouchPt,
} from "./touch-gestures";
/**
* TDD spec for the mobile touch-gesture recognizer (features/mobile).
*
* The recognizer is a pure state machine: it receives the ACTIVE touch list
* (the shape of `TouchEvent.touches`) plus a timestamp on every touch event,
* and emits abstract actions the boot shim translates into the editor's
* proven input paths:
* - pan-* synthetic middle-button drag (WX_VIEW_CONTROLS DRAG_PANNING)
* - zoom synthetic wheel at the pinch centroid (zoom-to-cursor)
* - tap synthetic left click (selection)
*/
const t = (id: number, x: number, y: number): TouchPt => ({ id, x, y });
/** One recognizer with deterministic defaults for tests. */
function rec() {
return new TouchGestureRecognizer({
tapMaxMs: 300,
tapMaxDist: 10,
zoomSensitivity: 3,
minWheelDelta: 15,
});
}
function kinds(actions: GestureAction[]): string[] {
return actions.map((a) => a.kind);
}
describe("tap", () => {
it("quick touch without movement emits a single tap at the touch point", () => {
const r = rec();
expect(r.update([t(1, 100, 100)], 0)).toEqual([]);
expect(r.update([], 150)).toEqual([{ kind: "tap", x: 100, y: 100 }]);
});
it("tolerates sub-threshold jitter", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
expect(r.update([t(1, 104, 103)], 50)).toEqual([]);
expect(kinds(r.update([], 120))).toEqual(["tap"]);
});
it("a long still press emits nothing", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
expect(r.update([], 500)).toEqual([]);
});
});
describe("one-finger pan", () => {
it("starts panning once movement exceeds the tap threshold, anchored at the ORIGINAL touch point", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
expect(r.update([t(1, 105, 100)], 20)).toEqual([]); // below threshold
expect(r.update([t(1, 130, 100)], 40)).toEqual([
{ kind: "pan-start", x: 100, y: 100 },
{ kind: "pan-move", x: 130, y: 100 },
]);
expect(r.update([t(1, 150, 120)], 60)).toEqual([
{ kind: "pan-move", x: 150, y: 120 },
]);
expect(r.update([], 80)).toEqual([{ kind: "pan-end", x: 150, y: 120 }]);
});
it("a slow drag is still a pan (time does not demote it to a tap)", () => {
const r = rec();
r.update([t(1, 0, 0)], 0);
r.update([t(1, 50, 0)], 1000);
expect(kinds(r.update([], 2000))).toEqual(["pan-end"]);
});
});
describe("pinch zoom", () => {
it("pinch-out emits negative wheel deltas (zoom in) at the centroid, totalling ~sensitivity*120 per doubling", () => {
const r = rec();
r.update([t(1, 100, 200), t(2, 200, 200)], 0); // dist 100, centroid (150,200)
const actions: GestureAction[] = [];
// widen 100 → 200 in 10 steps
for (let i = 1; i <= 10; i++) {
const spread = 100 + i * 10;
actions.push(
...r.update(
[t(1, 150 - spread / 2, 200), t(2, 150 + spread / 2, 200)],
i * 16,
),
);
}
const zooms = actions.filter((a) => a.kind === "zoom");
expect(zooms.length).toBeGreaterThan(0);
for (const z of zooms) {
expect(z.kind).toBe("zoom");
if (z.kind === "zoom") {
expect(z.deltaY).toBeLessThan(0); // pinch-out = zoom IN = negative wheel
expect(z.cy).toBe(200); // centroid stays on the finger axis
}
}
const total = zooms.reduce((s, z) => s + (z.kind === "zoom" ? z.deltaY : 0), 0);
// one full doubling = sensitivity(3) * 120 = 360, minus at most the
// un-emitted sub-threshold remainder
expect(total).toBeLessThanOrEqual(-360 + 15);
expect(total).toBeGreaterThanOrEqual(-360 - 1e-6);
});
it("pinch-in emits positive wheel deltas (zoom out)", () => {
const r = rec();
r.update([t(1, 50, 200), t(2, 250, 200)], 0); // dist 200
const actions = r.update([t(1, 100, 200), t(2, 200, 200)], 16); // dist 100
const zooms = actions.filter((a) => a.kind === "zoom");
expect(zooms.length).toBe(1);
const z = zooms[0];
if (z?.kind === "zoom") {
expect(z.deltaY).toBeCloseTo(360, 5);
expect(z.cx).toBe(150);
}
});
it("accumulates sub-threshold pinch movement instead of dropping it", () => {
const r = rec();
r.update([t(1, 0, 0), t(2, 100, 0)], 0); // dist 100
// +2% (≈ -10.4 deltaY): below the 15 threshold — nothing emitted
expect(r.update([t(1, 0, 0), t(2, 102, 0)], 16)).toEqual([]);
// another +2% (cumulative ≈ -21): now emits the ACCUMULATED delta
const actions = r.update([t(1, 0, 0), t(2, 104.04, 0)], 32);
expect(kinds(actions)).toEqual(["zoom"]);
const z = actions[0];
if (z?.kind === "zoom") {
expect(z.deltaY).toBeCloseTo(-360 * Math.log2(1.0404), 3);
}
});
it("ignores extra fingers beyond the first two", () => {
const r = rec();
r.update([t(1, 0, 0), t(2, 100, 0), t(3, 500, 500)], 0);
const actions = r.update([t(1, 0, 0), t(2, 200, 0), t(3, 500, 500)], 16);
const zooms = actions.filter((a) => a.kind === "zoom");
expect(zooms.length).toBe(1);
const z = zooms[0];
if (z?.kind === "zoom") {
expect(z.cx).toBe(100); // centroid of fingers 1+2 only
expect(z.cy).toBe(0);
}
});
});
describe("finger-count transitions", () => {
it("1→2: an active pan ends before the pinch starts", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
r.update([t(1, 150, 100)], 20); // pan active
expect(r.update([t(1, 150, 100), t(2, 250, 100)], 40)).toEqual([
{ kind: "pan-end", x: 150, y: 100 },
]);
const actions = r.update([t(1, 100, 100), t(2, 300, 100)], 56); // dist 100→200
expect(kinds(actions)).toEqual(["zoom"]);
});
it("1→2 during a pending tap emits nothing (no phantom pan)", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
expect(r.update([t(1, 100, 100), t(2, 200, 100)], 20)).toEqual([]);
});
it("2→1: pinch hands off to a pan anchored at the remaining finger", () => {
const r = rec();
r.update([t(1, 100, 100), t(2, 200, 100)], 0);
expect(r.update([t(2, 200, 100)], 20)).toEqual([
{ kind: "pan-start", x: 200, y: 100 },
]);
expect(r.update([t(2, 220, 110)], 40)).toEqual([
{ kind: "pan-move", x: 220, y: 110 },
]);
expect(r.update([], 60)).toEqual([{ kind: "pan-end", x: 220, y: 110 }]);
});
it("2→1→0 quickly does NOT produce a tap", () => {
const r = rec();
r.update([t(1, 100, 100), t(2, 200, 100)], 0);
r.update([t(2, 200, 100)], 10);
const actions = r.update([], 30);
expect(kinds(actions)).toEqual(["pan-end"]);
});
it("2→0 (both lifted at once) emits nothing", () => {
const r = rec();
r.update([t(1, 100, 100), t(2, 200, 100)], 0);
expect(r.update([], 20)).toEqual([]);
// and the recognizer is reusable afterwards
r.update([t(3, 50, 50)], 100);
expect(kinds(r.update([], 150))).toEqual(["tap"]);
});
});
describe("cancel", () => {
it("cancel during a pan ends it", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
r.update([t(1, 160, 100)], 20);
expect(r.cancel()).toEqual([{ kind: "pan-end", x: 160, y: 100 }]);
});
it("cancel during a pending tap or pinch emits nothing and resets", () => {
const r = rec();
r.update([t(1, 100, 100)], 0);
expect(r.cancel()).toEqual([]);
r.update([t(1, 0, 0), t(2, 100, 0)], 100);
expect(r.cancel()).toEqual([]);
// fresh after reset
r.update([t(9, 10, 10)], 200);
expect(kinds(r.update([], 250))).toEqual(["tap"]);
});
});

View file

@ -0,0 +1,274 @@
/**
* Mobile touch gestures for the editor canvas (features/mobile).
*
* The wx wasm layer consumes mouse/wheel only; its own touch mapping turns a
* single finger into a LEFT-button drag (rubber-band select) and drops
* multi-touch entirely. This module translates touches into the editor's
* proven input paths instead:
*
* one-finger drag synthetic middle-button drag (WX_VIEW_CONTROLS pan)
* two-finger pinch synthetic wheel at the pinch centroid (zoom-to-cursor)
* quick tap synthetic left click (selection)
*
* `TouchGestureRecognizer` is the pure state machine (unit-tested); it takes
* the ACTIVE touch list (the shape of `TouchEvent.touches`) plus a timestamp
* per event and emits abstract actions. `installTouchGestures` is the thin DOM
* shim that feeds it and dispatches the synthetic events (covered by the
* mobile e2e specs).
*/
export interface TouchPt {
id: number;
x: number;
y: number;
}
export type GestureAction =
| { kind: "pan-start"; x: number; y: number }
| { kind: "pan-move"; x: number; y: number }
| { kind: "pan-end"; x: number; y: number }
| { kind: "zoom"; cx: number; cy: number; deltaY: number }
| { kind: "tap"; x: number; y: number };
export interface RecognizerOptions {
/** Max press duration for a tap (ms). */
tapMaxMs?: number;
/** Max finger travel for a tap (px); beyond it the touch becomes a pan. */
tapMaxDist?: number;
/** Wheel detents (×120 deltaY) emitted per doubling of the pinch distance. */
zoomSensitivity?: number;
/** Emit a zoom only once the accumulated |deltaY| reaches this (sub-threshold
* movement keeps accumulating it is never dropped). */
minWheelDelta?: number;
}
type State =
| { mode: "idle" }
| {
mode: "single";
startX: number;
startY: number;
startT: number;
x: number;
y: number;
panning: boolean;
}
| { mode: "pinch"; lastEmitDist: number };
const dist = (a: TouchPt, b: TouchPt) => Math.hypot(a.x - b.x, a.y - b.y);
export class TouchGestureRecognizer {
private readonly tapMaxMs: number;
private readonly tapMaxDist: number;
private readonly zoomSensitivity: number;
private readonly minWheelDelta: number;
private state: State = { mode: "idle" };
constructor(opts: RecognizerOptions = {}) {
this.tapMaxMs = opts.tapMaxMs ?? 300;
this.tapMaxDist = opts.tapMaxDist ?? 10;
this.zoomSensitivity = opts.zoomSensitivity ?? 3;
this.minWheelDelta = opts.minWheelDelta ?? 15;
}
/** Feed the current active-touch list (TouchEvent.touches) for any touch event. */
update(touches: TouchPt[], timeMs: number): GestureAction[] {
const out: GestureAction[] = [];
const s = this.state;
const [a, b] = touches;
if (a && b) {
// Pinch uses the first two fingers; extras are ignored.
const d = dist(a, b);
if (s.mode === "pinch") {
const pending = -this.zoomSensitivity * 120 * Math.log2(d / s.lastEmitDist);
if (Math.abs(pending) >= this.minWheelDelta) {
out.push({
kind: "zoom",
cx: (a.x + b.x) / 2,
cy: (a.y + b.y) / 2,
deltaY: pending,
});
s.lastEmitDist = d;
}
} else {
if (s.mode === "single" && s.panning)
out.push({ kind: "pan-end", x: s.x, y: s.y });
this.state = { mode: "pinch", lastEmitDist: d };
}
return out;
}
if (a) {
const p = a;
if (s.mode === "single") {
if (s.panning) {
out.push({ kind: "pan-move", x: p.x, y: p.y });
} else if (dist(p, { id: 0, x: s.startX, y: s.startY }) > this.tapMaxDist) {
// Promote the pending tap to a pan, anchored at the ORIGINAL touch
// point so no movement is lost.
s.panning = true;
out.push({ kind: "pan-start", x: s.startX, y: s.startY });
out.push({ kind: "pan-move", x: p.x, y: p.y });
}
s.x = p.x;
s.y = p.y;
} else if (s.mode === "pinch") {
// One finger lifted mid-pinch: hand off to a pan from the survivor
// (immediately — a release here must not read as a tap).
this.state = {
mode: "single",
startX: p.x,
startY: p.y,
startT: timeMs,
x: p.x,
y: p.y,
panning: true,
};
out.push({ kind: "pan-start", x: p.x, y: p.y });
} else {
this.state = {
mode: "single",
startX: p.x,
startY: p.y,
startT: timeMs,
x: p.x,
y: p.y,
panning: false,
};
}
return out;
}
// all fingers lifted
if (s.mode === "single") {
if (s.panning) {
out.push({ kind: "pan-end", x: s.x, y: s.y });
} else if (timeMs - s.startT <= this.tapMaxMs) {
// never panned ⇒ total travel stayed within tapMaxDist
out.push({ kind: "tap", x: s.startX, y: s.startY });
}
}
this.state = { mode: "idle" };
return out;
}
/** touchcancel: end any active pan, drop everything else. */
cancel(): GestureAction[] {
const s = this.state;
this.state = { mode: "idle" };
if (s.mode === "single" && s.panning)
return [{ kind: "pan-end", x: s.x, y: s.y }];
return [];
}
}
/**
* Wire the recognizer to the Emscripten input canvas, translating actions into
* synthetic mouse/wheel events on it. MUST be installed in preRun (before the
* wasm app registers its own listeners): at-target listeners fire in
* registration order, so only an earlier registration lets
* stopImmediatePropagation() suppress the wx layer's single-fingerLEFT-drag
* touch mapping. Returns an uninstaller.
*/
export function installTouchGestures(
canvas: HTMLElement,
opts: RecognizerOptions = {},
): () => void {
const recognizer = new TouchGestureRecognizer(opts);
canvas.style.touchAction = "none"; // keep the browser's own pan/zoom off the canvas
const mouse = (
type: string,
x: number,
y: number,
button: number,
buttons: number,
) => {
canvas.dispatchEvent(
new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
screenX: x,
screenY: y,
button,
buttons,
}),
);
};
const apply = (actions: GestureAction[]) => {
for (const a of actions) {
switch (a.kind) {
case "pan-start":
// Settle the cursor before pressing — the GAL needs a motion event
// at the press point first (mirrors the wx layer's own synthetic
// MOTION-before-press in TouchCallback).
mouse("mousemove", a.x, a.y, 0, 0);
mouse("mousedown", a.x, a.y, 1, 4); // middle button = pan
break;
case "pan-move":
mouse("mousemove", a.x, a.y, 1, 4);
break;
case "pan-end":
mouse("mouseup", a.x, a.y, 1, 0);
break;
case "zoom":
mouse("mousemove", a.cx, a.cy, 0, 0);
canvas.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
clientX: a.cx,
clientY: a.cy,
deltaY: a.deltaY,
deltaMode: 0, // pixel mode, matching real browser wheels (±120/detent)
}),
);
break;
case "tap":
mouse("mousemove", a.x, a.y, 0, 0);
mouse("mousedown", a.x, a.y, 0, 1);
mouse("mouseup", a.x, a.y, 0, 0);
break;
}
}
};
const pts = (e: TouchEvent): TouchPt[] =>
Array.from(e.touches).map((t) => ({
id: t.identifier,
x: t.clientX,
y: t.clientY,
}));
const swallow = (e: TouchEvent) => {
// Keep the event from the wx layer's touch handlers AND from generating
// browser mouse-compat events — we synthesize our own.
e.stopImmediatePropagation();
if (e.cancelable) e.preventDefault();
};
const onTouch = (e: TouchEvent) => {
swallow(e);
apply(recognizer.update(pts(e), e.timeStamp));
};
const onCancel = (e: TouchEvent) => {
swallow(e);
apply(recognizer.cancel());
};
const listen = { capture: true, passive: false } as AddEventListenerOptions;
canvas.addEventListener("touchstart", onTouch, listen);
canvas.addEventListener("touchmove", onTouch, listen);
canvas.addEventListener("touchend", onTouch, listen);
canvas.addEventListener("touchcancel", onCancel, listen);
return () => {
canvas.removeEventListener("touchstart", onTouch, listen);
canvas.removeEventListener("touchmove", onTouch, listen);
canvas.removeEventListener("touchend", onTouch, listen);
canvas.removeEventListener("touchcancel", onCancel, listen);
};
}