Compare commits

..

No commits in common. "9032af426e2e1fbc9c1f1682b955b4d8ca2b221c" and "b28ffe267b7cb00c7da730c039c3043be3652ed5" have entirely different histories.

15 changed files with 419 additions and 1193 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@ -4,8 +4,7 @@ import StepIndicator from "@/components/StepIndicator.vue"
import ImageUpload from "@/components/ImageUpload.vue" import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue" import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue" import DatumEditor from "@/components/DatumEditor.vue"
import DeskewViewer from "@/components/DeskewViewer.vue" import ResultViewer from "@/components/ResultViewer.vue"
import MeasureViewer from "@/components/MeasureViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue" import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue" import SkwikLogo from "@/components/SkwikLogo.vue"
@ -51,19 +50,15 @@ const store = useAppStore()
</div> </div>
</header> </header>
<!-- Bottom padding clears the fixed footer so content never sits <main class="mx-auto max-w-7xl px-4 py-6">
underneath it. Footer height py-3 + 1lh 2.5rem; add a
small buffer. -->
<main class="mx-auto max-w-7xl px-4 pb-16 pt-6">
<ImageUpload v-if="store.currentStep === 1" /> <ImageUpload v-if="store.currentStep === 1" />
<ExifViewer v-else-if="store.currentStep === 2" /> <ExifViewer v-else-if="store.currentStep === 2" />
<DatumEditor v-else-if="store.currentStep === 3" /> <DatumEditor v-else-if="store.currentStep === 3" />
<DeskewViewer v-else-if="store.currentStep === 4" /> <ResultViewer v-else-if="store.currentStep === 4" />
<MeasureViewer v-else-if="store.currentStep === 5" />
</main> </main>
<footer <footer
class="fixed inset-x-0 bottom-0 z-40 border-t border-border/50 bg-background/95 py-3 text-center text-xs text-muted-foreground backdrop-blur supports-[backdrop-filter]:bg-background/60" class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
> >
Made by Made by
<a <a

View File

@ -53,7 +53,7 @@
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(0.965 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
@ -86,11 +86,11 @@
} }
.dark { .dark {
--background: oklch(0.185 0 0); --background: oklch(0.13 0 0);
--foreground: oklch(0.95 0 0); --foreground: oklch(0.95 0 0);
--card: oklch(0.235 0 0); --card: oklch(0.175 0 0);
--card-foreground: oklch(0.95 0 0); --card-foreground: oklch(0.95 0 0);
--popover: oklch(0.235 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);

View File

@ -14,7 +14,6 @@ import type {
import { getDatumColor } from "@/lib/datums" import { getDatumColor } from "@/lib/datums"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache" import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
const props = defineProps<{ const props = defineProps<{
imageUrl: string imageUrl: string
@ -125,45 +124,11 @@ function loadImg() {
img.value = image img.value = image
imgLoaded.value = true imgLoaded.value = true
fitToContainer() fitToContainer()
// After auto-fit, prefer a previously-saved zoom/pan if the
// values still place the image inside the container protects
// against stale cache entries (different image dims) that would
// otherwise leave the viewer staring at empty canvas.
const hash = store.fileHash
if (hash) {
const cached = loadZoom(hash)
if (cached && isZoomReasonable(cached)) {
viewScale.value = cached.viewScale
viewOffsetX.value = cached.viewOffsetX
viewOffsetY.value = cached.viewOffsetY
}
}
redraw() redraw()
} }
image.src = props.imageUrl image.src = props.imageUrl
} }
// A cached zoom/pan is "reasonable" if the image's bounding box still
// intersects the canvas at all under that transform. Catches the
// degenerate case where the cache outlived an image dimension change.
function isZoomReasonable(z: {
viewScale: number
viewOffsetX: number
viewOffsetY: number
}): boolean {
if (!Number.isFinite(z.viewScale) || z.viewScale <= 0) return false
if (!Number.isFinite(z.viewOffsetX) || !Number.isFinite(z.viewOffsetY))
return false
const c = containerRef.value
const i = img.value
if (!c || !i) return false
const left = z.viewOffsetX
const top = z.viewOffsetY
const right = left + i.naturalWidth * z.viewScale
const bottom = top + i.naturalHeight * z.viewScale
return right > 0 && bottom > 0 && left < c.clientWidth && top < c.clientHeight
}
function fitToContainer() { function fitToContainer() {
const c = containerRef.value const c = containerRef.value
const i = img.value const i = img.value
@ -2120,7 +2085,7 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
} }
// Legacy export: bare image + scale bar, no measurements. Preserved as-is // Legacy export: bare image + scale bar, no measurements. Preserved as-is
// for any caller still wired to it (currently none MeasureViewer's // for any caller still wired to it (currently none ResultViewer's
// addScaleBar handles the no-measurements case directly). // addScaleBar handles the no-measurements case directly).
function exportWithScaleBar(): Promise<Blob> { function exportWithScaleBar(): Promise<Blob> {
const image = img.value const image = img.value
@ -2285,24 +2250,6 @@ watch(
}, },
{ deep: true }, { deep: true },
) )
// Persist zoom/pan with a small debounce wheel events fire rapidly and
// resize bursts shouldn't each hit localStorage. The debounce is short
// enough that a normal pan-and-pause finishes saving before navigation.
let zoomSaveTimer: ReturnType<typeof setTimeout> | null = null
watch([viewScale, viewOffsetX, viewOffsetY], () => {
if (!imgLoaded.value || !store.fileHash) return
if (zoomSaveTimer) clearTimeout(zoomSaveTimer)
const hash = store.fileHash
zoomSaveTimer = setTimeout(() => {
saveZoom(hash, {
viewScale: viewScale.value,
viewOffsetX: viewOffsetX.value,
viewOffsetY: viewOffsetY.value,
})
zoomSaveTimer = null
}, 250)
})
</script> </script>
<template> <template>
@ -2494,10 +2441,9 @@ watch([viewScale, viewOffsetX, viewOffsetY], () => {
</span> </span>
</div> </div>
<!-- Canvas + side list. Width is dictated by the parent when <!-- Canvas + side list. The parent ResultViewer clamps width to
rendered inside MeasureViewer the surrounding container spans max-w-4xl; widening the canvas beyond that requires a parent
the full viewport width; inside DeskewViewer it is capped at change (see ResultViewer.vue root container). -->
the standard step width. -->
<div class="grid gap-3 md:grid-cols-[1fr_220px]"> <div class="grid gap-3 md:grid-cols-[1fr_220px]">
<div <div
ref="containerRef" ref="containerRef"

View File

@ -1,23 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue" import { ref, onMounted } from "vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader" import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif" import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash" import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache" import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import { import { clearCache as clearMeasurementCache } from "@/lib/measurement-cache"
clearCache as clearMeasurementCache,
loadMeasurements,
} from "@/lib/measurement-cache"
import {
saveUpload,
loadUpload,
listUploads,
deleteUpload,
clearUploads,
type UploadRecord,
} from "@/lib/upload-cache"
import { clearCache as clearZoomCache } from "@/lib/zoom-cache"
import { import {
Card, Card,
CardContent, CardContent,
@ -33,182 +21,17 @@ const isDragging = ref(false)
const error = ref("") const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0) const cacheCount = ref(0)
const confirmingClear = ref(false)
const recentUploads = ref<UploadRecord[]>([])
const recentUrls = ref<Map<string, string>>(new Map())
const ACCEPTED = "image/*,.heic,.heif" const ACCEPTED = "image/*,.heic,.heif"
// Auto-revert the "Are you sure?" prompt after this long of inactivity
// so a stray first click can't be confirmed minutes later.
const CLEAR_CONFIRM_TIMEOUT_MS = 4000
let confirmTimer: ReturnType<typeof setTimeout> | null = null onMounted(() => {
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
// Only show entries that have a deskew result those are the ones a
// click can meaningfully drop the user back into Measure with. Uploads
// that never reached deskew remain saved (so re-uploading the same file
// still hits the cache) but don't clutter the gallery.
const completedUploads = computed(() =>
recentUploads.value.filter(
(u) => u.correctedBlob !== undefined && u.diagnostics !== undefined,
),
)
onMounted(async () => {
cacheCount.value = getCacheSize() cacheCount.value = getCacheSize()
await refreshRecentUploads()
}) })
onUnmounted(() => { function handleClearCache() {
if (confirmTimer) clearTimeout(confirmTimer) clearCache()
for (const url of recentUrls.value.values()) URL.revokeObjectURL(url) clearMeasurementCache()
}) cacheCount.value = 0
async function refreshRecentUploads() {
try {
const all = await listUploads()
// Revoke any URLs whose entries no longer exist (e.g. after a
// delete) before refreshing the map.
const nextHashes = new Set(all.map((u) => u.hash))
for (const [hash, url] of recentUrls.value) {
if (!nextHashes.has(hash)) {
URL.revokeObjectURL(url)
recentUrls.value.delete(hash)
}
}
// Mint preview URLs for every entry; we keep them alive for the
// lifetime of this page mount (revoked in onUnmounted).
for (const u of all) {
if (!recentUrls.value.has(u.hash)) {
const preview = u.correctedBlob ?? u.originalBlob
recentUrls.value.set(u.hash, URL.createObjectURL(preview))
}
}
recentUploads.value = all
} catch {
recentUploads.value = []
}
}
function formatDate(ms: number): string {
return dateFormatter.format(new Date(ms))
}
function previewUrlFor(hash: string): string {
return recentUrls.value.get(hash) ?? ""
}
function handleClearCacheClick() {
if (confirmingClear.value) {
clearCache()
clearMeasurementCache()
clearZoomCache()
// IndexedDB clear is async but the UI doesn't depend on its
// completion fire and forget, then reload the gallery.
void clearUploads().then(() => refreshRecentUploads())
cacheCount.value = 0
confirmingClear.value = false
if (confirmTimer) {
clearTimeout(confirmTimer)
confirmTimer = null
}
return
}
confirmingClear.value = true
if (confirmTimer) clearTimeout(confirmTimer)
confirmTimer = setTimeout(() => {
confirmingClear.value = false
confirmTimer = null
}, CLEAR_CONFIRM_TIMEOUT_MS)
}
async function handleDeleteUpload(hash: string, ev: Event) {
ev.stopPropagation()
try {
await deleteUpload(hash)
await refreshRecentUploads()
} catch {
// ignore UI will reflect on next refresh
}
}
// Reopen a past upload straight in Measure. Replays the upload pipeline
// from the cached blob (the original image is needed if the user later
// goes back to Datums or Deskew), then stitches the cached deskew
// artefacts and datums onto the store before navigating.
async function restoreUpload(hash: string) {
error.value = ""
store.isProcessing = true
store.processingStatus = "Loading saved image..."
try {
const record = await loadUpload(hash)
if (!record) {
error.value = "Saved upload no longer available"
return
}
if (!record.correctedBlob || !record.diagnostics) {
error.value = "Saved upload has no deskew result"
return
}
const file = new File([record.originalBlob], record.filename, {
type: record.mimeType,
})
const { image, convertedFile } = await loadImage(file, (status) => {
store.processingStatus = status
})
const cachedDatums = loadDatums(hash) ?? []
const cachedMeasurements = loadMeasurements(hash) ?? []
store.setFileHash(hash)
store.setImage(convertedFile, image)
store.setExif(record.exif)
store.datums = cachedDatums
if (record.scalePxPerMm) {
store.scalePxPerMm = record.scalePxPerMm
}
store.setResult(
{
correctedImageBlob: record.correctedBlob,
diagnostics: record.diagnostics,
},
record.scalePxPerMm ?? store.scalePxPerMm,
)
if (cachedMeasurements.length > 0 || cachedDatums.length > 0) {
const parts: string[] = []
if (cachedDatums.length > 0) {
parts.push(
`${String(cachedDatums.length)} datum${cachedDatums.length === 1 ? "" : "s"}`,
)
}
if (cachedMeasurements.length > 0) {
parts.push(
`${String(cachedMeasurements.length)} measurement${cachedMeasurements.length === 1 ? "" : "s"}`,
)
}
store.cacheRestoreMessage = `Restored ${parts.join(" and ")}`
setTimeout(() => {
store.cacheRestoreMessage = ""
}, 4000)
}
// Bump max step so the indicator surfaces every prior step as
// navigable, just like a freshly-completed run would.
store.goToStep(5)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Failed to reopen image"
} finally {
store.isProcessing = false
store.processingStatus = ""
}
} }
async function handleFile(file: File) { async function handleFile(file: File) {
@ -241,27 +64,6 @@ async function handleFile(file: File) {
}, 4000) }, 4000)
} }
// Persist the upload so it shows up in the recent gallery and
// can be reopened later. We save the post-conversion JPEG (not
// the original HEIC) since that's what the rest of the app
// expects to load. Existing entries are merged so we keep any
// prior deskew artefacts attached to the same hash.
const existing = await loadUpload(hash).catch(() => null)
await saveUpload({
hash,
filename: convertedFile.name,
mimeType: convertedFile.type,
uploadedAt: existing?.uploadedAt ?? Date.now(),
originalBlob: convertedFile,
exif,
correctedBlob: existing?.correctedBlob,
diagnostics: existing?.diagnostics,
scalePxPerMm: existing?.scalePxPerMm,
}).catch(() => {
// IndexedDB unavailable or quota exceeded non-fatal,
// gallery just won't include this upload.
})
store.setImage(convertedFile, image) store.setImage(convertedFile, image)
store.setExif(exif) store.setExif(exif)
store.goToStep(2) store.goToStep(2)
@ -290,47 +92,7 @@ function onFileSelect(e: Event) {
<template> <template>
<div class="flex min-h-[60vh] items-start justify-center pt-12"> <div class="flex min-h-[60vh] items-start justify-center pt-12">
<div class="w-full max-w-2xl space-y-6"> <div class="w-full max-w-2xl space-y-6">
<Card class="relative"> <Card>
<!-- Clear-cache lives top-right of the upload card so it's
visible without scrolling but doesn't compete with the
primary drop target. Two-step confirm: first click
arms it; second click within
CLEAR_CONFIRM_TIMEOUT_MS commits. -->
<Button
v-if="cacheCount > 0"
variant="ghost"
size="sm"
class="absolute right-2 top-2 h-7 gap-1.5 text-xs"
:class="
confirmingClear
? 'text-destructive hover:text-destructive'
: 'text-muted-foreground/60 hover:text-destructive'
"
@click="handleClearCacheClick"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
{{
confirmingClear
? "Are you sure?"
: `Clear cache (${String(cacheCount)})`
}}
</Button>
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle class="text-lg">Load Source Image</CardTitle> <CardTitle class="text-lg">Load Source Image</CardTitle>
<CardDescription> <CardDescription>
@ -408,68 +170,36 @@ function onFileSelect(e: Event) {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<div
<!-- Recent uploads only rendered when there's something to v-if="cacheCount > 0"
show. Each tile reopens the image directly into Measure class="flex justify-end"
with its datums, measurements, scale and last canvas >
zoom restored. --> <Button
<section v-if="completedUploads.length > 0" class="space-y-3"> variant="ghost"
<p size="sm"
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70" class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
@click="handleClearCache"
> >
Recent uploads <svg
</p> xmlns="http://www.w3.org/2000/svg"
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> width="12"
<button height="12"
v-for="upload in completedUploads" viewBox="0 0 24 24"
:key="upload.hash" fill="none"
type="button" stroke="currentColor"
class="group relative overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary" stroke-width="2"
@click="restoreUpload(upload.hash)" stroke-linecap="round"
stroke-linejoin="round"
> >
<div <path d="M3 6h18" />
class="aspect-video w-full overflow-hidden bg-muted" <path
> d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
<img />
:src="previewUrlFor(upload.hash)" <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
:alt="upload.filename" </svg>
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]" Clear cache ({{ cacheCount }})
loading="lazy" </Button>
/> </div>
</div>
<div class="space-y-0.5 p-2">
<p class="truncate text-xs font-medium text-foreground">
{{ upload.filename }}
</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">
{{ formatDate(upload.uploadedAt) }}
</p>
</div>
<button
type="button"
class="absolute right-1 top-1 rounded-md bg-background/80 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus:opacity-100 focus:outline-none"
title="Remove from recent uploads"
aria-label="Remove from recent uploads"
@click="(e) => handleDeleteUpload(upload.hash, e)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</button>
</div>
</section>
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p> <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
@ -477,7 +207,7 @@ function onFileSelect(e: Event) {
<div class="space-y-1.5"> <div class="space-y-1.5">
<img <img
src="/example-before.jpg" src="/example-before.jpg"
alt="Before: angled photograph with reference paper laid flat" alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
class="w-full rounded-md border border-border object-cover" class="w-full rounded-md border border-border object-cover"
/> />
<p class="text-xs text-muted-foreground">Before &mdash; angled shot</p> <p class="text-xs text-muted-foreground">Before &mdash; angled shot</p>
@ -485,20 +215,12 @@ function onFileSelect(e: Event) {
<div class="space-y-1.5"> <div class="space-y-1.5">
<img <img
src="/example-after.jpg" src="/example-after.jpg"
alt="After: perspective-corrected top-down view" alt="After: perspective-corrected front-facing view"
class="w-full rounded-md border border-border object-cover" class="w-full rounded-md border border-border object-cover"
/> />
<p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p> <p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p>
</div> </div>
</div> </div>
<div class="space-y-1.5">
<img
src="/example-measured.jpg"
alt="Measure: the corrected image with measurement annotations baked in"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">Measured &mdash; annotations baked in</p>
</div>
</div> </div>
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">

View File

@ -1,402 +0,0 @@
<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"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const error = ref("")
const includeScaleBar = ref(false)
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
}
if (store.deskewResult) {
resultUrl.value = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
} 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)
})
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) return
let blob: Blob = store.deskewResult.correctedImageBlob
if (includeScaleBar.value) {
const imgUrl = URL.createObjectURL(
store.deskewResult.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 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 Deskew"
title="Back to Deskew"
@click="store.goToStep(4)"
>
<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"
/>
</CardContent>
</Card>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue" import { ref, computed, onMounted, watch } from "vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew" import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { Datum } from "@/types" import type { Datum } from "@/types"
@ -8,6 +8,12 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { import {
Card, Card,
CardContent, CardContent,
@ -24,22 +30,30 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { loadSettings } from "@/lib/settings-cache" 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 { import {
loadMeasurements, loadSettings,
saveMeasurements, saveSettings,
scaleMeasurements, } from "@/lib/settings-cache"
} from "@/lib/measurement-cache"
import { patchUpload } from "@/lib/upload-cache"
import { clearZoom } from "@/lib/zoom-cache"
const store = useAppStore() const store = useAppStore()
const previewUrl = ref<string | null>(null) const resultUrl = ref<string | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const error = ref("") const error = ref("")
const hasRun = ref(false) const hasRun = ref(false)
const cvReady = ref(false) const cvReady = ref(false)
const cvLoading = ref(false) const cvLoading = ref(false)
const showAlgoDetails = ref(false) const showAlgoDetails = ref(false)
const includeScaleBar = ref(false)
const scaleInput = ref(String(store.scalePxPerMm)) const scaleInput = ref(String(store.scalePxPerMm))
const scaleValid = computed(() => { const scaleValid = computed(() => {
const n = Number(scaleInput.value) const n = Number(scaleInput.value)
@ -135,28 +149,29 @@ function computeAutoScale(): number {
onMounted(() => { onMounted(() => {
const cached = loadSettings() const cached = loadSettings()
if (cached && cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) { if (cached) {
includeScaleBar.value = cached.includeScaleBar
// Only use cached scale if it was explicitly set before // Only use cached scale if it was explicitly set before
scaleInput.value = String(cached.scalePxPerMm) if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
} else { scaleInput.value = String(cached.scalePxPerMm)
// Auto-compute a sensible default scale return
const auto = computeAutoScale() }
store.scalePxPerMm = auto
scaleInput.value = String(auto)
}
// Re-create the preview URL if a deskew result is already cached on the
// store (e.g. user navigated back from Measure).
if (store.deskewResult) {
previewUrl.value = URL.createObjectURL(
store.deskewResult.correctedImageBlob,
)
hasRun.value = true
} }
// Auto-compute a sensible default scale
const auto = computeAutoScale()
store.scalePxPerMm = auto
scaleInput.value = String(auto)
}) })
onUnmounted(() => { watch(
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) [() => store.scalePxPerMm, includeScaleBar],
}) () => {
saveSettings({
scalePxPerMm: store.scalePxPerMm,
includeScaleBar: includeScaleBar.value,
})
},
)
// Progress tracking // Progress tracking
const progressStep = ref(0) const progressStep = ref(0)
@ -219,14 +234,11 @@ async function runDeskew() {
requestAnimationFrame(r) requestAnimationFrame(r)
}) })
const newScale = store.scalePxPerMm
const oldScale = store.lastDeskewScale
const result = await deskewImage({ const result = await deskewImage({
image: store.loadedImage, image: store.loadedImage,
datums: store.datums, datums: store.datums,
exif: store.exifData, exif: store.exifData,
scalePxPerMm: newScale, scalePxPerMm: store.scalePxPerMm,
onProgress: (step, total, label) => { onProgress: (step, total, label) => {
progressStep.value = step progressStep.value = step
progressTotal.value = total progressTotal.value = total
@ -235,47 +247,10 @@ async function runDeskew() {
}, },
}) })
// If the user changed the output scale between runs, the new store.setResult(result)
// corrected image is a different size rescale any measurements
// already cached for this image so they stay anchored to the same
// physical features. CorrectedImageViewer reads from cache on
// mount, so writing here is enough; no in-memory state to sync.
// The cached zoom/pan also no longer makes sense once the image
// dimensions change, so we drop it and let fitToContainer pick
// a fresh default.
if (
oldScale !== null &&
oldScale > 0 &&
oldScale !== newScale &&
store.fileHash
) {
const cached = loadMeasurements(store.fileHash)
if (cached && cached.length > 0) {
const scaled = scaleMeasurements(cached, newScale / oldScale)
saveMeasurements(store.fileHash, scaled)
}
clearZoom(store.fileHash)
}
store.setResult(result, newScale) if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(result.correctedImageBlob)
// Persist the deskew artefacts onto the upload record so the
// Recent Uploads gallery can reopen straight into Measure. Best
// effort: an IndexedDB failure shouldn't break the visible flow.
if (store.fileHash) {
try {
await patchUpload(store.fileHash, {
correctedBlob: result.correctedImageBlob,
diagnostics: result.diagnostics,
scalePxPerMm: newScale,
})
} catch {
// ignore gallery just won't include this entry
}
}
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed" error.value = e instanceof Error ? e.message : "Deskew failed"
} finally { } finally {
@ -283,27 +258,168 @@ async function runDeskew() {
store.processingStatus = "" store.processingStatus = ""
} }
} }
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) return
let blob: Blob = store.deskewResult.correctedImageBlob
console.log("[download] includeScaleBar =", includeScaleBar.value)
if (includeScaleBar.value) {
// Load the corrected image into an HTMLImageElement for drawing
const imgUrl = URL.createObjectURL(
store.deskewResult.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 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> </script>
<template> <template>
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold">Deskew</h2> <h2 class="text-xl font-semibold">Process &amp; Download</h2>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Set the output scale and run perspective correction. Set the output scale, run perspective correction, and
download.
</p> </p>
</div> </div>
<div class="flex shrink-0 gap-2"> <Button variant="outline" @click="store.goToStep(3)"
<Button variant="outline" @click="store.goToStep(3)" >Back</Button
>Back</Button >
>
<Button
:disabled="!store.canProceedToStep5"
@click="store.goToStep(5)"
>Next: Measure</Button
>
</div>
</div> </div>
<!-- Scale setting --> <!-- Scale setting -->
@ -394,7 +510,7 @@ async function runDeskew() {
{{ datum.label }} {{ datum.label }}
<span class="ml-1 font-mono text-xs">{{ <span class="ml-1 font-mono text-xs">{{
datum.type === "rectangle" datum.type === "rectangle"
? `${datum.widthMm}×${datum.heightMm}mm` ? `${datum.widthMm}\u00D7${datum.heightMm}mm`
: datum.type === "line" : datum.type === "line"
? `${datum.lengthMm}mm` ? `${datum.lengthMm}mm`
: `${datum.diameterMm}mm` : `${datum.diameterMm}mm`
@ -478,7 +594,7 @@ async function runDeskew() {
</p> </p>
<!-- Result --> <!-- Result -->
<template v-if="store.deskewResult && previewUrl"> <template v-if="store.deskewResult && resultUrl">
<!-- Diagnostics --> <!-- Diagnostics -->
<Card> <Card>
<CardHeader> <CardHeader>
@ -731,49 +847,173 @@ async function runDeskew() {
</CardContent> </CardContent>
</Card> </Card>
<!-- Deskewed preview --> <!-- Corrected image with tools full-bleed to use the whole page width
<Card> even though the surrounding column is capped at max-w-4xl. -->
<CardHeader> <div
<CardTitle class="text-base" class="relative left-1/2 w-screen -translate-x-1/2 px-4"
>Deskewed Preview</CardTitle >
> <Card>
<CardDescription> <CardHeader>
Continue to <strong>Measure</strong> to add <CardTitle class="text-base"
annotations and download. >Corrected Image</CardTitle
</CardDescription> >
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <CorrectedImageViewer
class="flex items-center justify-center overflow-hidden rounded-md bg-muted" ref="viewerRef"
> :image-url="resultUrl"
<img :scale-px-per-mm="store.scalePxPerMm"
:src="previewUrl"
alt="Deskewed image preview"
class="max-h-[480px] w-full object-contain"
/> />
</div> </CardContent>
</CardContent> </Card>
</Card> </div>
<div class="flex justify-center pb-8"> <!-- Download -->
<Button size="lg" @click="store.goToStep(5)"> <div class="flex flex-col items-center gap-3 pb-8">
Continue to Measure <TooltipProvider>
<svg <Tooltip>
xmlns="http://www.w3.org/2000/svg" <TooltipTrigger as-child>
width="18" <label
height="18" class="flex cursor-pointer items-center gap-2 select-none"
viewBox="0 0 24 24" >
fill="none" <input
stroke="currentColor" v-model="includeScaleBar"
stroke-width="2" type="checkbox"
stroke-linecap="round" class="h-4 w-4 accent-primary"
stroke-linejoin="round" />
class="ml-2" <span
class="text-sm text-muted-foreground"
>Include scale bar in export</span
>
</label>
</TooltipTrigger>
<TooltipContent side="top" 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="flex flex-wrap items-center justify-center gap-3">
<Button size="lg" @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
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>
Download PNG
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
size="lg"
variant="secondary"
@click="downloadMeasured('full')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
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>
Download full + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="top" 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
size="lg"
variant="secondary"
@click="downloadMeasured('view')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
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>
Download view + measurements
</Button>
</TooltipTrigger>
<TooltipContent side="top" 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>
<Button
size="lg"
variant="outline"
@click="store.reset()"
> >
<path d="M5 12h14" /> Process New Image
<path d="m12 5 7 7-7 7" /> </Button>
</svg> </div>
</Button> <p
class="font-mono text-sm text-muted-foreground"
>
{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}} px &mdash;
{{
(
store.deskewResult
.correctedImageBlob.size /
1024 /
1024
).toFixed(1)
}} MB
</p>
</div> </div>
</template> </template>
</div> </div>

View File

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

View File

@ -1,59 +1,7 @@
import type { Point } from "@/types"
import type { Measurement } from "@/types/measurements" import type { Measurement } from "@/types/measurements"
const KEY_PREFIX = "skwik-measurements-" const KEY_PREFIX = "skwik-measurements-"
/** Scale every image-space point in `m` by `ratio`. Used when the user
* re-runs the deskew at a different output px/mm the corrected image
* changes size, so measurements (stored in image-pixel coords) must move
* with it to stay anchored to the same physical features. */
function scalePoint(p: Point, ratio: number): Point {
return { x: p.x * ratio, y: p.y * ratio }
}
export function scaleMeasurements(
measurements: Measurement[],
ratio: number,
): Measurement[] {
if (ratio === 1) return measurements
return measurements.map((m) => {
switch (m.type) {
case "line":
return { ...m, a: scalePoint(m.a, ratio), b: scalePoint(m.b, ratio) }
case "rectangle":
return {
...m,
corners: [
scalePoint(m.corners[0], ratio),
scalePoint(m.corners[1], ratio),
scalePoint(m.corners[2], ratio),
scalePoint(m.corners[3], ratio),
],
}
case "ellipse":
return {
...m,
center: scalePoint(m.center, ratio),
axisEndA: scalePoint(m.axisEndA, ratio),
axisEndB: scalePoint(m.axisEndB, ratio),
}
case "circle":
return {
...m,
center: scalePoint(m.center, ratio),
edge: scalePoint(m.edge, ratio),
}
case "angle":
return {
...m,
vertex: scalePoint(m.vertex, ratio),
armA: scalePoint(m.armA, ratio),
armB: scalePoint(m.armB, ratio),
}
}
})
}
export function saveMeasurements( export function saveMeasurements(
hash: string, hash: string,
measurements: Measurement[], measurements: Measurement[],

View File

@ -1,158 +0,0 @@
import type { DeskewDiagnostics, ExifData } from "@/types"
// Past uploads + their deskew artefacts live in IndexedDB rather than
// localStorage because converted JPEGs routinely exceed localStorage's
// per-origin quota (510MB) within a few photos. IndexedDB stores Blobs
// natively (no base64 inflation) and tolerates GBs, which makes a
// "recent uploads" gallery practical.
const DB_NAME = "skwik-uploads"
const DB_VERSION = 1
const STORE_NAME = "uploads"
/** All persisted state for a single past upload. Filled in two phases:
* upload time captures `originalBlob` + `exif` + identity; deskew
* completion adds `correctedBlob` + `diagnostics` + `scalePxPerMm` so
* the upload can be re-opened straight into the Measure step. */
export interface UploadRecord {
hash: string
filename: string
mimeType: string
uploadedAt: number
originalBlob: Blob
exif: ExifData
correctedBlob?: Blob
diagnostics?: DeskewDiagnostics
scalePxPerMm?: number
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "hash",
})
store.createIndex("uploadedAt", "uploadedAt")
}
}
req.onsuccess = () => {
resolve(req.result)
}
req.onerror = () => {
dbPromise = null
reject(req.error ?? new Error("Failed to open uploads DB"))
}
})
return dbPromise
}
/** Insert or replace the record for `hash`. Re-saving with new fields
* (e.g. corrected blob) overwrites the previous entry. */
export async function saveUpload(record: UploadRecord): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).put(record)
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to save upload"))
}
})
}
/** Read the existing record (if any), apply `patch`, and persist. Used by
* the Deskew step to attach deskew artefacts without losing the upload
* metadata captured earlier. Returns the merged record, or null if no
* record exists for `hash`. */
export async function patchUpload(
hash: string,
patch: Partial<UploadRecord>,
): Promise<UploadRecord | null> {
const existing = await loadUpload(hash)
if (!existing) return null
const merged: UploadRecord = { ...existing, ...patch, hash }
await saveUpload(merged)
return merged
}
export async function loadUpload(hash: string): Promise<UploadRecord | null> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).get(hash)
req.onsuccess = () => {
resolve((req.result as UploadRecord | undefined) ?? null)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to load upload"))
}
})
}
/** All records, newest-first. Callers filter to entries with the fields
* they care about (e.g. require `correctedBlob` for the gallery). */
export async function listUploads(): Promise<UploadRecord[]> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).getAll()
req.onsuccess = () => {
const all = (req.result as UploadRecord[] | undefined) ?? []
all.sort((a, b) => b.uploadedAt - a.uploadedAt)
resolve(all)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to list uploads"))
}
})
}
export async function deleteUpload(hash: string): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).delete(hash)
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to delete upload"))
}
})
}
export async function clearUploads(): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).clear()
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to clear uploads"))
}
})
}
export async function countUploads(): Promise<number> {
const db = await openDB()
return new Promise<number>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).count()
req.onsuccess = () => {
resolve(req.result)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to count uploads"))
}
})
}

View File

@ -1,53 +0,0 @@
// Per-image zoom + pan state for the corrected-image canvas. Persisted
// so that revisiting the same upload restores the exact view the user
// last left it in. Keyed by file hash, same convention as datums and
// measurements.
const KEY_PREFIX = "skwik-zoom-"
export interface ZoomState {
/** Canvas-px / image-px scale. */
viewScale: number
/** Image origin in canvas px (top-left of the image in canvas space). */
viewOffsetX: number
viewOffsetY: number
}
export function saveZoom(hash: string, zoom: ZoomState): void {
try {
localStorage.setItem(KEY_PREFIX + hash, JSON.stringify(zoom))
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadZoom(hash: string): ZoomState | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
return JSON.parse(raw) as ZoomState
} catch {
return null
}
}
export function clearZoom(hash: string): void {
try {
localStorage.removeItem(KEY_PREFIX + hash)
} catch {
// ignore
}
}
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)
}
}

View File

@ -23,10 +23,6 @@ export const useAppStore = defineStore("app", () => {
) )
const fileHash = ref<string | null>(null) const fileHash = ref<string | null>(null)
const cacheRestoreMessage = ref("") const cacheRestoreMessage = ref("")
/** Output px/mm of the current `deskewResult`. Set whenever a deskew
* produces a new result; consumers compare against the live
* `scalePxPerMm` to detect when measurements need to be rescaled. */
const lastDeskewScale = ref<number | null>(null)
const canProceedToStep2 = computed(() => loadedImage.value !== null) const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value) const canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -38,9 +34,6 @@ export const useAppStore = defineStore("app", () => {
return d.diameterMm > 0 return d.diameterMm > 0
}) })
}) })
const canProceedToStep5 = computed(
() => canProceedToStep4.value && deskewResult.value !== null,
)
function setImage(file: File, image: HTMLImageElement) { function setImage(file: File, image: HTMLImageElement) {
originalFile.value = file originalFile.value = file
@ -148,9 +141,8 @@ export const useAppStore = defineStore("app", () => {
} }
} }
function setResult(result: DeskewResult, scalePxPerMmUsed: number) { function setResult(result: DeskewResult) {
deskewResult.value = result deskewResult.value = result
lastDeskewScale.value = scalePxPerMmUsed
} }
function setFileHash(hash: string) { function setFileHash(hash: string) {
@ -171,7 +163,6 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
fileHash.value = null fileHash.value = null
cacheRestoreMessage.value = "" cacheRestoreMessage.value = ""
lastDeskewScale.value = null
} }
return { return {
@ -188,11 +179,9 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm, scalePxPerMm,
fileHash, fileHash,
cacheRestoreMessage, cacheRestoreMessage,
lastDeskewScale,
canProceedToStep2, canProceedToStep2,
canProceedToStep3, canProceedToStep3,
canProceedToStep4, canProceedToStep4,
canProceedToStep5,
setImage, setImage,
setExif, setExif,
goToStep, goToStep,

View File

@ -114,7 +114,7 @@ export interface DeskewResult {
diagnostics: DeskewDiagnostics diagnostics: DeskewDiagnostics
} }
export type AppStep = 1 | 2 | 3 | 4 | 5 export type AppStep = 1 | 2 | 3 | 4
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */ /** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
export const DEFAULT_SCALE_PX_PER_MM = 10 export const DEFAULT_SCALE_PX_PER_MM = 10