Skwik/src/components/MeasureViewer.vue
Samuel Prevost 6dc5454d46
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
refactor(crop): tighten architecture + fix zoom-after-crop
Architectural debt addressed in one pass:

- CropViewer drops its five local refs (rotationDeg, cropLeft/Top/Right
  /Bottom) in favour of computed views over `store.cropRotate`. Drag and
  rotation handlers write straight to the store via thin setters; the
  store is now the canonical source of truth. localStorage is flushed on
  commit boundaries (drag end, rotation change, unmount, Next), not on
  every pointermove.
- `ImagePreTransform` deleted from `src/types/index.ts`. CorrectedImage-
  Viewer now takes `crop?: { state: CropRotateState; srcW; srcH }` and
  derives the pixel-space affine internally via a memoised `computed`.
  Cuts the second reactive ref in MeasureViewer and removes the
  redundant "type that exists only to ferry derived numbers across a
  prop boundary."
- `crop-transform.ts` is now pure geometry. The DOM-bound
  `renderRotatedCropped` moved to a new `src/lib/crop-render.ts`. The
  pure module is now safe to import from a worker / test without a
  canvas mock.

Bug fix shipped in the same commit because it fell out of the same
analysis:

- Zoom (wheel + pinch) anchored on a measurement-space point returned
  by `screenToImg`, but `viewOffset = screen - bitmap * scale` expects a
  bitmap-space point. With identity crop the two were equal, so the bug
  was invisible until the user cropped — at that point each zoom step
  shifted the image far off-screen ("canvas goes blank/black"). Pan was
  fine because it never converted screen↔image. Fix: a tiny
  `screenToBitmap` helper used by `onWheel` and the pinch branch of
  `onTouchMove`. Measurement placement / drag still go through the
  full `screenToImg` so coords stay in the canonical frame.
2026-05-01 00:29:24 +02:00

534 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
// `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.
type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
exportWithMeasurements: (opts: {
scope: "full" | "view"
includeScaleBar: boolean
}) => Promise<Blob>
}
import { loadSettings, saveSettings } from "@/lib/settings-cache"
import { renderRotatedCropped } from "@/lib/crop-render"
import { patchUpload } from "@/lib/upload-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
// Source dimensions of the deskewed bitmap (the input to rotate+crop).
// Captured once when we render the cropped output and passed to
// CorrectedImageViewer so it can derive the bitmap-space affine for
// projecting measurement coords. Null until the deskew bitmap is
// decoded for the first time.
const srcDims = ref<{ w: number; h: number } | 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 out = renderRotatedCropped(image, store.cropRotate)
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)
srcDims.value = { w: image.naturalWidth, h: image.naturalHeight }
schedulePreview()
} finally {
URL.revokeObjectURL(url)
}
}
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
}
if (store.deskewResult) {
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
// edits the URL or hot-reloads we don't want to render an empty
// viewer.
store.goToStep(4)
}
})
onUnmounted(() => {
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
if (previewTimer) {
clearTimeout(previewTimer)
previewTimer = null
}
})
watch(includeScaleBar, () => {
saveSettings({
scalePxPerMm: store.scalePxPerMm,
includeScaleBar: includeScaleBar.value,
})
})
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
return new Promise((resolve, reject) => {
const iw = image.naturalWidth
const ih = image.naturalHeight
const scale = store.scalePxPerMm
const unit = Math.max(iw / 100, 8)
const barHeightPx = Math.round(unit * 5)
const canvas = document.createElement("canvas")
canvas.width = iw
canvas.height = ih + barHeightPx
const ctx = canvas.getContext("2d")
if (!ctx) {
reject(new Error("No 2D context"))
return
}
ctx.drawImage(image, 0, 0)
ctx.fillStyle = "#000"
ctx.fillRect(0, ih, iw, barHeightPx)
const imgWidthMm = iw / scale
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
const targetMm = imgWidthMm * 0.2
let barMm = niceSteps[0] ?? 10
for (const s of niceSteps) {
barMm = s
if (s >= targetMm) break
}
const barWidthPx = barMm * scale
const margin = Math.round(unit * 2)
const barX = margin
const barY = ih + barHeightPx / 2
const barThick = Math.max(Math.round(unit * 0.6), 4)
const tickH = Math.round(unit * 1.5)
const tickW = Math.max(2, Math.round(unit * 0.15))
ctx.fillStyle = "#fff"
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
ctx.fillRect(
barX + barWidthPx - tickW,
barY - tickH / 2,
tickW,
tickH,
)
const fontSize = Math.round(unit * 1.4)
ctx.font = `bold ${String(fontSize)}px monospace`
ctx.fillStyle = "#fff"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(
`${String(barMm)} mm`,
barX + barWidthPx / 2,
barY - tickH / 2 - Math.round(unit * 0.3),
)
const smallFont = Math.round(unit * 1)
ctx.textAlign = "right"
ctx.textBaseline = "middle"
ctx.font = `${String(smallFont)}px monospace`
ctx.fillStyle = "rgba(255,255,255,0.6)"
ctx.fillText(`${String(scale)} px/mm`, iw - margin, barY)
canvas.toBlob((b) => {
if (b) resolve(b)
else reject(new Error("toBlob failed"))
}, "image/png")
})
}
async function download() {
if (!store.deskewResult || !resultUrl.value) return
// 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(blob)
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 image"))
}
el.src = imgUrl
},
)
blob = await addScaleBar(image)
} finally {
URL.revokeObjectURL(imgUrl)
}
}
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download = `${baseName}-skwik.png`
a.click()
URL.revokeObjectURL(url)
}
// Download the corrected image with measurement annotations baked in.
// scope="full": natural-resolution image + overlay → `-measured.png`.
// scope="view": current viewport (zoom/pan) + overlay → `-measured-view.png`.
// Both honour the existing `includeScaleBar` toggle. View export's bar is
// sized for the on-screen pixel scale (image-px/mm × CSS view scale) so it
// represents the same physical mm length the user is actually looking at.
async function downloadMeasured(scope: "full" | "view") {
const viewer = viewerRef.value
if (!viewer || !store.deskewResult) return
try {
const blob = await viewer.exportWithMeasurements({
scope,
includeScaleBar: includeScaleBar.value,
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const baseName =
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
a.download =
scope === "full"
? `${baseName}-measured.png`
: `${baseName}-measured-view.png`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Measured export failed"
}
}
</script>
<template>
<!-- Break out of <main>'s max-w-7xl so the Measure step spans the full
viewport width — annotation work benefits from the extra room. -->
<div class="relative left-1/2 w-screen -translate-x-1/2 space-y-4 px-4">
<!-- Padded right of the fork-me ribbon on desktop so the title
text isn't clipped underneath it. -->
<div class="md:pl-5">
<h2 class="text-xl font-semibold">Measure</h2>
<p class="text-sm text-muted-foreground">
Annotate the corrected image and download.
</p>
</div>
<!-- Toolbar row: Back arrow on far left, downloads in the middle,
"Start over" zone (dashed New Image) on the far right. -->
<div class="flex flex-wrap items-center justify-between gap-3">
<Button
variant="ghost"
size="icon"
class="shrink-0"
aria-label="Back to Crop"
title="Back to Crop"
@click="store.goToStep(5)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
</Button>
<div class="flex flex-wrap items-center gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<label
class="flex cursor-pointer items-center gap-2 select-none"
>
<input
v-model="includeScaleBar"
type="checkbox"
class="h-4 w-4 accent-primary"
/>
<span
class="text-sm text-muted-foreground"
>Scale bar</span
>
</label>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-xs">
Appends a black bar at the bottom of the
exported image with a measurement scale and
px/mm annotation.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="mx-1 h-6 w-px bg-border" />
<Button @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
PNG
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
@click="downloadMeasured('full')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
<path d="M3 3h6" />
</svg>
Full + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-xs">
Source image at full resolution with every
measurement (lines, rectangles, ellipses,
angles) and labels rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
@click="downloadMeasured('view')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
/>
<path d="M9 9h6v6H9z" />
</svg>
View + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-xs">
Captures exactly what's visible in the viewer
(current zoom and pan) with every measurement
rendered on top.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Dashed transparent styling marks this as a deliberate,
destructive-of-state action so it isn't mis-clicked
while reaching for a download. -->
<Button
variant="outline"
class="border-dashed bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground"
@click="store.reset()"
>Start Over</Button
>
</div>
<p v-if="error" class="text-sm text-destructive">
{{ error }}
</p>
<!-- Full-width corrected image with measurement tools -->
<Card v-if="resultUrl">
<CardHeader>
<CardTitle class="text-base">Corrected Image</CardTitle>
</CardHeader>
<CardContent>
<CorrectedImageViewer
ref="viewerRef"
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
:crop="
srcDims
? {
state: store.cropRotate,
srcW: srcDims.w,
srcH: srcDims.h,
}
: undefined
"
@measurements-changed="schedulePreview"
/>
</CardContent>
</Card>
</div>
</template>