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.
534 lines
20 KiB
Vue
534 lines
20 KiB
Vue
<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>
|