UNPKG

@limetech/lime-elements

Version:
532 lines (527 loc) 23.7 kB
import { r as registerInstance, c as createEvent, h, H as Host } from './index-2714248e.js'; import { i as isTypeAccepted } from './files-b1cef4e4.js'; import { g as getIconName } from './get-icon-props-37514418.js'; import { t as translate } from './translations-91c611da.js'; import { c as createRandomString } from './random-string-355331d3.js'; import './file-metadata-ce643c6e.js'; /** * Image resize utilities * * Overview * -------- * This module provides a small, dependency-free utility to resize images on the client * (in the browser) before uploading. It works by decoding an input `File` to an * `ImageBitmap` (or falling back to an `HTMLImageElement`), drawing it onto a * `Canvas`/`OffscreenCanvas` with the requested strategy (cover/contain), and * then exporting the result to a new `File` with your preferred MIME type and * quality. * * Why resize client-side? * - Faster perceived uploads and lower bandwidth usage * - Consistent avatar sizes and formats (e.g., JPEG 400x400) * - No server-side transformation required for common cases * * Fit strategies * - `cover` (default): The image is scaled to cover the target rectangle, and * the excess parts are center-cropped. Good for avatars. * - `contain`: The image is scaled to fit entirely within the target rectangle * without cropping, letterboxing if needed. Good when you must preserve the * entire image. * * Decoding & EXIF orientation * EXIF orientation is a piece of metadata stored inside image files * (usually JPEGs) that tells image renderer software how the image should be displayed * i.e., whether it should be rotated or flipped. This meta data is normally added * to photos by digital cameras and phones. * - When available, `createImageBitmap(file, { imageOrientation: 'from-image' })` * is used to automatically respect EXIF orientation. * - If not available or it fails (e.g., unsupported format), we fall back to * decoding via an `HTMLImageElement`. * * OffscreenCanvas * - If the environment supports `OffscreenCanvas`, it will be used for the draw * and encode operations for better performance in some cases. Otherwise, a * regular `HTMLCanvasElement` is used. * * HEIC/HEIF notes * - All major browsers except Safari lack native HEIC/HEIF decoding. * In such cases the `resizeImage` function will throw when decoding fails. * The caller should catch and fall back * to using the original file or handle conversion on the server. * - If we need guaranteed client-side HEIC->JPEG conversion, we must add a small * library or WASM module; this utility intentionally avoids extra dependencies. * * Output type & quality * - Default output is `image/jpeg` with `quality=0.85`, which is typically * appropriate for avatars. You can switch to `image/png` to preserve * transparency. * - The output filename extension is adjusted to match the chosen MIME type by * default (e.g., `.jpg` or `.png`). You can override naming via the `rename` * option. * * Error handling * - Throws if canvas/context cannot be created or if canvas->blob conversion fails. * - Decoding failures (unsupported type) will throw; caller can try/catch and * fall back to the original file. * * Performance tips * - Keep target sizes reasonable (e.g., 256–1024 px) to avoid long processing * times on modest devices. * - JPEG with quality 0.8–0.9 often strikes a good balance between size and * perceived quality for photos/avatars. * * Usage examples * -------------- * Basic usage: * ```ts * import { resizeImage } from '@limetech/lime-elements/util/image-resize'; * * const processed = await resizeImage(file, { * width: 400, * height: 400, * fit: 'cover', // default; center-crops * type: 'image/jpeg', // default * quality: 0.85, // default * }); * // Upload `processed` instead of the original file * ``` * * With custom naming: * ```ts * const processed = await resizeImage(file, { * width: 800, * height: 800, * fit: 'contain', * type: 'image/png', * rename: (name) => name.replace(/\.[^.]+$/, '') + '_resized.png', * }); * ``` * * In a Stencil component (simplified): * ```tsx * private async handleFilesSelected(file: File) { * try { * const resized = await resizeImage(file, { width: 400, height: 400 }); * // build your FileInfo and emit * } catch { * // fall back to original * } * } * ``` */ // (Removed exported ResizeFit to avoid forcing a public symbol.) /** * Resize an image file on the client using Canvas/OffscreenCanvas. * Returns a new File with the requested format and dimensions. * * Contract * - Input: image `File` * - Output: resized image as a new `File` with updated `type`, name, and size * - Errors: may throw on decode failure or canvas export failure * * @beta * @param file - The image file to resize. * @param options - Configuration for the resize operation. */ async function resizeImage(file, options) { const { width, height, fit = 'cover', type = 'image/jpeg', quality = 0.85, rename = (name) => renameWithType(name, type), } = options; const source = await loadSource(file); const { sx, sy, sw, sh, dx, dy, dw, dh } = computeRects(source.width, source.height, width, height, fit); const canvas = createCanvas(width, height); const ctx = get2dContext(canvas); ctx.clearRect(0, 0, width, height); ctx.drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh); const blob = await canvasToBlob(canvas, type, quality); const name = rename(file.name); return new File([blob], name, { type }); } /** Whether OffscreenCanvas is available in the current environment. */ function supportsOffscreen() { try { return typeof globalThis.OffscreenCanvas === 'function'; } catch (_a) { return false; } } /** * Create either an OffscreenCanvas or a regular canvas for drawing. * @param width - Target width * @param height - Target height */ function createCanvas(width, height) { if (supportsOffscreen()) { return new globalThis.OffscreenCanvas(width, height); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; return canvas; } /** * Get the 2D rendering context, throwing a descriptive error if unavailable. * @param canvas - The canvas to get context from */ function get2dContext(canvas) { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) { throw new Error('2D canvas context not available'); } return ctx; } /** * Convert the canvas content to a Blob, supporting both canvas types. * @param canvas - The source canvas * @param type - Output MIME type * @param quality - JPEG quality (0..1) */ function canvasToBlob(canvas, type, quality) { if ('convertToBlob' in canvas) { return canvas.convertToBlob({ type, quality }); } return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('Failed to create blob from canvas')); return; } resolve(blob); }, type, quality); }); } /** * Load the image into a decodable source (ImageBitmap preferred). * @param file - The input file to decode */ async function loadSource(file) { var _a, _b; if (typeof globalThis.createImageBitmap === 'function') { try { return await globalThis.createImageBitmap(file, { imageOrientation: 'from-image', }); } catch (error) { // Log for debugging in development, but continue with fallback const isDev = ((_b = (_a = globalThis.process) === null || _a === void 0 ? void 0 : _a.env) === null || _b === void 0 ? void 0 : _b.NODE_ENV) !== 'production'; if (isDev && typeof console !== 'undefined' && typeof console.debug === 'function') { console.debug('createImageBitmap failed, falling back to HTMLImageElement:', error); } } } return await loadImageElement(file); } /** * Decode an image file via HTMLImageElement when ImageBitmap is unavailable. * @param file - The input file to decode */ async function loadImageElement(file) { var _a; const url = URL.createObjectURL(file); try { const img = new Image(); img.decoding = 'sync'; img.src = url; await ((_a = img.decode) === null || _a === void 0 ? void 0 : _a.call(img).catch(() => undefined)); if (!img.complete) { await new Promise((resolve, reject) => { const cleanup = () => { img.removeEventListener('load', onLoad); img.removeEventListener('error', onError); }; const onLoad = () => { cleanup(); resolve(); }; const onError = (e) => { cleanup(); reject(e); }; img.addEventListener('load', onLoad); img.addEventListener('error', onError); }); } return img; } finally { URL.revokeObjectURL(url); } } /** * Compute source and destination rectangles for drawImage based on the fit mode. * * Returns sx, sy, sw, sh for the source crop/area and dx, dy, dw, dh for the * destination rectangle on the target canvas. * * @param sw - Source width * @param sh - Source height * @param tw - Target width * @param th - Target height * @param fit - Fit mode (cover/contain) */ function computeRects(sw, sh, tw, th, fit) { const sRatio = sw / sh; const tRatio = tw / th; if (fit === 'cover') { // scale source to cover target, then center-crop let cropW; let cropH; if (sRatio > tRatio) { // source is wider than target: crop width cropH = sh; cropW = sh * tRatio; } else { // source is taller than target: crop height cropW = sw; cropH = sw / tRatio; } const sx = (sw - cropW) / 2; const sy = (sh - cropH) / 2; return { sx, sy, sw: cropW, sh: cropH, dx: 0, dy: 0, dw: tw, dh: th }; } // contain: fit inside, letterbox if needed let drawW; let drawH; if (sRatio > tRatio) { drawW = tw; drawH = tw / sRatio; } else { drawH = th; drawW = th * sRatio; } const dx = (tw - drawW) / 2; const dy = (th - drawH) / 2; return { sx: 0, sy: 0, sw, sh, dx, dy, dw: drawW, dh: drawH }; } /** * Update filename extension to match the desired MIME type. * @param name - Original filename * @param type - Output MIME type */ function renameWithType(name, type) { const ext = type === 'image/png' ? 'png' : 'jpg'; const idx = name.lastIndexOf('.'); const base = idx > 0 ? name.slice(0, idx) : name; return `${base}.${ext}`; } const profilePictureCss = "@charset \"UTF-8\";:host(limel-profile-picture){position:relative;display:inline-flex;min-width:1.5rem;min-height:1.5rem;border-radius:var(--profile-picture-border-radius, 100vw);background-color:rgb(var(--contrast-400))}*{box-sizing:border-box}limel-file-dropzone,limel-file-input,button.avatar{display:flex;align-items:center;justify-content:center;width:100%;height:100%}button{all:unset;display:block}button:focus{outline:none}button:focus-visible{outline:none;box-shadow:var(--shadow-depth-8-focused)}button.avatar{overflow:hidden;border-radius:var(--profile-picture-border-radius, 100vw)}:host(:not([disabled]):not([disabled=true])) button.avatar{transition:color var(--limel-clickable-transition-speed, 0.4s) ease, background-color var(--limel-clickable-transition-speed, 0.4s) ease, box-shadow var(--limel-clickable-transform-speed, 0.4s) ease, transform var(--limel-clickable-transform-speed, 0.4s) var(--limel-clickable-transform-timing-function, ease);cursor:pointer;color:var(--limel-theme-on-surface-color);background-color:transparent}:host(:not([disabled]):not([disabled=true])) button.avatar:hover,:host(:not([disabled]):not([disabled=true])) button.avatar:focus,:host(:not([disabled]):not([disabled=true])) button.avatar:focus-visible{will-change:color, background-color, box-shadow, transform}:host(:not([disabled]):not([disabled=true])) button.avatar:hover,:host(:not([disabled]):not([disabled=true])) button.avatar:focus-visible{transform:translate3d(0, 0.01rem, 0);color:var(--limel-theme-on-surface-color);background-color:var(--lime-elevated-surface-background-color)}:host(:not([disabled]):not([disabled=true])) button.avatar:hover{box-shadow:var(--button-shadow-hovered)}:host(:not([disabled]):not([disabled=true])) button.avatar:active{--limel-clickable-transform-timing-function:cubic-bezier(\n 0.83,\n -0.15,\n 0.49,\n 1.16\n );transform:translate3d(0, 0.05rem, 0);box-shadow:var(--button-shadow-pressed)}:host(:not([disabled]):not([disabled=true])) button.avatar:hover,:host(:not([disabled]):not([disabled=true])) button.avatar:active{--limel-clickable-transition-speed:0.2s;--limel-clickable-transform-speed:0.16s}:host([invalid]:not([invalid=false])) button.avatar{box-shadow:var(--shadow-error-state)}button.remove{transition:color var(--limel-clickable-transition-speed, 0.4s) ease, background-color var(--limel-clickable-transition-speed, 0.4s) ease, box-shadow var(--limel-clickable-transform-speed, 0.4s) ease, transform var(--limel-clickable-transform-speed, 0.4s) var(--limel-clickable-transform-timing-function, ease);cursor:pointer;color:var(--limel-theme-on-surface-color);background-color:rgb(var(--contrast-900));cursor:pointer;height:1.25rem;width:1.25rem;border-radius:50%;background-repeat:no-repeat;background-position:center;background-size:0.75rem;background-image:url(\"data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><defs/><path fill='rgb(255,255,255)' d='M7.219 5.781L5.78 7.22 14.563 16 5.78 24.781 7.22 26.22 16 17.437l8.781 8.782 1.438-1.438L17.437 16l8.782-8.781L24.78 5.78 16 14.563z'/></svg>\");position:absolute;top:0;left:0;opacity:0}button.remove:hover,button.remove:focus,button.remove:focus-visible{will-change:color, background-color, box-shadow, transform}button.remove:hover,button.remove:focus-visible{transform:translate3d(0, 0.01rem, 0);color:rgb(var(--color-white));background-color:rgb(var(--color-red-default))}button.remove:hover{box-shadow:var(--button-shadow-hovered)}button.remove:active{--limel-clickable-transform-timing-function:cubic-bezier(\n 0.83,\n -0.15,\n 0.49,\n 1.16\n );transform:translate3d(0, 0.05rem, 0);box-shadow:var(--button-shadow-pressed)}button.remove:hover,button.remove:active{--limel-clickable-transition-speed:0.2s;--limel-clickable-transform-speed:0.16s}:host(:hover) button.remove,:host(:focus) button.remove,:host(:focus-visible) button.remove,:host(:focus-within) button.remove,:host(:active) button.remove{animation:show 0.4s ease-in-out forwards}@keyframes show{0%{transform:scale(0.9);opacity:0}100%{transform:scale(1);opacity:1}}button.avatar,img,limel-icon{border-radius:var(--profile-picture-border-radius, 100vw)}limel-icon{width:calc(100% - 1rem);min-width:1rem;max-width:4rem;color:var(--limel-theme-text-secondary-on-background-color);margin:auto}img{object-fit:var(--limel-profile-picture-object-fit);width:100%;height:100%}:host(.has-image-error) img{border:1px dashed rgb(var(--contrast-600));background:url(\"data:image/svg+xml;charset=utf-8, <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8' style='fill-rule:evenodd;'><path fill='rgba(186,186,192,0.16)' d='M0 0h4v4H0zM4 4h4v4H4z'/></svg>\");background-size:0.5rem}limel-spinner{position:absolute;inset:0;margin:auto}limel-popover{position:absolute;inset:auto 0 0 auto;display:block;width:2.25rem;height:2.25rem}"; const ProfilePicture = class { constructor(hostRef) { registerInstance(this, hostRef); this.change = createEvent(this, "change", 7); this.filesRejected = createEvent(this, "filesRejected", 7); this.removeButtonId = createRandomString(); this.browseButtonId = createRandomString(); this.renderHelperText = () => { if (!this.helperText) { return; } return (h("limel-tooltip", { elementId: this.browseButtonId, label: this.helperText })); }; this.handleNewFiles = async (event) => { var _a, _b; event.stopPropagation(); if (this.disabled) { return; } const file = (_a = event.detail) === null || _a === void 0 ? void 0 : _a[0]; if (!file) { return; } if (!isTypeAccepted(file, this.accept)) { this.filesRejected.emit([file]); return; } this.revokeObjectUrl(); this.imageError = false; let out = file; // Optional client-side resize if (this.resize && file.fileContent instanceof File) { try { const processed = await resizeImage(file.fileContent, Object.assign(Object.assign({}, this.resize), { fit: (_b = this.resize.fit) !== null && _b !== void 0 ? _b : this.imageFit })); out = Object.assign(Object.assign({}, file), { filename: processed.name, size: processed.size, contentType: processed.type, fileContent: processed }); } catch (_c) { // Fall back to original file if resize fails out = file; } } // Create an object URL for immediate preview if no href present if (!out.href && out.fileContent instanceof File) { this.objectUrl = URL.createObjectURL(out.fileContent); } this.change.emit(out); }; this.handleRejectedFiles = (event) => { event.stopPropagation(); this.filesRejected.emit(event.detail); }; this.handleClear = (event) => { event.stopPropagation(); this.revokeObjectUrl(); this.imageError = false; this.change.emit(undefined); }; this.onImageError = () => { this.imageError = true; }; this.openPopover = (event) => { event.stopPropagation(); this.isErrorMessagePopoverOpen = true; }; this.onPopoverClose = (event) => { event.stopPropagation(); this.isErrorMessagePopoverOpen = false; }; this.getTranslation = (key) => { return translate.get(key, this.language); }; this.language = 'en'; this.label = undefined; this.icon = 'user'; this.helperText = undefined; this.disabled = false; this.readonly = false; this.required = false; this.invalid = false; this.loading = false; this.value = undefined; this.imageFit = 'cover'; this.accept = 'image/jpeg,image/png,image/heic,.jpg,.jpeg,.png,.heic'; this.resize = undefined; this.objectUrl = undefined; this.imageError = false; this.isErrorMessagePopoverOpen = false; } disconnectedCallback() { this.revokeObjectUrl(); } handleValueChange() { // Clear previously created object URL when value changes this.revokeObjectUrl(); this.imageError = false; // If a new File without href is provided, create an object URL for preview const currentValue = this.value; if (currentValue && typeof currentValue !== 'string' && !currentValue.href && currentValue.fileContent instanceof File) { this.objectUrl = URL.createObjectURL(currentValue.fileContent); } } render() { const hostClassNames = { 'has-image-error': this.imageError, }; if (this.readonly) { return h(Host, { class: hostClassNames }, this.renderAvatar()); } return (h(Host, { class: hostClassNames }, h("limel-file-dropzone", { disabled: this.disabled, accept: this.accept, onFilesSelected: this.handleNewFiles, onFilesRejected: this.handleRejectedFiles }, h("limel-file-input", { accept: this.accept, disabled: this.disabled, "aria-required": this.required ? 'true' : undefined, "aria-invalid": this.invalid ? 'true' : undefined }, this.renderBrowseButton())), this.renderClearButton(), this.renderSpinner(), this.renderErrorMessage(), this.renderHelperText())); } get hasValue() { if (typeof this.value === 'string') { return !!this.value; } if (this.value && (this.value.href || this.value.fileContent)) { return true; } return !!this.objectUrl; } renderBrowseButton() { return (h("button", { id: this.browseButtonId, type: "button", class: "avatar", disabled: this.disabled, "aria-label": this.label, "aria-busy": this.loading ? 'true' : 'false', "aria-live": "polite" }, this.renderAvatar())); } renderAvatar() { const src = this.getImageSrc(); if (src) { return (h("img", { src: src, alt: "", style: { '--limel-profile-picture-object-fit': this.imageFit, }, loading: "lazy", onError: this.onImageError })); } return this.renderIcon(); } renderIcon() { var _a, _b; const icon = getIconName(this.icon); return (h("limel-icon", { name: icon, style: { color: `${(_a = this.icon) === null || _a === void 0 ? void 0 : _a.color}`, 'background-color': `${(_b = this.icon) === null || _b === void 0 ? void 0 : _b.backgroundColor}`, } })); } renderClearButton() { if (!this.hasValue || this.disabled) { return; } return [ h("button", { class: "remove", type: "button", id: this.removeButtonId, onClick: this.handleClear }), h("limel-tooltip", { label: this.getTranslation('profile-picture.remove'), elementId: this.removeButtonId }), ]; } renderSpinner() { if (!this.loading) { return; } return h("limel-spinner", null); } // Collects derived flags used for deciding whether to show the unsupported preview message getUnsupportedPreviewContext() { const currentValue = this.value; const hasNoSrc = !this.getImageSrc(); const hasLocalFile = !!(currentValue && typeof currentValue !== 'string' && currentValue.fileContent instanceof File && !currentValue.href); const isResizeConfigured = !!this.resize; return { hasNoSrc, hasLocalFile, isResizeConfigured }; } shouldShowErrorMessage() { const { hasNoSrc, hasLocalFile, isResizeConfigured } = this.getUnsupportedPreviewContext(); return ((hasNoSrc || this.imageError) && hasLocalFile && isResizeConfigured); } // Shows a non-intrusive note when there is a File without href and no object URL, which // can happen if the browser failed to decode the source (e.g., HEIC in Chromium). renderErrorMessage() { if (!this.shouldShowErrorMessage()) { return; } const errorIcon = { name: 'error', color: 'rgb(var(--color-orange-dark))', }; const errorMessageStyles = { maxWidth: '20rem', borderRadius: '0.75rem', }; return (h("limel-popover", { open: this.isErrorMessagePopoverOpen, onClick: this.openPopover, onClose: this.onPopoverClose }, h("limel-icon-button", { slot: "trigger", elevated: true, icon: errorIcon, "aria-live": "polite", label: this.getTranslation('profile-picture.unsupported-preview.title') }), h("limel-callout", { type: "warning", style: errorMessageStyles, heading: this.getTranslation('profile-picture.unsupported-preview.title') }, this.getTranslation('profile-picture.unsupported-preview.description')))); } getImageSrc() { if (!this.value) { return this.objectUrl; // Could be set from last selection before parent consumes } if (typeof this.value === 'string') { return this.value; } if (this.value.href) { return this.value.href; } if (this.value.fileContent instanceof File) { return this.objectUrl; } return undefined; } revokeObjectUrl() { if (this.objectUrl) { URL.revokeObjectURL(this.objectUrl); this.objectUrl = undefined; } } static get watchers() { return { "value": ["handleValueChange"] }; } }; ProfilePicture.style = profilePictureCss; export { ProfilePicture as limel_profile_picture }; //# sourceMappingURL=limel-profile-picture.entry.js.map