diff --git a/tests/playwright-web.config.ts b/tests/playwright-web.config.ts index d4f2d84..cd78356 100644 --- a/tests/playwright-web.config.ts +++ b/tests/playwright-web.config.ts @@ -43,12 +43,22 @@ export default defineConfig({ projects: [ { name: 'firefox', + testIgnore: /mobile-.*\.spec\.ts/, use: { ...devices['Desktop Firefox'], viewport: { width: 1280, height: 720 } }, }, { name: 'chromium', + testIgnore: /mobile-.*\.spec\.ts/, use: { channel: 'chrome', viewport: { width: 1280, height: 720 } }, }, + { + // Canvas-only mobile mode (features/mobile). Mobile emulation is + // Chromium-only, and headless SwiftShader WebGL is broken on ARM Mac (see + // header) — run this project headed locally: --project=mobile-chromium --headed. + name: 'mobile-chromium', + testMatch: /mobile-.*\.spec\.ts/, + use: { ...devices['Pixel 7'], channel: 'chrome' }, + }, ], webServer: { diff --git a/tests/web/mobile-editor.spec.ts b/tests/web/mobile-editor.spec.ts new file mode 100644 index 0000000..9c44ae6 --- /dev/null +++ b/tests/web/mobile-editor.spec.ts @@ -0,0 +1,306 @@ +import { test, expect, devices, type Page, type Browser } from '@playwright/test'; + +/** + * Mobile canvas-only mode e2e (features/mobile) — runs under the + * `mobile-chromium` project (Pixel 7 emulation, system Chrome). + * + * Boots pcbnew ONCE on the demo board with `?mobile=1` (the wasm runtime is + * process-global and each boot costs minutes, so the four checks share one + * serial page): + * + * 1. chrome-less: no visible wx toolbars/menubar, GL canvas ≈ viewport + * (needs kicadSetChrome — the wasm side of features/mobile) + * 2. tap contract: a touch tap synthesizes a LEFT click (selection keeps + * working), and no phantom middle-button events + * 3. pinch: two-finger pinch-out zooms in (and pinch-in restores) — rides + * the wheel→zoom-to-cursor path, asserted per zoom-cursor.spec.ts logic + * 4. pan: a one-finger drag translates the view (and is NOT the old + * single-finger rubber-band select, which left the view unchanged) + * + * Touches are dispatched as synthetic TouchEvents on #canvas — exactly what + * the boot shim (touch-gestures.ts) consumes; Playwright has no pinch API. + */ + +const FRONTEND_URL = process.env.WEB_APP_URL ?? 'http://localhost:3048'; +const SCOPE = 'default'; + +// NOT describe.serial: the config is fullyParallel:false + workers:1, so the +// file's tests already run in order in one worker sharing `page` — and a +// failure (e.g. chrome-hide RED before the wasm lands) must not skip the rest. +let page: Page; + +interface Pt { + id: number; + x: number; + y: number; +} + +/** Dispatch a TouchEvent on #canvas; `active` is the post-event touch list + * (the shape of `event.touches` — empty for the final touchend). */ +async function touch(pg: Page, type: string, active: Pt[], changed?: Pt[]): Promise { + await pg.evaluate( + ({ type, active, changed }) => { + const canvas = document.getElementById('canvas'); + if (!canvas) throw new Error('#canvas not found'); + const mk = (p: { id: number; x: number; y: number }) => + new Touch({ + identifier: p.id, + target: canvas, + clientX: p.x, + clientY: p.y, + screenX: p.x, + screenY: p.y, + radiusX: 2.5, + radiusY: 2.5, + force: 1, + }); + const touches = active.map(mk); + canvas.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + touches, + targetTouches: touches, + changedTouches: (changed ?? active).map(mk), + }), + ); + }, + { type, active, changed }, + ); +} + +/** One-finger drag as a touch sequence. */ +async function fingerDrag( + pg: Page, + from: { x: number; y: number }, + to: { x: number; y: number }, + steps = 10, +): Promise { + await touch(pg, 'touchstart', [{ id: 1, x: from.x, y: from.y }]); + for (let i = 1; i <= steps; i++) { + const x = from.x + ((to.x - from.x) * i) / steps; + const y = from.y + ((to.y - from.y) * i) / steps; + await touch(pg, 'touchmove', [{ id: 1, x, y }]); + await pg.waitForTimeout(30); + } + await touch(pg, 'touchend', [], [{ id: 1, x: to.x, y: to.y }]); +} + +/** Two-finger horizontal pinch around a centre, from ±spreadFrom to ±spreadTo. */ +async function pinch( + pg: Page, + centre: { x: number; y: number }, + spreadFrom: number, + spreadTo: number, + steps = 8, +): Promise { + const at = (s: number): Pt[] => [ + { id: 1, x: centre.x - s, y: centre.y }, + { id: 2, x: centre.x + s, y: centre.y }, + ]; + await touch(pg, 'touchstart', at(spreadFrom)); + for (let i = 1; i <= steps; i++) { + const s = spreadFrom + ((spreadTo - spreadFrom) * i) / steps; + await touch(pg, 'touchmove', at(s)); + await pg.waitForTimeout(30); + } + await touch(pg, 'touchend', [], at(spreadTo)); +} + +/** The visible GAL WebGL canvas box (same lookup as zoom-cursor.spec.ts). */ +async function getGlBox(pg: Page): Promise<{ x: number; y: number; width: number; height: number }> { + const id = await pg.evaluate(() => { + const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]')) + .map((c) => c as HTMLCanvasElement) + .find((c) => { + const rect = c.getBoundingClientRect(); + return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0; + }); + return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null; + }); + if (!id) throw new Error('No visible GL canvas found'); + const box = await pg.locator(`#${id}`).boundingBox(); + if (!box) throw new Error('GL canvas bounding box unavailable'); + return box; +} + +/** Fraction of pixels that differ (luma) between two PNGs inside a region + * (ported from zoom-cursor.spec.ts). */ +async function diffRatio( + pg: Page, + a: Buffer, + b: Buffer, + box: { x: number; y: number; width: number; height: number }, +): Promise { + return pg.evaluate( + async ({ aB64, bB64, box }) => { + const load = async (s: string) => { + const i = new Image(); + i.src = `data:image/png;base64,${s}`; + await i.decode(); + return i; + }; + const [ia, ib] = await Promise.all([load(aB64), load(bB64)]); + const w = Math.min(ia.width, ib.width), + h = Math.min(ia.height, ib.height); + const px = (img: HTMLImageElement) => { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = c.getContext('2d')!; + x.drawImage(img, 0, 0); + return x.getImageData(0, 0, w, h).data; + }; + const da = px(ia), + db = px(ib); + const x0 = Math.max(0, Math.round(box.x)), + x1 = Math.min(w, Math.round(box.x + box.width)); + const y0 = Math.max(0, Math.round(box.y)), + y1 = Math.min(h, Math.round(box.y + box.height)); + let diff = 0, + total = 0; + for (let y = y0; y < y1; y++) + for (let x = x0; x < x1; x++) { + const i = (y * w + x) * 4; + const la = 0.299 * da[i] + 0.587 * da[i + 1] + 0.114 * da[i + 2]; + const lb = 0.299 * db[i] + 0.587 * db[i + 1] + 0.114 * db[i + 2]; + if (Math.abs(la - lb) > 24) diff++; + total++; + } + return diff / total; + }, + { aB64: a.toString('base64'), bB64: b.toString('base64'), box }, + ); +} + +const shot = (name: string) => + page.screenshot({ path: `test-results/mobile-${name}.png`, scale: 'css' }); + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + test.setTimeout(420_000); // cold load: 136 MB wasm download + compile + page = await browser.newPage({ ...devices['Pixel 7'], baseURL: FRONTEND_URL }); + page.on('console', (m) => { + if (/Aborted\(|pageerror/i.test(m.text())) console.log(`[mobile-e2e console] ${m.text()}`); + }); + await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?mobile=1`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(/demo — PCB Editor/i); + // The full-screen loading overlays (boot + lib fat-load, both `inset-0 z-30` + // in WasmTool) must be GONE — the gesture tests diff screenshots, and an + // overlay screenshot diffs to zero (learned the hard way on a cold load). + await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 }); + // let the first paint + fit-to-view and the chrome-hide poll settle + await page.waitForTimeout(4000); +}); + +test.afterAll(async () => { + await page?.close(); +}); + +test('tap contract: a touch tap synthesizes a LEFT click, no phantom middle-drag', async () => { + const box = await getGlBox(page); + await page.evaluate(() => { + const c = document.getElementById('canvas')!; + const log: [string, number][] = ((window as unknown as { __mouseLog: [string, number][] }).__mouseLog = []); + for (const type of ['mousedown', 'mouseup']) + c.addEventListener(type, (e) => log.push([e.type, (e as MouseEvent).button]), true); + }); + + const p = { id: 1, x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 }; + await touch(page, 'touchstart', [p]); + await page.waitForTimeout(60); + await touch(page, 'touchend', [], [p]); + await page.waitForTimeout(300); + + const log = await page.evaluate( + () => (window as unknown as { __mouseLog: [string, number][] }).__mouseLog, + ); + expect(log, 'tap → left mousedown').toContainEqual(['mousedown', 0]); + expect(log, 'tap → left mouseup').toContainEqual(['mouseup', 0]); + expect( + log.filter(([, button]) => button === 1), + 'a tap must not press the middle (pan) button', + ).toHaveLength(0); +}); + +test('pinch: two-finger pinch-out zooms in, pinch-in restores', async () => { + // shim marker: mobile mode must have armed the canvas for touch + expect( + await page.evaluate(() => getComputedStyle(document.getElementById('canvas')!).touchAction), + 'canvas touch-action none (gesture shim installed)', + ).toBe('none'); + + const box = await getGlBox(page); + const centre = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.45 }; + + const base = await shot('pinch-00-base'); + await pinch(page, centre, 40, 140); // pinch OUT = zoom in + await page.waitForTimeout(500); + const zoomed = await shot('pinch-01-zoomed'); + + await pinch(page, centre, 140, 40); // pinch IN at the same centre = zoom back out + await page.waitForTimeout(500); + const restored = await shot('pinch-02-restored'); + + const dIn = await diffRatio(page, base, zoomed, box); + const dBack = await diffRatio(page, base, restored, box); + console.log(`[mobile pinch] dIn=${dIn.toFixed(3)} dBack=${dBack.toFixed(3)}`); + + expect(dIn, 'pinch-out visibly zooms').toBeGreaterThan(0.006); + // zoom-to-cursor at a fixed centroid is invertible (same discriminator as + // zoom-cursor.spec.ts) + expect(dBack, 'pinch in+out returns near baseline').toBeLessThan(dIn / 3); +}); + +test('pan: one-finger drag translates the view (not a rubber-band select)', async () => { + const box = await getGlBox(page); + // start/end in the bottom-left margin: the old single-finger LEFT-drag would + // rubber-band an EMPTY region there (no selection highlight → no pixel change), + // so this fails RED before the shim and can't false-pass on item selection. + const from = { x: box.x + box.width * 0.15, y: box.y + box.height * 0.88 }; + const to = { x: box.x + box.width * 0.55, y: box.y + box.height * 0.7 }; + + const base = await shot('pan-00-base'); + await fingerDrag(page, from, to); + await page.waitForTimeout(500); + const panned = await shot('pan-01-panned'); + + await fingerDrag(page, to, from); // drag back — translation is invertible + await page.waitForTimeout(500); + const restored = await shot('pan-02-restored'); + + const dPan = await diffRatio(page, base, panned, box); + const dBack = await diffRatio(page, base, restored, box); + console.log(`[mobile pan] dPan=${dPan.toFixed(3)} dBack=${dBack.toFixed(3)}`); + + expect(dPan, 'one-finger drag visibly pans the view').toBeGreaterThan(0.006); + expect(dBack, 'panning back returns near baseline').toBeLessThan(dPan / 3); +}); + +test('editor is canvas-only: no menubar, GL canvas fills the viewport', async () => { + await shot('chrome'); + + // The menubar is real DOM (.wx-menu-title per menu); AUI toolbars/panels are + // canvas islands, so the GL-canvas-fills-viewport check below is what proves + // THEY are gone (hidden AUI panes release their space to the center pane). + const menus = await page.evaluate( + () => + Array.from(document.querySelectorAll('.wx-menu-title')).filter((el) => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none'; + }).length, + ); + expect(menus, 'no visible menubar titles').toBe(0); + + // the drawing canvas reclaims the whole viewport + const box = await getGlBox(page); + const vp = page.viewportSize()!; + expect(box.width, 'GL canvas width ≈ viewport').toBeGreaterThan(vp.width * 0.95); + expect(box.height, 'GL canvas height ≈ viewport').toBeGreaterThan(vp.height * 0.9); + + // the shell's own persistent overlays are gone too + expect(await page.getByText(/console \(/).count(), 'console toggle hidden').toBe(0); +}); diff --git a/tests/web/tool-switch.spec.ts b/tests/web/tool-switch.spec.ts index 36911fa..82b1851 100644 --- a/tests/web/tool-switch.spec.ts +++ b/tests/web/tool-switch.spec.ts @@ -48,7 +48,7 @@ test.describe('web app — tool switching', () => { test('eeschema → Switch to PCB Editor navigates to pcbnew', async ({ page }) => { test.setTimeout(420000); // two full wasm boots - await page.goto('/p/demo/eeschema/demo.kicad_sch'); + await page.goto('/default/projects/demo/demo.kicad_sch'); await waitForToolReady(page, /demo — Schematic Editor/i); await switchTool( @@ -64,7 +64,7 @@ test.describe('web app — tool switching', () => { test('pcbnew → Switch to Schematic Editor navigates to eeschema', async ({ page }) => { test.setTimeout(420000); - await page.goto('/p/demo/pcbnew/demo.kicad_pcb'); + await page.goto('/default/projects/demo/demo.kicad_pcb'); await waitForToolReady(page, /demo — PCB Editor/i); await switchTool( diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index ce4c01c..fc56375 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -29,6 +29,10 @@ #include #include #include +#include +#include +#include +#include #include #include @@ -120,6 +124,52 @@ static bool kicadOpenFile( std::string path ) } +// Canvas-only mobile chrome toggle (features/mobile): hide/show every AUI pane +// except the central draw canvas, plus the menubar and status bar, so the GAL +// canvas fills the frame. Generic wxFrame/wxAui surface only (keeps this TU +// header-light and serves both editor frames). Hidden bars release their space +// because the wasm port's frame client-area math skips !IsShown() bars (native +// parity, see wxwidgets/src/wasm/frame.cpp). Returns false until the editor +// frame exists — main() builds it after runtime init — so JS polls this. +static bool kicadSetChrome( bool aShow ) +{ + wxFrame* frame = + wxTheApp ? dynamic_cast( wxTheApp->GetTopWindow() ) : nullptr; + + if( !frame ) + return false; + + if( wxMenuBar* menuBar = frame->GetMenuBar() ) + menuBar->Show( aShow ); + + // Kept alive rather than detached: KiCad SetStatusText()s on every cursor + // move, and wxFrameBase wxCHECKs a null status bar. + if( wxStatusBar* statusBar = frame->GetStatusBar() ) + statusBar->Show( aShow ); + + if( wxAuiManager* mgr = wxAuiManager::GetManager( frame ) ) + { + wxAuiPaneInfoArray& panes = mgr->GetAllPanes(); + + for( size_t i = 0; i < panes.GetCount(); ++i ) + { + wxAuiPaneInfo& pane = panes.Item( i ); + + // keep the central editor canvas (named "DrawFrame" in both editors) + if( pane.dock_direction == wxAUI_DOCK_CENTER || pane.name == wxT( "DrawFrame" ) ) + continue; + + pane.Show( aShow ); + } + + mgr->Update(); + } + + frame->SendSizeEvent(); + return true; +} + + // C++ → JS save notification. Called from BOTH fork save chokepoints // (PCB_EDIT_FRAME::SavePcbFile and SCH_EDIT_FRAME::saveSchematicFile) — one shared // definition serves the merged image. No-op without a JS listener. @@ -275,6 +325,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + // Canvas-only mobile mode (features/mobile). + function("kicadSetChrome", &kicadSetChrome); + // Yjs collaborative bridge entry points — same JS contract as the standalone // bundles, dispatched on the active editor frame. function("kicadCollabApply", &collabApply); diff --git a/web/standalone/src/App.tsx b/web/standalone/src/App.tsx index b22ae98..1bd2c72 100644 --- a/web/standalone/src/App.tsx +++ b/web/standalone/src/App.tsx @@ -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() { } /> } /> - {/* Version + source link, bottom-right on every route (home + editor). */} - + {/* Version + source link, bottom-right on every route (home + editor). + Mobile mode is canvas-only — no persistent overlays. */} + {!isMobileMode() && } ); } diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 557ce9f..b4e4fec 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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(["pl_editor", "eeschema", "pcbnew"]); @@ -689,6 +690,9 @@ export function WasmTool({ }) { const containerRef = React.useRef(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(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)) && (
{peers.length > 0 && ( )} - {sourceDescriptor && } + {sourceDescriptor && !mobileUi && ( + + )}
)} @@ -1365,20 +1373,22 @@ export function WasmTool({ )} -
- - {showLog && ( -
-            {logs.join("\n")}
-          
- )} -
+ {!mobileUi && ( +
+ + {showLog && ( +
+              {logs.join("\n")}
+            
+ )} +
+ )} ); } diff --git a/web/standalone/src/lib/mobile-mode.test.ts b/web/standalone/src/lib/mobile-mode.test.ts new file mode 100644 index 0000000..a34f860 --- /dev/null +++ b/web/standalone/src/lib/mobile-mode.test.ts @@ -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); + }); +}); diff --git a/web/standalone/src/lib/mobile-mode.ts b/web/standalone/src/lib/mobile-mode.ts new file mode 100644 index 0000000..55edd9a --- /dev/null +++ b/web/standalone/src/lib/mobile-mode.ts @@ -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; +} diff --git a/web/standalone/src/preflight/PreflightGate.tsx b/web/standalone/src/preflight/PreflightGate.tsx index 4785d83..26ea79f 100644 --- a/web/standalone/src/preflight/PreflightGate.tsx +++ b/web/standalone/src/preflight/PreflightGate.tsx @@ -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(() => 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(() => { + 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)); diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index 2718d01..9c4dfef 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -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=` 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 } | null = null; @@ -350,6 +355,13 @@ async function doBoot(opts: BootOptions): Promise { ); 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 { 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. diff --git a/web/standalone/src/wasm/touch-gestures.test.ts b/web/standalone/src/wasm/touch-gestures.test.ts new file mode 100644 index 0000000..1bf09c8 --- /dev/null +++ b/web/standalone/src/wasm/touch-gestures.test.ts @@ -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"]); + }); +}); diff --git a/web/standalone/src/wasm/touch-gestures.ts b/web/standalone/src/wasm/touch-gestures.ts new file mode 100644 index 0000000..c33424d --- /dev/null +++ b/web/standalone/src/wasm/touch-gestures.ts @@ -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-finger→LEFT-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); + }; +} diff --git a/wxwidgets b/wxwidgets index d4a4510..b13c4fa 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit d4a45100124cbfef846b999f7e1768892b1a9463 +Subproject commit b13c4fa5ed61d46ee28bd3ef3a9b788debfd34f7