From 6e810b66fcbf13c33782b2f0583840af2f7a8084 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 19:20:30 +0700 Subject: [PATCH] feat(studio): two fingers on the stage are the phone's own zoom, for aiming FIX and HEAL --- .../frontend/scripts/studio-pinch-check.mjs | 304 ++++++++++++++++++ docker/frontend/src/styles/app.css | 7 +- docker/frontend/src/ui/ImageStage.tsx | 93 ++++++ 3 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 docker/frontend/scripts/studio-pinch-check.mjs diff --git a/docker/frontend/scripts/studio-pinch-check.mjs b/docker/frontend/scripts/studio-pinch-check.mjs new file mode 100644 index 0000000..ebeb290 --- /dev/null +++ b/docker/frontend/scripts/studio-pinch-check.mjs @@ -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= CHROME= \ +// 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='); + 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); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 945caad..da52053 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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. */ diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 3199b48..e0d97fe 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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()); + 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