import { html, type TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { CFFileInput } from "../cf-file-input/index.ts"; import type { StoredFile } from "../../utils/file-cell-storage.ts"; import { compressImage, formatFileSize, } from "../../utils/image-compression.ts"; /** * Image-specific metadata (EXIF data) */ export interface ExifData { // Core metadata dateTime?: string; make?: string; model?: string; // Orientation orientation?: number; // Location (if available) gpsLatitude?: number; gpsLongitude?: number; gpsAltitude?: number; // Camera settings fNumber?: number; exposureTime?: string; iso?: number; focalLength?: number; // Dimensions pixelXDimension?: number; pixelYDimension?: number; // Software software?: string; // Raw EXIF tags for advanced use raw?: Record; } /** * Image data structure (extends FileData with image-specific fields) */ export interface ImageData extends StoredFile { width?: number; height?: number; exif?: ExifData; } /** * CFImageInput - Image capture and upload component with camera support * * Extends CFFileInput with image-specific features like compression, EXIF extraction, * and camera capture support. * * @element cf-image-input * * @attr {boolean} multiple - Allow multiple images (default: false) * @attr {number} maxImages - Max number of images (default: unlimited) * @attr {number} maxSizeBytes - Max size in bytes before compression (default: 5MB) * @attr {string} capture - Capture mode: "user" | "environment" | false * @attr {string} buttonText - Custom button text (default: "📷 Add Photo") * @attr {string} variant - Button style variant * @attr {string} size - Button size * @attr {boolean} showPreview - Show image previews (default: true) * @attr {string} previewSize - Preview thumbnail size: "sm" | "md" | "lg" * @attr {boolean} removable - Allow removing images (default: true) * @attr {boolean} disabled - Disable the input * * @fires cf-change - Fired when image(s) are added or removed. On add, images contains the newly added images. On remove, images contains the updated full list for compatibility. detail: { images: ImageData[], allImages: ImageData[] } * @fires cf-remove - Fired when an image is removed. detail: { id: string, images: ImageData[], allImages: ImageData[] } * @fires cf-error - Fired when an error occurs. detail: { error: Error, message: string } * * @example * * @example * */ export class CFImageInput extends CFFileInput { // Override default properties with image-specific defaults @property({ type: String }) override accessor buttonText = "📷 Add Photo"; @property({ type: String }) override accessor accept = "image/*"; @property({ type: Number }) override accessor maxSizeBytes = 5 * 1024 * 1024; // Default to 5MB for images // Image-specific properties @property({ type: String }) accessor capture: "user" | "environment" | false | undefined = undefined; @property({ type: Boolean }) accessor extractExif = false; // Alias maxImages to maxFiles for backward compatibility get maxImages(): number | undefined { return this.maxFiles; } set maxImages(value: number | undefined) { this.maxFiles = value; } // Override: Images should be compressed if maxSizeBytes is set and exceeded protected override shouldCompressFile(file: File): boolean { return !!(this.maxSizeBytes && file.size > this.maxSizeBytes); } // Override: Use image compression utility protected override async compressFile(file: File): Promise { if (!this.maxSizeBytes) return file; const result = await compressImage(file, { maxSizeBytes: this.maxSizeBytes, }); // Log compression result if (result.compressedSize < result.originalSize) { console.log( `Compressed ${file.name}: ${formatFileSize(result.originalSize)} → ${ formatFileSize(result.compressedSize) } (${result.width}x${result.height}, q${result.quality.toFixed(2)})`, ); } if (result.compressedSize > this.maxSizeBytes) { console.warn( `Could not compress ${file.name} below ${ formatFileSize(this.maxSizeBytes) }. Final size: ${formatFileSize(result.compressedSize)}`, ); } return result.blob; } // Override: Extract image dimensions and EXIF protected override async processFile(file: File): Promise { const dimensions = await readImageDimensions(file); return await this.storeFile(file, dimensions) as ImageData; } // Override: Always use for images (we know they're images) protected override renderPreview(file: ImageData): TemplateResult { return html` ${file.name} `; } // Override: Add capture attribute to file input protected override renderFileInput(): TemplateResult { const captureAttr = this.capture !== false ? this.capture : undefined; return html` `; } // Override render to keep "Processing images..." text override render() { return html`
${this.renderFileInput()} ${this.renderButton()} ${this.loading ? html`
Processing images...
` : ""} ${this.renderPreviews()}
`; } // Internal handler that calls parent's protected handler private _handleFileChangeInternal = (event: Event) => { // Call parent's protected _handleFileChange method super._handleFileChange(event); }; // Override emit to add image-specific event details protected override emit( eventName: string, detail?: T, options?: EventInit, ): boolean { if ( (eventName === "cf-change" || eventName === "cf-remove") && (detail as any)?.files ) { return super.emit(eventName, { ...detail, images: (detail as any).files, allImages: (detail as any).allFiles, } as T, options); } return super.emit(eventName, detail, options); } } function readImageDimensions( file: File, ): Promise> { const objectUrl = URL.createObjectURL(file); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { URL.revokeObjectURL(objectUrl); resolve({ width: img.width, height: img.height }); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error("Failed to load image")); }; img.src = objectUrl; }); }