@limetech/lime-elements
Version:
532 lines (527 loc) • 23.7 kB
JavaScript
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