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:
@@ -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. */
|
||||
|
||||
@@ -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"><</button>
|
||||
<div className="ruler">
|
||||
<div className="head">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user