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",