Skip to content

Commit

Permalink
Allow selecting previously uploaded image for picture upload (#23072)
Browse files Browse the repository at this point in the history
  • Loading branch information
karwosts authored Dec 20, 2024
1 parent 3b52d3d commit 65860a3
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 10 deletions.
9 changes: 9 additions & 0 deletions src/components/ha-file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`;
}
}
Expand Down
90 changes: 86 additions & 4 deletions src/components/ha-picture-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ 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";
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 {
Expand All @@ -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;
Expand All @@ -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`<button
class="link"
@click=${this._chooseMedia}
>
${this.hass.localize(
"ui.components.picture-upload.select_media"
)}
</button>`,
}
)}`
: undefined);

return html`
<ha-file-upload
.hass=${this.hass}
.icon=${mdiImagePlus}
.label=${this.label ||
this.hass.localize("ui.components.picture-upload.label")}
.secondary=${this.secondary}
.secondary=${secondary}
.supports=${this.supports ||
this.hass.localize("ui.components.picture-upload.supported_formats")}
.uploading=${this._uploading}
Expand Down Expand Up @@ -93,7 +121,7 @@ export class HaPictureUpload extends LitElement {
this.value = null;
}

private async _cropFile(file: File) {
private async _cropFile(file: File, mediaId?: string) {
if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
showAlertDialog(this, {
text: this.hass.localize(
Expand All @@ -109,7 +137,16 @@ export class HaPictureUpload extends LitElement {
aspectRatio: NaN,
},
croppedCallback: (croppedFile) => {
this._uploadFile(croppedFile);
if (mediaId && croppedFile === file) {
this.value = generateImageThumbnailUrl(
mediaId,
this.size,
this.original
);
fireEvent(this, "change");
} else {
this._uploadFile(croppedFile);
}
},
});
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/components/ha-selector/ha-selector-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
></ha-picture-upload>
`}
Expand Down
2 changes: 1 addition & 1 deletion src/components/media-player/dialog-media-player-browse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DialogMediaPlayerBrowse extends LitElement {
@opened=${this._dialogOpened}
>
<ha-dialog-header show-border slot="heading">
${this._navigateIds.length > 1
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
? html`
<ha-icon-button
slot="navigationIcon"
Expand Down
1 change: 1 addition & 0 deletions src/components/media-player/show-media-browser-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface MediaPlayerBrowseDialogParams {
entityId: string;
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number;
}

export const showMediaBrowserDialog = (
Expand Down
4 changes: 2 additions & 2 deletions src/data/image_upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};
Expand Down
36 changes: 35 additions & 1 deletion src/dialogs/image-cropper-dialog/image-cropper-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -47,6 +50,7 @@ export class HaImagecropperDialog extends LitElement {
dragMode: "move",
minCropBoxWidth: 50,
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
},
});
Expand All @@ -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`<ha-dialog
@closed=${this.closeDialog}
Expand All @@ -72,6 +95,12 @@ export class HaImagecropperDialog extends LitElement {
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</mwc-button>
${this._isTargetAspectRatio
? html`<mwc-button slot="primaryAction" @click=${this._useOriginal}>
${this.hass.localize("ui.dialogs.image_cropper.use_original")}
</mwc-button>`
: nothing}
<mwc-button slot="primaryAction" @click=${this._cropImage}>
${this.hass.localize("ui.dialogs.image_cropper.crop")}
</mwc-button>
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/panels/config/areas/dialog-area-registry-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class DialogAreaDetail extends LitElement {
.hass=${this.hass}
.value=${this._picture}
crop
select-media
.cropOptions=${cropOptions}
@change=${this._pictureChanged}
></ha-picture-upload>
Expand Down
1 change: 1 addition & 0 deletions src/panels/config/person/dialog-person-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
.hass=${this.hass}
.value=${this._picture}
crop
select-media
.cropOptions=${cropOptions}
@change=${this._pictureChanged}
></ha-picture-upload>
Expand Down
7 changes: 5 additions & 2 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 65860a3

Please sign in to comment.