diff --git a/src/packages/media/media/components/input-image-cropper/crop-change.event.ts b/src/packages/media/media/components/input-image-cropper/crop-change.event.ts new file mode 100644 index 0000000000..0c9b076fba --- /dev/null +++ b/src/packages/media/media/components/input-image-cropper/crop-change.event.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/packages/media/media/components/input-image-cropper/focalpoint-change.event.ts b/src/packages/media/media/components/input-image-cropper/focalpoint-change.event.ts new file mode 100644 index 0000000000..ba38c5e87f --- /dev/null +++ b/src/packages/media/media/components/input-image-cropper/focalpoint-change.event.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index c330b50c43..3ca9812849 100644 --- a/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -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}> `; } @@ -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}>
${this.renderActions()}
`; @@ -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 { diff --git a/src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts b/src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts index 5aa6150de5..a2f4fa2ef4 100644 --- a/src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts +++ b/src/packages/media/media/components/input-image-cropper/image-cropper-focus-setter.element.ts @@ -1,46 +1,52 @@ -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 }; // TODO: [LK] Temporary fix; to be reviewed. @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 | Map): 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 | Map): void { super.update(changedProperties); @@ -62,8 +68,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 = () => { @@ -79,73 +84,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); - }; + #setFocalPointStyle(left: number, top: number) { + if (!this.focalPointElement) return; - #onEndDrag = (event: MouseEvent) => { - event.preventDefault(); - window.removeEventListener('mousemove', this.#onDrag); - }; + this.focalPointElement.style.left = `calc(${left * 100}% - ${this.#DOT_RADIUS}px)`; + this.focalPointElement.style.top = `calc(${top * 100}% - ${this.#DOT_RADIUS}px)`; + } - #onDrag = (event: MouseEvent) => { - event.preventDefault(); - this.#onSetFocalPoint(event); - }; + #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; + } - #onSetFocalPoint(event: MouseEvent) { + #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(); - if (this.hideFocalPoint) return; + event.stopPropagation(); - if (!this.focalPointElement || !this.imageElement) return; + this._isDraggingGridHandle = true; - const image = this.imageElement.getBoundingClientRect(); + 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; - const x = clamp(event.clientX - image.left, 0, image.width); - const y = clamp(event.clientY - image.top, 0, image.height); + this.coords.x = x; + this.coords.y = y; - const left = clamp(x / image.width, 0, 1); - const top = clamp(y / image.height, 0, 1); + this.#setFocalPoint(x, y, width, height); + }, + onStop: () => (this._isDraggingGridHandle = false), + initialEvent: event, + }); + } - this.focalPointElement.style.left = `calc(${left * 100}% - ${this.#DOT_RADIUS}px)`; - this.focalPointElement.style.top = `calc(${top * 100}% - ${this.#DOT_RADIUS}px)`; + #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); + } - this.dispatchEvent( - new CustomEvent('change', { - detail: { left, top }, - bubbles: false, - composed: false, - }), - ); + 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` -
- nothing} src=${this.src} alt="" /> -
+
+ nothing} src=${this.src} alt="" /> + +
`; } @@ -162,12 +237,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; @@ -179,11 +258,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; diff --git a/src/packages/media/media/components/input-image-cropper/image-cropper.element.ts b/src/packages/media/media/components/input-image-cropper/image-cropper.element.ts index 1f838f82bd..6f99d7bd40 100644 --- a/src/packages/media/media/components/input-image-cropper/image-cropper.element.ts +++ b/src/packages/media/media/components/input-image-cropper/image-cropper.element.ts @@ -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"> + step="0.001"> +
- - - + + +
`; } @@ -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 */ diff --git a/src/packages/media/media/components/input-image-cropper/index.ts b/src/packages/media/media/components/input-image-cropper/index.ts index 8e082a62c0..558f325be0 100644 --- a/src/packages/media/media/components/input-image-cropper/index.ts +++ b/src/packages/media/media/components/input-image-cropper/index.ts @@ -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';