feat(upload): recent-uploads gallery + per-image zoom restore
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>
This commit is contained in:
parent
a5f4bf650c
commit
9032af426e
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
BIN
public/example-measured.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -30,6 +30,8 @@ import {
|
||||
saveMeasurements,
|
||||
scaleMeasurements,
|
||||
} from "@/lib/measurement-cache"
|
||||
import { patchUpload } from "@/lib/upload-cache"
|
||||
import { clearZoom } from "@/lib/zoom-cache"
|
||||
|
||||
const store = useAppStore()
|
||||
const previewUrl = ref<string | null>(null)
|
||||
@ -238,6 +240,9 @@ async function runDeskew() {
|
||||
// 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 &&
|
||||
@ -249,12 +254,28 @@ async function runDeskew() {
|
||||
const scaled = scaleMeasurements(cached, newScale / oldScale)
|
||||
saveMeasurements(store.fileHash, scaled)
|
||||
}
|
||||
clearZoom(store.fileHash)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -1,11 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } 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,
|
||||
@ -22,6 +34,8 @@ 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
|
||||
@ -30,18 +44,73 @@ 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()
|
||||
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) {
|
||||
@ -58,6 +127,90 @@ function handleClearCacheClick() {
|
||||
}, 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) {
|
||||
error.value = ""
|
||||
store.isProcessing = true
|
||||
@ -88,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)
|
||||
@ -235,13 +409,75 @@ function onFileSelect(e: Event) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
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="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">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<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 — angled shot</p>
|
||||
@ -249,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 — 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 — annotations baked in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-left">
|
||||
|
||||
@ -211,7 +211,9 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
<!-- 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">
|
||||
<div>
|
||||
<!-- 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.
|
||||
@ -368,23 +370,15 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- "Start over" zone: separator + label make it visually
|
||||
distinct from the action buttons; dashed/transparent
|
||||
styling on the button itself adds friction so it isn't
|
||||
mis-clicked while reaching for a download. -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-6 w-px bg-border" />
|
||||
<span
|
||||
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70"
|
||||
>Start over</span
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="border-dashed bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
||||
@click="store.reset()"
|
||||
>New Image</Button
|
||||
>
|
||||
</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">
|
||||
|
||||
158
src/lib/upload-cache.ts
Normal file
158
src/lib/upload-cache.ts
Normal 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 (5–10MB) 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
53
src/lib/zoom-cache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user