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:
parent
3a2255c817
commit
dc7c60f723
13 changed files with 1039 additions and 24 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
306
tests/web/mobile-editor.spec.ts
Normal file
306
tests/web/mobile-editor.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@
|
|||
#include <wx/app.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/window.h>
|
||||
#include <wx/frame.h>
|
||||
#include <wx/menu.h>
|
||||
#include <wx/statusbr.h>
|
||||
#include <wx/aui/framemanager.h>
|
||||
#include <kiway.h>
|
||||
#include <kiway_player.h>
|
||||
|
||||
|
|
@ -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<wxFrame*>( 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);
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
web/standalone/src/lib/mobile-mode.test.ts
Normal file
57
web/standalone/src/lib/mobile-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
web/standalone/src/lib/mobile-mode.ts
Normal file
35
web/standalone/src/lib/mobile-mode.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
218
web/standalone/src/wasm/touch-gestures.test.ts
Normal file
218
web/standalone/src/wasm/touch-gestures.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
274
web/standalone/src/wasm/touch-gestures.ts
Normal file
274
web/standalone/src/wasm/touch-gestures.ts
Normal 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-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);
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit d4a45100124cbfef846b999f7e1768892b1a9463
|
||||
Subproject commit b13c4fa5ed61d46ee28bd3ef3a9b788debfd34f7
|
||||
Loading…
Reference in a new issue