diff --git a/docker/frontend/scripts/studio-drag-check.mjs b/docker/frontend/scripts/studio-drag-check.mjs index 048074e..8648452 100644 --- a/docker/frontend/scripts/studio-drag-check.mjs +++ b/docker/frontend/scripts/studio-drag-check.mjs @@ -1,18 +1,16 @@ -// One finger on the photo, while a chip's ruler is open — the knob moved off -// the picture instead of out of the row. +// The phone's own gestures in the studio, once a chip's ruler is open — and the +// one the ruler is not allowed to take. // // 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. The row's own -// track takes a touch anywhere on it too, whatever the engine would have done -// with the thumb's 3px box. +// reader is judging sits above it, so the ruler is what the hand has to reach: +// the number is taken by the touch that lands on the track itself, wherever on +// it that is. WebKit begins a slider drag only inside the thumb's own box — a +// 3px line here, see `SliderThumbElement::handleTouchStart` — so the middle of +// the ruler used to answer nothing on a phone, and the row reads the value from +// the touch's own place on the track on every engine instead. What is held down +// besides that is that the photo stays the photo's: two fingers zoom it, one +// finger dragged across it pans it once it is bigger than its box, and the open +// knob moves under none of them. // // npm run build && node scripts/studio-drag-check.mjs // PLAYWRIGHT_CORE= CHROME= \ @@ -200,11 +198,15 @@ const touch = async (type, points) => { // 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 = () => +// The stage's own transform, read the way the reader sees it: the photo's own +// box. Fitted, untransformed is scale 1 and nowhere to pan to. +const view = () => page.$eval('.canvas-wrap img', (e) => { const t = getComputedStyle(e).transform; - return t === 'none' ? 1 : Number(new DOMMatrixReadOnly(t).a); + const m = t === 'none' ? new DOMMatrixReadOnly() : new DOMMatrixReadOnly(t); + return { s: m.a, x: m.e, y: m.f }; }); +const scale = async () => (await view()).s; // The box the photo is fitted to: the drag is measured against its width, the // way the stage measures it. const stageBox = () => @@ -215,7 +217,6 @@ const stageBox = () => // 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) => { @@ -262,78 +263,26 @@ step('the catalogue frame is on the phone’s stage, fitted', (await scale()) == // --- 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). +// opens its ruler 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()}`); - -// --- the look waits for the hand to come off --------------------------------- -// Rendering the look is the one thing in a drag that costs more than a frame, so -// it is held while the finger is down: the number moves on the row as the hand -// does, the photo's own src does not — not even over a pause a debounce would -// have rendered on — and it lands once, when the finger lifts. -const srcBefore = await page.$eval('.canvas-wrap img', (e) => e.src); -const from = await rulerValue(); -await touch('touchStart', [[box.cx, box.cy]]); -await touch('touchMove', [[box.cx + box.w * 0.1, box.cy]]); -await page.waitForTimeout(600); -const srcMid = await page.$eval('.canvas-wrap img', (e) => e.src); -const live = await rulerValue(); -step('the number is on the row while the hand is still on the photo', Math.abs(live - (from + 20)) <= 1, `CONTRAST ${from} -> ${live}`); -step('and the look itself is not rendered under it', srcMid === srcBefore, srcMid === srcBefore ? 'the photo held' : 'the photo re-rendered mid-drag'); -await touch('touchMove', [[box.cx + box.w * 0.2, box.cy]]); -await touch('touchEnd', []); -await page.waitForFunction((before) => document.querySelector('.canvas-wrap img')?.src !== before, srcBefore, { timeout: 30_000 }).catch(() => {}); -const srcAfter = await page.$eval('.canvas-wrap img', (e) => e.src); -step('the look lands once, when the finger lifts', srcAfter !== srcBefore, srcAfter === srcBefore ? 'nothing rendered' : 'one render for the gesture'); -step('and the knob stopped where the hand left it', Math.abs((await rulerValue()) - (from + 40)) <= 1, `CONTRAST ${await rulerValue()}`); - -// 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}`); +step('a tap on a knob opens its ruler, the row the number is read off', (await rulerValue()) === 0, `CONTRAST ${await rulerValue()}`); // --- the hand on the ruler itself -------------------------------------------- -// The other half of the same trade, and the one an engine can take away: the -// number can be had by the hand that lands on the ruler, not only by the one -// walking across the photo. WebKit begins a slider drag only when the touch -// falls inside the thumb's own box — a 3px line here (see `SliderThumbElement:: -// handleTouchStart`) — so on a phone the ticks across the track answered a finger -// while the middle of the ruler answered nothing. The row reads the value from -// the touch's own place on the track, so what is made here is the gesture a -// browser's slider would take: an untrusted `PointerEvent`, which Blink's own -// slider ignores and the row's own listener does not. The whole gesture is put -// down on one pixel of the box it started on, the way a hand is. +// The number is had by the hand that lands on the ruler, and that is an +// engine's business, not the reader's: WebKit begins a slider drag only when +// the touch falls inside the thumb's own box — a 3px line here (see +// `SliderThumbElement::handleTouchStart`) — so on a phone the ticks across the +// track answered a finger while the middle of the ruler answered nothing. The +// row reads the value from the touch's own place on the track instead, on every +// engine, so what is made here is the gesture a browser's slider would take: an +// untrusted `PointerEvent`, which Blink's own slider ignores and the row's own +// listener does not. The whole gesture is put down on one pixel of the box it +// started on, the way a hand is. const trackTouch = (rail, fraction, type = 'pointerdown', pointerId = 7, pointerType = 'touch') => page.$eval( '[data-col="slider"] input[type="range"]', @@ -362,6 +311,8 @@ const trackRect = () => return { left: r.left, width: r.width, mid: r.top + r.height / 2, value: Number(e.value) }; }); +// CONTRAST is one of the bipolar knobs the spec draws from -100 to +100 (see +// `deepen` in shared/utils/paramDefs), so 85% of the track is +70. const rail = await trackRect(); await trackTouch(rail, 0.85); const onTrack = await rulerValue(); @@ -376,23 +327,37 @@ step('a second finger is not a second value', (await rulerValue()) === sole, `CO await trackTouch(rail, 0.2, 'pointermove'); step('and the number comes down as the hand goes back', Math.abs((await rulerValue()) + 60) <= 1, `CONTRAST ${await rulerValue()} at a fifth`); await trackTouch(rail, 0.5, 'pointerup'); -const lifted = await rulerValue(); +const left = await rulerValue(); await trackTouch(rail, 0.95, 'pointermove'); -step('the number stands where the finger was lifted', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); +step('the number stands where the finger was lifted', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`); await trackTouch(rail, 0.1, 'pointerdown', 11, 'mouse'); -step('and the mouse keeps the drag the engines already give it', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); +step('and the mouse keeps the drag the engines already give it', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`); -// --- 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 }); +// --- the photo is still the photo's ------------------------------------------ +// The ruler is not what a finger on the picture moves: the picture is. Zoomed +// in, one finger drags it across the box — which is the drag a ruler used to +// take for itself, and the reason the view could not be moved by hand while a +// ruler was open. +const box = await stageBox(); +await pinch([box.cx, box.cy], 30, 150); +const zoomed = await view(); +step('two fingers on the photo are still the phone’s own zoom', zoomed.s >= 2, `scale ${zoomed.s.toFixed(2)}`); +step('and a pinch is not a value — the knob it let go did not move', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`); +await drag([box.cx, box.cy], [box.w * 0.2, 0]); +const panned = await view(); +step('with the photo up close one finger drags it across the box', Math.abs(panned.x - zoomed.x) > 20, `x ${Math.round(zoomed.x)} -> ${Math.round(panned.x)}`); +step('and the open ruler’s number is not what that hand was moving', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`); + +// The view is put back, and a fitted photo has nowhere to go: the same drag +// leaves both the picture and the number exactly where they were. +await pinch([box.cx, box.cy], 150, 30); +await page.waitForTimeout(200); +step('and two fingers together put the photo back', (await view()).s === 1, `scale ${(await view()).s.toFixed(2)}`); +const fitted = await view(); 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()}`); +const still = await view(); +step('a fitted photo has nowhere to pan to, ruler open or not', still.x === fitted.x && still.s === 1, `x ${Math.round(fitted.x)} -> ${Math.round(still.x)}`); +step('and the number the ruler is on did not move either', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`); step('no uncaught error', errors.length === 0, errors.join(' | ')); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 55d61b8..923b16a 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -505,12 +505,6 @@ export function Workspace() { const [flipH, setFlipH] = useState(() => session.flipH ?? false); const [flipV, setFlipV] = useState(() => session.flipV ?? false); const [openParam, setOpenParam] = useState(null); - // A finger on the photo, moving the open ruler. The look it is moving is not - // rendered while the hand is down: a develop and a JPEG is the one thing here - // that costs more than a frame, and a render landing between two moves of a - // drag is the drag the reader feels. The number the finger stops on is applied - // when it lifts, and the render follows it then — one for the whole gesture. - const [rulerDragging, setRulerDragging] = useState(false); // The HSL mixer: which hue band the ruler edits, whether the eyedropper is // armed, and the last colour it read off the photo. const [hslBand, setHslBand] = useState('red'); @@ -1054,11 +1048,6 @@ export function Workspace() { // recipe, so a preview behind it would only compete for the same renderer — // and the user is watching the live one. It comes back with the camera. if (!engine || !previewBytes || shooting) return; - // A hand on the photo is a drag of the open ruler, and a drag is not a - // moment to render: the value it lands on comes with the finger's own lift, - // and this effect runs again then (rulerDragging is a dependency) — so one - // render for the whole gesture instead of one in the middle of it. - if (rulerDragging) return; const token = ++renderToken.current; const timer = setTimeout(async () => { setBusy(t('act.exporting')); @@ -1118,7 +1107,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting, rulerDragging]); + }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -1455,9 +1444,6 @@ export function Workspace() { const sliderEditRef = useRef(false); useEffect(() => { sliderEditRef.current = false; - // A row that is no longer the open one is not being held: whatever gesture - // was moving it ended with it. - setRulerDragging(false); }, [openParam]); // The watermark's drag box rides the same one-edit-per-gesture rule as a knob: // a whole drag is one undo step, not one per pointer move. The ref clears @@ -4734,27 +4720,6 @@ 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, - // The gesture's two edges: while the hand is on the photo the - // look is not rendered, and the number it stops on is the one - // that lands (see `rulerDragging`). - onStart: () => setRulerDragging(true), - onEnd: () => setRulerDragging(false), - } - : null - } picking={picking} onPickColor={pickColor} brushTool={brushTool} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index ae4eb30..c527bcc 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -13,6 +13,7 @@ export const en: Dict = { // The phone's way back from a tab's strip to the rail that opened it. 'nav.tabs': 'TABS', 'nav.back': 'Back to home', + 'nav.menu': 'MENU', 'nav.guest': 'Guest', // The install offer (see vi.ts). diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index bee4eb7..798302c 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -18,6 +18,9 @@ export const vi = { 'nav.admin': 'Quản trị', 'nav.photos': 'Ảnh của tôi', 'nav.profile': 'Hồ sơ', + // The phone's one button in the header's corner: every control the bar + // carries, folded into a panel under it (see TopBar). + 'nav.menu': 'MENU', 'nav.library': 'THƯ VIỆN', 'nav.studio': 'Studio', 'nav.guest': 'Khách', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index e3ad016..2e18730 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -36,6 +36,15 @@ white-space: nowrap; } .menu-wrap { position: relative; } +/* The bar's controls in one box. A wide screen has the room for every one of + them, so the box is not there at all — `display: contents` leaves them as + children of the row — and the phone, which has not, gets the panel under the + hamburger (see the narrow-screen rules below). */ +.bar-menu { display: contents; } +.bar-toggle { display: none; } +/* The word a button keeps for that panel: the icon-only rules below take the + words out of the row, and the panel is where they are read again. */ +.mob-label { display: none; } .workspace { display: flex; flex: 1 1 auto; min-height: 0; } @@ -1906,6 +1915,57 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- .header { position: sticky; top: 0; z-index: 5; } .menu-wrap { position: static; } .popover { right: 8px; max-width: calc(100vw - 16px); } + /* --- the phone's bar: one line and one panel ----------------------------- + The row of controls is what wraps the bar onto a second and a third line + here, so it is taken out of the row and hung under a hamburger in the + corner: brand, page name, the button — and every control one tap away, + each wearing the name the icon-only rules below took from it. */ + .bar-menu { display: none; } + .bar-menu.open { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 6; + padding: 8px 12px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border-soft); + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.28); + } + /* Nothing for the spacer to push against in a panel. */ + .bar-menu.open .spacer { display: none; } + .bar-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: auto; + font-size: 15px; + line-height: 1.2; + padding: 4px 10px; + } + /* The names come back. LIBRARY, RESET, SAVE PHOTO, EXPORT and the Immich + button wear their word as text at font-size 0 in the row; the panel draws + it, and their ::before glyph comes along as the mark beside it. */ + .bar-menu.open [data-key="nav-library"], + .bar-menu.open [data-key="reset-look"], + .bar-menu.open [data-key="save-photo"], + .bar-menu.open [data-key="export-photo"], + .bar-menu.open [data-key="upload-immich"] { + width: auto; + height: auto; + padding: 7px 10px; + gap: 6px; + font-size: 12px; + } + .bar-menu.open [data-key="upload-immich"] > span { display: inline; } + /* The count is only half of what SAVE PHOTO says; the PRO marker rides with + the word it belongs to. */ + .bar-menu.open [data-key="save-photo"] .pro { display: inline; } + .bar-menu.open .mob-label { display: inline; margin-left: 6px; font-size: 12px; letter-spacing: 0.06em; } /* Four labelled buttons — LIBRARY, RESET, SAVE PHOTO, EXPORT — are most of what wraps the bar onto a third row on a 390px screen: the row's own content is 990px wide with the words and 716px with icons, and the bar measured 135px diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index f513d0d..f644376 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -60,41 +60,6 @@ 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; - // The gesture's own two edges, and what they are for: rendering the look is - // the one thing here that costs more than a frame — a full develop and a JPEG - // — so a hand on the photo holds it back (see PREVIEW_DEBOUNCE_MS in App) and - // the number the finger stops on is the one that lands, instead of a render - // standing between two moves of a drag that is not finished. - onStart: () => void; - onEnd: () => void; -}; - -// The ruler's own drag while it is in hand: which finger started it, where it -// began, the width the stage was when it did, what has already been applied, -// and the value still waiting for the frame it will be applied on. -type RulerHold = { - id: number; - x: number; - value: number; - width: number; - last: number; - started: boolean; - pending: number | null; - raf: number; -}; - // 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 @@ -305,7 +270,6 @@ 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, @@ -387,13 +351,6 @@ 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. @@ -1041,24 +998,8 @@ 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. 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(null); useEffect(() => { const wrap = wrapRef.current; if (!wrap) return; @@ -1075,66 +1016,12 @@ export function ImageStage({ d: Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y), }; }; - // One value, applied through the ruler's own setter, and the app told the - // first time one lands that the photo is in hand and its render can wait. - const applyRuler = (r: RulerHold, v: number) => { - const d = rulerDragRef.current; - if (!d || v === r.last) return; - r.last = v; - if (!r.started) { - r.started = true; - d.onStart(); - } - d.onChange(v); - }; - // Letting the ruler go: the finger lifted, the touch was cancelled, or a - // second finger turned the gesture into the zoom. Whatever the last frame - // had not applied goes in here (flush), the app is told it may render again, - // and the hand is emptied. - const dropRuler = (flush: boolean) => { - const r = rulerRef.current; - if (!r) return; - if (r.raf) { - cancelAnimationFrame(r.raf); - r.raf = 0; - } - if (flush && r.pending !== null) applyRuler(r, r.pending); - if (r.started) rulerDragRef.current?.onEnd(); - rulerRef.current = null; - }; 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, what the knob read at that moment, and how wide the stage was — - // measured once, because the box does not move under a drag and measuring - // it again on every move would lay the document out between two frames of - // the gesture being read. 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) { - const rect = wrap.getBoundingClientRect(); - if (rect.width > 0 && r.max > r.min) { - rulerRef.current = { - id: e.pointerId, - x: e.clientX, - value: r.value, - width: rect.width, - last: r.value, - started: false, - pending: null, - raf: 0, - }; - } - } - return; - } + // One finger is the photo's own drag — a pan, a stroke, a repair in hand + // (see `startPan` and the brush's layer). Two are this: the zoom. 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, and the - // look renders again — the zoom is what is being looked at now. - dropRuler(false); const m = pinch(); if (!m || !(m.d > 0)) return; // The photo's own point under the fingers, and the size the stage is at: @@ -1150,33 +1037,7 @@ export function ImageStage({ finger.y = e.clientY; } const p = pinchRef.current; - 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; - if (!r || r.id !== e.pointerId || !rulerDragRef.current) return; - e.preventDefault(); - const d = rulerDragRef.current; - const step = d.step > 0 ? d.step : 1; - const raw = r.value + ((e.clientX - r.x) / r.width) * (d.max - d.min); - r.pending = Math.min(d.max, Math.max(d.min, Number((Math.round(raw / step) * step).toFixed(4)))); - // One value a frame, not one a move: a finger reports more moves than a - // screen has frames, and what is applied is whatever the last of them - // said — the row is re-rendered once for it instead of once each. - if (!r.raf) { - r.raf = requestAnimationFrame(() => { - const held = rulerRef.current; - if (!held) return; - held.raf = 0; - const v = held.pending; - held.pending = null; - if (v !== null) applyRuler(held, v); - }); - } - return; - } + 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. @@ -1192,10 +1053,6 @@ export function ImageStage({ setView({ s, ...clampPan(s, m.x - s * p.px, m.y - s * p.py) }); }; const up = (e: PointerEvent) => { - // The finger that was holding the ruler lets it go here, with whatever the - // last frame had not applied: the number the hand stopped on lands, and - // the render it was holding back follows it in the same step. - if (rulerRef.current?.id === e.pointerId) dropRuler(true); 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 @@ -1213,12 +1070,6 @@ export function ImageStage({ wrap.removeEventListener('pointercancel', up); fingersRef.current.clear(); pinchRef.current = null; - // The listener is re-bound whenever a preview lands, and a face on the - // photo is not the end of a gesture: the ruler's hold is kept unless the - // box itself has gone (a frame replaced, a photo cleared) — where the app - // would otherwise be left holding its render back for a hand that is no - // longer on anything. - if (!wrap.isConnected) dropRuler(false); }; // `previewUrl` because the wrap is only on the stage once a frame is: the // listener has to follow the node it is bound to. @@ -1480,10 +1331,6 @@ 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(); diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 92c7943..4f71283 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -58,6 +58,11 @@ export function TopBar({ const { t, lang, setLang } = useI18n(); const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme(); const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null); + // The phone's one button: on a narrow screen the row of controls is the whole + // of a second and a third line, so it folds away into a panel under the + // hamburger in the corner (see .bar-menu in app.css). A wide screen never + // opens it — there the panel is the row itself. + const [bar, setBar] = useState(false); const wrap = useRef(null); // The catalogue's own reading runs in this tab whatever screen is up, so the @@ -71,13 +76,15 @@ export function TopBar({ }, []); useEffect(() => { - if (!menu) return; + if (!menu && !bar) return; const away = (e: MouseEvent) => { - if (!wrap.current?.contains(e.target as Node)) setMenu(null); + if (wrap.current?.contains(e.target as Node)) return; + setMenu(null); + setBar(false); }; document.addEventListener('mousedown', away); return () => document.removeEventListener('mousedown', away); - }, [menu]); + }, [menu, bar]); return (
@@ -88,230 +95,266 @@ export function TopBar({ {t('nav.studio')} {recipeName} - {/* First of the controls, on purpose: sending the frame to Immich Cloud is - what the phone's header puts at the head of its row, and it is the one - control here that hands the photo to another box. Drawn only once the - account has a server to send to. The pinwheel is the label — the word - beside it is a desktop convenience the phone drops (see app.css). */} - {hasImmich && onUploadImmich ? ( + {/* Everything the bar carries, in one box so a phone can fold it away: + see `.bar-menu` — `display: contents` wherever the row has the room + for the buttons, a panel under the hamburger where it has not. */} + {/* A menu of the bar's own (theme, language, account) draws its popover + from inside this box, so the box is held open while one is: the popover + would be a child of a box that is not on the screen. */} +
setBar(false)} + > + {/* First of the controls, on purpose: sending the frame to Immich Cloud is + what the phone's header puts at the head of its row, and it is the one + control here that hands the photo to another box. Drawn only once the + account has a server to send to. The pinwheel is the label — the word + beside it is a desktop convenience the phone drops (see app.css). */} + {hasImmich && onUploadImmich ? ( + + ) : null} + + {/* Both arrows are one icon wide, so the pair reads as one control: the + counter-clockwise arrow steps back, the clockwise one forward. They sit + with the name they would take back — after the mark and the preset. */} - ) : null} - - {/* Both arrows are one icon wide, so the pair reads as one control: the - counter-clockwise arrow steps back, the clockwise one forward. They sit - with the name they would take back — after the mark and the preset. */} - - - - - {/* The catalogue's own reading runs in this tab whatever screen is up, so - the header says so while it does. The shelf itself is the accounts' - (Library.tsx turns a guest away), which is why the link waits for one. */} - {live ? ( - - - ) : null} - {user ? ( - {t('nav.library')} - ) : ( - /* Not a link. An anchor wearing `aria-disabled` still navigates on a - click, and the screen behind it would only send the guest home again — - so a guest gets the shape of the button and nothing to press. */ - - {t('nav.library')} - - )} - - -
- - {menu === 'theme' ? ( -
-
- {/* The name is a span, not a bare text node: a chip is a flex row, - and a loose text node in one is an anonymous item — the shape - iOS stops painting until the page is touched (same as the - panel heads and ChipRow's chips). */} - - - + + + {/* The catalogue's own reading runs in this tab whatever screen is up, so + the header says so while it does. The shelf itself is the accounts' + (Library.tsx turns a guest away), which is why the link waits for one. */} + {live ? ( + + + ) : null} + {user ? ( + {t('nav.library')} + ) : ( + /* Not a link. An anchor wearing `aria-disabled` still navigates on a + click, and the screen behind it would only send the guest home again — + so a guest gets the shape of the button and nothing to press. */ + + {t('nav.library')} + + )} + + +
+ + {menu === 'theme' ? ( +
+
+ {/* The name is a span, not a bare text node: a chip is a flex row, + and a loose text node in one is an anonymous item — the shape + iOS stops painting until the page is touched (same as the + panel heads and ChipRow's chips). */} + + + +
+
+ {ACCENTS.map((entry) => ( + + ))} +
+ {/* A dropdown keeps the group compact — the labels are long and the + current choice is the only one that needs to be visible. */} +
-
- {ACCENTS.map((entry) => ( + ) : null} +
+ +
+ + {menu === 'lang' ? ( +
+ {LANGS.map((entry) => ( ))}
- {/* A dropdown keeps the group compact — the labels are long and the - current choice is the only one that needs to be visible. */} - -
- ) : null} -
+ ) : null} +
-
- - {menu === 'lang' ? ( -
- {LANGS.map((entry) => ( - - ))} -
- ) : null} -
- -
- - {menu === 'account' ? ( -
+
+ + + {user.email.split('@')[0]} ) : ( - <> - - - + t('nav.guest') )} -
- ) : null} + + {menu === 'account' ? ( +
+ {user ? ( + <> +

{t('auth.loggedInAs', { email: user.email })}

+ {/* The account control opens the one page that account can act + on: moderation for an admin, its own profile for a member. */} + {user.admin ? ( + {t('nav.admin')} + ) : ( + <> + {t('nav.photos')} + {t('nav.profile')} + + )} + + + ) : ( + <> + + + + )} +
+ ) : null} +
+ {/* Last in the row on purpose: EXPORT is the end of the job, so it sits at + the end of the bar — the one button the eye lands on when the edit is + done. */} +
- {/* Last in the row on purpose: EXPORT is the end of the job, so it sits at - the end of the bar — the one button the eye lands on when the edit is - done. */} -
);