feat(studio): a finger across the photo is the open ruler's own track

This commit is contained in:
2026-10-13 19:42:05 +07:00
parent 3948d525bd
commit b03896e398
3 changed files with 407 additions and 1 deletions
+10
View File
@@ -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}
+75 -1
View File
@@ -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<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: 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<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();