Zoom and pan the preview with the wheel, and step a parameter from its ruler

Cursor over the image: the wheel zooms about the pointer, and a drag pans,
both clamped to the contain-fit of the loaded bitmap so the photo can never
be lost off-screen. Cursor over an open parameter ruler: the wheel moves that
parameter by one step, clamped to its min/max. While the crop frame is being
dragged the wheel keeps resizing the crop instead, and APPLY puts the view
back on the fit.
This commit is contained in:
2026-09-17 20:57:00 +07:00
parent 6e8a600a82
commit 014b3e5a4b
3 changed files with 166 additions and 2 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
}
.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; }
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; }
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; }
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
/* Crop editor: the layer is the photo's own box, the rect is the framing and
its shadow is what shades the part being cut away. */
+22 -1
View File
@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react';
// One strip at a time — the same shape as the phone's AdjustmentPanel: a ruler
// row for the open slider, an options strip for the open group, then the tab's
// chip row. Chip labels are the phone's own strings (they are not translated
@@ -53,8 +55,27 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
}
export function SliderRow({ slider }: { slider: SliderDef }) {
// The wheel belongs to the open ruler: one notch is one step, held inside the
// parameter's own range. The listener is native because React's is passive —
// the panel must not scroll away under the cursor while the value moves.
const rowRef = useRef<HTMLDivElement>(null);
const live = useRef(slider);
live.current = slider;
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const s = live.current;
const step = s.step || 1;
const next = Math.min(s.max, Math.max(s.min, s.value + (e.deltaY < 0 ? step : -step)));
if (next !== s.value) s.onChange(next);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<div className="slider-row">
<div className="slider-row" ref={rowRef}>
<button type="button" className="back" onClick={slider.onBack} aria-label="back">&lt;</button>
<div className="ruler">
<div className="head">
+143
View File
@@ -2,6 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
// One wheel notch, and how far the photo may be pulled open. Past 1 the render
// is the 1600px copy, so deep zoom is soft by design — it is for reading
// framing, not for judging grain.
const ZOOM_STEP = 1.15;
const ZOOM_MAX = 8;
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
@@ -41,6 +47,12 @@ export function ImageStage({
// whatever the stage does with the space around it.
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
const dragRef = useRef<{ mode: string; rect: CropRect; px: number; py: number } | null>(null);
// Zoom is a way of looking at the photo, never part of it: 1 = the CSS fit,
// and every render/export still reads the source bytes untouched.
const [view, setView] = useState({ s: 1, x: 0, y: 0 });
const viewRef = useRef(view);
viewRef.current = view;
const panRef = useRef<{ px: number; py: number } | null>(null);
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
useEffect(() => {
@@ -93,6 +105,125 @@ export function ImageStage({
return () => window.removeEventListener('resize', measure);
}, [cropEditing, measure, previewUrl]);
// The img's box at scale 1 — the natural size fitted into the wrap, never
// enlarged. Computed rather than measured so the transform cannot feed back
// into it.
const baseRect = useCallback(() => {
const img = imgRef.current;
const wrap = wrapRef.current;
if (!img || !wrap) return null;
const nw = img.naturalWidth;
const nh = img.naturalHeight;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
if (!(nw > 0 && nh > 0 && w > 0 && h > 0)) return null;
const k = Math.min(w / nw, h / nh, 1);
return { w: nw * k, h: nh * k, wrapW: w, wrapH: h };
}, []);
// The photo may never be dragged off its own frame: an axis only moves once
// it is wider (or taller) than the stage.
const clampPan = useCallback(
(s: number, x: number, y: number) => {
const b = baseRect();
if (!b) return { x: 0, y: 0 };
const mx = Math.max(0, (s * b.w - b.wrapW) / 2);
const my = Math.max(0, (s * b.h - b.wrapH) / 2);
return { x: Math.max(-mx, Math.min(mx, x)), y: Math.max(-my, Math.min(my, y)) };
},
[baseRect]
);
// With the frame up the same wheel tightens or loosens the CROP instead —
// wheel up still means "closer", kept inside the photo and on the picked
// ratio.
const wheelCrop = useCallback(
(up: boolean) => {
if (!cropRect || !onCropRectChange) return;
const b = baseRect();
const k = up ? 1 / 1.1 : 1.1;
let w = Math.min(1, Math.max(MIN_CROP_FRAC, cropRect.w * k));
let h = Math.min(1, Math.max(MIN_CROP_FRAC, cropRect.h * k));
if (cropWH != null && b) {
// same lock as the corner drag: w/h in fractions = ratio / (photo w/h)
const rr = cropWH / (b.w / b.h);
h = Math.min(1, Math.max(MIN_CROP_FRAC, w / rr));
w = Math.min(1, Math.max(MIN_CROP_FRAC, h * rr));
}
const cx = cropRect.x + cropRect.w / 2;
const cy = cropRect.y + cropRect.h / 2;
onCropRectChange({
x: Math.max(0, Math.min(1 - w, cx - w / 2)),
y: Math.max(0, Math.min(1 - h, cy - h / 2)),
w,
h,
});
},
[baseRect, cropRect, cropWH, onCropRectChange]
);
// Native, because React's wheel listener is passive and the page must not
// scroll while the photo zooms under the cursor.
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const onWheel = (e: WheelEvent) => {
if (!imgRef.current) return;
e.preventDefault();
if (cropEditing) {
wheelCrop(e.deltaY < 0);
return;
}
const v = viewRef.current;
const s = Math.min(ZOOM_MAX, Math.max(1, v.s * (e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)));
if (s === v.s) return;
const r = wrap.getBoundingClientRect();
const cx = e.clientX - r.left - r.width / 2;
const cy = e.clientY - r.top - r.height / 2;
// the point under the cursor stays put: t' = c - (s'/s)(c - t)
const x = cx - (s / v.s) * (cx - v.x);
const y = cy - (s / v.s) * (cy - v.y);
setView({ s, ...clampPan(s, x, y) });
};
wrap.addEventListener('wheel', onWheel, { passive: false });
return () => wrap.removeEventListener('wheel', onWheel);
}, [cropEditing, previewUrl, wheelCrop, clampPan]);
// A new photo, or the frame coming up or going down (APPLY, CANCEL, UNDO),
// puts the view back on the fit.
useEffect(() => {
setView({ s: 1, x: 0, y: 0 });
}, [originalUrl, cropEditing]);
useEffect(() => {
const onResize = () => setView((v) => (v.s <= 1 ? v : { s: v.s, ...clampPan(v.s, v.x, v.y) }));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clampPan]);
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
panRef.current = { px: e.clientX, py: e.clientY };
};
const movePan = (e: React.PointerEvent<HTMLImageElement>) => {
const p = panRef.current;
if (!p) return;
e.preventDefault();
const v = viewRef.current;
setView({ s: v.s, ...clampPan(v.s, v.x + (e.clientX - p.px), v.y + (e.clientY - p.py)) });
panRef.current = { px: e.clientX, py: e.clientY };
};
const endPan = (e: React.PointerEvent<HTMLImageElement>) => {
if (!panRef.current) return;
panRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
const beginDrag = (mode: string) => (e: React.PointerEvent<HTMLElement>) => {
if (!onCropRectChange || !cropRect) return;
e.preventDefault();
@@ -193,6 +324,18 @@ export function ImageStage({
alt="preview"
draggable={false}
onLoad={measure}
onPointerDown={startPan}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
style={
view.s === 1
? undefined
: {
transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
cursor: cropEditing ? undefined : 'grab',
}
}
/>
{cropEditing && box && cropRect && onCropRectChange ? (
<div