diff --git a/docker/frontend/scripts/studio-drag-check.mjs b/docker/frontend/scripts/studio-drag-check.mjs new file mode 100644 index 0000000..4deefb6 --- /dev/null +++ b/docker/frontend/scripts/studio-drag-check.mjs @@ -0,0 +1,322 @@ +// One finger on the photo, while a chip's ruler is open — the knob moved off +// the picture instead of out of the row. +// +// A phone's ruler stands in a column at the foot of the screen and the corner a +// reader is judging sits above it, so the hand leaves the number to go and look +// at what the number is doing. What this holds down is the trade the stage makes +// instead: with a ruler open, one finger dragged across the photo IS that +// ruler's track — right for up the scale, left for down it, the range travelling +// the photo's whole width, the same travel the row's own slider has — and the +// number stops at its own ends. It holds down the two things that could be lost +// to it as well: two fingers are still the phone's own zoom (a pinch is not a +// value, so the finger's hold is given up the moment the second one lands), and +// with the row put away the same drag changes nothing at all. +// +// npm run build && node scripts/studio-drag-check.mjs +// PLAYWRIGHT_CORE= CHROME= \ +// APP_PORT=6164 SAMPLE_PORT=6165 node scripts/studio-drag-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-pinch-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 ?? 4312); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4313); +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 what a real finger does on the +// browser's own pointers, not what a script can make one look like. +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); + }); +// The box the photo is fitted to: the drag is measured against its width, the +// way the stage measures it. +const stageBox = () => + page.$eval('.canvas-wrap', (e) => { + const r = e.getBoundingClientRect(); + return { w: r.width, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; + }); +// The open ruler's number, read off the row's own track: the slider is what the +// drag writes, so asking the slider is asking what landed. +const rulerValue = () => page.$eval('[data-col="slider"] input[type="range"]', (e) => Number(e.value)); +const chipText = (key) => page.$eval(`[data-key="${key}"]`, (e) => e.textContent ?? ''); + +// One finger: down, walked across in even steps, lifted. +const drag = async (from, [dx, dy], steps = 8) => { + await touch('touchStart', [from]); + for (let i = 1; i <= steps; i++) await touch('touchMove', [[from[0] + (dx * i) / steps, from[1] + (dy * i) / steps]]); + await touch('touchEnd', []); + await page.waitForTimeout(250); +}; + +// Two fingers put down `from` either side of the middle of the stage and walked +// apart to `to` — the zoom, unchanged from `studio-pinch-check.mjs`. +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 -------------------------------------------------- +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"]'); +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()}`); + +// --- a ruler, open over the photo -------------------------------------------- +// LIGHT's panels are the phone's chips, and a panel's row is a knob: the tap +// opens its ruler in the column above the strip (see .col-slider). +await page.click('nav.rail button:has-text("LIGHT")'); +await page.waitForSelector('[data-key="dev-tone"]', { timeout: 30_000 }); +await page.click('[data-key="dev-tone"]'); +await page.waitForSelector('[data-key="contrast"]', { timeout: 30_000 }); +await page.click('[data-key="contrast"]'); +await page.waitForSelector('[data-col="slider"] input[type="range"]', { timeout: 30_000 }); +step('a tap on a knob opens its ruler, the row the photo is about to become', (await rulerValue()) === 0, `CONTRAST ${await rulerValue()}`); + +// --- one finger across the photo --------------------------------------------- +// CONTRAST is one of the bipolar knobs the spec draws from -100 to +100 (see +// `deepen` in shared/utils/paramDefs), so a quarter of the photo's width is +// fifty of them — and the number is on the row, not under the hand. +const box = await stageBox(); +await drag([box.cx, box.cy], [box.w * 0.25, 0]); +const quarter = await rulerValue(); +step('a finger dragged to the right lifts the knob with it', Math.abs(quarter - 50) <= 1, `CONTRAST ${quarter} for a quarter of the width`); +step('and the photo stayed where it was — the drag is the value, not the view', (await scale()) === 1, `scale ${await scale()}`); + +// Past the far edge the number stops at its own end rather than running on. +await drag([box.cx, box.cy], [box.w * 0.6, 0]); +step('dragged past the edge, the knob stops at its own end', (await rulerValue()) === 100, `CONTRAST ${await rulerValue()}`); + +// --- two fingers are still the zoom ----------------------------------------- +// The second finger is not a second value: the zoom wins, and the knob it was +// holding is left exactly where the pinch found it. +const held = await rulerValue(); +await pinch([box.cx, box.cy], 30, 150); +const zoomed = await scale(); +step('two fingers on the photo are still the phone’s own zoom', zoomed >= 2, `scale ${zoomed.toFixed(2)}`); +step('and a pinch is not a value — the knob it let go did not move', (await rulerValue()) === held, `CONTRAST ${held} -> ${await rulerValue()}`); +await pinch([box.cx, box.cy], 150, 30); +await page.waitForTimeout(200); +step('and two fingers together put the photo back', (await scale()) === 1, `scale ${(await scale()).toFixed(2)}`); + +// --- and back down the scale ------------------------------------------------- +await drag([box.cx, box.cy], [-box.w * 0.25, 0]); +const down = await rulerValue(); +step('dragged to the left the knob comes down with it', Math.abs(down - 50) <= 1, `CONTRAST ${down}`); + +// --- with the row put away --------------------------------------------------- +// The drag belongs to the ruler, not to the photo, and a phone puts the row away +// through the path under the strip — the strip it came from is off the screen +// while the row stands (see the rule that hides the options column). The same +// finger across the same picture then moves nothing at all. +const before = await chipText('contrast'); +await page.click('[data-key="crumb-strip"]'); +await page.waitForFunction(() => !document.querySelector('[data-col="slider"] input[type="range"]'), null, { timeout: 30_000 }); +await drag([box.cx, box.cy], [box.w * 0.3, 0]); +step('with the ruler put away the same drag changes nothing', (await chipText('contrast')) === before, `${before} -> ${await chipText('contrast')}`); +step('and the photo is still fitted, neither panned nor zoomed', (await scale()) === 1, `scale ${await scale()}`); + +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/App.tsx b/docker/frontend/src/App.tsx index 923b16a..deb71d2 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -4720,6 +4720,16 @@ export function Workspace() { canCompare={!!source} onSaveOriginal={() => void saveOriginal()} onClear={() => setClearing(true)} + // The open ruler, handed to the stage as the track the photo itself + // becomes: a chip's ruler stands at the foot of a phone's screen, and + // the corner being judged is at the top of it, so a finger dragged + // across the picture moves the knob instead. STRAIGHTEN and TONE CURVE + // open no row (see `slider`), and neither hands anything over. + rulerDrag={ + slider + ? { value: slider.value, min: slider.min, max: slider.max, step: slider.step, onChange: slider.onChange } + : null + } picking={picking} onPickColor={pickColor} brushTool={brushTool} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index e0d97fe..e09e2bf 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -60,6 +60,20 @@ export type WmMark = { onMove: (p: { x?: number; y?: number; size?: number }) => void; }; +// A ruler that is open while the photo is under the hand. A phone has no wheel +// to notch and the row the ruler stands in is off at the foot of the screen, +// away from the thing being judged: with one of these in hand, the photo itself +// is the track — a finger dragged across it moves the value the same distance +// the drag covers, so the number can be found on the picture it is changing. +// The range travels the whole width, the same travel the row's own slider has. +export type RulerDrag = { + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; +}; + // One mark's box, from the numbers the engine draws it with. The width of the // text is the face's own when the mark carries a family (measureMark), and the // monospace em above otherwise; the ascent/descent come back with it, because @@ -270,6 +284,7 @@ export function ImageStage({ marks = [], straightenRuler = null, curvePanel = null, + rulerDrag = null, // FX's brush: the tool that takes the pointer on the photo, and the size a // spot is laid down at. One spot is a circle — HEAL's the area it fills, // MOSAIC's the cell it hides. Either way they are fractions of the render, @@ -351,6 +366,13 @@ export function ImageStage({ // rather than in a column beside it. It is anchored, not draggable — the graph // is a fixed square and the photo moves under it. curvePanel?: ReactNode; + // The ruler a chip has open, when one is. While it is here a finger dragged + // across the photo moves that knob instead of the view — the value travels + // with the hand's own direction, right for up the scale and left for down it + // — and two fingers are still the phone's own zoom, because a pinch is not a + // value (see the listeners on the stage's box). Null leaves the photo's + // gestures exactly as they were. + rulerDrag?: RulerDrag | null; // FX's brush, HEAL or MOSAIC: the circle under the cursor is the size of the // spot a gesture lays down (the wheel sets it), and one gesture reports its // spots together — one gesture is one undo step however many spots it laid. @@ -998,8 +1020,25 @@ export function ImageStage({ // 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). + // + // The same listeners carry the ruler: a chip's ruler stands in a row at the + // foot of a phone's screen, which is a long way from the corner a reader is + // looking at, and one finger is not spoken for while a ruler is open (opening + // one puts FIX's brush and the mask tool down). So one finger there is that + // ruler's track and two are still the zoom — the pinch wins the moment its + // second finger lands. const fingersRef = useRef(new Map()); const pinchRef = useRef<{ d: number; s: number; px: number; py: number } | null>(null); + // The open ruler, read through a ref so the listener below is bound once per + // stage rather than once per render: the value it is on belongs to the parent + // and changes on every move of the very drag it is measuring. + const rulerDragRef = useRef(rulerDrag); + rulerDragRef.current = rulerDrag; + // The ruler's own drag, in hand: which finger started it, where it began and + // what the value was then. Read against its own beginning rather than against + // the last move, so a step the pointer snaps to is never counted twice and the + // number under the finger never drifts away from it. + const rulerRef = useRef<{ id: number; x: number; value: number } | null>(null); useEffect(() => { const wrap = wrapRef.current; if (!wrap) return; @@ -1019,7 +1058,18 @@ export function ImageStage({ const down = (e: PointerEvent) => { if (e.pointerType !== 'touch') return; fingersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + // The first finger is the open ruler's, when there is one: where it went + // down and what the knob read at that moment. Nothing is changed yet — a + // tap on the photo is not a move of the number. + const r = rulerDragRef.current; + if (fingersRef.current.size === 1) { + if (r) rulerRef.current = { id: e.pointerId, x: e.clientX, value: r.value }; + return; + } if (fingersRef.current.size !== 2) return; + // The second finger is the zoom, and a pinch is not a value: whatever the + // first finger had hold of is let go the moment this one lands. + rulerRef.current = null; const m = pinch(); if (!m || !(m.d > 0)) return; // The photo's own point under the fingers, and the size the stage is at: @@ -1035,7 +1085,25 @@ export function ImageStage({ finger.y = e.clientY; } const p = pinchRef.current; - if (!p) return; + if (!p) { + // One finger, no pinch: the ruler's own drag. The photo's width is the + // track — the range travels it end to end, the same travel the row's + // slider has — and the value is read from where this drag began, so a + // hand that comes back the way it went lands on the number it left. + const r = rulerRef.current; + const d = rulerDragRef.current; + if (!r || !d || r.id !== e.pointerId) return; + const width = wrap.getBoundingClientRect().width; + if (!(width > 0) || !(d.max > d.min)) return; + e.preventDefault(); + const step = d.step > 0 ? d.step : 1; + const raw = r.value + ((e.clientX - r.x) / width) * (d.max - d.min); + const next = Math.min(d.max, Math.max(d.min, Number((Math.round(raw / step) * step).toFixed(4)))); + // A move that lands on the same step as the last one is not an edit: + // firing it would lay a second undo step over a drag that never left. + if (next !== d.value) d.onChange(next); + 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. @@ -1051,6 +1119,7 @@ export function ImageStage({ setView({ s, ...clampPan(s, m.x - s * p.px, m.y - s * p.py) }); }; const up = (e: PointerEvent) => { + if (rulerRef.current?.id === e.pointerId) rulerRef.current = null; 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 @@ -1068,6 +1137,7 @@ export function ImageStage({ wrap.removeEventListener('pointercancel', up); fingersRef.current.clear(); pinchRef.current = null; + rulerRef.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. @@ -1329,6 +1399,10 @@ export function ImageStage({ const startPan = (e: React.PointerEvent) => { if (cropEditing || compare || e.button !== 0) return; + // A ruler that is open owns the finger: dragging the photo is moving that + // knob, which is the same drag the listener on the wrap reads (see above). + // Two fingers still pan and zoom, so the view is never out of reach. + if (rulerDrag && e.pointerType === 'touch') return; if (frameId === 'custom' && onCustomFramePan) { e.preventDefault(); e.stopPropagation();