feat(crop): rotate + crop step between deskew and measure
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

This commit is contained in:
Samuel Prevost 2026-05-01 00:12:08 +02:00
parent 415058d7d8
commit 565baddfbf
12 changed files with 994 additions and 32 deletions

View File

@ -5,6 +5,7 @@ import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import DeskewViewer from "@/components/DeskewViewer.vue"
import CropViewer from "@/components/CropViewer.vue"
import MeasureViewer from "@/components/MeasureViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
@ -76,7 +77,8 @@ const store = useAppStore()
<ExifViewer v-else-if="store.currentStep === 2" />
<DatumEditor v-else-if="store.currentStep === 3" />
<DeskewViewer v-else-if="store.currentStep === 4" />
<MeasureViewer v-else-if="store.currentStep === 5" />
<CropViewer v-else-if="store.currentStep === 5" />
<MeasureViewer v-else-if="store.currentStep === 6" />
</main>
<!-- Footer is fixed to the bottom. On mobile we tuck the theme

View File

@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { nanoid } from "nanoid"
import type { Point } from "@/types"
import type { ImagePreTransform, Point } from "@/types"
import type {
LineMeasurement,
RectMeasurement,
@ -16,9 +16,37 @@ import { useAppStore } from "@/stores/app"
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
// `imageTransform` is an optional pre-transform from
// "deskewed-image space" (the original untransformed measurement
// coordinate frame) to the bitmap space of the image actually shown
// by `imageUrl`. Used by the Crop & Rotate step so measurements stay
// anchored to the deskewed image while the user views a rotated +
// cropped sub-bitmap. The mapping is:
//
// measurement_pt rotate around (srcW/2, srcH/2) by rotationDeg
// translate by (rotW/2, rotH/2)
// subtract (cropX, cropY) bitmap_pt
//
// When omitted (or set to identity values), behaviour is unchanged.
//
// The viewer does NOT rewrite stored measurement coordinates when the
// transform changes; it only adjusts the draw-time projection. This is
// option (b) from the task brief and means changing the crop/rotation
// never invalidates persisted measurements.
const props = defineProps<{
imageUrl: string
scalePxPerMm: number
/** Optional pre-transform; identity when not supplied. */
imageTransform?: ImagePreTransform
}>()
// Emit when the measurement set changes (add / mutate / delete) so the
// parent can refresh the recent-uploads gallery thumbnail. Fired
// debounced (via the same `measurements` deep watcher that persists to
// localStorage) so high-frequency interactions like dragging an
// endpoint don't thrash the encoder.
const emit = defineEmits<{
(event: "measurements-changed"): void
}>()
const isMobile = useMediaQuery("(max-width: 767px)")
@ -356,25 +384,68 @@ function makeLiveCtx(): RenderCtx {
}
}
function imgToCtx(pt: Point, t: RenderCtx): Point {
// Project a measurement point from deskewed-image space into the
// shown bitmap's coordinate frame. When no `imageTransform` is set we
// short-circuit to the identity for a tiny perf win and to keep the
// behaviour pixel-identical for the legacy non-cropped path.
function imgPreTransform(pt: Point): Point {
const tr = props.imageTransform
if (!tr) return pt
const r = (tr.rotationDeg * Math.PI) / 180
const cx = tr.srcW / 2
const cy = tr.srcH / 2
const dx = pt.x - cx
const dy = pt.y - cy
const cos = Math.cos(r)
const sin = Math.sin(r)
const rx = dx * cos - dy * sin + tr.rotW / 2
const ry = dx * sin + dy * cos + tr.rotH / 2
return { x: rx - tr.cropX, y: ry - tr.cropY }
}
// Inverse of `imgPreTransform`: bitmap-space deskewed-image space.
// Used by `screenToImg` so pointer-driven placements / drags stay in
// the canonical measurement coordinate frame.
function imgPreTransformInverse(pt: Point): Point {
const tr = props.imageTransform
if (!tr) return pt
const r = (tr.rotationDeg * Math.PI) / 180
const cx = tr.srcW / 2
const cy = tr.srcH / 2
const rx = pt.x + tr.cropX
const ry = pt.y + tr.cropY
const dx = rx - tr.rotW / 2
const dy = ry - tr.rotH / 2
const cos = Math.cos(r)
const sin = Math.sin(r)
return {
x: pt.x * t.scale + t.offsetX,
y: pt.y * t.scale + t.offsetY,
x: dx * cos + dy * sin + cx,
y: -dx * sin + dy * cos + cy,
}
}
function imgToCtx(pt: Point, t: RenderCtx): Point {
const b = imgPreTransform(pt)
return {
x: b.x * t.scale + t.offsetX,
y: b.y * t.scale + t.offsetY,
}
}
function imgToScreen(pt: Point): Point {
const b = imgPreTransform(pt)
return {
x: pt.x * viewScale.value + viewOffsetX.value,
y: pt.y * viewScale.value + viewOffsetY.value,
x: b.x * viewScale.value + viewOffsetX.value,
y: b.y * viewScale.value + viewOffsetY.value,
}
}
function screenToImg(sx: number, sy: number): Point {
return {
const b: Point = {
x: (sx - viewOffsetX.value) / viewScale.value,
y: (sy - viewOffsetY.value) / viewScale.value,
}
return imgPreTransformInverse(b)
}
// If `snapToAngle` is on, rotate `to` around `from` to the nearest 45°
@ -2324,6 +2395,7 @@ watch(
if (store.fileHash) {
saveMeasurements(store.fileHash, next)
}
emit("measurements-changed")
},
{ deep: true },
)

View File

@ -0,0 +1,507 @@
<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>

View File

@ -301,7 +301,7 @@ async function runDeskew() {
<Button
:disabled="!store.canProceedToStep5"
@click="store.goToStep(5)"
>Next: Measure</Button
>Next: Crop</Button
>
</div>
</div>
@ -738,8 +738,8 @@ async function runDeskew() {
>Deskewed Preview</CardTitle
>
<CardDescription>
Continue to <strong>Measure</strong> to add
annotations and download.
Continue to <strong>Crop</strong> to rotate and
crop the result before measuring.
</CardDescription>
</CardHeader>
<CardContent>
@ -757,7 +757,7 @@ async function runDeskew() {
<div class="flex justify-center pb-8">
<Button size="lg" @click="store.goToStep(5)">
Continue to Measure
Continue to Crop
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"

View File

@ -9,6 +9,10 @@ import {
clearCache as clearMeasurementCache,
loadMeasurements,
} from "@/lib/measurement-cache"
import {
clearCache as clearCropCache,
loadCropRotate,
} from "@/lib/crop-cache"
import {
saveUpload,
loadUpload,
@ -82,11 +86,26 @@ async function refreshRecentUploads() {
}
}
// Mint preview URLs for every entry; we keep them alive for the
// lifetime of this page mount (revoked in onUnmounted).
// lifetime of this page mount (revoked in onUnmounted). Prefer
// the annotated thumbnail (rotation + crop + measurements baked
// in) generated by the Measure step; fall back to the deskew
// result, then the raw original. Existing URL is replaced when
// the preview blob has changed since the last refresh.
const prevById = new Map<string, Blob>()
for (const u of recentUploads.value) {
const prev = u.previewBlob ?? u.correctedBlob ?? u.originalBlob
prevById.set(u.hash, prev)
}
for (const u of all) {
const next = u.previewBlob ?? u.correctedBlob ?? u.originalBlob
const prev = prevById.get(u.hash)
const existing = recentUrls.value.get(u.hash)
if (existing && prev !== next) {
URL.revokeObjectURL(existing)
recentUrls.value.delete(u.hash)
}
if (!recentUrls.value.has(u.hash)) {
const preview = u.correctedBlob ?? u.originalBlob
recentUrls.value.set(u.hash, URL.createObjectURL(preview))
recentUrls.value.set(u.hash, URL.createObjectURL(next))
}
}
recentUploads.value = all
@ -108,6 +127,7 @@ function handleClearCacheClick() {
clearCache()
clearMeasurementCache()
clearZoomCache()
clearCropCache()
// IndexedDB clear is async but the UI doesn't depend on its
// completion fire and forget, then reload the gallery.
void clearUploads().then(() => refreshRecentUploads())
@ -165,6 +185,9 @@ async function restoreUpload(hash: string) {
const cachedDatums = loadDatums(hash) ?? []
const cachedMeasurements = loadMeasurements(hash) ?? []
const cachedCropRotate = loadCropRotate(hash)
if (cachedCropRotate) store.setCropRotate(cachedCropRotate)
else store.resetCropRotate()
store.setFileHash(hash)
store.setImage(convertedFile, image)
@ -200,8 +223,10 @@ async function restoreUpload(hash: string) {
}
// Bump max step so the indicator surfaces every prior step as
// navigable, just like a freshly-completed run would.
store.goToStep(5)
// navigable, just like a freshly-completed run would. Crop
// (step 5) is reachable from the indicator if the user wants to
// revisit it.
store.goToStep(6)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Failed to reopen image"
@ -231,6 +256,12 @@ async function handleFile(file: File) {
const hash = await hashFile(file)
store.setFileHash(hash)
// Hydrate any saved rotation/crop for this file so re-uploading
// the same image keeps the user's prior framing.
const cachedCropRotate = loadCropRotate(hash)
if (cachedCropRotate) store.setCropRotate(cachedCropRotate)
else store.resetCropRotate()
const cached = loadDatums(hash)
if (cached && cached.length > 0) {
store.datums = cached
@ -257,6 +288,7 @@ async function handleFile(file: File) {
correctedBlob: existing?.correctedBlob,
diagnostics: existing?.diagnostics,
scalePxPerMm: existing?.scalePxPerMm,
previewBlob: existing?.previewBlob,
}).catch(() => {
// IndexedDB unavailable or quota exceeded non-fatal,
// gallery just won't include this upload.

View File

@ -15,6 +15,7 @@ import {
CardTitle,
} from "@/components/ui/card"
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
import type { ImagePreTransform } from "@/types"
// `defineExpose` in CorrectedImageViewer makes these methods available on
// the template ref, but Vue's ComponentPublicInstance type doesn't surface
// them automatically we type the ref explicitly so the call is checked.
@ -25,13 +26,144 @@ type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
}) => Promise<Blob>
}
import { loadSettings, saveSettings } from "@/lib/settings-cache"
import {
rotatedBboxSize,
cropPixels,
renderRotatedCropped,
} from "@/lib/crop-transform"
import { patchUpload } from "@/lib/upload-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const imageTransform = ref<ImagePreTransform | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const error = ref("")
const includeScaleBar = ref(false)
// Debounce timer for the recent-uploads thumbnail. Measurement
// dragging fires the watcher dozens of times a second; we only want
// one regen per ~500 ms idle period.
let previewTimer: ReturnType<typeof setTimeout> | null = null
const PREVIEW_DEBOUNCE_MS = 500
const PREVIEW_MAX_WIDTH = 400
function downscaleToPreview(srcBlob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(srcBlob)
const el = new Image()
el.onload = () => {
try {
const w = el.naturalWidth
const h = el.naturalHeight
if (w <= 0 || h <= 0) {
reject(new Error("Empty preview source"))
return
}
const ratio = Math.min(1, PREVIEW_MAX_WIDTH / w)
const tw = Math.max(1, Math.round(w * ratio))
const th = Math.max(1, Math.round(h * ratio))
const c = document.createElement("canvas")
c.width = tw
c.height = th
const ctx = c.getContext("2d")
if (!ctx) {
reject(new Error("No 2D context"))
return
}
ctx.drawImage(el, 0, 0, tw, th)
c.toBlob((b) => {
if (b) resolve(b)
else reject(new Error("preview toBlob failed"))
}, "image/png")
} finally {
URL.revokeObjectURL(url)
}
}
el.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error("preview load failed"))
}
el.src = url
})
}
async function regeneratePreview() {
const viewer = viewerRef.value
const hash = store.fileHash
if (!viewer || !hash) return
try {
const annotated = await viewer.exportWithMeasurements({
scope: "full",
includeScaleBar: false,
})
const preview = await downscaleToPreview(annotated)
await patchUpload(hash, { previewBlob: preview })
} catch {
// Preview is a nice-to-have never block the user on a failure.
}
}
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer)
previewTimer = setTimeout(() => {
previewTimer = null
void regeneratePreview()
}, PREVIEW_DEBOUNCE_MS)
}
// Render the rotation + crop applied to the deskew result and surface
// it as an object URL + transform for CorrectedImageViewer. We keep
// the stored measurements in pre-rotate, pre-crop deskewed-image
// space, and pass a pre-transform so the viewer can draw them on the
// cropped bitmap correctly. Recomputed on entry to Measure.
async function buildTransformedSource() {
const result = store.deskewResult
if (!result) return
const url = URL.createObjectURL(result.correctedImageBlob)
try {
const image = await new Promise<HTMLImageElement>(
(resolve, reject) => {
const el = new Image()
el.onload = () => {
resolve(el)
}
el.onerror = () => {
reject(new Error("Failed to load deskewed image"))
}
el.src = url
},
)
const state = store.cropRotate
const rot = rotatedBboxSize(
image.naturalWidth,
image.naturalHeight,
state.rotationDeg,
)
const px = cropPixels(state, rot)
const out = renderRotatedCropped(image, state)
const blob = await new Promise<Blob>((resolve, reject) => {
out.toBlob((b) => {
if (b) resolve(b)
else reject(new Error("Crop render failed"))
}, "image/png")
})
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(blob)
imageTransform.value = {
rotationDeg: state.rotationDeg,
srcW: image.naturalWidth,
srcH: image.naturalHeight,
rotW: rot.rotW,
rotH: rot.rotH,
cropX: px.cropX,
cropY: px.cropY,
}
schedulePreview()
} finally {
URL.revokeObjectURL(url)
}
}
onMounted(() => {
const cached = loadSettings()
if (cached) {
@ -39,9 +171,7 @@ onMounted(() => {
}
if (store.deskewResult) {
resultUrl.value = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
void buildTransformedSource()
} else {
// No result yet bounce back to Deskew. Should never happen via
// normal navigation since the Next button is gated, but if a user
@ -53,6 +183,10 @@ onMounted(() => {
onUnmounted(() => {
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
if (previewTimer) {
clearTimeout(previewTimer)
previewTimer = null
}
})
watch(includeScaleBar, () => {
@ -138,14 +272,16 @@ function addScaleBar(image: HTMLImageElement): Promise<Blob> {
}
async function download() {
if (!store.deskewResult) return
if (!store.deskewResult || !resultUrl.value) return
let blob: Blob = store.deskewResult.correctedImageBlob
// Download the post-crop, post-rotation bitmap so the file matches
// what the user has been looking at in the Measure step. When the
// user kept the defaults (no rotation, full-image crop) this is
// bit-identical to the original deskew blob (modulo PNG re-encode).
let blob: Blob = await fetch(resultUrl.value).then((r) => r.blob())
if (includeScaleBar.value) {
const imgUrl = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
const imgUrl = URL.createObjectURL(blob)
try {
const image = await new Promise<HTMLImageElement>(
(resolve, reject) => {
@ -227,9 +363,9 @@ async function downloadMeasured(scope: "full" | "view") {
variant="ghost"
size="icon"
class="shrink-0"
aria-label="Back to Deskew"
title="Back to Deskew"
@click="store.goToStep(4)"
aria-label="Back to Crop"
title="Back to Crop"
@click="store.goToStep(5)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -395,6 +531,8 @@ async function downloadMeasured(scope: "full" | "view") {
ref="viewerRef"
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
:image-transform="imageTransform ?? undefined"
@measurements-changed="schedulePreview"
/>
</CardContent>
</Card>

View File

@ -10,7 +10,8 @@ const steps: { num: AppStep; label: string }[] = [
{ num: 2, label: "EXIF" },
{ num: 3, label: "Datums" },
{ num: 4, label: "Deskew" },
{ num: 5, label: "Measure" },
{ num: 5, label: "Crop" },
{ num: 6, label: "Measure" },
]
function isReachable(num: AppStep): boolean {

49
src/lib/crop-cache.ts Normal file
View File

@ -0,0 +1,49 @@
import type { CropRotateState } from "@/types"
// Per-file-hash storage of the user's rotation + crop choices for the
// Crop & Rotate step. Mirrors the shape of `measurement-cache.ts`. We
// store fractional crop bounds (0..1) of the rotated image so the same
// crop applies cleanly after a re-deskew at a different output px/mm.
const KEY_PREFIX = "skwik-crop-"
export function saveCropRotate(hash: string, state: CropRotateState): void {
try {
localStorage.setItem(KEY_PREFIX + hash, JSON.stringify(state))
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadCropRotate(hash: string): CropRotateState | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<CropRotateState> | null
if (!parsed || typeof parsed !== "object") return null
const rot = parsed.rotationDeg
const c = parsed.crop
if (
typeof rot !== "number" ||
!c ||
typeof c.left !== "number" ||
typeof c.top !== "number" ||
typeof c.right !== "number" ||
typeof c.bottom !== "number"
) {
return null
}
return { rotationDeg: rot, crop: { left: c.left, top: c.top, right: c.right, bottom: c.bottom } }
} catch {
return null
}
}
export function clearCache(): void {
const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(KEY_PREFIX)) toRemove.push(key)
}
for (const key of toRemove) localStorage.removeItem(key)
}

90
src/lib/crop-transform.ts Normal file
View File

@ -0,0 +1,90 @@
import type { CropRotateState } from "@/types"
// Geometry helpers for the Crop & Rotate step. The pipeline:
// 1. Start in "deskewed image space" (the bitmap produced by the
// solver, dimensions deskewW × deskewH).
// 2. Rotate around the deskewed image's centre by `rotationDeg`.
// The rotated bitmap is the axis-aligned bounding box of the
// rotated rectangle (rotW × rotH); the original corners get
// translated so the bbox starts at (0, 0).
// 3. Crop a sub-rectangle of that rotated bitmap, defined by
// fractions `crop.left/top/right/bottom` of its size.
// Measurements live in deskewed-image space; the renderer composes this
// transform on top of the live pan/zoom to project them onto the cropped
// bitmap, which is what the user is actually looking at.
interface RotatedSize {
rotW: number
rotH: number
}
/** Bounding box dimensions of `srcW × srcH` rotated by `rotationDeg`
* around its centre. */
export function rotatedBboxSize(
srcW: number,
srcH: number,
rotationDeg: number,
): RotatedSize {
const r = (rotationDeg * Math.PI) / 180
const c = Math.abs(Math.cos(r))
const s = Math.abs(Math.sin(r))
return {
rotW: srcW * c + srcH * s,
rotH: srcW * s + srcH * c,
}
}
interface CropPixels {
cropX: number
cropY: number
cropW: number
cropH: number
}
/** Pixel-space crop rect on the rotated bitmap, derived from the
* fractional crop. Clamped to the rotated bbox so a stale fraction
* never produces a negative-size crop. */
export function cropPixels(
state: CropRotateState,
rot: RotatedSize,
): CropPixels {
const left = Math.min(Math.max(state.crop.left, 0), 1)
const top = Math.min(Math.max(state.crop.top, 0), 1)
const right = Math.min(Math.max(state.crop.right, left), 1)
const bottom = Math.min(Math.max(state.crop.bottom, top), 1)
return {
cropX: left * rot.rotW,
cropY: top * rot.rotH,
cropW: Math.max(1, (right - left) * rot.rotW),
cropH: Math.max(1, (bottom - top) * rot.rotH),
}
}
/** Render the deskewed bitmap onto a fresh canvas with rotation and
* fractional crop applied. Output canvas dims match the cropped sub-
* rectangle in pixels. Used to feed Measure/preview/exports. */
export function renderRotatedCropped(
image: HTMLImageElement | HTMLCanvasElement,
state: CropRotateState,
): HTMLCanvasElement {
const srcW = "naturalWidth" in image ? image.naturalWidth : image.width
const srcH = "naturalHeight" in image ? image.naturalHeight : image.height
const rot = rotatedBboxSize(srcW, srcH, state.rotationDeg)
const px = cropPixels(state, rot)
const out = document.createElement("canvas")
out.width = Math.round(px.cropW)
out.height = Math.round(px.cropH)
const ctx = out.getContext("2d")
if (!ctx) return out
// Draw with the deskewed-bitmap-centre→rotated-bbox-centre transform,
// then translate so the crop's top-left corner becomes (0, 0).
ctx.save()
ctx.translate(-px.cropX, -px.cropY)
ctx.translate(rot.rotW / 2, rot.rotH / 2)
ctx.rotate((state.rotationDeg * Math.PI) / 180)
ctx.drawImage(image, -srcW / 2, -srcH / 2)
ctx.restore()
return out
}

View File

@ -24,6 +24,14 @@ export interface UploadRecord {
correctedBlob?: Blob
diagnostics?: DeskewDiagnostics
scalePxPerMm?: number
/** Latest annotated thumbnail (cropped, rotated, with measurements
* baked in) generated by the Measure step. Used by the recent-
* uploads gallery so each tile reflects the work the user
* actually did on it, not just the raw deskew result. Optional
* the gallery falls back to `correctedBlob`/`originalBlob` when
* unset (e.g. a freshly-uploaded image that hasn't reached
* Measure yet). */
previewBlob?: Blob
}
let dbPromise: Promise<IDBDatabase> | null = null

View File

@ -1,7 +1,14 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import type {
AppStep,
CropRotateState,
Datum,
DeskewResult,
ExifData,
Point,
} from "@/types"
import { DEFAULT_SCALE_PX_PER_MM, IDENTITY_CROP_ROTATE } from "@/types"
import { loadSettings } from "@/lib/settings-cache"
import { fitEllipse } from "@/lib/ellipse-fit"
@ -27,6 +34,10 @@ export const useAppStore = defineStore("app", () => {
* produces a new result; consumers compare against the live
* `scalePxPerMm` to detect when measurements need to be rescaled. */
const lastDeskewScale = ref<number | null>(null)
/** Rotation (deg) + fractional crop applied on top of the deskew
* result. Identity = no rotation, full image. Persisted per-hash via
* `src/lib/crop-cache.ts` so it survives reloads and re-deskews. */
const cropRotate = ref<CropRotateState>({ ...IDENTITY_CROP_ROTATE })
const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -48,6 +59,7 @@ export const useAppStore = defineStore("app", () => {
const canProceedToStep5 = computed(
() => canProceedToStep4.value && deskewResult.value !== null,
)
const canProceedToStep6 = computed(() => canProceedToStep5.value)
function setImage(file: File, image: HTMLImageElement) {
originalFile.value = file
@ -178,6 +190,14 @@ export const useAppStore = defineStore("app", () => {
fileHash.value = hash
}
function setCropRotate(state: CropRotateState) {
cropRotate.value = state
}
function resetCropRotate() {
cropRotate.value = { ...IDENTITY_CROP_ROTATE }
}
function reset() {
currentStep.value = 1
maxStepReached.value = 1
@ -193,6 +213,7 @@ export const useAppStore = defineStore("app", () => {
fileHash.value = null
cacheRestoreMessage.value = ""
lastDeskewScale.value = null
cropRotate.value = { ...IDENTITY_CROP_ROTATE }
}
return {
@ -214,6 +235,10 @@ export const useAppStore = defineStore("app", () => {
canProceedToStep3,
canProceedToStep4,
canProceedToStep5,
canProceedToStep6,
cropRotate,
setCropRotate,
resetCropRotate,
setImage,
setExif,
goToStep,

View File

@ -118,7 +118,45 @@ export interface DeskewResult {
diagnostics: DeskewDiagnostics
}
export type AppStep = 1 | 2 | 3 | 4 | 5
export type AppStep = 1 | 2 | 3 | 4 | 5 | 6
/** Crop rectangle stored as fractions (0..1) of the rotated deskewed
* image. Persisting fractions (rather than absolute pixels) means the
* same crop survives a re-deskew at a different output px/mm. */
export interface CropRectFractions {
left: number
top: number
right: number
bottom: number
}
/** Per-hash post-deskew transform: rotate around the deskewed image's
* centre by `rotationDeg`, then crop the bounding box defined by
* fractional `crop` values of the rotated image. Default is identity:
* rotation 0, crop covering the full image. */
export interface CropRotateState {
rotationDeg: number
crop: CropRectFractions
}
export const IDENTITY_CROP_ROTATE: CropRotateState = {
rotationDeg: 0,
crop: { left: 0, top: 0, right: 1, bottom: 1 },
}
/** Pre-transform consumed by `CorrectedImageViewer`: maps measurement
* points from the original deskewed-image space onto the bitmap that
* is actually painted (which may be a rotated + cropped derivative).
* See `CorrectedImageViewer.vue` for the formula. */
export interface ImagePreTransform {
rotationDeg: number
srcW: number
srcH: number
rotW: number
rotH: number
cropX: number
cropY: number
}
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
export const DEFAULT_SCALE_PX_PER_MM = 10