feat(studio): two fingers on the stage are the phone's own zoom, for aiming FIX and HEAL

This commit is contained in:
2026-10-13 19:20:30 +07:00
parent 60c2c33eef
commit 6e810b66fc
3 changed files with 403 additions and 1 deletions
@@ -0,0 +1,304 @@
// Two fingers on the phone's stage — the zoom FIX and HEAL are aimed with.
//
// FIX's brushes are aimed at a zoom: a speck of dust is a few pixels wide on a
// frame fitted to a phone's screen, and a repair laid on the fitted photo is a
// smear. A phone has no wheel to notch, so two fingers are the whole of how a
// reader gets close enough — apart to zoom in, together to zoom out, their
// midpoint travelling to move the photo, and one finger left over to paint with.
//
// What this holds down is that the gesture reaches the stage *with the brush's
// own layer over the photo* (the layer takes the pointer for HEAL, so a listener
// bound to the photo would never hear it), that the pointer the browser would
// otherwise take for its own page zoom is taken by the stage instead (see
// `touch-action` on `.canvas-wrap`), that the pinch lays no repair down on its
// way, and that the finger left when it ends is the one that paints.
//
// npm run build && node scripts/studio-pinch-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to a chromium> \
// APP_PORT=6150 SAMPLE_PORT=6151 node scripts/studio-pinch-check.mjs
//
// Playwright is not a dependency of this package (a browser run needs it, a
// build does not), so a run without it says SKIP and exits 0. Two real frames
// are served off a local server and `showDirectoryPicker` is answered with a
// folder made of them, the same stand-in `studio-export-check.mjs` uses.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4310);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4311);
const A_NAME = 'P1010256.JPG';
const B_NAME = 'D0004128.JPG';
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the two frames, off a local server (the page fetches them by CORS) ------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (name !== A_NAME && name !== B_NAME) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// A server already up, when one is named; otherwise the build in `dist/`.
let app = null;
const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`;
if (!process.env.BASE) {
app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
}
// --- the browser, on a phone -------------------------------------------------
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
// A folder of two frames, handed over the way the browser hands one over.
const INIT = ({ origin, names, folder }) => {
const bytes = new Map();
const load = async (n) => {
if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer()));
return bytes.get(n);
};
const fileHandle = (name) => {
const h = { kind: 'file', name, __fake: true };
Object.defineProperties(h, {
getFile: {
value: async () =>
new File([await load(name)], name, {
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
lastModified: Date.UTC(2026, 0, 1),
}),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return h;
};
const dirHandle = (label) => {
const h = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(h, {
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: { value: async () => { throw new DOMException('no folder', 'NotFoundError'); } },
getFileHandle: {
value: async (name) => {
if (!names.includes(name)) throw new DOMException('no frame', 'NotFoundError');
return fileHandle(name);
},
},
});
return h;
};
const root = () => dirHandle(folder);
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile'))
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
return row;
};
for (const m of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[m];
IDBObjectStore.prototype[m] = function (...a) {
const req = original.apply(this, a);
const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(req, 'result', {
configurable: true,
get() {
const v = d.get.call(this);
return Array.isArray(v) ? v.map(revive) : revive(v);
},
});
return req;
};
}
};
// The phone the gesture is made on: a width under the 860px the app calls
// mobile at, a touch screen, and nothing else changed.
const context = await browser.newContext({
viewport: { width: 412, height: 900 },
hasTouch: true,
isMobile: true,
deviceScaleFactor: 1,
});
// No server is up behind this build, and a vite preview answers an API path
// with the SPA's own `index.html` — which parses to `{}` and so reads as an
// empty *answer* rather than as "no server", taking the studio down with it.
const replies = {
'/api/auth/me': { user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } },
'/api/recipes': { recipes: [] },
'/api/photos/mine': { photos: [] },
'/api/photos': { photos: [] },
};
await context.route('**/api/**', (route) => {
const body = replies[new URL(route.request().url()).pathname];
return route.fulfill(
body
? { status: 200, contentType: 'application/json', body: JSON.stringify(body) }
: { status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'HTTP 404' }) }
);
});
await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [A_NAME, B_NAME], folder: 'CheckRoll' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
// --- the fingers -------------------------------------------------------------
// Touch, through the browser's own input pipeline rather than as a hand-made
// `PointerEvent`: the point of the check is that a real pinch is not swallowed
// by the browser's page zoom, and that is the pipeline's business, not a script's.
const cdp = await context.newCDPSession(page);
const touch = async (type, points) => {
await cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: points.map(([x, y], i) => ({ x, y, id: i + 1 })),
});
};
// The stage's own zoom, read the way the reader sees it: the transform on the
// photo. Untransformed is the fitted photo, which is 1.
const scale = () =>
page.$eval('.canvas-wrap img', (e) => {
const t = getComputedStyle(e).transform;
return t === 'none' ? 1 : Number(new DOMMatrixReadOnly(t).a);
});
const stageAt = () =>
page.$eval('.canvas-wrap', (e) => {
const r = e.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
});
// The gesture itself: two fingers put down `from` either side of the middle of
// the stage, walked apart to `to`, and lifted together.
const pinch = async ([cx, cy], from, to) => {
await touch('touchStart', [
[cx - from, cy],
[cx + from, cy],
]);
for (const d of [1 / 3, 2 / 3, 1]) {
await touch('touchMove', [
[cx - (from + (to - from) * d), cy],
[cx + (from + (to - from) * d), cy],
]);
}
await touch('touchEnd', []);
await page.waitForTimeout(300);
};
// --- the frame on the stage, with HEAL armed ---------------------------------
await page.goto(`${base}/library`);
// The library has a phone's own screen at this width, and the catalogue is
// picked on the other one; the studio the frame opens into is the phone's either
// way, because that is the window's width and not this switch (see `isMobile`).
await page.click('button:has-text("Desktop")').catch(() => {});
await page.click('[data-key="lib-add"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length >= 2, '[data-key^="lib-thumb-"]', {
timeout: 120_000,
});
await page.click('[data-key="lib-open"]');
// The phone's studio is the stage and the bar of tabs: the chips of a tab stand
// behind it until one of them is opened, so what says the frame has landed is
// the photo on the stage.
await page.waitForFunction(() => {
const img = document.querySelector('.canvas-wrap img');
return !!img && img.getBoundingClientRect().width > 0;
}, null, { timeout: 120_000 });
await page.waitForTimeout(1500);
step('the catalogue frame is on the phone’s stage, fitted', (await scale()) === 1, `${page.url()}`);
// --- the photo itself, with no tool armed ------------------------------------
// The fingers are the same two wherever they land, and what they are on is the
// photo until a tool is armed. The browser would take the gesture for its own
// page zoom and cancel the pointers under it, which is what `touch-action` on
// the stage's own box is there to stop.
const [cx, cy] = await stageAt();
await pinch([cx, cy], 30, 150);
const bare = await scale();
step('two fingers on the photo bring it up close', bare >= 2, `scale ${bare.toFixed(2)}`);
await pinch([cx, cy], 150, 30);
step('and two fingers together put it back', (await scale()) === 1, `scale ${(await scale()).toFixed(2)}`);
await page.click('nav.rail button:has-text("TOOLS")');
await page.waitForSelector('[data-key="fix"]', { timeout: 30_000 });
await page.click('[data-key="fix"]');
await page.waitForSelector('[data-key="heal"]', { timeout: 30_000 });
await page.click('[data-key="heal"]');
await page.waitForSelector('[data-key="brush-layer"]', { timeout: 30_000 });
step('and HEAL is armed, its own layer over the photo', true);
// --- apart again, with HEAL's own layer over the photo ------------------------
await pinch([cx, cy], 30, 150);
const zoomed = await scale();
step('two fingers apart bring the photo up close', zoomed >= 2, `scale ${zoomed.toFixed(2)}`);
step(
'and the fingers that zoomed laid no repair down on the way',
(await page.$('[data-key="heal-clear"]')) === null,
(await page.$('[data-key="heal-clear"]')) ? 'a repair landed' : 'nothing laid'
);
// --- one finger: the tool the reader came for --------------------------------
await touch('touchStart', [[cx - 40, cy]]);
for (let i = 1; i <= 6; i++) await touch('touchMove', [[cx - 40 + i * 8, cy + 4]]);
await touch('touchEnd', []);
await page.waitForSelector('[data-key="heal-clear"]', { timeout: 10_000 }).catch(() => {});
step(
'and one finger up close paints — the repair the zoom was for',
(await page.$('[data-key="heal-clear"]')) !== null,
(await page.$('[data-key="heal"]')) ? await page.$eval('[data-key="heal"]', (e) => e.textContent) : 'no chip'
);
// --- together: the photo goes back ------------------------------------------
await pinch([cx, cy], 150, 30);
const back = await scale();
step('two fingers together put the photo back on the screen', back === 1, `scale ${back.toFixed(2)}`);
step('no uncaught error', errors.length === 0, errors.join(' | '));
await context.close();
await browser.close();
sampleServer.close();
app?.kill('SIGTERM');
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
process.exit(failures ? 1 : 0);
+6 -1
View File
@@ -552,7 +552,12 @@ button.hint:disabled { cursor: default; }
rather than riding on the sentence above it. The click on this button must
not also open the file dialog the dropzone itself listens for. */
.dropzone .btn { margin-top: 14px; }
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; }
/* `touch-action: none` because the two fingers are the stage's own gesture: the
browser would otherwise take a pinch on the photo for its own page zoom, cancel
the pointers under it, and the photo would never be the thing that grew. The
layers over the photo — the brush's, the masks' — already say the same; this is
the photo itself, and the space around it. */
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; touch-action: none; }
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
/* Crop editor: the layer is the photo's own box, the rect is the framing and
its shadow is what shades the part being cut away. */
+93
View File
@@ -980,6 +980,99 @@ export function ImageStage({
return () => el.removeEventListener('wheel', onWheel);
}, [onBrushR, brushR, brushTool, box, zoomAt]);
// --- two fingers on the stage: apart to zoom in, together to zoom out ------
// A phone has no wheel and no room for a double click, and FIX and HEAL are
// aimed at a zoom — a speck of dust is a few pixels wide, and a repair laid on
// the fitted photo lands a smear. So the stage takes the one gesture a phone
// has for it: two fingers, their own spread being the zoom and their midpoint
// travelling being the pan, so a reader can zoom into a corner and stay there
// with one finger left over to paint with. The point under the fingers when
// the second one lands is the point that stays put, the same way a wheel notch
// holds the point under the cursor (see `zoomAt`).
//
// Watched on the wrap rather than on the photo, because the photo is not what
// the pointer is on while a tool is armed: the brush's own layer lies over it
// and takes the pointer for HEAL and MOSAIC, and a mask layer does the same
// for GRADIENT MASK — which is exactly when a reader needs to zoom. A stroke
// the first finger had started is given up the moment the second one lands:
// the pinch is what two fingers mean, and what is left of the stroke is not
// laid down (the pointer releases the stroke rather than repainting it with
// the finger after).
const fingersRef = useRef(new Map<number, { x: number; y: number }>());
const pinchRef = useRef<{ d: number; s: number; px: number; py: number } | null>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
// The two fingers as one: the midpoint between them, in the wrap's own
// coordinates and about its centre (which is what the transform is about),
// and how far apart they are.
const pinch = () => {
const two = [...fingersRef.current.values()];
if (two.length !== 2) return null;
const r = wrap.getBoundingClientRect();
return {
x: (two[0].x + two[1].x) / 2 - r.left - r.width / 2,
y: (two[0].y + two[1].y) / 2 - r.top - r.height / 2,
d: Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y),
};
};
const down = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
fingersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (fingersRef.current.size !== 2) return;
const m = pinch();
if (!m || !(m.d > 0)) return;
// The photo's own point under the fingers, and the size the stage is at:
// both are what the gesture is measured against, and neither moves while
// it runs.
const v = viewRef.current;
pinchRef.current = { d: m.d, s: v.s, px: (m.x - v.x) / v.s, py: (m.y - v.y) / v.s };
};
const move = (e: PointerEvent) => {
const finger = fingersRef.current.get(e.pointerId);
if (finger) {
finger.x = e.clientX;
finger.y = e.clientY;
}
const p = pinchRef.current;
if (!p) return;
// What the first finger had begun — a stroke, a repair in hand, a pan — is
// dropped here rather than at the second `pointerdown`, because the
// brush's own handler on that layer runs after this listener does.
if (strokeRef.current || grabRef.current !== null || panRef.current) {
strokeRef.current = null;
grabRef.current = null;
panRef.current = null;
setBrushTrail([]);
}
const m = pinch();
if (!m) return;
const s = Math.min(ZOOM_MAX, Math.max(1, p.s * (m.d / p.d)));
setView({ s, ...clampPan(s, m.x - s * p.px, m.y - s * p.py) });
};
const up = (e: PointerEvent) => {
fingersRef.current.delete(e.pointerId);
// One finger left is not a pinch: the stage stays where the gesture put it
// and the finger that is left paints nothing until it is lifted and put
// down again, which is the whole of how a stroke is started.
if (fingersRef.current.size < 2) pinchRef.current = null;
};
wrap.addEventListener('pointerdown', down);
wrap.addEventListener('pointermove', move);
wrap.addEventListener('pointerup', up);
wrap.addEventListener('pointercancel', up);
return () => {
wrap.removeEventListener('pointerdown', down);
wrap.removeEventListener('pointermove', move);
wrap.removeEventListener('pointerup', up);
wrap.removeEventListener('pointercancel', up);
fingersRef.current.clear();
pinchRef.current = null;
};
// `previewUrl` because the wrap is only on the stage once a frame is: the
// listener has to follow the node it is bound to.
}, [clampPan, previewUrl]);
// --- FX's gradient masks ---------------------------------------------------
// A mask is drawn rather than painted: one drag is one shape, and the drag's
// two ends are what is stored (the ramp of a linear mask, the centre and the