// Image Editor — the crop tool's stage overlay and its pixel fields. // // The crop operation itself (fractions of its input, the exact blit) lives in // javascript/image_editor_ops.js; this file is only its UI. It must load after // ImageEditorParts.jsx (whose stylesheet carries the shared canvas rules) and // before ImageEditorPage.jsx. // // Because a crop stores fractions of its input, everything here converts // between three spaces: // // fraction 0..1 of the op's input — what the descriptor stores // image px pixels of the FULL-resolution canvas entering the op — what the // sidebar fields show, so the numbers match the exported file // stage px CSS pixels inside the preview stage — where the pointer moves // // The whole file is wrapped in an IIFE so its helpers stay out of the shared // global scope that every text/babel script compiles into (see the "no // top-level const" gotcha in docs/design_style_guide.md). (function () { 'use strict'; const { Box, TextField } = MaterialUI; const HANDLE = 7; // drawn handle size, stage px const GRAB = 12; // how near the pointer must be to grab one const MIN_PX = 16; // smallest crop box, in preview-image px const clamp01 = (v) => (v < 0 ? 0 : (v > 1 ? 1 : v)); const clampRange = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); // Fractions are rounded so JSON.stringify(params) — the pipeline's cache key // — stays short and stable while a handle is dragged. Same reasoning as the // integer curve points in image_editor_ops.js. const round6 = (v) => Math.round(v * 1e6) / 1e6; // Handle positions as unit offsets within the crop box. The id's letters say // which edges it moves, which is the whole of the resize logic below. const HANDLES = [ { id: 'nw', fx: 0, fy: 0, cursor: 'nwse-resize' }, { id: 'n', fx: 0.5, fy: 0, cursor: 'ns-resize' }, { id: 'ne', fx: 1, fy: 0, cursor: 'nesw-resize' }, { id: 'e', fx: 1, fy: 0.5, cursor: 'ew-resize' }, { id: 'se', fx: 1, fy: 1, cursor: 'nwse-resize' }, { id: 's', fx: 0.5, fy: 1, cursor: 'ns-resize' }, { id: 'sw', fx: 0, fy: 1, cursor: 'nesw-resize' }, { id: 'w', fx: 0, fy: 0.5, cursor: 'ew-resize' } ]; const boxToStage = (params, rect) => ({ x: rect.dx + params.x * rect.dw, y: rect.dy + params.y * rect.dh, w: params.width * rect.dw, h: params.height * rect.dh }); const stageToFraction = (px, py, rect) => ({ fx: rect.dw > 0 ? (px - rect.dx) / rect.dw : 0, fy: rect.dh > 0 ? (py - rect.dy) / rect.dh : 0 }); // A box from two corners, in either order, never smaller than the minimum. const fromCorners = (ax, ay, bx, by, minFx, minFy) => { let l = clamp01(Math.min(ax, bx)); let r = clamp01(Math.max(ax, bx)); let t = clamp01(Math.min(ay, by)); let b = clamp01(Math.max(ay, by)); if (r - l < minFx) { r = Math.min(1, l + minFx); l = Math.max(0, r - minFx); } if (b - t < minFy) { b = Math.min(1, t + minFy); t = Math.max(0, b - minFy); } return { x: round6(l), y: round6(t), width: round6(r - l), height: round6(b - t) }; }; // One step of a drag → the new crop params. const nextBox = (drag, now, minFx, minFy) => { const o = drag.origin; if (drag.mode === 'new') { return fromCorners(drag.start.fx, drag.start.fy, now.fx, now.fy, minFx, minFy); } if (drag.mode === 'move') { return { x: round6(clampRange(o.x + (now.fx - drag.start.fx), 0, 1 - o.width)), y: round6(clampRange(o.y + (now.fy - drag.start.fy), 0, 1 - o.height)), width: round6(o.width), height: round6(o.height) }; } let l = o.x; let t = o.y; let r = o.x + o.width; let b = o.y + o.height; // Each edge moves on its own, stopped short of its opposite rather than // flipping through it, so the box can never invert mid-drag. if (drag.mode.indexOf('w') >= 0) l = Math.min(clamp01(now.fx), r - minFx); if (drag.mode.indexOf('e') >= 0) r = Math.max(clamp01(now.fx), l + minFx); if (drag.mode.indexOf('n') >= 0) t = Math.min(clamp01(now.fy), b - minFy); if (drag.mode.indexOf('s') >= 0) b = Math.max(clamp01(now.fy), t + minFy); l = Math.max(0, l); t = Math.max(0, t); return { x: round6(l), y: round6(t), width: round6(Math.min(1, r) - l), height: round6(Math.min(1, b) - t) }; }; // ---- stage overlay ------------------------------------------------------- // Drawn on its own canvas above the preview. Being a it is already // exempt from the mobile sidebar gesture — `canvas` is in the BLOCKED // selector list in javascript/sidebar_swipe.js — so a rightward crop drag on // a phone does not pull the drawer out. const CropOverlay = ({ T, rect, image, params, onChange }) => { const canvasRef = React.useRef(null); const dragRef = React.useRef(null); const [cursor, setCursor] = React.useState('crosshair'); const minFx = image.width > 0 ? Math.min(0.5, MIN_PX / image.width) : 0.05; const minFy = image.height > 0 ? Math.min(0.5, MIN_PX / image.height) : 0.05; React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const bounds = canvas.getBoundingClientRect(); const w = Math.max(1, Math.floor(bounds.width)); const h = Math.max(1, Math.floor(bounds.height)); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const box = boxToStage(params, rect); const ix = rect.dx; const iy = rect.dy; const iw = rect.dw; const ih = rect.dh; // Dim what the crop will discard: four rects around the box, rather than // a fill plus a cut-out, so there is no compositing mode to reset. // Deliberately a literal black rather than a theme token — it shades the // image, and must read the same in light and dark mode. ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'; ctx.fillRect(ix, iy, iw, box.y - iy); ctx.fillRect(ix, box.y + box.h, iw, (iy + ih) - (box.y + box.h)); ctx.fillRect(ix, box.y, box.x - ix, box.h); ctx.fillRect(box.x + box.w, box.y, (ix + iw) - (box.x + box.w), box.h); // Rule-of-thirds guides inside the kept area. ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 1; i <= 2; i++) { const gx = Math.round(box.x + (box.w * i) / 3) + 0.5; const gy = Math.round(box.y + (box.h * i) / 3) + 0.5; ctx.moveTo(gx, box.y); ctx.lineTo(gx, box.y + box.h); ctx.moveTo(box.x, gy); ctx.lineTo(box.x + box.w, gy); } ctx.stroke(); ctx.strokeStyle = T.accent; ctx.lineWidth = 1; ctx.strokeRect(Math.round(box.x) + 0.5, Math.round(box.y) + 0.5, Math.round(box.w), Math.round(box.h)); ctx.fillStyle = T.accent; HANDLES.forEach((hd) => { const cx = box.x + hd.fx * box.w; const cy = box.y + hd.fy * box.h; ctx.fillRect(Math.round(cx - HANDLE / 2), Math.round(cy - HANDLE / 2), HANDLE, HANDLE); }); }, [rect, params, T.accent]); const localPoint = (e) => { const b = canvasRef.current.getBoundingClientRect(); return { px: e.clientX - b.left, py: e.clientY - b.top }; }; const handleAt = (px, py) => { const box = boxToStage(params, rect); for (let i = 0; i < HANDLES.length; i++) { const hd = HANDLES[i]; const cx = box.x + hd.fx * box.w; const cy = box.y + hd.fy * box.h; if (Math.abs(px - cx) <= GRAB && Math.abs(py - cy) <= GRAB) return hd; } return null; }; const isInside = (px, py) => { const box = boxToStage(params, rect); return px >= box.x && px <= box.x + box.w && py >= box.y && py <= box.y + box.h; }; // A crop opens at the full frame, so every point is "inside" the box and // there is nowhere left to start a fresh one — and a full-frame box cannot // be moved anyway, so an interior drag would silently do nothing. Treat it // as drawing instead, which is what dragging across an uncropped image // looks like it should do. Once the box is smaller, interior means move. const isFullFrame = params.x <= 0 && params.y <= 0 && params.width >= 1 && params.height >= 1; // Pointer events rather than HTML5 drag-and-drop, which has no touch // support — the same hand-rolled approach the history list's reordering // uses in ImageEditorParts.jsx. const onDown = (e) => { if (e.button != null && e.button !== 0) return; e.preventDefault(); const p = localPoint(e); const hd = handleAt(p.px, p.py); try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* older browsers */ } dragRef.current = { mode: hd ? hd.id : (isInside(p.px, p.py) && !isFullFrame ? 'move' : 'new'), start: stageToFraction(p.px, p.py, rect), origin: { x: params.x, y: params.y, width: params.width, height: params.height } }; }; const onMove = (e) => { const p = localPoint(e); const drag = dragRef.current; if (!drag) { const hd = handleAt(p.px, p.py); setCursor(hd ? hd.cursor : (isInside(p.px, p.py) && !isFullFrame ? 'move' : 'crosshair')); return; } onChange(nextBox(drag, stageToFraction(p.px, p.py, rect), minFx, minFy)); }; const onUp = () => { dragRef.current = null; }; return ( ); }; // ---- pixel fields -------------------------------------------------------- // Reads and writes full-resolution pixels against `inputSize` (the size // entering the crop op), while the op stores fractions. An integer px round // trips exactly, so typing a width gives that exact exported width. const CropFields = ({ T, DS, params, inputSize, onChange }) => { // Held while a field has focus, so clearing it to retype does not snap the // box back from under the cursor. Committed on blur or Enter. const [editing, setEditing] = React.useState({}); const px = { x: Math.round(params.x * inputSize.width), y: Math.round(params.y * inputSize.height), width: Math.max(1, Math.round(params.width * inputSize.width)), height: Math.max(1, Math.round(params.height * inputSize.height)) }; const commit = (key, raw) => { const n = parseInt(raw, 10); if (!isFinite(n)) return; const next = Object.assign({}, params); if (key === 'width') { const w = clampRange(n, 1, inputSize.width); next.width = round6(w / inputSize.width); next.x = round6(Math.min(next.x, 1 - next.width)); } else if (key === 'height') { const h = clampRange(n, 1, inputSize.height); next.height = round6(h / inputSize.height); next.y = round6(Math.min(next.y, 1 - next.height)); } else if (key === 'x') { next.x = round6(clampRange(n, 0, inputSize.width - px.width) / inputSize.width); } else { next.y = round6(clampRange(n, 0, inputSize.height - px.height) / inputSize.height); } onChange(next); }; const release = (key) => { const raw = editing[key]; setEditing((e) => { const n = Object.assign({}, e); delete n[key]; return n; }); if (raw != null && raw !== '') commit(key, raw); }; const FIELDS = [ { key: 'x', label: 'X' }, { key: 'y', label: 'Y' }, { key: 'width', label: 'W' }, { key: 'height', label: 'H' } ]; return ( Crop box {px.width} × {px.height} {FIELDS.map((f) => ( setEditing((prev) => Object.assign({}, prev, { [f.key]: e.target.value }))} onBlur={() => release(f.key)} onKeyDown={(e) => { if (e.key === 'Enter') release(f.key); }} /> ))} Drag on the preview to frame the crop, or type exact pixels. Values are against the {inputSize.width} × {inputSize.height} px image entering this edit. ); }; window.ImageEditorCrop = { CropOverlay: CropOverlay, CropFields: CropFields }; })();