From 65860a3142835d93aca9bb3c82f2ef26a2b9409d Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 20 Dec 2024 01:55:24 -0800 Subject: [PATCH] Allow selecting previously uploaded image for picture upload (#23072) --- src/components/ha-file-upload.ts | 9 ++ src/components/ha-picture-upload.ts | 90 ++++++++++++++++++- .../ha-selector/ha-selector-image.ts | 1 + .../dialog-media-player-browse.ts | 2 +- .../media-player/show-media-browser-dialog.ts | 1 + src/data/image_upload.ts | 4 +- .../image-cropper-dialog.ts | 36 +++++++- .../areas/dialog-area-registry-detail.ts | 1 + .../config/person/dialog-person-detail.ts | 1 + src/translations/en.json | 7 +- 10 files changed, 142 insertions(+), 10 deletions(-) diff --git a/src/components/ha-file-upload.ts b/src/components/ha-file-upload.ts index f68e52f673a3..3c8eb6c775fd 100644 --- a/src/components/ha-file-upload.ts +++ b/src/components/ha-file-upload.ts @@ -320,6 +320,15 @@ export class HaFileUpload extends LitElement { .progress { color: var(--secondary-text-color); } + button.link { + background: none; + border: none; + padding: 0; + font-size: 14px; + color: var(--primary-color); + text-decoration: underline; + cursor: pointer; + } `; } } diff --git a/src/components/ha-picture-upload.ts b/src/components/ha-picture-upload.ts index c61fecc87c06..9c2ee334bcb2 100644 --- a/src/components/ha-picture-upload.ts +++ b/src/components/ha-picture-upload.ts @@ -2,9 +2,15 @@ import { mdiImagePlus } from "@mdi/js"; import type { TemplateResult } from "lit"; import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators"; +import type { MediaPickedEvent } from "../data/media-player"; import { fireEvent } from "../common/dom/fire_event"; import { haStyle } from "../resources/styles"; -import { createImage, generateImageThumbnailUrl } from "../data/image_upload"; +import { + MEDIA_PREFIX, + getIdFromUrl, + createImage, + generateImageThumbnailUrl, +} from "../data/image_upload"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; import { showImageCropperDialog } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; @@ -12,6 +18,7 @@ import type { HomeAssistant } from "../types"; import "./ha-button"; import "./ha-circular-progress"; import "./ha-file-upload"; +import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog"; @customElement("ha-picture-upload") export class HaPictureUpload extends LitElement { @@ -29,6 +36,9 @@ export class HaPictureUpload extends LitElement { @property({ type: Boolean }) public crop = false; + @property({ type: Boolean, attribute: "select-media" }) public selectMedia = + false; + @property({ attribute: false }) public cropOptions?: CropOptions; @property({ type: Boolean }) public original = false; @@ -39,13 +49,31 @@ export class HaPictureUpload extends LitElement { public render(): TemplateResult { if (!this.value) { + const secondary = + this.secondary || + (this.selectMedia + ? html`${this.hass.localize( + "ui.components.picture-upload.secondary", + { + select_media: html``, + } + )}` + : undefined); + return html` { - this._uploadFile(croppedFile); + if (mediaId && croppedFile === file) { + this.value = generateImageThumbnailUrl( + mediaId, + this.size, + this.original + ); + fireEvent(this, "change"); + } else { + this._uploadFile(croppedFile); + } }, }); } @@ -141,6 +178,51 @@ export class HaPictureUpload extends LitElement { } } + private _chooseMedia = () => { + showMediaBrowserDialog(this, { + action: "pick", + entityId: "browser", + navigateIds: [ + { media_content_id: undefined, media_content_type: undefined }, + { + media_content_id: MEDIA_PREFIX, + media_content_type: "app", + }, + ], + minimumNavigateLevel: 2, + mediaPickedCallback: async (pickedMedia: MediaPickedEvent) => { + const mediaId = getIdFromUrl(pickedMedia.item.media_content_id); + if (mediaId) { + if (this.crop) { + const url = generateImageThumbnailUrl(mediaId, undefined, true); + let data; + try { + const response = await fetch(url); + data = await response.blob(); + } catch (err: any) { + showAlertDialog(this, { + text: err.toString(), + }); + return; + } + const metadata = { + type: pickedMedia.item.media_content_type, + }; + const file = new File([data], pickedMedia.item.title, metadata); + this._cropFile(file, mediaId); + } else { + this.value = generateImageThumbnailUrl( + mediaId, + this.size, + this.original + ); + fireEvent(this, "change"); + } + } + }, + }); + }; + static get styles() { return [ haStyle, diff --git a/src/components/ha-selector/ha-selector-image.ts b/src/components/ha-selector/ha-selector-image.ts index 382281e3774a..5e01b04c85f7 100644 --- a/src/components/ha-selector/ha-selector-image.ts +++ b/src/components/ha-selector/ha-selector-image.ts @@ -96,6 +96,7 @@ export class HaImageSelector extends LitElement { .value=${this.value?.startsWith(URL_PREFIX) ? this.value : null} .original=${this.selector.image?.original} .cropOptions=${this.selector.image?.crop} + select-media @change=${this._pictureChanged} > `} diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 93aa4d39d08d..ea657601929d 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -85,7 +85,7 @@ class DialogMediaPlayerBrowse extends LitElement { @opened=${this._dialogOpened} > - ${this._navigateIds.length > 1 + ${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1) ? html` void; navigateIds?: MediaPlayerItemId[]; + minimumNavigateLevel?: number; } export const showMediaBrowserDialog = ( diff --git a/src/data/image_upload.ts b/src/data/image_upload.ts index 504f7fb06031..dbf591a62ec4 100644 --- a/src/data/image_upload.ts +++ b/src/data/image_upload.ts @@ -9,7 +9,7 @@ interface Image { } export const URL_PREFIX = "/api/image/serve/"; -export const MEDIA_PREFIX = "media-source://image_upload/"; +export const MEDIA_PREFIX = "media-source://image_upload"; export interface ImageMutableParams { name: string; @@ -24,7 +24,7 @@ export const getIdFromUrl = (url: string): string | undefined => { id = id.substring(0, idx); } } else if (url.startsWith(MEDIA_PREFIX)) { - id = url.substring(MEDIA_PREFIX.length); + id = url.substring(MEDIA_PREFIX.length + 1); } return id; }; diff --git a/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts b/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts index 8084fc421af6..57d6717cf07a 100644 --- a/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts +++ b/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts @@ -3,7 +3,7 @@ import Cropper from "cropperjs"; // @ts-ignore import cropperCss from "cropperjs/dist/cropper.css"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; -import { css, html, LitElement, unsafeCSS } from "lit"; +import { css, html, nothing, LitElement, unsafeCSS } from "lit"; import { customElement, property, state, query } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import "../../components/ha-dialog"; @@ -23,6 +23,8 @@ export class HaImagecropperDialog extends LitElement { private _cropper?: Cropper; + @state() private _isTargetAspectRatio?: boolean; + public showDialog(params: HaImageCropperDialogParams): void { this._params = params; this._open = true; @@ -33,6 +35,7 @@ export class HaImagecropperDialog extends LitElement { this._params = undefined; this._cropper?.destroy(); this._cropper = undefined; + this._isTargetAspectRatio = false; } protected updated(changedProperties: PropertyValues) { @@ -47,6 +50,7 @@ export class HaImagecropperDialog extends LitElement { dragMode: "move", minCropBoxWidth: 50, ready: () => { + this._isTargetAspectRatio = this._checkMatchAspectRatio(); URL.revokeObjectURL(this._image!.src); }, }); @@ -55,6 +59,25 @@ export class HaImagecropperDialog extends LitElement { } } + private _checkMatchAspectRatio(): boolean { + const targetRatio = this._params?.options.aspectRatio; + if (!targetRatio) { + return true; + } + const imageData = this._cropper!.getImageData(); + if (imageData.aspectRatio === targetRatio) { + return true; + } + + // If the image is not exactly the aspect ratio see if it is within a pixel. + if (imageData.naturalWidth > imageData.naturalHeight) { + const targetHeight = imageData.naturalWidth / targetRatio; + return Math.abs(targetHeight - imageData.naturalHeight) <= 1; + } + const targetWidth = imageData.naturalHeight * targetRatio; + return Math.abs(targetWidth - imageData.naturalWidth) <= 1; + } + protected render(): TemplateResult { return html` ${this.hass.localize("ui.common.cancel")} + ${this._isTargetAspectRatio + ? html` + ${this.hass.localize("ui.dialogs.image_cropper.use_original")} + ` + : nothing} + ${this.hass.localize("ui.dialogs.image_cropper.crop")} @@ -95,6 +124,11 @@ export class HaImagecropperDialog extends LitElement { ); } + private _useOriginal() { + this._params!.croppedCallback(this._params!.file); + this.closeDialog(); + } + static get styles(): CSSResultGroup { return [ haStyleDialog, diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index d8375babc442..59dcedc6f272 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -140,6 +140,7 @@ class DialogAreaDetail extends LitElement { .hass=${this.hass} .value=${this._picture} crop + select-media .cropOptions=${cropOptions} @change=${this._pictureChanged} > diff --git a/src/panels/config/person/dialog-person-detail.ts b/src/panels/config/person/dialog-person-detail.ts index 8a1c3d4ab44b..90dc3eb8dda1 100644 --- a/src/panels/config/person/dialog-person-detail.ts +++ b/src/panels/config/person/dialog-person-detail.ts @@ -153,6 +153,7 @@ class DialogPersonDetail extends LitElement implements HassDialog { .hass=${this.hass} .value=${this._picture} crop + select-media .cropOptions=${cropOptions} @change=${this._pictureChanged} > diff --git a/src/translations/en.json b/src/translations/en.json index adbd83b5d6fd..987bc3e4972f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -757,7 +757,9 @@ "change_picture": "Change picture", "current_image_alt": "Current picture", "supported_formats": "Supports JPEG, PNG, or GIF image.", - "unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image." + "unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image.", + "secondary": "Drop your file here or {select_media}", + "select_media": "select from media" }, "color-picker": { "default": "default", @@ -1226,7 +1228,8 @@ }, "image_cropper": { "crop": "Crop", - "crop_image": "Picture to crop" + "crop_image": "Picture to crop", + "use_original": "Use original" }, "date-picker": { "today": "Today",