From 014b3e5a4b66c817e7e1eb5a04625651f76ba36f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 20:57:00 +0700 Subject: [PATCH] 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. --- docker/frontend/src/styles/app.css | 2 +- docker/frontend/src/ui/ChipColumn.tsx | 23 ++++- docker/frontend/src/ui/ImageStage.tsx | 143 ++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index b79b28b..078f8bd 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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. */ diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 16e1e16..04286f8 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -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(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 ( -
+
diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 51b8c19..e246bb9 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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) => { + 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) => { + 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) => { + 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) => { 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 ? (