feat(studio): a phone's header folds into one button, the open ruler no longer rides the photo

This commit is contained in:
2026-10-13 23:35:44 +07:00
parent 522e4e9ee4
commit e31074be4a
7 changed files with 374 additions and 490 deletions
+58 -93
View File
@@ -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=<path to playwright-core/index.mjs> CHROME=<path to a chromium> \
@@ -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(' | '));
+1 -36
View File
@@ -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<string | null>(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<HslBandId>('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}
+1
View File
@@ -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).
+3
View File
@@ -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',
+60
View File
@@ -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
+3 -156
View File
@@ -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<number, { x: number; y: number }>());
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<RulerHold | null>(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<HTMLImageElement>) => {
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();
+248 -205
View File
@@ -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<HTMLDivElement>(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 (
<header className="header" ref={wrap}>
@@ -88,230 +95,266 @@ export function TopBar({
<b className="page-name">{t('nav.studio')}</b>
<span className="recipe-name">{recipeName}</span>
{/* 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. */}
<div
className={`bar-menu${bar || menu ? ' open' : ''}`}
data-key="bar-menu"
onClick={() => 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 ? (
<button
type="button"
className="btn"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 168, 83, 0.15))',
borderColor: '#4285f4',
color: '#e4e4e7',
}}
data-key="upload-immich"
disabled={uploadingImmich || exporting}
onClick={onUploadImmich}
title={t('immich.uploadTitle') || 'Upload lên Immich Cloud'}
>
{/* Immich Pinwheel Logo */}
<svg width="18" height="18" viewBox="0 0 100 100" fill="none" style={{ flexShrink: 0 }}>
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{uploadingImmich ? (t('immich.uploading') || 'Đang tải lên...') : 'Immich'}</span>
</button>
) : 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. */}
<button
type="button"
className="btn"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 168, 83, 0.15))',
borderColor: '#4285f4',
color: '#e4e4e7',
}}
data-key="upload-immich"
disabled={uploadingImmich || exporting}
onClick={onUploadImmich}
title={t('immich.uploadTitle') || 'Upload lên Immich Cloud'}
className="btn ghost icon"
data-key="undo"
disabled={!canUndo}
title={t('act.undo')}
aria-label={t('act.undo')}
onClick={onUndo}
>
{/* Immich Pinwheel Logo */}
<svg width="18" height="18" viewBox="0 0 100 100" fill="none" style={{ flexShrink: 0 }}>
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{uploadingImmich ? (t('immich.uploading') || 'Đang tải lên...') : 'Immich'}</span>
<span aria-hidden="true">↺</span>
<span className="mob-label">{t('act.undo')}</span>
</button>
) : 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. */}
<button
type="button"
className="btn ghost icon"
data-key="undo"
disabled={!canUndo}
title={t('act.undo')}
aria-label={t('act.undo')}
onClick={onUndo}
>
↺
</button>
<button
type="button"
className="btn ghost icon"
data-key="redo"
disabled={!canRedo}
title={t('act.redo')}
aria-label={t('act.redo')}
onClick={onRedo}
>
↻
</button>
<span className="spacer" />
{/* 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 ? (
<span
className="hint lib-scan"
data-key="studio-scan"
title={t('lib.scanning', {
done: live.progress.done,
total: live.progress.total,
added: live.progress.added,
})}
<button
type="button"
className="btn ghost icon"
data-key="redo"
disabled={!canRedo}
title={t('act.redo')}
aria-label={t('act.redo')}
onClick={onRedo}
>
<span className="lib-spin" aria-hidden="true" />
<span data-key="studio-scan-count">{`${live.progress.done}/${live.progress.total}`}</span>
</span>
) : null}
{user ? (
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
) : (
/* 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. */
<span className="btn ghost" data-key="nav-library" aria-disabled="true" title={t('lib.needAccount')}>
{t('nav.library')}
</span>
)}
<button type="button" className="btn ghost" data-key="reset-look" onClick={onReset}>{t('act.reset')}</button>
<button
type="button"
className="btn ghost"
data-key="save-photo"
disabled={savingPhoto || exporting}
title={
photoMax !== null && photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined
}
onClick={onSavePhoto}
>
{savingPhoto
? t('photo.uploading')
: photoMax === null
? t('save.photoNoMax', { n: photoCount })
: t('save.photo', { n: photoCount, max: photoMax })}
{!pro ? <span className="pro">PRO</span> : null}
</button>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
◐
<span aria-hidden="true">↻</span>
<span className="mob-label">{t('act.redo')}</span>
</button>
{menu === 'theme' ? (
<div className="popover">
<div className="chip-row">
{/* 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). */}
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}><span className="chip-label">Light</span></button>
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}><span className="chip-label">Dark</span></button>
<button type="button" className={`chip${mode === 'auto' ? ' on' : ''}`} data-key="theme-auto" onClick={() => setMode('auto')}><span className="chip-label">Auto</span></button>
<span className="spacer" />
{/* 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 ? (
<span
className="hint lib-scan"
data-key="studio-scan"
title={t('lib.scanning', {
done: live.progress.done,
total: live.progress.total,
added: live.progress.added,
})}
>
<span className="lib-spin" aria-hidden="true" />
<span data-key="studio-scan-count">{`${live.progress.done}/${live.progress.total}`}</span>
</span>
) : null}
{user ? (
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
) : (
/* 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. */
<span className="btn ghost" data-key="nav-library" aria-disabled="true" title={t('lib.needAccount')}>
{t('nav.library')}
</span>
)}
<button type="button" className="btn ghost" data-key="reset-look" onClick={onReset}>{t('act.reset')}</button>
<button
type="button"
className="btn ghost"
data-key="save-photo"
disabled={savingPhoto || exporting}
title={
photoMax !== null && photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined
}
onClick={onSavePhoto}
>
{savingPhoto
? t('photo.uploading')
: photoMax === null
? t('save.photoNoMax', { n: photoCount })
: t('save.photo', { n: photoCount, max: photoMax })}
{!pro ? <span className="pro">PRO</span> : null}
</button>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
<span aria-hidden="true">◐</span>
<span className="mob-label">{t('nav.theme')}</span>
</button>
{menu === 'theme' ? (
<div className="popover">
<div className="chip-row">
{/* 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). */}
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}><span className="chip-label">Light</span></button>
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}><span className="chip-label">Dark</span></button>
<button type="button" className={`chip${mode === 'auto' ? ' on' : ''}`} data-key="theme-auto" onClick={() => setMode('auto')}><span className="chip-label">Auto</span></button>
</div>
<div className="chip-row" style={{ marginTop: 8 }}>
{ACCENTS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${accent === entry.id ? ' on' : ''}`}
onClick={() => setAccent(entry.id)}
>
<span className="chip-label">{entry.label}</span>
</button>
))}
</div>
{/* A dropdown keeps the group compact — the labels are long and the
current choice is the only one that needs to be visible. */}
<select
className="chip font-select"
data-key="fonts"
title={FONT_SETS.find((f) => f.id === fonts)?.note}
value={fonts}
onChange={(e) => setFonts(e.target.value as FontSetId)}
>
{FONT_SETS.map((entry) => (
<option key={entry.id} value={entry.id} title={entry.note}>{entry.label}</option>
))}
</select>
</div>
<div className="chip-row" style={{ marginTop: 8 }}>
{ACCENTS.map((entry) => (
) : null}
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
<span aria-hidden="true">{lang.toUpperCase()}</span>
<span className="mob-label">{t('nav.language')}</span>
</button>
{menu === 'lang' ? (
<div className="popover">
{LANGS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${accent === entry.id ? ' on' : ''}`}
onClick={() => setAccent(entry.id)}
className={`chip${lang === entry.id ? ' on' : ''}`}
onClick={() => {
setLang(entry.id);
setMenu(null);
}}
>
<span className="chip-label">{entry.label}</span>
</button>
))}
</div>
{/* A dropdown keeps the group compact — the labels are long and the
current choice is the only one that needs to be visible. */}
<select
className="chip font-select"
data-key="fonts"
title={FONT_SETS.find((f) => f.id === fonts)?.note}
value={fonts}
onChange={(e) => setFonts(e.target.value as FontSetId)}
>
{FONT_SETS.map((entry) => (
<option key={entry.id} value={entry.id} title={entry.note}>{entry.label}</option>
))}
</select>
</div>
) : null}
</div>
) : null}
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
{lang.toUpperCase()}
</button>
{menu === 'lang' ? (
<div className="popover">
{LANGS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${lang === entry.id ? ' on' : ''}`}
onClick={() => {
setLang(entry.id);
setMenu(null);
}}
>
<span className="chip-label">{entry.label}</span>
</button>
))}
</div>
) : null}
</div>
<div className="menu-wrap">
<button
type="button"
className="btn ghost account-btn"
data-key="nav-account"
aria-expanded={menu === 'account'}
onClick={() => setMenu(menu === 'account' ? null : 'account')}
>
{user ? (
<>
<Avatar user={user} small />
{user.email.split('@')[0]}
</>
) : (
t('nav.guest')
)}
</button>
{menu === 'account' ? (
<div className="popover">
<div className="menu-wrap">
<button
type="button"
className="btn ghost account-btn"
data-key="nav-account"
aria-expanded={menu === 'account'}
onClick={() => setMenu(menu === 'account' ? null : 'account')}
>
{user ? (
<>
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
{/* The account control opens the one page that account can act
on: moderation for an admin, its own profile for a member. */}
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : (
<>
<a className="btn" data-key="nav-photos" href="/photos">{t('nav.photos')}</a>
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
</>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
<Avatar user={user} small />
{user.email.split('@')[0]}
</>
) : (
<>
<button type="button" className="btn primary" data-key="nav-login" onClick={onAuth}>
{t('nav.login')}
</button>
<button type="button" className="btn" data-key="nav-signup" onClick={onSignup}>
{t('nav.signup')}
</button>
</>
t('nav.guest')
)}
</div>
) : null}
</button>
{menu === 'account' ? (
<div className="popover">
{user ? (
<>
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
{/* The account control opens the one page that account can act
on: moderation for an admin, its own profile for a member. */}
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : (
<>
<a className="btn" data-key="nav-photos" href="/photos">{t('nav.photos')}</a>
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
</>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
</>
) : (
<>
<button type="button" className="btn primary" data-key="nav-login" onClick={onAuth}>
{t('nav.login')}
</button>
<button type="button" className="btn" data-key="nav-signup" onClick={onSignup}>
{t('nav.signup')}
</button>
</>
)}
</div>
) : null}
</div>
{/* 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. */}
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting || uploadingImmich} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>
</div>
{/* 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. */}
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting || uploadingImmich} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
{/* The corner the panel hangs from, and only on a phone: a wide screen
draws the row itself instead (see .bar-toggle). */}
<button
type="button"
className="btn ghost bar-toggle"
data-key="bar-toggle"
aria-expanded={bar || menu !== null}
aria-label={t('nav.menu')}
title={t('nav.menu')}
onClick={() => {
// The panel goes away when the button is pressed, and the popover a
// menu of the panel's own may have left up goes with it: the button
// is the whole of how a phone puts the bar away.
const showing = bar || menu !== null;
setBar(!showing);
if (showing) setMenu(null);
}}
>
<span aria-hidden="true">{bar || menu !== null ? '✕' : '☰'}</span>
</button>
</header>
);