Skwik/src/components/CropViewer.vue
Samuel Prevost 565baddfbf
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
feat(crop): rotate + crop step between deskew and measure
2026-05-01 00:12:11 +02:00

508 lines
16 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { saveCropRotate, loadCropRotate } from "@/lib/crop-cache"
import { rotatedBboxSize } from "@/lib/crop-transform"
import type { Point } from "@/types"
// Crop & Rotate step.
// * The user picks an arbitrary rotation in degrees (-180..180).
// * Rotation is applied first, around the deskewed image's centre.
// The rotated bitmap's axis-aligned bounding box is the canvas the
// crop rectangle lives in.
// * The crop rectangle is then dragged via 8 handles (4 corners + 4
// edge midpoints). It's stored as fractions of the rotated bbox so
// the same crop survives a re-deskew at a different output px/mm.
//
// Persistence: identical pattern to measurement-cache.ts, keyed by
// `store.fileHash`. We write to localStorage on every change.
const store = useAppStore()
const containerRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const overlayRef = ref<HTMLCanvasElement | null>(null)
const img = ref<HTMLImageElement | null>(null)
const imgUrl = ref<string | null>(null)
// Rotation in degrees. Crop fractions of the rotated bbox.
const rotationDeg = ref(0)
const cropLeft = ref(0)
const cropTop = ref(0)
const cropRight = ref(1)
const cropBottom = ref(1)
// Live-fit transform from rotated-bbox space → screen canvas pixels.
// Recomputed on resize / rotation change so the user always sees the
// whole bbox + a bit of breathing room.
const fitScale = ref(1)
const fitOffsetX = ref(0)
const fitOffsetY = ref(0)
const HANDLE_HIT_PX = 14
const rotBbox = computed(() => {
const i = img.value
if (!i) return { rotW: 1, rotH: 1 }
return rotatedBboxSize(i.naturalWidth, i.naturalHeight, rotationDeg.value)
})
function persist() {
if (!store.fileHash) return
const state = {
rotationDeg: rotationDeg.value,
crop: {
left: cropLeft.value,
top: cropTop.value,
right: cropRight.value,
bottom: cropBottom.value,
},
}
store.setCropRotate(state)
saveCropRotate(store.fileHash, state)
}
function loadImage(url: string) {
const el = new Image()
el.onload = () => {
img.value = el
fitToContainer()
redraw()
}
el.src = url
}
function fitToContainer() {
const c = containerRef.value
const i = img.value
if (!c || !i) return
const cw = c.clientWidth
const ch = c.clientHeight
if (canvasRef.value) {
canvasRef.value.width = cw
canvasRef.value.height = ch
}
if (overlayRef.value) {
overlayRef.value.width = cw
overlayRef.value.height = ch
}
const { rotW, rotH } = rotBbox.value
const fit = Math.min(cw / rotW, ch / rotH) * 0.9
fitScale.value = fit
fitOffsetX.value = (cw - rotW * fit) / 2
fitOffsetY.value = (ch - rotH * fit) / 2
}
function rotatedToScreen(p: Point): Point {
return {
x: p.x * fitScale.value + fitOffsetX.value,
y: p.y * fitScale.value + fitOffsetY.value,
}
}
function screenToRotated(sx: number, sy: number): Point {
return {
x: (sx - fitOffsetX.value) / fitScale.value,
y: (sy - fitOffsetY.value) / fitScale.value,
}
}
function redraw() {
drawImage()
drawOverlay()
}
function drawImage() {
const canvas = canvasRef.value
const i = img.value
if (!canvas || !i) return
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const { rotW, rotH } = rotBbox.value
ctx.save()
// Place rotated-bbox origin at fit offset, then scale.
ctx.translate(fitOffsetX.value, fitOffsetY.value)
ctx.scale(fitScale.value, fitScale.value)
// Rotation around the bbox centre — cancels back to deskewed-image
// axis-aligned drawing offset by (rotW-srcW)/2 etc.
ctx.translate(rotW / 2, rotH / 2)
ctx.rotate((rotationDeg.value * Math.PI) / 180)
ctx.drawImage(i, -i.naturalWidth / 2, -i.naturalHeight / 2)
ctx.restore()
}
function cropRectScreen(): { x: number; y: number; w: number; h: number } {
const { rotW, rotH } = rotBbox.value
const tl = rotatedToScreen({ x: cropLeft.value * rotW, y: cropTop.value * rotH })
const br = rotatedToScreen({ x: cropRight.value * rotW, y: cropBottom.value * rotH })
return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y }
}
function drawOverlay() {
const canvas = overlayRef.value
const i = img.value
if (!canvas || !i) return
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const r = cropRectScreen()
// Dim everything outside the crop rect with a 50% black overlay.
// Draw a full-canvas dark fill with a punched-out hole — fillRect +
// even-odd fill is overkill; clipping with two rects is simpler.
ctx.save()
ctx.fillStyle = "rgba(0,0,0,0.55)"
ctx.fillRect(0, 0, canvas.width, r.y)
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - (r.y + r.h))
ctx.fillRect(0, r.y, r.x, r.h)
ctx.fillRect(r.x + r.w, r.y, canvas.width - (r.x + r.w), r.h)
ctx.restore()
// Crop rectangle outline.
ctx.save()
ctx.strokeStyle = "#fff"
ctx.lineWidth = 1.5
ctx.setLineDash([6, 4])
ctx.strokeRect(r.x, r.y, r.w, r.h)
ctx.restore()
// Rule-of-thirds guides — light grey, no dash.
ctx.save()
ctx.strokeStyle = "rgba(255,255,255,0.25)"
ctx.lineWidth = 1
for (let k = 1; k < 3; k++) {
const x = r.x + (r.w * k) / 3
ctx.beginPath()
ctx.moveTo(x, r.y)
ctx.lineTo(x, r.y + r.h)
ctx.stroke()
const y = r.y + (r.h * k) / 3
ctx.beginPath()
ctx.moveTo(r.x, y)
ctx.lineTo(r.x + r.w, y)
ctx.stroke()
}
ctx.restore()
// Handle dots: 4 corners + 4 edge midpoints.
const handles = handlePositionsScreen()
ctx.save()
ctx.fillStyle = "#fff"
ctx.strokeStyle = "rgba(0,0,0,0.5)"
ctx.lineWidth = 1
for (const h of handles) {
ctx.beginPath()
ctx.arc(h.pt.x, h.pt.y, 5, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
ctx.restore()
}
type HandleKey =
| "tl" | "tr" | "br" | "bl"
| "t" | "r" | "b" | "l"
function handlePositionsScreen(): { key: HandleKey; pt: Point }[] {
const r = cropRectScreen()
const cx = r.x + r.w / 2
const cy = r.y + r.h / 2
return [
{ key: "tl", pt: { x: r.x, y: r.y } },
{ key: "tr", pt: { x: r.x + r.w, y: r.y } },
{ key: "br", pt: { x: r.x + r.w, y: r.y + r.h } },
{ key: "bl", pt: { x: r.x, y: r.y + r.h } },
{ key: "t", pt: { x: cx, y: r.y } },
{ key: "r", pt: { x: r.x + r.w, y: cy } },
{ key: "b", pt: { x: cx, y: r.y + r.h } },
{ key: "l", pt: { x: r.x, y: cy } },
]
}
interface DragState {
handle: HandleKey | "body"
/** Crop in fractions at drag start, used as the base for delta updates. */
startLeft: number
startTop: number
startRight: number
startBottom: number
/** Pointer position in rotated-bbox space at drag start. */
startRot: Point
}
let drag: DragState | null = null
function pickHandle(sx: number, sy: number): HandleKey | null {
for (const h of handlePositionsScreen()) {
if (Math.hypot(sx - h.pt.x, sy - h.pt.y) <= HANDLE_HIT_PX) return h.key
}
return null
}
function onPointerDown(ev: PointerEvent) {
const canvas = overlayRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const sx = ev.clientX - rect.left
const sy = ev.clientY - rect.top
const handle = pickHandle(sx, sy)
const r = cropRectScreen()
const insideBody =
handle === null &&
sx >= r.x && sx <= r.x + r.w &&
sy >= r.y && sy <= r.y + r.h
if (!handle && !insideBody) return
canvas.setPointerCapture(ev.pointerId)
drag = {
handle: handle ?? "body",
startLeft: cropLeft.value,
startTop: cropTop.value,
startRight: cropRight.value,
startBottom: cropBottom.value,
startRot: screenToRotated(sx, sy),
}
}
function onPointerMove(ev: PointerEvent) {
if (!drag) return
const canvas = overlayRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const sx = ev.clientX - rect.left
const sy = ev.clientY - rect.top
const cur = screenToRotated(sx, sy)
const { rotW, rotH } = rotBbox.value
const dx = (cur.x - drag.startRot.x) / rotW
const dy = (cur.y - drag.startRot.y) / rotH
const minSize = 0.05 // 5% minimum width/height to keep the crop usable.
let l = drag.startLeft
let t = drag.startTop
let r2 = drag.startRight
let b = drag.startBottom
const k = drag.handle
if (k === "body") {
const w = drag.startRight - drag.startLeft
const h = drag.startBottom - drag.startTop
let nl = drag.startLeft + dx
let nt = drag.startTop + dy
nl = Math.min(Math.max(nl, 0), 1 - w)
nt = Math.min(Math.max(nt, 0), 1 - h)
l = nl
t = nt
r2 = nl + w
b = nt + h
} else {
if (k === "tl" || k === "l" || k === "bl") {
l = Math.min(Math.max(drag.startLeft + dx, 0), drag.startRight - minSize)
}
if (k === "tr" || k === "r" || k === "br") {
r2 = Math.min(Math.max(drag.startRight + dx, drag.startLeft + minSize), 1)
}
if (k === "tl" || k === "t" || k === "tr") {
t = Math.min(Math.max(drag.startTop + dy, 0), drag.startBottom - minSize)
}
if (k === "bl" || k === "b" || k === "br") {
b = Math.min(Math.max(drag.startBottom + dy, drag.startTop + minSize), 1)
}
}
cropLeft.value = l
cropTop.value = t
cropRight.value = r2
cropBottom.value = b
drawOverlay()
}
function onPointerUp(ev: PointerEvent) {
const canvas = overlayRef.value
if (canvas?.hasPointerCapture(ev.pointerId)) {
canvas.releasePointerCapture(ev.pointerId)
}
if (drag) {
drag = null
persist()
}
}
function resetCrop() {
cropLeft.value = 0
cropTop.value = 0
cropRight.value = 1
cropBottom.value = 1
rotationDeg.value = 0
persist()
fitToContainer()
redraw()
}
function onRotationInput(v: number) {
if (!Number.isFinite(v)) return
// Clamp to [-180, 180] and normalise so the slider/spinbox can't escape.
const clamped = Math.min(Math.max(v, -180), 180)
rotationDeg.value = clamped
fitToContainer()
redraw()
persist()
}
function rotateBy(delta: number) {
onRotationInput(rotationDeg.value + delta)
}
let resizeObs: ResizeObserver | null = null
onMounted(() => {
if (!store.deskewResult) {
store.goToStep(4)
return
}
// Pre-fill from the store / cache so the user's previous crop &
// rotation come back when they re-enter the step.
const cached = store.fileHash ? loadCropRotate(store.fileHash) : null
const seed = cached ?? store.cropRotate
rotationDeg.value = seed.rotationDeg
cropLeft.value = seed.crop.left
cropTop.value = seed.crop.top
cropRight.value = seed.crop.right
cropBottom.value = seed.crop.bottom
if (cached) store.setCropRotate(cached)
imgUrl.value = URL.createObjectURL(store.deskewResult.correctedImageBlob)
loadImage(imgUrl.value)
if (containerRef.value) {
resizeObs = new ResizeObserver(() => {
fitToContainer()
redraw()
})
resizeObs.observe(containerRef.value)
}
})
onUnmounted(() => {
resizeObs?.disconnect()
if (imgUrl.value) URL.revokeObjectURL(imgUrl.value)
})
watch(rotationDeg, () => {
fitToContainer()
redraw()
})
watch([cropLeft, cropTop, cropRight, cropBottom], () => {
drawOverlay()
})
function next() {
persist()
store.goToStep(6)
}
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-xl font-semibold">Crop &amp; Rotate</h2>
<p class="text-sm text-muted-foreground">
Optionally rotate and crop the deskewed image before
measuring.
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" @click="store.goToStep(4)">Back</Button>
<Button variant="outline" @click="resetCrop">Reset</Button>
<Button @click="next">Next: Measure</Button>
</div>
</div>
<Card>
<CardHeader class="space-y-2">
<CardTitle class="text-base">Rotation</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<input
type="range"
min="-180"
max="180"
step="0.1"
:value="rotationDeg"
class="h-2 w-full max-w-md flex-1 accent-primary"
@input="(e) => onRotationInput(Number((e.target as HTMLInputElement).value))"
/>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 px-2"
@click="rotateBy(-90)"
>-90°</Button
>
<Button
variant="outline"
size="sm"
class="h-8 px-2"
@click="rotateBy(-1)"
>-1°</Button
>
<input
type="number"
min="-180"
max="180"
step="0.1"
:value="rotationDeg.toFixed(1)"
class="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
@input="(e) => onRotationInput(Number((e.target as HTMLInputElement).value))"
/>
<span class="text-sm text-muted-foreground">°</span>
<Button
variant="outline"
size="sm"
class="h-8 px-2"
@click="rotateBy(1)"
>+1°</Button
>
<Button
variant="outline"
size="sm"
class="h-8 px-2"
@click="rotateBy(90)"
>+90°</Button
>
</div>
</div>
</CardHeader>
<CardContent>
<div
ref="containerRef"
class="relative h-[calc(100vh-22rem)] min-h-[320px] w-full overflow-hidden rounded-md bg-muted"
>
<canvas
ref="canvasRef"
class="absolute inset-0"
/>
<canvas
ref="overlayRef"
class="absolute inset-0 touch-none"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
</div>
</CardContent>
</Card>
</div>
</template>