Refactor image conversion logic in convert-image-to-webp.ts to handle HEIC files and improve compatibility with Safari. Update ReceiptForm and +page components to streamline image upload process, removing camera functionality and enhancing user feedback during image processing. Adjust styles for better accessibility and clarity in image handling.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m50s

This commit is contained in:
eewing
2026-02-19 15:07:32 -06:00
parent 132e376b9f
commit 8b977b5e86
3 changed files with 96 additions and 237 deletions
+82 -30
View File
@@ -1,42 +1,94 @@
import { convertHeicBlobToJpeg } from "./convert-heic";
function isHeicFile(file: File): boolean {
const name = file.name.toLowerCase();
const type = (file.type ?? "").toLowerCase();
return (
name.endsWith(".heic") ||
name.endsWith(".heif") ||
type === "image/heic" ||
type === "image/heif"
);
}
function makeOutputFile(blob: Blob, baseName: string, ext: string): File {
return new File([blob], `${baseName}.${ext}`, {
type: blob.type,
lastModified: Date.now(),
});
}
/**
* Converts an image File to WebP and returns a new File.
* Used so receipt uploads are stored only as WebP.
* Converts an image File to WebP (or JPEG on Safari where WebP isn't supported).
* HEIC from iPhone is handled: if the image fails to load, we decode with heic2any first.
*/
export function convertImageToWebP(file: File, quality = 0.85): Promise<File> {
return new Promise((resolve, reject) => {
const img = new Image();
const baseName = file.name.replace(/\.[^.]+$/i, "") || "image";
const tryCanvas = (sourceBlob: Blob) => {
const url = URL.createObjectURL(sourceBlob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
ctx.drawImage(img, 0, 0);
// Safari doesn't support WebP in toBlob; try WebP first, fall back to JPEG
canvas.toBlob(
(blob) => {
if (blob && blob.type === "image/webp") {
resolve(makeOutputFile(blob, baseName, "webp"));
return;
}
canvas.toBlob(
(jpegBlob) => {
if (!jpegBlob) {
reject(new Error("Failed to encode image"));
return;
}
resolve(makeOutputFile(jpegBlob, baseName, "jpg"));
},
"image/jpeg",
quality
);
},
"image/webp",
quality
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
};
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to encode WebP"));
return;
}
const baseName = file.name.replace(/\.[^.]+$/i, "") || "image";
const webpFile = new File([blob], `${baseName}.webp`, {
type: "image/webp",
lastModified: Date.now(),
});
resolve(webpFile);
},
"image/webp",
quality
);
tryCanvas(file);
};
img.onerror = () => {
img.onerror = async () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
// HEIC often fails to load in Image() on iOS; decode with heic2any then run canvas
if (isHeicFile(file)) {
try {
const jpegBlob = await convertHeicBlobToJpeg(file);
tryCanvas(jpegBlob);
} catch (e) {
reject(e instanceof Error ? e : new Error("Failed to convert HEIC"));
}
} else {
reject(new Error("Failed to load image"));
}
};
img.src = url;
});
+10 -109
View File
@@ -7,8 +7,8 @@
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
const BUTTON_OUTLINE_CLASS =
"border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors";
const SCAN_BUTTON_CLASS =
"border-input bg-background hover:bg-accent hover:text-accent-foreground flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors";
let {
action,
@@ -24,69 +24,9 @@
defaultType?: string;
} = $props();
let imageInputEl = $state<HTMLInputElement | null>(null);
let imageFileName = $state<string | null>(null);
let imageConverting = $state(false);
let showCamera = $state(false);
let cameraError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
async function startCamera() {
cameraError = null;
showCamera = true;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
cameraError = e instanceof Error ? e.message : "Could not access camera.";
}
}
$effect(() => {
if (!showCamera || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
function stopCamera() {
showCamera = false;
cameraError = null;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) videoEl.srcObject = null;
}
function capturePhoto() {
if (!videoEl || videoEl.readyState < 2 || !imageInputEl) return;
const canvas = document.createElement("canvas");
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(videoEl, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return;
const file = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() });
const dt = new DataTransfer();
dt.items.add(file);
imageInputEl.files = dt.files;
imageFileName = file.name;
stopCamera();
},
"image/webp",
0.85
);
}
async function onImageChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
@@ -94,12 +34,9 @@
imageFileName = null;
return;
}
if (file.type === "image/webp") {
imageFileName = file.name;
return;
}
imageConverting = true;
imageFileName = null;
// Let the UI paint "Processing image…" before we block the main thread
await new Promise((r) => setTimeout(r, 0));
try {
const webpFile = await convertImageToWebP(file);
@@ -146,19 +83,19 @@
<Label for="Image">Image</Label>
<div class="relative flex flex-col gap-2">
<input
bind:this={imageInputEl}
id="Image"
name="Image"
type="file"
accept="image/*"
capture="environment"
onchange={onImageChange}
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
tabindex="-1"
aria-label="Choose image from gallery"
aria-label="Scan receipt"
/>
{#if imageConverting}
<div
class="{BUTTON_OUTLINE_CLASS} w-full pointer-events-none opacity-80"
class="{SCAN_BUTTON_CLASS} pointer-events-none opacity-80"
aria-busy="true"
aria-live="polite"
>
@@ -166,56 +103,20 @@
<span>Processing image…</span>
</div>
{:else}
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" class="flex-1 sm:flex-none" onclick={startCamera}>
Take photo
</Button>
<label for="Image" class="{BUTTON_OUTLINE_CLASS} flex-1 sm:flex-none">
Choose from gallery
</label>
</div>
<label for="Image" class={SCAN_BUTTON_CLASS}>
Scan receipt
</label>
{/if}
{#if imageConverting}
<p class="text-muted-foreground text-xs">Converting to WebP…</p>
{:else if imageFileName}
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
{:else}
<p class="text-muted-foreground text-xs">Optional. Take a photo (saved as WebP) or choose from gallery.</p>
<p class="text-muted-foreground text-xs">Optional. Images are converted to WebP before upload.</p>
{/if}
</div>
</div>
{#if showCamera}
<div
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Take receipt photo"
>
<div class="flex w-full max-w-lg flex-col gap-3">
{#if cameraError}
<p class="text-destructive text-sm">{cameraError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input aspect-video w-full rounded-lg border bg-black"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
Cancel
</Button>
{#if !cameraError}
<Button type="button" class="flex-1" onclick={capturePhoto}>
Capture
</Button>
{/if}
</div>
</div>
</div>
{/if}
{#if error}
<p class="text-destructive text-sm">{error}</p>
{/if}
+4 -98
View File
@@ -125,11 +125,6 @@
let imageError = $state(false);
let imageConverting = $state(false);
let showCamera = $state(false);
let cameraError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
/** On small screens image is collapsed by default; on lg+ expanded. */
let imageOpen = $state(false);
let imageOpenInitialized = $state(false);
@@ -140,57 +135,6 @@
imageOpen = mq.matches;
});
async function startCamera() {
cameraError = null;
showCamera = true;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
cameraError = e instanceof Error ? e.message : "Could not access camera.";
}
}
$effect(() => {
if (!showCamera || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
function stopCamera() {
showCamera = false;
cameraError = null;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) videoEl.srcObject = null;
}
function capturePhoto() {
if (!videoEl || videoEl.readyState < 2) return;
const canvas = document.createElement("canvas");
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(videoEl, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return;
selectedImageFile = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() });
imageFileName = "receipt.webp";
stopCamera();
},
"image/webp",
0.85
);
}
async function onImageChange() {
const input = imageInputEl;
const file = input?.files?.[0] ?? null;
@@ -199,11 +143,6 @@
imageFileName = null;
return;
}
if (file.type === "image/webp") {
selectedImageFile = file;
imageFileName = file.name;
return;
}
imageConverting = true;
selectedImageFile = null;
imageFileName = null;
@@ -233,37 +172,6 @@
</svelte:head>
<main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
{#if showCamera}
<div
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Take receipt photo"
>
<div class="flex w-full max-w-lg flex-col gap-3">
{#if cameraError}
<p class="text-destructive text-sm">{cameraError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input aspect-video w-full rounded-lg border bg-black"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
Cancel
</Button>
{#if !cameraError}
<Button type="button" class="flex-1" onclick={capturePhoto}>
Capture
</Button>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center gap-3">
<a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
@@ -334,17 +242,18 @@
class="flex flex-col gap-4"
>
<input type="hidden" name="dataJson" value={buildDataJson()} />
<div class="relative flex flex-wrap items-center gap-2">
<div class="relative flex items-center gap-2">
<input
id="receipt-image-input"
bind:this={imageInputEl}
type="file"
name="Image"
accept="image/*"
capture="environment"
onchange={onImageChange}
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
class="absolute opacity-0 w-0 h-0 overflow-hidden"
tabindex="-1"
aria-label="Choose image from gallery"
aria-label={receipt.Image ? "Replace image" : "Add image"}
/>
{#if imageConverting}
<div
@@ -356,9 +265,6 @@
<span>Processing image…</span>
</div>
{:else}
<Button type="button" variant="outline" size="sm" onclick={startCamera}>
Take photo
</Button>
<label
for="receipt-image-input"
class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors"