diff --git a/core-web/libs/dotcms-models/src/lib/shared-models.ts b/core-web/libs/dotcms-models/src/lib/shared-models.ts index 2da10347ece1..fe86425a80ee 100644 --- a/core-web/libs/dotcms-models/src/lib/shared-models.ts +++ b/core-web/libs/dotcms-models/src/lib/shared-models.ts @@ -36,7 +36,8 @@ export const enum FeaturedFlags { FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION = 'FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION', FEATURE_FLAG_NEW_BLOCK_EDITOR = 'FEATURE_FLAG_NEW_BLOCK_EDITOR', FEATURE_FLAG_REPORT_ISSUE_ENABLED = 'FEATURE_FLAG_REPORT_ISSUE_ENABLED', - FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2' + FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2', + FEATURE_FLAG_NEW_IMAGE_EDITOR = 'FEATURE_FLAG_NEW_IMAGE_EDITOR' } export const enum DotConfigurationVariables { diff --git a/core-web/libs/edit-content/src/lib/edit-content.shell.component.ts b/core-web/libs/edit-content/src/lib/edit-content.shell.component.ts index b7a7439a27e4..31724d8d76ed 100644 --- a/core-web/libs/edit-content/src/lib/edit-content.shell.component.ts +++ b/core-web/libs/edit-content/src/lib/edit-content.shell.component.ts @@ -2,12 +2,25 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; import { MessageService } from 'primeng/api'; +import { DialogService } from 'primeng/dynamicdialog'; import { ToastModule } from 'primeng/toast'; +import { + AngularImageEditorLauncher, + IMAGE_EDITOR_LAUNCHER +} from './fields/shared/image-editor-launcher'; + @Component({ selector: 'dot-edit-content', imports: [RouterModule, ToastModule], - providers: [MessageService], + providers: [ + MessageService, + // Scope the new Angular image editor to the edit-content shell so it only + // activates here and never leaks into the legacy/web-component path. DialogService + // is required by AngularImageEditorLauncher to open the modal. + DialogService, + { provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher } + ], template: ' ', styleUrls: ['./edit-content.shell.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts index d9d03b3104b0..a9cb5142b444 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts @@ -4,14 +4,12 @@ import { createComponentFactory, createHostFactory, Spectator, - SpectatorHost, - SpyObject + SpectatorHost } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; -import { Component, NgZone } from '@angular/core'; -import { fakeAsync, tick } from '@angular/core/testing'; +import { Component } from '@angular/core'; import { ControlContainer, FormControl, @@ -46,6 +44,7 @@ import { getUiMessage } from './utils/binary-field-utils'; import { CONTENTTYPE_FIELDS_MESSAGE_MOCK, fileMetaData } from './utils/mock'; import { BINARY_FIELD_MOCK, createFormGroupDirectiveMock } from '../../utils/mocks'; +import { IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; const TEMP_FILE_MOCK: DotCMSTempFile = { fileName: 'image.png', @@ -81,13 +80,15 @@ const MOCK_DOTCMS_FILE = { binaryFieldMetaData: fileMetaData }; +const imageEditorLauncherMock = { + isAvailable: jest.fn().mockReturnValue(true), + open: jest.fn().mockReturnValue(of(null)) +}; + describe('DotEditContentBinaryFieldComponent', () => { let spectator: Spectator; let store: DotBinaryFieldStore; - - let dotBinaryFieldEditImageService: SpyObject; let dotAiService: DotAiService; - let ngZone: NgZone; const createComponent = createComponentFactory({ component: DotEditContentBinaryFieldComponent, @@ -95,7 +96,8 @@ describe('DotEditContentBinaryFieldComponent', () => { DotBinaryFieldStore, DotBinaryFieldEditImageService, DotAiService, - DialogService + DialogService, + { provide: IMAGE_EDITOR_LAUNCHER, useValue: imageEditorLauncherMock } ], componentViewProviders: [ { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } @@ -135,6 +137,9 @@ describe('DotEditContentBinaryFieldComponent', () => { // which can cause these async setups to hang/flap. Force real timers for isolation. jest.useRealTimers(); + imageEditorLauncherMock.isAvailable.mockReturnValue(true); + imageEditorLauncherMock.open.mockReturnValue(of(null)); + spectator = createComponent({ detectChanges: false, props: { @@ -145,9 +150,7 @@ describe('DotEditContentBinaryFieldComponent', () => { } }); store = spectator.inject(DotBinaryFieldStore, true); - dotBinaryFieldEditImageService = spectator.inject(DotBinaryFieldEditImageService, true); dotAiService = spectator.inject(DotAiService, true); - ngZone = spectator.inject(NgZone); }); it('shouldnt show url import button if not setted in settings', () => { @@ -301,37 +304,77 @@ describe('DotEditContentBinaryFieldComponent', () => { }); describe('Edit Image', () => { - it('should open edit image dialog when click on edit image button', () => { + it('should launch the image editor through the launcher seam on edit image', () => { spectator.detectChanges(); - const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor'); spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); - expect(spy).toHaveBeenCalled(); + + expect(imageEditorLauncherMock.open).toHaveBeenCalledWith( + expect.objectContaining({ + variable: BINARY_FIELD_MOCK.variable, + fieldName: BINARY_FIELD_MOCK.name, + byInode: false + }) + ); }); - it('should emit the tempId of the edited image', () => { - // Needed because the openImageEditor method is using a DOM custom event - ngZone.run( - fakeAsync(() => { - const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor'); - const spyTempFile = jest.spyOn(store, 'setFileFromTemp'); - const dotBinaryFieldPreviewComponent = spectator.fixture.debugElement.query( - By.css('dot-binary-field-preview') - ); - dotBinaryFieldPreviewComponent.triggerEventHandler('editImage'); - const customEvent = new CustomEvent( - `binaryField-tempfile-${BINARY_FIELD_MOCK.variable}`, - { - detail: { tempFile: TEMP_FILE_MOCK } - } - ); - document.dispatchEvent(customEvent); + it('should map asset identifiers from the contentlet when launching', () => { + spectator.setInput('contentlet', { + ...MOCK_DOTCMS_FILE, + inode: 'inode-123', + fileName: 'photo.png' + }); + spectator.detectChanges(); + + spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); + + expect(imageEditorLauncherMock.open).toHaveBeenCalledWith( + expect.objectContaining({ + inode: 'inode-123', + variable: BINARY_FIELD_MOCK.variable, + fieldName: BINARY_FIELD_MOCK.name, + byInode: true, + fileName: 'photo.png' + }) + ); + }); + + it('should apply the edited temp file through the store', () => { + imageEditorLauncherMock.open.mockReturnValue(of(TEMP_FILE_MOCK)); + const spyTempFile = jest.spyOn(store, 'setFileFromTemp'); + spectator.detectChanges(); + + spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); + + expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK); + }); + + it('should not apply a temp file when the editor is cancelled', () => { + imageEditorLauncherMock.open.mockReturnValue(of(null)); + const spyTempFile = jest.spyOn(store, 'setFileFromTemp'); + spectator.detectChanges(); + + spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); + + expect(spyTempFile).not.toHaveBeenCalled(); + }); + + it('should fall back to the legacy Dojo editor when the new editor is disabled', () => { + imageEditorLauncherMock.isAvailable.mockReturnValue(false); + const spyLegacy = jest + .spyOn(DotBinaryFieldEditImageService.prototype, 'openImageEditor') + .mockImplementation(); + spectator.setInput('contentlet', { ...MOCK_DOTCMS_FILE, inode: 'inode-123' }); + spectator.detectChanges(); - tick(1000); + spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); - expect(spy).toHaveBeenCalled(); - expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK); + expect(spyLegacy).toHaveBeenCalledWith( + expect.objectContaining({ + inode: 'inode-123', + variable: BINARY_FIELD_MOCK.variable }) ); + expect(imageEditorLauncherMock.open).not.toHaveBeenCalled(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts index 45ff5a87b787..85fd25108bb6 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts @@ -38,6 +38,7 @@ import { DotCMSContentTypeField, DotCMSContentTypeFieldVariable, DotCMSTempFile, + DotFileMetadata, DotGeneratedAIImage } from '@dotcms/dotcms-models'; import { @@ -58,10 +59,11 @@ import { BinaryFieldMode, BinaryFieldStatus } from './interfaces'; import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service'; import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service'; import { DotBinaryFieldStore } from './store/binary-field.store'; -import { getUiMessage } from './utils/binary-field-utils'; +import { getFileMetadata, getUiMessage } from './utils/binary-field-utils'; import { DEFAULT_MONACO_CONFIG } from '../../models/dot-edit-content-field.constant'; import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util'; +import { IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { ...DEFAULT_MONACO_CONFIG, @@ -118,6 +120,10 @@ export class DotEditContentBinaryFieldComponent readonly #dotAiService = inject(DotAiService); readonly #dialogService = inject(DialogService); readonly #destroyRef = inject(DestroyRef); + // Optional: the launcher is provided by the Angular edit-content shell, so the new + // image editor only activates there. When absent (e.g. a non-Angular host), or when + // isAvailable() is false, `onEditImage()` safely no-ops. + readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER, { optional: true }); $isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), { initialValue: false @@ -389,14 +395,54 @@ export class DotEditContentBinaryFieldComponent /** * Open Image Editor * + * Launches the editor through the {@link IMAGE_EDITOR_LAUNCHER} seam and applies + * the edited image back to the field via the binary field store. + * * @memberof DotEditContentBinaryFieldComponent */ onEditImage() { - this.#dotBinaryFieldEditImageService.openImageEditor({ - inode: this.contentlet?.inode, - tempId: this.tempId, - variable: this.variable - }); + const launcher = this.#imageEditorLauncher; + + // The new Angular editor is gated by FEATURE_FLAG_NEW_IMAGE_EDITOR (via the + // launcher's `isAvailable()`). When it's off — or no launcher is provided in + // this context — fall back to the legacy Dojo image editor. + if (!launcher?.isAvailable()) { + this.#dotBinaryFieldEditImageService.openImageEditor({ + inode: this.contentlet?.inode, + tempId: this.tempId, + variable: this.variable + }); + + return; + } + + const inode = this.contentlet?.inode; + const metadata = this.contentlet + ? (getFileMetadata(this.contentlet) as Partial) + : null; + + launcher + .open({ + inode, + tempId: this.tempId, + variable: this.variable, + fieldName: this.$field()?.name, + byInode: !!inode, + fileName: this.contentlet?.fileName ?? metadata?.name, + mimeType: metadata?.contentType + }) + .pipe( + filter((tempFile): tempFile is DotCMSTempFile => !!tempFile), + takeUntilDestroyed(this.#destroyRef) + ) + .subscribe({ + next: (tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile), + // The dialog stream isn't expected to error, but guard it so an + // unexpected failure surfaces in the console instead of being swallowed + // by the global handler with the edited image silently never applied. + error: (error) => + console.error('Image editor failed to apply the edited image', error) + }); } /** diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts new file mode 100644 index 000000000000..5ca27f31711d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts @@ -0,0 +1,87 @@ +import { expect } from '@jest/globals'; +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { BehaviorSubject, Subject } from 'rxjs'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotPropertiesService } from '@dotcms/data-access'; +import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models'; +import { DotImageEditorComponent, ImageEditorOpenParams } from '@dotcms/image-editor'; + +import { AngularImageEditorLauncher } from './angular-image-editor.launcher'; + +describe('AngularImageEditorLauncher', () => { + let spectator: SpectatorService; + let onClose: Subject; + // Drives the new-image-editor flag; `next()` flows through `toSignal` so the same + // service instance reflects on/off without re-creating it. + const featureFlag$ = new BehaviorSubject(true); + const getFeatureFlag = jest.fn(() => featureFlag$); + + const params: ImageEditorOpenParams = { + inode: 'inode-1', + variable: 'binaryField', + fieldName: 'binary' + }; + + const createService = createServiceFactory({ + service: AngularImageEditorLauncher, + providers: [mockProvider(DotPropertiesService, { getFeatureFlag })], + mocks: [DialogService] + }); + + beforeEach(() => { + // Default the flag ON so the open() tests below run the Angular path; the + // gating itself is covered by the dedicated tests. + featureFlag$.next(true); + onClose = new Subject(); + spectator = createService(); + spectator.inject(DialogService).open.mockReturnValue({ onClose }); + }); + + it('should be available when FEATURE_FLAG_NEW_IMAGE_EDITOR is on', () => { + expect(spectator.service.isAvailable()).toBe(true); + expect(getFeatureFlag).toHaveBeenCalledWith(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR); + }); + + it('should NOT be available when the feature flag is off', () => { + featureFlag$.next(false); + + expect(spectator.service.isAvailable()).toBe(false); + }); + + it('should open the DotImageEditorComponent with a headerless, closable, escapable dialog', () => { + spectator.service.open(params).subscribe(); + + expect(spectator.inject(DialogService).open).toHaveBeenCalledWith( + DotImageEditorComponent, + expect.objectContaining({ + data: params, + modal: true, + // The editor renders its own header; PrimeNG's chrome header is hidden. + showHeader: false, + closable: true, + closeOnEscape: true + }) + ); + }); + + it('should resolve the temp file emitted on close', () => { + const tempFile = { id: 'temp-123' } as DotCMSTempFile; + let result: DotCMSTempFile | null | undefined; + + spectator.service.open(params).subscribe((value) => (result = value)); + onClose.next(tempFile); + + expect(result).toEqual(tempFile); + }); + + it('should resolve null when the dialog closes without a value', () => { + let result: DotCMSTempFile | null | undefined; + + spectator.service.open(params).subscribe((value) => (result = value)); + onClose.next(undefined); + + expect(result).toBeNull(); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts new file mode 100644 index 000000000000..5d8755cdbb37 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts @@ -0,0 +1,71 @@ +import { Observable, map, take } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotPropertiesService } from '@dotcms/data-access'; +import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models'; +import { + DotImageEditorComponent, + DotImageEditorLauncher, + ImageEditorOpenParams +} from '@dotcms/image-editor'; + +/** + * Launches the Angular `@dotcms/image-editor` modal through PrimeNG's `DialogService`. + * + * Gated behind the {@link FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR} feature flag: + * `isAvailable()` resolves to the server-configured value, which ships as `false` + * for rollback safety (mirroring the other new-editor flags), so the binary field + * falls back to the legacy Dojo editor until an admin enables it. + */ +@Injectable() +export class AngularImageEditorLauncher implements DotImageEditorLauncher { + readonly #dialogService = inject(DialogService); + readonly #propertiesService = inject(DotPropertiesService); + + /** Resolved value of the new-image-editor feature flag (off until the server replies). */ + readonly #enabled = toSignal( + this.#propertiesService.getFeatureFlag(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR), + { initialValue: false } + ); + + isAvailable(): boolean { + return this.#enabled(); + } + + /** + * Opens the image editor dialog for the given asset. + * + * @param params - Identifiers and metadata of the asset to edit + * @returns Emits the saved temp file, or `null` if the user cancelled + */ + open(params: ImageEditorOpenParams): Observable { + const ref = this.#dialogService.open(DotImageEditorComponent, { + // The editor renders its own header (title + close ✕), so hide PrimeNG's + // chrome header to avoid a duplicate. Closing is handled by the internal ✕ + // (DotImageEditorHeaderComponent) and the Esc key (closeOnEscape). + showHeader: false, + data: params, + // Large landscape dialog: wider than tall. Generous caps keep it big on wide + // screens while staying within the viewport on smaller ones. + width: 'min(96vw, 90rem)', + height: 'min(96vh, 60rem)', + modal: true, + draggable: false, + resizable: false, + closable: true, + closeOnEscape: true, + dismissableMask: false, + contentStyle: { height: '100%', overflow: 'hidden', padding: '0' }, + styleClass: 'dot-image-editor-dialog' + }); + + return ref.onClose.pipe( + map((tempFile?: DotCMSTempFile) => tempFile ?? null), + take(1) + ); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts new file mode 100644 index 000000000000..f559b583c2df --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts @@ -0,0 +1,17 @@ +import { InjectionToken } from '@angular/core'; + +import { DotImageEditorLauncher } from '@dotcms/image-editor'; + +export type { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/image-editor'; + +/** + * DI seam for launching the image editor from the binary field. + * + * The Angular edit-content shell provides the dialog-based launcher. The binary + * field injects it as `{ optional: true }`; when the token is unprovided (or the + * launcher's feature flag is off), `onEditImage()` falls back to the legacy Dojo + * image editor, so no Angular launcher is required for the field to work. + */ +export const IMAGE_EDITOR_LAUNCHER = new InjectionToken( + 'IMAGE_EDITOR_LAUNCHER' +); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/index.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/index.ts new file mode 100644 index 000000000000..fcffb3c4e3d0 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/index.ts @@ -0,0 +1,6 @@ +export { + IMAGE_EDITOR_LAUNCHER, + DotImageEditorLauncher, + ImageEditorOpenParams +} from './image-editor-launcher.token'; +export { AngularImageEditorLauncher } from './angular-image-editor.launcher'; diff --git a/core-web/libs/image-editor/.eslintrc.json b/core-web/libs/image-editor/.eslintrc.json new file mode 100644 index 000000000000..e2d29dd91dcb --- /dev/null +++ b/core-web/libs/image-editor/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/core-web/libs/image-editor/jest.config.ts b/core-web/libs/image-editor/jest.config.ts new file mode 100644 index 000000000000..b3fe4511329a --- /dev/null +++ b/core-web/libs/image-editor/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'image-editor', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/image-editor', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ] +}; diff --git a/core-web/libs/image-editor/project.json b/core-web/libs/image-editor/project.json new file mode 100644 index 000000000000..c75c1c2484fc --- /dev/null +++ b/core-web/libs/image-editor/project.json @@ -0,0 +1,21 @@ +{ + "name": "image-editor", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/image-editor/src", + "prefix": "dot", + "projectType": "library", + "tags": ["type:feature", "scope:dotcms-ui"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/image-editor/jest.config.ts", + "tsConfig": "libs/image-editor/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/core-web/libs/image-editor/src/index.ts b/core-web/libs/image-editor/src/index.ts new file mode 100644 index 000000000000..3899a11ec43f --- /dev/null +++ b/core-web/libs/image-editor/src/index.ts @@ -0,0 +1,8 @@ +export { DotImageEditorComponent } from './lib/components/dot-image-editor/dot-image-editor.component'; +export * from './lib/models/image-editor.models'; +export { RANGES } from './lib/image-editor.constants'; +export { buildFilterChain, buildPreviewUrl, cleanUrl } from './lib/utils/image-filter-url.builder'; +export * from './lib/store/image-editor.events'; +export { ImageEditorStore } from './lib/store/image-editor.store'; +export { initialImageEditorState } from './lib/store/image-editor.state'; +export { DotImageEditorService } from './lib/services/dot-image-editor.service'; diff --git a/core-web/libs/image-editor/src/lib/animations/image-editor.animations.ts b/core-web/libs/image-editor/src/lib/animations/image-editor.animations.ts new file mode 100644 index 000000000000..921c9e47acb2 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/animations/image-editor.animations.ts @@ -0,0 +1,87 @@ +import { + animate, + AnimationTriggerMetadata, + keyframes, + style, + transition, + trigger +} from '@angular/animations'; + +/** + * Scale-and-fade entrance/exit for the image editor modal. + * @param duration - Animation duration (default: 200ms; pass '0ms' for reduced motion) + * @param easing - CSS easing function (default: 'ease-out') + */ +export const imageEditorModalScaleFade = ( + duration = '200ms', + easing = 'ease-out' +): AnimationTriggerMetadata => { + return trigger('imageEditorModalScaleFade', [ + transition(':enter', [ + style({ opacity: 0, transform: 'scale(0.96)' }), + animate(`${duration} ${easing}`, style({ opacity: 1, transform: 'scale(1)' })) + ]), + transition(':leave', [ + animate(`${duration} ${easing}`, style({ opacity: 0, transform: 'scale(0.96)' })) + ]) + ]); +}; + +/** + * Crossfade between two preview images as filters are applied. + * @param duration - Animation duration (default: 150ms; pass '0ms' for reduced motion) + * @param easing - CSS easing function (default: 'ease-in-out') + */ +export const imageCrossfade = ( + duration = '150ms', + easing = 'ease-in-out' +): AnimationTriggerMetadata => { + return trigger('imageCrossfade', [ + transition(':enter', [ + style({ opacity: 0 }), + animate(`${duration} ${easing}`, style({ opacity: 1 })) + ]), + transition(':leave', [animate(`${duration} ${easing}`, style({ opacity: 0 }))]) + ]); +}; + +/** + * Fade for transient overlays (loaders, toolbars) entering and leaving. + * @param duration - Animation duration (default: 150ms; pass '0ms' for reduced motion) + * @param easing - CSS easing function (default: 'ease-in-out') + */ +export const imageEditorOverlayEnterLeave = ( + duration = '150ms', + easing = 'ease-in-out' +): AnimationTriggerMetadata => { + return trigger('imageEditorOverlayEnterLeave', [ + transition(':enter', [ + style({ opacity: 0 }), + animate(`${duration} ${easing}`, style({ opacity: 1 })) + ]), + transition(':leave', [animate(`${duration} ${easing}`, style({ opacity: 0 }))]) + ]); +}; + +/** + * Brief pulse used to acknowledge a successful save. + * @param duration - Animation duration (default: 400ms; pass '0ms' for reduced motion) + * @param easing - CSS easing function (default: 'ease-out') + */ +export const saveSuccessPulse = ( + duration = '400ms', + easing = 'ease-out' +): AnimationTriggerMetadata => { + return trigger('saveSuccessPulse', [ + transition('* => pulse', [ + animate( + `${duration} ${easing}`, + keyframes([ + style({ transform: 'scale(1)', offset: 0 }), + style({ transform: 'scale(1.08)', offset: 0.5 }), + style({ transform: 'scale(1)', offset: 1 }) + ]) + ) + ]) + ]); +}; diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.html new file mode 100644 index 000000000000..dd7e51d8ca1a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.html @@ -0,0 +1,155 @@ + +
+ @for (tool of tools; track tool.id) { + + + @switch (tool.id) { + @case ('move') { + pan_tool + } + @case ('crop') { + crop + } + @case ('focal') { + center_focus_strong + } + } + + + } +
+ + +
+ + + content_copy + + + + {{ store.previewUrl() }} + + + + + open_in_new + + +
+ + +
+ + + remove + + + + + + + add + + + + + + + + undo + + + + + redo + + +
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.scss new file mode 100644 index 000000000000..14deccfdddba --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.scss @@ -0,0 +1,94 @@ +:host { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + // White header band; the gray pills sit on it (UVE toolbar-center look). + background-color: var(--p-surface-0, #ffffff); + // Muted dark text/icons at rest; full-strength dark is reserved for hover/active. + color: var(--p-surface-600, #4b5563); + border-bottom: 1px solid var(--p-surface-200, #e2e8f0); +} + +// Rounded-full gray pill grouping a row of small icon buttons (mirrors the UVE +// editor toolbar-center segments). +.address-bar__pill { + display: flex; + align-items: center; + gap: 0.25rem; + // Pills keep their natural (content) width and never shrink, so the tool and the + // zoom/undo/redo controls always stay intact; only the URL pill flexes. + flex: 0 0 auto; + min-width: 0; + padding: 0.125rem; + border-radius: 1000px; + // Light slate fill from the design (surface-100), not the heavier neutral + // gray-200 we were falling back to. `--p-surface-*` is the PrimeNG 21 token; + // the bare `--surface-*` we used before is undefined in the theme. + background-color: var(--p-surface-100, #f1f5f9); +} + +// The URL pill fills the free space between the tool and zoom pills. `flex-basis: 0` +// (not `auto`) is key: the long, server-generated URL truncates inside the pill +// instead of sizing it to the content and pushing the zoom/undo/redo controls out +// of the header when more filters are appended. +.address-bar__pill--url { + flex: 1 1 0; + padding-right: 0.75rem; +} + +// Material Symbols default to 24px; scale the bar's glyphs (projected into the +// pButton-component icons and the inline tool glyphs) down to the previous +// footprint (~1.125rem) so they read as compact controls. +:host ::ng-deep .material-symbols-outlined { + font-size: 1.125rem; +} + +// Tool toggle: the active tool renders as a filled white rounded square inside the +// gray pill (subtle shadow); the inactive one stays transparent. `styleClass` puts +// `address-bar__tool--active` directly on the inner PrimeNG `.p-button`, so the +// override targets that element (and its hover) and uses `!important` to beat the +// theme's text-button background. +:host ::ng-deep .p-button.address-bar__tool--active, +:host ::ng-deep .p-button.address-bar__tool--active:hover { + // Active tool reads as a primary tint (matches the design's .iem-iconbtn.active: + // primary-50 fill + primary-700 icon), not a white chip. + background-color: var(--p-primary-50, #f6f7fe) !important; + // Circular (like the rounded icon buttons and the pill), so it sits cleanly + // inside the pill instead of a square poking past its rounded ends. + border-radius: 9999px; + color: var(--p-primary-700, #3747a9) !important; +} + +.address-bar__field { + flex: 1 1 auto; + min-width: 0; + color: inherit; + font-size: 0.8125rem; +} + +// Hairline divider grouping items inside a pill. +.address-bar__divider { + flex: 0 0 auto; + width: 1px; + height: 1rem; + background-color: var(--p-surface-300, #cbd5e1); +} + +.address-bar__zoom-value { + min-width: 3.5rem; + text-align: center; + font-variant-numeric: tabular-nums; + // Rendered as a + + + + + + +
+ + + + px +
+ + + + + + + } + + diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.scss new file mode 100644 index 000000000000..a07c84853df2 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.scss @@ -0,0 +1,299 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; +} + +.canvas { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + background-color: var(--surface-0, #ffffff); + color: var(--surface-900, #1f2937); +} + +.canvas__address-bar { + // Don't set `display` here — let the address bar's own `:host { display: flex }` + // lay the URL (left) and zoom controls (right) out on a single row. + flex: 0 0 auto; + // Shared band height with the footer so the top/bottom frames match and the image + // reads as vertically centered (the footer is empty in 'move' mode). + min-height: 3.5rem; +} + +.canvas__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 1.5rem; +} + +// Floating action bar centered at the bottom of the viewport: hosts the crop +// tool's actions (aspect presets + size inputs + apply/cancel). Light rounded +// chrome (white card), revealed on hover or keyboard focus so it floats over the +// image instead of taking a permanent band. +.canvas__actions { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 2; + display: flex; + align-items: center; + gap: 0.5rem; + // Size to content (capped to the viewport) so the bar is as wide as it needs to + // be. Without an explicit width an absolutely-positioned box is capped at the + // space right of `left: 50%`, which squeezed "Apply crop" onto two lines. + width: max-content; + max-width: calc(100% - 2rem); + padding: 0.375rem 0.625rem; + border-radius: 0.75rem; + background-color: var(--surface-0, #ffffff); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + color: var(--surface-700, #374151); + // Hidden until the user hovers the canvas (or tabs into an action, for keyboard a11y). + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +// Keep the action button labels (e.g. "Apply crop") on a single line; the bar +// sizes to content so they never need to wrap. +:host ::ng-deep .canvas__actions .p-button-label { + white-space: nowrap; +} + +.canvas__viewport:hover .canvas__actions, +.canvas__actions:focus-within { + opacity: 1; + pointer-events: auto; +} + +@media (prefers-reduced-motion: reduce) { + .canvas__actions { + transition: none; + } +} + +// Aspect presets in the (light) floating crop action bar: plain dark-gray text +// pills, the selected one filled with the primary color. +// Compact aspect-ratio preset dropdown; the floating bar is tight so cap the +// trigger width. The overlay panel (appended to body) keeps the default theme. +:host ::ng-deep .canvas__aspect-select { + min-width: 5.5rem; +} + +:host ::ng-deep .canvas__aspect-select .p-select-label { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; +} + +// Orientation toggle: two square icon buttons (landscape / portrait). Active +// reads as a filled primary chip; disabled (Free / 1:1) dims out. +.canvas__orient { + display: flex; + gap: 0.25rem; +} + +.canvas__orient-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.875rem; + height: 1.875rem; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--surface-600, #4b5563); + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; + + .material-symbols-outlined { + font-size: 1.125rem; + } +} + +.canvas__orient-btn:hover:not(:disabled) { + background: var(--surface-100, #f3f4f6); + color: var(--surface-900, #1f2937); +} + +.canvas__orient-btn--active, +.canvas__orient-btn--active:hover { + background: var(--primary-color, #6366f1); + color: #ffffff; +} + +.canvas__orient-btn:disabled { + opacity: 0.4; + cursor: default; +} + +@media (prefers-reduced-motion: reduce) { + .canvas__orient-btn { + transition: none; + } +} + +// Thin vertical dividers separating the preset group, the size inputs and the +// action buttons. +.canvas__separator { + width: 1px; + height: 1.25rem; + background: var(--surface-200, #e5e7eb); +} + +// Natural-pixel width/height readout/editor: white right-aligned number fields +// flanking an `×` glyph, with a static `px` unit label. +.canvas__size { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--surface-700, #374151); +} + +.canvas__size-glyph { + color: var(--surface-500, #6b7280); +} + +.canvas__size-unit { + color: var(--surface-500, #6b7280); + font-variant-numeric: tabular-nums; +} + +// Tighten the PrimeNG inputNumber to a compact, right-aligned field that reads as +// part of the light bar; the disabled (Free) state is visibly muted. +:host ::ng-deep .canvas__size-input input { + width: 4rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--surface-300, #d1d5db); + border-radius: 6px; + background: var(--surface-0, #ffffff); + color: var(--surface-900, #1f2937); + font-size: 0.8125rem; + text-align: right; + font-variant-numeric: tabular-nums; +} + +:host ::ng-deep .canvas__size-input input:disabled { + background: var(--surface-100, #f3f4f6); + color: var(--surface-500, #6b7280); + opacity: 1; +} + +.canvas__stage { + position: relative; + // Grid + centered items: stack both image layers in one cell and center them with + // equal margins on every side (so the black bars top and bottom match), while + // giving the images a definite box to contain against. + display: grid; + place-items: center; + width: 100%; + height: 100%; + min-height: 0; + transform-origin: center center; + transition: transform 150ms ease-out; +} + +// Grab cursor while a zoomed-in image can be panned; tighten to grabbing during the +// drag and drop the transition so the pan tracks the pointer 1:1 (no easing lag). +.canvas__stage--pannable { + cursor: grab; +} + +.canvas__stage--panning { + cursor: grabbing; + transition: none; +} + +// Both image layers occupy the same grid cell so they overlay pixel-for-pixel and stay +// centered. min-* lets them shrink to fit (an in-flow flex doesn't honor +// max-height reliably, which clipped the new frame). +.canvas__img { + grid-area: 1 / 1; + max-width: 100%; + max-height: 100%; + min-width: 0; + min-height: 0; + object-fit: contain; + user-select: none; + transition: opacity 200ms ease-in-out; +} + +// Dim the current frame while a new preview renders so loading reads clearly. +.canvas__img--loading { + opacity: 0.35; +} + +// The incoming frame loads HIDDEN — it is only a preloader. A visible +// paints progressively as it downloads, which would flash a half-loaded image on +// top of the canvas. Instead the current frame (Layer A) stays visible (dimmed) +// until this one is fully decoded and promoted, then shown instantly from cache. +.canvas__img--pending { + z-index: 1; + opacity: 0; +} + +.canvas__skeleton { + display: block; +} + +// Centered, non-blocking loading indicator over the dimmed image. +.canvas__loading { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.canvas__error { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + text-align: center; + background-color: rgba(255, 255, 255, 0.85); +} + +.canvas__error-icon { + font-size: 2.5rem; + color: var(--yellow-500, #f5a623); +} + +// The retry button's glyph is projected into PrimeNG's button; pierce with +// ::ng-deep to scale the Material Symbol down to the previous icon footprint. +:host ::ng-deep .canvas__error .p-button .material-symbols-outlined { + font-size: 1.125rem; +} + +.canvas__error-message { + margin: 0; + max-width: 24rem; +} + +@media (prefers-reduced-motion: reduce) { + .canvas__stage, + .canvas__img--pending { + transition: none; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts new file mode 100644 index 000000000000..2609c3104933 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts @@ -0,0 +1,500 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; +import { MockComponent, MockInstance } from 'ng-mocks'; +import { Observable, of, throwError } from 'rxjs'; + +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { Select } from 'primeng/select'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorCanvasComponent } from './dot-image-editor-canvas.component'; + +import { PreviewStatus } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { DotImageEditorAddressBarComponent } from '../dot-image-editor-address-bar/dot-image-editor-address-bar.component'; +import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component'; + +const PREVIEW_URL = '/contentAsset/image/inode-1/fileAsset?byInode=true&r=1'; +const NEXT_PREVIEW_URL = '/contentAsset/image/inode-1/fileAsset?byInode=true&r=2'; + +/** The object URL the mocked service returns for a given preview URL. */ +const objectUrlFor = (url: string) => `blob:${url}`; + +// The preview-load strategy, reset to "succeed" each test and overridden per test. +// Indirecting through this keeps the shared mock fn stable so a per-test override +// can never leak into the initial fetch of the following test's component. +let loadResult: (url: string) => Observable; + +// jsdom has no ResizeObserver; the canvas observes the displayed image to track +// its rendered rect, so provide a no-op implementation for the suite. +class MockResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + configurable: true, + value: MockResizeObserver +}); + +// jsdom has no object-URL API; the canvas revokes object URLs it no longer shows. +URL.createObjectURL = jest.fn( + (blob: Blob) => `blob:${(blob as unknown as { name?: string }).name ?? 'mock'}` +); +URL.revokeObjectURL = jest.fn(); + +// jsdom implements neither HTMLImageElement.decode() nor real natural dimensions; +// stub them so the canvas's decode()-based completeness check resolves with a +// valid, sized image. Individual tests override decode() to simulate failures. +HTMLImageElement.prototype.decode = jest.fn().mockResolvedValue(undefined); +Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', { + configurable: true, + get: () => 800 +}); +Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', { + configurable: true, + get: () => 600 +}); + +/** Flushes the microtask queue so a resolved/rejected `decode()` promise settles. */ +const flushDecode = () => new Promise((resolve) => setTimeout(resolve)); + +describe('DotImageEditorCanvasComponent', () => { + // The crop overlay is mocked with MockComponent, which stubs only its + // inputs/outputs — not its public `naturalCropSize` computed nor its + // `setNaturalCropSize` method, which the canvas footer reads/calls. Stub both + // on the mock so the footer can wire to them: `naturalCropSize` is a signal the + // test can drive, `setNaturalCropSize` a spy to assert the canvas forwards edits. + MockInstance.scope(); + const overlayCropSize = signal({ width: 0, height: 0 }); + const setNaturalCropSize = jest.fn(); + + let spectator: Spectator; + let dispatcher: Dispatcher; + let service: jest.Mocked; + + const previewUrl = signal(PREVIEW_URL); + const previewStatus = signal('idle'); + const zoom = signal({ level: 100, fitToScreen: true }); + const activeTool = signal<'move' | 'crop'>('move'); + const assetContext = signal({ naturalWidth: 800, naturalHeight: 600 }); + + const createComponent = createComponentFactory({ + component: DotImageEditorCanvasComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }), + mockProvider(DotImageEditorService, { + // Stable fn that delegates to the per-test strategy (see `loadResult`). + loadPreviewImage: jest.fn((url: string) => loadResult(url)) + }) + ], + componentProviders: [ + mockProvider(ImageEditorStore, { + previewUrl, + previewStatus, + zoom, + activeTool, + assetContext + }) + ], + // Isolate the canvas from the children's own store/dispatch wiring. + overrideComponents: [ + [ + DotImageEditorCanvasComponent, + { + remove: { + imports: [ + DotImageEditorAddressBarComponent, + DotImageEditorCropOverlayComponent + ] + }, + add: { + imports: [ + MockComponent(DotImageEditorAddressBarComponent), + MockComponent(DotImageEditorCropOverlayComponent) + ] + } + } + ] + ] + }); + + /** + * Pumps change detection so the `toObservable(pendingUrl)` effect emits (the + * queued preview is fetched, mocked) and a second pass mounts its pending + * `` layer. Two passes because the effect sets `pendingSrc` during the + * first pass, after the template was already evaluated. + */ + const settlePending = () => { + spectator.detectChanges(); + spectator.detectChanges(); + }; + + beforeEach(() => { + // Expose the overlay's public size read/write on the mock for this test. + overlayCropSize.set({ width: 0, height: 0 }); + setNaturalCropSize.mockClear(); + MockInstance(DotImageEditorCropOverlayComponent, 'naturalCropSize', overlayCropSize); + MockInstance(DotImageEditorCropOverlayComponent, 'setNaturalCropSize', setNaturalCropSize); + + previewUrl.set(PREVIEW_URL); + previewStatus.set('idle'); + zoom.set({ level: 100, fitToScreen: true }); + activeTool.set('move'); + (HTMLImageElement.prototype.decode as jest.Mock).mockResolvedValue(undefined); + // Default strategy: every preview resolves to a complete object URL. Set + // before createComponent so the component's initial fetch uses it. + loadResult = (url: string) => of(objectUrlFor(url)); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + service = spectator.inject( + DotImageEditorService, + true + ) as jest.Mocked; + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the canvas stage and child components', () => { + expect(spectator.query(byTestId('image-editor-canvas'))).toExist(); + expect(spectator.query('dot-image-editor-address-bar')).toExist(); + expect(spectator.query('dot-image-editor-crop-overlay')).toExist(); + }); + + it('should no longer render the floating tool rail', () => { + expect(spectator.query('dot-image-editor-tool-rail')).not.toExist(); + }); + + it('should show the skeleton and no spinner when idle', () => { + expect(spectator.query(byTestId('image-editor-skeleton'))).toExist(); + expect(spectator.query(byTestId('image-editor-loading'))).not.toExist(); + }); + + it('should fetch the queued preview as a verified blob before rendering it', () => { + settlePending(); + + expect(service.loadPreviewImage).toHaveBeenCalledWith(PREVIEW_URL); + const pending = spectator.query(byTestId('image-editor-pending-img')); + expect(pending?.getAttribute('src')).toBe(objectUrlFor(PREVIEW_URL)); + }); + + it('should keep the displayed image visible while loading (crossfade invariant)', async () => { + // Promote a first frame, then begin loading the next preview. + settlePending(); + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + await flushDecode(); + + previewStatus.set('loading'); + previewUrl.set(NEXT_PREVIEW_URL); + settlePending(); + + expect(spectator.query(byTestId('image-editor-loading'))).toExist(); + expect(spectator.query(byTestId('image-editor-display-img'))).toExist(); + expect( + spectator + .query(byTestId('image-editor-display-img')) + ?.hasAttribute('hidden') + ).toBe(false); + }); + + it('should promote the pending image and dispatch previewLoaded on load', async () => { + settlePending(); + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + await flushDecode(); + + expect(dispatchedEvent('previewLoaded')).toBeDefined(); + // Once promoted, the displayed layer shows the verified blob and no pending layer remains. + spectator.detectChanges(); + const displayed = spectator.query(byTestId('image-editor-display-img')); + expect(displayed?.getAttribute('src')).toBe(objectUrlFor(PREVIEW_URL)); + expect(spectator.query(byTestId('image-editor-pending-img'))).not.toExist(); + }); + + it('should abandon a superseded preview and promote only the latest under rapid edits', async () => { + // Queue the first preview, then advance the store before it is promoted. + settlePending(); + previewUrl.set(NEXT_PREVIEW_URL); + settlePending(); + + // The pending layer now targets the newest preview; the stale fetch was dropped. + const pending = spectator.query(byTestId('image-editor-pending-img')); + expect(pending?.getAttribute('src')).toBe(objectUrlFor(NEXT_PREVIEW_URL)); + + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + await flushDecode(); + spectator.detectChanges(); + + const displayed = spectator.query(byTestId('image-editor-display-img')); + expect(displayed?.getAttribute('src')).toBe(objectUrlFor(NEXT_PREVIEW_URL)); + expect(dispatchedEvent('previewLoaded')).toBeDefined(); + }); + + it('should report previewErrored when the preview blob fails to load (incomplete response)', () => { + // The service rejects a truncated / incomplete server response. + loadResult = () => throwError(() => new Error('Incomplete or invalid image response')); + previewUrl.set(NEXT_PREVIEW_URL); + settlePending(); + + expect(dispatchedEvent('previewErrored')).toBeDefined(); + // No pending layer is mounted for a failed fetch. + expect(spectator.query(byTestId('image-editor-pending-img'))).not.toExist(); + }); + + it('should report a failed pending image load to the store (which owns the retry policy)', () => { + settlePending(); + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'error'); + + expect(dispatchedEvent('previewErrored')).toBeDefined(); + }); + + it('should report previewErrored when the loaded image fails to decode', async () => { + (HTMLImageElement.prototype.decode as jest.Mock).mockRejectedValueOnce( + new Error('decode failed') + ); + + settlePending(); + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + await flushDecode(); + + expect(dispatchedEvent('previewErrored')).toBeDefined(); + expect(dispatchedEvent('previewLoaded')).toBeUndefined(); + }); + + it('should render the pending image only when the preview URL differs from the displayed one', async () => { + // No displayed frame yet, so the current preview is pending. + settlePending(); + expect(spectator.query(byTestId('image-editor-pending-img'))).toExist(); + + // Promote the current preview: the pending layer is released. + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + await flushDecode(); + spectator.detectChanges(); + expect(spectator.query(byTestId('image-editor-pending-img'))).not.toExist(); + + // Advancing the store re-mounts the pending layer for the new URL. + previewUrl.set(NEXT_PREVIEW_URL); + settlePending(); + const pending = spectator.query(byTestId('image-editor-pending-img')); + expect(pending?.getAttribute('src')).toBe(objectUrlFor(NEXT_PREVIEW_URL)); + }); + + it('should show the error overlay and retry button when errored', () => { + previewStatus.set('error'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-error'))).toExist(); + expect(spectator.query(byTestId('image-editor-retry-btn'))).toExist(); + }); + + it('should dispatch retryRequested when retry is clicked', () => { + previewStatus.set('error'); + spectator.detectChanges(); + + const retryBtn = spectator.query(byTestId('image-editor-retry-btn')); + spectator.click(retryBtn!.querySelector('button')!); + + expect(dispatchedEvent('retryRequested')).toBeDefined(); + }); + + describe('tool actions bar', () => { + it('should not render the action bar when the move tool is active', () => { + activeTool.set('move'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-canvas-footer'))).not.toExist(); + expect(spectator.query(byTestId('image-editor-crop-apply-btn'))).not.toExist(); + expect(spectator.query(byTestId('image-editor-crop-cancel-btn'))).not.toExist(); + }); + + it('should render the action bar with crop actions when the crop tool is active', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-canvas-footer'))).toExist(); + expect(spectator.query(byTestId('image-editor-crop-apply-btn'))).toExist(); + expect(spectator.query(byTestId('image-editor-crop-cancel-btn'))).toExist(); + }); + + it('should render the aspect dropdown and orientation toggle in the crop bar', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-aspect-select'))).toExist(); + expect(spectator.query(byTestId('image-editor-orient-landscape'))).toExist(); + expect(spectator.query(byTestId('image-editor-orient-portrait'))).toExist(); + }); + + it('should derive the locked aspect from the selected preset and orientation', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + // The component owns cropPreset/cropOrientation; cropAspect is the + // orientation-adjusted ratio it binds to the overlay's `aspect` input. + const cmp = spectator.component as unknown as { + cropPreset: { set: (value: string) => void }; + cropOrientation: { set: (value: 'landscape' | 'portrait') => void }; + cropAspect: () => number | null; + orientationDisabled: () => boolean; + }; + + // Free: no lock, orientation disabled. + expect(cmp.cropAspect()).toBeNull(); + expect(cmp.orientationDisabled()).toBe(true); + + // 16:9 landscape; orientation now applies. + cmp.cropPreset.set('wide'); + expect(cmp.cropAspect()).toBeCloseTo(16 / 9, 5); + expect(cmp.orientationDisabled()).toBe(false); + + // Portrait flips the ratio to 9:16. + cmp.cropOrientation.set('portrait'); + expect(cmp.cropAspect()).toBeCloseTo(9 / 16, 5); + + // 1:1 is square: orientation has no effect and is disabled. + cmp.cropPreset.set('square'); + expect(cmp.cropAspect()).toBe(1); + expect(cmp.orientationDisabled()).toBe(true); + }); + + it('should set the preset from the aspect dropdown selection', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + spectator + .query(Select)! + .onChange.emit({ originalEvent: new Event('change'), value: 'standard' }); + + expect( + (spectator.component as unknown as { cropAspect: () => number | null }).cropAspect() + ).toBeCloseTo(4 / 3, 5); + }); + + it('should flip orientation from the toolbar buttons', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + const cmp = spectator.component as unknown as { + cropPreset: { set: (value: string) => void }; + cropOrientation: () => 'landscape' | 'portrait'; + }; + cmp.cropPreset.set('wide'); + spectator.detectChanges(); + + spectator.click(spectator.query(byTestId('image-editor-orient-portrait'))!); + spectator.detectChanges(); + + expect(cmp.cropOrientation()).toBe('portrait'); + expect(spectator.query(byTestId('image-editor-orient-portrait'))).toHaveClass( + 'canvas__orient-btn--active' + ); + }); + + it('should invoke the crop overlay apply/cancel from the action bar when cropping', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + const cropOverlay = spectator.query(DotImageEditorCropOverlayComponent)!; + const applySpy = jest.spyOn(cropOverlay, 'applyCrop'); + const cancelSpy = jest.spyOn(cropOverlay, 'cancelCrop'); + + const applyBtn = spectator.query(byTestId('image-editor-crop-apply-btn')); + spectator.click(applyBtn!.querySelector('button')!); + expect(applySpy).toHaveBeenCalled(); + + const cancelBtn = spectator.query(byTestId('image-editor-crop-cancel-btn')); + spectator.click(cancelBtn!.querySelector('button')!); + expect(cancelSpy).toHaveBeenCalled(); + }); + + it('should render the width/height size inputs when cropping', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-crop-width-input'))).toExist(); + expect(spectator.query(byTestId('image-editor-crop-height-input'))).toExist(); + }); + + it('should disable the size inputs in Free mode and enable them under a preset', async () => { + activeTool.set('crop'); + spectator.detectChanges(); + // NgModel applies disabled-state changes in a microtask, so flush it + // before reading the inner input's disabled state. + await Promise.resolve(); + spectator.detectChanges(); + + // The inner PrimeNG carries the disabled state. Free (null) is a + // pure readout, so both fields are disabled. + const widthInput = () => + spectator.query(byTestId('image-editor-crop-width-input'))!.querySelector('input')!; + const heightInput = () => + spectator + .query(byTestId('image-editor-crop-height-input'))! + .querySelector('input')!; + + expect(widthInput().disabled).toBe(true); + expect(heightInput().disabled).toBe(true); + + // Selecting a preset locks a ratio and makes the fields editable. + ( + spectator.component as unknown as { cropPreset: { set: (value: string) => void } } + ).cropPreset.set('square'); + spectator.detectChanges(); + await Promise.resolve(); + spectator.detectChanges(); + + expect(widthInput().disabled).toBe(false); + expect(heightInput().disabled).toBe(false); + }); + + it('should drive the crop overlay setter when the width is edited under a preset', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + // Lock to 1:1 so the height follows the typed width. + ( + spectator.component as unknown as { cropPreset: { set: (value: string) => void } } + ).cropPreset.set('square'); + spectator.detectChanges(); + + // Editing the width to 320 px drives the overlay setter with the locked + // ratio's matching height (320 / 1 = 320). + ( + spectator.component as unknown as { + onCropWidthChange: (value: number | null) => void; + } + ).onCropWidthChange(320); + + expect(setNaturalCropSize).toHaveBeenCalledWith(320, 320); + }); + + it('should ignore size edits while in Free mode (pure readout)', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + // Free is the default; an edit must not reach the overlay setter. + ( + spectator.component as unknown as { + onCropWidthChange: (value: number | null) => void; + } + ).onCropWidthChange(320); + + expect(setNaturalCropSize).not.toHaveBeenCalled(); + }); + }); + + /** Finds the first dispatched event whose type matches the given suffix. */ + function dispatchedEvent(typeSuffix: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find(([dispatched]) => + dispatched.type.includes(typeSuffix) + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts new file mode 100644 index 000000000000..55756239c157 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts @@ -0,0 +1,596 @@ +import { injectDispatch } from '@ngrx/signals/events'; +import { EMPTY } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + ElementRef, + inject, + signal, + untracked, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { SelectModule } from 'primeng/select'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TooltipModule } from 'primeng/tooltip'; + +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from '../../image-editor.constants'; +import { ImageRect } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { imageEditorLifecycleEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { clamp } from '../../utils/dimensions.util'; +import { DotImageEditorAddressBarComponent } from '../dot-image-editor-address-bar/dot-image-editor-address-bar.component'; +import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component'; +import { DotImageEditorFocalOverlayComponent } from '../dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component'; + +/** + * Stage that renders the live image preview at the center of the editor. + * Hosts the top address sub-bar (which carries the canvas tools), the crop overlay, + * and a floating bottom action bar that surfaces the crop tool's actions + * (aspect-ratio presets, a natural-pixel width/height readout/editor, plus + * apply/cancel). Owns three pieces of local UI state the store does + * not: a two-layer image crossfade between successive previews, the rendered + * image's bounding rect (measured for the overlay), and the display-only zoom + * level. Preview loading outcomes are reported back to the store via + * {@link imageEditorLifecycleEvents}. + */ +@Component({ + selector: 'dot-image-editor-canvas', + templateUrl: './dot-image-editor-canvas.component.html', + styleUrl: './dot-image-editor-canvas.component.scss', + imports: [ + FormsModule, + ButtonModule, + InputNumberModule, + ProgressSpinnerModule, + SelectModule, + SkeletonModule, + TooltipModule, + DotMessagePipe, + DotImageEditorAddressBarComponent, + DotImageEditorCropOverlayComponent, + DotImageEditorFocalOverlayComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotImageEditorCanvasComponent { + protected readonly store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorLifecycleEvents); + readonly #destroyRef = inject(DestroyRef); + readonly #service = inject(DotImageEditorService); + + /** + * Aspect-ratio presets offered in the crop action bar's dropdown. Each ratio is + * the landscape value; the orientation toggle inverts it for portrait. `Free` + * (a `null` ratio) clears the lock and returns to free-form cropping. + */ + protected readonly aspectPresets: { key: string; label: string; aspect: number | null }[] = [ + { key: 'free', label: 'Free', aspect: null }, + { key: 'square', label: '1:1', aspect: 1 }, + { key: 'wide', label: '16:9', aspect: 16 / 9 }, + { key: 'standard', label: '4:3', aspect: 4 / 3 } + ]; + + /** Selected aspect preset key (the dropdown value); `free` clears the lock. */ + protected readonly cropPreset = signal('free'); + + /** Crop box orientation; flips a ratio'd preset (e.g. 16:9 -> 9:16). */ + protected readonly cropOrientation = signal<'landscape' | 'portrait'>('landscape'); + + /** Base (landscape) ratio of the selected preset, or `null` for Free. */ + readonly #presetRatio = computed( + () => this.aspectPresets.find((preset) => preset.key === this.cropPreset())?.aspect ?? null + ); + + /** + * The effective locked aspect ratio (width / height) handed to the crop overlay: + * the selected preset's ratio, inverted when portrait is chosen. `null` for Free + * (no lock). + */ + protected readonly cropAspect = computed(() => { + const ratio = this.#presetRatio(); + if (ratio === null) { + return null; + } + + return this.cropOrientation() === 'portrait' ? 1 / ratio : ratio; + }); + + /** Orientation only changes non-square ratios; disabled for Free and 1:1. */ + protected readonly orientationDisabled = computed(() => { + const ratio = this.#presetRatio(); + + return ratio === null || ratio === 1; + }); + + /** + * Live size of the crop box in natural image pixels, read from the crop + * overlay (the single source of truth for the box). Drives the footer's + * width/height readout/inputs and tracks every drag, resize and ratio change. + * `0×0` when no overlay/box is present. + */ + protected readonly cropSize = computed( + () => this.cropOverlay()?.naturalCropSize() ?? { width: 0, height: 0 } + ); + + /** The image stage, used as the origin for the rendered image rect. */ + protected readonly stage = viewChild>('stage'); + /** The currently displayed image, observed to recompute its rendered rect. */ + protected readonly displayImg = viewChild>('displayImg'); + + /** The crop overlay, so the footer can apply or cancel the active crop. */ + protected readonly cropOverlay = viewChild(DotImageEditorCropOverlayComponent); + + /** Filter URL of the last successfully loaded preview (the bottom layer's identity). */ + protected readonly displayedUrl = signal(''); + + /** Object URL rendered on the bottom (displayed) layer — verified, complete bytes. */ + protected readonly displayedSrc = signal(''); + + /** + * Filter URL queued for loading on the top layer: the store's current preview + * when it differs from what is already displayed, otherwise empty. This is the + * remote URL we fetch as a blob; the layer renders the resulting object URL. + */ + protected readonly pendingUrl = computed(() => { + const next = this.store.previewUrl(); + + return next && next !== this.displayedUrl() ? next : ''; + }); + + /** Object URL of the verified pending blob, rendered on the top layer once ready. */ + protected readonly pendingSrc = signal(''); + + /** The verified-but-not-yet-promoted pending preview (filter URL + its object URL). */ + #pending: { filterUrl: string; objectUrl: string } | null = null; + + /** Object URL currently shown on the displayed layer, retained for revocation. */ + #displayedObjectUrl: string | null = null; + + /** Rendered bounds of the displayed image within the stage, in CSS px. */ + protected readonly imageRect = signal(undefined); + + /** Internal zoom multiplier (×100) applied as a CSS transform; 100 = fit-to-stage. */ + protected readonly zoomLevel = signal(ZOOM_DEFAULT); + + /** + * Ratio of the rendered (fit) image to its natural pixels — `renderedWidth / + * naturalWidth`. A huge image shrunk to fit has a ratio < 1; an image smaller + * than the stage stays at 1 (it is never upscaled). Measured on load with the + * rect; drives the natural-relative zoom readout. + */ + protected readonly fitRatio = signal(1); + + /** Pan offset (CSS px) applied to the stage so a zoomed-in image can be dragged. */ + protected readonly panOffset = signal<{ x: number; y: number }>({ x: 0, y: 0 }); + + /** Whether a pan drag is in progress (suppresses the transform transition). */ + protected readonly panning = signal(false); + + /** Panning only applies when zoomed past fit with the move tool active. */ + protected readonly canPan = computed( + () => this.zoomLevel() > ZOOM_DEFAULT && this.store.activeTool() === 'move' + ); + + /** Combined pan + zoom transform applied to the stage. */ + protected readonly stageTransform = computed(() => { + const { x, y } = this.panOffset(); + + return `translate(${x}px, ${y}px) scale(${this.zoomLevel() / 100})`; + }); + + /** + * Zoom percentage shown to the user, relative to the image's NATURAL pixels + * (not the fit size): `fitRatio × zoomLevel`. A huge image shown whole reads as + * e.g. 30%, and 100% means 1:1 with the source pixels — while `zoomLevel` stays + * the internal transform multiplier the +/- and fit controls operate on. + */ + protected readonly displayZoom = computed(() => Math.round(this.fitRatio() * this.zoomLevel())); + + /** + * The visible image region (image-local CSS px) captured when the crop tool is + * activated while zoomed in. Seeds the crop overlay so switching to crop frames + * exactly what the user had in view. `undefined` when not zoomed (full image). + */ + protected readonly capturedCropRect = signal(undefined); + + /** Observes the displayed image so overlay rects track resize and layout. */ + #resizeObserver: ResizeObserver | null = null; + + /** Tears down the active pan drag's window listeners, if any. */ + #panCleanup: (() => void) | null = null; + + constructor() { + this.#destroyRef.onDestroy(() => { + this.#resizeObserver?.disconnect(); + this.#detachPan(); + this.#revoke(this.#displayedObjectUrl); + this.#revoke(this.#pending?.objectUrl ?? null); + }); + + // Fetch each queued preview as a complete, verified blob before it is ever + // rendered. `switchMap` cancels a superseded in-flight request (a newer edit + // wins); a fetch failure — including a truncated / partially-generated + // response — reports to the store, which owns the silent-retry policy. + toObservable(this.pendingUrl) + .pipe( + switchMap((filterUrl) => { + // A new target supersedes any verified-but-unpromoted pending blob. + this.#discardPending(); + + if (!filterUrl) { + return EMPTY; + } + + return this.#service.loadPreviewImage(filterUrl).pipe( + map((objectUrl) => ({ filterUrl, objectUrl })), + catchError(() => { + this.#dispatch.previewErrored(); + + return EMPTY; + }) + ); + }), + takeUntilDestroyed() + ) + .subscribe(({ filterUrl, objectUrl }) => { + this.#pending = { filterUrl, objectUrl }; + this.pendingSrc.set(objectUrl); + }); + + // When the crop tool activates while zoomed in, capture the region the user + // had framed and reset to fit, so the crop box lands on exactly what was in + // view (crop-to-current-view). `untracked` keeps this firing only on the + // tool change, not on every zoom/pan tweak. + effect(() => { + const tool = this.store.activeTool(); + + untracked(() => { + if (tool !== 'crop') { + this.capturedCropRect.set(undefined); + // Leaving crop clears the locked aspect so the next crop session + // starts free-form (and landscape). + this.cropPreset.set('free'); + this.cropOrientation.set('landscape'); + + return; + } + + const visible = this.#computeVisibleImageRect(); + this.capturedCropRect.set(visible); + + if (visible) { + this.fit(); + } + }); + }); + } + + /** + * Promotes the verified pending blob to the displayed layer and reports the + * successful preview to the store. The pending image already holds complete, + * fetched bytes (rendered from a local object URL), so it cannot paint a + * truncated frame; `decode()` is a final paint-ready gate and we also require + * real dimensions. The crossfade keeps the previous frame visible until this + * one is promoted, so the canvas is never blanked. + */ + protected onPendingLoaded(event: Event): void { + const pending = this.#pending; + const img = event.target as HTMLImageElement; + + // Guard against a stale load event after the pending blob was superseded. + if (!pending) { + return; + } + + img.decode() + .then(() => { + // `decode()` is async: a newer edit may have superseded (and revoked) + // this pending blob via #discardPending while it ran. Bail if so — + // promoting a revoked object URL would render a broken frame. + if (this.#pending !== pending) { + return; + } + + if (!img.naturalWidth || !img.naturalHeight) { + this.#dispatch.previewErrored(); + + return; + } + + // The pending blob becomes the displayed frame; release the one it replaces. + this.#revoke(this.#displayedObjectUrl); + this.#displayedObjectUrl = pending.objectUrl; + this.displayedSrc.set(pending.objectUrl); + this.displayedUrl.set(pending.filterUrl); + this.#pending = null; + this.pendingSrc.set(''); + + this.#measureImageRect(); + this.#dispatch.previewLoaded(); + }) + .catch(() => this.#dispatch.previewErrored()); + } + + /** + * Reports a failed pending load, keeping the last good frame visible. The store + * owns the retry policy: it silently retries a transient failure before + * surfacing the error UI (see the `previewErrored` reducer). + */ + protected onPendingError(): void { + this.#dispatch.previewErrored(); + } + + /** Drops any verified-but-unpromoted pending blob, revoking its object URL. */ + #discardPending(): void { + this.#revoke(this.#pending?.objectUrl ?? null); + this.#pending = null; + this.pendingSrc.set(''); + } + + /** Releases an object URL created for a preview blob, if any. */ + #revoke(objectUrl: string | null): void { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + } + + /** Recomputes the rendered image rect once the displayed image lays out. */ + protected onDisplayLoaded(): void { + this.#observeDisplayImg(); + this.#measureImageRect(); + } + + /** Requests a fresh preview after a render error. */ + protected retry(): void { + this.#dispatch.retryRequested(); + } + + /** Applies the active crop via the crop overlay from the footer action. */ + protected applyCrop(): void { + this.cropOverlay()?.applyCrop(); + } + + /** Cancels the active crop via the crop overlay from the footer action. */ + protected cancelCrop(): void { + this.cropOverlay()?.cancelCrop(); + } + + /** + * Commits a width typed into the footer's crop width input. Only editable when + * a preset ratio is active, so the height follows from the locked ratio; the + * resulting natural size is handed to the overlay, which resizes the box + * (clamped, centered). A cleared/invalid value is ignored. + */ + protected onCropWidthChange(width: number | null): void { + const aspect = this.cropAspect(); + + if (aspect == null || width == null || width <= 0) { + return; + } + + this.cropOverlay()?.setNaturalCropSize(width, Math.round(width / aspect)); + } + + /** + * Commits a height typed into the footer's crop height input. Mirror of + * {@link onCropWidthChange}: the width follows from the locked ratio. + */ + protected onCropHeightChange(height: number | null): void { + const aspect = this.cropAspect(); + + if (aspect == null || height == null || height <= 0) { + return; + } + + this.cropOverlay()?.setNaturalCropSize(Math.round(height * aspect), height); + } + + /** Increases the zoom by one step, clamped to the maximum. */ + protected zoomIn(): void { + this.zoomLevel.update((level) => Math.min(ZOOM_MAX, level + ZOOM_STEP)); + const { x, y } = this.panOffset(); + this.panOffset.set(this.#clampPan(x, y)); + } + + /** Decreases the zoom by one step, clamped to the minimum; recenters at/below fit. */ + protected zoomOut(): void { + const level = Math.max(ZOOM_MIN, this.zoomLevel() - ZOOM_STEP); + this.zoomLevel.set(level); + + if (level <= ZOOM_DEFAULT) { + this.panOffset.set({ x: 0, y: 0 }); + } else { + // A smaller zoom shrinks the pannable range; re-clamp so the prior + // offset can't leave empty space past an edge. + const { x, y } = this.panOffset(); + this.panOffset.set(this.#clampPan(x, y)); + } + } + + /** Resets the zoom so the image fits the stage and recenters the pan. */ + protected fit(): void { + this.zoomLevel.set(ZOOM_DEFAULT); + this.panOffset.set({ x: 0, y: 0 }); + } + + /** + * Starts a pan drag when zoomed in with the move tool: pointer moves translate + * the stage by the drag delta. Listeners live on `window` so the drag continues + * outside the stage and are torn down on pointer-up (and on destroy). + */ + protected onStagePointerDown(event: PointerEvent): void { + if (!this.canPan()) { + return; + } + + event.preventDefault(); + this.panning.set(true); + + const startX = event.clientX; + const startY = event.clientY; + const origin = this.panOffset(); + + const move = (moveEvent: PointerEvent) => { + this.panOffset.set( + this.#clampPan( + origin.x + (moveEvent.clientX - startX), + origin.y + (moveEvent.clientY - startY) + ) + ); + }; + const up = () => { + this.panning.set(false); + this.#detachPan(); + }; + + this.#detachPan(); + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + this.#panCleanup = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + } + + /** Removes any active pan drag's window listeners. */ + #detachPan(): void { + this.#panCleanup?.(); + this.#panCleanup = null; + } + + /** + * Constrains a candidate pan offset so the zoomed image always covers the + * stage — the drag can't pull empty space in past the top/left or + * bottom/right edge. Mirrors the center-origin `translate(pan) scale(zoom)` + * geometry that #computeVisibleImageRect inverts. On an axis where the scaled + * image is still smaller than the stage (nothing to pan), it is pinned to the + * centering offset. + */ + #clampPan(x: number, y: number): { x: number; y: number } { + const rect = this.imageRect(); + const stage = this.stage()?.nativeElement; + const scale = this.zoomLevel() / 100; + + if (!rect || !stage || scale <= 1) { + return { x: 0, y: 0 }; + } + + const axis = (stageSize: number, rectStart: number, rectSize: number, pan: number) => { + const center = stageSize / 2; + // Offsets at which the scaled image's near/far edge meets the stage box. + const max = center * (scale - 1) - scale * rectStart; + const min = stageSize - center - scale * (rectStart + rectSize - center); + + return min > max ? (min + max) / 2 : clamp(pan, min, max); + }; + + return { + x: axis(stage.clientWidth, rect.x, rect.width, x), + y: axis(stage.clientHeight, rect.y, rect.height, y) + }; + } + + /** Lazily attaches a single ResizeObserver to the displayed image element. */ + #observeDisplayImg(): void { + const img = this.displayImg()?.nativeElement; + + if (!img || this.#resizeObserver) { + return; + } + + this.#resizeObserver = new ResizeObserver(() => this.#measureImageRect()); + this.#resizeObserver.observe(img); + } + + /** + * Measures the displayed image relative to the stage origin so the crop overlay + * can position itself over the rendered pixels. + */ + #measureImageRect(): void { + const img = this.displayImg()?.nativeElement; + + if (!img) { + return; + } + + // Measure the image's LAYOUT box (offset*) relative to the stage, not + // `getBoundingClientRect()/scale`. The stage carries the zoom as a CSS + // `transform: scale(...)` with a 150ms transition; a painted rect read + // mid-transition and divided by the final scale yields a slightly-wrong size + // that then sticks (ResizeObserver is layout-based, so it never fires again to + // correct it) — which left the default crop box a few px short of the image + // edges. `offset*` is the true pre-transform size in the stage's logical CSS + // px — the exact space the crop overlay positions itself in — so the box + // always matches the image at any zoom. (Its `offsetParent` is the + // position:relative stage.) + this.imageRect.set({ + x: img.offsetLeft, + y: img.offsetTop, + width: img.offsetWidth, + height: img.offsetHeight + }); + + // The intrinsic size is the current preview's real pixels; its layout + // width is the fit size. Their ratio is the true on-screen scale at + // zoomLevel 100, used to report zoom relative to natural pixels. Require both + // to be measured (a not-yet-laid-out image reports 0) so we never store a + // bogus 0 ratio — the next measure (load / ResizeObserver) sets the real one. + this.fitRatio.set( + img.naturalWidth && img.offsetWidth ? img.offsetWidth / img.naturalWidth : 1 + ); + } + + /** + * The portion of the image currently visible in the viewport, expressed in the + * image-local CSS px the crop overlay uses, or `undefined` when not zoomed in + * (the whole image is already visible). Inverts the stage's + * `translate(pan) scale(zoom)` transform about its center to find the logical + * window that maps onto the visible stage box, then clamps it to the image. + */ + #computeVisibleImageRect(): ImageRect | undefined { + const rect = this.imageRect(); + const stage = this.stage()?.nativeElement; + const scale = this.zoomLevel() / 100; + + if (!rect || !stage || scale <= 1) { + return undefined; + } + + const { x: panX, y: panY } = this.panOffset(); + const stageWidth = stage.clientWidth; + const stageHeight = stage.clientHeight; + const centerX = stageWidth / 2; + const centerY = stageHeight / 2; + + const visibleLeft = centerX - (centerX + panX) / scale; + const visibleRight = centerX + (stageWidth - centerX - panX) / scale; + const visibleTop = centerY - (centerY + panY) / scale; + const visibleBottom = centerY + (stageHeight - centerY - panY) / scale; + + const left = clamp(visibleLeft - rect.x, 0, rect.width); + const right = clamp(visibleRight - rect.x, 0, rect.width); + const top = clamp(visibleTop - rect.y, 0, rect.height); + const bottom = clamp(visibleBottom - rect.y, 0, rect.height); + + if (right - left < 1 || bottom - top < 1) { + return undefined; + } + + return { x: left, y: top, width: right - left, height: bottom - top }; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.html new file mode 100644 index 000000000000..3be7e7009261 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.html @@ -0,0 +1,46 @@ +@if (isActive() && imageRect()) { +
+ +
+
+
+
+ +
+
+ + + + +
+ + @for (handle of handles; track handle) { + + } +
+
+} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.scss new file mode 100644 index 000000000000..5bac947ad57e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.scss @@ -0,0 +1,152 @@ +:host { + position: absolute; + inset: 0; + display: block; + pointer-events: none; +} + +.crop-overlay { + position: absolute; + inset: 0; +} + +.crop-box { + position: absolute; + box-sizing: border-box; + border: 1px solid var(--surface-0, #ffffff); + cursor: move; + pointer-events: auto; + outline: none; + + &:focus-visible { + border-color: var(--primary-color, #426bf0); + } +} + +// Solid panels that dim everything outside the selection. Four cheap solid fills +// replace a viewport-sized `box-shadow`, whose per-frame re-rasterization was the +// dominant cost of dragging/resizing the box. Geometry comes from `maskBounds`; +// each panel stretches to the overlay edges via the fixed insets below. +.crop-mask { + position: absolute; + background-color: rgba(0, 0, 0, 0.5); + pointer-events: none; + + &--top { + top: 0; + left: 0; + right: 0; + } + + &--bottom { + bottom: 0; + left: 0; + right: 0; + } + + &--left { + left: 0; + } + + &--right { + right: 0; + } +} + +.crop-grid { + position: absolute; + inset: 0; + + &__line { + position: absolute; + background-color: rgba(255, 255, 255, 0.5); + + &--v1, + &--v2 { + top: 0; + bottom: 0; + width: 1px; + } + + &--v1 { + left: 33.333%; + } + + &--v2 { + left: 66.666%; + } + + &--h1, + &--h2 { + left: 0; + right: 0; + height: 1px; + } + + &--h1 { + top: 33.333%; + } + + &--h2 { + top: 66.666%; + } + } +} + +.crop-handle { + position: absolute; + width: 12px; + height: 12px; + background-color: var(--surface-0, #ffffff); + border: 1px solid var(--primary-color, #426bf0); + border-radius: 2px; + pointer-events: auto; + + &--tl { + top: -6px; + left: -6px; + cursor: nwse-resize; + } + + &--t { + top: -6px; + left: calc(50% - 6px); + cursor: ns-resize; + } + + &--tr { + top: -6px; + right: -6px; + cursor: nesw-resize; + } + + &--r { + top: calc(50% - 6px); + right: -6px; + cursor: ew-resize; + } + + &--br { + bottom: -6px; + right: -6px; + cursor: nwse-resize; + } + + &--b { + bottom: -6px; + left: calc(50% - 6px); + cursor: ns-resize; + } + + &--bl { + bottom: -6px; + left: -6px; + cursor: nesw-resize; + } + + &--l { + top: calc(50% - 6px); + left: -6px; + cursor: ew-resize; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.spec.ts new file mode 100644 index 000000000000..620cdd2ae6ed --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.spec.ts @@ -0,0 +1,241 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorCropOverlayComponent } from './dot-image-editor-crop-overlay.component'; + +import { ImageEditorStore } from '../../store/image-editor.store'; + +const IMAGE_RECT = { x: 0, y: 0, width: 400, height: 300 }; +const NATURAL = { naturalWidth: 800, naturalHeight: 600 }; + +describe('DotImageEditorCropOverlayComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const createComponent = createComponentFactory({ + component: DotImageEditorCropOverlayComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [ + mockProvider(ImageEditorStore, { + activeTool: () => 'crop', + assetContext: () => NATURAL + }) + ] + }); + + beforeEach(() => { + spectator = createComponent({ detectChanges: false }); + spectator.setInput('imageRect', IMAGE_RECT); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + spectator.detectChanges(); + }); + + it('should render the crop box and eight resize handles', () => { + expect(spectator.query(byTestId('image-editor-crop-box'))).toExist(); + + const handles = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']; + handles.forEach((position) => { + expect(spectator.query(byTestId(`image-editor-crop-handle-${position}`))).toExist(); + }); + }); + + it('should dispatch cropApplied with a natural-pixel rect when applyCrop is called', () => { + spectator.component.applyCrop(); + + // The default selection covers the whole image, so scaling 400x300 CSS px + // up to 800x600 natural px yields the full natural rectangle. + expect(dispatcher.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { x: 0, y: 0, w: 800, h: 600, active: true, aspect: null } + }), + { scope: 'self' } + ); + }); + + it('should dispatch cropCancelled when cancelCrop is called', () => { + spectator.component.cancelCrop(); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: expect.stringContaining('cropCancelled') }), + { scope: 'self' } + ); + }); + + it('renders four solid dim panels aligned to the crop box edges', () => { + // Shrink the box 100px from the right so it no longer fills the image and the + // dim is visible; the right panel should then start at the box's right edge. + const rightHandle = spectator.query(byTestId('image-editor-crop-handle-r')); + rightHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 300, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 300, clientY: 150 })); + spectator.detectChanges(); + + ['top', 'bottom', 'left', 'right'].forEach((side) => { + expect(spectator.query(`.crop-mask--${side}`)).toExist(); + }); + + // Box now spans x:0..300, so the right dim panel begins at x=300. + const right = spectator.query('.crop-mask--right'); + expect(right!.style.left).toBe('300px'); + }); + + it('should nudge the crop rect by 1px on ArrowRight from the focused box', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + + // The box is seeded to the full image width, so it has no room to move + // right. Shrink it from the right edge first by dragging the `r` handle + // 50px to the left, leaving 50px of horizontal slack. jsdom does not + // expose a global PointerEvent constructor, so a MouseEvent stands in: + // the handlers only read clientX/clientY and the window listeners are + // keyed by the pointer event-type strings. + const rightHandle = spectator.query(byTestId('image-editor-crop-handle-r')); + rightHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 350, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 350, clientY: 150 })); + spectator.detectChanges(); + + // The box now starts flush against the left edge of the image. + expect(box!.style.left).toEqual('0px'); + + spectator.dispatchKeyboardEvent(box!, 'keydown', 'ArrowRight'); + spectator.detectChanges(); + + expect(box!.style.left).toEqual('1px'); + }); + + it('should lock the crop to its aspect ratio when dragging a corner with Shift', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + const brHandle = spectator.query(byTestId('image-editor-crop-handle-br')); + + // The box starts at the full 400x300 image (4:3). Drag the bottom-right + // corner inward with Shift held; the locked resize must keep the 4:3 ratio + // and stay anchored at the opposite (top-left) corner. + brHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, clientY: 300 })); + window.dispatchEvent( + new MouseEvent('pointermove', { clientX: 300, clientY: 200, shiftKey: true }) + ); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 300, clientY: 200 })); + spectator.detectChanges(); + + expect(parseFloat(box!.style.width) / parseFloat(box!.style.height)).toBeCloseTo( + 400 / 300, + 5 + ); + expect(box!.style.left).toEqual('0px'); + expect(box!.style.top).toEqual('0px'); + }); + + it('should resize a corner freely (ignoring aspect) without Shift', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + const brHandle = spectator.query(byTestId('image-editor-crop-handle-br')); + + // The same inward corner drag without Shift is free-form: width and height + // follow the pointer independently (400→300, 300→200), breaking the ratio. + brHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, clientY: 300 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 300, clientY: 200 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 300, clientY: 200 })); + spectator.detectChanges(); + + expect(box!.style.width).toEqual('300px'); + expect(box!.style.height).toEqual('200px'); + }); + + it('should reshape the box to the locked aspect, centered and fit to the image', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + + // The 400x300 image is 4:3 (1.33). A 1:1 lock fits the full height (300) and + // a matching 300 width, centered horizontally → x=(400-300)/2=50, y=0. + spectator.setInput('aspect', 1); + spectator.detectChanges(); + + expect(parseFloat(box!.style.width) / parseFloat(box!.style.height)).toBeCloseTo(1, 5); + expect(box!.style.width).toEqual('300px'); + expect(box!.style.height).toEqual('300px'); + expect(box!.style.left).toEqual('50px'); + expect(box!.style.top).toEqual('0px'); + }); + + it('should preserve the locked aspect when resizing a corner without Shift', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + + // Lock to 1:1 first (box becomes 300x300 at x=50), then drag the bottom-right + // corner inward WITHOUT Shift: the locked ratio must still hold. + spectator.setInput('aspect', 1); + spectator.detectChanges(); + + const brHandle = spectator.query(byTestId('image-editor-crop-handle-br')); + brHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 350, clientY: 300 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 250, clientY: 200 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 250, clientY: 200 })); + spectator.detectChanges(); + + expect(parseFloat(box!.style.width) / parseFloat(box!.style.height)).toBeCloseTo(1, 5); + }); + + it('should expose the crop box size in natural image pixels', () => { + // The default selection covers the whole 400x300 rendered image; the asset is + // 800x600 natural, so the box reports the full 800x600 natural size. + expect(spectator.component.naturalCropSize()).toEqual({ width: 800, height: 600 }); + }); + + it('should track the natural size as the box is resized on the canvas', () => { + // Shrink the box from the right edge by 50 CSS px (400→350). At the 2x scale + // the reported natural width follows (700) while the height is unchanged. + const rightHandle = spectator.query(byTestId('image-editor-crop-handle-r')); + rightHandle!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 350, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 350, clientY: 150 })); + spectator.detectChanges(); + + expect(spectator.component.naturalCropSize()).toEqual({ width: 700, height: 600 }); + }); + + it('should resize the box to a given natural size, centered on its current center', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + + // Request a 400x300 natural box. At the 2x scale that is 200x150 CSS px, + // centered in the 400x300 image → x=(400-200)/2=100, y=(300-150)/2=75. + spectator.component.setNaturalCropSize(400, 300); + spectator.detectChanges(); + + expect(box!.style.width).toEqual('200px'); + expect(box!.style.height).toEqual('150px'); + expect(box!.style.left).toEqual('100px'); + expect(box!.style.top).toEqual('75px'); + expect(spectator.component.naturalCropSize()).toEqual({ width: 400, height: 300 }); + }); + + it('should clamp a requested natural size to the rendered image bounds', () => { + const box = spectator.query(byTestId('image-editor-crop-box')); + + // Request a natural size larger than the source (1600x1200 vs 800x600); the + // CSS-px box is clamped to the full rendered image (400x300). + spectator.component.setNaturalCropSize(1600, 1200); + spectator.detectChanges(); + + expect(box!.style.width).toEqual('400px'); + expect(box!.style.height).toEqual('300px'); + }); + + it('should stop propagation and dispatch cropCancelled on Escape', () => { + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + const stopSpy = jest.spyOn(event, 'stopPropagation'); + + spectator.element.dispatchEvent(event); + + expect(stopSpy).toHaveBeenCalled(); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: expect.stringContaining('cropCancelled') }), + { scope: 'self' } + ); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.ts new file mode 100644 index 000000000000..7efcc357df4a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.ts @@ -0,0 +1,530 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + NgZone, + signal, + untracked +} from '@angular/core'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorOverlayEnterLeave } from '../../animations/image-editor.animations'; +import { + CROP_HANDLES, + CROP_NUDGE_STEP, + CROP_NUDGE_STEP_LARGE, + MIN_CROP_SIZE +} from '../../image-editor.constants'; +import { Dimensions, HandlePosition, ImageRect, LocalRect } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { clamp } from '../../utils/dimensions.util'; + +/** + * Interactive crop overlay rendered on top of the image canvas while the crop + * tool is active. Presents a draggable/resizable selection rectangle with a + * rule-of-thirds grid and eight resize handles. The selection is kept in CSS px + * (local to the rendered image) and converted to natural image pixels only when + * the user applies the crop. Keyboard control mirrors the pointer interactions: + * arrows nudge, Enter applies and Escape cancels. + */ +@Component({ + selector: 'dot-image-editor-crop-overlay', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DotMessagePipe], + templateUrl: './dot-image-editor-crop-overlay.component.html', + styleUrl: './dot-image-editor-crop-overlay.component.scss', + animations: [imageEditorOverlayEnterLeave()], + host: { '(keydown.escape)': 'onEscape($event)' } +}) +export class DotImageEditorCropOverlayComponent { + /** Bounds of the rendered image within the canvas, in CSS px. */ + imageRect = input(); + + /** + * Initial crop selection (image-local CSS px) to seed on activation — set when + * the user switches to crop while zoomed, so the box frames what was in view. + * When unset the selection defaults to the full image. + */ + initialRect = input(); + + /** + * Locked aspect ratio (width / height) for the crop box, or `null` for + * free-form. When a non-null ratio is set the box is reshaped to it (centered + * and maximized to fit the rendered image) and subsequent handle-resizes keep + * the ratio; `null` restores unconstrained, Shift-to-lock free-form behavior. + */ + aspect = input(null); + + readonly #store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorToolEvents); + readonly #zone = inject(NgZone); + + /** The resize handles rendered around the crop box. */ + protected readonly handles = CROP_HANDLES; + + /** Whether the crop tool is the active canvas tool. */ + protected readonly isActive = computed(() => this.#store.activeTool() === 'crop'); + + /** Crop selection in CSS px, local to the rendered image origin. */ + protected readonly cropRect = signal({ x: 0, y: 0, width: 0, height: 0 }); + + /** + * The current crop box size in natural image pixels, derived by scaling the + * rendered CSS-px box up to the source resolution. Surfaced so the canvas + * footer can show (and edit) the crop size as real pixels; `0×0` when there + * is no rendered image to scale against. Updates live as the box is dragged + * or resized. + */ + readonly naturalCropSize = computed( + () => { + const rect = this.imageRect(); + const crop = this.cropRect(); + + if (!rect || rect.width === 0 || rect.height === 0) { + return { width: 0, height: 0 }; + } + + const { naturalWidth, naturalHeight } = this.#store.assetContext(); + + return { + width: Math.round((crop.width * naturalWidth) / rect.width), + height: Math.round((crop.height * naturalHeight) / rect.height) + }; + }, + // Compare by value, not reference: a box MOVE produces a fresh object every + // frame but with identical width/height, so this stops the (size-only) footer + // inputs from re-rendering while dragging the box around. Only an actual + // size change (a resize) notifies and updates the inputs. + { equal: (a, b) => a.width === b.width && a.height === b.height } + ); + + /** Absolute CSS-px position of the crop box within the canvas. */ + protected readonly boxStyle = computed(() => { + const rect = this.imageRect(); + const crop = this.cropRect(); + + return { + left: `${(rect?.x ?? 0) + crop.x}px`, + top: `${(rect?.y ?? 0) + crop.y}px`, + width: `${crop.width}px`, + height: `${crop.height}px` + }; + }); + + /** + * Edges of the crop box (CSS px, overlay-local) used to lay out the four dim + * panels that darken everything outside the selection. We dim with solid panels + * rather than the box's `box-shadow` because a viewport-sized shadow re-rasterizes + * the whole dimmed area on every frame — the dominant cost while dragging — whereas + * four solid fills driven by this signal are cheap. The panels stretch to the + * overlay edges via `right:0`/`bottom:0`, so only the box edges are needed here. + */ + protected readonly maskBounds = computed(() => { + const rect = this.imageRect(); + const crop = this.cropRect(); + const left = (rect?.x ?? 0) + crop.x; + const top = (rect?.y ?? 0) + crop.y; + + return { + left, + top, + right: left + crop.width, + bottom: top + crop.height, + height: crop.height + }; + }); + + constructor() { + // Seed the selection whenever the tool activates or the rendered bounds + // change while cropping: to the captured visible region when the user + // switched to crop while zoomed, otherwise to the full rendered image. + // Reads `aspect()` untracked so this fires only on activation/bounds + // changes — the dedicated reshape effect below owns reacting to the ratio. + effect(() => { + const rect = this.imageRect(); + + if (this.isActive() && rect) { + const ratio = untracked(this.aspect); + if (ratio) { + this.cropRect.set(this.#aspectFittedRect(ratio, rect)); + + return; + } + + const initial = this.initialRect(); + this.cropRect.set( + initial ?? { x: 0, y: 0, width: rect.width, height: rect.height } + ); + } + }); + + // Reshape the box whenever the locked ratio changes while cropping: a + // non-null ratio fits a centered, maximized box of that ratio inside the + // rendered image. `null` (Free) leaves the current box untouched. + effect(() => { + const ratio = this.aspect(); + const rect = untracked(this.imageRect); + + if (untracked(this.isActive) && rect && ratio) { + this.cropRect.set(this.#aspectFittedRect(ratio, rect)); + } + }); + } + + /** + * The largest box of the given aspect ratio (width / height) that fits the + * rendered image, centered within it — in image-local CSS px. + */ + #aspectFittedRect(aspect: number, rect: ImageRect): LocalRect { + const rectAspect = rect.width / rect.height; + + let width: number; + let height: number; + if (aspect > rectAspect) { + width = rect.width; + height = width / aspect; + } else { + height = rect.height; + width = height * aspect; + } + + return { + x: (rect.width - width) / 2, + y: (rect.height - height) / 2, + width, + height + }; + } + + /** Begins a box move from a pointer press, tracking until release. */ + protected onBoxPointerDown(event: PointerEvent): void { + event.preventDefault(); + const start = this.cropRect(); + + this.#trackPointer(event, (dx, dy) => { + this.#moveTo(start.x + dx, start.y + dy); + }); + } + + /** Begins a resize from a handle pointer press, tracking until release. */ + protected onHandlePointerDown(event: PointerEvent, position: HandlePosition): void { + event.preventDefault(); + event.stopPropagation(); + const start = this.cropRect(); + + this.#trackPointer(event, (dx, dy, shiftKey) => { + this.#resize(start, position, dx, dy, shiftKey); + }); + } + + /** Nudges or applies/cancels the crop in response to keyboard input. */ + protected onBoxKeydown(event: KeyboardEvent): void { + const step = event.shiftKey ? CROP_NUDGE_STEP_LARGE : CROP_NUDGE_STEP; + const current = this.cropRect(); + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + this.#moveTo(current.x - step, current.y); + break; + case 'ArrowRight': + event.preventDefault(); + this.#moveTo(current.x + step, current.y); + break; + case 'ArrowUp': + event.preventDefault(); + this.#moveTo(current.x, current.y - step); + break; + case 'ArrowDown': + event.preventDefault(); + this.#moveTo(current.x, current.y + step); + break; + case 'Enter': + event.preventDefault(); + this.applyCrop(); + break; + default: + break; + } + } + + /** + * Applies the current crop selection, converting it from rendered CSS px to + * natural image pixels before dispatching. Invoked by the canvas footer's + * "Apply crop" action and by the Enter key while the box is focused. + */ + applyCrop(): void { + const rect = this.imageRect(); + + if (!rect || rect.width === 0 || rect.height === 0) { + return; + } + + const { naturalWidth, naturalHeight } = this.#store.assetContext(); + const crop = this.cropRect(); + const scaleX = naturalWidth / rect.width; + const scaleY = naturalHeight / rect.height; + + this.#dispatch.cropApplied({ + x: Math.round(crop.x * scaleX), + y: Math.round(crop.y * scaleY), + w: Math.round(crop.width * scaleX), + h: Math.round(crop.height * scaleY), + active: true, + aspect: null + }); + } + + /** + * Cancels cropping and restores the move tool. Invoked by the canvas footer's + * "Cancel" action and by the Escape key. + */ + cancelCrop(): void { + this.#dispatch.cropCancelled(); + } + + /** + * Resizes the crop box to the given size in natural image pixels, keeping it + * centered on its current center and clamped to the rendered image. Invoked by + * the canvas footer's width/height inputs so typing a pixel size drives the + * on-canvas box. When a ratio is locked the natural size is expected to already + * honor it; the resulting CSS-px box is clamped to the image, which may shrink + * it proportionally if it would overflow. No-op without a rendered image. + */ + setNaturalCropSize(width: number, height: number): void { + const rect = this.imageRect(); + + if (!rect || rect.width === 0 || rect.height === 0 || width <= 0 || height <= 0) { + return; + } + + const { naturalWidth, naturalHeight } = this.#store.assetContext(); + const scaleX = rect.width / naturalWidth; + const scaleY = rect.height / naturalHeight; + + // Convert to rendered CSS px and clamp to the image, never below the + // minimum usable size. + const cssWidth = clamp(width * scaleX, MIN_CROP_SIZE, rect.width); + const cssHeight = clamp(height * scaleY, MIN_CROP_SIZE, rect.height); + + // Keep the box centered on its current center, then clamp the origin so it + // stays fully inside the image. + const current = this.cropRect(); + const centerX = current.x + current.width / 2; + const centerY = current.y + current.height / 2; + + this.cropRect.set({ + x: clamp(centerX - cssWidth / 2, 0, rect.width - cssWidth), + y: clamp(centerY - cssHeight / 2, 0, rect.height - cssHeight), + width: cssWidth, + height: cssHeight + }); + } + + /** + * Intercepts Escape so the host dialog does not close while cropping; the + * keypress instead cancels the crop selection. + */ + protected onEscape(event: KeyboardEvent): void { + if (!this.isActive()) { + return; + } + + event.stopPropagation(); + this.cancelCrop(); + } + + /** Moves the box to a new local origin, clamped within the rendered image. */ + #moveTo(x: number, y: number): void { + const rect = this.imageRect(); + + if (!rect) { + return; + } + + const crop = this.cropRect(); + this.cropRect.set({ + ...crop, + x: clamp(x, 0, rect.width - crop.width), + y: clamp(y, 0, rect.height - crop.height) + }); + } + + /** + * Resizes the box from a handle, constrained within the rendered image. The + * selection is locked to a fixed aspect ratio when one is set via the `aspect` + * input (a preset is active) or while Shift is held over a corner (the common + * "shift to keep proportions" behavior). The locked ratio comes from the + * `aspect` input when present, otherwise from the box's starting proportions; + * edge handles and unmodified free-form drags resize freely. + */ + #resize( + start: LocalRect, + position: HandlePosition, + dx: number, + dy: number, + shiftKey = false + ): void { + const rect = this.imageRect(); + + if (!rect) { + return; + } + + // Corner handles carry both an horizontal and a vertical edge ('tl', etc.). + const isCorner = position.length === 2; + const locked = this.aspect(); + const lockAspect = locked != null || shiftKey; + + if (lockAspect && isCorner && start.height > 0) { + const ratio = locked ?? start.width / start.height; + this.cropRect.set(this.#resizeLockedAspect(start, position, dx, dy, rect, ratio)); + + return; + } + + let { x, y, width, height } = start; + const right = start.x + start.width; + const bottom = start.y + start.height; + + if (position.includes('l')) { + x = clamp(start.x + dx, 0, right - MIN_CROP_SIZE); + width = right - x; + } + + if (position.includes('r')) { + width = clamp(start.width + dx, MIN_CROP_SIZE, rect.width - start.x); + } + + if (position.includes('t')) { + y = clamp(start.y + dy, 0, bottom - MIN_CROP_SIZE); + height = bottom - y; + } + + if (position.includes('b')) { + height = clamp(start.height + dy, MIN_CROP_SIZE, rect.height - start.y); + } + + this.cropRect.set({ x, y, width, height }); + } + + /** + * Corner resize that preserves a fixed aspect ratio. The corner opposite the + * dragged handle stays anchored; the dominant pointer axis drives the size and + * the other dimension follows the locked ratio. When a box edge is reached, + * both dimensions scale down together so the ratio holds. + */ + #resizeLockedAspect( + start: LocalRect, + position: HandlePosition, + dx: number, + dy: number, + rect: ImageRect, + aspect: number + ): LocalRect { + // The dragged handle grows the box left/up (sign -1) or right/down (+1) + // from the opposite, anchored corner. + const growLeft = position.includes('l'); + const growUp = position.includes('t'); + const anchorX = growLeft ? start.x + start.width : start.x; + const anchorY = growUp ? start.y + start.height : start.y; + + // Free-form proposed size from the pointer delta. + const proposedWidth = Math.max(MIN_CROP_SIZE, start.width + (growLeft ? -dx : dx)); + const proposedHeight = Math.max(MIN_CROP_SIZE, start.height + (growUp ? -dy : dy)); + + // Drive by whichever axis moved more, relative to the starting box. + let width: number; + let height: number; + if (proposedWidth / start.width >= proposedHeight / start.height) { + width = proposedWidth; + height = width / aspect; + } else { + height = proposedHeight; + width = height * aspect; + } + + // Keep the box inside the image from the anchor, preserving the ratio. + const maxWidth = growLeft ? anchorX : rect.width - anchorX; + const maxHeight = growUp ? anchorY : rect.height - anchorY; + + if (width > maxWidth) { + width = maxWidth; + height = width / aspect; + } + + if (height > maxHeight) { + height = maxHeight; + width = height * aspect; + } + + return { + x: growLeft ? anchorX - width : anchorX, + y: growUp ? anchorY - height : anchorY, + width, + height + }; + } + + /** + * Tracks pointer movement until release, reporting the delta and the live + * Shift-key state to `onMove` (Shift toggles aspect-ratio locking mid-drag). + * + * Performance: high-frequency pointing devices (120–240 Hz trackpads/mice) fire + * `pointermove` far faster than the screen refreshes, and each move mutates the + * crop box — which now also re-renders the footer's width/height inputs. To keep + * dragging smooth we (a) listen OUTSIDE Angular so a raw move never triggers a + * change-detection tick, and (b) coalesce moves to a single state update per + * animation frame, re-entering the zone only in that rAF flush. + */ + #trackPointer( + start: PointerEvent, + onMove: (dx: number, dy: number, shiftKey: boolean) => void + ): void { + const originX = start.clientX; + const originY = start.clientY; + let frame = 0; + let latest = { dx: 0, dy: 0, shiftKey: start.shiftKey }; + + // Apply the latest pointer position inside Angular (so OnPush re-renders the + // box and the size inputs), at most once per frame. + const apply = () => { + frame = 0; + this.#zone.run(() => onMove(latest.dx, latest.dy, latest.shiftKey)); + }; + const move = (event: PointerEvent) => { + latest = { + dx: event.clientX - originX, + dy: event.clientY - originY, + shiftKey: event.shiftKey + }; + + if (!frame) { + frame = requestAnimationFrame(apply); + } + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + + if (frame) { + cancelAnimationFrame(frame); + } + // Flush the final position synchronously so the box settles exactly + // where the pointer was released. + apply(); + }; + + this.#zone.runOutsideAngular(() => { + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html new file mode 100644 index 000000000000..7ce9e022010b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html @@ -0,0 +1,19 @@ +@if (isActive() && imageRect()) { +
+
+ + +
+} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss new file mode 100644 index 000000000000..da46f444d3b8 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss @@ -0,0 +1,48 @@ +:host { + position: absolute; + inset: 0; + display: block; + pointer-events: none; +} + +.focal-overlay { + position: absolute; + inset: 0; +} + +.focal-surface { + position: absolute; + inset: 0; + cursor: crosshair; + pointer-events: auto; +} + +.focal-marker { + position: absolute; + width: 24px; + height: 24px; + box-sizing: border-box; + transform: translate(-50%, -50%); + border: 2px solid var(--surface-0, #ffffff); + border-radius: 9999px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); + cursor: grab; + pointer-events: auto; + outline: none; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 6px; + height: 6px; + transform: translate(-50%, -50%); + background-color: var(--primary-color, #426bf0); + border-radius: 9999px; + } + + &:focus-visible { + border-color: var(--primary-color, #426bf0); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts new file mode 100644 index 000000000000..77e65e94d92e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts @@ -0,0 +1,117 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorFocalOverlayComponent } from './dot-image-editor-focal-overlay.component'; + +import { ImageEditorStore } from '../../store/image-editor.store'; + +const IMAGE_RECT = { x: 0, y: 0, width: 400, height: 300 }; + +describe('DotImageEditorFocalOverlayComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const createComponent = createComponentFactory({ + component: DotImageEditorFocalOverlayComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [ + mockProvider(ImageEditorStore, { + activeTool: () => 'focal', + focalPoint: () => ({ x: 0.5, y: 0.5, active: false }) + }) + ] + }); + + beforeEach(() => { + spectator = createComponent({ detectChanges: false }); + spectator.setInput('imageRect', IMAGE_RECT); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + spectator.detectChanges(); + }); + + it('should render the focal marker when the tool is active', () => { + expect(spectator.query(byTestId('image-editor-focal-marker'))).toExist(); + }); + + it('should dispatch focalPointSet with normalized 0..1 coordinates when setFocalPoint is called', () => { + spectator.component.setFocalPoint(); + + // `injectDispatch` forwards a `{ scope: 'self' }` options argument, so the + // dispatched event is read from the first call argument directly. + const event = dispatchedEvent('focalPointSet'); + expect(event).toBeDefined(); + + const { x, y } = event!.payload as { x: number; y: number }; + // The seeded focal point is the image center. + expect(event!.payload).toEqual({ x: 0.5, y: 0.5 }); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(1); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(1); + }); + + it('should commit the focal point live when the marker is placed and released', () => { + // jsdom has no global PointerEvent constructor; a MouseEvent stands in — + // the handler only reads clientX/clientY and keys window listeners by type. + const surface = spectator.query(byTestId('image-editor-focal-surface')); + surface!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 100, clientY: 150 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 100, clientY: 150 })); + + // 100/400 = 0.25 horizontally, 150/300 = 0.5 vertically. + const event = dispatchedEvent('focalPointSet'); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ x: 0.25, y: 0.5 }); + }); + + it('maps the click through the zoom scale so the point lands under the cursor when zoomed', () => { + // Zoomed out to 50%: the painted click offset is half the logical px, so the + // handler must divide by the scale. Before the fix this mapped to 0.25/0.2. + spectator.setInput('scale', 0.5); + spectator.detectChanges(); + + const surface = spectator.query(byTestId('image-editor-focal-surface')); + surface!.dispatchEvent(new MouseEvent('pointerdown', { clientX: 100, clientY: 60 })); + window.dispatchEvent(new MouseEvent('pointerup', { clientX: 100, clientY: 60 })); + + // (100 / 0.5) / 400 = 0.5 ; (60 / 0.5) / 300 = 0.4 + const event = dispatchedEvent('focalPointSet'); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ x: 0.5, y: 0.4 }); + }); + + it('should leave the focal tool (toolSelected move) when done is called', () => { + spectator.component.done(); + + const event = dispatchedEvent('toolSelected'); + expect(event).toBeDefined(); + expect(event!.payload).toBe('move'); + }); + + it('should stop propagation and leave the focal tool on Escape', () => { + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + const stopSpy = jest.spyOn(event, 'stopPropagation'); + + spectator.element.dispatchEvent(event); + + expect(stopSpy).toHaveBeenCalled(); + expect(dispatchedEvent('toolSelected')).toBeDefined(); + }); + + /** Finds the first dispatched event whose type matches the given suffix. */ + function dispatchedEvent(typeSuffix: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find(([dispatched]) => + dispatched.type.includes(typeSuffix) + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts new file mode 100644 index 000000000000..7886c426f751 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts @@ -0,0 +1,197 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + input, + signal +} from '@angular/core'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { FOCAL_NUDGE_STEP, FOCAL_NUDGE_STEP_LARGE } from '../../image-editor.constants'; +import { ImageRect, NormalizedPoint } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { clamp } from '../../utils/dimensions.util'; + +/** + * Focal point overlay rendered on top of the image canvas while the focal tool + * is active. Presents a draggable circular target marker positioned from the + * normalized focal point. Clicking or dragging sets a local normalized point + * (kept in 0..1) which is dispatched only when the user confirms. Keyboard + * control mirrors the pointer interactions: arrows move, Enter sets and Escape + * cancels. + */ +@Component({ + selector: 'dot-image-editor-focal-overlay', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DotMessagePipe], + templateUrl: './dot-image-editor-focal-overlay.component.html', + styleUrl: './dot-image-editor-focal-overlay.component.scss', + host: { '(keydown.escape)': 'onEscape($event)' } +}) +export class DotImageEditorFocalOverlayComponent { + /** Bounds of the rendered image within the canvas, in CSS px. */ + imageRect = input(); + + /** + * Current stage zoom scale (1 = 100%). The overlay sits inside the + * zoom-scaled stage, so `getBoundingClientRect()` returns painted pixels; + * `imageRect` is in unscaled (logical) px. This factor converts a painted + * pointer offset back to logical px so clicks land where the user pressed at + * any zoom level. + */ + scale = input(1); + + readonly #store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorToolEvents); + readonly #host = inject(ElementRef); + + /** Whether the focal tool is the active canvas tool. */ + protected readonly isActive = computed(() => this.#store.activeTool() === 'focal'); + + /** Working focal point as normalized 0..1 coordinates. */ + protected readonly point = signal({ x: 0.5, y: 0.5 }); + + /** Absolute CSS-px position of the marker center within the canvas. */ + protected readonly markerStyle = computed(() => { + const rect = this.imageRect(); + const { x, y } = this.point(); + + return { + left: `${(rect?.x ?? 0) + x * (rect?.width ?? 0)}px`, + top: `${(rect?.y ?? 0) + y * (rect?.height ?? 0)}px` + }; + }); + + constructor() { + // Seed the marker from the stored focal point whenever the tool + // activates so it reflects the persisted position. + effect(() => { + if (this.isActive()) { + const focal = this.#store.focalPoint(); + this.point.set({ x: focal.x, y: focal.y }); + } + }); + } + + /** + * Places the focal point at the clicked location and tracks the drag, then + * commits it on release — the marker IS the focal point, so positioning it + * sets it (no separate confirm step). + */ + protected onSurfacePointerDown(event: PointerEvent): void { + event.preventDefault(); + this.#setFromClient(event.clientX, event.clientY); + this.#trackPointer( + (clientX, clientY) => this.#setFromClient(clientX, clientY), + () => this.setFocalPoint() + ); + } + + /** Moves (and commits) or finishes the focal point in response to keyboard input. */ + protected onMarkerKeydown(event: KeyboardEvent): void { + const step = event.shiftKey ? FOCAL_NUDGE_STEP_LARGE : FOCAL_NUDGE_STEP; + const current = this.point(); + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + this.#moveTo(current.x - step, current.y); + this.setFocalPoint(); + break; + case 'ArrowRight': + event.preventDefault(); + this.#moveTo(current.x + step, current.y); + this.setFocalPoint(); + break; + case 'ArrowUp': + event.preventDefault(); + this.#moveTo(current.x, current.y - step); + this.setFocalPoint(); + break; + case 'ArrowDown': + event.preventDefault(); + this.#moveTo(current.x, current.y + step); + this.setFocalPoint(); + break; + case 'Enter': + event.preventDefault(); + this.done(); + break; + default: + break; + } + } + + /** Commits the current marker position as the focal point (normalized 0..1). */ + setFocalPoint(): void { + const { x, y } = this.point(); + this.#dispatch.focalPointSet({ x: clamp(x, 0, 1), y: clamp(y, 0, 1) }); + } + + /** Leaves the focal tool (back to move), keeping the placed focal point. */ + done(): void { + this.#dispatch.toolSelected('move'); + } + + /** + * Intercepts Escape so the host dialog does not close while placing the focal + * point; the keypress instead leaves the focal tool (the point stays set). + */ + protected onEscape(event: KeyboardEvent): void { + if (!this.isActive()) { + return; + } + + event.stopPropagation(); + this.done(); + } + + /** Converts a client-space pointer position into a normalized focal point. */ + #setFromClient(clientX: number, clientY: number): void { + const rect = this.imageRect(); + + if (!rect || rect.width === 0 || rect.height === 0) { + return; + } + + // `host` is painted (zoom-scaled); divide the painted offset by the zoom + // scale to get logical px before comparing against `rect` (logical), so the + // point lands under the cursor at any zoom. Pan is already folded into + // `host.left/top`. + const host = this.#hostRect(); + const scale = this.scale() || 1; + const localX = (clientX - host.left) / scale - rect.x; + const localY = (clientY - host.top) / scale - rect.y; + this.#moveTo(localX / rect.width, localY / rect.height); + } + + /** Sets the working point from normalized coordinates, clamped to 0..1. */ + #moveTo(x: number, y: number): void { + this.point.set({ x: clamp(x, 0, 1), y: clamp(y, 0, 1) }); + } + + /** Tracks pointer movement until release, reporting position and a release hook. */ + #trackPointer(onMove: (clientX: number, clientY: number) => void, onUp: () => void): void { + const move = (event: PointerEvent) => onMove(event.clientX, event.clientY); + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + onUp(); + }; + + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + } + + /** The host element's viewport rectangle, used to map client coordinates. */ + #hostRect(): DOMRect { + return this.#host.nativeElement.getBoundingClientRect(); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.html new file mode 100644 index 000000000000..1f1355b4cef6 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.html @@ -0,0 +1,19 @@ +
+ + + + + download + + +
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.spec.ts new file mode 100644 index 000000000000..13ed57336818 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.spec.ts @@ -0,0 +1,74 @@ +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotImageEditorFooterComponent } from './dot-image-editor-footer.component'; + +import { imageEditorLifecycleEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +/** Resolves the inner native button for a PrimeNG control by its testid. */ +function nativeButton(spectator: Spectator, testId: string) { + return spectator.query(byTestId(testId))?.querySelector('button') as HTMLElement; +} + +describe('DotImageEditorFooterComponent', () => { + let spectator: Spectator; + let dispatcher: SpyObject; + + const isBusy = signal(false); + + const createComponent = createComponentFactory({ + component: DotImageEditorFooterComponent, + imports: [DotMessagePipe], + providers: [mockProvider(DotMessageService, { get: jest.fn((key: string) => key) })], + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { isBusy })] + }); + + beforeEach(() => { + isBusy.set(false); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the cancel and download actions', () => { + expect(spectator.query(byTestId('image-editor-cancel-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-download-btn'))).toBeTruthy(); + }); + + it('should emit cancel when Cancel is clicked', () => { + const cancelSpy = jest.spyOn(spectator.component.cancel, 'emit'); + + spectator.click(nativeButton(spectator, 'image-editor-cancel-btn')); + + expect(cancelSpy).toHaveBeenCalledTimes(1); + }); + + it('should dispatch downloadRequested when Download is clicked', () => { + spectator.click(nativeButton(spectator, 'image-editor-download-btn')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorLifecycleEvents.downloadRequested(), + { scope: 'self' } + ); + }); + + it('should disable Download when isBusy is true', () => { + isBusy.set(true); + spectator.detectChanges(); + + expect(nativeButton(spectator, 'image-editor-download-btn').disabled).toBe(true); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.ts new file mode 100644 index 000000000000..b30e4cf03707 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.ts @@ -0,0 +1,38 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorLifecycleEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +/** + * Footer action bar of the image editor dialog. Reads readiness from the + * {@link ImageEditorStore} (`isBusy`) and dispatches the download lifecycle event. + * Cancel is surfaced as an output so the owning dialog component controls closing. + * (Saving the edited image back to the field is handled in a separate issue.) + */ +@Component({ + selector: 'dot-image-editor-footer', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-footer.component.html' +}) +export class DotImageEditorFooterComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** Lifecycle event dispatcher for the download action. */ + protected readonly dispatch = injectDispatch(imageEditorLifecycleEvents); + + /** Emitted when the user clicks Cancel; the dialog owner closes the editor. */ + cancel = output(); + + /** Dispatches a download of the current preview. */ + protected onDownload(): void { + this.dispatch.downloadRequested(); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html new file mode 100644 index 000000000000..997f2024734c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html @@ -0,0 +1,39 @@ +
+

{{ 'edit.content.image-editor.title' | dm }}

+ +
+ + + {{ fullscreenIcon() }} + + + + + + close + + +
+
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts new file mode 100644 index 000000000000..15dd8c0835ba --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts @@ -0,0 +1,89 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotImageEditorHeaderComponent } from './dot-image-editor-header.component'; + +import { imageEditorViewEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +describe('DotImageEditorHeaderComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const isFullscreen = signal(false); + + const createComponent = createComponentFactory({ + component: DotImageEditorHeaderComponent, + imports: [ButtonModule, DotMessagePipe], + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { isFullscreen })] + }); + + beforeEach(() => { + isFullscreen.set(false); + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the title', () => { + const header = spectator.query(byTestId('image-editor-header')); + + expect(header).toBeTruthy(); + expect(header).toHaveText('edit.content.image-editor.title'); + }); + + it('should expose the header, full-screen and close testids', () => { + expect(spectator.query(byTestId('image-editor-header'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-fullscreen-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-close-btn'))).toBeTruthy(); + }); + + it('should emit close when the close button is clicked', () => { + const closeSpy = jest.spyOn(spectator.component.close, 'emit'); + const button = spectator.query(byTestId('image-editor-close-btn'))?.querySelector('button'); + + spectator.click(button as HTMLElement); + + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('should dispatch fullscreenToggled when the full-screen button is clicked', () => { + const button = spectator + .query(byTestId('image-editor-fullscreen-btn')) + ?.querySelector('button'); + + spectator.click(button as HTMLElement); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorViewEvents.fullscreenToggled(), + { + scope: 'self' + } + ); + }); + + it('should show the "enter full screen" icon while windowed', () => { + const icon = spectator + .query(byTestId('image-editor-fullscreen-btn')) + ?.querySelector('.material-symbols-outlined'); + + expect(icon?.textContent?.trim()).toBe('open_in_full'); + }); + + it('should swap the full-screen icon to "exit full screen" while full-screen', () => { + isFullscreen.set(true); + spectator.detectChanges(); + + const icon = spectator + .query(byTestId('image-editor-fullscreen-btn')) + ?.querySelector('.material-symbols-outlined'); + + expect(icon?.textContent?.trim()).toBe('close_fullscreen'); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts new file mode 100644 index 000000000000..7e08fc88ed2c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts @@ -0,0 +1,43 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorViewEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +/** + * Header bar of the image editor dialog. Renders the editor title on the left and, + * on the right, the full-screen toggle next to a close icon button (grouped as the + * dialog's window controls). Close emits {@link DotImageEditorHeaderComponent.close}; + * the full-screen toggle dispatches {@link imageEditorViewEvents} and the root + * component performs the actual dialog resize, reacting to `store.isFullscreen()`. + */ +@Component({ + selector: 'dot-image-editor-header', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, TooltipModule, DotMessagePipe], + templateUrl: './dot-image-editor-header.component.html' +}) +export class DotImageEditorHeaderComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + readonly #viewDispatch = injectDispatch(imageEditorViewEvents); + + /** Emitted when the user clicks the close (✕) button. */ + close = output(); + + /** Material Symbol ligature for the full-screen toggle, by current state. */ + protected readonly fullscreenIcon = computed(() => + this.store.isFullscreen() ? 'close_fullscreen' : 'open_in_full' + ); + + /** Toggles the editor dialog between its windowed size and full-screen. */ + protected toggleFullscreen(): void { + this.#viewDispatch.fullscreenToggled(); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.html new file mode 100644 index 000000000000..1355a8651efd --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.html @@ -0,0 +1,85 @@ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ + +
+
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.scss new file mode 100644 index 000000000000..dc520afb9f97 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.scss @@ -0,0 +1,95 @@ +.ie-adjust { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.ie-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + + &--inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; + } +} + +.ie-field__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +// Inline editable value: a borderless number field that reveals a border on +// hover and a focus ring on focus, mirroring the design's `.iem-value-input`. +// Native spinners are hidden to keep it compact and aligned with the slider. +.ie-field__value { + width: 3rem; + padding: 1px 4px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: inherit; + text-align: right; + font-variant-numeric: tabular-nums; + -moz-appearance: textfield; + appearance: textfield; + + &:hover { + border-color: var(--surface-200, #e2e8f0); + } + + &:focus { + outline: none; + border-color: var(--primary-color, #6366f1); + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; + } +} + +// Gradient slider tracks matching the design. The styleClass lands on the `.p-slider` +// track; we paint the gradient on it and make the filled range transparent so the +// full gradient shows. `:host ::ng-deep` scopes these to this panel's sliders. +:host ::ng-deep { + .ie-slider--brightness, + .ie-slider--brightness .p-slider { + background: linear-gradient(to right, #14151f, #f3eeb0); + } + + .ie-slider--hue, + .ie-slider--hue .p-slider { + background: linear-gradient( + to right, + hsl(0deg 90% 65%), + hsl(45deg 95% 60%), + hsl(120deg 60% 55%), + hsl(190deg 85% 55%), + hsl(240deg 80% 65%), + hsl(290deg 75% 65%), + hsl(360deg 90% 65%) + ); + } + + .ie-slider--saturation, + .ie-slider--saturation .p-slider { + background: linear-gradient(to right, #9aa3b2, #1e2a78); + } + + // Let the gradient show through PrimeNG's filled range. + .p-slider-range { + background: transparent; + } + + // White handle with a dark ring so it reads on every gradient. + .p-slider-handle { + background: #ffffff; + border: 2px solid #1e2a5e; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.spec.ts new file mode 100644 index 000000000000..621fe5c093c5 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.spec.ts @@ -0,0 +1,132 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorAdjustPanelComponent } from './dot-image-editor-adjust-panel.component'; + +import { ImageEditorStore } from '../../../store/image-editor.store'; + +const ADJUST = { brightness: 0, hue: 0, saturation: 0, grayscale: false }; + +describe('DotImageEditorAdjustPanelComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const createComponent = createComponentFactory({ + component: DotImageEditorAdjustPanelComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [ + mockProvider(ImageEditorStore, { + adjust: () => ADJUST + }) + ] + }); + + beforeEach(() => { + spectator = createComponent({ detectChanges: false }); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + spectator.detectChanges(); + }); + + it('should render the brightness, hue, saturation sliders and grayscale checkbox', () => { + expect(spectator.query(byTestId('image-editor-brightness-slider'))).toExist(); + expect(spectator.query(byTestId('image-editor-hue-slider'))).toExist(); + expect(spectator.query(byTestId('image-editor-saturation-slider'))).toExist(); + expect(spectator.query(byTestId('image-editor-grayscale-checkbox'))).toExist(); + }); + + it('should dispatch brightnessChanged with the committed value on slide end', () => { + spectator.triggerEventHandler( + '[data-testid="image-editor-brightness-slider"]', + 'onSlideEnd', + { + originalEvent: new MouseEvent('mouseup'), + value: 42 + } + ); + + const event = dispatchedEvent('brightnessChanged'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(42); + }); + + it('should dispatch hueChanged with the committed value on slide end', () => { + spectator.triggerEventHandler('[data-testid="image-editor-hue-slider"]', 'onSlideEnd', { + originalEvent: new MouseEvent('mouseup'), + value: -30 + }); + + const event = dispatchedEvent('hueChanged'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(-30); + }); + + it('should dispatch saturationChanged with the committed value on slide end', () => { + spectator.triggerEventHandler( + '[data-testid="image-editor-saturation-slider"]', + 'onSlideEnd', + { + originalEvent: new MouseEvent('mouseup'), + value: 75 + } + ); + + const event = dispatchedEvent('saturationChanged'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(75); + }); + + it('should dispatch brightnessChanged with the value typed into the number field', () => { + const input = spectator.query(byTestId('image-editor-brightness-value'))!; + input.value = '55'; + spectator.dispatchFakeEvent(input, 'change'); + + const event = dispatchedEvent('brightnessChanged'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(55); + }); + + it('should clamp a typed value to the slider range', () => { + const input = spectator.query(byTestId('image-editor-hue-value'))!; + input.value = '150'; + spectator.dispatchFakeEvent(input, 'change'); + + const event = dispatchedEvent('hueChanged'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(100); + // The field is corrected to the clamped value. + expect(input.value).toBe('100'); + }); + + it('should dispatch grayscaleToggled when the checkbox changes', () => { + spectator.triggerEventHandler( + '[data-testid="image-editor-grayscale-checkbox"]', + 'onChange', + { + originalEvent: new Event('change'), + checked: true + } + ); + + const event = dispatchedEvent('grayscaleToggled'); + expect(event).toBeDefined(); + expect(event!.payload).toBe(true); + }); + + /** Finds the first dispatched event whose type matches the given suffix. */ + function dispatchedEvent(typeSuffix: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find(([dispatched]) => + dispatched.type.includes(typeSuffix) + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.ts new file mode 100644 index 000000000000..609881508a8c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.ts @@ -0,0 +1,138 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { CheckboxChangeEvent, CheckboxModule } from 'primeng/checkbox'; +import { SliderChangeEvent, SliderModule, SliderSlideEndEvent } from 'primeng/slider'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { RANGES } from '../../../image-editor.constants'; +import { imageEditorAdjustEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; +import { clamp } from '../../../utils/dimensions.util'; + +// Brightness, hue and saturation share the same inclusive range, so the inline +// number fields all clamp to RANGES.brightness. + +/** + * Color & light adjustment panel. Binds brightness, hue and saturation sliders + * plus a grayscale checkbox to the {@link ImageEditorStore} `adjust` slice and + * dispatches the matching {@link imageEditorAdjustEvents} on user input. Sliders + * update their displayed value optimistically on `onChange` and dispatch the + * committed value on `onSlideEnd`, letting the store own debouncing of the + * resulting preview. Each value also doubles as an inline number field: typing a + * value commits it (clamped to the slider range) just like releasing the slider. + */ +@Component({ + selector: 'dot-image-editor-adjust-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, SliderModule, CheckboxModule, DotMessagePipe], + templateUrl: './dot-image-editor-adjust-panel.component.html', + styleUrl: './dot-image-editor-adjust-panel.component.scss' +}) +export class DotImageEditorAdjustPanelComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** Panel event dispatcher for color adjustment changes. */ + protected readonly dispatch = injectDispatch(imageEditorAdjustEvents); + + /** Optimistic brightness value shown while the slider is being dragged. */ + protected readonly brightness = signal(0); + /** Optimistic hue value shown while the slider is being dragged. */ + protected readonly hue = signal(0); + /** Optimistic saturation value shown while the slider is being dragged. */ + protected readonly saturation = signal(0); + + constructor() { + // Keep the optimistic slider values in sync with committed store state + // (e.g. after undo/redo, reset or removing a history entry). + effect(() => { + const adjust = this.store.adjust(); + this.brightness.set(adjust.brightness); + this.hue.set(adjust.hue); + this.saturation.set(adjust.saturation); + }); + } + + /** Updates the optimistic brightness label as the slider moves. */ + protected onBrightnessChange(event: SliderChangeEvent): void { + this.brightness.set(this.#singleValue(event.value)); + } + + /** Dispatches the final brightness value once the slider drag ends. */ + protected onBrightnessSlideEnd(event: SliderSlideEndEvent): void { + this.dispatch.brightnessChanged(event.value ?? 0); + } + + /** Commits a brightness value typed into the inline number field. */ + protected onBrightnessInput(event: Event): void { + const value = this.#commitTypedValue(event, this.brightness()); + this.brightness.set(value); + this.dispatch.brightnessChanged(value); + } + + /** Updates the optimistic hue label as the slider moves. */ + protected onHueChange(event: SliderChangeEvent): void { + this.hue.set(this.#singleValue(event.value)); + } + + /** Dispatches the final hue value once the slider drag ends. */ + protected onHueSlideEnd(event: SliderSlideEndEvent): void { + this.dispatch.hueChanged(event.value ?? 0); + } + + /** Commits a hue value typed into the inline number field. */ + protected onHueInput(event: Event): void { + const value = this.#commitTypedValue(event, this.hue()); + this.hue.set(value); + this.dispatch.hueChanged(value); + } + + /** Updates the optimistic saturation label as the slider moves. */ + protected onSaturationChange(event: SliderChangeEvent): void { + this.saturation.set(this.#singleValue(event.value)); + } + + /** Dispatches the final saturation value once the slider drag ends. */ + protected onSaturationSlideEnd(event: SliderSlideEndEvent): void { + this.dispatch.saturationChanged(event.value ?? 0); + } + + /** Commits a saturation value typed into the inline number field. */ + protected onSaturationInput(event: Event): void { + const value = this.#commitTypedValue(event, this.saturation()); + this.saturation.set(value); + this.dispatch.saturationChanged(value); + } + + /** Dispatches the grayscale toggle state. */ + protected onGrayscaleToggle(event: CheckboxChangeEvent): void { + this.dispatch.grayscaleToggled(Boolean(event.checked)); + } + + /** Narrows the slider's number-or-range value to a single number. */ + #singleValue(value: SliderChangeEvent['value']): number { + return Array.isArray(value) ? (value[0] ?? 0) : (value ?? 0); + } + + /** + * Parses a value typed into an inline number field: reverts to `fallback` + * when empty/invalid, rounds, and clamps to the slider range. Writes the + * resolved value back to the field so an out-of-range entry shows corrected. + */ + #commitTypedValue(event: Event, fallback: number): number { + const input = event.target as HTMLInputElement; + const raw = input.valueAsNumber; + const value = clamp( + Math.round(Number.isFinite(raw) ? raw : fallback), + RANGES.brightness.min, + RANGES.brightness.max + ); + input.value = String(value); + + return value; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.html new file mode 100644 index 000000000000..5a251b971a8b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.html @@ -0,0 +1,51 @@ +
+
+ + + + {{ option.label | dm }} + + + {{ option.label | dm }} + + +
+ + @if (isCompressing()) { +
+
+ + {{ store.fileInfo().quality }} +
+ +
+ } + +
+ + + {{ fileSize() }} + +
+ +
+ + + {{ originalSize() }} + +
+
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.scss new file mode 100644 index 000000000000..98f5f738fb98 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.scss @@ -0,0 +1,29 @@ +.ie-fileinfo { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.ie-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + + &--inline { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } +} + +.ie-field__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ie-field__value { + font-variant-numeric: tabular-nums; + color: var(--text-color-secondary); +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.spec.ts new file mode 100644 index 000000000000..2ad70360516b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.spec.ts @@ -0,0 +1,124 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { Select } from 'primeng/select'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorFileInfoPanelComponent } from './dot-image-editor-fileinfo-panel.component'; + +import { FileInfoState } from '../../../models/image-editor.models'; +import { imageEditorFileInfoEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +const FILE_INFO: FileInfoState = { + compression: 'none', + quality: 85, + currentBytes: null, + originalBytes: null +}; + +describe('DotImageEditorFileInfoPanelComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const fileInfo = signal(FILE_INFO); + const assetContext = signal({ naturalWidth: 0, naturalHeight: 0 }); + + const createComponent = createComponentFactory({ + component: DotImageEditorFileInfoPanelComponent, + providers: [ + provideNoopAnimations(), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { fileInfo, assetContext })] + }); + + beforeEach(() => { + fileInfo.set(FILE_INFO); + assetContext.set({ naturalWidth: 0, naturalHeight: 0 }); + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the compression select', () => { + expect(spectator.query(byTestId('image-editor-compression-select'))).toExist(); + }); + + it('should dispatch compressionChanged on the select change', () => { + const select = spectator.query(Select); + select!.onChange.emit({ originalEvent: new Event('change'), value: 'avif' }); + + const event = dispatchedEvent(imageEditorFileInfoEvents.compressionChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe('avif'); + }); + + describe('when compression is none', () => { + it('should hide the quality slider', () => { + expect(spectator.query(byTestId('image-editor-quality-slider'))).toBeNull(); + }); + }); + + describe('when compression is active', () => { + beforeEach(() => { + fileInfo.set({ ...FILE_INFO, compression: 'jpeg' }); + spectator.detectChanges(); + }); + + it('should show the quality slider', () => { + expect(spectator.query(byTestId('image-editor-quality-slider'))).toExist(); + }); + }); + + describe('file size', () => { + it('should render an em dash when the size is unknown', () => { + expect(spectator.query(byTestId('image-editor-filesize-value'))!.textContent).toContain( + '—' + ); + }); + + it('should format the current size in KB', () => { + fileInfo.set({ ...FILE_INFO, currentBytes: 2048 }); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-filesize-value'))!.textContent).toContain( + 'KB' + ); + }); + }); + + describe('original size', () => { + it('should render an em dash before the asset loads', () => { + expect( + spectator.query(byTestId('image-editor-originalsize-value'))!.textContent + ).toContain('—'); + }); + + it('should render the natural dimensions once loaded', () => { + assetContext.set({ naturalWidth: 3024, naturalHeight: 1964 }); + spectator.detectChanges(); + + expect( + spectator.query(byTestId('image-editor-originalsize-value'))!.textContent + ).toContain('3024 × 1964 px'); + }); + }); + + /** + * Finds the dispatched event matching the given type. `injectDispatch` + * forwards a `{ scope: 'self' }` options argument, so the event is read from + * the first call argument. + */ + function dispatchedEvent(type: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find( + ([dispatched]) => dispatched.type === type + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.ts new file mode 100644 index 000000000000..8aef5205a28e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.ts @@ -0,0 +1,82 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { SelectModule } from 'primeng/select'; +import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { BYTES_PER_KB } from '../../../image-editor.constants'; +import { CompressionMode, CompressionOption } from '../../../models/image-editor.models'; +import { imageEditorFileInfoEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +/** + * File info / compression panel. Lets the user pick a compression strategy and, + * when compression is active, tune its quality, while surfacing the current + * preview file size. Binds to the {@link ImageEditorStore} `fileInfo` slice and + * dispatches the matching {@link imageEditorFileInfoEvents} on user input; the + * quality slider dispatches its committed value on `onSlideEnd`. + */ +@Component({ + selector: 'dot-image-editor-fileinfo-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, SelectModule, SliderModule, DotMessagePipe], + templateUrl: './dot-image-editor-fileinfo-panel.component.html', + styleUrl: './dot-image-editor-fileinfo-panel.component.scss' +}) +export class DotImageEditorFileInfoPanelComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** Panel event dispatcher for compression and quality changes. */ + protected readonly dispatch = injectDispatch(imageEditorFileInfoEvents); + + /** Selectable compression strategies. */ + protected readonly compressionOptions: CompressionOption[] = [ + { label: 'edit.content.image-editor.fileinfo.compression.none', value: 'none' }, + { label: 'edit.content.image-editor.fileinfo.compression.auto', value: 'auto' }, + { label: 'edit.content.image-editor.fileinfo.compression.jpeg', value: 'jpeg' }, + { label: 'edit.content.image-editor.fileinfo.compression.webp', value: 'webp' }, + { label: 'edit.content.image-editor.fileinfo.compression.avif', value: 'avif' } + ]; + + /** Whether a compression strategy is active (so quality applies). */ + protected readonly isCompressing = computed(() => this.store.fileInfo().compression !== 'none'); + + /** Human-readable current preview size, or an em dash when unknown. */ + protected readonly fileSize = computed(() => formatBytes(this.store.fileInfo().currentBytes)); + + /** Original (source) image dimensions, or an em dash before the asset loads. */ + protected readonly originalSize = computed(() => { + const { naturalWidth, naturalHeight } = this.store.assetContext(); + + return naturalWidth && naturalHeight ? `${naturalWidth} × ${naturalHeight} px` : '—'; + }); + + /** Dispatches the selected compression strategy. */ + protected compressionChanged(value: CompressionMode): void { + this.dispatch.compressionChanged(value); + } + + /** Dispatches the final quality value once the slider drag ends. */ + protected qualityChanged(event: SliderSlideEndEvent): void { + this.dispatch.qualityChanged(event.value ?? 0); + } +} + +/** Formats a byte count to KB/MB with one decimal, or an em dash when null. */ +function formatBytes(bytes: number | null): string { + if (bytes == null) { + return '—'; + } + + const kb = bytes / BYTES_PER_KB; + if (kb < BYTES_PER_KB) { + return `${kb.toFixed(1)} KB`; + } + + return `${(kb / BYTES_PER_KB).toFixed(1)} MB`; +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.html new file mode 100644 index 000000000000..38788670eed7 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.html @@ -0,0 +1,36 @@ +
+ @if (store.appliedEdits().length) { +
    + @for (entry of store.appliedEdits(); track entry.id) { +
  • + {{ entry.label }} + + + close + + +
  • + } +
+ + + + delete + + + } @else { +

+ {{ 'edit.content.image-editor.history.empty' | dm }} +

+ } +
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.scss new file mode 100644 index 000000000000..890a8a4422c0 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.scss @@ -0,0 +1,34 @@ +.ie-history { + display: flex; + flex-direction: column; + gap: 1rem; +} + +// The remove/reset glyphs are projected into PrimeNG buttons; pierce with +// ::ng-deep to scale the Material Symbol down to the previous icon footprint. +:host ::ng-deep .material-symbols-outlined { + font-size: 1.125rem; +} + +.ie-history__list { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.ie-history__entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.ie-history__label { + overflow: hidden; + font-size: 0.8125rem; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.spec.ts new file mode 100644 index 000000000000..9c73dbf7e4cd --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.spec.ts @@ -0,0 +1,92 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorHistoryPanelComponent } from './dot-image-editor-history-panel.component'; + +import { AppliedEditEntry } from '../../../models/image-editor.models'; +import { imageEditorHistoryEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +const ENTRY: AppliedEditEntry = { id: 'a1', category: 'adjust', label: 'Brightness 30' }; + +describe('DotImageEditorHistoryPanelComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const appliedEdits = signal([]); + + const createComponent = createComponentFactory({ + component: DotImageEditorHistoryPanelComponent, + providers: [ + provideNoopAnimations(), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { appliedEdits })] + }); + + beforeEach(() => { + appliedEdits.set([]); + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + describe('with applied edits', () => { + beforeEach(() => { + appliedEdits.set([ENTRY]); + spectator.detectChanges(); + }); + + it('should render a row per applied edit', () => { + expect(spectator.queryAll(byTestId('image-editor-history-entry'))).toHaveLength(1); + }); + + it('should dispatch editRemoved with the entry id when removing', () => { + const removeBtn = spectator.query(byTestId('image-editor-history-remove')); + spectator.click(removeBtn!.querySelector('button')!); + + const event = dispatchedEvent(imageEditorHistoryEvents.editRemoved.type); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ id: 'a1' }); + }); + + it('should dispatch resetRequested when resetting all edits', () => { + const resetBtn = spectator.query(byTestId('image-editor-history-reset')); + spectator.click(resetBtn!.querySelector('button')!); + + expect(dispatchedEvent(imageEditorHistoryEvents.resetRequested.type)).toBeDefined(); + }); + + it('should not render the empty state', () => { + expect(spectator.query(byTestId('image-editor-history-empty'))).toBeNull(); + }); + }); + + describe('with no applied edits', () => { + it('should render the empty state', () => { + expect(spectator.query(byTestId('image-editor-history-empty'))).toExist(); + }); + + it('should not render any history rows', () => { + expect(spectator.queryAll(byTestId('image-editor-history-entry'))).toHaveLength(0); + }); + }); + + /** + * Finds the dispatched event matching the given type. `injectDispatch` + * forwards a `{ scope: 'self' }` options argument, so the event is read from + * the first call argument. + */ + function dispatchedEvent(type: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find( + ([dispatched]) => dispatched.type === type + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.ts new file mode 100644 index 000000000000..508f9b277e6f --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.ts @@ -0,0 +1,42 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorHistoryEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +/** + * Applied-edits panel. Lists the edits captured up to the current history head + * from the {@link ImageEditorStore} `appliedEdits` computed, lets the user remove + * an individual entry or reset every edit, and shows an empty state when no edits + * have been applied. User actions dispatch the matching + * {@link imageEditorHistoryEvents}; the store owns the resulting state changes. + */ +@Component({ + selector: 'dot-image-editor-history-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-history-panel.component.html', + styleUrl: './dot-image-editor-history-panel.component.scss' +}) +export class DotImageEditorHistoryPanelComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** History event dispatcher for removing and resetting applied edits. */ + protected readonly dispatch = injectDispatch(imageEditorHistoryEvents); + + /** Dispatches removal of a single applied edit by its id. */ + protected onRemove(id: string): void { + this.dispatch.editRemoved({ id }); + } + + /** Dispatches a reset that clears every applied edit. */ + protected onReset(): void { + this.dispatch.resetRequested(); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.html new file mode 100644 index 000000000000..b6c3d5b2b216 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.html @@ -0,0 +1,103 @@ + + + + + + tune + + + + {{ 'edit.content.image-editor.panel.adjust.title' | dm }} + + + {{ 'edit.content.image-editor.panel.adjust.subtitle' | dm }} + + + + + + + + + + + + + + + transform + + + + {{ 'edit.content.image-editor.panel.transform.title' | dm }} + + + {{ 'edit.content.image-editor.panel.transform.subtitle' | dm }} + + + + + + + + + + + + + + + description + + + + {{ 'edit.content.image-editor.panel.fileinfo.title' | dm }} + + + {{ 'edit.content.image-editor.panel.fileinfo.subtitle' | dm }} + + + + + + + + + + + + + + + history + + + + {{ 'edit.content.image-editor.panel.history.title' | dm }} + + + {{ 'edit.content.image-editor.panel.history.subtitle' | dm }} + + + + + + + + + + diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.scss new file mode 100644 index 000000000000..9d9411d2d99e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.scss @@ -0,0 +1,177 @@ +:host { + display: block; +} + +// Accordion section styling, aligned to the Claude Design "Edit image" spec: +// header title 600/13px, subtitle 400/11px muted, a 26px rounded icon chip that +// turns primary when the section is open, and the design's 14/18px density. +// Scoped to this component's subtree via :host; pierces PrimeNG internals with +// ::ng-deep since the header content is projected into . +:host ::ng-deep { + // Header: pinned while its content scrolls (the panel column is scrollable), + // like the Edit Content sidebar sections. PrimeNG sets `.p-accordionheader + // .p-ripple { position: relative }` which outranks this rule, so `position` + // needs !important to win; `top`/`z-index` have no PrimeNG rule to fight. The + // opaque background + hover come from the global accordion preset (PrimeNG owns + // `background`, so it can only be changed there, not here). + .p-accordionheader { + position: sticky !important; + top: 0; + z-index: 1; + padding: 14px 18px; + gap: 10px; + } + + // Body padding on all four sides (the sub-panels carry none of their own). + .p-accordioncontent-content { + padding: 1rem 1.5rem; + } + + // Section divider only, last one removed. + .p-accordionpanel { + border-bottom: 1px solid var(--p-surface-100, #f1f5f9); + } + + .p-accordionpanel:last-child { + border-bottom: 0; + } + + // Our own rotating chevron replaces PrimeNG's swapped expand/collapse icons + // (which can't animate); see `.ie-panel-head__chevron` below. + .p-accordionheader-toggle-icon { + display: none; + } + + .ie-panel-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + } + + // 26px rounded icon chip; muted while collapsed. + .ie-panel-head__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + background: var(--p-surface-100, #f1f5f9); + color: var(--p-text-muted-color, #64748b); + flex-shrink: 0; + } + + .ie-panel-head__icon .material-symbols-outlined { + font-size: 16px; + } + + // Open section: chip turns primary (`.iem-acc.open .iem-acc-icon`). + .p-accordionpanel-active .ie-panel-head__icon { + background: var(--p-primary-50, #eef2ff); + color: var(--p-primary-700, #4338ca); + } + + .ie-panel-head__titles { + display: flex; + flex-direction: column; + gap: 2px; + text-align: left; + } + + .ie-panel-head__title { + font-family: var(--p-font-family); + font-weight: 600; + font-size: 13px; + line-height: 1.2; + color: var(--p-text-color, #334155); + } + + .ie-panel-head__subtitle { + font-family: var(--p-font-family); + font-weight: 400; + font-size: 11px; + line-height: 1.3; + color: var(--p-text-muted-color, #64748b); + } + + // Single chevron pinned to the right that rotates on open (the Edit Content + // sidebar pattern), instead of PrimeNG's instant down/up icon swap. + .ie-panel-head__chevron { + margin-left: auto; + font-size: 0.75rem; + color: var(--p-text-muted-color, #64748b); + transition: transform 0.2s ease; + } + + .p-accordionpanel-active .ie-panel-head__chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .ie-panel-head__chevron { + transition: none; + } + } +} + +// Constrain the PrimeNG controls in every sub-panel to the (narrow) panel column +// so nothing overflows horizontally. Scoped to this component's subtree via :host. +:host ::ng-deep { + // Control labels (Brightness, Scale, Flip, Compression, …): smaller and muted + // rather than heavy near-black, so they read as field labels under the bolder + // section headers. + label { + font-size: 0.8125rem; + font-weight: 400; + color: var(--p-text-muted-color, #64748b); + } + + // Control values (the number next to each label) match the label scale + // instead of being oversized. + .ie-field__value { + font-size: 0.8125rem; + } + + // Toggle buttons (flip, compression) and number inputs share the same smaller + // control scale — they looked oversized once the labels/values shrank. PrimeNG + // only sets font-size on its sm/lg size variants, so this base override wins. + .p-togglebutton, + .p-inputnumber-input { + font-size: 0.8125rem; + } + + // Sliders fill the available width. + .p-slider { + width: 100%; + } + + // Output-dimension number inputs fill their half of the row instead of using + // their intrinsic width (which overflowed the column). + .ie-transform__dimension { + flex: 1 1 0; + min-width: 0; + } + + .p-inputnumber { + width: 100%; + } + + .p-inputnumber-input { + width: 100%; + min-width: 0; + } + + // Flip toggle pair and the compression segmented control share the row evenly. + .ie-transform__flip, + .p-selectbutton { + display: flex; + width: 100%; + } + + .ie-transform__flip .p-togglebutton, + .p-selectbutton .p-togglebutton { + flex: 1 1 0; + min-width: 0; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.spec.ts new file mode 100644 index 000000000000..05ae932e58db --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.spec.ts @@ -0,0 +1,94 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { MockComponent } from 'ng-mocks'; + +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorAdjustPanelComponent } from './dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component'; +import { DotImageEditorFileInfoPanelComponent } from './dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component'; +import { DotImageEditorHistoryPanelComponent } from './dot-image-editor-history-panel/dot-image-editor-history-panel.component'; +import { DotImageEditorPanelsComponent } from './dot-image-editor-panels.component'; +import { DotImageEditorTransformPanelComponent } from './dot-image-editor-transform-panel/dot-image-editor-transform-panel.component'; + +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../../image-editor.constants'; + +describe('DotImageEditorPanelsComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotImageEditorPanelsComponent, + providers: [ + provideNoopAnimations(), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + // The sub-panels own their own store/dispatch wiring; mock them so this + // container spec stays isolated to the accordion layout. + overrideComponents: [ + [ + DotImageEditorPanelsComponent, + { + remove: { + imports: [ + DotImageEditorAdjustPanelComponent, + DotImageEditorTransformPanelComponent, + DotImageEditorFileInfoPanelComponent, + DotImageEditorHistoryPanelComponent + ] + }, + add: { + imports: [ + MockComponent(DotImageEditorAdjustPanelComponent), + MockComponent(DotImageEditorTransformPanelComponent), + MockComponent(DotImageEditorFileInfoPanelComponent), + MockComponent(DotImageEditorHistoryPanelComponent) + ] + } + } + ] + ] + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should render the four accordion sections in order', () => { + spectator = createComponent(); + + expect(spectator.query(byTestId('image-editor-panel-adjust'))).toExist(); + expect(spectator.query(byTestId('image-editor-panel-transform'))).toExist(); + expect(spectator.query(byTestId('image-editor-panel-fileinfo'))).toExist(); + expect(spectator.query(byTestId('image-editor-panel-history'))).toExist(); + }); + + it('should start with every section open by default', () => { + spectator = createComponent(); + + expect(spectator.component['openPanels']()).toEqual([ + 'adjust', + 'transform', + 'fileinfo', + 'history' + ]); + }); + + it('should seed the open sections from localStorage', () => { + localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, JSON.stringify(['transform'])); + + spectator = createComponent(); + + expect(spectator.component['openPanels']()).toEqual(['transform']); + }); + + it('should persist the open sections to localStorage when they change', () => { + spectator = createComponent(); + + spectator.component['onOpenPanelsChange'](['adjust', 'history']); + spectator.detectChanges(); + + expect(localStorage.getItem(IMAGE_EDITOR_PANEL_STATE_KEY)).toBe( + JSON.stringify(['adjust', 'history']) + ); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.ts new file mode 100644 index 000000000000..b71f8c64982f --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core'; + +import { AccordionModule } from 'primeng/accordion'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotImageEditorAdjustPanelComponent } from './dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component'; +import { DotImageEditorFileInfoPanelComponent } from './dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component'; +import { DotImageEditorHistoryPanelComponent } from './dot-image-editor-history-panel/dot-image-editor-history-panel.component'; +import { DotImageEditorTransformPanelComponent } from './dot-image-editor-transform-panel/dot-image-editor-transform-panel.component'; + +import { getStoredPanelState, savePanelState } from '../../utils/panel-state.storage'; + +/** + * Side panel container of the image editor dialog. Stacks the adjust, transform, + * file info and applied-edits sub-panels in a multi-expand PrimeNG accordion so + * the user can keep several sections open at once. Each sub-panel is fully + * self-contained and talks to the {@link ImageEditorStore} on its own; this + * container only owns the accordion layout and section headers. + * + * Which sections are open is persisted to `localStorage` (same approach as the + * Edit Content sidebar): the panel starts from the stored set — or, on first use + * with nothing stored, every section open — and an effect writes the set back + * whenever it changes, so the user's layout is remembered the way they left it. + */ +@Component({ + selector: 'dot-image-editor-panels', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './dot-image-editor-panels.component.html', + styleUrl: './dot-image-editor-panels.component.scss', + imports: [ + AccordionModule, + DotMessagePipe, + DotImageEditorAdjustPanelComponent, + DotImageEditorTransformPanelComponent, + DotImageEditorFileInfoPanelComponent, + DotImageEditorHistoryPanelComponent + ] +}) +export class DotImageEditorPanelsComponent { + /** Values of the currently open accordion sections; seeded from storage. */ + protected readonly openPanels = signal(getStoredPanelState()); + + constructor() { + // Persist the open sections whenever they change, mirroring the Edit + // Content sidebar's save-on-change effect. + effect(() => savePanelState(this.openPanels())); + } + + /** Updates the open-section set from the accordion (also triggers the save effect). */ + protected onOpenPanelsChange(value: string[]): void { + this.openPanels.set(value ?? []); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.html new file mode 100644 index 000000000000..f14b5a834517 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.html @@ -0,0 +1,118 @@ +
+
+
+ + + + % + +
+ +
+ +
+
+ + + + ° + +
+ +
+ +
+ +
+ + + flip + {{ 'edit.content.image-editor.transform.flip.horizontal' | dm }} + + + + + flip + {{ 'edit.content.image-editor.transform.flip.vertical' | dm }} + + +
+
+ +
+ +
+
+ + @if (widthError()) { + + {{ 'edit.content.image-editor.transform.dimensions.error.min' | dm }} + + } +
+ × +
+ + @if (heightError()) { + + {{ 'edit.content.image-editor.transform.dimensions.error.min' | dm }} + + } +
+
+
+
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.scss new file mode 100644 index 000000000000..b58d9f0d4ac1 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.scss @@ -0,0 +1,105 @@ +.ie-transform { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.ie-field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ie-field__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +// Editable value (number field + unit), mirroring the Adjust panel: borderless +// until hover/focus, right-aligned, native spinners hidden. +.ie-field__value-wrap { + display: inline-flex; + align-items: center; + gap: 1px; +} + +.ie-field__value { + width: 2.75rem; + padding: 1px 4px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: inherit; + text-align: right; + font-variant-numeric: tabular-nums; + -moz-appearance: textfield; + appearance: textfield; + + &:hover { + border-color: var(--surface-200, #e2e8f0); + } + + &:focus { + outline: none; + border-color: var(--primary-color, #6366f1); + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; + } +} + +.ie-field__unit { + color: var(--text-color-secondary); + font-variant-numeric: tabular-nums; +} + +.ie-transform__flip { + display: flex; + gap: 0.5rem; +} + +// The flip toggles project their icon + label into PrimeNG's toggle button, so +// pierce its content slot with ::ng-deep to size the Material Symbol down to the +// previous icon footprint and keep the glyph and label on one centered row. +:host ::ng-deep { + .p-togglebutton-content { + display: inline-flex; + align-items: center; + gap: 0.375rem; + } + + .ie-transform__flip .material-symbols-outlined { + font-size: 1.125rem; + } + + // Vertical flip reuses the `flip` glyph rotated a quarter turn. + .icon-rotate-90 { + transform: rotate(90deg); + } +} + +.ie-transform__dimensions { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.ie-transform__dimension { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ie-transform__dimension-separator { + padding-top: 0.5rem; + color: var(--text-color-secondary); +} + +.ie-transform__error { + color: var(--red-500); +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.spec.ts new file mode 100644 index 000000000000..339ed2990d2a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.spec.ts @@ -0,0 +1,172 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { Slider } from 'primeng/slider'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorTransformPanelComponent } from './dot-image-editor-transform-panel.component'; + +import { TransformState } from '../../../models/image-editor.models'; +import { imageEditorTransformEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +const TRANSFORM: TransformState = { + scale: 100, + rotateDeg: 0, + flipH: false, + flipV: false, + outputWidth: null, + outputHeight: null, + lockAspectRatio: true +}; + +describe('DotImageEditorTransformPanelComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const transform = signal(TRANSFORM); + const outputDimensions = signal<{ width: number; height: number }>({ width: 800, height: 600 }); + + const createComponent = createComponentFactory({ + component: DotImageEditorTransformPanelComponent, + providers: [ + provideNoopAnimations(), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [ + Dispatcher, + mockProvider(ImageEditorStore, { transform, outputDimensions }) + ] + }); + + beforeEach(() => { + transform.set(TRANSFORM); + outputDimensions.set({ width: 800, height: 600 }); + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should dispatch scaleChanged on the scale slider slide end', () => { + const slider = sliderAt('image-editor-scale-slider'); + slider.onSlideEnd.emit({ originalEvent: new Event('mouseup'), value: 250 }); + + const event = dispatchedEvent(imageEditorTransformEvents.scaleChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe(250); + }); + + it('should dispatch rotateChanged on the rotate slider slide end', () => { + const slider = sliderAt('image-editor-rotate-slider'); + slider.onSlideEnd.emit({ originalEvent: new Event('mouseup'), value: -90 }); + + const event = dispatchedEvent(imageEditorTransformEvents.rotateChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe(-90); + }); + + it('should dispatch flipHToggled when toggling horizontal flip', () => { + spectator.triggerEventHandler(byTestId('image-editor-flip-horizontal-btn'), 'onChange', { + checked: true, + originalEvent: new Event('click') + }); + + expect(dispatchedEvent(imageEditorTransformEvents.flipHToggled.type)).toBeDefined(); + }); + + it('should dispatch flipVToggled when toggling vertical flip', () => { + spectator.triggerEventHandler(byTestId('image-editor-flip-vertical-btn'), 'onChange', { + checked: true, + originalEvent: new Event('click') + }); + + expect(dispatchedEvent(imageEditorTransformEvents.flipVToggled.type)).toBeDefined(); + }); + + it('should render the width input and dispatch outputDimsChanged with the new width', () => { + expect(spectator.query(byTestId('image-editor-output-width-input'))).toExist(); + spectator.component['outputWidthChanged'](1024); + + const event = dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type); + expect(event).toBeDefined(); + // The unchanged dimension carries the currently-displayed (computed) value, + // not the raw stored null, so persisted matches what the field shows. + expect(event!.payload).toEqual({ width: 1024, height: 600 }); + }); + + it('should dispatch outputDimsChanged with the new height on height input', () => { + spectator.component['outputHeightChanged'](768); + + const event = dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ width: 800, height: 768 }); + }); + + it('should show the min error and dispatch a null dimension when width is below 1', () => { + spectator.component['outputWidthChanged'](0); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-output-width-error'))).toExist(); + expect(dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type)!.payload).toEqual( + { + width: null, + height: 600 + } + ); + }); + + it('should dispatch scaleChanged with the value typed into the scale field', () => { + const input = spectator.query(byTestId('image-editor-scale-value'))!; + input.value = '250'; + spectator.dispatchFakeEvent(input, 'change'); + + const event = dispatchedEvent(imageEditorTransformEvents.scaleChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe(250); + }); + + it('should clamp a typed scale value to its range', () => { + const input = spectator.query(byTestId('image-editor-scale-value'))!; + input.value = '900'; + spectator.dispatchFakeEvent(input, 'change'); + + expect(dispatchedEvent(imageEditorTransformEvents.scaleChanged.type)!.payload).toBe(400); + expect(input.value).toBe('400'); + }); + + it('should dispatch rotateChanged with the value typed into the rotate field', () => { + const input = spectator.query(byTestId('image-editor-rotate-value'))!; + input.value = '-90'; + spectator.dispatchFakeEvent(input, 'change'); + + const event = dispatchedEvent(imageEditorTransformEvents.rotateChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe(-90); + }); + + /** Resolves the PrimeNG Slider instance rendered under the given test id. */ + function sliderAt(testId: string): Slider { + const debugEl = spectator.fixture.debugElement.query( + (el) => el.nativeElement.getAttribute?.('data-testid') === testId + ); + + return debugEl.componentInstance as Slider; + } + + /** + * Finds the dispatched event matching the given type. `injectDispatch` + * forwards a `{ scope: 'self' }` options argument, so the event is read from + * the first call argument. + */ + function dispatchedEvent(type: string): { type: string; payload?: unknown } | undefined { + const call = (dispatcher.dispatch as jest.Mock).mock.calls.find( + ([dispatched]) => dispatched.type === type + ); + + return call?.[0]; + } +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.ts new file mode 100644 index 000000000000..7d5501cfba86 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.ts @@ -0,0 +1,162 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { InputNumberModule } from 'primeng/inputnumber'; +import { SliderChangeEvent, SliderModule, SliderSlideEndEvent } from 'primeng/slider'; +import { ToggleButtonModule } from 'primeng/togglebutton'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { RANGES } from '../../../image-editor.constants'; +import { imageEditorTransformEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; +import { clamp } from '../../../utils/dimensions.util'; + +/** + * Geometric transform panel. Binds scale and rotate sliders, horizontal/vertical + * flip toggles and explicit output dimension inputs to the {@link ImageEditorStore} + * `transform`/`outputDimensions` slices, dispatching the matching + * {@link imageEditorTransformEvents} on user input. Sliders dispatch their committed + * value on `onSlideEnd`; the scale and rotate readouts double as inline number + * fields that commit (clamped) on input — the same pattern as the Adjust panel. + */ +@Component({ + selector: 'dot-image-editor-transform-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, SliderModule, ToggleButtonModule, InputNumberModule, DotMessagePipe], + templateUrl: './dot-image-editor-transform-panel.component.html', + styleUrl: './dot-image-editor-transform-panel.component.scss' +}) +export class DotImageEditorTransformPanelComponent { + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** Panel event dispatcher for transform changes. */ + protected readonly dispatch = injectDispatch(imageEditorTransformEvents); + + /** Optimistic scale (%) shown in the field while the slider is dragged. */ + protected readonly scale = signal(100); + /** Optimistic rotation (°) shown in the field while the slider is dragged. */ + protected readonly rotate = signal(0); + + /** Whether the last edited width is below the allowed minimum, for the inline error. */ + protected readonly widthError = signal(false); + /** Whether the last edited height is below the allowed minimum, for the inline error. */ + protected readonly heightError = signal(false); + + constructor() { + // Keep the optimistic field values in sync with committed store state + // (e.g. after undo/redo, reset or removing a history entry). + effect(() => { + const transform = this.store.transform(); + this.scale.set(transform.scale); + this.rotate.set(transform.rotateDeg); + }); + } + + /** Updates the optimistic scale field as the slider moves. */ + protected onScaleChange(event: SliderChangeEvent): void { + this.scale.set(this.#singleValue(event.value)); + } + + /** Dispatches the final scale value once the slider drag ends. */ + protected scaleChanged(event: SliderSlideEndEvent): void { + this.dispatch.scaleChanged(event.value ?? 100); + } + + /** Commits a scale value typed into the inline number field. */ + protected onScaleInput(event: Event): void { + const value = this.#commitTypedValue( + event, + this.scale(), + RANGES.scale.min, + RANGES.scale.max + ); + this.scale.set(value); + this.dispatch.scaleChanged(value); + } + + /** Updates the optimistic rotation field as the slider moves. */ + protected onRotateChange(event: SliderChangeEvent): void { + this.rotate.set(this.#singleValue(event.value)); + } + + /** Dispatches the final rotation value once the slider drag ends. */ + protected rotateChanged(event: SliderSlideEndEvent): void { + this.dispatch.rotateChanged(event.value ?? 0); + } + + /** Commits a rotation value typed into the inline number field. */ + protected onRotateInput(event: Event): void { + const value = this.#commitTypedValue( + event, + this.rotate(), + RANGES.rotate.min, + RANGES.rotate.max + ); + this.rotate.set(value); + this.dispatch.rotateChanged(value); + } + + /** Dispatches a horizontal flip toggle. */ + protected flipHToggled(): void { + this.dispatch.flipHToggled(); + } + + /** Dispatches a vertical flip toggle. */ + protected flipVToggled(): void { + this.dispatch.flipVToggled(); + } + + /** Dispatches a change to the explicit output width, guarding the minimum. */ + protected outputWidthChanged(value: number | null): void { + this.widthError.set(this.#isBelowMinimum(value)); + // Pair the new width with the currently-DISPLAYED height (the computed + // `outputDimensions`, which falls back to the natural size), not the raw — + // possibly null — stored value, so what is persisted matches what the field shows. + this.dispatch.outputDimsChanged({ + width: this.#toDimension(value), + height: this.store.outputDimensions().height + }); + } + + /** Dispatches a change to the explicit output height, guarding the minimum. */ + protected outputHeightChanged(value: number | null): void { + this.heightError.set(this.#isBelowMinimum(value)); + this.dispatch.outputDimsChanged({ + width: this.store.outputDimensions().width, + height: this.#toDimension(value) + }); + } + + /** Normalizes a dimension input to a positive integer, or `null` when cleared/invalid. */ + #toDimension(value: number | null): number | null { + return value != null && value >= 1 ? value : null; + } + + /** Whether a non-empty dimension is below the allowed minimum of 1. */ + #isBelowMinimum(value: number | null): boolean { + return value != null && value < 1; + } + + /** Narrows the slider's number-or-range value to a single number. */ + #singleValue(value: SliderChangeEvent['value']): number { + return Array.isArray(value) ? (value[0] ?? 0) : (value ?? 0); + } + + /** + * Parses a value typed into an inline number field: reverts to `fallback` + * when empty/invalid, rounds, and clamps to [min, max]. Writes the resolved + * value back to the field so an out-of-range entry shows corrected. + */ + #commitTypedValue(event: Event, fallback: number, min: number, max: number): number { + const input = event.target as HTMLInputElement; + const raw = input.valueAsNumber; + const value = clamp(Math.round(Number.isFinite(raw) ? raw : fallback), min, max); + input.value = String(value); + + return value; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html new file mode 100644 index 000000000000..911f69efdf52 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html @@ -0,0 +1,17 @@ +
+
+ +
+ + + + + +
+ +
+
+ + diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss new file mode 100644 index 000000000000..6b9de7f58daf --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss @@ -0,0 +1,45 @@ +:host { + display: block; + height: 100%; +} + +// Two-dimensional shell: header and footer span both columns; the middle row holds +// the canvas (fluid) and the panels (fixed). minmax(0, …) lets the canvas contain and +// the panels scroll instead of forcing the tracks to grow. +.image-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) 23rem; + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + "header header" + "canvas panels" + "footer footer"; + height: 100%; + overflow: hidden; +} + +.image-editor__header { + grid-area: header; +} + +.image-editor__canvas { + grid-area: canvas; + min-width: 0; + min-height: 0; +} + +.image-editor__panels { + grid-area: panels; + min-height: 0; +} + +.image-editor__footer { + grid-area: footer; +} + +// Respect the user's motion preference: disable the scale/fade entrance. +@media (prefers-reduced-motion: reduce) { + :host { + animation: none !important; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts new file mode 100644 index 000000000000..88de9a80fdae --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts @@ -0,0 +1,252 @@ +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; +import { MockComponent } from 'ng-mocks'; + +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { Confirmation, ConfirmationService, ConfirmEventType } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorComponent } from './dot-image-editor.component'; + +import { ImageEditorOpenParams } from '../../models/image-editor.models'; +import { + imageEditorHistoryEvents, + imageEditorLifecycleEvents +} from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { DotImageEditorCanvasComponent } from '../dot-image-editor-canvas/dot-image-editor-canvas.component'; +import { DotImageEditorFooterComponent } from '../dot-image-editor-footer/dot-image-editor-footer.component'; +import { DotImageEditorHeaderComponent } from '../dot-image-editor-header/dot-image-editor-header.component'; +import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-image-editor-panels.component'; + +/** Builds the suite for a given set of open params, asserting the shared shell behavior. */ +function describeWith(label: string, data: ImageEditorOpenParams): void { + describe(`DotImageEditorComponent (${label})`, () => { + let spectator: Spectator; + let dispatcher: SpyObject; + let dialogRef: SpyObject; + let confirmationService: SpyObject; + + const isDirty = signal(false); + const canUndo = signal(false); + const canRedo = signal(false); + const isFullscreen = signal(false); + + const createComponent = createComponentFactory({ + component: DotImageEditorComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { provide: DynamicDialogConfig, useValue: { data } } + ], + // `componentProviders` overrides the component's own `providers`, so + // re-supply the real ConfirmationService (the template's + // `` subscribes to it; we spy on `confirm`) while + // mocking only the store. + componentProviders: [ + ConfirmationService, + mockProvider(ImageEditorStore, { isDirty, canUndo, canRedo, isFullscreen }) + ], + // Isolate the shell from the children's own store/dispatch wiring. + overrideComponents: [ + [ + DotImageEditorComponent, + { + remove: { + imports: [ + DotImageEditorHeaderComponent, + DotImageEditorCanvasComponent, + DotImageEditorPanelsComponent, + DotImageEditorFooterComponent + ] + }, + add: { + imports: [ + MockComponent(DotImageEditorHeaderComponent), + MockComponent(DotImageEditorCanvasComponent), + MockComponent(DotImageEditorPanelsComponent), + MockComponent(DotImageEditorFooterComponent) + ] + } + } + ] + ] + }); + + beforeEach(() => { + // The DynamicDialogRef `close` mock is shared across tests in this + // describe; clear it so prior tests' close() calls don't leak into the + // "not closed" assertions. + jest.clearAllMocks(); + isDirty.set(false); + canUndo.set(false); + canRedo.set(false); + isFullscreen.set(false); + + // Spy before creation so the constructor's assetRequested dispatch is + // captured (injectDispatch dispatches through Dispatcher.prototype). + jest.spyOn(Dispatcher.prototype, 'dispatch'); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + dialogRef = spectator.inject(DynamicDialogRef, true); + // Resolve the component-scoped instance and spy on its confirm method. + confirmationService = spectator.inject(ConfirmationService, true); + jest.spyOn(confirmationService, 'confirm'); + }); + + it('should render the root and the four child components', () => { + expect(spectator.query(byTestId('image-editor-root'))).toExist(); + expect(spectator.query('dot-image-editor-header')).toExist(); + expect(spectator.query('dot-image-editor-canvas')).toExist(); + expect(spectator.query('dot-image-editor-panels')).toExist(); + expect(spectator.query('dot-image-editor-footer')).toExist(); + }); + + it('should dispatch assetRequested with the dialog data on init', () => { + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorLifecycleEvents.assetRequested(data), + { scope: 'self' } + ); + }); + + it('should close with null when the header close is triggered and not dirty', () => { + const header = spectator.query(DotImageEditorHeaderComponent)!; + header.close.emit(); + + expect(dialogRef.close).toHaveBeenCalledWith(null); + expect(confirmationService.confirm).not.toHaveBeenCalled(); + }); + + it('should close with null when the footer cancel is triggered and not dirty', () => { + const footer = spectator.query(DotImageEditorFooterComponent)!; + footer.cancel.emit(); + + expect(dialogRef.close).toHaveBeenCalledWith(null); + expect(confirmationService.confirm).not.toHaveBeenCalled(); + }); + + it('should confirm before closing when there are unsaved edits', () => { + isDirty.set(true); + + spectator.query(DotImageEditorHeaderComponent)!.close.emit(); + + expect(confirmationService.confirm).toHaveBeenCalledTimes(1); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should close with null when the secondary "Discard" button is clicked (reject/REJECT)', () => { + isDirty.set(true); + + spectator.query(DotImageEditorFooterComponent)!.cancel.emit(); + + const confirmation = (confirmationService.confirm as jest.Mock).mock + .calls[0][0] as Confirmation; + confirmation.reject?.(ConfirmEventType.REJECT); + + expect(dialogRef.close).toHaveBeenCalledWith(null); + }); + + it('should NOT close when the primary "Keep editing" button is clicked (accept)', () => { + isDirty.set(true); + + spectator.query(DotImageEditorFooterComponent)!.cancel.emit(); + + const confirmation = (confirmationService.confirm as jest.Mock).mock + .calls[0][0] as Confirmation; + confirmation.accept?.(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should NOT close when the prompt is dismissed via X/ESC (reject/CANCEL)', () => { + isDirty.set(true); + + spectator.query(DotImageEditorFooterComponent)!.cancel.emit(); + + const confirmation = (confirmationService.confirm as jest.Mock).mock + .calls[0][0] as Confirmation; + confirmation.reject?.(ConfirmEventType.CANCEL); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + describe('undo/redo shortcuts', () => { + it('should dispatch undoRequested on Ctrl/Cmd+Z when undo is available', () => { + canUndo.set(true); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorHistoryEvents.undoRequested(), + { scope: 'self' } + ); + }); + + it('should dispatch redoRequested on Ctrl/Cmd+Shift+Z when redo is available', () => { + canRedo.set(true); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'z', metaKey: true, shiftKey: true }) + ); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorHistoryEvents.redoRequested(), + { scope: 'self' } + ); + }); + + it('should dispatch redoRequested on Ctrl+Y when redo is available', () => { + canRedo.set(true); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'y', ctrlKey: true })); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorHistoryEvents.redoRequested(), + { scope: 'self' } + ); + }); + + it('should not dispatch undo when there is nothing to undo', () => { + canUndo.set(false); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })); + + expect(dispatcher.dispatch).not.toHaveBeenCalledWith( + imageEditorHistoryEvents.undoRequested(), + { scope: 'self' } + ); + }); + + it('should ignore the shortcut while a text field has focus', () => { + canUndo.set(true); + + const input = document.createElement('input'); + spectator.element.appendChild(input); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }) + ); + + expect(dispatcher.dispatch).not.toHaveBeenCalledWith( + imageEditorHistoryEvents.undoRequested(), + { scope: 'self' } + ); + }); + }); + }); +} + +describeWith('inode', { inode: 'i1', variable: 'fileAsset', fieldName: 'fileAsset' }); +describeWith('tempId', { tempId: 'temp_x', variable: 'fileAsset', fieldName: 'fileAsset' }); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts new file mode 100644 index 000000000000..60723e55da40 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts @@ -0,0 +1,204 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { DOCUMENT } from '@angular/common'; +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; + +import { ConfirmationService, ConfirmEventType } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { Dialog } from 'primeng/dialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { imageEditorModalScaleFade } from '../../animations/image-editor.animations'; +import { DIALOG_SIZE_TRANSITION, FULLSCREEN_DIALOG_STYLE } from '../../image-editor.constants'; +import { ImageEditorOpenParams } from '../../models/image-editor.models'; +import { + imageEditorHistoryEvents, + imageEditorLifecycleEvents +} from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { DotImageEditorCanvasComponent } from '../dot-image-editor-canvas/dot-image-editor-canvas.component'; +import { DotImageEditorFooterComponent } from '../dot-image-editor-footer/dot-image-editor-footer.component'; +import { DotImageEditorHeaderComponent } from '../dot-image-editor-header/dot-image-editor-header.component'; +import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-image-editor-panels.component'; + +/** + * Full-screen "Edit image" modal shell, opened through PrimeNG's `DialogService`. + * Assembles the header, canvas, side panels and footer over a single + * {@link ImageEditorStore} instance scoped to this dialog. It owns the dialog + * lifecycle: it requests the asset on init, closes with the saved temp file when + * a save succeeds, and guards close/cancel against unsaved edits. + */ +@Component({ + selector: 'dot-image-editor', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ConfirmDialogModule, + DotImageEditorHeaderComponent, + DotImageEditorCanvasComponent, + DotImageEditorPanelsComponent, + DotImageEditorFooterComponent + ], + providers: [ + ImageEditorStore, + // Scoped here so the template's `` and the discard + // guard share one instance; the lib has no global confirm dialog. + ConfirmationService + ], + templateUrl: './dot-image-editor.component.html', + styleUrl: './dot-image-editor.component.scss', + animations: [imageEditorModalScaleFade()], + host: { + '[@imageEditorModalScaleFade]': '', + // Listen on the document, not the host: in a DynamicDialog focus usually + // sits outside this component, so a host-only keydown never fires. + '(document:keydown)': 'onKeydown($event)' + } +}) +export class DotImageEditorComponent { + /** Image editor state store, isolated to this dialog instance. */ + protected readonly store = inject(ImageEditorStore); + + readonly #config = inject>(DynamicDialogConfig); + readonly #dialogRef = inject(DynamicDialogRef); + readonly #confirmationService = inject(ConfirmationService); + readonly #dotMessageService = inject(DotMessageService); + readonly #dispatch = injectDispatch(imageEditorLifecycleEvents); + readonly #historyDispatch = injectDispatch(imageEditorHistoryEvents); + readonly #document = inject(DOCUMENT); + // The PrimeNG dialog hosting this editor: injectable because the dialog content + // is declared inside `` in DynamicDialog's template, so we sit in the + // Dialog's element injector. Its `container()` signal is the `.p-dialog` element. + readonly #dialog = inject(Dialog, { optional: true }); + + /** Windowed inline dialog styles saved on entering full-screen, restored on exit. */ + #windowedStyle: Record | null = null; + + constructor() { + this.#dispatch.assetRequested(this.#config.data as ImageEditorOpenParams); + + // The editor owns its dialog, so it owns full-screen too: resize the host + // `.p-dialog` to fill the viewport whenever `isFullscreen` flips. + effect(() => this.#applyFullscreen(this.store.isFullscreen())); + } + + /** + * Expands the host dialog to the viewport (or restores it). `DialogService` + * sets the dialog width/height as inline styles, so we override those inline + * styles directly — a stylesheet rule can't win against inline without + * `!important` — and restore the saved values on exit. + */ + #applyFullscreen(on: boolean): void { + const dialog = this.#dialog?.container() as HTMLElement | undefined; + + if (!dialog) { + return; + } + + // Set the size transition (idempotent) before any toggle, honouring + // reduced-motion. It lands on the first (windowed) effect run, so the + // first real toggle already animates. + dialog.style.transition = this.#prefersReducedMotion() ? '' : DIALOG_SIZE_TRANSITION; + + if (on) { + this.#windowedStyle ??= Object.fromEntries( + Object.keys(FULLSCREEN_DIALOG_STYLE).map((prop) => [prop, dialog.style[prop]]) + ); + Object.assign(dialog.style, FULLSCREEN_DIALOG_STYLE); + } else if (this.#windowedStyle) { + Object.assign(dialog.style, this.#windowedStyle); + this.#windowedStyle = null; + } + } + + /** Whether the user has requested reduced motion (skips the resize animation). */ + #prefersReducedMotion(): boolean { + return ( + this.#document.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? + false + ); + } + + /** + * Handles the standard undo/redo shortcuts while the editor is focused: + * Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y redo. Skipped when a + * text field has focus so its native text undo keeps working. + */ + protected onKeydown(event: KeyboardEvent): void { + if (!(event.metaKey || event.ctrlKey) || this.#isEditableTarget(event.target)) { + return; + } + + const key = event.key.toLowerCase(); + const isUndo = key === 'z' && !event.shiftKey; + const isRedo = key === 'y' || (key === 'z' && event.shiftKey); + + if (!isUndo && !isRedo) { + return; + } + + event.preventDefault(); + + if (isUndo && this.store.canUndo()) { + this.#historyDispatch.undoRequested(); + } else if (isRedo && this.store.canRedo()) { + this.#historyDispatch.redoRequested(); + } + } + + /** Closes the editor, confirming first when there are unsaved edits. */ + protected requestClose(): void { + if (!this.store.isDirty()) { + this.#dialogRef.close(null); + + return; + } + + this.#confirmationService.confirm({ + header: this.#dotMessageService.get('edit.content.image-editor.discard.header'), + message: this.#dotMessageService.get('edit.content.image-editor.discard.message'), + // Mirror the edit-content unsaved-changes dialog (unsavedChangesGuard): + // the safe "Keep editing" is the primary (accept) button and the + // destructive "Discard" is the secondary outlined (reject) button, so + // the prompt never renders two identical primaries. The labels reuse the + // existing message keys; only the accept/reject roles carry the hierarchy. + acceptLabel: this.#dotMessageService.get('edit.content.image-editor.discard.reject'), + rejectLabel: this.#dotMessageService.get('edit.content.image-editor.discard.confirm'), + // Text-only buttons, matching the unsaved-changes prompt. + acceptIcon: 'hidden', + rejectIcon: 'hidden', + rejectButtonStyleClass: 'p-button-outlined', + // "Keep editing" (primary): stay in the editor, nothing to do. + accept: () => { + /* keep editing */ + }, + // PrimeNG funnels the secondary button and dismissals (X / ESC / mask + // click) through reject(); only the explicit "Discard" click (REJECT) + // closes, so a dismissal safely keeps the user's edits. + reject: (type?: ConfirmEventType) => { + if (type === ConfirmEventType.REJECT) { + this.#dialogRef.close(null); + } + } + }); + } + + /** Whether the event originated from an editable control (keeps its native undo). */ + #isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable || + // PrimeNG p-select/p-dropdown/p-autocomplete expose a focusable element with + // role="combobox"; treat it (and its contents) as an editable control so the + // undo/redo shortcuts don't hijack keyboard selection inside an open control. + target.closest('[role="combobox"]') !== null + ); + } +} diff --git a/core-web/libs/image-editor/src/lib/image-editor.constants.ts b/core-web/libs/image-editor/src/lib/image-editor.constants.ts new file mode 100644 index 000000000000..02cea86e2651 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -0,0 +1,90 @@ +import { CompressionMode, HandlePosition } from './models/image-editor.models'; + +/** + * Library-wide constants for the image editor: control ranges, zoom/crop + * interaction steps, formatting and behavior tuning. Kept in one place so the + * components and store share a single source of truth instead of redeclaring + * magic numbers per file. + */ + +/** Inclusive value ranges enforced when clamping panel edits (sliders + inputs). */ +export const RANGES = { + brightness: { min: -100, max: 100 }, + hue: { min: -100, max: 100 }, + saturation: { min: -100, max: 100 }, + scale: { min: 1, max: 400 }, + rotate: { min: -180, max: 180 }, + quality: { min: 0, max: 100 } +} as const; + +/** Canvas zoom bounds, step and default (percentages). */ +export const ZOOM_MIN = 10; +export const ZOOM_MAX = 400; +export const ZOOM_STEP = 25; +export const ZOOM_DEFAULT = 100; + +/** Crop box keyboard nudge in CSS px (Shift uses the larger step). */ +export const CROP_NUDGE_STEP = 1; +export const CROP_NUDGE_STEP_LARGE = 10; + +/** Focal point keyboard nudge as a fraction of the image (Shift uses the larger step). */ +export const FOCAL_NUDGE_STEP = 0.01; +export const FOCAL_NUDGE_STEP_LARGE = 0.1; + +/** Smallest allowed crop dimension in CSS px to keep the box usable. */ +export const MIN_CROP_SIZE = 16; + +/** The eight resize handles rendered around the crop box, in render order. */ +export const CROP_HANDLES: readonly HandlePosition[] = [ + 'tl', + 't', + 'tr', + 'r', + 'br', + 'b', + 'bl', + 'l' +] as const; + +/** One kibibyte, used to format byte counts for display. */ +export const BYTES_PER_KB = 1024; + +/** + * Times a failed preview is silently retried before the error UI is shown. The + * server renders filter chains on the fly, so the first request for a fresh URL + * can race that generation and return an incomplete response; a couple of silent + * re-attempts (mirroring a manual refresh) almost always lands the finished image. + */ +export const AUTO_PREVIEW_RETRY_LIMIT = 3; + +/** Human-readable labels per compression mode, used in history entries. */ +export const COMPRESSION_LABELS: Record = { + none: 'None', + auto: 'Auto', + jpeg: 'JPEG', + webp: 'WebP', + avif: 'AVIF' +}; + +/** The editable slices, in snapshot order (used to diff/replay history). */ +export const SLICE_KEYS = ['adjust', 'transform', 'crop', 'fileInfo'] as const; + +/** localStorage key persisting which editor side panels are expanded. */ +export const IMAGE_EDITOR_PANEL_STATE_KEY = 'DOT_IMAGE_EDITOR_PANEL_STATE'; + +/** + * Inline `.p-dialog` style props applied when the editor goes full-screen and + * restored on exit. Overrides PrimeNG's `DynamicDialog` size (set inline via + * `[ngStyle]`), so it must be applied as inline styles to win. + */ +export const FULLSCREEN_DIALOG_STYLE: Record = { + width: '100vw', + height: '100vh', + maxWidth: '100vw', + maxHeight: '100vh', + borderRadius: '0' +}; + +/** Eased transition so the dialog grows/shrinks smoothly instead of snapping. */ +export const DIALOG_SIZE_TRANSITION = + 'width 250ms ease, height 250ms ease, border-radius 250ms ease'; diff --git a/core-web/libs/image-editor/src/lib/models/image-editor.models.ts b/core-web/libs/image-editor/src/lib/models/image-editor.models.ts new file mode 100644 index 000000000000..befc49b3d9f8 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/models/image-editor.models.ts @@ -0,0 +1,316 @@ +import { Observable } from 'rxjs'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +/** + * Tool currently selected on the editing canvas. + * - `move`: pan/zoom the image + * - `crop`: draw a crop selection + * - `focal`: place the focal point marker + */ +export type ActiveTool = 'move' | 'crop' | 'focal'; + +/** A point expressed as normalized 0..1 coordinates, independent of zoom/pan. */ +export interface NormalizedPoint { + x: number; + y: number; +} + +/** Output compression strategy applied as the last filter in the chain. */ +export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp' | 'avif'; + +/** Axis-aligned rectangle of the rendered image inside the canvas, in CSS px. */ +export interface ImageRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Loading lifecycle of the preview image. */ +export type PreviewStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +/** Logical category an applied edit belongs to, used for grouping and labels. */ +export type FilterCategory = + | 'adjust' + | 'resize' + | 'crop' + | 'rotate' + | 'flip' + | 'grayscale' + | 'compression'; + +/** Server-side filter name as understood by the dotCMS image filter endpoint. */ +export type FilterName = + | 'Resize' + | 'Crop' + | 'Rotate' + | 'Flip' + | 'Grayscale' + | 'Hsb' + | 'Jpeg' + | 'WebP' + | 'Quality' + // libvips-only modern format (AV1); registered lowercase as `avif`. + | 'avif'; + +/** + * Resolved information about the asset being edited, including the source + * identifiers and the natural pixel dimensions of the original image. + */ +export interface ImageEditorAssetContext { + /** Inode when editing an existing asset, otherwise the temp file id. */ + idOrTempId: string; + /** Inode of the persisted asset, or `null` when editing a temp file. */ + inode: string | null; + /** Temp file id when editing an uploaded file, otherwise `null`. */ + tempId: string | null; + /** Content type variable that owns the binary field. */ + variable: string; + /** Binary field name being edited. */ + fieldName: string; + /** Original file name of the asset. */ + fileName: string; + /** MIME type of the source asset. */ + mimeType: string; + /** Whether the asset is backed by a temporary upload. */ + isTempFile: boolean; + /** Whether the asset should be addressed by inode in built URLs. */ + byInode: boolean; + /** Intrinsic width of the original image in pixels. */ + naturalWidth: number; + /** Intrinsic height of the original image in pixels. */ + naturalHeight: number; + /** Base URL of the unfiltered original asset. */ + originalUrl: string; +} + +/** Color adjustment values. Hue, saturation and brightness range -100..100. */ +export interface AdjustState { + brightness: number; + hue: number; + saturation: number; + grayscale: boolean; +} + +/** Geometric transform values applied to the image. */ +export interface TransformState { + /** Zoom percentage where 100 means no scaling. */ + scale: number; + /** Rotation in degrees. */ + rotateDeg: number; + /** Horizontal flip toggle. */ + flipH: boolean; + /** Vertical flip toggle. */ + flipV: boolean; + /** Explicit output width in pixels, or `null` to derive it. */ + outputWidth: number | null; + /** Explicit output height in pixels, or `null` to derive it. */ + outputHeight: number | null; + /** Whether width/height stay proportional when one is changed. */ + lockAspectRatio: boolean; +} + +/** Crop selection in source pixels. `active` gates whether it is applied. */ +export interface CropState { + x: number; + y: number; + w: number; + h: number; + active: boolean; + /** Locked aspect ratio (width / height) or `null` for freeform. */ + aspect: number | null; +} + +/** Compression configuration and the resulting/original file sizes in bytes. */ +export interface FileInfoState { + compression: CompressionMode; + /** Compression quality 0..100. */ + quality: number; + /** Current preview size in bytes, or `null` when unknown. */ + currentBytes: number | null; + /** Original asset size in bytes, or `null` when unknown. */ + originalBytes: number | null; +} + +/** Canvas zoom state. */ +export interface ZoomState { + /** Zoom level as a multiplier where 1 is 100%. */ + level: number; + /** Whether the image is auto-fitted to the viewport. */ + fitToScreen: boolean; +} + +/** A single server filter and its argument string, ready to be concatenated. */ +export interface AppliedFilter { + name: FilterName; + args: string; +} + +/** A user-facing entry in the applied-edits list. */ +export interface AppliedEditEntry { + id: string; + category: FilterCategory; + label: string; +} + +/** + * Snapshot of the editable state at a point in history, used to power + * undo/redo and coalescing of rapid consecutive edits in the same category. + */ +export interface ImageEditorHistoryEntry { + id: string; + category: FilterCategory; + label: string; + /** State slices captured when this entry was created. */ + snapshot: { + adjust: AdjustState; + transform: TransformState; + crop: CropState; + fileInfo: FileInfoState; + }; +} + +/** Parameters used to open the editor for a given asset. */ +export interface ImageEditorOpenParams { + inode?: string; + tempId?: string; + variable: string; + fieldName: string; + byInode?: boolean; + fileName?: string; + mimeType?: string; +} + +/** + * Public contract for the service that launches the image editor. + * Implementations decide availability (e.g. behind a feature flag) and how + * the editor surfaces its result. + */ +export interface DotImageEditorLauncher { + /** Whether the editor can be launched in the current environment. */ + isAvailable(): boolean; + /** + * Opens the editor for the given asset. + * @param params - Identifiers and metadata of the asset to edit + * @returns Emits the saved temp file, or `null` if the user cancelled + */ + open(params: ImageEditorOpenParams): Observable; +} + +/** + * The complete, flat state of the image editor. Each slice owns a domain of the + * editing experience (color adjustment, geometric transform, crop, + * file/compression info, zoom) plus the editor lifecycle bookkeeping (active + * tool, preview/save status, history and a cache-busting counter). + */ +export interface ImageEditorState { + /** Resolved identifiers and natural dimensions of the asset being edited. */ + assetContext: ImageEditorAssetContext; + /** Color adjustment slice (brightness/hue/saturation/grayscale). */ + adjust: AdjustState; + /** Geometric transform slice (scale/rotate/flip/output dimensions). */ + transform: TransformState; + /** Crop selection slice. */ + crop: CropState; + /** Compression configuration and resulting/original file sizes. */ + fileInfo: FileInfoState; + /** Canvas zoom slice. */ + zoom: ZoomState; + /** + * Normalized 0..1 focal point persisted directly as asset metadata. It is NOT + * a filter slice: it never enters the preview filter chain nor the edit history. + */ + focalPoint: NormalizedPoint; + /** Tool currently selected on the canvas. */ + activeTool: ActiveTool; + /** Loading lifecycle of the preview image. */ + previewStatus: PreviewStatus; + /** Consecutive silent retries of the current failing preview (reset on success). */ + previewRetries: number; + /** Last error message surfaced to the user, or `null`. */ + error: string | null; + /** Ordered undo/redo history of applied edits. */ + history: ImageEditorHistoryEntry[]; + /** Index of the current head entry in {@link history}, or `-1` when empty. */ + historyIndex: number; + /** Monotonic counter appended to preview URLs to bust the browser cache. */ + cacheBust: number; + /** Whether the editor dialog is expanded to fill the viewport (full-screen). */ + isFullscreen: boolean; +} + +// --------------------------------------------------------------------------- +// Implementation-level shapes shared across the components, store and services. +// Kept here so the library has a single home for types rather than scattering +// them across the files that happen to use them. +// --------------------------------------------------------------------------- + +/** Intrinsic pixel dimensions of an image. */ +export interface Dimensions { + width: number; + height: number; +} + +/** State slices required to build the server filter chain. */ +export interface FilterChainInput { + adjust: AdjustState; + transform: TransformState; + crop: CropState; + fileInfo: FileInfoState; + /** Natural image width, needed to translate scale% into resize pixels. */ + naturalWidth: number; + /** Natural image height, needed to translate scale% into resize pixels. */ + naturalHeight: number; +} + +/** A selectable tool on the floating canvas rail. */ +export interface ToolRailItem { + /** The tool identifier dispatched on selection; also selects the inline SVG icon. */ + id: ActiveTool; + /** i18n key for the aria-label and tooltip. */ + label: string; + /** `data-testid` value for the button. */ + testId: string; +} + +/** A selectable compression option shown in the compression dropdown. */ +export interface CompressionOption { + label: string; + value: CompressionMode; +} + +/** A crop rectangle expressed in CSS px, local to the rendered image origin. */ +export interface LocalRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Identifiers for the eight resize handles around the crop box. */ +export type HandlePosition = 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l'; + +/** Natural pixel dimensions of an image resolved from the browser. */ +export interface NaturalDimensions { + naturalWidth: number; + naturalHeight: number; +} + +/** Metadata resolved for an asset before editing begins. */ +export interface AssetMeta { + naturalWidth: number; + naturalHeight: number; + originalBytes: number | null; +} + +/** The editable slices captured in a history snapshot. */ +export type EditableSlices = ImageEditorHistoryEntry['snapshot']; + +/** A field-level patch over the editable slices (only the fields that changed). */ +export type SlicePatch = { + adjust?: Partial; + transform?: Partial; + crop?: Partial; + fileInfo?: Partial; +}; diff --git a/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.spec.ts b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.spec.ts new file mode 100644 index 000000000000..9840849604af --- /dev/null +++ b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.spec.ts @@ -0,0 +1,171 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { DotImageEditorService } from './dot-image-editor.service'; + +// jsdom has no object-URL API; the service creates one for each verified blob. +URL.createObjectURL = jest.fn(() => 'blob:mock-object-url'); + +describe('DotImageEditorService', () => { + let spectator: SpectatorService; + let httpMock: HttpTestingController; + + const createService = createServiceFactory({ + service: DotImageEditorService, + providers: [provideHttpClient(), provideHttpClientTesting()] + }); + + beforeEach(() => { + spectator = createService(); + httpMock = spectator.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('getFileSize', () => { + it('should issue a HEAD request and parse Content-Length to a number', () => { + const result: number[] = []; + spectator.service.getFileSize('/dA/asset.png').subscribe((size) => result.push(size)); + + const req = httpMock.expectOne('/dA/asset.png'); + expect(req.request.method).toBe('HEAD'); + req.flush(null, { headers: { 'Content-Length': '2048' } }); + + expect(result).toEqual([2048]); + }); + + it('should return 0 when Content-Length header is missing', () => { + const result: number[] = []; + spectator.service.getFileSize('/dA/asset.png').subscribe((size) => result.push(size)); + + httpMock.expectOne('/dA/asset.png').flush(null); + + expect(result).toEqual([0]); + }); + + it('should return 0 on HTTP error without throwing', () => { + const result: number[] = []; + let errored = false; + spectator.service.getFileSize('/dA/asset.png').subscribe({ + next: (size) => result.push(size), + error: () => (errored = true) + }); + + httpMock + .expectOne('/dA/asset.png') + .flush('boom', { status: 500, statusText: 'Server Error' }); + + expect(result).toEqual([0]); + expect(errored).toBe(false); + }); + }); + + describe('loadPreviewImage', () => { + it('should GET the URL as a blob and return an object URL for a complete image', () => { + const blob = new Blob(['imagedata'], { type: 'image/png' }); + let result: string | undefined; + spectator.service + .loadPreviewImage('/dA/preview.png') + .subscribe((url) => (result = url)); + + const req = httpMock.expectOne('/dA/preview.png'); + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(blob, { + headers: { 'Content-Length': String(blob.size), 'Content-Type': 'image/png' } + }); + + expect(URL.createObjectURL).toHaveBeenCalledWith(blob); + expect(result).toBe('blob:mock-object-url'); + }); + + it('should accept an image blob when no Content-Length is present', () => { + const blob = new Blob(['x'], { type: 'image/jpeg' }); + let result: string | undefined; + spectator.service + .loadPreviewImage('/dA/preview.png') + .subscribe((url) => (result = url)); + + httpMock + .expectOne('/dA/preview.png') + .flush(blob, { headers: { 'Content-Type': 'image/jpeg' } }); + + expect(result).toBe('blob:mock-object-url'); + }); + + it('should error when the body is shorter than the declared Content-Length (truncated)', () => { + const blob = new Blob(['x'], { type: 'image/png' }); + let errored = false; + spectator.service.loadPreviewImage('/dA/preview.png').subscribe({ + next: () => undefined, + error: () => (errored = true) + }); + + httpMock.expectOne('/dA/preview.png').flush(blob, { + headers: { 'Content-Length': '500', 'Content-Type': 'image/png' } + }); + + expect(errored).toBe(true); + }); + + it('should error on an empty body', () => { + const blob = new Blob([], { type: 'image/png' }); + let errored = false; + spectator.service.loadPreviewImage('/dA/preview.png').subscribe({ + next: () => undefined, + error: () => (errored = true) + }); + + httpMock + .expectOne('/dA/preview.png') + .flush(blob, { headers: { 'Content-Type': 'image/png' } }); + + expect(errored).toBe(true); + }); + + it('should reject an HTML/JSON error body served with a 200', () => { + const blob = new Blob(['error'], { type: 'text/html' }); + let errored = false; + spectator.service.loadPreviewImage('/dA/preview.png').subscribe({ + next: () => undefined, + error: () => (errored = true) + }); + + httpMock + .expectOne('/dA/preview.png') + .flush(blob, { headers: { 'Content-Type': 'text/html' } }); + + expect(errored).toBe(true); + }); + }); + + describe('triggerDownload', () => { + it('should create an anchor with href/download and click it', () => { + const anchor = document.createElement('a'); + const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn()); + const createSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(anchor as HTMLAnchorElement); + const appendSpy = jest.spyOn(document.body, 'appendChild'); + const removeSpy = jest.spyOn(anchor, 'remove'); + + spectator.service.triggerDownload('/dA/asset.png', 'edited.png'); + + expect(createSpy).toHaveBeenCalledWith('a'); + expect(anchor.getAttribute('href')).toBe('/dA/asset.png'); + expect(anchor.download).toBe('edited.png'); + // The anchor must be attached, clicked, then detached so the click works + // cross-browser and leaves no orphan node behind. + expect(appendSpy).toHaveBeenCalledWith(anchor); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + + createSpy.mockRestore(); + appendSpy.mockRestore(); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.ts b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.ts new file mode 100644 index 000000000000..9ab36720a74d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.ts @@ -0,0 +1,138 @@ +import { Observable, forkJoin, of } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { catchError, map } from 'rxjs/operators'; + +import { + AssetMeta, + ImageEditorAssetContext, + NaturalDimensions +} from '../models/image-editor.models'; + +/** + * Data-access service for the image editor: resolves asset metadata, queries the + * verified preview blob and file sizes, and triggers client-side downloads. All + * read-only/metadata calls are non-fatal and never throw. (Saving the edited image + * is handled in a separate issue.) + */ +@Injectable({ providedIn: 'root' }) +export class DotImageEditorService { + readonly #http = inject(HttpClient); + + /** + * Resolves the byte size of a remote asset via a HEAD request. + * @param url - The asset URL to inspect + * @returns The `Content-Length` as a number, or `0` when missing or on error + */ + getFileSize(url: string): Observable { + return this.#http.head(url, { observe: 'response', responseType: 'text' }).pipe( + map((res) => Number(res.headers.get('Content-Length')) || 0), + catchError(() => of(0)) + ); + } + + /** + * Loads a preview image as a complete, verified blob and returns a local + * object URL for it. + * + * Binding a remote `/contentAsset/image/...` URL straight to an `` paints + * progressively, so a partially-generated response (the server renders filters + * on the fly and the first request can race that generation) shows as a + * truncated band — and the browser still fires `load` because the file header, + * which carries the dimensions, arrived intact. Reading the full response body + * here closes that gap: a stream truncated against `Content-Length` makes the + * request error outright, an explicit length mismatch or an empty / error + * (HTML/JSON) body is rejected, and only complete image bytes reach the caller — + * which renders them from a local object URL that can never paint half an image. + * + * The request cancels automatically when the caller unsubscribes (e.g. a newer + * edit supersedes it). Callers own revoking the returned object URL. + * @param url - The fully-built filter/preview URL + * @returns An object URL for the verified image blob; errors on an incomplete + * or non-image response + */ + loadPreviewImage(url: string): Observable { + return this.#http.get(url, { observe: 'response', responseType: 'blob' }).pipe( + map((res) => { + const blob = res.body; + const declared = Number(res.headers.get('Content-Length')); + const type = blob?.type ?? ''; + // The server can answer a still-generating render with a 200 that + // carries an HTML/JSON error page instead of image bytes. + const isErrorBody = type.startsWith('text/') || type.includes('json'); + + if ( + !blob || + blob.size === 0 || + isErrorBody || + (declared > 0 && blob.size !== declared) + ) { + throw new Error('Incomplete or invalid image response'); + } + + return URL.createObjectURL(blob); + }) + ); + } + + /** + * Resolves the metadata needed to seed the editor: natural dimensions and + * the original byte size. Always emits a safe default on error and never + * throws. + * @param ctx - Resolved asset context providing the original URL + * @returns The natural dimensions and original byte size + */ + loadAssetMeta(ctx: ImageEditorAssetContext): Observable { + return forkJoin({ + dimensions: this.#resolveNaturalDimensions(ctx.originalUrl), + originalBytes: this.getFileSize(ctx.originalUrl) + }).pipe( + map(({ dimensions, originalBytes }) => ({ + naturalWidth: dimensions.naturalWidth, + naturalHeight: dimensions.naturalHeight, + originalBytes + })), + catchError(() => + of({ naturalWidth: 0, naturalHeight: 0, originalBytes: null }) + ) + ); + } + + /** + * Triggers a browser download of the given URL using a transient anchor. + * @param url - The URL of the resource to download + * @param fileName - The suggested file name for the download + */ + triggerDownload(url: string, fileName: string): void { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.rel = 'noopener'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + + #resolveNaturalDimensions(url: string): Observable { + return new Observable((subscriber) => { + const image = new Image(); + + image.onload = () => { + subscriber.next({ + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight + }); + subscriber.complete(); + }; + + image.onerror = () => { + subscriber.next({ naturalWidth: 0, naturalHeight: 0 }); + subscriber.complete(); + }; + + image.src = url; + }); + } +} diff --git a/core-web/libs/image-editor/src/lib/store/features/index.ts b/core-web/libs/image-editor/src/lib/store/features/index.ts new file mode 100644 index 000000000000..f323309868b4 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/index.ts @@ -0,0 +1,10 @@ +export { withAdjust } from './with-adjust.feature'; +export { withAsset } from './with-asset.feature'; +export { withCrop } from './with-crop.feature'; +export { withDownload } from './with-download.feature'; +export { withFileInfo } from './with-file-info.feature'; +export { withFocalPoint } from './with-focal-point.feature'; +export { withHistory } from './with-history.feature'; +export { withPreview } from './with-preview.feature'; +export { withTransform } from './with-transform.feature'; +export { withView } from './with-view.feature'; diff --git a/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.spec.ts new file mode 100644 index 000000000000..3f09aebed0d4 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.spec.ts @@ -0,0 +1,71 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withAdjust } from './with-adjust.feature'; + +import { imageEditorAdjustEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const AdjustStore = signalStore(withState(initialImageEditorState), withAdjust()); + +describe('withAdjust', () => { + let store: InstanceType; + let adjust: ReturnType>; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [AdjustStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + store = TestBed.inject(AdjustStore); + runInInjectionContext(injector, () => { + adjust = injectDispatch(imageEditorAdjustEvents); + }); + }); + + it('clamps brightness to the max and records a history entry', () => { + adjust.brightnessChanged(150); + + expect(store.adjust().brightness).toBe(100); + expect(store.history()[0].category).toBe('adjust'); + expect(store.history()[0].label).toBe('Brightness 100'); + expect(store.previewStatus()).toBe('loading'); + expect(store.cacheBust()).toBe(1); + }); + + it('clamps brightness to the min', () => { + adjust.brightnessChanged(-150); + expect(store.adjust().brightness).toBe(-100); + }); + + it('sets and clamps hue', () => { + adjust.hueChanged(50); + expect(store.adjust().hue).toBe(50); + expect(store.history()[0].label).toBe('Hue 50'); + + adjust.hueChanged(999); + expect(store.adjust().hue).toBe(100); + }); + + it('sets and clamps saturation', () => { + adjust.saturationChanged(-30); + expect(store.adjust().saturation).toBe(-30); + expect(store.history()[0].label).toBe('Saturation -30'); + }); + + it('toggles grayscale on under the grayscale category', () => { + adjust.grayscaleToggled(true); + + expect(store.adjust().grayscale).toBe(true); + expect(store.history()[0].category).toBe('grayscale'); + expect(store.history()[0].label).toBe('Grayscale on'); + }); + + it('labels grayscale off when toggled false', () => { + adjust.grayscaleToggled(false); + + expect(store.adjust().grayscale).toBe(false); + expect(store.history()[0].label).toBe('Grayscale off'); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.ts new file mode 100644 index 000000000000..9690f6ec469a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.ts @@ -0,0 +1,49 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { RANGES } from '../../image-editor.constants'; +import { AdjustState, ImageEditorState } from '../../models/image-editor.models'; +import { clamp } from '../../utils/dimensions.util'; +import { imageEditorAdjustEvents } from '../image-editor.events'; +import { adjustPatch } from '../image-editor.store-utils'; + +/** + * Adjust feature: color & light. Folds the brightness / hue / saturation sliders + * and the grayscale toggle into the `adjust` slice (clamped to {@link RANGES}), + * each producing a coalesced history entry via {@link adjustPatch}. + */ +export function withAdjust() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorAdjustEvents.brightnessChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.brightness.min, RANGES.brightness.max); + const adjust: AdjustState = { ...state.adjust, brightness: value }; + + return adjustPatch(state, adjust, 'adjust', `Brightness ${value}`); + }), + on(imageEditorAdjustEvents.hueChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.hue.min, RANGES.hue.max); + const adjust: AdjustState = { ...state.adjust, hue: value }; + + return adjustPatch(state, adjust, 'adjust', `Hue ${value}`); + }), + on(imageEditorAdjustEvents.saturationChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.saturation.min, RANGES.saturation.max); + const adjust: AdjustState = { ...state.adjust, saturation: value }; + + return adjustPatch(state, adjust, 'adjust', `Saturation ${value}`); + }), + on(imageEditorAdjustEvents.grayscaleToggled, ({ payload }, state) => { + const adjust: AdjustState = { ...state.adjust, grayscale: payload }; + + return adjustPatch( + state, + adjust, + 'grayscale', + `Grayscale ${payload ? 'on' : 'off'}` + ); + }) + ) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-asset.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-asset.feature.ts new file mode 100644 index 000000000000..3f2508c149ff --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-asset.feature.ts @@ -0,0 +1,74 @@ +import { tapResponse } from '@ngrx/operators'; +import { signalStoreFeature, type } from '@ngrx/signals'; +import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; + +import { inject } from '@angular/core'; + +import { switchMap } from 'rxjs/operators'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { imageEditorLifecycleEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; +import { contextFromParams, errorMessage } from '../image-editor.store-utils'; + +/** + * Asset feature: resolves the asset being edited. A request resets the editor to + * the new asset's context; the `loadAsset$` effect fetches its natural dimensions + * and original size and folds them back in (or surfaces a load error). + */ +export function withAsset() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorLifecycleEvents.assetRequested, ({ payload }, _state) => ({ + ...initialImageEditorState, + assetContext: contextFromParams(payload), + previewStatus: 'loading' as const + })), + on(imageEditorLifecycleEvents.assetLoaded, ({ payload }, state) => ({ + ...state, + assetContext: { + ...state.assetContext, + naturalWidth: payload.naturalWidth, + naturalHeight: payload.naturalHeight + }, + fileInfo: { + ...state.fileInfo, + originalBytes: payload.originalBytes, + currentBytes: payload.originalBytes + } + })), + on(imageEditorLifecycleEvents.assetLoadFailed, ({ payload }, state) => ({ + ...state, + previewStatus: 'error' as const, + error: errorMessage(payload, 'Failed to load image') + })) + ), + withEventHandlers((store) => { + const events = inject(Events); + const dispatcher = inject(Dispatcher); + const service = inject(DotImageEditorService); + + return { + // Load asset metadata whenever a new asset is requested. + loadAsset$: events.on(imageEditorLifecycleEvents.assetRequested).pipe( + switchMap(() => + service.loadAssetMeta(store.assetContext()).pipe( + tapResponse({ + next: (meta) => + dispatcher.dispatch( + imageEditorLifecycleEvents.assetLoaded(meta) + ), + error: (error) => + dispatcher.dispatch( + imageEditorLifecycleEvents.assetLoadFailed(error) + ) + }) + ) + ) + ) + }; + }) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.spec.ts new file mode 100644 index 000000000000..8a18fc9d9322 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.spec.ts @@ -0,0 +1,57 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withCrop } from './with-crop.feature'; + +import { CropState } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +// Seed a pending resize so applying a crop can be shown to clear it. +const RESIZED = { + ...initialImageEditorState, + transform: { ...initialImageEditorState.transform, scale: 50, outputWidth: 500 } +}; +const ACTIVE_CROP: CropState = { x: 10, y: 10, w: 200, h: 150, active: true, aspect: null }; + +const CropStore = signalStore(withState(RESIZED), withCrop()); +const CropStoreActive = signalStore( + withState({ ...initialImageEditorState, crop: ACTIVE_CROP, activeTool: 'crop' as const }), + withCrop() +); + +describe('withCrop', () => { + it('applies a crop, clears resize, returns to move and adds a crop entry', () => { + TestBed.configureTestingModule({ providers: [CropStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + const store = TestBed.inject(CropStore); + let tool!: ReturnType>; + runInInjectionContext(injector, () => (tool = injectDispatch(imageEditorToolEvents))); + + tool.cropApplied({ x: 10, y: 10, w: 200, h: 150, active: false, aspect: null }); + + expect(store.crop()).toEqual({ x: 10, y: 10, w: 200, h: 150, active: true, aspect: null }); + expect(store.transform().scale).toBe(100); + expect(store.transform().outputWidth).toBeNull(); + expect(store.activeTool()).toBe('move'); + expect(store.history().at(-1)?.category).toBe('crop'); + expect(store.previewStatus()).toBe('loading'); + expect(store.cacheBust()).toBe(1); + }); + + it('cancels a crop back to inactive and the move tool', () => { + TestBed.configureTestingModule({ providers: [CropStoreActive, Dispatcher] }); + const injector = TestBed.inject(Injector); + const store = TestBed.inject(CropStoreActive); + let tool!: ReturnType>; + runInInjectionContext(injector, () => (tool = injectDispatch(imageEditorToolEvents))); + + tool.cropCancelled(); + + expect(store.crop().active).toBe(false); + expect(store.activeTool()).toBe('move'); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.ts new file mode 100644 index 000000000000..c26055c10797 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-crop.feature.ts @@ -0,0 +1,49 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { CropState, ImageEditorState, TransformState } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialCropState } from '../image-editor.state'; +import { coalesceHistory, editableSlicesOf } from '../image-editor.store-utils'; + +/** + * Crop feature: applies or cancels a manual crop selection. Applying a crop is + * mutually exclusive with resize (it clears scale/output dims), returns to the + * move tool and records a coalesced history entry; cancelling clears the pending + * selection. + */ +export function withCrop() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorToolEvents.cropApplied, ({ payload }, state) => { + const crop: CropState = { ...payload, active: true }; + // Crop and resize are mutually exclusive: applying a crop clears resize. + const transform: TransformState = { + ...state.transform, + scale: 100, + outputWidth: null, + outputHeight: null + }; + const next: ImageEditorState = { + ...state, + crop, + transform, + activeTool: 'move', + previewStatus: 'loading', + cacheBust: state.cacheBust + 1 + }; + + return { + ...next, + ...coalesceHistory(next, 'crop', 'Crop', editableSlicesOf(next)) + }; + }), + on(imageEditorToolEvents.cropCancelled, (_event, state) => ({ + ...state, + crop: initialCropState, + activeTool: 'move' as const + })) + ) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-download.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-download.feature.ts new file mode 100644 index 000000000000..4431e993d6fe --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-download.feature.ts @@ -0,0 +1,39 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { Events, withEventHandlers } from '@ngrx/signals/events'; + +import { inject, Signal } from '@angular/core'; + +import { tap } from 'rxjs/operators'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { imageEditorLifecycleEvents } from '../image-editor.events'; + +/** + * Download feature: triggers a client-side download of the current preview. + * Consumes the `previewUrl` selector from {@link withPreview} (so it composes + * after it). Saving the edited image back to the field is handled in a separate + * issue and is intentionally not part of this store. + */ +export function withDownload() { + return signalStoreFeature( + type<{ state: ImageEditorState; props: { previewUrl: Signal } }>(), + withEventHandlers((store) => { + const events = inject(Events); + const service = inject(DotImageEditorService); + + return { + download$: events + .on(imageEditorLifecycleEvents.downloadRequested) + .pipe( + tap(() => + service.triggerDownload( + store.previewUrl(), + store.assetContext().fileName + ) + ) + ) + }; + }) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.spec.ts new file mode 100644 index 000000000000..37e920d405ed --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.spec.ts @@ -0,0 +1,47 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withFileInfo } from './with-file-info.feature'; + +import { imageEditorFileInfoEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const FileInfoStore = signalStore(withState(initialImageEditorState), withFileInfo()); + +describe('withFileInfo', () => { + let store: InstanceType; + let fileInfo: ReturnType>; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [FileInfoStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + store = TestBed.inject(FileInfoStore); + runInInjectionContext(injector, () => { + fileInfo = injectDispatch(imageEditorFileInfoEvents); + }); + }); + + it('sets the compression mode under the compression category', () => { + fileInfo.compressionChanged('jpeg'); + expect(store.fileInfo().compression).toBe('jpeg'); + expect(store.history()[0].category).toBe('compression'); + expect(store.history()[0].label).toBe('Compression JPEG'); + }); + + it('sets and labels quality', () => { + fileInfo.qualityChanged(50); + expect(store.fileInfo().quality).toBe(50); + expect(store.history()[0].label).toBe('Quality 50'); + }); + + it('clamps quality into 0..100', () => { + fileInfo.qualityChanged(150); + expect(store.fileInfo().quality).toBe(100); + + fileInfo.qualityChanged(-50); + expect(store.fileInfo().quality).toBe(0); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.ts new file mode 100644 index 000000000000..be46846099b8 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.ts @@ -0,0 +1,37 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { COMPRESSION_LABELS, RANGES } from '../../image-editor.constants'; +import { FileInfoState, ImageEditorState } from '../../models/image-editor.models'; +import { clamp } from '../../utils/dimensions.util'; +import { imageEditorFileInfoEvents } from '../image-editor.events'; +import { fileInfoPatch } from '../image-editor.store-utils'; + +/** + * File info feature: compression strategy and quality. Folds both into the + * `fileInfo` slice (quality clamped to {@link RANGES}) as coalesced history + * entries via {@link fileInfoPatch}. + */ +export function withFileInfo() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorFileInfoEvents.compressionChanged, ({ payload }, state) => { + const fileInfo: FileInfoState = { ...state.fileInfo, compression: payload }; + + return fileInfoPatch( + state, + fileInfo, + 'compression', + `Compression ${COMPRESSION_LABELS[payload]}` + ); + }), + on(imageEditorFileInfoEvents.qualityChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.quality.min, RANGES.quality.max); + const fileInfo: FileInfoState = { ...state.fileInfo, quality: value }; + + return fileInfoPatch(state, fileInfo, 'compression', `Quality ${value}`); + }) + ) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts new file mode 100644 index 000000000000..babfca5aa254 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts @@ -0,0 +1,44 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withFocalPoint } from './with-focal-point.feature'; + +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const FocalPointStore = signalStore(withState(initialImageEditorState), withFocalPoint()); + +describe('withFocalPoint', () => { + let store: InstanceType; + let tools: ReturnType>; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [FocalPointStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + store = TestBed.inject(FocalPointStore); + runInInjectionContext(injector, () => { + tools = injectDispatch(imageEditorToolEvents); + }); + }); + + it('sets the focal point from focalPointSet', () => { + tools.focalPointSet({ x: 0.3, y: 0.7 }); + + expect(store.focalPoint()).toEqual({ x: 0.3, y: 0.7 }); + }); + + it('clamps the focal point into 0..1', () => { + tools.focalPointSet({ x: 1.5, y: -0.2 }); + + expect(store.focalPoint()).toEqual({ x: 1, y: 0 }); + }); + + it('does not record a history entry (focal point is not a preview edit)', () => { + tools.focalPointSet({ x: 0.42, y: 0.31 }); + + expect(store.history()).toEqual([]); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts new file mode 100644 index 000000000000..9a424414b18d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts @@ -0,0 +1,28 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { clamp } from '../../utils/dimensions.util'; +import { imageEditorToolEvents } from '../image-editor.events'; + +/** + * Focal point feature: tracks the normalized 0..1 focal point as editor state. + * + * It is intentionally NOT persisted on its own. In dotCMS the focal point is part + * of the edit→save pipeline — a write goes to a temp staging slot and is only + * committed to the content during check-in/save (see `FocalPointImageFilter` + + * `BinaryExporterServlet.copyMetadata`). So the marker just records the point here; + * the (separate) Save flow will persist it alongside the other edits. The focal + * point never enters the preview filter chain nor the edit history. + */ +export function withFocalPoint() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => ({ + ...state, + focalPoint: { x: clamp(payload.x, 0, 1), y: clamp(payload.y, 0, 1) } + })) + ) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-history.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-history.feature.spec.ts new file mode 100644 index 000000000000..c77968d1d78c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-history.feature.spec.ts @@ -0,0 +1,152 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext, Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withHistory } from './with-history.feature'; + +import { EditableSlices, ImageEditorHistoryEntry } from '../../models/image-editor.models'; +import { imageEditorHistoryEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; +import { initialEditableSlices } from '../image-editor.store-utils'; + +function entry( + id: string, + category: ImageEditorHistoryEntry['category'], + patch: Partial +): ImageEditorHistoryEntry { + return { id, category, label: id, snapshot: { ...initialEditableSlices, ...patch } }; +} + +const brightness20 = { ...initialEditableSlices.adjust, brightness: 20 }; +// Cumulative snapshots: a (brightness 20), b (brightness 20 + rotate 45), c (+ flipH). +const a = entry('a', 'adjust', { adjust: brightness20 }); +const b = entry('b', 'rotate', { + adjust: brightness20, + transform: { ...initialEditableSlices.transform, rotateDeg: 45 } +}); +const c = entry('c', 'flip', { + adjust: brightness20, + transform: { ...initialEditableSlices.transform, rotateDeg: 45, flipH: true } +}); + +const HistoryStoreAB = signalStore( + withState({ ...initialImageEditorState, history: [a, b], historyIndex: 1 }), + withHistory() +); +const HistoryStoreABCHeadFirst = signalStore( + withState({ ...initialImageEditorState, history: [a, b, c], historyIndex: 0 }), + withHistory() +); + +function setup(StoreClass: Type) { + TestBed.configureTestingModule({ providers: [StoreClass, Dispatcher] }); + const injector = TestBed.inject(Injector); + const store = TestBed.inject(StoreClass); + let history!: ReturnType>; + runInInjectionContext(injector, () => { + history = injectDispatch(imageEditorHistoryEvents); + }); + + return { store, history }; +} + +describe('withHistory', () => { + describe('editRemoved', () => { + it('removes the head entry and rebuilds the surviving slices', () => { + const { store, history } = setup(HistoryStoreAB); + + history.editRemoved({ id: 'b' }); + + expect(store.history().map((e) => e.id)).toEqual(['a']); + expect(store.historyIndex()).toBe(0); + expect(store.adjust().brightness).toBe(20); + expect(store.transform().rotateDeg).toBe(0); + }); + + it('is a no-op when the id does not exist', () => { + const { store, history } = setup(HistoryStoreAB); + + history.editRemoved({ id: 'missing' }); + + expect(store.history()).toHaveLength(2); + expect(store.historyIndex()).toBe(1); + }); + + it('keeps the head when removing an entry past it (redo tail)', () => { + const { store, history } = setup(HistoryStoreABCHeadFirst); // head at index 0 + + history.editRemoved({ id: 'c' }); // index 2 > head + + expect(store.history().map((e) => e.id)).toEqual(['a', 'b']); + expect(store.historyIndex()).toBe(0); + }); + }); + + describe('undo / redo', () => { + it('undoes to the previous snapshot and redoes forward', () => { + const { store, history } = setup(HistoryStoreAB); + + history.undoRequested(); + expect(store.historyIndex()).toBe(0); + expect(store.transform().rotateDeg).toBe(0); + expect(store.adjust().brightness).toBe(20); + + history.redoRequested(); + expect(store.historyIndex()).toBe(1); + expect(store.transform().rotateDeg).toBe(45); + }); + + it('does not undo past the start nor redo past the end', () => { + const { store, history } = setup(HistoryStoreAB); + + history.undoRequested(); + history.undoRequested(); + history.undoRequested(); + expect(store.historyIndex()).toBe(-1); + expect(store.adjust().brightness).toBe(0); + + history.redoRequested(); + history.redoRequested(); + history.redoRequested(); + expect(store.historyIndex()).toBe(1); + }); + }); + + it('resets the whole history and the editable slices', () => { + const { store, history } = setup(HistoryStoreAB); + + history.resetRequested(); + + expect(store.history()).toEqual([]); + expect(store.historyIndex()).toBe(-1); + expect(store.adjust().brightness).toBe(0); + expect(store.transform().rotateDeg).toBe(0); + }); + + describe('computed selectors', () => { + it('appliedEdits lists the entries up to the head', () => { + const { store, history } = setup(HistoryStoreAB); + + expect(store.appliedEdits().map((e) => e.id)).toEqual(['a', 'b']); + + history.undoRequested(); + expect(store.appliedEdits().map((e) => e.id)).toEqual(['a']); + }); + + it('canUndo / canRedo reflect the head position', () => { + const { store, history } = setup(HistoryStoreAB); + + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + history.undoRequested(); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(true); + + history.undoRequested(); + expect(store.canUndo()).toBe(false); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-history.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-history.feature.ts new file mode 100644 index 000000000000..8fd087f9561e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-history.feature.ts @@ -0,0 +1,96 @@ +import { signalStoreFeature, type, withComputed } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { computed } from '@angular/core'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { imageEditorHistoryEvents } from '../image-editor.events'; +import { + initialEditableSlices, + rebuildHistory, + restoreSlices, + slicesAtIndex +} from '../image-editor.store-utils'; + +/** + * History feature: the removable applied-edits list and undo / redo / reset. + * Removing an entry replays the survivors so its effect is folded out; undo/redo + * move the head and restore the corresponding snapshot. Exposes the user-facing + * `appliedEdits` list plus the `canUndo` / `canRedo` flags. + */ +export function withHistory() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorHistoryEvents.editRemoved, ({ payload }, state) => { + const removedIdx = state.history.findIndex((entry) => entry.id === payload.id); + + if (removedIdx === -1) { + return state; + } + + // Replay the survivors so the removed edit's effect is folded out (a + // plain filter left it baked into later snapshots). + const history = rebuildHistory(state.history, removedIdx); + // Preserve the logical head: shift it back only when an entry at or + // before the head was removed, never jumping it forward to the tail. + const historyIndex = + removedIdx <= state.historyIndex + ? Math.max(-1, state.historyIndex - 1) + : Math.min(state.historyIndex, history.length - 1); + + return { + ...state, + ...slicesAtIndex(history, historyIndex), + history, + historyIndex, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + }; + }), + on(imageEditorHistoryEvents.undoRequested, (_event, state) => { + const historyIndex = Math.max(-1, state.historyIndex - 1); + + return { + ...state, + ...slicesAtIndex(state.history, historyIndex), + historyIndex, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + }; + }), + on(imageEditorHistoryEvents.redoRequested, (_event, state) => { + const historyIndex = Math.min(state.history.length - 1, state.historyIndex + 1); + + return { + ...state, + ...slicesAtIndex(state.history, historyIndex), + historyIndex, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + }; + }), + on(imageEditorHistoryEvents.resetRequested, (_event, state) => ({ + ...state, + ...restoreSlices(initialEditableSlices), + history: [], + historyIndex: -1, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + })) + ), + withComputed((store) => ({ + /** The applied edits up to the current history head, for the edits list. */ + appliedEdits: computed(() => + store + .history() + .slice(0, store.historyIndex() + 1) + .map(({ id, category, label }) => ({ id, category, label })) + ), + /** Whether there is a previous history step to undo to. */ + canUndo: computed(() => store.historyIndex() > -1), + /** Whether there is a forward history step to redo to. */ + canRedo: computed(() => store.historyIndex() < store.history().length - 1) + })) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts new file mode 100644 index 000000000000..a298dafe1317 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts @@ -0,0 +1,114 @@ +import { signalStoreFeature, type, withComputed } from '@ngrx/signals'; +import { Dispatcher, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; +import { EMPTY } from 'rxjs'; + +import { computed, inject } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; + +import { catchError, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators'; + +import { AUTO_PREVIEW_RETRY_LIMIT } from '../../image-editor.constants'; +import { ImageEditorState } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { buildFilterChain, buildPreviewUrl } from '../../utils/image-filter-url.builder'; +import { imageEditorLifecycleEvents } from '../image-editor.events'; + +/** + * Preview feature — the heart of the "viewer". Derives the ordered server filter + * chain and the cache-busted preview URL from the edit state, owns the preview + * loading lifecycle (silent retry up to {@link AUTO_PREVIEW_RETRY_LIMIT}, then the + * error UI) and resolves the rendered file size (debounced) as the URL changes. + */ +export function withPreview() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorLifecycleEvents.previewLoaded, (_event, state) => ({ + ...state, + previewStatus: 'loaded' as const, + previewRetries: 0, + error: null + })), + // Image GETs for heavy filter chains can fail transiently even when the URL + // is valid (a later HEAD/GET returns 200). Retry silently with a fresh + // cache-bust up to AUTO_PREVIEW_RETRY_LIMIT before surfacing the error UI. + on(imageEditorLifecycleEvents.previewErrored, (_event, state) => { + if (state.previewRetries < AUTO_PREVIEW_RETRY_LIMIT) { + return { + ...state, + previewRetries: state.previewRetries + 1, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + }; + } + + return { + ...state, + previewStatus: 'error' as const, + previewRetries: 0, + error: 'Failed to render preview' + }; + }), + on(imageEditorLifecycleEvents.retryRequested, (_event, state) => ({ + ...state, + // A manual retry restores the silent-retry budget. + previewRetries: 0, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + })), + on(imageEditorLifecycleEvents.previewSizeResolved, ({ payload }, state) => ({ + ...state, + fileInfo: { ...state.fileInfo, currentBytes: payload } + })) + ), + withComputed((store) => { + const appliedFilters = computed(() => + buildFilterChain({ + adjust: store.adjust(), + transform: store.transform(), + crop: store.crop(), + fileInfo: store.fileInfo(), + naturalWidth: store.assetContext().naturalWidth, + naturalHeight: store.assetContext().naturalHeight + }) + ); + + return { + /** The ordered server filter chain derived from the current edits. */ + appliedFilters, + /** The fully-qualified, cache-busted preview URL for the current edits. */ + previewUrl: computed(() => + buildPreviewUrl(store.assetContext(), appliedFilters(), store.cacheBust()) + ), + /** Whether any edit produces a non-empty filter chain. */ + isDirty: computed(() => appliedFilters().length > 0), + /** Whether the editor is mid-flight loading a preview. */ + isBusy: computed(() => store.previewStatus() === 'loading') + }; + }), + withEventHandlers((store) => { + const dispatcher = inject(Dispatcher); + const service = inject(DotImageEditorService); + + return { + // Resolve the edited preview size, debounced against rapid edits. + resolveSize$: toObservable(store.previewUrl).pipe( + debounceTime(250), + distinctUntilChanged(), + switchMap((url) => + service.getFileSize(url).pipe( + tap((bytes) => + dispatcher.dispatch( + imageEditorLifecycleEvents.previewSizeResolved(bytes) + ) + ), + // Keep the long-lived size stream alive if a dispatch ever + // throws, so the file-size readout doesn't freeze for the session. + catchError(() => EMPTY) + ) + ) + ) + }; + }) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.spec.ts new file mode 100644 index 000000000000..f03f8519453e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.spec.ts @@ -0,0 +1,114 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext, Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withTransform } from './with-transform.feature'; + +import { CropState } from '../../models/image-editor.models'; +import { imageEditorTransformEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const SIZED_CONTEXT = { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 +}; +const ACTIVE_CROP: CropState = { x: 0, y: 0, w: 100, h: 100, active: true, aspect: null }; + +const TransformStore = signalStore(withState(initialImageEditorState), withTransform()); +const TransformStoreSized = signalStore( + withState({ ...initialImageEditorState, assetContext: SIZED_CONTEXT }), + withTransform() +); +const TransformStoreCropped = signalStore( + withState({ ...initialImageEditorState, assetContext: SIZED_CONTEXT, crop: ACTIVE_CROP }), + withTransform() +); + +/** Boots a minimal store for the given seeded class and resolves its dispatcher. */ +function setup(StoreClass: Type) { + TestBed.configureTestingModule({ providers: [StoreClass, Dispatcher] }); + const injector = TestBed.inject(Injector); + const store = TestBed.inject(StoreClass); + let transform!: ReturnType>; + runInInjectionContext(injector, () => { + transform = injectDispatch(imageEditorTransformEvents); + }); + + return { store, transform }; +} + +describe('withTransform', () => { + it('clamps scale to the max', () => { + const { store, transform } = setup(TransformStore); + transform.scaleChanged(500); + expect(store.transform().scale).toBe(400); + }); + + it('clears an active crop when scaling away from 100 (resize XOR crop)', () => { + const { store, transform } = setup(TransformStoreCropped); + transform.scaleChanged(50); + expect(store.transform().scale).toBe(50); + expect(store.crop().active).toBe(false); + }); + + it('keeps the crop when scale stays at 100', () => { + const { store, transform } = setup(TransformStoreCropped); + transform.scaleChanged(100); + expect(store.crop().active).toBe(true); + }); + + it('clamps rotation to both bounds and labels it', () => { + const { store, transform } = setup(TransformStore); + transform.rotateChanged(200); + expect(store.transform().rotateDeg).toBe(180); + expect(store.history().at(-1)?.label).toBe('Rotate 180°'); + + transform.rotateChanged(-200); + expect(store.transform().rotateDeg).toBe(-180); + }); + + it('toggles horizontal and vertical flip under the flip category', () => { + const { store, transform } = setup(TransformStore); + transform.flipHToggled(); + transform.flipVToggled(); + expect(store.transform().flipH).toBe(true); + expect(store.transform().flipV).toBe(true); + expect(store.history().some((entry) => entry.category === 'flip')).toBe(true); + }); + + it('clears an active crop when explicit output dimensions are set', () => { + const { store, transform } = setup(TransformStoreCropped); + transform.outputDimsChanged({ width: 500, height: null }); + expect(store.transform().outputWidth).toBe(500); + expect(store.crop().active).toBe(false); + }); + + it('keeps the crop when output dimensions are cleared (both null)', () => { + const { store, transform } = setup(TransformStoreCropped); + transform.outputDimsChanged({ width: null, height: null }); + expect(store.crop().active).toBe(true); + }); + + describe('outputDimensions selector', () => { + it('reflects the natural size with no edits', () => { + const { store } = setup(TransformStoreSized); + expect(store.outputDimensions()).toEqual({ width: 1000, height: 800 }); + }); + + it('scales the natural size by the scale percentage', () => { + const { store, transform } = setup(TransformStoreSized); + transform.scaleChanged(50); + expect(store.outputDimensions()).toEqual({ width: 500, height: 400 }); + }); + + it('derives the missing dimension from the aspect ratio', () => { + const { store, transform } = setup(TransformStoreSized); + transform.outputDimsChanged({ width: 300, height: null }); + // height = round(300 / (1000/800)) = 240 + expect(store.outputDimensions()).toEqual({ width: 300, height: 240 }); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.ts new file mode 100644 index 000000000000..418d27400b5b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.ts @@ -0,0 +1,72 @@ +import { signalStoreFeature, type, withComputed } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { computed } from '@angular/core'; + +import { RANGES } from '../../image-editor.constants'; +import { ImageEditorState, TransformState } from '../../models/image-editor.models'; +import { clamp, computeOutputDimensions } from '../../utils/dimensions.util'; +import { imageEditorTransformEvents } from '../image-editor.events'; +import { initialCropState } from '../image-editor.state'; +import { transformPatch } from '../image-editor.store-utils'; + +/** + * Transform feature: scale, rotate, flip and explicit output size. Folds the + * transform controls into the `transform` slice (resizing supersedes crop, + * mirroring the filter-chain rule) and exposes the effective `outputDimensions` + * derived from the transform + crop + natural size. + */ +export function withTransform() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorTransformEvents.scaleChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.scale.min, RANGES.scale.max); + // Resizing supersedes crop, mirroring the filter-chain rule. + const transform: TransformState = { ...state.transform, scale: value }; + const crop = value !== 100 ? initialCropState : state.crop; + + return transformPatch(state, transform, crop, 'resize', `Scale ${value}%`); + }), + on(imageEditorTransformEvents.rotateChanged, ({ payload }, state) => { + const value = clamp(payload, RANGES.rotate.min, RANGES.rotate.max); + const transform: TransformState = { ...state.transform, rotateDeg: value }; + + return transformPatch(state, transform, state.crop, 'rotate', `Rotate ${value}°`); + }), + on(imageEditorTransformEvents.flipHToggled, (_event, state) => { + const transform: TransformState = { + ...state.transform, + flipH: !state.transform.flipH + }; + + return transformPatch(state, transform, state.crop, 'flip', 'Flip horizontal'); + }), + on(imageEditorTransformEvents.flipVToggled, (_event, state) => { + const transform: TransformState = { + ...state.transform, + flipV: !state.transform.flipV + }; + + return transformPatch(state, transform, state.crop, 'flip', 'Flip vertical'); + }), + on(imageEditorTransformEvents.outputDimsChanged, ({ payload }, state) => { + const transform: TransformState = { + ...state.transform, + outputWidth: payload.width, + outputHeight: payload.height + }; + const isResizing = payload.width != null || payload.height != null; + const crop = isResizing ? initialCropState : state.crop; + + return transformPatch(state, transform, crop, 'resize', 'Resize'); + }) + ), + withComputed((store) => ({ + /** The effective output dimensions of the edited image. */ + outputDimensions: computed(() => + computeOutputDimensions(store.assetContext(), store.transform(), store.crop()) + ) + })) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-view.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-view.feature.spec.ts new file mode 100644 index 000000000000..ebdf11fc2bc8 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-view.feature.spec.ts @@ -0,0 +1,47 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { withView } from './with-view.feature'; + +import { imageEditorToolEvents, imageEditorViewEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const ViewStore = signalStore(withState(initialImageEditorState), withView()); + +describe('withView', () => { + let store: InstanceType; + let tool: ReturnType>; + let view: ReturnType>; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ViewStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + store = TestBed.inject(ViewStore); + runInInjectionContext(injector, () => { + tool = injectDispatch(imageEditorToolEvents); + view = injectDispatch(imageEditorViewEvents); + }); + }); + + it('selects a tool without touching history', () => { + tool.toolSelected('crop'); + expect(store.activeTool()).toBe('crop'); + expect(store.history()).toHaveLength(0); + + tool.toolSelected('move'); + expect(store.activeTool()).toBe('move'); + }); + + it('toggles full-screen on and off', () => { + expect(store.isFullscreen()).toBe(false); + + view.fullscreenToggled(); + expect(store.isFullscreen()).toBe(true); + + view.fullscreenToggled(); + expect(store.isFullscreen()).toBe(false); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/features/with-view.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-view.feature.ts new file mode 100644 index 000000000000..d8fd71f40329 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-view.feature.ts @@ -0,0 +1,29 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { imageEditorToolEvents, imageEditorViewEvents } from '../image-editor.events'; + +/** + * View feature: the editor's transient view/UI state, as opposed to the edit + * slices that feed the preview URL and history. It owns which canvas tool is + * active (move / crop) and whether the dialog is full-screen. The crop + * interaction itself lives in {@link withCrop}; resizing the dialog to + * full-screen is the root component's job — this feature only owns the + * `isFullscreen` flag it reads. + */ +export function withView() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorToolEvents.toolSelected, ({ payload }, state) => ({ + ...state, + activeTool: payload + })), + on(imageEditorViewEvents.fullscreenToggled, (_event, state) => ({ + ...state, + isFullscreen: !state.isFullscreen + })) + ) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.events.ts b/core-web/libs/image-editor/src/lib/store/image-editor.events.ts new file mode 100644 index 000000000000..a55a7e2bde6e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.events.ts @@ -0,0 +1,91 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +import { + ActiveTool, + CompressionMode, + CropState, + ImageEditorOpenParams, + NormalizedPoint +} from '../models/image-editor.models'; + +/** Events emitted by the Adjust panel (color & light). */ +export const imageEditorAdjustEvents = eventGroup({ + source: 'Image Editor Adjust', + events: { + brightnessChanged: type(), + hueChanged: type(), + saturationChanged: type(), + grayscaleToggled: type() + } +}); + +/** Events emitted by the Transform panel (scale, rotate, flip, output size). */ +export const imageEditorTransformEvents = eventGroup({ + source: 'Image Editor Transform', + events: { + scaleChanged: type(), + rotateChanged: type(), + flipHToggled: type(), + flipVToggled: type(), + outputDimsChanged: type<{ width: number | null; height: number | null }>() + } +}); + +/** Events emitted by the File info panel (compression & quality). */ +export const imageEditorFileInfoEvents = eventGroup({ + source: 'Image Editor File Info', + events: { + compressionChanged: type(), + qualityChanged: type() + } +}); + +/** Events emitted by the editor view chrome (full-screen toggle). */ +export const imageEditorViewEvents = eventGroup({ + source: 'Image Editor View', + events: { + fullscreenToggled: type() + } +}); + +/** Events emitted by the canvas tools (move/crop/focal). */ +export const imageEditorToolEvents = eventGroup({ + source: 'Image Editor Tool', + events: { + toolSelected: type(), + cropApplied: type(), + cropCancelled: type(), + focalPointSet: type() + } +}); + +/** Events emitted by the applied-edits / undo-redo history panel. */ +export const imageEditorHistoryEvents = eventGroup({ + source: 'Image Editor History', + events: { + editRemoved: type<{ id: string }>(), + undoRequested: type(), + redoRequested: type(), + resetRequested: type() + } +}); + +/** Events covering the editor lifecycle: load, preview and download. */ +export const imageEditorLifecycleEvents = eventGroup({ + source: 'Image Editor Lifecycle', + events: { + assetRequested: type(), + previewLoaded: type(), + previewErrored: type(), + retryRequested: type(), + downloadRequested: type(), + assetLoaded: type<{ + naturalWidth: number; + naturalHeight: number; + originalBytes: number | null; + }>(), + assetLoadFailed: type(), + previewSizeResolved: type() + } +}); diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.state.ts b/core-web/libs/image-editor/src/lib/store/image-editor.state.ts new file mode 100644 index 000000000000..2c6afa0a0b9c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.state.ts @@ -0,0 +1,91 @@ +import { + AdjustState, + CropState, + FileInfoState, + ImageEditorAssetContext, + ImageEditorState, + NormalizedPoint, + TransformState, + ZoomState +} from '../models/image-editor.models'; + +/** Default empty asset context used before an asset is requested. */ +const initialAssetContext: ImageEditorAssetContext = { + idOrTempId: '', + inode: null, + tempId: null, + variable: '', + fieldName: '', + fileName: '', + mimeType: '', + isTempFile: false, + byInode: false, + naturalWidth: 0, + naturalHeight: 0, + originalUrl: '' +}; + +/** Default color adjustment slice: no adjustments, no grayscale. */ +export const initialAdjustState: AdjustState = { + brightness: 0, + hue: 0, + saturation: 0, + grayscale: false +}; + +/** Default transform slice: no scaling, rotation, flip or output override. */ +export const initialTransformState: TransformState = { + scale: 100, + rotateDeg: 0, + flipH: false, + flipV: false, + outputWidth: null, + outputHeight: null, + lockAspectRatio: true +}; + +/** Default crop slice: inactive, freeform. */ +export const initialCropState: CropState = { + x: 0, + y: 0, + w: 0, + h: 0, + active: false, + aspect: null +}; + +/** Default file info slice: no compression, quality 85, sizes unknown. */ +export const initialFileInfoState: FileInfoState = { + compression: 'none', + quality: 85, + currentBytes: null, + originalBytes: null +}; + +/** Default zoom slice: 100% and fitted to screen. */ +export const initialZoomState: ZoomState = { + level: 100, + fitToScreen: true +}; + +/** Default focal point: the center of the image. */ +export const initialFocalPointState: NormalizedPoint = { x: 0.5, y: 0.5 }; + +/** The pristine state of the editor before any asset is loaded. */ +export const initialImageEditorState: ImageEditorState = { + assetContext: initialAssetContext, + adjust: initialAdjustState, + transform: initialTransformState, + crop: initialCropState, + fileInfo: initialFileInfoState, + zoom: initialZoomState, + focalPoint: initialFocalPointState, + activeTool: 'move', + previewStatus: 'idle', + previewRetries: 0, + error: null, + history: [], + historyIndex: -1, + cacheBust: 0, + isFullscreen: false +}; diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.spec.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.spec.ts new file mode 100644 index 000000000000..d1fae18544ef --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.spec.ts @@ -0,0 +1,300 @@ +import { initialImageEditorState } from './image-editor.state'; +import { + adjustPatch, + coalesceHistory, + contextFromParams, + editableSlicesOf, + errorMessage, + fileInfoPatch, + initialEditableSlices, + rebuildHistory, + restoreSlices, + slicesAtIndex, + transformPatch +} from './image-editor.store-utils'; + +import { + EditableSlices, + ImageEditorHistoryEntry, + ImageEditorState +} from '../models/image-editor.models'; + +/** A history entry whose snapshot patches the given editable slices. */ +function entry( + id: string, + category: ImageEditorHistoryEntry['category'], + patch: Partial +): ImageEditorHistoryEntry { + return { + id, + category, + label: id, + snapshot: { ...initialEditableSlices, ...patch } + }; +} + +/** A full state seeded with a history and head index. */ +function stateWith( + history: ImageEditorHistoryEntry[], + historyIndex = history.length - 1, + overrides: Partial = {} +): ImageEditorState { + return { ...initialImageEditorState, history, historyIndex, ...overrides }; +} + +describe('image-editor.store-utils', () => { + describe('editableSlicesOf', () => { + it('extracts exactly the four editable slices', () => { + const slices = editableSlicesOf(initialImageEditorState); + + expect(Object.keys(slices).sort()).toEqual(['adjust', 'crop', 'fileInfo', 'transform']); + expect(slices.adjust).toBe(initialImageEditorState.adjust); + }); + }); + + describe('restoreSlices', () => { + it('returns a shallow copy carrying every slice', () => { + const restored = restoreSlices(initialEditableSlices); + + expect(restored).toEqual(initialEditableSlices); + expect(restored).not.toBe(initialEditableSlices); + }); + }); + + describe('slicesAtIndex', () => { + it('returns the initial slices for index < 0', () => { + const history = [ + entry('a', 'adjust', { + adjust: { ...initialEditableSlices.adjust, brightness: 50 } + }) + ]; + + expect(slicesAtIndex(history, -1)).toEqual(initialEditableSlices); + }); + + it('returns the snapshot at a valid index', () => { + const snapshot = { ...initialEditableSlices.adjust, brightness: 50 }; + const history = [entry('a', 'adjust', { adjust: snapshot })]; + + expect(slicesAtIndex(history, 0).adjust.brightness).toBe(50); + }); + }); + + describe('coalesceHistory', () => { + it('appends a new entry when the head category differs', () => { + const state = stateWith([entry('a', 'adjust', {})], 0); + + const result = coalesceHistory(state, 'rotate', 'Rotate 90°', editableSlicesOf(state)); + + expect(result.history).toHaveLength(2); + expect(result.historyIndex).toBe(1); + expect(result.history[1].category).toBe('rotate'); + }); + + it('appends a new entry when history is empty', () => { + const result = coalesceHistory( + initialImageEditorState, + 'adjust', + 'Brightness 10', + editableSlicesOf(initialImageEditorState) + ); + + expect(result.history).toHaveLength(1); + expect(result.historyIndex).toBe(0); + }); + + it('updates in place when the head shares the category', () => { + const state = stateWith([entry('a', 'adjust', {})], 0); + + const result = coalesceHistory( + state, + 'adjust', + 'Brightness 20', + editableSlicesOf(state) + ); + + expect(result.history).toHaveLength(1); + expect(result.history[0].label).toBe('Brightness 20'); + expect(result.historyIndex).toBe(0); + }); + + it('drops the redo tail on a same-category in-place update', () => { + const state = stateWith( + [entry('a', 'adjust', {}), entry('b', 'rotate', {})], + 0 // head on the adjust entry; rotate is a redo step + ); + + const result = coalesceHistory( + state, + 'adjust', + 'Brightness 30', + editableSlicesOf(state) + ); + + expect(result.history).toHaveLength(1); + expect(result.history[0].label).toBe('Brightness 30'); + expect(result.historyIndex).toBe(0); + }); + + it('truncates the redo tail when appending a different category', () => { + const state = stateWith( + [entry('a', 'adjust', {}), entry('b', 'rotate', {})], + 0 // head before the rotate redo step + ); + + const result = coalesceHistory( + state, + 'flip', + 'Flip horizontal', + editableSlicesOf(state) + ); + + expect(result.history.map((e) => e.category)).toEqual(['adjust', 'flip']); + expect(result.historyIndex).toBe(1); + }); + }); + + describe('rebuildHistory', () => { + const wide = { ...initialEditableSlices.adjust, brightness: 10 }; + const tall = { ...initialEditableSlices.adjust, brightness: 10, hue: 20 }; + + it('removes the first entry and replays survivors from initial', () => { + const history = [ + entry('a', 'adjust', { adjust: wide }), + entry('b', 'rotate', { + adjust: wide, + transform: { ...initialEditableSlices.transform, rotateDeg: 90 } + }) + ]; + + const rebuilt = rebuildHistory(history, 0); + + expect(rebuilt).toHaveLength(1); + // The removed brightness delta is folded out; rotation survives. + expect(rebuilt[0].snapshot.adjust.brightness).toBe(0); + expect(rebuilt[0].snapshot.transform.rotateDeg).toBe(90); + }); + + it('removes a middle entry and rebuilds later snapshots without its delta', () => { + const history = [ + entry('a', 'adjust', { adjust: wide }), + entry('b', 'hue', { adjust: tall }), + entry('c', 'rotate', { + adjust: tall, + transform: { ...initialEditableSlices.transform, rotateDeg: 45 } + }) + ]; + + const rebuilt = rebuildHistory(history, 1); + + expect(rebuilt.map((e) => e.id)).toEqual(['a', 'c']); + // The hue delta (from entry b) is folded out of the surviving rotate snapshot. + expect(rebuilt[1].snapshot.adjust.hue).toBe(0); + expect(rebuilt[1].snapshot.adjust.brightness).toBe(10); + expect(rebuilt[1].snapshot.transform.rotateDeg).toBe(45); + }); + + it('removes the last entry', () => { + const history = [ + entry('a', 'adjust', { adjust: wide }), + entry('b', 'rotate', { adjust: wide }) + ]; + + expect(rebuildHistory(history, 1).map((e) => e.id)).toEqual(['a']); + }); + }); + + describe('contextFromParams', () => { + it('prefers the temp file id when present and marks it a temp file', () => { + const ctx = contextFromParams({ + tempId: 'temp-1', + inode: 'inode-1', + variable: 'fileAsset', + fieldName: 'fileAsset' + }); + + expect(ctx.idOrTempId).toBe('temp-1'); + expect(ctx.isTempFile).toBe(true); + expect(ctx.originalUrl).toBe('/contentAsset/image/temp-1/fileAsset'); + }); + + it('falls through an empty temp id to the inode', () => { + const ctx = contextFromParams({ + tempId: '', + inode: 'inode-1', + variable: 'fileAsset', + fieldName: 'fileAsset', + byInode: true + }); + + expect(ctx.idOrTempId).toBe('inode-1'); + expect(ctx.isTempFile).toBe(false); + expect(ctx.byInode).toBe(true); + }); + + it('defaults byInode to false and optional strings to empty', () => { + const ctx = contextFromParams({ inode: 'inode-1', variable: 'v', fieldName: 'f' }); + + expect(ctx.byInode).toBe(false); + expect(ctx.fileName).toBe(''); + expect(ctx.mimeType).toBe(''); + }); + }); + + describe('slice patch helpers', () => { + it('adjustPatch sets loading, bumps the cache-bust and coalesces history', () => { + const next = adjustPatch( + initialImageEditorState, + { ...initialImageEditorState.adjust, brightness: 10 }, + 'adjust', + 'Brightness 10' + ); + + expect(next.adjust.brightness).toBe(10); + expect(next.previewStatus).toBe('loading'); + expect(next.cacheBust).toBe(1); + expect(next.history).toHaveLength(1); + }); + + it('transformPatch applies the transform and crop together', () => { + const next = transformPatch( + initialImageEditorState, + { ...initialImageEditorState.transform, scale: 50 }, + initialImageEditorState.crop, + 'adjust', + 'Scale 50%' + ); + + expect(next.transform.scale).toBe(50); + expect(next.previewStatus).toBe('loading'); + expect(next.cacheBust).toBe(1); + }); + + it('fileInfoPatch applies the fileInfo slice', () => { + const next = fileInfoPatch( + initialImageEditorState, + { ...initialImageEditorState.fileInfo, quality: 50 }, + 'compression', + 'Quality 50' + ); + + expect(next.fileInfo.quality).toBe(50); + expect(next.history[0].category).toBe('compression'); + }); + }); + + describe('errorMessage', () => { + it('returns the message of an Error instance', () => { + expect(errorMessage(new Error('boom'), 'fallback')).toBe('boom'); + }); + + it('returns a string payload as-is', () => { + expect(errorMessage('plain error', 'fallback')).toBe('plain error'); + }); + + it('returns the fallback for any other payload', () => { + expect(errorMessage({ unexpected: true }, 'fallback')).toBe('fallback'); + expect(errorMessage(undefined, 'fallback')).toBe('fallback'); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.ts new file mode 100644 index 000000000000..5c2f2c23476f --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.ts @@ -0,0 +1,271 @@ +import { + initialAdjustState, + initialCropState, + initialFileInfoState, + initialTransformState +} from './image-editor.state'; + +import { SLICE_KEYS } from '../image-editor.constants'; +import { + AdjustState, + CropState, + EditableSlices, + FileInfoState, + FilterCategory, + ImageEditorAssetContext, + ImageEditorHistoryEntry, + ImageEditorOpenParams, + ImageEditorState, + SlicePatch, + TransformState +} from '../models/image-editor.models'; + +/** + * Pure helpers for the {@link ImageEditorStore}: history coalescing/replay, + * asset-context construction, slice-patch reducers and small formatters. Kept out + * of the store file so it stays focused on the signalStore definition; every + * function here is side-effect-free and unit-testable in isolation. + */ + +/** The pristine values of the editable slices, used to seed and reset history. */ +export const initialEditableSlices: EditableSlices = { + adjust: initialAdjustState, + transform: initialTransformState, + crop: initialCropState, + fileInfo: initialFileInfoState +}; + +/** Extracts the editable slices from the full state for snapshotting. */ +export function editableSlicesOf(state: ImageEditorState): EditableSlices { + return { + adjust: state.adjust, + transform: state.transform, + crop: state.crop, + fileInfo: state.fileInfo + }; +} + +/** + * Coalesces a new edit into the undo/redo history. When the current head entry + * shares the edit's category the entry is updated in place (so dragging a slider + * produces a single history step) and any redo tail is discarded; otherwise a new + * entry is appended — also discarding any redo tail. Either way a new value + * invalidates forward history that was built against the old one. + * @param state - The current editor state + * @param category - The category the edit belongs to + * @param label - The human-readable label for the entry + * @param snapshot - The editable slices to capture + * @returns The new `history` array and `historyIndex` + */ +/** Process-local monotonic sequence backing unique, collision-free history-entry ids. */ +let historyEntrySeq = 0; + +export function coalesceHistory( + state: ImageEditorState, + category: FilterCategory, + label: string, + snapshot: EditableSlices +): Pick { + const head = state.history[state.historyIndex]; + + if (head && head.category === category) { + // Update the head in place AND drop any redo tail: a new value in the same + // category invalidates forward history built against the old value (matching + // the redo-tail truncation the new-entry branch below performs). + const history = [ + ...state.history.slice(0, state.historyIndex), + { ...head, label, snapshot } + ]; + + return { history, historyIndex: state.historyIndex }; + } + + const entry: ImageEditorHistoryEntry = { + // A monotonic counter keeps ids collision-free even for two entries created + // in the same millisecond (Date.now() could collide, e.g. under fake timers). + id: `${category}-${++historyEntrySeq}`, + category, + label, + snapshot + }; + const history = [...state.history.slice(0, state.historyIndex + 1), entry]; + + return { history, historyIndex: history.length - 1 }; +} + +/** Builds a partial state that restores the editable slices from a snapshot. */ +export function restoreSlices(snapshot: EditableSlices): EditableSlices { + return { + adjust: snapshot.adjust, + transform: snapshot.transform, + crop: snapshot.crop, + fileInfo: snapshot.fileInfo + }; +} + +/** Resolves the editable slices for a given history index, or initial when empty. */ +export function slicesAtIndex(history: ImageEditorHistoryEntry[], index: number): EditableSlices { + return index < 0 + ? restoreSlices(initialEditableSlices) + : restoreSlices(history[index].snapshot); +} + +/** + * The field-level changes in `next` relative to `prev`, grouped by slice. The + * slices are flat objects of primitives, so a shallow per-field compare captures + * exactly what an edit changed — including the cross-slice resets that crop/resize + * exclusivity produces. + */ +function diffSlices(next: EditableSlices, prev: EditableSlices): SlicePatch { + const patch: Record> = {}; + + for (const key of SLICE_KEYS) { + const nextFields = next[key] as unknown as Record; + const prevFields = prev[key] as unknown as Record; + const fieldPatch: Record = {}; + + for (const field of Object.keys(nextFields)) { + if (nextFields[field] !== prevFields[field]) { + fieldPatch[field] = nextFields[field]; + } + } + + if (Object.keys(fieldPatch).length > 0) { + patch[key] = fieldPatch; + } + } + + return patch as SlicePatch; +} + +/** Merges a field-level patch over a base set of slices. */ +function applySlicePatch(base: EditableSlices, patch: SlicePatch): EditableSlices { + return { + adjust: { ...base.adjust, ...patch.adjust }, + transform: { ...base.transform, ...patch.transform }, + crop: { ...base.crop, ...patch.crop }, + fileInfo: { ...base.fileInfo, ...patch.fileInfo } + }; +} + +/** + * Removes the entry at `removedIdx` and replays the survivors. Each entry's own + * change is recovered as a field-level delta against its predecessor; dropping the + * removed delta and re-folding the rest from the initial state rebuilds every + * surviving entry's cumulative snapshot. This keeps a removed edit's effect from + * lingering in later snapshots (the bug a plain `filter` left behind) and keeps + * undo/redo correct afterwards. + */ +export function rebuildHistory( + history: ImageEditorHistoryEntry[], + removedIdx: number +): ImageEditorHistoryEntry[] { + const deltas = history.map((entry, index) => + diffSlices( + entry.snapshot, + index === 0 ? initialEditableSlices : history[index - 1].snapshot + ) + ); + + let accumulated = restoreSlices(initialEditableSlices); + + return history + .filter((_, index) => index !== removedIdx) + .map((entry, keptIndex) => { + const originalIdx = keptIndex < removedIdx ? keptIndex : keptIndex + 1; + accumulated = applySlicePatch(accumulated, deltas[originalIdx]); + + return { ...entry, snapshot: accumulated }; + }); +} + +/** Produces the asset context for a freshly requested asset. */ +export function contextFromParams(params: ImageEditorOpenParams): ImageEditorAssetContext { + // Use `||` (not `??`) so an empty-string tempId falls through to the inode — callers + // such as the binary field default tempId to '' when no upload has happened. + const idOrTempId = params.tempId || params.inode || ''; + const byInode = params.byInode ?? false; + + return { + idOrTempId, + inode: params.inode ?? null, + tempId: params.tempId ?? null, + variable: params.variable, + fieldName: params.fieldName, + fileName: params.fileName ?? '', + mimeType: params.mimeType ?? '', + isTempFile: !!params.tempId, + byInode, + naturalWidth: 0, + naturalHeight: 0, + // The /contentAsset/image/ path segment is the field VARIABLE (the canonical id + // the server resolves), not the display name. + originalUrl: `/contentAsset/image/${idOrTempId}/${params.variable}` + }; +} + +/** Applies an adjust-slice edit, bumps the cache and coalesces history. */ +export function adjustPatch( + state: ImageEditorState, + adjust: AdjustState, + category: FilterCategory, + label: string +): ImageEditorState { + const next: ImageEditorState = { + ...state, + adjust, + previewStatus: 'loading', + cacheBust: state.cacheBust + 1 + }; + + return { ...next, ...coalesceHistory(next, category, label, editableSlicesOf(next)) }; +} + +/** Applies a transform-slice edit, bumps the cache and coalesces history. */ +export function transformPatch( + state: ImageEditorState, + transform: TransformState, + crop: CropState, + category: FilterCategory, + label: string +): ImageEditorState { + const next: ImageEditorState = { + ...state, + transform, + crop, + previewStatus: 'loading', + cacheBust: state.cacheBust + 1 + }; + + return { ...next, ...coalesceHistory(next, category, label, editableSlicesOf(next)) }; +} + +/** Applies a fileInfo-slice edit, bumps the cache and coalesces history. */ +export function fileInfoPatch( + state: ImageEditorState, + fileInfo: FileInfoState, + category: FilterCategory, + label: string +): ImageEditorState { + const next: ImageEditorState = { + ...state, + fileInfo, + previewStatus: 'loading', + cacheBust: state.cacheBust + 1 + }; + + return { ...next, ...coalesceHistory(next, category, label, editableSlicesOf(next)) }; +} + +/** Extracts a readable message from an unknown error payload. */ +export function errorMessage(payload: unknown, fallback: string): string { + if (payload instanceof Error) { + return payload.message; + } + + if (typeof payload === 'string') { + return payload; + } + + return fallback; +} diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts new file mode 100644 index 000000000000..e7d7019b8efe --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts @@ -0,0 +1,444 @@ +import { mockProvider, SpyObject } from '@ngneat/spectator/jest'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; +import { of, throwError } from 'rxjs'; + +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { + imageEditorAdjustEvents, + imageEditorFileInfoEvents, + imageEditorHistoryEvents, + imageEditorLifecycleEvents, + imageEditorToolEvents, + imageEditorTransformEvents +} from './image-editor.events'; +import { ImageEditorStore } from './image-editor.store'; + +import { ImageEditorOpenParams } from '../models/image-editor.models'; +import { DotImageEditorService } from '../services/dot-image-editor.service'; + +const OPEN_PARAMS: ImageEditorOpenParams = { + inode: 'inode-1', + variable: 'fileAsset', + fieldName: 'fileAsset', + fileName: 'photo.png', + mimeType: 'image/png' +}; + +describe('ImageEditorStore', () => { + let store: InstanceType; + let service: SpyObject; + let injector: Injector; + + // Self-dispatching event groups, resolved within the injection context. + let adjust: ReturnType>; + let transform: ReturnType>; + let fileInfo: ReturnType>; + let tool: ReturnType>; + let history: ReturnType>; + let lifecycle: ReturnType>; + + beforeEach(() => { + jest.useFakeTimers(); + + TestBed.configureTestingModule({ + providers: [ + ImageEditorStore, + Dispatcher, + mockProvider(DotImageEditorService, { + getFileSize: jest.fn().mockReturnValue(of(1000)), + loadAssetMeta: jest + .fn() + .mockReturnValue( + of({ naturalWidth: 800, naturalHeight: 600, originalBytes: 5000 }) + ), + triggerDownload: jest.fn() + }), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ] + }); + + injector = TestBed.inject(Injector); + // Instantiating the store runs its `onInit` effects. + store = TestBed.inject(ImageEditorStore); + service = TestBed.inject(DotImageEditorService) as SpyObject; + + runInInjectionContext(injector, () => { + adjust = injectDispatch(imageEditorAdjustEvents); + transform = injectDispatch(imageEditorTransformEvents); + fileInfo = injectDispatch(imageEditorFileInfoEvents); + tool = injectDispatch(imageEditorToolEvents); + history = injectDispatch(imageEditorHistoryEvents); + lifecycle = injectDispatch(imageEditorLifecycleEvents); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should start from the initial state', () => { + expect(store.adjust().brightness).toBe(0); + expect(store.transform().scale).toBe(100); + expect(store.fileInfo().quality).toBe(85); + expect(store.activeTool()).toBe('move'); + expect(store.previewStatus()).toBe('idle'); + expect(store.history()).toEqual([]); + expect(store.historyIndex()).toBe(-1); + expect(store.cacheBust()).toBe(0); + }); + + describe('panel edits', () => { + it('should clamp and patch brightness and append a history entry', () => { + adjust.brightnessChanged(150); + + expect(store.adjust().brightness).toBe(100); + expect(store.history()).toHaveLength(1); + expect(store.history()[0].category).toBe('adjust'); + expect(store.history()[0].label).toBe('Brightness 100'); + expect(store.historyIndex()).toBe(0); + expect(store.cacheBust()).toBe(1); + expect(store.previewStatus()).toBe('loading'); + }); + + it('should coalesce rapid same-category edits into a single entry', () => { + adjust.brightnessChanged(10); + adjust.brightnessChanged(20); + adjust.saturationChanged(30); + + expect(store.adjust().brightness).toBe(20); + expect(store.adjust().saturation).toBe(30); + expect(store.history()).toHaveLength(1); + expect(store.history()[0].label).toBe('Saturation 30'); + }); + + it('should append a new entry when the category changes', () => { + adjust.brightnessChanged(10); + transform.rotateChanged(90); + + expect(store.history()).toHaveLength(2); + expect(store.history()[0].category).toBe('adjust'); + expect(store.history()[1].category).toBe('rotate'); + expect(store.history()[1].label).toBe('Rotate 90°'); + }); + + it('should clamp scale and reset crop (resize XOR crop)', () => { + tool.cropApplied({ x: 0, y: 0, w: 100, h: 100, active: false, aspect: null }); + transform.scaleChanged(500); + + expect(store.transform().scale).toBe(400); + expect(store.crop().active).toBe(false); + }); + + it('should toggle flip flags under the flip category', () => { + transform.flipHToggled(); + transform.flipVToggled(); + + expect(store.transform().flipH).toBe(true); + expect(store.transform().flipV).toBe(true); + expect(store.history().some((entry) => entry.category === 'flip')).toBe(true); + }); + + it('should set compression under the compression category', () => { + fileInfo.compressionChanged('jpeg'); + + expect(store.fileInfo().compression).toBe('jpeg'); + expect(store.history()[0].category).toBe('compression'); + }); + }); + + describe('tools', () => { + it('should select a tool without touching history', () => { + tool.toolSelected('crop'); + + expect(store.activeTool()).toBe('crop'); + expect(store.history()).toHaveLength(0); + }); + + it('should apply a crop, reset resize and add a crop entry', () => { + transform.scaleChanged(50); + tool.cropApplied({ x: 10, y: 10, w: 200, h: 150, active: false, aspect: null }); + + expect(store.crop()).toEqual({ + x: 10, + y: 10, + w: 200, + h: 150, + active: true, + aspect: null + }); + expect(store.transform().scale).toBe(100); + expect(store.activeTool()).toBe('move'); + expect(store.history().at(-1)?.category).toBe('crop'); + }); + + it('should cancel a crop back to inactive', () => { + tool.cropApplied({ x: 10, y: 10, w: 200, h: 150, active: false, aspect: null }); + tool.cropCancelled(); + + expect(store.crop().active).toBe(false); + expect(store.activeTool()).toBe('move'); + }); + }); + + describe('history', () => { + it('should remove a specific edit and recompute slices', () => { + adjust.brightnessChanged(20); + transform.rotateChanged(45); + const rotateId = store.history()[1].id; + + history.editRemoved({ id: rotateId }); + + expect(store.history()).toHaveLength(1); + expect(store.transform().rotateDeg).toBe(0); + expect(store.adjust().brightness).toBe(20); + }); + + it('should keep the head on the correct entry when a middle edit is removed', () => { + adjust.brightnessChanged(20); + transform.rotateChanged(45); + adjust.saturationChanged(30); + + // Step the head back to the rotate entry, then remove the older + // brightness entry so the removed index sits before the head. + history.undoRequested(); + expect(store.historyIndex()).toBe(1); + const brightnessId = store.history()[0].id; + + history.editRemoved({ id: brightnessId }); + + // The head must not jump to the tail (saturation) — it stays on the + // rotate entry, whose logical position shifted from index 1 to 0. + expect(store.history()).toHaveLength(2); + expect(store.historyIndex()).toBe(0); + expect(store.history()[0].category).toBe('rotate'); + expect(store.history()[0].label).toBe('Rotate 45°'); + // The removed brightness edit is replayed out: the rebuilt snapshot at + // the head keeps the rotation but no longer carries the brightness. + expect(store.transform().rotateDeg).toBe(45); + expect(store.adjust().brightness).toBe(0); + expect(store.adjust().saturation).toBe(0); + }); + + it('should drop a removed edit effect while re-applying the survivors', () => { + // The reported bug: remove compression but keep crop — crop must still + // apply and compression must revert. + fileInfo.compressionChanged('webp'); + tool.cropApplied({ x: 10, y: 10, w: 100, h: 100, active: true, aspect: null }); + const compressionId = store.history()[0].id; + + history.editRemoved({ id: compressionId }); + + expect(store.history()).toHaveLength(1); + expect(store.fileInfo().compression).toBe('none'); + expect(store.crop().active).toBe(true); + }); + + it('should undo and redo edits', () => { + adjust.brightnessChanged(20); + transform.rotateChanged(45); + + history.undoRequested(); + expect(store.historyIndex()).toBe(0); + expect(store.transform().rotateDeg).toBe(0); + expect(store.adjust().brightness).toBe(20); + + history.undoRequested(); + expect(store.historyIndex()).toBe(-1); + expect(store.adjust().brightness).toBe(0); + + history.redoRequested(); + expect(store.historyIndex()).toBe(0); + expect(store.adjust().brightness).toBe(20); + }); + + it('should not undo past the start nor redo past the end', () => { + adjust.brightnessChanged(20); + + history.undoRequested(); + history.undoRequested(); + expect(store.historyIndex()).toBe(-1); + + history.redoRequested(); + history.redoRequested(); + expect(store.historyIndex()).toBe(0); + }); + + it('drops the redo tail when a same-category edit follows an undo', () => { + adjust.brightnessChanged(20); // entry 0 (adjust) + transform.rotateChanged(45); // entry 1 (rotate) + expect(store.history()).toHaveLength(2); + + history.undoRequested(); // back to entry 0; entry 1 is now a redo step + expect(store.canRedo()).toBe(true); + + // A new same-category edit coalesces in place AND invalidates the redo + // tail (it was built against the pre-undo value). + adjust.brightnessChanged(40); + expect(store.history()).toHaveLength(1); + expect(store.historyIndex()).toBe(0); + expect(store.canRedo()).toBe(false); + expect(store.adjust().brightness).toBe(40); + }); + + it('should reset everything', () => { + adjust.brightnessChanged(20); + transform.rotateChanged(45); + + history.resetRequested(); + + expect(store.history()).toEqual([]); + expect(store.historyIndex()).toBe(-1); + expect(store.adjust().brightness).toBe(0); + expect(store.transform().rotateDeg).toBe(0); + }); + }); + + describe('computed truth tables', () => { + it('canUndo / canRedo reflect the history index', () => { + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(false); + + adjust.brightnessChanged(20); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + history.undoRequested(); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(true); + }); + + it('isDirty becomes true only with a non-empty filter chain', () => { + expect(store.isDirty()).toBe(false); + adjust.brightnessChanged(20); + expect(store.isDirty()).toBe(true); + }); + + it('isBusy reflects loading/saving status', () => { + expect(store.isBusy()).toBe(false); + adjust.brightnessChanged(20); + expect(store.isBusy()).toBe(true); + + lifecycle.previewLoaded(); + expect(store.isBusy()).toBe(false); + }); + }); + + describe('preview lifecycle', () => { + it('silently retries failures up to the budget before surfacing the error', () => { + let bust = store.cacheBust(); + + // Each failure within the budget stays loading and bumps the cache-bust + // for a fresh attempt; the error is never surfaced yet. + for (let attempt = 1; attempt <= 3; attempt++) { + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('loading'); + expect(store.cacheBust()).toBe(bust + 1); + expect(store.error()).toBeNull(); + bust = store.cacheBust(); + } + + // The failure past the budget surfaces the error. + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('error'); + expect(store.error()).toBe('Failed to render preview'); + }); + + it('previewLoaded sets status loaded, clears error and resets the retry budget', () => { + // Exhaust the budget (3 silent retries) then fail once more to reach error. + for (let attempt = 0; attempt < 4; attempt++) { + lifecycle.previewErrored(); + } + expect(store.previewStatus()).toBe('error'); + + lifecycle.previewLoaded(); + expect(store.previewStatus()).toBe('loaded'); + expect(store.error()).toBeNull(); + + // Budget restored: a later single failure retries again rather than erroring. + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('loading'); + }); + + it('retryRequested reloads and restores the silent-retry budget', () => { + // Exhaust the budget so the editor is in the error state. + for (let attempt = 0; attempt < 4; attempt++) { + lifecycle.previewErrored(); + } + expect(store.previewStatus()).toBe('error'); + const bust = store.cacheBust(); + + lifecycle.retryRequested(); + expect(store.previewStatus()).toBe('loading'); + expect(store.cacheBust()).toBe(bust + 1); + + // Budget reset: the next failure retries (loading) rather than erroring. + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('loading'); + }); + }); + + describe('previewUrl', () => { + it('recomputes when a slice changes', () => { + lifecycle.assetRequested(OPEN_PARAMS); + const before = store.previewUrl(); + + adjust.brightnessChanged(40); + const after = store.previewUrl(); + + expect(after).not.toBe(before); + expect(after).toContain('/filter/'); + }); + }); + + describe('debounced size effect', () => { + it('fires once after 250ms and updates currentBytes', () => { + lifecycle.assetRequested(OPEN_PARAMS); + service.getFileSize.mockClear(); + service.getFileSize.mockReturnValue(of(4242)); + + adjust.brightnessChanged(10); + adjust.brightnessChanged(20); + + jest.advanceTimersByTime(250); + + expect(service.getFileSize).toHaveBeenCalledTimes(1); + expect(store.fileInfo().currentBytes).toBe(4242); + }); + }); + + describe('asset loading', () => { + it('assetRequested -> loadAssetMeta -> assetLoaded patches the context', () => { + lifecycle.assetRequested(OPEN_PARAMS); + + expect(service.loadAssetMeta).toHaveBeenCalled(); + expect(store.assetContext().idOrTempId).toBe('inode-1'); + expect(store.assetContext().originalUrl).toBe('/contentAsset/image/inode-1/fileAsset'); + expect(store.assetContext().naturalWidth).toBe(800); + expect(store.assetContext().naturalHeight).toBe(600); + expect(store.fileInfo().originalBytes).toBe(5000); + }); + + it('surfaces a load failure when loadAssetMeta errors', () => { + service.loadAssetMeta.mockReturnValue(throwError(() => new Error('boom'))); + + lifecycle.assetRequested(OPEN_PARAMS); + + expect(store.previewStatus()).toBe('error'); + expect(store.error()).toBe('boom'); + }); + }); + + describe('download', () => { + it('triggers a download of the current preview', () => { + lifecycle.assetRequested(OPEN_PARAMS); + + lifecycle.downloadRequested(); + + expect(service.triggerDownload).toHaveBeenCalledWith(store.previewUrl(), 'photo.png'); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/store/image-editor.store.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store.ts new file mode 100644 index 000000000000..b15eb74bf769 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store.ts @@ -0,0 +1,40 @@ +import { signalStore, withState } from '@ngrx/signals'; + +import { + withAdjust, + withAsset, + withCrop, + withDownload, + withFileInfo, + withFocalPoint, + withHistory, + withPreview, + withTransform, + withView +} from './features'; +import { initialImageEditorState } from './image-editor.state'; + +/** + * NgRx SignalStore for the image editor, composed from one vertical feature per + * area of functionality. Each feature + * bundles its own reducers, derived selectors and effects, so a domain lives in a + * single place (`features/with-*.feature.ts`). + * + * Order matters only where features consume each other's selectors: `withPreview` + * derives `previewUrl`, which `withDownload` reads — so download composes after + * preview. The store is NOT provided in root; the editor dialog supplies it so + * each editor instance is isolated. + */ +export const ImageEditorStore = signalStore( + withState(initialImageEditorState), + withAdjust(), + withTransform(), + withCrop(), + withFileInfo(), + withFocalPoint(), + withView(), + withHistory(), + withAsset(), + withPreview(), + withDownload() +); diff --git a/core-web/libs/image-editor/src/lib/utils/dimensions.util.spec.ts b/core-web/libs/image-editor/src/lib/utils/dimensions.util.spec.ts new file mode 100644 index 000000000000..ea113dbeac94 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/dimensions.util.spec.ts @@ -0,0 +1,152 @@ +import { clamp, computeOutputDimensions, computeResizeParams } from './dimensions.util'; + +import { CropState, TransformState } from '../models/image-editor.models'; +import { + initialCropState, + initialImageEditorState, + initialTransformState +} from '../store/image-editor.state'; + +/** A transform slice with the given overrides on top of the defaults. */ +const transform = (overrides: Partial = {}): TransformState => ({ + ...initialTransformState, + ...overrides +}); + +/** A crop slice with the given overrides on top of the defaults. */ +const crop = (overrides: Partial = {}): CropState => ({ + ...initialCropState, + ...overrides +}); + +/** An asset context seeded with the given natural dimensions. */ +const ctx = (naturalWidth: number, naturalHeight: number) => ({ + ...initialImageEditorState.assetContext, + naturalWidth, + naturalHeight +}); + +describe('dimensions.util', () => { + describe('clamp', () => { + it('returns the value when within range', () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it('clamps to the lower bound', () => { + expect(clamp(-5, 0, 10)).toBe(0); + }); + + it('clamps to the upper bound', () => { + expect(clamp(15, 0, 10)).toBe(10); + }); + + it('treats both bounds as inclusive', () => { + expect(clamp(0, 0, 10)).toBe(0); + expect(clamp(10, 0, 10)).toBe(10); + }); + }); + + describe('computeResizeParams', () => { + const natural = { width: 1000, height: 800 }; + + it('returns null/null when no resize is constrained (scale 100, no output)', () => { + expect(computeResizeParams(transform(), natural)).toEqual({ + width: null, + height: null + }); + }); + + it('uses explicit output width and height when both are set', () => { + expect( + computeResizeParams(transform({ outputWidth: 320.4, outputHeight: 240.6 }), natural) + ).toEqual({ width: 320, height: 241 }); + }); + + it('returns only the width when just the width is set', () => { + expect(computeResizeParams(transform({ outputWidth: 500 }), natural)).toEqual({ + width: 500, + height: null + }); + }); + + it('returns only the height when just the height is set', () => { + expect(computeResizeParams(transform({ outputHeight: 250 }), natural)).toEqual({ + width: null, + height: 250 + }); + }); + + it('scales the natural size by the scale percentage when no output is set', () => { + expect(computeResizeParams(transform({ scale: 50 }), natural)).toEqual({ + width: 500, + height: 400 + }); + }); + }); + + describe('computeOutputDimensions', () => { + it('returns the natural size with no edits', () => { + expect(computeOutputDimensions(ctx(1000, 800), transform(), crop())).toEqual({ + width: 1000, + height: 800 + }); + }); + + it('uses the crop size when an active crop is present and not resizing', () => { + expect( + computeOutputDimensions( + ctx(1000, 800), + transform(), + crop({ active: true, w: 200.4, h: 150.6 }) + ) + ).toEqual({ width: 200, height: 151 }); + }); + + it('ignores the crop when resizing (resize supersedes crop)', () => { + expect( + computeOutputDimensions( + ctx(1000, 800), + transform({ scale: 50 }), + crop({ active: true, w: 200, h: 150 }) + ) + ).toEqual({ width: 500, height: 400 }); + }); + + it('uses explicit output width and height when both are set', () => { + expect( + computeOutputDimensions( + ctx(1000, 800), + transform({ outputWidth: 300, outputHeight: 200 }), + crop() + ) + ).toEqual({ width: 300, height: 200 }); + }); + + it('derives the height from the aspect ratio when only width is set', () => { + // aspect 1000/800 = 1.25 → height = round(500 / 1.25) = 400 + expect( + computeOutputDimensions(ctx(1000, 800), transform({ outputWidth: 500 }), crop()) + ).toEqual({ width: 500, height: 400 }); + }); + + it('derives the width from the aspect ratio when only height is set', () => { + // height 400 → width = round(400 * 1.25) = 500 + expect( + computeOutputDimensions(ctx(1000, 800), transform({ outputHeight: 400 }), crop()) + ).toEqual({ width: 500, height: 400 }); + }); + + it('falls back to aspect 1 when the natural height is zero', () => { + // height 0 → aspect defaults to 1 → height = round(500 / 1) = 500 + expect( + computeOutputDimensions(ctx(1000, 0), transform({ outputWidth: 500 }), crop()) + ).toEqual({ width: 500, height: 500 }); + }); + + it('scales by the scale percentage when no explicit output is set', () => { + expect( + computeOutputDimensions(ctx(1000, 800), transform({ scale: 25 }), crop()) + ).toEqual({ width: 250, height: 200 }); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts b/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts new file mode 100644 index 000000000000..061960b4cdba --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts @@ -0,0 +1,93 @@ +import { + CropState, + Dimensions, + ImageEditorAssetContext, + TransformState +} from '../models/image-editor.models'; + +/** + * Constrains a number to the inclusive `[min, max]` range. + * @param v - The value to clamp + * @param min - The lower bound + * @param max - The upper bound + * @returns `v` clamped to `[min, max]` + */ +export function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +/** + * Resolves the resize width/height to request from the server based on the + * transform state, falling back to scaling the natural dimensions when no + * explicit output size is set. + * @param transform - The current transform state + * @param natural - The intrinsic dimensions of the source image + * @returns The width and height to resize to, each `null` when not constrained + */ +export function computeResizeParams( + transform: TransformState, + natural: Dimensions +): { width: number | null; height: number | null } { + if (transform.outputWidth != null || transform.outputHeight != null) { + return { + width: transform.outputWidth != null ? Math.round(transform.outputWidth) : null, + height: transform.outputHeight != null ? Math.round(transform.outputHeight) : null + }; + } + + if (transform.scale !== 100) { + const factor = transform.scale / 100; + return { + width: Math.round(natural.width * factor), + height: Math.round(natural.height * factor) + }; + } + + return { width: null, height: null }; +} + +/** + * Computes the effective output dimensions of the edited image, accounting for + * an active crop and any resize/scale in the transform state. + * @param ctx - The asset context (provides the natural dimensions) + * @param transform - The current transform state + * @param crop - The current crop state + * @returns The resulting width and height in pixels + */ +export function computeOutputDimensions( + ctx: ImageEditorAssetContext, + transform: TransformState, + crop: CropState +): Dimensions { + let width = ctx.naturalWidth; + let height = ctx.naturalHeight; + + const isResizing = + transform.outputWidth != null || transform.outputHeight != null || transform.scale !== 100; + + // Resizing supersedes crop, mirroring the filter-chain rule. + if (!isResizing && crop.active && crop.w > 0 && crop.h > 0) { + width = Math.round(crop.w); + height = Math.round(crop.h); + } + + if (transform.outputWidth != null || transform.outputHeight != null) { + const aspect = height === 0 ? 1 : width / height; + if (transform.outputWidth != null && transform.outputHeight != null) { + width = Math.round(transform.outputWidth); + height = Math.round(transform.outputHeight); + } else if (transform.outputWidth != null) { + width = Math.round(transform.outputWidth); + height = Math.round(width / aspect); + } else if (transform.outputHeight != null) { + height = Math.round(transform.outputHeight); + width = Math.round(height * aspect); + } + } else if (transform.scale !== 100) { + const factor = transform.scale / 100; + width = Math.round(width * factor); + height = Math.round(height * factor); + } + + return { width, height }; +} diff --git a/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.spec.ts b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.spec.ts new file mode 100644 index 000000000000..722283ac8380 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.spec.ts @@ -0,0 +1,300 @@ +import { buildFilterChain, buildPreviewUrl, cleanUrl, toHsb } from './image-filter-url.builder'; + +import { + AdjustState, + CompressionMode, + CropState, + FileInfoState, + ImageEditorAssetContext, + TransformState +} from '../models/image-editor.models'; + +const baseAdjust: AdjustState = { + brightness: 0, + hue: 0, + saturation: 0, + grayscale: false +}; + +const baseTransform: TransformState = { + scale: 100, + rotateDeg: 0, + flipH: false, + flipV: false, + outputWidth: null, + outputHeight: null, + lockAspectRatio: true +}; + +const baseCrop: CropState = { + x: 0, + y: 0, + w: 0, + h: 0, + active: false, + aspect: null +}; + +const baseFileInfo: FileInfoState = { + compression: 'none', + quality: 65, + currentBytes: null, + originalBytes: null +}; + +const baseContext: ImageEditorAssetContext = { + idOrTempId: 'abc123', + inode: 'abc123', + tempId: null, + variable: 'fileAsset', + fieldName: 'fileAsset', + fileName: 'image.png', + mimeType: 'image/png', + isTempFile: false, + byInode: false, + naturalWidth: 1000, + naturalHeight: 800, + originalUrl: '/contentAsset/image/abc123/fileAsset' +}; + +function chain( + overrides: Partial<{ + adjust: Partial; + transform: Partial; + crop: Partial; + fileInfo: Partial; + naturalWidth: number; + naturalHeight: number; + }> = {} +) { + return buildFilterChain({ + adjust: { ...baseAdjust, ...overrides.adjust }, + transform: { ...baseTransform, ...overrides.transform }, + crop: { ...baseCrop, ...overrides.crop }, + fileInfo: { ...baseFileInfo, ...overrides.fileInfo }, + naturalWidth: overrides.naturalWidth ?? 1000, + naturalHeight: overrides.naturalHeight ?? 800 + }); +} + +describe('image-filter-url.builder', () => { + describe('toHsb', () => { + it('formats positive midpoint with two decimals', () => { + expect(toHsb(50)).toBe('0.50'); + }); + + it('formats negative bounds with two decimals', () => { + expect(toHsb(-100)).toBe('-1.00'); + }); + + it('formats zero', () => { + expect(toHsb(0)).toBe('0.00'); + }); + }); + + describe('buildFilterChain - individual controls', () => { + it('produces no filters for the default state', () => { + expect(chain()).toEqual([]); + }); + + it('builds a Resize filter from explicit output dimensions', () => { + const result = chain({ transform: { outputWidth: 500, outputHeight: 250 } }); + expect(result).toEqual([{ name: 'Resize', args: '/resize_w/500/resize_h/250' }]); + }); + + it('builds a Resize filter when only width is set', () => { + const result = chain({ transform: { outputWidth: 500 } }); + expect(result).toEqual([{ name: 'Resize', args: '/resize_w/500' }]); + }); + + it('builds a Crop filter with rounded params', () => { + const result = chain({ + crop: { active: true, x: 10.4, y: 20.6, w: 100.5, h: 50.2 } + }); + expect(result).toEqual([ + { name: 'Crop', args: '/crop_w/101/crop_h/50/crop_x/10/crop_y/21' } + ]); + }); + + it('does not crop when inactive', () => { + const result = chain({ crop: { active: false, x: 0, y: 0, w: 100, h: 50 } }); + expect(result).toEqual([]); + }); + + it('applies Crop after Flip/Rotate so it crops the image as displayed', () => { + const result = chain({ + transform: { flipH: true, rotateDeg: 90 }, + crop: { active: true, x: 10, y: 20, w: 100, h: 50 } + }); + const names = result.map((f) => f.name); + const rotateIdx = names.indexOf('Rotate'); + const flipIdx = names.indexOf('Flip'); + const cropIdx = names.indexOf('Crop'); + + expect(cropIdx).toBeGreaterThan(rotateIdx); + expect(cropIdx).toBeGreaterThan(flipIdx); + }); + + it('builds a Rotate filter', () => { + const result = chain({ transform: { rotateDeg: 90 } }); + expect(result).toEqual([{ name: 'Rotate', args: '/rotate_a/90.0' }]); + }); + + it('builds a Grayscale filter', () => { + const result = chain({ adjust: { grayscale: true } }); + expect(result).toEqual([{ name: 'Grayscale', args: '/grayscale/1' }]); + }); + + it('builds an Hsb filter in h/s/b order', () => { + const result = chain({ + adjust: { hue: 50, saturation: -100, brightness: 25 } + }); + expect(result).toEqual([{ name: 'Hsb', args: '/hsb_h/0.50/hsb_s/-1.00/hsb_b/0.25' }]); + }); + + it('builds an Hsb filter when only one channel is non-zero', () => { + const result = chain({ adjust: { brightness: 10 } }); + expect(result).toEqual([{ name: 'Hsb', args: '/hsb_h/0.00/hsb_s/0.00/hsb_b/0.10' }]); + }); + }); + + describe('buildFilterChain - resize removes crop', () => { + it('drops the Crop filter when resizing', () => { + const result = chain({ + transform: { outputWidth: 600 }, + crop: { active: true, x: 0, y: 0, w: 100, h: 100 } + }); + expect(result).toEqual([{ name: 'Resize', args: '/resize_w/600' }]); + expect(result.some((f) => f.name === 'Crop')).toBe(false); + }); + + it('treats a non-100 scale as resizing and drops crop', () => { + const result = chain({ + transform: { scale: 50 }, + crop: { active: true, x: 0, y: 0, w: 100, h: 100 } + }); + expect(result.some((f) => f.name === 'Crop')).toBe(false); + }); + + it('builds a Resize filter from scale% × the natural size', () => { + // 50% of the 1000×800 natural size. + const result = chain({ transform: { scale: 50 } }); + expect(result).toEqual([{ name: 'Resize', args: '/resize_w/500/resize_h/400' }]); + }); + }); + + describe('buildFilterChain - flip rules', () => { + it('expresses vertical flip as flip token plus 180deg rotation', () => { + const result = chain({ transform: { flipV: true } }); + expect(result).toEqual([ + { name: 'Rotate', args: '/rotate_a/180.0' }, + { name: 'Flip', args: '/flip_flip/1' } + ]); + }); + + it('combines an existing rotation with the vertical-flip rotation', () => { + const result = chain({ transform: { rotateDeg: 90, flipV: true } }); + expect(result).toContainEqual({ name: 'Rotate', args: '/rotate_a/270.0' }); + }); + + it('emits a single Flip token for a horizontal flip', () => { + const result = chain({ transform: { flipH: true } }); + expect(result).toEqual([{ name: 'Flip', args: '/flip_flip/1' }]); + }); + + it('cancels the flip token when both H and V flips are active', () => { + const result = chain({ transform: { flipH: true, flipV: true } }); + expect(result.some((f) => f.name === 'Flip')).toBe(false); + expect(result).toContainEqual({ name: 'Rotate', args: '/rotate_a/180.0' }); + }); + }); + + describe('buildFilterChain - compression', () => { + const cases: Array<[CompressionMode, string, string]> = [ + ['jpeg', '/jpeg_q/80', 'Jpeg'], + ['webp', '/webp_q/80', 'WebP'], + ['avif', '/avif_q/80', 'avif'], + ['auto', '/quality_q/80', 'Quality'] + ]; + + it.each(cases)('appends %s compression last', (mode, args, name) => { + const result = chain({ + adjust: { grayscale: true }, + fileInfo: { compression: mode, quality: 80 } + }); + expect(result[result.length - 1]).toEqual({ name, args }); + }); + + it('adds no compression filter for mode none', () => { + const result = chain({ fileInfo: { compression: 'none', quality: 80 } }); + expect(result).toEqual([]); + }); + + it('applies only one compression filter (mutual exclusion)', () => { + const result = chain({ fileInfo: { compression: 'webp', quality: 50 } }); + const compressionFilters = result.filter((f) => + ['Jpeg', 'WebP', 'avif', 'Quality'].includes(f.name) + ); + expect(compressionFilters).toHaveLength(1); + }); + + it('clamps the quality value into 0..100', () => { + const high = chain({ fileInfo: { compression: 'jpeg', quality: 150 } }); + const low = chain({ fileInfo: { compression: 'jpeg', quality: -20 } }); + expect(high[0]).toEqual({ name: 'Jpeg', args: '/jpeg_q/100' }); + expect(low[0]).toEqual({ name: 'Jpeg', args: '/jpeg_q/0' }); + }); + }); + + describe('cleanUrl', () => { + it('collapses repeated slashes in the path', () => { + expect(cleanUrl('/contentAsset//image///abc')).toBe('/contentAsset/image/abc'); + }); + + it('preserves the protocol separator', () => { + expect(cleanUrl('https://host//a//b')).toBe('https://host/a/b'); + }); + }); + + describe('buildPreviewUrl', () => { + it('returns the base url with a cache-buster for an empty chain', () => { + const url = buildPreviewUrl(baseContext, [], 12345); + expect(url).toBe('/contentAsset/image/abc123/fileAsset?test=12345'); + }); + + it('appends the filter segment for a non-empty chain', () => { + const filters = chain({ adjust: { grayscale: true } }); + const url = buildPreviewUrl(baseContext, filters, 999); + expect(url).toBe( + '/contentAsset/image/abc123/fileAsset/filter/Grayscale/grayscale/1?test=999' + ); + }); + + it('joins multiple filter names with commas and concatenates args', () => { + const filters = chain({ + transform: { rotateDeg: 90 }, + adjust: { grayscale: true } + }); + const url = buildPreviewUrl(baseContext, filters, 1); + expect(url).toContain('/filter/Rotate,Grayscale/rotate_a/90.0/grayscale/1'); + }); + + it('uses & for the cache-buster when the url already has a query', () => { + const ctx = { ...baseContext, originalUrl: '/contentAsset/image/abc123/fileAsset?x=1' }; + const url = buildPreviewUrl(ctx, [], 7); + expect(url).toBe('/contentAsset/image/abc123/fileAsset?x=1&test=7'); + }); + + it('appends &byInode=true when the context requires it', () => { + const ctx = { ...baseContext, byInode: true }; + const url = buildPreviewUrl(ctx, [], 42); + expect(url).toBe('/contentAsset/image/abc123/fileAsset?test=42&byInode=true'); + }); + + it('collapses redundant slashes from the original url', () => { + const ctx = { ...baseContext, originalUrl: '/contentAsset//image//abc123/fileAsset' }; + const url = buildPreviewUrl(ctx, [], 5); + expect(url).toBe('/contentAsset/image/abc123/fileAsset?test=5'); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts new file mode 100644 index 000000000000..936128178dba --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts @@ -0,0 +1,160 @@ +import { computeResizeParams } from './dimensions.util'; + +import { + AppliedFilter, + CompressionMode, + FilterChainInput, + ImageEditorAssetContext +} from '../models/image-editor.models'; + +/** + * Formats an HSB slider value (-100..100) into the legacy `-1..1` string the + * dotCMS Hsb filter expects, always with two decimals (e.g. 50 -> "0.50"). + * @param v - Slider value in the range -100..100 + * @returns The value divided by 100, fixed to two decimals + */ +export function toHsb(v: number): string { + return (v / 100).toFixed(2); +} + +/** Clamps a quality value into the valid 0..100 range and rounds it. */ +function clampQuality(quality: number): number { + return Math.min(100, Math.max(0, Math.round(quality))); +} + +function compressionFilter(mode: CompressionMode, quality: number): AppliedFilter | null { + const q = clampQuality(quality); + switch (mode) { + case 'jpeg': + return { name: 'Jpeg', args: `/jpeg_q/${q}` }; + case 'webp': + return { name: 'WebP', args: `/webp_q/${q}` }; + // AVIF is a libvips-only filter (registered lowercase as `avif`); it + // takes the same 0..100 quality as jpeg/webp via `avif_q`. + case 'avif': + return { name: 'avif', args: `/avif_q/${q}` }; + case 'auto': + return { name: 'Quality', args: `/quality_q/${q}` }; + case 'none': + default: + return null; + } +} + +/** + * Builds the ordered list of server filters from the current edit state, + * mirroring the legacy ImageEditor rules: resizing removes crop, vertical flip + * is expressed as a 180deg rotation plus flip-token cancellation, crop is applied + * after the geometry transforms (so it crops the image as displayed), and the + * compression filter is always applied last and exclusively. + * @param input - The adjust/transform/crop/fileInfo slices plus the natural dimensions + * @returns The applied filters in the exact order they must be concatenated + */ +export function buildFilterChain(input: FilterChainInput): AppliedFilter[] { + const { adjust, transform, crop, fileInfo, naturalWidth, naturalHeight } = input; + const filters: AppliedFilter[] = []; + + // Resize derives from explicit output dimensions OR scale% × natural size, so a + // scale change with no explicit W/H still produces resize pixels. + const resize = computeResizeParams(transform, { width: naturalWidth, height: naturalHeight }); + const hasResize = !!(resize.width || resize.height); + + if (hasResize) { + let args = ''; + if (resize.width) { + args += `/resize_w/${resize.width}`; + } + if (resize.height) { + args += `/resize_h/${resize.height}`; + } + filters.push({ name: 'Resize', args }); + } + + // Vertical flip is achieved by rotating 180deg and toggling the flip token. + let rotation = transform.rotateDeg; + if (transform.flipV) { + rotation = (rotation + 180) % 360; + } + if (rotation !== 0) { + filters.push({ name: 'Rotate', args: `/rotate_a/${rotation}.0` }); + } + + if (transform.flipH !== transform.flipV) { + filters.push({ name: 'Flip', args: '/flip_flip/1' }); + } + + // Crop is applied AFTER the geometry transforms (rotate/flip) so it acts on the + // image as displayed — the box the user draws lives in the flipped/rotated + // preview's coordinates ("crop what you see", matching the legacy editor, which + // appends Crop to the already-transformed chain). Without this, a horizontal + // flip mirrors the crop region. Resize and crop stay mutually exclusive: resize + // wins. + if (!hasResize && crop.active && crop.w > 0 && crop.h > 0) { + const args = + `/crop_w/${Math.round(crop.w)}` + + `/crop_h/${Math.round(crop.h)}` + + `/crop_x/${Math.round(crop.x)}` + + `/crop_y/${Math.round(crop.y)}`; + filters.push({ name: 'Crop', args }); + } + + if (adjust.grayscale) { + filters.push({ name: 'Grayscale', args: '/grayscale/1' }); + } + + if (adjust.brightness !== 0 || adjust.hue !== 0 || adjust.saturation !== 0) { + const args = + `/hsb_h/${toHsb(adjust.hue)}` + + `/hsb_s/${toHsb(adjust.saturation)}` + + `/hsb_b/${toHsb(adjust.brightness)}`; + filters.push({ name: 'Hsb', args }); + } + + const compression = compressionFilter(fileInfo.compression, fileInfo.quality); + if (compression) { + filters.push(compression); + } + + return filters; +} + +/** + * Collapses repeated path slashes while preserving the protocol separator + * (e.g. `http://host//a//b` -> `http://host/a/b`). + * @param x - The URL to normalize + * @returns The URL with redundant slashes removed + */ +export function cleanUrl(x: string): string { + return x.replace(/([^:]\/)\/+/g, '$1'); +} + +/** + * Builds the preview URL for the given asset and filter chain, appending a + * cache-busting `test` parameter and the `byInode` flag when applicable. + * @param ctx - Resolved asset context (provides the base URL and byInode flag) + * @param chain - The ordered filters produced by {@link buildFilterChain} + * @param cacheBust - A value used to bust the browser cache (e.g. `Date.now()`) + * @returns The fully-qualified preview URL + */ +export function buildPreviewUrl( + ctx: ImageEditorAssetContext, + chain: AppliedFilter[], + cacheBust: number +): string { + let url = ctx.originalUrl; + + if (chain.length > 0) { + const names = chain.map((filter) => filter.name).join(','); + const args = chain.map((filter) => filter.args).join(''); + url = `${url}/filter/${names}${args}`; + } + + url = cleanUrl(url); + url += url.includes('?') ? `&test=${cacheBust}` : `?test=${cacheBust}`; + + if (ctx.byInode) { + url += '&byInode=true'; + } + + return url; +} diff --git a/core-web/libs/image-editor/src/lib/utils/panel-state.storage.spec.ts b/core-web/libs/image-editor/src/lib/utils/panel-state.storage.spec.ts new file mode 100644 index 000000000000..d9f72d74787c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/panel-state.storage.spec.ts @@ -0,0 +1,64 @@ +import { getStoredPanelState, savePanelState } from './panel-state.storage'; + +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; + +/** The default (no stored layout) opens every section. */ +const ALL_OPEN = ['adjust', 'transform', 'fileinfo', 'history']; + +describe('panel-state.storage', () => { + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('getStoredPanelState', () => { + it('returns every section (all open) when nothing is stored', () => { + expect(getStoredPanelState()).toEqual(ALL_OPEN); + }); + + it('returns the persisted open sections', () => { + localStorage.setItem( + IMAGE_EDITOR_PANEL_STATE_KEY, + JSON.stringify(['adjust', 'history']) + ); + + expect(getStoredPanelState()).toEqual(['adjust', 'history']); + }); + + it('falls back to the default when the stored value is corrupt', () => { + localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, '{ not json'); + + expect(getStoredPanelState()).toEqual(ALL_OPEN); + }); + + it('falls back to the default when the stored value is not an array of strings', () => { + localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, JSON.stringify({ adjust: true })); + + expect(getStoredPanelState()).toEqual(ALL_OPEN); + }); + }); + + describe('savePanelState', () => { + it('persists the open sections as JSON', () => { + savePanelState(['transform']); + + expect(localStorage.getItem(IMAGE_EDITOR_PANEL_STATE_KEY)).toBe( + JSON.stringify(['transform']) + ); + }); + + it('round-trips through getStoredPanelState', () => { + savePanelState(['adjust', 'fileinfo']); + + expect(getStoredPanelState()).toEqual(['adjust', 'fileinfo']); + }); + + it('does not throw when storage is unavailable', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + expect(() => savePanelState(['adjust'])).not.toThrow(); + }); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/utils/panel-state.storage.ts b/core-web/libs/image-editor/src/lib/utils/panel-state.storage.ts new file mode 100644 index 000000000000..32435e661ef6 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/panel-state.storage.ts @@ -0,0 +1,50 @@ +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; + +/** + * Persistence for the image-editor side-panel accordion's expanded sections. + * + * Mirrors the Edit Content sidebar pattern (`getStoredUIState` / + * `saveStoreUIState` in `libs/edit-content/.../functions.util.ts`): a guarded + * read that falls back to a sane default and a guarded write, both wrapped so a + * blocked or corrupt storage entry can never break the editor. We use + * `localStorage` (Edit Content uses `sessionStorage`) so the user's last layout + * survives across browser sessions — they get the panels back the way they left + * them, even after closing the tab. + * + * The stored value (under {@link IMAGE_EDITOR_PANEL_STATE_KEY}) is the array of + * open `p-accordion-panel` values (e.g. `['adjust']`); the default opens every + * section so a first-time user sees all the controls at once. + */ + +/** Every accordion section open by default (first use / no stored layout). */ +const DEFAULT_PANEL_STATE: string[] = ['adjust', 'transform', 'fileinfo', 'history']; + +/** + * Reads the persisted set of open accordion sections, or returns the default + * (all sections open) when nothing is stored or the stored value is unusable. + */ +export const getStoredPanelState = (): string[] => { + try { + const stored = localStorage.getItem(IMAGE_EDITOR_PANEL_STATE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed) && parsed.every((value) => typeof value === 'string')) { + return parsed; + } + } + } catch { + // Reading the layout is non-critical UI persistence; on any storage error + // (blocked/corrupt) fall through to the default rather than surface it. + } + + return [...DEFAULT_PANEL_STATE]; +}; + +/** Persists the current set of open accordion sections. */ +export const savePanelState = (state: string[]): void => { + try { + localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, JSON.stringify(state)); + } catch { + // Persisting the layout is best-effort; ignore storage errors (quota/blocked). + } +}; diff --git a/core-web/libs/image-editor/src/test-setup.ts b/core-web/libs/image-editor/src/test-setup.ts new file mode 100644 index 000000000000..78f198de57b6 --- /dev/null +++ b/core-web/libs/image-editor/src/test-setup.ts @@ -0,0 +1,21 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); + +// PrimeNG overlay components (e.g. p-splitButton's TieredMenu) call matchMedia on init. +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) +}); diff --git a/core-web/libs/image-editor/tsconfig.json b/core-web/libs/image-editor/tsconfig.json new file mode 100644 index 000000000000..06ef647df4ea --- /dev/null +++ b/core-web/libs/image-editor/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/core-web/libs/image-editor/tsconfig.lib.json b/core-web/libs/image-editor/tsconfig.lib.json new file mode 100644 index 000000000000..7f25962460e3 --- /dev/null +++ b/core-web/libs/image-editor/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "jest.config.ts", "src/test-setup.ts"] +} diff --git a/core-web/libs/image-editor/tsconfig.spec.json b/core-web/libs/image-editor/tsconfig.spec.json new file mode 100644 index 000000000000..e1dfa8f55416 --- /dev/null +++ b/core-web/libs/image-editor/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10", + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/core-web/libs/ui/src/lib/theme/theme.config.ts b/core-web/libs/ui/src/lib/theme/theme.config.ts index ac709d8775b8..e6ac594b4d26 100644 --- a/core-web/libs/ui/src/lib/theme/theme.config.ts +++ b/core-web/libs/ui/src/lib/theme/theme.config.ts @@ -24,6 +24,45 @@ export const CustomLaraPreset = definePreset(Lara, { primary: DotUiColorsService.getDefaultPrimeNGPalette() }, components: { + accordion: { + // Flat accordion app-wide: Lara renders each panel as a rounded, + // bordered, surface-filled card. We drop the card chrome — the header + // fill, the L/R/B borders, the first/last corner radii — so sections + // read as flush bands separated only by their own dividers (and the + // focus ring becomes square along with the header). Per-feature spacing + // and dividers stay in the consuming component. + panel: { + borderWidth: '0' + }, + header: { + borderWidth: '0', + borderRadius: '0', + first: { + topBorderRadius: '0', + borderWidth: '0' + }, + last: { + bottomBorderRadius: '0', + activeBottomBorderRadius: '0' + } + }, + content: { + borderWidth: '0' + }, + colorScheme: { + light: { + header: { + // Opaque surface (not transparent) so a header pinned via + // position: sticky never shows scrolling content through it. + // On a white panel this still reads as a flat, fill-less band. + background: '{surface.0}', + hoverBackground: '{surface.50}', + activeBackground: '{surface.0}', + activeHoverBackground: '{surface.50}' + } + } + } + }, treeselect: { tree: { padding: '0.5rem' diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 9385bd89c861..47e0ad00d20a 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -103,7 +103,8 @@ "@shared/*": ["apps/dotcms-ui/src/app/shared/*"], "@tests/*": ["apps/dotcms-ui/src/app/test/*"], "sdk-create-app": ["libs/sdk/create-app/src/index.ts"], - "@dotcms/agentic-tools": ["libs/agentic-tools/src/index.ts"] + "@dotcms/agentic-tools": ["libs/agentic-tools/src/index.ts"], + "@dotcms/image-editor": ["libs/image-editor/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java index 290970b98a8d..849bbe50a8e7 100644 --- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java +++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java @@ -74,4 +74,12 @@ public interface FeatureFlagName { String FEATURE_FLAG_CONTENT_EDITOR2_ENABLED = "CONTENT_EDITOR2_ENABLED"; String FEATURE_FLAG_LOCALE_SELECTOR_V2 = "FEATURE_FLAG_LOCALE_SELECTOR_V2"; + + /** + * Enables the new Angular image editor (@dotcms/image-editor) in the Edit Content v2 + * binary field. Off by default; when disabled the binary field falls back to the + * legacy Dojo image editor. + * Frontend equivalent: {@code FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR}. + */ + String FEATURE_FLAG_NEW_IMAGE_EDITOR = "FEATURE_FLAG_NEW_IMAGE_EDITOR"; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index 5bcf09180f5c..d5cbcb874239 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -92,7 +92,8 @@ public class ConfigurationResource implements Serializable { FeatureFlagName.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION, FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR, FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED, - FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2); + FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2, + FeatureFlagName.FEATURE_FLAG_NEW_IMAGE_EDITOR); private static final Set WHITE_LIST = ImmutableSet.copyOf( Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST", @@ -108,7 +109,8 @@ public class ConfigurationResource implements Serializable { FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR, REPORT_ISSUE_INCLUDE_USER_PII, FeatureFlagName.FEATURE_FLAG_REPORT_ISSUE_ENABLED, - FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2 })); + FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2, + FeatureFlagName.FEATURE_FLAG_NEW_IMAGE_EDITOR })); private boolean isOnBlackList(final String key) { return null != JVMInfoResource.obfuscatePattern ? JVMInfoResource.obfuscatePattern.matcher(key).find() : false; diff --git a/dotCMS/src/main/resources/dotmarketing-config.properties b/dotCMS/src/main/resources/dotmarketing-config.properties index 690aca98d0b6..abb4ef0fd107 100644 --- a/dotCMS/src/main/resources/dotmarketing-config.properties +++ b/dotCMS/src/main/resources/dotmarketing-config.properties @@ -870,6 +870,13 @@ FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION=false ## New TipTap-v3 Block Editor (rollback safety: legacy editor renders by default) FEATURE_FLAG_NEW_BLOCK_EDITOR=false +## Enhanced locale selector v2 in the edit-content sidebar +FEATURE_FLAG_LOCALE_SELECTOR_V2=true + +## New Angular image editor in the Edit Content v2 binary field (rollback safety: +## the legacy Dojo image editor opens by default until this is explicitly enabled) +FEATURE_FLAG_NEW_IMAGE_EDITOR=false + STARTER_BUILD_VERSION=${starter.deploy.version} ##LTS properties to show EOL message diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4d76af7a1eef..b3b1e5d4aa8b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6469,6 +6469,90 @@ edit.content.dialog.error.title=Error edit.content.dialog.error.loading=Error Loading. edit.content.dialog.loading=Loading +# Edit Content - Image Editor +edit.content.image-editor.title=Edit image +edit.content.image-editor.close.aria=Close image editor +edit.content.image-editor.footer.cancel=Cancel +edit.content.image-editor.footer.download=Download +edit.content.image-editor.footer.download.aria=Download image +edit.content.image-editor.canvas.alt=Image preview +edit.content.image-editor.canvas.loading=Applying preview… +edit.content.image-editor.canvas.error.title=Could not load image +edit.content.image-editor.canvas.error.message=We couldn't render the preview. Please try again. +edit.content.image-editor.canvas.error.retry=Retry +edit.content.image-editor.address.label=Preview URL +edit.content.image-editor.address.copy.aria=Copy URL +edit.content.image-editor.address.copy.success=URL copied to clipboard +edit.content.image-editor.address.copy.error=Could not copy URL +edit.content.image-editor.address.preview.aria=Open preview in new tab +edit.content.image-editor.zoom.in.aria=Zoom in +edit.content.image-editor.zoom.out.aria=Zoom out +edit.content.image-editor.zoom.fit.aria=Fit to screen +edit.content.image-editor.zoom.value=Zoom: {0}% +edit.content.image-editor.fullscreen.enter.aria=Enter full screen +edit.content.image-editor.fullscreen.exit.aria=Exit full screen +edit.content.image-editor.undo.aria=Undo +edit.content.image-editor.redo.aria=Redo +edit.content.image-editor.tool.move=Move +edit.content.image-editor.tool.crop=Crop +edit.content.image-editor.tool.focal=Focal point +edit.content.image-editor.crop.apply=Apply crop +edit.content.image-editor.crop.cancel=Cancel +edit.content.image-editor.crop.box.aria=Crop region +edit.content.image-editor.crop.aspects=Aspect ratio +edit.content.image-editor.crop.width=Crop width in pixels +edit.content.image-editor.crop.height=Crop height in pixels +edit.content.image-editor.crop.orientation=Orientation +edit.content.image-editor.crop.landscape=Landscape +edit.content.image-editor.crop.portrait=Portrait +edit.content.image-editor.focal.set=Set focal point +edit.content.image-editor.focal.cancel=Cancel +edit.content.image-editor.focal.crop=Crop +edit.content.image-editor.focal.aspects=Aspect ratio +edit.content.image-editor.focal.marker.aria=Focal point marker +edit.content.image-editor.panel.adjust.title=Adjust +edit.content.image-editor.panel.adjust.subtitle=Color & light +edit.content.image-editor.adjust.brightness=Brightness +edit.content.image-editor.adjust.hue=Hue +edit.content.image-editor.adjust.saturation=Saturation +edit.content.image-editor.adjust.grayscale=Grayscale +edit.content.image-editor.panel.transform.title=Transform +edit.content.image-editor.panel.transform.subtitle=Size, rotate, flip +edit.content.image-editor.transform.scale=Scale +edit.content.image-editor.transform.rotate=Rotate +edit.content.image-editor.transform.flip=Flip +edit.content.image-editor.transform.flip.horizontal=Horizontal +edit.content.image-editor.transform.flip.vertical=Vertical +edit.content.image-editor.transform.dimensions=Output dimensions +edit.content.image-editor.transform.dimensions.width.aria=Output width in pixels +edit.content.image-editor.transform.dimensions.height.aria=Output height in pixels +edit.content.image-editor.transform.dimensions.error.min=Minimum value is {0} +edit.content.image-editor.transform.dimensions.error.max=Maximum value is {0} +edit.content.image-editor.panel.fileinfo.title=File info +edit.content.image-editor.panel.fileinfo.subtitle=Compression, size +edit.content.image-editor.fileinfo.compression=Compression +edit.content.image-editor.fileinfo.compression.none=None +edit.content.image-editor.fileinfo.compression.auto=Auto +edit.content.image-editor.fileinfo.compression.jpeg=JPEG +edit.content.image-editor.fileinfo.compression.webp=WebP +edit.content.image-editor.fileinfo.compression.avif=AVIF +edit.content.image-editor.fileinfo.quality=Quality +edit.content.image-editor.fileinfo.filesize=File size +edit.content.image-editor.fileinfo.originalsize=Original Size +edit.content.image-editor.panel.history.title=Applied edits +edit.content.image-editor.panel.history.subtitle=Edit history +edit.content.image-editor.history.applied-edits=Applied edits +edit.content.image-editor.history.remove.aria=Remove edit +edit.content.image-editor.history.reset=Reset all +edit.content.image-editor.history.empty=No edits applied yet +edit.content.image-editor.error.save=The image could not be saved. Please try again. +edit.content.image-editor.error.load=The image could not be loaded. +edit.content.image-editor.error.permission=You don't have permission to edit this image. +edit.content.image-editor.discard.header=Discard changes? +edit.content.image-editor.discard.message=Your edits will be lost. Are you sure? +edit.content.image-editor.discard.confirm=Discard +edit.content.image-editor.discard.reject=Keep editing + lts.expired.message = This version of dotCMS is no longer supported. Please contact your customer success manager to schedule an upgrade. lts.expires.soon.message = Your dotCMS version will no longer be supported in {0} days. Please contact your customer success manager to schedule an upgrade.