diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 379f429..03670aa 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -2860,6 +2860,21 @@ form[data-key='immich-modal'] { order: 0 !important; min-height: 0 !important; } + .workspace.is-mobile .stage { + padding: 6px 8px !important; + gap: 8px !important; + } + .workspace.is-mobile .stage[data-zoomed="1"] { + padding: 2px 4px 6px !important; + } + .workspace.is-mobile .canvas-wrap { + width: 100% !important; + border-radius: var(--radius-sm); + } + .workspace.is-mobile .canvas-wrap img:not([data-zoomed="1"]) { + max-width: calc(100% - 16px); + max-height: calc(100% - 12px); + } .workspace.is-mobile .col[data-col="slider"] { order: 1 !important; } diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index cbcf32f..ea4213e 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -89,7 +89,108 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { // it is. All of it is read off the recipe, which is where the run comes from // after a reload, an undo, or a copy of the recipe to another photo: a stroke // is not stored anywhere. -// Traces a smooth, closed SVG contour around a stroke (capsule hull around spots at distance r) +// Cache offscreen canvas for computing true outer union contours of overlapping circles +let contourCanvas: HTMLCanvasElement | null = null; +let contourCtx: CanvasRenderingContext2D | null = null; + +// Moore-Neighbor tracing (8-connected) to find the outermost perimeter of a binary mask +const traceOuterBoundary = ( + alpha: Uint8ClampedArray, + width: number, + height: number +): { x: number; y: number }[] => { + let startX = -1; + let startY = -1; + for (let y = 0; y < height && startX === -1; y++) { + const rowOffset = y * width; + for (let x = 0; x < width; x++) { + if (alpha[(rowOffset + x) * 4 + 3] > 64) { + startX = x; + startY = y; + break; + } + } + } + if (startX === -1) return []; + + // Directions: 0: R, 1: DR, 2: D, 3: DL, 4: L, 5: UL, 6: U, 7: UR + const dx = [1, 1, 0, -1, -1, -1, 0, 1]; + const dy = [0, 1, 1, 1, 0, -1, -1, -1]; + + const contour: { x: number; y: number }[] = []; + let currX = startX; + let currY = startY; + let enterDir = 4; // came from left + contour.push({ x: currX, y: currY }); + + const maxSteps = width * height; + let steps = 0; + + while (steps++ < maxSteps) { + let found = false; + const nextDir = (enterDir + 5) % 8; + for (let i = 0; i < 8; i++) { + const d = (nextDir + i) % 8; + const nx = currX + dx[d]; + const ny = currY + dy[d]; + if ( + nx >= 0 && + nx < width && + ny >= 0 && + ny < height && + alpha[(ny * width + nx) * 4 + 3] > 64 + ) { + currX = nx; + currY = ny; + enterDir = d; + found = true; + break; + } + } + if (!found || (currX === startX && currY === startY)) break; + contour.push({ x: currX, y: currY }); + } + + return contour; +}; + +// Ramer-Douglas-Peucker polygon simplification +const simplifyContour = ( + points: { x: number; y: number }[], + epsilon: number +): { x: number; y: number }[] => { + if (points.length <= 2) return points; + let dmax = 0; + let index = 0; + const end = points.length - 1; + const p0 = points[0]; + const pend = points[end]; + const mag = Math.hypot(pend.x - p0.x, pend.y - p0.y); + for (let i = 1; i < end; i++) { + const pt = points[i]; + const d = + mag === 0 + ? Math.hypot(pt.x - p0.x, pt.y - p0.y) + : Math.abs( + (pend.y - p0.y) * pt.x - + (pend.x - p0.x) * pt.y + + pend.x * p0.y - + pend.y * p0.x + ) / mag; + if (d > dmax) { + index = i; + dmax = d; + } + } + if (dmax > epsilon) { + const r1 = simplifyContour(points.slice(0, index + 1), epsilon); + const r2 = simplifyContour(points.slice(index), epsilon); + return r1.slice(0, -1).concat(r2); + } + return [p0, pend]; +}; + +// Computes the total outer union boundary of all overlapping circles in a stroke const strokeContourPath = ( spots: { x: number; y: number; r: number }[], box: { width: number; height: number } @@ -97,6 +198,8 @@ const strokeContourPath = ( if (!spots.length) return ''; const bw = box.width; const bh = box.height; + + // Single spot: exact circle if (spots.length === 1) { const s = spots[0]; const cx = s.x * bw; @@ -105,10 +208,89 @@ const strokeContourPath = ( return `M ${(cx - r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx + r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx - r).toFixed(1)} ${cy.toFixed(1)} Z`; } + // Exact 2D union boundary tracing via offscreen canvas + if (typeof document !== 'undefined') { + try { + if (!contourCanvas) { + contourCanvas = document.createElement('canvas'); + contourCtx = contourCanvas.getContext('2d', { willReadFrequently: true }); + } + if (contourCtx && contourCanvas) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + const maxR = Math.max(...spots.map((s) => s.r * bw)); + const pad = Math.ceil(maxR + 6); + + for (const s of spots) { + const px = s.x * bw; + const py = s.y * bh; + if (px < minX) minX = px; + if (px > maxX) maxX = px; + if (py < minY) minY = py; + if (py > maxY) maxY = py; + } + + const originX = Math.floor(minX - pad); + const originY = Math.floor(minY - pad); + const cw = Math.ceil(maxX - minX + 2 * pad); + const ch = Math.ceil(maxY - minY + 2 * pad); + + if (cw > 0 && ch > 0 && cw <= 4000 && ch <= 4000) { + contourCanvas.width = cw; + contourCanvas.height = ch; + contourCtx.clearRect(0, 0, cw, ch); + contourCtx.fillStyle = '#fff'; + contourCtx.strokeStyle = '#fff'; + contourCtx.lineCap = 'round'; + contourCtx.lineJoin = 'round'; + + // Connect centers with thick line + contourCtx.lineWidth = 2 * spots[0].r * bw; + contourCtx.beginPath(); + for (let i = 0; i < spots.length; i++) { + const x = spots[i].x * bw - originX; + const y = spots[i].y * bh - originY; + if (i === 0) contourCtx.moveTo(x, y); + else contourCtx.lineTo(x, y); + } + contourCtx.stroke(); + + // Fill circle at each spot + for (const s of spots) { + const x = s.x * bw - originX; + const y = s.y * bh - originY; + const r = s.r * bw; + contourCtx.beginPath(); + contourCtx.arc(x, y, r, 0, Math.PI * 2); + contourCtx.fill(); + } + + const imgData = contourCtx.getImageData(0, 0, cw, ch); + const rawContour = traceOuterBoundary(imgData.data, cw, ch); + if (rawContour.length >= 3) { + const simplified = simplifyContour(rawContour, 1.2); + if (simplified.length >= 3) { + let d = `M ${(simplified[0].x + originX).toFixed(1)} ${(simplified[0].y + originY).toFixed(1)}`; + for (let i = 1; i < simplified.length; i++) { + d += ` L ${(simplified[i].x + originX).toFixed(1)} ${(simplified[i].y + originY).toFixed(1)}`; + } + d += ' Z'; + return d; + } + } + } + } + } catch { + // Fallback below + } + } + + // Fallback hull const pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw })); const r = pts[0].r; const n = pts.length; - const leftPts: { x: number; y: number }[] = []; const rightPts: { x: number; y: number }[] = []; @@ -995,14 +1177,14 @@ export function ImageStage({ 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. + // When zoomed (s > 1), allows panning across the full viewport in both axes 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); + if (s <= 1) return { x: 0, y: 0 }; + const mx = Math.max(Math.abs(s * b.w - b.wrapW) / 2, s * b.w * 0.4); + const my = Math.max(Math.abs(s * b.h - b.wrapH) / 2, s * b.h * 0.4); return { x: Math.max(-mx, Math.min(mx, x)), y: Math.max(-my, Math.min(my, y)) }; }, [baseRect] @@ -1181,15 +1363,15 @@ export function ImageStage({ // down again, which is the whole of how a stroke is started. if (fingersRef.current.size < 2) pinchRef.current = null; }; - wrap.addEventListener('pointerdown', down); - wrap.addEventListener('pointermove', move); - wrap.addEventListener('pointerup', up); - wrap.addEventListener('pointercancel', up); + wrap.addEventListener('pointerdown', down, { capture: true }); + wrap.addEventListener('pointermove', move, { capture: true }); + wrap.addEventListener('pointerup', up, { capture: true }); + wrap.addEventListener('pointercancel', up, { capture: true }); return () => { - wrap.removeEventListener('pointerdown', down); - wrap.removeEventListener('pointermove', move); - wrap.removeEventListener('pointerup', up); - wrap.removeEventListener('pointercancel', up); + wrap.removeEventListener('pointerdown', down, { capture: true }); + wrap.removeEventListener('pointermove', move, { capture: true }); + wrap.removeEventListener('pointerup', up, { capture: true }); + wrap.removeEventListener('pointercancel', up, { capture: true }); fingersRef.current.clear(); pinchRef.current = null; }; @@ -1672,6 +1854,7 @@ export function ImageStage({ return (
1 ? '1' : undefined} onDragOver={(e) => { stop(e); setOver(true); @@ -1719,6 +1902,7 @@ export function ImageStage({ back as a new photo — the look re-applied once per drag. */} 1 ? '1' : undefined} src={peek && originalUrl ? originalUrl : previewUrl} alt="preview" draggable={false} diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index bc08126..2d5e6c8 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz +be6e387b7d02f5fcce947d7411cebe01331f87d14207e009802c21624a3bebe7 recipescam-images.tar.gz