Adjust Image Cropper handle (focal point) (#1021)

* Adjust Image Cropper focal point to be consistent with Color Area handle

* Make focus point focusable via keyboard

* Update import of utils from uui

* Make focal point accessible via keyboard

* Move logic to function

* Reset coords

* Remove reset coords

* Reset coords if focal point is updated to center

* Format document

* Set focal point property instead

* Formatting

* Fix warnings

* Width and height

* Remove added outline so we can see when focal point has focus via keyboard

* Reset coords

* Cleanup

* Update import

* Cleanup

* Use UmbChangeEvent

* Adjust elements

* Import drag for uui

* Cleanup styling

* Cleanup

* Remove unnecessary events

* Container is HTMLElement

* Check focal point updated

* Fix conflicts after merge

* Null check

* Update correct element types

* Revert to custom event

* Cleanup console

* Don't hide overflow, so focal point is not cut off on edges on image

* Add margin top at actions so focal point doesn't overlap buttons

* Top margin at actions

* Crosshair cursor when focal point is not hidden

* Cancel update focal point

* Add move cursor to viewport image

* Use crop label

* Add missing label

* Disable draggable of image as in old backoffice

* Fix previous merge conflicts

* Update src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>

* Update src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>

* Update src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>

* Update src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>

* Remove disconnectedCallback

* Ignore in the debug element

* Add custom event for focal point change

* Import type

* Replace with UmbImageCropChangeEvent

* Use UmbFocalPointModel

* Move set focal pont style to setter

* Update custom event name

* Localize focal point label

* Fix import of UmbFocalPointModel type

* Localize buttons

* Update labels

* Combine imports

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Bjarne Fyrstenborg
2024-10-16 14:13:57 +02:00
committed by GitHub
parent 6c6a525619
commit c53cb49496
6 changed files with 209 additions and 90 deletions

View File

@@ -0,0 +1,8 @@
export class UmbImageCropChangeEvent extends Event {
public static readonly TYPE = 'imagecrop-change';
public constructor(args?: EventInit) {
// mimics the native change event
super(UmbImageCropChangeEvent.TYPE, { bubbles: false, composed: false, cancelable: false, ...args });
}
}

View File

@@ -0,0 +1,13 @@
import type { UmbFocalPointModel } from '../../types.js';
export class UmbFocalPointChangeEvent extends Event {
public static readonly TYPE = 'focalpoint-change';
public focalPoint: UmbFocalPointModel;
public constructor(focalPoint: UmbFocalPointModel, args?: EventInit) {
// mimics the native change event
super(UmbFocalPointChangeEvent.TYPE, { bubbles: false, composed: false, cancelable: false, ...args });
this.focalPoint = focalPoint;
}
}

View File

@@ -7,6 +7,8 @@ import type {
UmbImageCropperCrops,
UmbImageCropperFocalPoint,
UmbImageCropperPropertyEditorValue,
UmbFocalPointChangeEvent,
UmbImageCropChangeEvent,
} from './index.js';
import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
@@ -87,7 +89,7 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
this.currentCrop = { ...this.crops[index] };
}
#onCropChange = (event: CustomEvent) => {
#onCropChange = (event: UmbImageCropChangeEvent) => {
const target = event.target as UmbImageCropperElement;
const value = target.value;
@@ -102,8 +104,8 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
this.#updateValue();
};
#onFocalPointChange = (event: CustomEvent) => {
this.focalPoint = event.detail;
#onFocalPointChange = (event: UmbFocalPointChangeEvent) => {
this.focalPoint = { top: event.focalPoint.top, left: event.focalPoint.left };
this.#updateValue();
};
@@ -137,7 +139,7 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
.src=${this.source}
.value=${this.currentCrop}
?hideFocalPoint=${this.hideFocalPoint}
@change=${this.#onCropChange}>
@imagecrop-change=${this.#onCropChange}>
</umb-image-cropper>
`;
}
@@ -147,7 +149,7 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
.focalPoint=${this.focalPoint}
.src=${this.source}
?hideFocalPoint=${this.hideFocalPoint}
@change=${this.#onFocalPointChange}>
@focalpoint-change=${this.#onFocalPointChange}>
</umb-image-cropper-focus-setter>
<div id="actions">${this.renderActions()}</div>
`;
@@ -200,6 +202,7 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
#actions {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
}
umb-image-cropper-focus-setter {

View File

@@ -1,45 +1,51 @@
import type { UmbImageCropperFocalPoint } from './index.js';
import { type UmbImageCropperFocalPoint, UmbFocalPointChangeEvent } from './index.js';
import type { UmbFocalPointModel } from '../../types.js';
import { drag } from '@umbraco-cms/backoffice/external/uui';
import { clamp } from '@umbraco-cms/backoffice/utils';
import { css, customElement, html, nothing, property, query, LitElement } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, classMap, ifDefined, html, nothing, state, property, query } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-image-cropper-focus-setter')
export class UmbImageCropperFocusSetterElement extends LitElement {
export class UmbImageCropperFocusSetterElement extends UmbLitElement {
@query('#image')
imageElement!: HTMLImageElement;
@query('#wrapper')
wrapperElement?: HTMLImageElement;
wrapperElement?: HTMLElement;
@query('#focal-point')
focalPointElement!: HTMLImageElement;
focalPointElement!: HTMLElement;
@state()
private _isDraggingGridHandle = false;
@state()
private coords = { x: 0, y: 0 };
@property({ attribute: false })
focalPoint: UmbImageCropperFocalPoint = { left: 0.5, top: 0.5 };
set focalPoint(value) {
this.#focalPoint = value;
this.#setFocalPointStyle(this.#focalPoint.left, this.#focalPoint.top);
this.#onFocalPointUpdated();
}
get focalPoint() {
return this.#focalPoint;
}
#focalPoint: UmbImageCropperFocalPoint = { left: 0.5, top: 0.5 };
@property({ type: Boolean })
hideFocalPoint = false;
@property({ type: Boolean, reflect: true })
disabled = false;
@property({ type: String })
src?: string;
#DOT_RADIUS = 6 as const;
override disconnectedCallback() {
super.disconnectedCallback();
this.#removeEventListeners();
}
protected override updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.updated(_changedProperties);
if (this.hideFocalPoint) return;
if (_changedProperties.has('focalPoint') && this.focalPointElement) {
this.focalPointElement.style.left = `calc(${this.focalPoint.left * 100}% - ${this.#DOT_RADIUS}px)`;
this.focalPointElement.style.top = `calc(${this.focalPoint.top * 100}% - ${this.#DOT_RADIUS}px)`;
}
}
#DOT_RADIUS = 8 as const;
protected override update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.update(changedProperties);
@@ -61,8 +67,7 @@ export class UmbImageCropperFocusSetterElement extends LitElement {
await this.updateComplete; // Wait for the @query to be resolved
if (!this.hideFocalPoint) {
this.focalPointElement.style.left = `calc(${this.focalPoint.left * 100}% - ${this.#DOT_RADIUS}px)`;
this.focalPointElement.style.top = `calc(${this.focalPoint.top * 100}% - ${this.#DOT_RADIUS}px)`;
this.#setFocalPointStyle(this.focalPoint.left, this.focalPoint.top);
}
this.imageElement.onload = () => {
@@ -78,73 +83,143 @@ export class UmbImageCropperFocusSetterElement extends LitElement {
this.imageElement.style.height = '100%';
}
this.#resetCoords();
this.imageElement.style.aspectRatio = `${imageAspectRatio}`;
this.wrapperElement.style.aspectRatio = `${imageAspectRatio}`;
};
}
if (!this.hideFocalPoint) {
this.#addEventListeners();
#onFocalPointUpdated() {
if (this.#isCentered(this.#focalPoint)) {
this.#resetCoords();
}
}
async #addEventListeners() {
await this.updateComplete; // Wait for the @query to be resolved
this.imageElement?.addEventListener('mousedown', this.#onStartDrag);
window.addEventListener('mouseup', this.#onEndDrag);
#coordsToFactor(x: number, y: number) {
const top = (y / 100) / y * 50;
const left = (x / 100) / x * 50;
return { top, left };
}
#removeEventListeners() {
this.imageElement?.removeEventListener('mousedown', this.#onStartDrag);
window.removeEventListener('mouseup', this.#onEndDrag);
#setFocalPoint(x: number, y: number, width: number, height: number) {
const left = clamp((x / width), 0, 1);
const top = clamp((y / height), 0, 1);
this.#coordsToFactor(x, y);
const focalPoint = { left, top } as UmbFocalPointModel;
console.log("setFocalPoint", focalPoint)
this.dispatchEvent(new UmbFocalPointChangeEvent(focalPoint));
}
#onStartDrag = (event: MouseEvent) => {
event.preventDefault();
window.addEventListener('mousemove', this.#onDrag);
};
#onEndDrag = (event: MouseEvent) => {
event.preventDefault();
window.removeEventListener('mousemove', this.#onDrag);
};
#onDrag = (event: MouseEvent) => {
event.preventDefault();
this.#onSetFocalPoint(event);
};
#onSetFocalPoint(event: MouseEvent) {
event.preventDefault();
if (this.hideFocalPoint) return;
if (!this.focalPointElement || !this.imageElement) return;
const image = this.imageElement.getBoundingClientRect();
const x = clamp(event.clientX - image.left, 0, image.width);
const y = clamp(event.clientY - image.top, 0, image.height);
const left = clamp(x / image.width, 0, 1);
const top = clamp(y / image.height, 0, 1);
#setFocalPointStyle(left: number, top: number) {
if (!this.focalPointElement) return;
this.focalPointElement.style.left = `calc(${left * 100}% - ${this.#DOT_RADIUS}px)`;
this.focalPointElement.style.top = `calc(${top * 100}% - ${this.#DOT_RADIUS}px)`;
}
this.dispatchEvent(
new CustomEvent('change', {
detail: { left, top },
bubbles: false,
composed: false,
}),
);
#isCentered(focalPoint: UmbImageCropperFocalPoint) {
if (!this.focalPoint) return;
return focalPoint.left === 0.5 && focalPoint.top === 0.5;
}
#resetCoords() {
if (!this.imageElement) return;
// Init x and y coords from half of rendered image size, which is equavalient to focal point { left: 0.5, top: 0.5 }.
this.coords.x = this.imageElement?.clientWidth / 2;
this.coords.y = this.imageElement.clientHeight / 2;
}
#handleGridDrag(event: PointerEvent) {
if (this.disabled || this.hideFocalPoint) return;
const grid = this.wrapperElement;
const handle = this.focalPointElement;
if (!grid) return;
const { width, height } = grid.getBoundingClientRect();
handle?.focus();
event.preventDefault();
event.stopPropagation();
this._isDraggingGridHandle = true;
drag(grid, {
onMove: (x, y) => {
// check if coordinates are not NaN (can happen when dragging outside of the grid)
if (isNaN(x) || isNaN(y)) return;
this.coords.x = x;
this.coords.y = y;
this.#setFocalPoint(x, y, width, height);
},
onStop: () => (this._isDraggingGridHandle = false),
initialEvent: event,
});
}
#handleGridKeyDown(event: KeyboardEvent) {
if (this.disabled || this.hideFocalPoint) return;
const increment = event.shiftKey ? 1 : 10;
const grid = this.wrapperElement;
if (!grid) return;
const { width, height } = grid.getBoundingClientRect();
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.coords.x = clamp(this.coords.x - increment, 0, width);
this.#setFocalPoint(this.coords.x, this.coords.y, width, height);
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.coords.x = clamp(this.coords.x + increment, 0, width);
this.#setFocalPoint(this.coords.x, this.coords.y, width, height);
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.coords.y = clamp(this.coords.y - increment, 0, height);
this.#setFocalPoint(this.coords.x, this.coords.y, width, height);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.coords.y = clamp(this.coords.y + increment, 0, height);
this.#setFocalPoint(this.coords.x, this.coords.y, width, height);
}
}
override render() {
if (!this.src) return nothing;
return html`
<div id="wrapper">
<img id="image" @click=${this.#onSetFocalPoint} @keydown=${() => nothing} src=${this.src} alt="" />
<div id="focal-point" class=${this.hideFocalPoint ? 'hidden' : ''}></div>
<div id="wrapper"
@mousedown=${this.#handleGridDrag}
@touchstart=${this.#handleGridDrag}>
<img id="image" @keydown=${() => nothing} src=${this.src} alt="" />
<span id="focal-point"
class=${classMap({
'focal-point--dragging': this._isDraggingGridHandle,
'hidden': this.hideFocalPoint
})}
tabindex=${ifDefined(this.disabled ? undefined : '0')}
aria-label="${this.localize.term('general_focalPoint')}"
@keydown=${this.#handleGridKeyDown}>
</span>
</div>
`;
}
@@ -161,12 +236,16 @@ export class UmbImageCropperFocusSetterElement extends LitElement {
}
/* Wrapper is used to make the focal point position responsive to the image size */
#wrapper {
overflow: hidden;
position: relative;
display: flex;
margin: auto;
max-width: 100%;
max-height: 100%;
box-sizing: border-box;
forced-color-adjust: none;
}
:host(:not([hidefocalpoint])) #wrapper {
cursor: crosshair;
}
#image {
margin: auto;
@@ -178,11 +257,18 @@ export class UmbImageCropperFocusSetterElement extends LitElement {
position: absolute;
width: calc(2 * var(--dot-radius));
height: calc(2 * var(--dot-radius));
outline: 3px solid black;
top: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
border: solid 2px white;
border-radius: 50%;
pointer-events: none;
background-color: white;
background-color: var(--uui-palette-spanish-pink-light);
transition: 150ms transform;
box-sizing: inherit;
}
.focal-point--dragging {
cursor: none;
transform: scale(1.5);
}
#focal-point.hidden {
display: none;

View File

@@ -1,10 +1,11 @@
import type { UmbImageCropperCrop, UmbImageCropperFocalPoint } from './index.js';
import { UmbImageCropChangeEvent, type UmbImageCropperCrop, type UmbImageCropperFocalPoint } from './index.js';
import { calculateExtrapolatedValue, clamp, inverseLerp, lerp } from '@umbraco-cms/backoffice/utils';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import { customElement, property, query, state, LitElement, css, html } from '@umbraco-cms/backoffice/external/lit';
import { customElement, property, query, state, css, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-image-cropper')
export class UmbImageCropperElement extends LitElement {
export class UmbImageCropperElement extends UmbLitElement {
@query('#viewport') viewportElement!: HTMLElement;
@query('#mask') maskElement!: HTMLElement;
@query('#image') imageElement!: HTMLImageElement;
@@ -261,19 +262,19 @@ export class UmbImageCropperElement extends LitElement {
coordinates: { x1, x2, y1, y2 },
};
this.dispatchEvent(new CustomEvent('change'));
this.dispatchEvent(new UmbImageCropChangeEvent());
}
#onCancel() {
//TODO: How should we handle canceling the crop?
this.dispatchEvent(new CustomEvent('change'));
this.dispatchEvent(new UmbImageCropChangeEvent());
}
#onReset() {
if (!this.value) return;
delete this.value.coordinates;
this.dispatchEvent(new CustomEvent('change'));
this.dispatchEvent(new UmbImageCropChangeEvent());
}
#onSliderUpdate(event: InputEvent) {
@@ -330,11 +331,12 @@ export class UmbImageCropperElement extends LitElement {
min="0"
max="1"
value="0"
step="0.001"></uui-slider>
step="0.001">
</uui-slider>
<div id="actions">
<uui-button @click=${this.#onReset} label="Reset"></uui-button>
<uui-button look="secondary" @click=${this.#onCancel} label="Cancel"></uui-button>
<uui-button look="primary" color="positive" @click=${this.#onSave} label="Save"></uui-button>
<uui-button @click=${this.#onReset} label="${this.localize.term('general_reset')}"></uui-button>
<uui-button look="secondary" @click=${this.#onCancel} label="${this.localize.term('general_cancel')}"></uui-button>
<uui-button look="primary" color="positive" @click=${this.#onSave} label="${this.localize.term('buttons_save')}"></uui-button>
</div>
`;
}
@@ -364,6 +366,7 @@ export class UmbImageCropperElement extends LitElement {
display: flex;
justify-content: flex-end;
gap: var(--uui-size-space-1);
margin-top: 0.5rem;
}
#mask {
@@ -379,6 +382,10 @@ export class UmbImageCropperElement extends LitElement {
user-select: none;
}
#viewport #image {
cursor: move;
}
#slider {
width: 100%;
height: 0px; /* TODO: FIX - This is needed to prevent the slider from taking up more space than needed */

View File

@@ -1,2 +1,4 @@
export * from './input-image-cropper.element.js';
export * from './crop-change.event.js';
export * from './focalpoint-change.event.js';
export * from './types.js';