Compare commits

...

4 Commits

Author SHA1 Message Date
Samuel Prevost
9032af426e feat(upload): recent-uploads gallery + per-image zoom restore
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Past uploads now persist to IndexedDB (originalBlob, exif, and — once
deskew has run — the corrected blob, diagnostics, and output scale).
The upload page renders a tile grid for any entry that completed a
deskew; clicking one rehydrates the store from the cached artefacts
and drops the user straight back into Measure with their datums and
measurements intact. The section is hidden entirely when there are no
completed entries, and each tile has a hover-only delete affordance.

Canvas zoom + pan are now per-image, persisted to localStorage with a
short debounce. Re-running the deskew at a different scale clears the
saved zoom (the stale offsets would point off-image at the new
dimensions).

Other tweaks bundled here:
  • Start Over collapses the previous "Start over | New Image" group
    into a single dashed/transparent button.
  • The Measure header gets md:pl-5 so the title clears the fork-me
    ribbon on desktop.
  • Example images on the landing page swap to the IMG_8324 set, with
    a third "Measured" tile spanning the combined width that shows the
    annotation-baked output.
  • Clearing the cache now also wipes the upload + zoom caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:05:18 +02:00
Samuel Prevost
a5f4bf650c feat(measure): split toolbar into back · downloads · start-over zones
Back collapses to a leading arrow icon, downloads stay in the middle,
and "New Image" moves to the far right behind a labeled "Start over"
separator. The reset button now uses a dashed transparent outline so
it reads as a deliberate, low-frequency action and is harder to hit
while reaching for a download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:54:00 +02:00
Samuel Prevost
ed4da082ce feat(upload): move clear-cache to top-right with two-step confirm
The cache button now sits in the corner of the upload card so it's out
of the way of the drop zone. First click swaps the label to "Are you
sure?", second click within 4s clears; otherwise the prompt reverts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:50:16 +02:00
Samuel Prevost
1118de74da feat(pipeline): split Deskew + Measure into separate steps
Step 4 (was "Result") now only runs the perspective correction and shows
diagnostics + a small preview at the standard column width. Step 5
("Measure") is a new full-bleed view dedicated to annotation, with the
download buttons promoted to the top of the page.

Re-running the deskew at a different output px/mm now rescales any
measurements already saved for the image (cached by file hash) so they
stay anchored to the same physical features instead of drifting.

Theme tweaks: card surfaces are now visibly distinct from the page
background in light mode, and dark mode is a touch lighter than the
previous near-black. The "Made by" footer is pinned to the bottom of
the viewport via fixed positioning, with corresponding pb on <main>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:46:19 +02:00
15 changed files with 1196 additions and 422 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/example-measured.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -4,7 +4,8 @@ import StepIndicator from "@/components/StepIndicator.vue"
import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"
import DeskewViewer from "@/components/DeskewViewer.vue"
import MeasureViewer from "@/components/MeasureViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
@ -50,15 +51,19 @@ const store = useAppStore()
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6">
<!-- Bottom padding clears the fixed footer so content never sits
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" />
<ExifViewer v-else-if="store.currentStep === 2" />
<DatumEditor v-else-if="store.currentStep === 3" />
<ResultViewer v-else-if="store.currentStep === 4" />
<DeskewViewer v-else-if="store.currentStep === 4" />
<MeasureViewer v-else-if="store.currentStep === 5" />
</main>
<footer
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
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"
>
Made by
<a

View File

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

View File

@ -14,6 +14,7 @@ import type {
import { getDatumColor } from "@/lib/datums"
import { useAppStore } from "@/stores/app"
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
const props = defineProps<{
imageUrl: string
@ -124,11 +125,45 @@ function loadImg() {
img.value = image
imgLoaded.value = true
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()
}
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() {
const c = containerRef.value
const i = img.value
@ -2085,7 +2120,7 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
}
// Legacy export: bare image + scale bar, no measurements. Preserved as-is
// for any caller still wired to it (currently none ResultViewer's
// for any caller still wired to it (currently none MeasureViewer's
// addScaleBar handles the no-measurements case directly).
function exportWithScaleBar(): Promise<Blob> {
const image = img.value
@ -2250,6 +2285,24 @@ watch(
},
{ 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>
<template>
@ -2441,9 +2494,10 @@ watch(
</span>
</div>
<!-- Canvas + side list. The parent ResultViewer clamps width to
max-w-4xl; widening the canvas beyond that requires a parent
change (see ResultViewer.vue root container). -->
<!-- Canvas + side list. Width is dictated by the parent when
rendered inside MeasureViewer the surrounding container spans
the full viewport width; inside DeskewViewer it is capped at
the standard step width. -->
<div class="grid gap-3 md:grid-cols-[1fr_220px]">
<div
ref="containerRef"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import type { Datum } from "@/types"
@ -8,12 +8,6 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Card,
CardContent,
@ -30,30 +24,22 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
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 } from "@/lib/settings-cache"
import {
loadSettings,
saveSettings,
} from "@/lib/settings-cache"
loadMeasurements,
saveMeasurements,
scaleMeasurements,
} from "@/lib/measurement-cache"
import { patchUpload } from "@/lib/upload-cache"
import { clearZoom } from "@/lib/zoom-cache"
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
const previewUrl = ref<string | null>(null)
const error = ref("")
const hasRun = ref(false)
const cvReady = ref(false)
const cvLoading = ref(false)
const showAlgoDetails = ref(false)
const includeScaleBar = ref(false)
const scaleInput = ref(String(store.scalePxPerMm))
const scaleValid = computed(() => {
const n = Number(scaleInput.value)
@ -149,29 +135,28 @@ function computeAutoScale(): number {
onMounted(() => {
const cached = loadSettings()
if (cached) {
includeScaleBar.value = cached.includeScaleBar
if (cached && cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
// Only use cached scale if it was explicitly set before
if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
scaleInput.value = String(cached.scalePxPerMm)
return
}
}
} else {
// Auto-compute a sensible default scale
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
}
})
watch(
[() => store.scalePxPerMm, includeScaleBar],
() => {
saveSettings({
scalePxPerMm: store.scalePxPerMm,
includeScaleBar: includeScaleBar.value,
})
},
)
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
// Progress tracking
const progressStep = ref(0)
@ -234,11 +219,14 @@ async function runDeskew() {
requestAnimationFrame(r)
})
const newScale = store.scalePxPerMm
const oldScale = store.lastDeskewScale
const result = await deskewImage({
image: store.loadedImage,
datums: store.datums,
exif: store.exifData,
scalePxPerMm: store.scalePxPerMm,
scalePxPerMm: newScale,
onProgress: (step, total, label) => {
progressStep.value = step
progressTotal.value = total
@ -247,10 +235,47 @@ async function runDeskew() {
},
})
store.setResult(result)
// If the user changed the output scale between runs, the new
// 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)
}
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
store.setResult(result, newScale)
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) {
error.value = e instanceof Error ? e.message : "Deskew failed"
} finally {
@ -258,168 +283,27 @@ async function runDeskew() {
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>
<template>
<div class="mx-auto max-w-4xl space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<div>
<h2 class="text-xl font-semibold">Process &amp; Download</h2>
<h2 class="text-xl font-semibold">Deskew</h2>
<p class="text-sm text-muted-foreground">
Set the output scale, run perspective correction, and
download.
Set the output scale and run perspective correction.
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" @click="store.goToStep(3)"
>Back</Button
>
<Button
:disabled="!store.canProceedToStep5"
@click="store.goToStep(5)"
>Next: Measure</Button
>
</div>
</div>
<!-- Scale setting -->
@ -510,7 +394,7 @@ async function downloadMeasured(scope: "full" | "view") {
{{ datum.label }}
<span class="ml-1 font-mono text-xs">{{
datum.type === "rectangle"
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
? `${datum.widthMm}×${datum.heightMm}mm`
: datum.type === "line"
? `${datum.lengthMm}mm`
: `${datum.diameterMm}mm`
@ -594,7 +478,7 @@ async function downloadMeasured(scope: "full" | "view") {
</p>
<!-- Result -->
<template v-if="store.deskewResult && resultUrl">
<template v-if="store.deskewResult && previewUrl">
<!-- Diagnostics -->
<Card>
<CardHeader>
@ -847,55 +731,33 @@ async function downloadMeasured(scope: "full" | "view") {
</CardContent>
</Card>
<!-- Corrected image with tools full-bleed to use the whole page width
even though the surrounding column is capped at max-w-4xl. -->
<div
class="relative left-1/2 w-screen -translate-x-1/2 px-4"
>
<!-- Deskewed preview -->
<Card>
<CardHeader>
<CardTitle class="text-base"
>Corrected Image</CardTitle
>Deskewed Preview</CardTitle
>
<CardDescription>
Continue to <strong>Measure</strong> to add
annotations and download.
</CardDescription>
</CardHeader>
<CardContent>
<CorrectedImageViewer
ref="viewerRef"
:image-url="resultUrl"
:scale-px-per-mm="store.scalePxPerMm"
<div
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
>
<img
:src="previewUrl"
alt="Deskewed image preview"
class="max-h-[480px] w-full object-contain"
/>
</div>
</CardContent>
</Card>
</div>
<!-- Download -->
<div class="flex flex-col items-center gap-3 pb-8">
<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"
>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">
<div class="flex justify-center pb-8">
<Button size="lg" @click="store.goToStep(5)">
Continue to Measure
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
@ -906,114 +768,12 @@ async function downloadMeasured(scope: "full" | "view") {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
class="ml-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="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</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()"
>
Process New Image
</Button>
</div>
<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>
</template>
</div>

View File

@ -1,11 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { ref, computed, onMounted, onUnmounted } from "vue"
import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import { clearCache as clearMeasurementCache } from "@/lib/measurement-cache"
import {
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 {
Card,
CardContent,
@ -21,17 +33,182 @@ const isDragging = ref(false)
const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null)
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"
// 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
onMounted(() => {
cacheCount.value = getCacheSize()
let confirmTimer: ReturnType<typeof setTimeout> | null = null
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
function handleClearCache() {
// 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()
await refreshRecentUploads()
})
onUnmounted(() => {
if (confirmTimer) clearTimeout(confirmTimer)
for (const url of recentUrls.value.values()) URL.revokeObjectURL(url)
})
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) {
@ -64,6 +241,27 @@ async function handleFile(file: File) {
}, 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.setExif(exif)
store.goToStep(2)
@ -92,7 +290,47 @@ function onFileSelect(e: Event) {
<template>
<div class="flex min-h-[60vh] items-start justify-center pt-12">
<div class="w-full max-w-2xl space-y-6">
<Card>
<Card class="relative">
<!-- 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">
<CardTitle class="text-lg">Load Source Image</CardTitle>
<CardDescription>
@ -170,20 +408,54 @@ function onFileSelect(e: Event) {
</p>
</CardContent>
</Card>
<div
v-if="cacheCount > 0"
class="flex justify-end"
<!-- Recent uploads only rendered when there's something to
show. Each tile reopens the image directly into Measure
with its datums, measurements, scale and last canvas
zoom restored. -->
<section v-if="completedUploads.length > 0" class="space-y-3">
<p
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70"
>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
@click="handleClearCache"
Recent uploads
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="upload in completedUploads"
:key="upload.hash"
type="button"
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"
@click="restoreUpload(upload.hash)"
>
<div
class="aspect-video w-full overflow-hidden bg-muted"
>
<img
:src="previewUrlFor(upload.hash)"
:alt="upload.filename"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
loading="lazy"
/>
</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="12"
height="12"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@ -191,15 +463,13 @@ function onFileSelect(e: Event) {
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" />
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
Clear cache ({{ cacheCount }})
</Button>
</button>
</button>
</div>
</section>
<div class="space-y-2 text-left">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
@ -207,7 +477,7 @@ function onFileSelect(e: Event) {
<div class="space-y-1.5">
<img
src="/example-before.jpg"
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
alt="Before: angled photograph with reference paper laid flat"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">Before &mdash; angled shot</p>
@ -215,12 +485,20 @@ function onFileSelect(e: Event) {
<div class="space-y-1.5">
<img
src="/example-after.jpg"
alt="After: perspective-corrected front-facing view"
alt="After: perspective-corrected top-down view"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p>
</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 class="space-y-2 text-left">

View File

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

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

View File

@ -1,7 +1,59 @@
import type { Point } from "@/types"
import type { Measurement } from "@/types/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(
hash: string,
measurements: Measurement[],

158
src/lib/upload-cache.ts Normal file
View File

@ -0,0 +1,158 @@
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"))
}
})
}

53
src/lib/zoom-cache.ts Normal file
View File

@ -0,0 +1,53 @@
// 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,6 +23,10 @@ export const useAppStore = defineStore("app", () => {
)
const fileHash = ref<string | null>(null)
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 canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -34,6 +38,9 @@ export const useAppStore = defineStore("app", () => {
return d.diameterMm > 0
})
})
const canProceedToStep5 = computed(
() => canProceedToStep4.value && deskewResult.value !== null,
)
function setImage(file: File, image: HTMLImageElement) {
originalFile.value = file
@ -141,8 +148,9 @@ export const useAppStore = defineStore("app", () => {
}
}
function setResult(result: DeskewResult) {
function setResult(result: DeskewResult, scalePxPerMmUsed: number) {
deskewResult.value = result
lastDeskewScale.value = scalePxPerMmUsed
}
function setFileHash(hash: string) {
@ -163,6 +171,7 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
fileHash.value = null
cacheRestoreMessage.value = ""
lastDeskewScale.value = null
}
return {
@ -179,9 +188,11 @@ export const useAppStore = defineStore("app", () => {
scalePxPerMm,
fileHash,
cacheRestoreMessage,
lastDeskewScale,
canProceedToStep2,
canProceedToStep3,
canProceedToStep4,
canProceedToStep5,
setImage,
setExif,
goToStep,

View File

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