Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 1dfbca53b4 Migrate the image cropper dialog to cropperjs 2
Cropper.js 2 is a rewrite built on shadow-DOM custom elements: the v1
class API (getData, getImageData, getCroppedCanvas, the ready and crop
callbacks) and the cropper.css stylesheet no longer exist, so the plain
Renovate bump (#55562) fails type checking and every rspack build.

Render the v2 elements directly from the dialog template instead of
wrapping an <img>. The canvas has no intrinsic size, so decode the file
first and give the canvas the image aspect ratio; the image then fills
it exactly. Initialise after the dialog has finished its scale-in
animation, because the elements measure themselves with
getBoundingClientRect, and refit the image and selection when the
canvas is resized.

Keep the v1 behaviour the callers rely on: the selection cannot leave
the image or the visible canvas, the image cannot be moved or zoomed out
from under it (viewMode 1), resizing stops at 50px, the rounded preview
is CSS only so the exported file stays rectangular, and the export is
rendered at the source resolution. Dirty tracking compares the crop
rectangle in source image pixels, like getData() did in v1.
2026-11-02 21:32:33 +01:00
3 changed files with 449 additions and 65 deletions

No files matched your search

+1 -1
View File
@@ -89,7 +89,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "1.7.0",
"cropperjs": "2.3.1",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
+107 -5
View File
@@ -328,8 +328,8 @@ importers:
specifier: 3.51.0
version: 3.51.0
cropperjs:
specifier: 1.7.0
version: 1.7.0
specifier: 2.3.1
version: 2.3.1
culori:
specifier: 4.0.2
version: 4.0.2
@@ -1799,6 +1799,39 @@ packages:
peerDependencies:
commander: ~15.0.0
'@cropper/[email protected]':
resolution: {integrity: sha512-UH3VMyjn58PdwYkPiTHuvRUjGzmyKr6E2pYWxTxN/04wnjgYLA77B5u5c1HhV6vD1ZKSvxJ/i9TkiySR9P1D6A==}
'@cropper/[email protected]':
resolution: {integrity: sha512-Vz2heRwr2nd2rZ161vKyE4JwYV3eSodGCGGZq39TR0LICGOOeggLq0R664Af/eFB9PU8+tfJ42lWLoUdxQlVJQ==}
'@cropper/[email protected]':
resolution: {integrity: sha512-6LDaUDxH2xdA7biTcqnihPQVHjlT4bXysA403s9rU4jo5f4pPnFHtNbB+FQTYKNz5EKun52WgVtR+6oWbmbxlg==}
'@cropper/[email protected]':
resolution: {integrity: sha512-ygLw89PWu9k8SuKW7rjRBpD6ZfmtVr5ayWux9YKHU7PxX0SeL5kbCD+PpOxQB1ycW4UcyH0tHt77EWnsdSoOHw==}
'@cropper/[email protected]':
resolution: {integrity: sha512-rPE7OX/BPTw66+DjpN5aOuPb1xSAjQMgAJZXdRgmeJnn0YcDDhfGQm8VwcL8/PMLxtWFcKEM+aUiezbO01j4fg==}
'@cropper/[email protected]':
resolution: {integrity: sha512-b8sabaFbHeQYY459uItDIeDY0IisZBO/wF+SYhKvAgI8TExj/f2mNcBZXjPHYCkWel2+1BSUTTNZxy+tR8sDEA==}
'@cropper/[email protected]':
resolution: {integrity: sha512-X5ceZsy7iBxuIRhsL05H9+cPYzxZuvM7/9j5irXIbNsDiNwTedxIHdjhEhPbUYa+FaNn9wsKId24AEyH43JTGw==}
'@cropper/[email protected]':
resolution: {integrity: sha512-EdrYDbnkj0IOl/kyWKzzvcDRobr7Tfzho7FkVu8mpSk1ILDHmVf8pfZrYVwou8l4znLqp8ZqwuabAezJPfC2Yg==}
'@cropper/[email protected]':
resolution: {integrity: sha512-CgrLOx0qERWlfJK1Ed6ePq7SXcB6gLHtj3mtDVypyzti70fJdmNxrFm5Ucbq8vpp2Yo3tW2L6uDI0IBDoiT17A==}
'@cropper/[email protected]':
resolution: {integrity: sha512-+D5rFlfFlWSL1lp0vPYI59/R6XM2vMnTSX0/B1zKRvmkc0t0N0m9lxVsaDb+9RkHx6PwAkBBNPZSRUeau33jMg==}
'@cropper/[email protected]':
resolution: {integrity: sha512-b5qFTwws/osx0asIGLK1V//hdL08frGvfpi6s+p6zj5XH05ofVcz0Xl+Tot7sUx12pbfhhAlsquQTOJ1IsOoFw==}
'@csstools/[email protected]':
resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
engines: {node: '>=20.19.0'}
@@ -4381,8 +4414,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cropperjs@1.7.0:
resolution: {integrity: sha512-AkGnZxr7xAJBa63ObXq5ZKLkh9c8wisC+Ym8VzIDn3KZ+6qRTbBnOl2w2dFOTMzLP5IcfOpEK9PICDywNXhncg==}
cropperjs@2.3.1:
resolution: {integrity: sha512-bgy+iHRFjsBxBCriXkTuFuHRJGSGZISuqDGhDQG7gLK0F7qOzhH06bdXzGf4b0bZSQ+mApakybiW9xvKnS9dbg==}
[email protected]:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -9264,6 +9297,72 @@ snapshots:
dependencies:
commander: 15.0.0
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/element-canvas': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/element-canvas': 2.3.1
'@cropper/element-image': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/element-canvas': 2.3.1
'@cropper/element-selection': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/element-canvas': 2.3.1
'@cropper/element-image': 2.3.1
'@cropper/element-selection': 2.3.1
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/utils': 2.3.1
'@cropper/[email protected]':
dependencies:
'@cropper/element': 2.3.1
'@cropper/element-canvas': 2.3.1
'@cropper/element-crosshair': 2.3.1
'@cropper/element-grid': 2.3.1
'@cropper/element-handle': 2.3.1
'@cropper/element-image': 2.3.1
'@cropper/element-selection': 2.3.1
'@cropper/element-shade': 2.3.1
'@cropper/element-viewer': 2.3.1
'@cropper/[email protected]': {}
'@csstools/[email protected]': {}
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
@@ -11996,7 +12095,10 @@ snapshots:
[email protected]: {}
cropperjs@1.7.0: {}
cropperjs@2.3.1:
dependencies:
'@cropper/elements': 2.3.1
'@cropper/utils': 2.3.1
[email protected]:
dependencies:
@@ -1,11 +1,19 @@
import type { ContextType } from "@lit/context";
import Cropper from "cropperjs";
// @ts-ignore
import cropperCss from "cropperjs/dist/cropper.css";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import "cropperjs";
import type {
CropperCanvas,
CropperGrid,
CropperHandle,
CropperImage,
CropperSelection,
CropperShade,
} from "cropperjs";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
@@ -16,22 +24,74 @@ import { haStyleDialog } from "../../resources/styles";
import { DialogMixin } from "../dialog-mixin";
import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const MIN_SELECTION_SIZE = 50;
const INITIAL_COVERAGE = 0.8;
const EPSILON = 0.05;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const contains = (outer: Rect, inner: Rect): boolean =>
inner.x >= outer.x - EPSILON &&
inner.y >= outer.y - EPSILON &&
inner.x + inner.width <= outer.x + outer.width + EPSILON &&
inner.y + inner.height <= outer.y + outer.height + EPSILON;
const intersect = (a: Rect, b: Rect): Rect => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return {
x,
y,
width: Math.min(a.x + a.width, b.x + b.width) - x,
height: Math.min(a.y + a.height, b.y + b.height) - y,
};
};
const scaleRect = ({ x, y, width, height }: Rect, ratio: number): Rect => ({
x: x * ratio,
y: y * ratio,
width: width * ratio,
height: height * ratio,
});
@customElement("image-cropper-dialog")
export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>()(
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
DialogMixin<HaImageCropperDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@query("img") private _image!: HTMLImageElement;
@query("cropper-image") private _image?: CropperImage;
private _cropper?: Cropper;
@query("cropper-selection") private _selection?: CropperSelection;
@state() private _imageSize?: { width: number; height: number };
@state() private _shown = false;
@state() private _cropReady = false;
@state() private _isTargetAspectRatio?: boolean;
private _imageUrl?: string;
private _resizeObserver?: ResizeObserver;
private _canvasWidth?: number;
private _refitting = false;
private _cropped = false;
private _closing = false;
@@ -41,51 +101,176 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
if (!this._cropped) {
this.params?.cancelCallback?.();
}
this._cropper?.destroy();
this._cropper = undefined;
if (this._imageUrl) {
URL.revokeObjectURL(this._imageUrl);
this._imageUrl = undefined;
}
this._resizeObserver?.disconnect();
this._resizeObserver = undefined;
}
protected firstUpdated() {
if (!this.params) {
return;
}
this._image.src = URL.createObjectURL(this.params.file);
this._cropper = new Cropper(this._image, {
aspectRatio: this.params.options.aspectRatio,
viewMode: 1,
dragMode: "move",
minCropBoxWidth: 50,
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
this._cropReady = true;
},
crop: () => {
if (this._cropReady) {
this._updateDirtyState(this._cropper!.getData());
}
},
});
this._imageUrl = URL.createObjectURL(this.params.file);
this._loadImageSize(this._imageUrl);
}
private _checkMatchAspectRatio(): boolean {
private async _loadImageSize(url: string) {
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
return;
}
if (!this.isConnected) {
return;
}
this._imageSize = {
width: image.naturalWidth,
height: image.naturalHeight,
};
this._isTargetAspectRatio = this._checkMatchAspectRatio(
image.naturalWidth,
image.naturalHeight
);
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
!this._cropReady &&
(changedProps.has("_imageSize") || changedProps.has("_shown")) &&
this._image
) {
this._initCropper(this._image);
}
}
private async _initCropper(image: CropperImage) {
try {
await image.$ready();
} catch {
return;
}
if (!this.isConnected || image !== this._image) {
return;
}
const selection = this._selection!;
if (!selection.width) {
await new Promise<void>((resolve) => {
selection.addEventListener("change", () => resolve(), { once: true });
});
if (!this.isConnected || image !== this._image) {
return;
}
}
this._initDirtyTracking(
{ type: "deep" },
this._cropData(this._selectionRect(), this._imageRect())
);
this._cropReady = true;
this._resizeObserver = new ResizeObserver((entries) => {
this._refit(entries[0].contentRect);
});
this._resizeObserver.observe(image.parentElement!);
}
private _selectionRect(): Rect {
const { x, y, width, height } = this._selection!;
return { x, y, width, height };
}
private _canvasRect(): Rect {
const { width, height } =
this._image!.parentElement!.getBoundingClientRect();
return { x: 0, y: 0, width, height };
}
private _imageRect(): Rect {
const canvasRect = this._image!.parentElement!.getBoundingClientRect();
const imageRect = this._image!.getBoundingClientRect();
return {
x: imageRect.x - canvasRect.x,
y: imageRect.y - canvasRect.y,
width: imageRect.width,
height: imageRect.height,
};
}
private _cropData(selection: Rect, image: Rect): Rect {
const scale = image.width / this._imageSize!.width;
return {
x: Math.round((selection.x - image.x) / scale),
y: Math.round((selection.y - image.y) / scale),
width: Math.round(selection.width / scale),
height: Math.round(selection.height / scale),
};
}
private _refit(canvas: { width: number; height: number }) {
const previous = this._canvasWidth;
this._canvasWidth = canvas.width;
if (!previous || !canvas.width || previous === canvas.width) {
return;
}
const current = this._selectionRect();
const ratio = Math.min(
canvas.width / previous,
canvas.height / current.height
);
const image = scaleRect(this._imageRect(), ratio);
const selection = scaleRect(current, ratio);
const dy =
image.height <= canvas.height
? (canvas.height - image.height) / 2 - image.y
: clamp(
0,
-selection.y,
canvas.height - selection.y - selection.height
);
image.y += dy;
selection.y += dy;
const { width, height } = this._imageSize!;
this._refitting = true;
this._image!.$setTransform(
image.width / width,
0,
0,
image.height / height,
image.x + image.width / 2 - width / 2,
image.y + image.height / 2 - height / 2
);
this._selection!.$change(
selection.x,
selection.y,
selection.width,
selection.height
);
this._refitting = false;
}
private _checkMatchAspectRatio(
naturalWidth: number,
naturalHeight: number
): boolean {
const targetRatio = this.params?.options.aspectRatio;
if (!targetRatio) {
return true;
}
const imageData = this._cropper!.getImageData();
if (imageData.aspectRatio === targetRatio) {
if (naturalWidth / naturalHeight === 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;
if (naturalWidth > naturalHeight) {
const targetHeight = naturalWidth / targetRatio;
return Math.abs(targetHeight - naturalHeight) <= 1;
}
const targetWidth = imageData.naturalHeight * targetRatio;
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
const targetWidth = naturalHeight * targetRatio;
return Math.abs(targetWidth - naturalWidth) <= 1;
}
protected render(): TemplateResult | typeof nothing {
@@ -93,23 +278,65 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
return nothing;
}
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
return html`
<ha-dialog
open
header-title=${this._i18n.localize(
"ui.dialogs.image_cropper.crop_image"
)}
header-title=${label}
.preventScrimClose=${this.isDirtyState}
@wa-hide=${this._handleHide}
@after-show=${this._handleAfterShow}
>
<div
class="container ${classMap({
round: Boolean(this.params.options.round),
})}"
>
<img
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
/>
${
this._imageSize && this._shown
? html`
<cropper-canvas
background
style=${styleMap({
aspectRatio: `${this._imageSize.width} / ${this._imageSize.height}`,
})}
>
<cropper-image
src=${this._imageUrl!}
alt=${label}
scalable
translatable
@change=${this._handleImageChange}
></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection
aspect-ratio=${ifDefined(
this.params.options.aspectRatio || undefined
)}
initial-coverage=${INITIAL_COVERAGE}
precise
movable
resizable
outlined
@change=${this._handleSelectionChange}
>
<cropper-grid role="grid" bordered covered></cropper-grid>
<cropper-handle action="move" plain></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
: nothing
}
</div>
<ha-dialog-footer slot="footer">
<ha-button
@@ -127,9 +354,7 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
appearance="plain"
@click=${this._useOriginal}
>
${this._i18n.localize(
"ui.dialogs.image_cropper.use_original"
)}
${this._i18n.localize("ui.dialogs.image_cropper.use_original")}
</ha-button>
`
: nothing
@@ -146,17 +371,68 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
`;
}
private _handleAfterShow() {
this._shown = true;
}
private _handleHide(ev: Event) {
// Ignore hide events from nested overlays and blocked dismissals.
if (ev.eventPhase !== Event.AT_TARGET || ev.defaultPrevented) {
return;
}
this._closing = true;
}
private _cropImage() {
const canvas = this._cropper?.getCroppedCanvas();
if (!canvas) {
private _handleSelectionChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selection!;
const image = this._imageRect();
const next = ev.detail;
if (
(next.width < MIN_SELECTION_SIZE && next.width < selection.width) ||
!contains(intersect(image, this._canvasRect()), next)
) {
ev.preventDefault();
return;
}
this._updateDirtyState(this._cropData(next, image));
}
private _handleImageChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
if (!contains(ev.detail, selection)) {
ev.preventDefault();
return;
}
this._updateDirtyState(this._cropData(selection, ev.detail));
}
private async _cropImage() {
const selection = this._selection;
if (!selection || !this._cropReady) {
return;
}
const image = this._imageRect();
const rect = this._selectionRect();
const x = clamp(rect.x, image.x, image.x + image.width - rect.width);
const y = clamp(rect.y, image.y, image.y + image.height - rect.height);
if (x !== rect.x || y !== rect.y) {
selection.$change(x, y);
}
const crop = this._cropData(this._selectionRect(), image);
let canvas: HTMLCanvasElement;
try {
canvas = await selection.$toCanvas({
width: crop.width,
beforeDraw: (_context, target) => {
target.height = crop.height;
},
});
} catch {
return;
}
const params = this.params!;
@@ -188,22 +464,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
return [
haStyleDialog,
css`
${unsafeCSS(cropperCss)}
.container {
max-width: 640px;
}
img {
max-width: 100%;
cropper-canvas {
width: 100%;
direction: ltr;
--theme-color: var(--primary-color);
}
.container.round .cropper-view-box,
.container.round .cropper-face {
cropper-handle {
--theme-color: var(--primary-color);
}
.container.round cropper-selection,
.container.round cropper-shade,
.container.round cropper-grid {
border-radius: var(--ha-picture-border-radius);
}
.cropper-line,
.cropper-point,
.cropper-point.point-se::before {
background-color: var(--primary-color);
}
`,
];
}
@@ -212,5 +488,11 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
declare global {
interface HTMLElementTagNameMap {
"image-cropper-dialog": HaImagecropperDialog;
"cropper-canvas": CropperCanvas;
"cropper-image": CropperImage;
"cropper-shade": CropperShade;
"cropper-handle": CropperHandle;
"cropper-selection": CropperSelection;
"cropper-grid": CropperGrid;
}
}