import { css, html } from "lit"; import { property, state } from "lit/decorators.js"; import { FabricBytes } from "@commonfabric/data-model/fabric-primitives"; import { BaseElement } from "../../core/base-element.ts"; import "../cf-button/index.ts"; /** The width and height, in pixels, of the image an avatar is stored as. */ export const AVATAR_PIXELS = 128; /** * Returns the bytes of a square avatar made from `image`: its centered square, * scaled to `AVATAR_PIXELS` on each side, encoded as WebP, or as PNG where the * browser cannot encode WebP. */ export async function avatarBytesFromImage(image: Blob): Promise { let bitmap: ImageBitmap; try { bitmap = await createImageBitmap(image); } catch { throw new NotAnImageError(); } try { const side = Math.min(bitmap.width, bitmap.height); const canvas = new OffscreenCanvas(AVATAR_PIXELS, AVATAR_PIXELS); const context = canvas.getContext("2d"); if (!context) throw new Error("No 2D canvas context is available"); context.drawImage( bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side, 0, 0, AVATAR_PIXELS, AVATAR_PIXELS, ); const blob = await canvas.convertToBlob({ type: "image/webp", quality: 0.85, }); return new Uint8Array(await blob.arrayBuffer()); } finally { bitmap.close(); } } /** Thrown by {@link avatarBytesFromImage} for a file that is not an image. */ export class NotAnImageError extends Error { constructor() { super("The file is not an image"); } } /** * CFAvatarInput - Picks an image file and hands it on as avatar bytes. * * The picked image is cropped to its centered square and scaled down to * `AVATAR_PIXELS` on each side, so what a profile stores is small whatever * the user picks. * * @element cf-avatar-input * * @attr {boolean} disabled - Disables the button * * @fires cf-change - The picked image. detail: { bytes: FabricBytes } * @fires cf-error - The picked file is not an image, or the browser could not * draw it into a picture. detail: { message: string } */ export class CFAvatarInput extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: inline-block; } input { display: none; } `, ]; @property({ type: Boolean, reflect: true }) accessor disabled = false; @state() private accessor _busy = false; #handleClick = (): void => { this.#input()?.click(); }; #handleChange = async (): Promise => { const input = this.#input(); const file = input?.files?.[0]; if (!input || !file) return; // Cleared so that picking the same file again still fires a change. input.value = ""; this._busy = true; try { const bytes = new FabricBytes(await avatarBytesFromImage(file), true); this.emit("cf-change", { bytes }); } catch (error) { this.emit("cf-error", { message: error instanceof NotAnImageError ? `${file.name} is not an image` : `${file.name} could not be made into a picture`, }); } finally { this._busy = false; } }; override render() { return html` Choose image `; } #input(): HTMLInputElement | null | undefined { return this.shadowRoot?.querySelector("input"); } } declare global { interface HTMLElementTagNameMap { "cf-avatar-input": CFAvatarInput; } }