From 356b5e9da23360359965bc7c62eb13e2814ff08a Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 18 Jun 2026 13:19:43 -0400 Subject: [PATCH 01/29] feat(edit-content): add Angular image editor with events-based signalStore (#36063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New @dotcms/image-editor library — a full-screen "Edit image" modal that renders a live, server-side preview by building dotCMS /contentAsset/image filter URLs (a viewer of the endpoint). State is an @ngrx/signals events-based store (eventGroup/withReducer/ on/injectDispatch + rxMethod effects) with adjust/transform/crop/focalPoint/fileInfo/ zoom slices and a coalesced command history (undo/redo + removable applied edits). - DotImageEditorComponent (OnPush) opened via PrimeNG DialogService - Canvas with two-layer image crossfade + skeleton/spinner/error+retry - Tool rail (move/crop/focal), accordion panels (Adjust/Transform/File info/History), footer (Cancel/Download/Save split button) - IMAGE_EDITOR_LAUNCHER seam (Angular/Legacy/Noop); binary field 'Edit image' now opens the new editor and saves via the _imageToolSaveFile temp-file flow - 79 edit.content.image-editor.* i18n keys; Storybook story for isolated testing Closes #36063 --- core-web/apps/dotcms-ui/.storybook/main.js | 1 + ...dit-content-binary-field.component.spec.ts | 90 ++- ...dot-edit-content-binary-field.component.ts | 34 +- .../angular-image-editor.launcher.spec.ts | 71 ++ .../angular-image-editor.launcher.ts | 54 ++ .../image-editor-launcher.token.ts | 15 + .../shared/image-editor-launcher/index.ts | 8 + .../legacy-dojo-image-editor.launcher.spec.ts | 96 +++ .../legacy-dojo-image-editor.launcher.ts | 65 ++ .../noop-image-editor.launcher.spec.ts | 28 + .../noop-image-editor.launcher.ts | 23 + core-web/libs/image-editor/.eslintrc.json | 18 + core-web/libs/image-editor/jest.config.ts | 22 + core-web/libs/image-editor/project.json | 21 + core-web/libs/image-editor/src/index.ts | 8 + .../lib/animations/image-editor.animations.ts | 113 +++ ...ot-image-editor-address-bar.component.html | 78 +++ ...ot-image-editor-address-bar.component.scss | 33 + ...image-editor-address-bar.component.spec.ts | 132 ++++ .../dot-image-editor-address-bar.component.ts | 74 ++ .../dot-image-editor-canvas.component.html | 69 ++ .../dot-image-editor-canvas.component.scss | 116 ++++ .../dot-image-editor-canvas.component.spec.ts | 198 ++++++ .../dot-image-editor-canvas.component.ts | 183 +++++ ...t-image-editor-crop-overlay.component.html | 43 ++ ...t-image-editor-crop-overlay.component.scss | 138 ++++ ...mage-editor-crop-overlay.component.spec.ts | 111 +++ ...dot-image-editor-crop-overlay.component.ts | 267 ++++++++ ...-image-editor-focal-overlay.component.html | 33 + ...-image-editor-focal-overlay.component.scss | 63 ++ ...age-editor-focal-overlay.component.spec.ts | 88 +++ ...ot-image-editor-focal-overlay.component.ts | 192 ++++++ .../dot-image-editor-footer.component.html | 24 + .../dot-image-editor-footer.component.spec.ts | 120 ++++ .../dot-image-editor-footer.component.ts | 73 ++ .../dot-image-editor-header.component.html | 11 + .../dot-image-editor-header.component.spec.ts | 41 ++ .../dot-image-editor-header.component.ts | 20 + ...t-image-editor-adjust-panel.component.html | 57 ++ ...t-image-editor-adjust-panel.component.scss | 28 + ...mage-editor-adjust-panel.component.spec.ts | 110 +++ ...dot-image-editor-adjust-panel.component.ts | 93 +++ ...image-editor-fileinfo-panel.component.html | 40 ++ ...image-editor-fileinfo-panel.component.scss | 29 + ...ge-editor-fileinfo-panel.component.spec.ts | 105 +++ ...t-image-editor-fileinfo-panel.component.ts | 82 +++ ...-image-editor-history-panel.component.html | 33 + ...-image-editor-history-panel.component.scss | 32 + ...age-editor-history-panel.component.spec.ts | 92 +++ ...ot-image-editor-history-panel.component.ts | 42 ++ .../dot-image-editor-panels.component.html | 80 +++ .../dot-image-editor-panels.component.spec.ts | 60 ++ .../dot-image-editor-panels.component.ts | 32 + ...mage-editor-transform-panel.component.html | 84 +++ ...mage-editor-transform-panel.component.scss | 48 ++ ...e-editor-transform-panel.component.spec.ts | 139 ++++ ...-image-editor-transform-panel.component.ts | 88 +++ .../dot-image-editor-tool-rail.component.html | 17 + .../dot-image-editor-tool-rail.component.scss | 14 + ...t-image-editor-tool-rail.component.spec.ts | 86 +++ .../dot-image-editor-tool-rail.component.ts | 73 ++ .../dot-image-editor.component.html | 18 + .../dot-image-editor.component.scss | 11 + .../dot-image-editor.component.spec.ts | 177 +++++ .../dot-image-editor.component.stories.ts | 183 +++++ .../dot-image-editor.component.ts | 89 +++ .../src/lib/models/image-editor.models.ts | 195 ++++++ .../services/dot-image-editor.service.spec.ts | 175 +++++ .../lib/services/dot-image-editor.service.ts | 169 +++++ .../src/lib/store/image-editor.events.ts | 77 +++ .../src/lib/store/image-editor.state.ts | 149 ++++ .../src/lib/store/image-editor.store.spec.ts | 473 +++++++++++++ .../src/lib/store/image-editor.store.ts | 642 ++++++++++++++++++ .../src/lib/utils/dimensions.util.ts | 94 +++ .../utils/image-filter-url.builder.spec.ts | 297 ++++++++ .../src/lib/utils/image-filter-url.builder.ts | 162 +++++ core-web/libs/image-editor/src/test-setup.ts | 21 + core-web/libs/image-editor/tsconfig.json | 29 + core-web/libs/image-editor/tsconfig.lib.json | 12 + core-web/libs/image-editor/tsconfig.spec.json | 13 + core-web/tsconfig.base.json | 3 +- .../WEB-INF/messages/Language.properties | 81 +++ 82 files changed, 7268 insertions(+), 40 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/index.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts create mode 100644 core-web/libs/image-editor/.eslintrc.json create mode 100644 core-web/libs/image-editor/jest.config.ts create mode 100644 core-web/libs/image-editor/project.json create mode 100644 core-web/libs/image-editor/src/index.ts create mode 100644 core-web/libs/image-editor/src/lib/animations/image-editor.animations.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-crop-overlay/dot-image-editor-crop-overlay.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.scss create mode 100644 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 create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-adjust-panel/dot-image-editor-adjust-panel.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.scss create mode 100644 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 create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-fileinfo-panel/dot-image-editor-fileinfo-panel.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.scss create mode 100644 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 create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-history-panel/dot-image-editor-history-panel.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.scss create mode 100644 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 create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-transform-panel/dot-image-editor-transform-panel.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.stories.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts create mode 100644 core-web/libs/image-editor/src/lib/models/image-editor.models.ts create mode 100644 core-web/libs/image-editor/src/lib/services/dot-image-editor.service.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/services/dot-image-editor.service.ts create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.events.ts create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.state.ts create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.store.ts create mode 100644 core-web/libs/image-editor/src/lib/utils/dimensions.util.ts create mode 100644 core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts create mode 100644 core-web/libs/image-editor/src/test-setup.ts create mode 100644 core-web/libs/image-editor/tsconfig.json create mode 100644 core-web/libs/image-editor/tsconfig.lib.json create mode 100644 core-web/libs/image-editor/tsconfig.spec.json diff --git a/core-web/apps/dotcms-ui/.storybook/main.js b/core-web/apps/dotcms-ui/.storybook/main.js index a17f082701ae..a4c63fe3a1c9 100644 --- a/core-web/apps/dotcms-ui/.storybook/main.js +++ b/core-web/apps/dotcms-ui/.storybook/main.js @@ -14,6 +14,7 @@ module.exports = { '../../../libs/template-builder/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/block-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/edit-content/**/*.stories.@(js|jsx|ts|tsx|mdx)', + '../../../libs/image-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/ui/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/portlets/**/*.stories.@(js|jsx|ts|tsx|mdx)' ], 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..4046c126f9a1 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,38 +304,59 @@ 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(); - tick(1000); + spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); - expect(spy).toHaveBeenCalled(); - expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK); + 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(); + }); }); }); 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..a8c2bfaf153c 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 { AngularImageEditorLauncher, IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { ...DEFAULT_MONACO_CONFIG, @@ -97,6 +99,7 @@ type SystemOptionsType = { DotBinaryFieldStore, DotLicenseService, DotBinaryFieldValidatorService, + { provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher }, { multi: true, provide: NG_VALUE_ACCESSOR, @@ -118,6 +121,7 @@ export class DotEditContentBinaryFieldComponent readonly #dotAiService = inject(DotAiService); readonly #dialogService = inject(DialogService); readonly #destroyRef = inject(DestroyRef); + readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER); $isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), { initialValue: false @@ -389,14 +393,32 @@ 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 inode = this.contentlet?.inode; + const metadata = this.contentlet + ? (getFileMetadata(this.contentlet) as Partial) + : null; + + this.#imageEditorLauncher + .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((tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile)); } /** 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..2251477c42ae --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts @@ -0,0 +1,71 @@ +import { expect } from '@jest/globals'; +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSTempFile } 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; + + const params: ImageEditorOpenParams = { + inode: 'inode-1', + variable: 'binaryField', + fieldName: 'binary' + }; + + const createService = createServiceFactory({ + service: AngularImageEditorLauncher, + providers: [mockProvider(DotMessageService)], + mocks: [DialogService] + }); + + beforeEach(() => { + onClose = new Subject(); + spectator = createService(); + spectator.inject(DialogService).open.mockReturnValue({ onClose }); + }); + + it('should report itself as available', () => { + expect(spectator.service.isAvailable()).toBe(true); + }); + + it('should open the DotImageEditorComponent with a closable, escapable dialog', () => { + spectator.service.open(params).subscribe(); + + expect(spectator.inject(DialogService).open).toHaveBeenCalledWith( + DotImageEditorComponent, + expect.objectContaining({ + data: params, + modal: true, + 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..4db6e1ffe267 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts @@ -0,0 +1,54 @@ +import { Observable, map, take } from 'rxjs'; + +import { Injectable, inject } from '@angular/core'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; +import { + DotImageEditorComponent, + DotImageEditorLauncher, + ImageEditorOpenParams +} from '@dotcms/image-editor'; + +/** + * Launches the Angular `@dotcms/image-editor` modal through PrimeNG's `DialogService`. + */ +@Injectable() +export class AngularImageEditorLauncher implements DotImageEditorLauncher { + readonly #dialogService = inject(DialogService); + readonly #dotMessageService = inject(DotMessageService); + + isAvailable(): boolean { + return true; + } + + /** + * 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, { + header: undefined, + data: params, + width: 'min(92vw, 75rem)', + height: '90%', + 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..7fb029f6ba30 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts @@ -0,0 +1,15 @@ +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. + * + * Providers swap implementations (Angular dialog, legacy Dojo bridge, or noop) + * without the consuming field knowing which editor surfaces the result. + */ +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..866a05f98f24 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/index.ts @@ -0,0 +1,8 @@ +export { + IMAGE_EDITOR_LAUNCHER, + DotImageEditorLauncher, + ImageEditorOpenParams +} from './image-editor-launcher.token'; +export { AngularImageEditorLauncher } from './angular-image-editor.launcher'; +export { LegacyDojoImageEditorLauncher } from './legacy-dojo-image-editor.launcher'; +export { NoopImageEditorLauncher } from './noop-image-editor.launcher'; diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts new file mode 100644 index 000000000000..8d731434c8d8 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts @@ -0,0 +1,96 @@ +import { expect } from '@jest/globals'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { Subscription } from 'rxjs'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; +import { ImageEditorOpenParams } from '@dotcms/image-editor'; + +import { LegacyDojoImageEditorLauncher } from './legacy-dojo-image-editor.launcher'; + +describe('LegacyDojoImageEditorLauncher', () => { + let spectator: SpectatorService; + let subscription: Subscription | undefined; + + const params: ImageEditorOpenParams = { + inode: 'inode-1', + tempId: 'temp-1', + variable: 'binaryField', + fieldName: 'binary' + }; + + const openEventName = `binaryField-open-image-editor-${params.variable}`; + const tempEventName = `binaryField-tempfile-${params.variable}`; + const closeEventName = `binaryField-close-image-editor-${params.variable}`; + + const createService = createServiceFactory(LegacyDojoImageEditorLauncher); + + beforeEach(() => { + spectator = createService(); + }); + + afterEach(() => { + subscription?.unsubscribe(); + }); + + it('should report itself as available', () => { + expect(spectator.service.isAvailable()).toBe(true); + }); + + it('should dispatch the open event with the asset detail', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + subscription = spectator.service.open(params).subscribe(); + + expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: openEventName })); + const openEvent = dispatchSpy.mock.calls + .map(([event]) => event) + .find( + (event): event is CustomEvent => + event instanceof CustomEvent && event.type === openEventName + ); + expect(openEvent?.detail).toEqual({ + inode: 'inode-1', + tempId: 'temp-1', + variable: 'binaryField' + }); + }); + + it('should resolve the temp file when the tempfile event fires', () => { + const tempFile = { id: 'temp-123' } as DotCMSTempFile; + let result: DotCMSTempFile | null | undefined; + + subscription = spectator.service.open(params).subscribe((value) => (result = value)); + + document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); + + expect(result).toEqual(tempFile); + }); + + it('should resolve null when the close event fires', () => { + let emitted = false; + let result: DotCMSTempFile | null | undefined; + + subscription = spectator.service.open(params).subscribe((value) => { + emitted = true; + result = value; + }); + + document.dispatchEvent(new CustomEvent(closeEventName)); + + expect(emitted).toBe(true); + expect(result).toBeNull(); + }); + + it('should stop listening after the first emission', () => { + const tempFile = { id: 'temp-123' } as DotCMSTempFile; + const emissions: (DotCMSTempFile | null)[] = []; + + subscription = spectator.service.open(params).subscribe((value) => emissions.push(value)); + + document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); + document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); + document.dispatchEvent(new CustomEvent(closeEventName)); + + expect(emissions).toEqual([tempFile]); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts new file mode 100644 index 000000000000..4ae01415bdde --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts @@ -0,0 +1,65 @@ +import { Observable } from 'rxjs'; + +import { Injectable } from '@angular/core'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; +import { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/image-editor'; + +/** Detail of the `binaryField-tempfile-{variable}` custom event. */ +interface TempFileEventDetail { + tempFile: DotCMSTempFile; +} + +/** + * Bridges the editor launcher contract to the existing legacy Dojo image editor. + * + * Reuses the established document `CustomEvent` channel: it dispatches + * `binaryField-open-image-editor-{variable}` to open the editor and resolves the + * result from `binaryField-tempfile-{variable}` (edited image) or + * `binaryField-close-image-editor-{variable}` (cancelled). + */ +@Injectable() +export class LegacyDojoImageEditorLauncher implements DotImageEditorLauncher { + isAvailable(): boolean { + return true; + } + + /** + * Opens the legacy image editor for the given asset. + * + * @param params - Identifiers and metadata of the asset to edit + * @returns Emits the edited temp file, or `null` if the user cancelled + */ + open(params: ImageEditorOpenParams): Observable { + const { inode, tempId, variable } = params; + const tempFileEventName = `binaryField-tempfile-${variable}`; + const closeEventName = `binaryField-close-image-editor-${variable}`; + + return new Observable((subscriber) => { + const handleTempFile = (event: Event): void => { + const { detail } = event as CustomEvent; + subscriber.next(detail?.tempFile ?? null); + subscriber.complete(); + }; + + const handleClose = (): void => { + subscriber.next(null); + subscriber.complete(); + }; + + document.addEventListener(tempFileEventName, handleTempFile); + document.addEventListener(closeEventName, handleClose); + + document.dispatchEvent( + new CustomEvent(`binaryField-open-image-editor-${variable}`, { + detail: { inode, tempId, variable } + }) + ); + + return () => { + document.removeEventListener(tempFileEventName, handleTempFile); + document.removeEventListener(closeEventName, handleClose); + }; + }); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts new file mode 100644 index 000000000000..13faec6c1544 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts @@ -0,0 +1,28 @@ +import { expect } from '@jest/globals'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { NoopImageEditorLauncher } from './noop-image-editor.launcher'; + +describe('NoopImageEditorLauncher', () => { + let spectator: SpectatorService; + + const createService = createServiceFactory(NoopImageEditorLauncher); + + beforeEach(() => { + spectator = createService(); + }); + + it('should report itself as unavailable', () => { + expect(spectator.service.isAvailable()).toBe(false); + }); + + it('should emit null when opened', () => { + let result: DotCMSTempFile | null | undefined; + + spectator.service.open().subscribe((value) => (result = value)); + + expect(result).toBeNull(); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts new file mode 100644 index 000000000000..0e5d0d21dfce --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts @@ -0,0 +1,23 @@ +import { Observable, of } from 'rxjs'; + +import { Injectable } from '@angular/core'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; +import { DotImageEditorLauncher } from '@dotcms/image-editor'; + +/** + * Inert launcher used when no image editor is available in the environment. + * + * Reports itself unavailable and never opens an editor, so the binary field can + * hide the "edit image" affordance instead of branching on a missing provider. + */ +@Injectable() +export class NoopImageEditorLauncher implements DotImageEditorLauncher { + isAvailable(): boolean { + return false; + } + + open(): Observable { + return of(null); + } +} 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..12b9a44f56dd --- /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 { 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, RANGES } from './lib/store/image-editor.state'; +export type { ImageEditorState } 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..df6aa29ae078 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/animations/image-editor.animations.ts @@ -0,0 +1,113 @@ +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 }))]) + ]); +}; + +/** + * Pop-in with a slight overshoot for the focal point marker. + * @param duration - Animation duration (default: 250ms; pass '0ms' for reduced motion) + * @param easing - CSS easing function (default: 'ease-out') + */ +export const focalPointPop = ( + duration = '250ms', + easing = 'ease-out' +): AnimationTriggerMetadata => { + return trigger('focalPointPop', [ + transition(':enter', [ + animate( + `${duration} ${easing}`, + keyframes([ + style({ opacity: 0, transform: 'scale(0)', offset: 0 }), + style({ opacity: 1, transform: 'scale(1.2)', offset: 0.7 }), + style({ opacity: 1, transform: 'scale(1)', offset: 1 }) + ]) + ) + ]), + transition(':leave', [ + animate(`${duration} ${easing}`, style({ opacity: 0, transform: 'scale(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..6a0000555341 --- /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,78 @@ +
+ + + +
+ +
+ + + {{ zoomLevel() }}% + + + + + +
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..8c14c4b2163b --- /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,33 @@ +:host { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0.75rem; + background-color: rgba(28, 30, 38, 0.92); + color: var(--surface-0, #ffffff); +} + +.address-bar__group { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.address-bar__link-icon { + flex: 0 0 auto; + opacity: 0.7; +} + +.address-bar__field { + flex: 1 1 auto; + min-width: 0; + max-width: 28rem; +} + +.address-bar__zoom-value { + min-width: 3.5rem; + text-align: center; + font-variant-numeric: tabular-nums; +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts new file mode 100644 index 000000000000..68d85de0cc6b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts @@ -0,0 +1,132 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; + +import { MessageService } from 'primeng/api'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotImageEditorAddressBarComponent } from './dot-image-editor-address-bar.component'; + +import { imageEditorHistoryEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +const PREVIEW_URL = '/contentAsset/image/inode-1/fileAsset?byInode=true'; + +const messageServiceMock = new MockDotMessageService({ + 'edit.content.image-editor.address.copy.success': 'Copied', + 'edit.content.image-editor.address.copy.error': 'Could not copy' +}); + +describe('DotImageEditorAddressBarComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + let writeText: jest.Mock; + + const previewUrl = signal(PREVIEW_URL); + const zoom = signal({ level: 100, fitToScreen: true }); + const canUndo = signal(true); + const canRedo = signal(true); + + const createComponent = createComponentFactory({ + component: DotImageEditorAddressBarComponent, + providers: [{ provide: DotMessageService, useValue: messageServiceMock }], + componentProviders: [ + Dispatcher, + mockProvider(MessageService), + mockProvider(ImageEditorStore, { + previewUrl, + zoom, + canUndo, + canRedo + }) + ] + }); + + beforeEach(() => { + previewUrl.set(PREVIEW_URL); + zoom.set({ level: 100, fitToScreen: true }); + canUndo.set(true); + canRedo.set(true); + + writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true + }); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the preview URL in the address field', () => { + const field = spectator.query(byTestId('image-editor-address-field')); + + expect(field?.value).toBe(PREVIEW_URL); + }); + + it('should render the zoom level from the zoomLevel input', () => { + spectator.setInput('zoomLevel', 125); + + expect(spectator.query(byTestId('image-editor-zoom-value'))).toHaveText('125%'); + }); + + it('should copy the preview URL to the clipboard when the copy button is clicked', () => { + spectator.click(byTestId('image-editor-copy-url-btn')); + + expect(writeText).toHaveBeenCalledWith(PREVIEW_URL); + }); + + it('should dispatch undoRequested when undo is clicked', () => { + spectator.click(byTestId('image-editor-undo-btn')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith(imageEditorHistoryEvents.undoRequested(), { + scope: 'self' + }); + }); + + it('should dispatch redoRequested when redo is clicked', () => { + spectator.click(byTestId('image-editor-redo-btn')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith(imageEditorHistoryEvents.redoRequested(), { + scope: 'self' + }); + }); + + it('should disable undo when there is nothing to undo', () => { + canUndo.set(false); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-undo-btn'))).toHaveAttribute('disabled'); + }); + + it('should emit zoomIn, zoomOut and fit from the zoom controls', () => { + const zoomInSpy = jest.fn(); + const zoomOutSpy = jest.fn(); + const fitSpy = jest.fn(); + spectator.output('zoomIn').subscribe(zoomInSpy); + spectator.output('zoomOut').subscribe(zoomOutSpy); + spectator.output('fit').subscribe(fitSpy); + + spectator.click(byTestId('image-editor-zoom-in-btn')); + spectator.click(byTestId('image-editor-zoom-out-btn')); + spectator.click(byTestId('image-editor-fit-btn')); + + expect(zoomInSpy).toHaveBeenCalledTimes(1); + expect(zoomOutSpy).toHaveBeenCalledTimes(1); + expect(fitSpy).toHaveBeenCalledTimes(1); + }); + + it('should expose the expected testids', () => { + expect(spectator.query(byTestId('image-editor-address-field'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-copy-url-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-zoom-out-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-zoom-in-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-fit-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-undo-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-redo-btn'))).toBeTruthy(); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts new file mode 100644 index 000000000000..01f97dfa3987 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts @@ -0,0 +1,74 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; + +import { MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorHistoryEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +/** + * Address sub-bar shown on the dark canvas. Surfaces the cache-busted preview + * URL with a copy action on the left, and zoom plus undo/redo controls on the + * right. Zoom is emitted as outputs (`zoomIn`/`zoomOut`/`fit`) for the canvas to + * apply a CSS transform, since the store has no zoom events yet; undo and redo + * dispatch {@link imageEditorHistoryEvents} so the store owns history. + */ +@Component({ + selector: 'dot-image-editor-address-bar', + templateUrl: './dot-image-editor-address-bar.component.html', + styleUrl: './dot-image-editor-address-bar.component.scss', + imports: [ButtonModule, InputTextModule, TooltipModule, DotMessagePipe], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotImageEditorAddressBarComponent { + protected readonly store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorHistoryEvents); + readonly #messageService = inject(MessageService); + readonly #dotMessageService = inject(DotMessageService); + + /** Current canvas zoom percentage to display; owned and updated by the canvas. */ + zoomLevel = input(100); + + /** Emitted when the user requests to zoom the canvas in. */ + zoomIn = output(); + /** Emitted when the user requests to zoom the canvas out. */ + zoomOut = output(); + /** Emitted when the user requests to fit the image to the viewport. */ + fit = output(); + + /** Copies the current preview URL to the clipboard, surfacing a toast. */ + protected async copyUrl(): Promise { + try { + await navigator.clipboard.writeText(this.store.previewUrl()); + this.#messageService.add({ + severity: 'success', + detail: this.#dotMessageService.get( + 'edit.content.image-editor.address.copy.success' + ) + }); + } catch { + // Clipboard access can be denied; copy failure is non-fatal. + this.#messageService.add({ + severity: 'error', + detail: this.#dotMessageService.get('edit.content.image-editor.address.copy.error') + }); + } + } + + /** Steps back one entry in the edit history. */ + protected undo(): void { + this.#dispatch.undoRequested(); + } + + /** Steps forward one entry in the edit history. */ + protected redo(): void { + this.#dispatch.redoRequested(); + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html new file mode 100644 index 000000000000..2e792bc0ad5c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html @@ -0,0 +1,69 @@ +
+ + +
+ + +
+ @if (store.previewStatus() === 'idle' && !displayedUrl()) { + + } + + + + + + @if (pendingUrl(); as pending) { + + } + + + +
+ + @if (store.previewStatus() === 'loading') { +
+ +
+ } + + @if (store.previewStatus() === 'error') { + + } +
+
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..44c9cde2efda --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.scss @@ -0,0 +1,116 @@ +: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: #11131a; + color: var(--surface-0, #ffffff); +} + +.canvas__address-bar { + flex: 0 0 auto; + display: block; +} + +.canvas__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.canvas__tool-rail { + position: absolute; + top: 1rem; + left: 1rem; + z-index: 2; +} + +.canvas__stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + max-height: 100%; + transform-origin: center center; + transition: transform 150ms ease-out; +} + +.canvas__img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; +} + +// Stack the two layers so the pending image crossfades over the displayed one. +.canvas__img--pending { + position: absolute; + inset: 0; + margin: auto; + opacity: 1; + transition: opacity 200ms ease-in-out; +} + +.canvas__skeleton { + display: block; +} + +.canvas__loading { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + border-radius: 0.75rem; + background-color: rgba(28, 30, 38, 0.92); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.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(17, 19, 26, 0.85); +} + +.canvas__error-icon { + font-size: 2.5rem; + color: var(--yellow-500, #f5a623); +} + +.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..8bc71d872149 --- /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,198 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } 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 { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorCanvasComponent } from './dot-image-editor-canvas.component'; + +import { PreviewStatus } from '../../models/image-editor.models'; +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'; +import { DotImageEditorFocalOverlayComponent } from '../dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component'; +import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/dot-image-editor-tool-rail.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'; + +// 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 +}); + +describe('DotImageEditorCanvasComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const previewUrl = signal(PREVIEW_URL); + const previewStatus = signal('idle'); + const zoom = signal({ level: 100, fitToScreen: true }); + const activeTool = signal<'move' | 'crop' | 'focal'>('move'); + const assetContext = signal({ naturalWidth: 800, naturalHeight: 600 }); + + const createComponent = createComponentFactory({ + component: DotImageEditorCanvasComponent, + providers: [ + provideNoopAnimations(), + Dispatcher, + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [ + mockProvider(ImageEditorStore, { + previewUrl, + previewStatus, + zoom, + activeTool, + assetContext + }) + ], + // Isolate the canvas from the children's own store/dispatch wiring. + overrideComponents: [ + [ + DotImageEditorCanvasComponent, + { + remove: { + imports: [ + DotImageEditorAddressBarComponent, + DotImageEditorToolRailComponent, + DotImageEditorCropOverlayComponent, + DotImageEditorFocalOverlayComponent + ] + }, + add: { + imports: [ + MockComponent(DotImageEditorAddressBarComponent), + MockComponent(DotImageEditorToolRailComponent), + MockComponent(DotImageEditorCropOverlayComponent), + MockComponent(DotImageEditorFocalOverlayComponent) + ] + } + } + ] + ] + }); + + beforeEach(() => { + previewUrl.set(PREVIEW_URL); + previewStatus.set('idle'); + zoom.set({ level: 100, fitToScreen: true }); + activeTool.set('move'); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + 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-tool-rail')).toExist(); + expect(spectator.query('dot-image-editor-crop-overlay')).toExist(); + expect(spectator.query('dot-image-editor-focal-overlay')).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 keep the displayed image visible while loading (crossfade invariant)', () => { + // Seed a displayed frame, then begin loading the next preview. + previewUrl.set(PREVIEW_URL); + spectator.detectChanges(); + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + previewStatus.set('loading'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-loading'))).toExist(); + expect(spectator.query(byTestId('image-editor-display-img'))).toExist(); + }); + + it('should promote the pending image and dispatch previewLoaded on load', () => { + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + + expect(dispatchedEvent('previewLoaded')).toBeDefined(); + // Once promoted, the displayed URL matches the preview so no pending layer remains. + spectator.detectChanges(); + expect(spectator.query(byTestId('image-editor-pending-img'))).not.toExist(); + }); + + it('should promote the URL that loaded, not the live store URL, under rapid edits', () => { + // The store has already advanced to a newer preview by the time the + // earlier pending image fires its load event. + previewUrl.set(NEXT_PREVIEW_URL); + spectator.component['onPendingLoaded'](PREVIEW_URL); + spectator.detectChanges(); + + // Layer A promotes the URL that actually loaded. + const displayed = spectator.query(byTestId('image-editor-display-img')); + expect(displayed?.getAttribute('src')).toBe(PREVIEW_URL); + + // Layer A stays visible (crossfade invariant holds). + expect(displayed?.hasAttribute('hidden')).toBe(false); + + // Layer B remains mounted for the newer URL the store advanced to. + const pending = spectator.query(byTestId('image-editor-pending-img')); + expect(pending?.getAttribute('src')).toBe(NEXT_PREVIEW_URL); + + expect(dispatchedEvent('previewLoaded')).toBeDefined(); + }); + + it('should dispatch previewErrored when the pending image fails to load', () => { + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'error'); + + expect(dispatchedEvent('previewErrored')).toBeDefined(); + }); + + it('should render the pending image only when the preview URL differs from the displayed one', () => { + // No displayed frame yet, so the current preview is pending. + expect(spectator.query(byTestId('image-editor-pending-img'))).toExist(); + + // Promote the current preview, then advance the store to a new URL. + spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); + previewUrl.set(NEXT_PREVIEW_URL); + spectator.detectChanges(); + + const pending = spectator.query(byTestId('image-editor-pending-img')); + expect(pending?.getAttribute('src')).toBe(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(); + }); + + /** 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..39f699090b99 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts @@ -0,0 +1,183 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + signal, + viewChild +} from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { SkeletonModule } from 'primeng/skeleton'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorLifecycleEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { DotImageEditorAddressBarComponent } from '../dot-image-editor-address-bar/dot-image-editor-address-bar.component'; +import { + DotImageEditorCropOverlayComponent, + ImageRect +} 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'; +import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/dot-image-editor-tool-rail.component'; + +/** Smallest zoom percentage the canvas allows. */ +const ZOOM_MIN = 10; +/** Largest zoom percentage the canvas allows. */ +const ZOOM_MAX = 400; +/** Step applied per zoom-in / zoom-out request. */ +const ZOOM_STEP = 25; +/** Default (fit) zoom percentage. */ +const ZOOM_DEFAULT = 100; + +/** + * Dark stage that renders the live image preview at the center of the editor. + * Hosts the address sub-bar, the floating tool rail and the crop/focal overlays, + * and 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 overlays), 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: [ + ButtonModule, + ProgressSpinnerModule, + SkeletonModule, + DotMessagePipe, + DotImageEditorAddressBarComponent, + DotImageEditorToolRailComponent, + DotImageEditorCropOverlayComponent, + DotImageEditorFocalOverlayComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotImageEditorCanvasComponent { + protected readonly store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorLifecycleEvents); + readonly #destroyRef = inject(DestroyRef); + + /** 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'); + + /** URL of the last successfully loaded preview, shown on the bottom layer. */ + protected readonly displayedUrl = signal(''); + + /** + * URL queued for loading on the top layer: the store's current preview when + * it differs from what is already displayed, otherwise empty. + */ + protected readonly pendingUrl = computed(() => { + const next = this.store.previewUrl(); + + return next && next !== this.displayedUrl() ? next : ''; + }); + + /** Rendered bounds of the displayed image within the stage, in CSS px. */ + protected readonly imageRect = signal(undefined); + + /** Display-only zoom percentage applied as a CSS transform to the stage. */ + protected readonly zoomLevel = signal(ZOOM_DEFAULT); + + /** CSS scale derived from the current zoom percentage. */ + protected readonly stageScale = computed(() => `scale(${this.zoomLevel() / 100})`); + + /** Observes the displayed image so overlay rects track resize and layout. */ + #resizeObserver: ResizeObserver | null = null; + + constructor() { + this.#destroyRef.onDestroy(() => this.#resizeObserver?.disconnect()); + } + + /** + * Promotes a freshly loaded pending image to the displayed layer and reports + * the successful preview to the store. The crossfade is keyed on URL identity + * so the visible frame is never blanked while the next one loads. + * + * Promotes the URL that actually finished loading — not the live store value, + * which may have advanced under rapid edits. When a newer preview already + * exists, the `pendingUrl` computed keeps Layer B mounted for that newer URL. + * @param loadedUrl - The URL of the pending image that finished loading + */ + protected onPendingLoaded(loadedUrl: string): void { + this.displayedUrl.set(loadedUrl); + this.#measureImageRect(); + this.#dispatch.previewLoaded(); + } + + /** Reports a failed pending load, keeping the last good frame visible. */ + protected onPendingError(): void { + this.#dispatch.previewErrored(); + } + + /** 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(); + } + + /** Increases the zoom by one step, clamped to the maximum. */ + protected zoomIn(): void { + this.zoomLevel.update((level) => Math.min(ZOOM_MAX, level + ZOOM_STEP)); + } + + /** Decreases the zoom by one step, clamped to the minimum. */ + protected zoomOut(): void { + this.zoomLevel.update((level) => Math.max(ZOOM_MIN, level - ZOOM_STEP)); + } + + /** Resets the zoom so the image fits the stage. */ + protected fit(): void { + this.zoomLevel.set(ZOOM_DEFAULT); + } + + /** 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 and + * focal overlays can position themselves over the rendered pixels. + */ + #measureImageRect(): void { + const stage = this.stage()?.nativeElement; + const img = this.displayImg()?.nativeElement; + + if (!stage || !img) { + return; + } + + const stageBox = stage.getBoundingClientRect(); + const imgBox = img.getBoundingClientRect(); + + this.imageRect.set({ + x: imgBox.left - stageBox.left, + y: imgBox.top - stageBox.top, + width: imgBox.width, + height: imgBox.height + }); + } +} 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..c8fff93a56ea --- /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,43 @@ +@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..5da90330d8c6 --- /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,138 @@ +: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); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); + cursor: move; + pointer-events: auto; + outline: none; + + &:focus-visible { + border-color: var(--primary-color, #426bf0); + } +} + +.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; + } +} + +.crop-pill { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.5rem; + border-radius: 9999px; + background-color: var(--surface-900, #1f2937); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + pointer-events: auto; +} 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..cf69f42a8e71 --- /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,111 @@ +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 applying', () => { + const applyBtn = spectator.query(byTestId('image-editor-crop-apply-btn')); + spectator.click(applyBtn!.querySelector('button')!); + + // 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 cancelling', () => { + const cancelBtn = spectator.query(byTestId('image-editor-crop-cancel-btn')); + spectator.click(cancelBtn!.querySelector('button')!); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: expect.stringContaining('cropCancelled') }), + { scope: 'self' } + ); + }); + + 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 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..b928df6a7a88 --- /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,267 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + HostListener, + inject, + input, + signal +} from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorOverlayEnterLeave } from '../../animations/image-editor.animations'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { clamp } from '../../utils/dimensions.util'; + +/** Axis-aligned rectangle of the rendered image inside the canvas, in CSS px. */ +export interface ImageRect { + x: number; + y: number; + width: number; + height: number; +} + +/** A crop rectangle expressed in CSS px, local to the rendered image origin. */ +interface LocalRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Identifiers for the eight resize handles around the crop box. */ +type HandlePosition = 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l'; + +/** Distance in CSS px nudged per arrow keypress; Shift multiplies this. */ +const NUDGE_STEP = 1; +const NUDGE_STEP_LARGE = 10; + +/** Smallest allowed crop dimension in CSS px to keep the box usable. */ +const MIN_CROP_SIZE = 16; + +/** The eight resize handles rendered around the crop box. */ +const HANDLES: readonly HandlePosition[] = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l'] as const; + +/** + * 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: [ButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-crop-overlay.component.html', + styleUrl: './dot-image-editor-crop-overlay.component.scss', + animations: [imageEditorOverlayEnterLeave()] +}) +export class DotImageEditorCropOverlayComponent { + /** Bounds of the rendered image within the canvas, in CSS px. */ + imageRect = input(); + + readonly #store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorToolEvents); + + /** The resize handles rendered around the crop box. */ + protected readonly handles = 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 }); + + /** 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` + }; + }); + + constructor() { + // Seed the selection to the full rendered image whenever the tool + // activates or the rendered bounds change while cropping. + effect(() => { + const rect = this.imageRect(); + + if (this.isActive() && rect) { + this.cropRect.set({ x: 0, y: 0, width: rect.width, height: rect.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) => { + this.#resize(start, position, dx, dy); + }); + } + + /** Nudges or applies/cancels the crop in response to keyboard input. */ + protected onBoxKeydown(event: KeyboardEvent): void { + const step = event.shiftKey ? NUDGE_STEP_LARGE : 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.apply(); + break; + default: + break; + } + } + + /** Applies the crop by converting the selection to natural image pixels. */ + protected apply(): 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. */ + protected cancel(): void { + this.#dispatch.cropCancelled(); + } + + /** + * Intercepts Escape so the host dialog does not close while cropping; the + * keypress instead cancels the crop selection. + */ + @HostListener('keydown.escape', ['$event']) + protected onEscape(event: KeyboardEvent): void { + if (!this.isActive()) { + return; + } + + event.stopPropagation(); + this.cancel(); + } + + /** 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. */ + #resize(start: LocalRect, position: HandlePosition, dx: number, dy: number): void { + const rect = this.imageRect(); + + if (!rect) { + 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 }); + } + + /** Tracks pointer movement until release, reporting the delta to `onMove`. */ + #trackPointer(start: PointerEvent, onMove: (dx: number, dy: number) => void): void { + const originX = start.clientX; + const originY = start.clientY; + + const move = (event: PointerEvent) => + onMove(event.clientX - originX, event.clientY - originY); + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + + 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..1792e10f4176 --- /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,33 @@ +@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..c60fc20d940a --- /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,63 @@ +: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); + } +} + +.focal-pill { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.5rem; + border-radius: 9999px; + background-color: var(--surface-900, #1f2937); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + pointer-events: auto; +} 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..3f915ad7a789 --- /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,88 @@ +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 setting', () => { + const setBtn = spectator.query(byTestId('image-editor-focal-set-btn')); + spectator.click(setBtn!.querySelector('button')!); + + // `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 dispatch focalPointCleared when cancelling', () => { + const cancelBtn = spectator.query(byTestId('image-editor-focal-cancel-btn')); + spectator.click(cancelBtn!.querySelector('button')!); + + expect(dispatchedEvent('focalPointCleared')).toBeDefined(); + }); + + it('should stop propagation and dispatch focalPointCleared on Escape', () => { + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + const stopSpy = jest.spyOn(event, 'stopPropagation'); + + spectator.element.dispatchEvent(event); + + expect(stopSpy).toHaveBeenCalled(); + expect(dispatchedEvent('focalPointCleared')).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..e9e836c33d56 --- /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,192 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + HostListener, + inject, + input, + signal +} from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { focalPointPop } from '../../animations/image-editor.animations'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; +import { clamp } from '../../utils/dimensions.util'; + +/** Axis-aligned rectangle of the rendered image inside the canvas, in CSS px. */ +export interface ImageRect { + x: number; + y: number; + width: number; + height: number; +} + +/** A normalized point in the unit square, where {x:0.5, y:0.5} is the center. */ +interface NormalizedPoint { + x: number; + y: number; +} + +/** Fraction of the image moved per arrow keypress; Shift uses the larger step. */ +const NUDGE_STEP = 0.01; +const NUDGE_STEP_LARGE = 0.05; + +/** + * 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: [ButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-focal-overlay.component.html', + styleUrl: './dot-image-editor-focal-overlay.component.scss', + animations: [focalPointPop()] +}) +export class DotImageEditorFocalOverlayComponent { + /** Bounds of the rendered image within the canvas, in CSS px. */ + imageRect = input(); + + 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 on the image surface. */ + protected onSurfacePointerDown(event: PointerEvent): void { + event.preventDefault(); + this.#setFromClient(event.clientX, event.clientY); + this.#trackPointer((clientX, clientY) => this.#setFromClient(clientX, clientY)); + } + + /** Moves or confirms/cancels the focal point in response to keyboard input. */ + protected onMarkerKeydown(event: KeyboardEvent): void { + const step = event.shiftKey ? NUDGE_STEP_LARGE : NUDGE_STEP; + const current = this.point(); + + 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.set(); + break; + default: + break; + } + } + + /** Confirms the focal point, dispatching the normalized 0..1 coordinates. */ + protected set(): void { + const { x, y } = this.point(); + this.#dispatch.focalPointSet({ x: clamp(x, 0, 1), y: clamp(y, 0, 1) }); + } + + /** Cancels focal placement and restores the move tool. */ + protected cancel(): void { + this.#dispatch.focalPointCleared(); + } + + /** + * Intercepts Escape so the host dialog does not close while placing the + * focal point; the keypress instead clears the focal selection. + */ + @HostListener('keydown.escape', ['$event']) + protected onEscape(event: KeyboardEvent): void { + if (!this.isActive()) { + return; + } + + event.stopPropagation(); + this.cancel(); + } + + /** 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; + } + + const host = this.#hostRect(); + const localX = clientX - host.left - rect.x; + const localY = clientY - host.top - 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 the client position. */ + #trackPointer(onMove: (clientX: number, clientY: number) => void): void { + const move = (event: PointerEvent) => onMove(event.clientX, event.clientY); + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + + 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..baf984abbe2d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.html @@ -0,0 +1,24 @@ +
+ + + + + +
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..ad4f9690c410 --- /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,120 @@ +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 canSave = signal(true); + const saveStatus = signal<'idle' | 'saving' | 'saved' | 'error'>('idle'); + + const createComponent = createComponentFactory({ + component: DotImageEditorFooterComponent, + imports: [DotMessagePipe], + providers: [mockProvider(DotMessageService, { get: jest.fn((key: string) => key) })], + componentProviders: [ + Dispatcher, + mockProvider(ImageEditorStore, { + isBusy, + canSave, + saveStatus, + assetContext: () => ({ fileName: 'x.jpg' }) + }) + ] + }); + + beforeEach(() => { + isBusy.set(false); + canSave.set(true); + saveStatus.set('idle'); + + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the action buttons', () => { + expect(spectator.query(byTestId('image-editor-cancel-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-download-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-save-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 dispatch saveRequested when Save is clicked', () => { + spectator.click(nativeButton(spectator, 'image-editor-save-btn')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorLifecycleEvents.saveRequested(), + { + scope: 'self' + } + ); + }); + + it('should expose a "Save as…" menu item that dispatches saveAsRequested', () => { + const saveAsItem = spectator.component['saveMenuItems'][0]; + + expect(saveAsItem.label).toBe('edit.content.image-editor.footer.save-as'); + + saveAsItem.command?.({}); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorLifecycleEvents.saveAsRequested({ fileName: 'x.jpg' }), + { scope: 'self' } + ); + }); + + describe('disabled states', () => { + it('should disable Save when canSave is false', () => { + canSave.set(false); + spectator.detectChanges(); + + expect(nativeButton(spectator, 'image-editor-save-btn').disabled).toBe(true); + }); + + 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..e8b8ddfcb521 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-footer/dot-image-editor-footer.component.ts @@ -0,0 +1,73 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; + +import { MenuItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { SplitButtonModule } from 'primeng/splitbutton'; + +import { DotMessageService } from '@dotcms/data-access'; +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`, `canSave`, `saveStatus`) and dispatches the + * download / save / save-as lifecycle events. Cancel is surfaced as an output so + * the owning dialog component controls closing. + */ +@Component({ + selector: 'dot-image-editor-footer', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, SplitButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-footer.component.html' +}) +export class DotImageEditorFooterComponent { + readonly #dotMessageService = inject(DotMessageService); + + /** Image editor state store, provided by the owning dialog component. */ + protected readonly store = inject(ImageEditorStore); + + /** Lifecycle event dispatcher for download/save/save-as actions. */ + protected readonly dispatch = injectDispatch(imageEditorLifecycleEvents); + + /** Emitted when the user clicks Cancel; the dialog owner closes the editor. */ + cancel = output(); + + /** Label shown on the primary save button, reflecting the in-flight save status. */ + protected readonly saveLabel = computed(() => + this.store.saveStatus() === 'saving' + ? this.#dotMessageService.get('edit.content.image-editor.footer.saving') + : this.#dotMessageService.get('edit.content.image-editor.footer.save') + ); + + /** Spinner icon shown on the save button only while a save is in flight. */ + protected readonly saveIcon = computed(() => + this.store.saveStatus() === 'saving' ? 'pi pi-spin pi-spinner' : '' + ); + + /** Secondary save actions; currently only "Save as…". */ + protected readonly saveMenuItems: MenuItem[] = [ + { + label: this.#dotMessageService.get('edit.content.image-editor.footer.save-as'), + command: () => this.onSaveAs() + } + ]; + + /** Dispatches a download of the current preview. */ + protected onDownload(): void { + this.dispatch.downloadRequested(); + } + + /** Dispatches a save of the current edits. */ + protected onSave(): void { + this.dispatch.saveRequested(); + } + + /** Dispatches a save-as using the current asset file name. */ + protected onSaveAs(): void { + this.dispatch.saveAsRequested({ fileName: this.store.assetContext().fileName }); + } +} 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..cfb686e66f18 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html @@ -0,0 +1,11 @@ +
+

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

+ + +
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..bf352af3a69d --- /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,41 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotImageEditorHeaderComponent } from './dot-image-editor-header.component'; + +describe('DotImageEditorHeaderComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotImageEditorHeaderComponent, + imports: [ButtonModule, DotMessagePipe] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + 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 and close testids', () => { + expect(spectator.query(byTestId('image-editor-header'))).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); + }); +}); 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..eb0becd84cde --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessagePipe } from '@dotcms/ui'; + +/** + * Header bar of the image editor dialog. Renders the editor title on the left and + * a close icon button on the right that emits {@link DotImageEditorHeaderComponent.close}. + */ +@Component({ + selector: 'dot-image-editor-header', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DotMessagePipe], + templateUrl: './dot-image-editor-header.component.html' +}) +export class DotImageEditorHeaderComponent { + /** Emitted when the user clicks the close (✕) button. */ + close = output(); +} 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..45bb3833fd56 --- /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,57 @@ +
+
+
+ + {{ brightness() }} +
+ +
+ +
+
+ + {{ hue() }} +
+ +
+ +
+
+ + {{ saturation() }} +
+ +
+ +
+ + +
+
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..13ff82706284 --- /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,28 @@ +.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; +} + +.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-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..9c70a9abd9fe --- /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,110 @@ +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 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..ad05d41fa1b0 --- /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,93 @@ +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 { imageEditorPanelEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +/** + * 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 imageEditorPanelEvents} 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. + */ +@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(imageEditorPanelEvents); + + /** 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); + } + + /** 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); + } + + /** 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); + } + + /** 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. */ + private singleValue(value: SliderChangeEvent['value']): number { + return Array.isArray(value) ? (value[0] ?? 0) : (value ?? 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.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..ba05d4ed3bc0 --- /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,40 @@ +
+
+ + + + {{ option.label | dm }} + + +
+ + @if (isCompressing()) { +
+
+ + {{ store.fileInfo().quality }} +
+ +
+ } + +
+ + + {{ fileSize() }} + +
+
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..193f1ba9c9ab --- /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,105 @@ +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 { SelectButton } from 'primeng/selectbutton'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotImageEditorFileInfoPanelComponent } from './dot-image-editor-fileinfo-panel.component'; + +import { FileInfoState } from '../../../models/image-editor.models'; +import { imageEditorPanelEvents } 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 createComponent = createComponentFactory({ + component: DotImageEditorFileInfoPanelComponent, + providers: [ + provideNoopAnimations(), + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + ], + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { fileInfo })] + }); + + beforeEach(() => { + fileInfo.set(FILE_INFO); + 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(SelectButton); + select!.onChange.emit({ originalEvent: new Event('click'), value: 'webp' }); + + const event = dispatchedEvent(imageEditorPanelEvents.compressionChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toBe('webp'); + }); + + 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' + ); + }); + }); + + /** + * 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..17a9f26b00b4 --- /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 { SelectButtonModule } from 'primeng/selectbutton'; +import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { CompressionMode } from '../../../models/image-editor.models'; +import { imageEditorPanelEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +/** A selectable compression option shown in the compression select button. */ +interface CompressionOption { + label: string; + value: CompressionMode; +} + +/** One kibibyte, used to format byte counts for display. */ +const BYTES_PER_KB = 1024; + +/** + * 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 imageEditorPanelEvents} on user input; the + * quality slider dispatches its committed value on `onSlideEnd`. + */ +@Component({ + selector: 'dot-image-editor-fileinfo-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, SelectButtonModule, 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(imageEditorPanelEvents); + + /** 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' } + ]; + + /** 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)); + + /** 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..e0fe7dfe18c4 --- /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,33 @@ +
+ @if (store.appliedEdits().length) { +
    + @for (entry of store.appliedEdits(); track entry.id) { +
  • + + {{ 'edit.content.image-editor.history.category.' + entry.category | dm }} + {{ entry.label }} + + +
  • + } +
+ + + } @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..519a22c88c3f --- /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,32 @@ +.ie-history { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.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; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ie-history__empty { + margin: 0; + color: var(--text-color-secondary); +} 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..336306cc24c6 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.html @@ -0,0 +1,80 @@ + + + + + + + + {{ 'edit.content.image-editor.panel.adjust.title' | dm }} + + + {{ 'edit.content.image-editor.panel.adjust.subtitle' | dm }} + + + + + + + + + + + + + + + + {{ 'edit.content.image-editor.panel.transform.title' | dm }} + + + {{ 'edit.content.image-editor.panel.transform.subtitle' | dm }} + + + + + + + + + + + + + + + + {{ 'edit.content.image-editor.panel.fileinfo.title' | dm }} + + + {{ 'edit.content.image-editor.panel.fileinfo.subtitle' | dm }} + + + + + + + + + + + + + + + + {{ '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.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..b748f6bbe883 --- /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,60 @@ +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'; + +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) + ] + } + } + ] + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render the four accordion sections in order', () => { + 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(); + }); +}); 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..443fd83b5c20 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component } 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'; + +/** + * 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. + */ +@Component({ + selector: 'dot-image-editor-panels', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AccordionModule, + DotMessagePipe, + DotImageEditorAdjustPanelComponent, + DotImageEditorTransformPanelComponent, + DotImageEditorFileInfoPanelComponent, + DotImageEditorHistoryPanelComponent + ], + templateUrl: './dot-image-editor-panels.component.html' +}) +export class DotImageEditorPanelsComponent {} 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..87b3d276468d --- /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,84 @@ +
+
+
+ + {{ store.transform().scale }}% +
+ +
+ +
+
+ + {{ store.transform().rotateDeg }}° +
+ +
+ +
+ +
+ + +
+
+ +
+ +
+
+ + @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..8f3786a6da69 --- /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,48 @@ +.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; +} + +.ie-field__value { + font-variant-numeric: tabular-nums; + color: var(--text-color-secondary); +} + +.ie-transform__flip { + display: flex; + gap: 0.5rem; +} + +.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..4c1482b7a145 --- /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,139 @@ +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 { imageEditorPanelEvents } 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(imageEditorPanelEvents.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(imageEditorPanelEvents.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(imageEditorPanelEvents.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(imageEditorPanelEvents.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(imageEditorPanelEvents.outputDimsChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ width: 1024, height: null }); + }); + + it('should dispatch outputDimsChanged with the new height on height input', () => { + spectator.component['outputHeightChanged'](768); + + const event = dispatchedEvent(imageEditorPanelEvents.outputDimsChanged.type); + expect(event).toBeDefined(); + expect(event!.payload).toEqual({ width: null, 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(imageEditorPanelEvents.outputDimsChanged.type)!.payload).toEqual({ + width: null, + height: null + }); + }); + + /** 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..ff5c13b60d06 --- /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,88 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { InputNumberModule } from 'primeng/inputnumber'; +import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; +import { ToggleButtonModule } from 'primeng/togglebutton'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { imageEditorPanelEvents } from '../../../store/image-editor.events'; +import { ImageEditorStore } from '../../../store/image-editor.store'; + +/** + * 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 imageEditorPanelEvents} on user input. Sliders dispatch their committed + * value on `onSlideEnd`, letting the store own debouncing of the resulting preview. + */ +@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(imageEditorPanelEvents); + + /** 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); + + /** Dispatches the final scale value once the slider drag ends. */ + protected scaleChanged(event: SliderSlideEndEvent): void { + this.dispatch.scaleChanged(event.value ?? 100); + } + + /** Dispatches the final rotation value once the slider drag ends. */ + protected rotateChanged(event: SliderSlideEndEvent): void { + this.dispatch.rotateChanged(event.value ?? 0); + } + + /** 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)); + this.dispatch.outputDimsChanged({ + width: this.toDimension(value), + height: this.store.transform().outputHeight + }); + } + + /** 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.transform().outputWidth, + height: this.toDimension(value) + }); + } + + /** Normalizes a dimension input to a positive integer, or `null` when cleared/invalid. */ + private 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. */ + private isBelowMinimum(value: number | null): boolean { + return value != null && value < 1; + } +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html new file mode 100644 index 000000000000..d16e7d176b9e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html @@ -0,0 +1,17 @@ +@for (tool of tools; track tool.id) { + +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss new file mode 100644 index 000000000000..f8bcd7f0c298 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss @@ -0,0 +1,14 @@ +:host { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.75rem; + background-color: rgba(28, 30, 38, 0.92); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.tool-rail__button { + width: 2.5rem; + height: 2.5rem; +} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts new file mode 100644 index 000000000000..a38461ab6390 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts @@ -0,0 +1,86 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Dispatcher } from '@ngrx/signals/events'; + +import { signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotImageEditorToolRailComponent } from './dot-image-editor-tool-rail.component'; + +import { ActiveTool } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +const messageServiceMock = new MockDotMessageService({ + 'edit.content.image-editor.tool.move': 'Move', + 'edit.content.image-editor.tool.crop': 'Crop', + 'edit.content.image-editor.tool.focal': 'Focal point' +}); + +describe('DotImageEditorToolRailComponent', () => { + let spectator: Spectator; + let dispatcher: Dispatcher; + + const activeTool = signal('move'); + + const createComponent = createComponentFactory({ + component: DotImageEditorToolRailComponent, + providers: [{ provide: DotMessageService, useValue: messageServiceMock }], + componentProviders: [ + Dispatcher, + mockProvider(ImageEditorStore, { + activeTool + }) + ] + }); + + beforeEach(() => { + activeTool.set('move'); + spectator = createComponent(); + dispatcher = spectator.inject(Dispatcher, true); + jest.spyOn(dispatcher, 'dispatch'); + }); + + it('should render the three tools with testids and aria-labels', () => { + const move = spectator.query(byTestId('image-editor-tool-move')); + const crop = spectator.query(byTestId('image-editor-tool-crop')); + const focal = spectator.query(byTestId('image-editor-tool-focal')); + + expect(move).toBeTruthy(); + expect(crop).toBeTruthy(); + expect(focal).toBeTruthy(); + + expect(move).toHaveAttribute('aria-label', 'Move'); + expect(crop).toHaveAttribute('aria-label', 'Crop'); + expect(focal).toHaveAttribute('aria-label', 'Focal point'); + }); + + it('should expose a vertical toolbar role on the host', () => { + expect(spectator.element).toHaveAttribute('role', 'toolbar'); + expect(spectator.element).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('should dispatch toolSelected with the crop tool when crop is clicked', () => { + spectator.click(byTestId('image-editor-tool-crop')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorToolEvents.toolSelected('crop'), + { scope: 'self' } + ); + }); + + it('should mark the active tool with aria-pressed true and others false', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-tool-crop'))).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(spectator.query(byTestId('image-editor-tool-move'))).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts new file mode 100644 index 000000000000..e8f76de5cfff --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts @@ -0,0 +1,73 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { ActiveTool } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../../store/image-editor.events'; +import { ImageEditorStore } from '../../store/image-editor.store'; + +/** A selectable tool on the floating canvas rail. */ +interface ToolRailItem { + /** The tool identifier dispatched on selection. */ + id: ActiveTool; + /** PrimeNG icon class for the button. */ + icon: string; + /** i18n key for the aria-label and tooltip. */ + label: string; + /** `data-testid` value for the button. */ + testId: string; +} + +/** + * Floating vertical rail of canvas tools (move, crop, focal point). Acts as a + * `toolbar` with roving tabindex: the active tool is the only focusable button, + * matching the WAI-ARIA toolbar pattern. Selecting a tool dispatches + * {@link imageEditorToolEvents.toolSelected} so the store owns the active tool. + */ +@Component({ + selector: 'dot-image-editor-tool-rail', + templateUrl: './dot-image-editor-tool-rail.component.html', + styleUrl: './dot-image-editor-tool-rail.component.scss', + imports: [ButtonModule, TooltipModule, DotMessagePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + role: 'toolbar', + 'aria-orientation': 'vertical' + } +}) +export class DotImageEditorToolRailComponent { + protected readonly store = inject(ImageEditorStore); + readonly #dispatch = injectDispatch(imageEditorToolEvents); + + /** The ordered tools rendered on the rail. */ + protected readonly tools: ToolRailItem[] = [ + { + id: 'move', + icon: 'pi pi-arrows-alt', + label: 'edit.content.image-editor.tool.move', + testId: 'image-editor-tool-move' + }, + { + id: 'crop', + icon: 'pi pi-crop', + label: 'edit.content.image-editor.tool.crop', + testId: 'image-editor-tool-crop' + }, + { + id: 'focal', + icon: 'pi pi-circle', + label: 'edit.content.image-editor.tool.focal', + testId: 'image-editor-tool-focal' + } + ]; + + /** Selects a tool, delegating the state change to the store. */ + protected selectTool(id: ActiveTool): void { + this.#dispatch.toolSelected(id); + } +} 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..4a3671ccfe03 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html @@ -0,0 +1,18 @@ +
+
+ +
+ +
+ + +
+ +
+ +
+
+ + 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..b0b2301ee4bb --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + height: 100%; +} + +// 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..ec546c911f45 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts @@ -0,0 +1,177 @@ +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 } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { DotImageEditorComponent } from './dot-image-editor.component'; + +import { ImageEditorOpenParams } from '../../models/image-editor.models'; +import { 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'; + +const TEMP_FILE: DotCMSTempFile = { + id: 'temp_saved', + fileName: 'edited.jpg', + length: 1024, + folder: '', + image: true, + mimeType: 'image/jpeg', + referenceUrl: '', + thumbnailUrl: '' +}; + +/** 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 savedTempFile = signal(null); + const isDirty = 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, { savedTempFile, isDirty }) + ], + // 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(); + savedTempFile.set(null); + isDirty.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 the saved temp file once a save succeeds', () => { + savedTempFile.set(TEMP_FILE); + spectator.detectChanges(); + + expect(dialogRef.close).toHaveBeenCalledWith(TEMP_FILE); + }); + + 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 discard confirmation is accepted', () => { + 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).toHaveBeenCalledWith(null); + }); + }); +} + +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.stories.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.stories.ts new file mode 100644 index 000000000000..d286378427eb --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.stories.ts @@ -0,0 +1,183 @@ +import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; + +import { provideHttpClient } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { provideAnimations } from '@angular/platform-browser/animations'; + +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotImageEditorComponent } from './dot-image-editor.component'; + +/** + * Readable English labels for the `edit.content.image-editor.*` i18n keys so the + * isolated Storybook UI shows text instead of raw keys. Any key not listed falls + * back to the key itself via {@link MockDotMessageService}. + */ +const IMAGE_EDITOR_MESSAGES: Record = { + 'edit.content.image-editor.title': 'Edit image', + 'edit.content.image-editor.close.aria': 'Close image editor', + '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.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.canvas.error.message': 'The image could not be loaded.', + 'edit.content.image-editor.canvas.error.retry': 'Retry', + 'edit.content.image-editor.crop.apply': 'Apply', + 'edit.content.image-editor.crop.cancel': 'Cancel', + 'edit.content.image-editor.crop.box.aria': 'Crop selection', + 'edit.content.image-editor.focal.set': 'Set focal point', + 'edit.content.image-editor.focal.cancel': 'Cancel', + 'edit.content.image-editor.focal.marker.aria': 'Focal point marker', + 'edit.content.image-editor.address.copy.aria': 'Copy image URL', + 'edit.content.image-editor.address.copy.success': 'URL copied to clipboard', + 'edit.content.image-editor.address.copy.error': 'Could not copy the URL', + 'edit.content.image-editor.panel.adjust.title': 'Adjust', + 'edit.content.image-editor.panel.adjust.subtitle': 'Color and light', + 'edit.content.image-editor.panel.transform.title': 'Transform', + 'edit.content.image-editor.panel.transform.subtitle': 'Rotate, flip and resize', + 'edit.content.image-editor.panel.fileinfo.title': 'File info', + 'edit.content.image-editor.panel.fileinfo.subtitle': 'Size and compression', + 'edit.content.image-editor.panel.history.title': 'History', + 'edit.content.image-editor.panel.history.subtitle': 'Applied edits', + '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.transform.rotate': 'Rotate', + 'edit.content.image-editor.transform.flip': 'Flip', + 'edit.content.image-editor.transform.flip.horizontal': 'Flip horizontal', + 'edit.content.image-editor.transform.flip.vertical': 'Flip vertical', + 'edit.content.image-editor.transform.scale': 'Scale', + 'edit.content.image-editor.transform.dimensions': 'Dimensions', + 'edit.content.image-editor.transform.dimensions.error.min': 'Value is too small', + '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.quality': 'Quality', + 'edit.content.image-editor.fileinfo.filesize': 'File size', + 'edit.content.image-editor.history.empty': 'No edits applied yet', + 'edit.content.image-editor.history.reset': 'Reset', + 'edit.content.image-editor.history.remove.aria': 'Remove edit', + 'edit.content.image-editor.footer.cancel': 'Cancel', + 'edit.content.image-editor.footer.save': 'Save', + 'edit.content.image-editor.footer.save-as': 'Save as new', + 'edit.content.image-editor.footer.saving': 'Saving…', + 'edit.content.image-editor.footer.download': 'Download', + 'edit.content.image-editor.footer.download.aria': 'Download image', + 'edit.content.image-editor.discard.header': 'Discard changes?', + 'edit.content.image-editor.discard.message': 'Your unsaved edits will be lost.', + 'edit.content.image-editor.discard.confirm': 'Discard', + 'edit.content.image-editor.discard.reject': 'Keep editing' +}; + +/** + * Story-only launcher that opens {@link DotImageEditorComponent} through PrimeNG's + * `DialogService`. The editor injects `DynamicDialogConfig`/`DynamicDialogRef`, so + * it can only be rendered from inside a dynamic dialog — clicking the button is the + * only way to see it. + */ +@Component({ + selector: 'dot-image-editor-story-host', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DynamicDialogModule], + providers: [DialogService], + template: ` +
+ +
+ ` +}) +class DotImageEditorStoryHostComponent { + /** Inode used to build the preview URL the editor requests on open. */ + inode = '00000000-0000-0000-0000-000000000000'; + + readonly #dialogService = inject(DialogService); + + open(): void { + this.#dialogService.open(DotImageEditorComponent, { + data: { + inode: this.inode, + variable: 'fileAsset', + fieldName: 'fileAsset', + byInode: true, + fileName: 'sample.jpg', + mimeType: 'image/jpeg' + }, + width: 'min(92vw, 75rem)', + height: '90%', + modal: true, + closable: true, + closeOnEscape: true, + contentStyle: { height: '100%', overflow: 'hidden', padding: '0' } + }); + } +} + +const meta: Meta = { + title: 'Edit Content/Image Editor', + component: DotImageEditorStoryHostComponent, + decorators: [ + applicationConfig({ + // The store's effects use HttpClient; animations power the modal transitions. + providers: [provideHttpClient(), provideAnimations()] + }), + moduleMetadata({ + providers: [ + DialogService, + MessageService, + ConfirmationService, + { + provide: DotMessageService, + useValue: new MockDotMessageService(IMAGE_EDITOR_MESSAGES) + } + ] + }) + ], + parameters: { + docs: { + description: { + component: [ + 'Manual preview/launcher for the new image editor. The editor is opened', + "through PrimeNG's `DialogService` (DynamicDialog) — click **Open Image", + 'Editor** to launch it.', + '', + 'The live image preview requires a running dotCMS backend: in isolated', + 'Storybook the `/contentAsset/image/...` URL has no server to answer it, so', + 'the request 404s and the canvas shows the loading → error/retry state. That', + 'is expected and intentionally exercises the error UI. Point the `inode` arg', + 'at a real image asset on a connected instance to see a live preview.' + ].join('\n') + } + } + } +}; + +export default meta; + +type Story = StoryObj; + +/** + * Default launcher. Override `inode` to target a real image asset on a connected + * dotCMS instance for a live preview. + */ +export const Default: Story = { + args: { + inode: '00000000-0000-0000-0000-000000000000' + } +}; 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..5e782027c0ab --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts @@ -0,0 +1,89 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { imageEditorModalScaleFade } from '../../animations/image-editor.animations'; +import { ImageEditorOpenParams } from '../../models/image-editor.models'; +import { 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]': '' + } +}) +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); + + constructor() { + this.#dispatch.assetRequested(this.#config.data as ImageEditorOpenParams); + + // Close the dialog with the saved file the moment a save succeeds. + effect(() => { + const savedTempFile = this.store.savedTempFile(); + + if (savedTempFile) { + this.#dialogRef.close(savedTempFile); + } + }); + } + + /** 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'), + acceptLabel: this.#dotMessageService.get('edit.content.image-editor.discard.confirm'), + rejectLabel: this.#dotMessageService.get('edit.content.image-editor.discard.reject'), + accept: () => this.#dialogRef.close(null) + }); + } +} 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..a5c8c76d406b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/models/image-editor.models.ts @@ -0,0 +1,195 @@ +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 + */ +export type ActiveTool = 'move' | 'crop' | 'focal'; + +/** Output compression strategy applied as the last filter in the chain. */ +export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp'; + +/** Loading lifecycle of the preview image. */ +export type PreviewStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +/** Lifecycle of a save / save-as operation. */ +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +/** Logical category an applied edit belongs to, used for grouping and labels. */ +export type FilterCategory = + | 'adjust' + | 'crop' + | 'rotate' + | 'flip' + | 'grayscale' + | 'compression' + | 'focal'; + +/** Server-side filter name as understood by the dotCMS image filter endpoint. */ +export type FilterName = + | 'Resize' + | 'Crop' + | 'Rotate' + | 'Flip' + | 'Grayscale' + | 'Hsb' + | 'FocalPoint' + | 'Jpeg' + | 'WebP' + | 'Quality'; + +/** + * 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; +} + +/** Focal point as normalized 0..1 coordinates. `active` gates application. */ +export interface FocalPointState { + x: number; + y: number; + active: boolean; +} + +/** 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; + focalPoint: FocalPointState; + 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; +} 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..a2fc379651f4 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.spec.ts @@ -0,0 +1,175 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; + +import { DotImageEditorService } from './dot-image-editor.service'; + +describe('DotImageEditorService', () => { + let spectator: SpectatorService; + let httpMock: HttpTestingController; + let httpErrorManager: DotHttpErrorManagerService; + + const createService = createServiceFactory({ + service: DotImageEditorService, + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createService(); + httpMock = spectator.inject(HttpTestingController); + httpErrorManager = spectator.inject(DotHttpErrorManagerService); + }); + + 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('saveEditedImage', () => { + const tempResponse = { + id: 'temp_123', + fileName: 'edited.png', + length: 4096, + metadata: { contentType: 'image/png' } + }; + + it('should GET a URL with binaryFieldId and _imageToolSaveFile and map to a temp file', () => { + const result: unknown[] = []; + spectator.service + .saveEditedImage('/dA/asset.png/filter/Grayscale/grayscale/1', 'fileField') + .subscribe((file) => result.push(file)); + + const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); + expect(req.request.method).toBe('GET'); + expect(req.request.urlWithParams).toContain('binaryFieldId=fileField'); + expect(req.request.urlWithParams).toContain('_imageToolSaveFile=true'); + req.flush(tempResponse); + + expect(result[0]).toEqual({ + id: 'temp_123', + fileName: 'edited.png', + length: 4096, + metadata: { contentType: 'image/png' }, + folder: '', + image: true, + mimeType: 'image/png', + referenceUrl: '', + thumbnailUrl: '' + }); + }); + + it('should append the save tokens with & when the filter URL already has a query', () => { + spectator.service.saveEditedImage('/dA/asset.png?test=1', 'fileField').subscribe(); + + const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); + expect(req.request.urlWithParams).toContain('test=1'); + expect(req.request.urlWithParams).toContain('&binaryFieldId=fileField'); + req.flush(tempResponse); + }); + + it('should handle the error and rethrow on failure', () => { + let errored = false; + spectator.service.saveEditedImage('/dA/asset.png', 'fileField').subscribe({ + error: () => (errored = true) + }); + + httpMock + .expectOne((request) => request.url.startsWith('/dA/asset.png')) + .flush('boom', { status: 500, statusText: 'Server Error' }); + + expect(httpErrorManager.handle).toHaveBeenCalled(); + expect(errored).toBe(true); + }); + }); + + describe('persistFocalPoint', () => { + it('should GET the FocalPoint filter URL with an overwrite cache-buster', () => { + spectator.service.persistFocalPoint('/dA/asset.png', { x: 0.25, y: 0.75 }).subscribe(); + + const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); + expect(req.request.method).toBe('GET'); + expect(req.request.urlWithParams).toContain('/filter/FocalPoint/fp/0.25,0.75/'); + expect(req.request.urlWithParams).toContain('overwrite='); + req.flush(''); + }); + + it('should complete with void on HTTP error without throwing', () => { + let completed = false; + let errored = false; + spectator.service.persistFocalPoint('/dA/asset.png', { x: 0.5, y: 0.5 }).subscribe({ + complete: () => (completed = true), + error: () => (errored = true) + }); + + httpMock + .expectOne((request) => request.url.startsWith('/dA/asset.png')) + .flush('boom', { status: 500, statusText: 'Server Error' }); + + expect(completed).toBe(true); + expect(errored).toBe(false); + }); + }); + + 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); + + 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'); + expect(clickSpy).toHaveBeenCalled(); + + createSpy.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..9373af150f5a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/services/dot-image-editor.service.ts @@ -0,0 +1,169 @@ +import { Observable, forkJoin, of, throwError } from 'rxjs'; + +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { catchError, map } from 'rxjs/operators'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { FocalPointState, ImageEditorAssetContext } from '../models/image-editor.models'; + +/** Shape of the save endpoint JSON response used to build a {@link DotCMSTempFile}. */ +interface SaveEditedImageResponse { + id: string; + fileName: string; + length: number; + metadata?: DotCMSTempFile['metadata']; +} + +/** Natural pixel dimensions of an image resolved from the browser. */ +interface NaturalDimensions { + naturalWidth: number; + naturalHeight: number; +} + +/** Metadata resolved for an asset before editing begins. */ +interface AssetMeta { + naturalWidth: number; + naturalHeight: number; + originalBytes: number | null; + focalPoint?: FocalPointState; +} + +/** + * Data-access service for the image editor: resolves asset metadata, queries + * file sizes, persists the saved/edited image and the focal point, and triggers + * client-side downloads. All read-only/metadata calls are non-fatal and never + * throw; only {@link saveEditedImage} rethrows so the store can keep the modal + * open on failure. + */ +@Injectable() +export class DotImageEditorService { + readonly #http = inject(HttpClient); + readonly #httpErrorManager = inject(DotHttpErrorManagerService); + + /** + * 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)) + ); + } + + /** + * Persists the edited image to a temp file by hitting the filter URL with + * the save tokens appended. + * @param filterUrl - The fully-built filter/preview URL for the edited image + * @param variable - The binary field id the saved file should target + * @returns The resulting temp file + * @throws Rethrows the original error after surfacing it, so callers can keep + * the editor open on failure + */ + saveEditedImage(filterUrl: string, variable: string): Observable { + const separator = filterUrl.includes('?') ? '&' : '?'; + const url = `${filterUrl}${separator}binaryFieldId=${encodeURIComponent(variable)}&_imageToolSaveFile=true`; + + return this.#http.get(url).pipe( + map((res) => this.#toTempFile(res)), + catchError((error: HttpErrorResponse) => { + this.#httpErrorManager.handle(error); + + return throwError(() => error); + }) + ); + } + + /** + * Persists the focal point for an asset so it is honored by future renders. + * @param originalUrl - The base URL of the unfiltered original asset + * @param fp - Normalized focal point coordinates + * @returns Completes with `void`; non-fatal and never throws + */ + persistFocalPoint(originalUrl: string, fp: { x: number; y: number }): Observable { + const url = `${originalUrl}/filter/FocalPoint/fp/${fp.x},${fp.y}/?overwrite=${Date.now()}`; + + return this.#http.get(url, { responseType: 'text' }).pipe( + map(() => void 0), + catchError(() => of(void 0)) + ); + } + + /** + * 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, original byte size and optional focal point + */ + 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; + }); + } + + #toTempFile(res: SaveEditedImageResponse): DotCMSTempFile { + return { + id: res.id, + fileName: res.fileName, + length: res.length, + metadata: res.metadata, + folder: '', + image: true, + mimeType: res.metadata?.contentType ?? '', + referenceUrl: '', + thumbnailUrl: '' + }; + } +} 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..704677eb401c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.events.ts @@ -0,0 +1,77 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { + ActiveTool, + CompressionMode, + CropState, + FocalPointState, + ImageEditorOpenParams +} from '../models/image-editor.models'; + +/** Events emitted by the adjustment/transform/compression control panel. */ +export const imageEditorPanelEvents = eventGroup({ + source: 'Image Editor Panel', + events: { + brightnessChanged: type(), + hueChanged: type(), + saturationChanged: type(), + grayscaleToggled: type(), + scaleChanged: type(), + rotateChanged: type(), + flipHToggled: type(), + flipVToggled: type(), + outputDimsChanged: type<{ width: number | null; height: number | null }>(), + compressionChanged: type(), + qualityChanged: 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<{ x: number; y: number }>(), + focalPointCleared: 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, download and save. */ +export const imageEditorLifecycleEvents = eventGroup({ + source: 'Image Editor Lifecycle', + events: { + assetRequested: type(), + previewLoaded: type(), + previewErrored: type(), + retryRequested: type(), + downloadRequested: type(), + saveRequested: type(), + saveAsRequested: type<{ fileName: string }>(), + assetLoaded: type<{ + naturalWidth: number; + naturalHeight: number; + originalBytes: number | null; + focalPoint?: FocalPointState; + }>(), + assetLoadFailed: type(), + previewSizeResolved: type(), + saveSucceeded: type(), + saveFailed: 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..c7b9409ff60a --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.state.ts @@ -0,0 +1,149 @@ +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { + ActiveTool, + AdjustState, + CropState, + FileInfoState, + FocalPointState, + ImageEditorAssetContext, + ImageEditorHistoryEntry, + PreviewStatus, + SaveStatus, + TransformState, + ZoomState +} from '../models/image-editor.models'; + +/** + * The complete, flat state of the image editor. Each slice owns a domain of the + * editing experience (color adjustment, geometric transform, crop, focal point, + * 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; + /** Normalized focal point slice. */ + focalPoint: FocalPointState; + /** Canvas zoom slice. */ + zoom: ZoomState; + /** Tool currently selected on the canvas. */ + activeTool: ActiveTool; + /** Loading lifecycle of the preview image. */ + previewStatus: PreviewStatus; + /** Lifecycle of the current save / save-as operation. */ + saveStatus: SaveStatus; + /** Temp file produced by the last successful save, or `null`. */ + savedTempFile: DotCMSTempFile | null; + /** 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; +} + +/** Inclusive value ranges enforced by the reducer when clamping panel edits. */ +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; + +/** 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 focal point slice: centered and inactive. */ +export const initialFocalPointState: FocalPointState = { + x: 0.5, + y: 0.5, + active: false +}; + +/** Default zoom slice: 100% and fitted to screen. */ +export const initialZoomState: ZoomState = { + level: 100, + fitToScreen: true +}; + +/** 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, + focalPoint: initialFocalPointState, + zoom: initialZoomState, + activeTool: 'move', + previewStatus: 'idle', + saveStatus: 'idle', + savedTempFile: null, + error: null, + history: [], + historyIndex: -1, + cacheBust: 0 +}; 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..75f81748f14d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts @@ -0,0 +1,473 @@ +import { mockProvider, SpyObject } from '@ngneat/spectator/jest'; +import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; +import { of, Subject, throwError } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { + imageEditorHistoryEvents, + imageEditorLifecycleEvents, + imageEditorPanelEvents, + imageEditorToolEvents +} 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 TEMP_FILE: DotCMSTempFile = { + id: 'temp-123', + fileName: 'edited.png', + folder: '', + image: true, + length: 2048, + mimeType: 'image/png', + referenceUrl: '', + thumbnailUrl: '' +}; + +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 httpErrorManager: SpyObject; + let injector: Injector; + + // Self-dispatching event groups, resolved within the injection context. + let panel: 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 }) + ), + persistFocalPoint: jest.fn().mockReturnValue(of(void 0)), + saveEditedImage: jest.fn().mockReturnValue(of(TEMP_FILE)), + triggerDownload: jest.fn() + }), + mockProvider(DotHttpErrorManagerService, { + handle: jest.fn().mockReturnValue(of({})) + }), + 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; + httpErrorManager = TestBed.inject( + DotHttpErrorManagerService + ) as SpyObject; + + runInInjectionContext(injector, () => { + panel = injectDispatch(imageEditorPanelEvents); + 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.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); + 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', () => { + panel.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', () => { + panel.brightnessChanged(10); + panel.brightnessChanged(20); + panel.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', () => { + panel.brightnessChanged(10); + panel.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 }); + panel.scaleChanged(500); + + expect(store.transform().scale).toBe(400); + expect(store.crop().active).toBe(false); + }); + + it('should toggle flip flags under the flip category', () => { + panel.flipHToggled(); + panel.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', () => { + panel.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', () => { + panel.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'); + }); + + it('should set and clear the focal point', () => { + tool.focalPointSet({ x: 0.25, y: 0.75 }); + + expect(store.focalPoint()).toEqual({ x: 0.25, y: 0.75, active: true }); + expect(store.history().at(-1)?.category).toBe('focal'); + + tool.focalPointCleared(); + + expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); + }); + }); + + describe('history', () => { + it('should remove a specific edit and recompute slices', () => { + panel.brightnessChanged(20); + panel.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', () => { + panel.brightnessChanged(20); + panel.rotateChanged(45); + panel.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°'); + // Slices restore from the new head's snapshot, which carries the + // brightness that was active when the rotation was applied. + expect(store.transform().rotateDeg).toBe(45); + expect(store.adjust().brightness).toBe(20); + expect(store.adjust().saturation).toBe(0); + }); + + it('should undo and redo edits', () => { + panel.brightnessChanged(20); + panel.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', () => { + panel.brightnessChanged(20); + + history.undoRequested(); + history.undoRequested(); + expect(store.historyIndex()).toBe(-1); + + history.redoRequested(); + history.redoRequested(); + expect(store.historyIndex()).toBe(0); + }); + + it('should reset everything', () => { + panel.brightnessChanged(20); + panel.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); + + panel.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); + panel.brightnessChanged(20); + expect(store.isDirty()).toBe(true); + }); + + it('isBusy reflects loading/saving status', () => { + expect(store.isBusy()).toBe(false); + panel.brightnessChanged(20); + expect(store.isBusy()).toBe(true); + + lifecycle.previewLoaded(); + expect(store.isBusy()).toBe(false); + }); + + it('canSave requires loaded preview, not saving and dirty', () => { + expect(store.canSave()).toBe(false); + + panel.brightnessChanged(20); + expect(store.canSave()).toBe(false); // still loading + + lifecycle.previewLoaded(); + expect(store.canSave()).toBe(true); + }); + }); + + describe('preview lifecycle', () => { + it('previewLoaded sets status loaded and clears error', () => { + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('error'); + + lifecycle.previewLoaded(); + expect(store.previewStatus()).toBe('loaded'); + expect(store.error()).toBeNull(); + }); + + it('previewErrored sets the error status', () => { + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('error'); + expect(store.error()).toBe('Failed to render preview'); + }); + }); + + describe('previewUrl', () => { + it('recomputes when a slice changes', () => { + lifecycle.assetRequested(OPEN_PARAMS); + const before = store.previewUrl(); + + panel.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)); + + panel.brightnessChanged(10); + panel.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); + }); + }); + + describe('save', () => { + it('success sets the saved temp file and saved status', () => { + lifecycle.assetRequested(OPEN_PARAMS); + panel.brightnessChanged(20); + lifecycle.saveRequested(); + + expect(service.saveEditedImage).toHaveBeenCalled(); + expect(store.savedTempFile()).toEqual(TEMP_FILE); + expect(store.saveStatus()).toBe('saved'); + }); + + it('persists the focal point before saving when active', () => { + lifecycle.assetRequested(OPEN_PARAMS); + tool.focalPointSet({ x: 0.3, y: 0.4 }); + lifecycle.saveRequested(); + + expect(service.persistFocalPoint).toHaveBeenCalledWith(expect.any(String), { + x: 0.3, + y: 0.4 + }); + expect(service.saveEditedImage).toHaveBeenCalled(); + }); + + it('does not persist the focal point when inactive', () => { + lifecycle.assetRequested(OPEN_PARAMS); + panel.brightnessChanged(20); + lifecycle.saveRequested(); + + expect(service.persistFocalPoint).not.toHaveBeenCalled(); + }); + + it('error path handles the error and sets save status to error', () => { + const error = new HttpErrorResponse({ status: 500 }); + service.saveEditedImage.mockReturnValue(throwError(() => error)); + + lifecycle.assetRequested(OPEN_PARAMS); + panel.brightnessChanged(20); + lifecycle.saveRequested(); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.saveStatus()).toBe('error'); + }); + + it('ignores a second save while one is in flight and never strands saving', () => { + // Defer the first save so a second request arrives mid-flight. + const inFlight = new Subject(); + service.saveEditedImage.mockReturnValue(inFlight.asObservable()); + + lifecycle.assetRequested(OPEN_PARAMS); + panel.brightnessChanged(20); + + lifecycle.saveRequested(); + expect(store.saveStatus()).toBe('saving'); + + // A second trigger while saving must be ignored (exhaustMap), not + // cancel the first and strand the store in 'saving'. + lifecycle.saveAsRequested(); + + // Complete the original save. + inFlight.next(TEMP_FILE); + inFlight.complete(); + + expect(service.saveEditedImage).toHaveBeenCalledTimes(1); + expect(store.savedTempFile()).toEqual(TEMP_FILE); + expect(store.saveStatus()).toBe('saved'); + }); + }); + + 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..428a80d531f3 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store.ts @@ -0,0 +1,642 @@ +import { tapResponse } from '@ngrx/operators'; +import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals'; +import { Dispatcher, Events, on, withReducer } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; + +import { + catchError, + debounceTime, + distinctUntilChanged, + exhaustMap, + switchMap, + tap +} from 'rxjs/operators'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { + imageEditorHistoryEvents, + imageEditorLifecycleEvents, + imageEditorPanelEvents, + imageEditorToolEvents +} from './image-editor.events'; +import { + ImageEditorState, + initialAdjustState, + initialCropState, + initialFileInfoState, + initialFocalPointState, + initialImageEditorState, + initialTransformState, + RANGES +} from './image-editor.state'; + +import { + AdjustState, + CompressionMode, + CropState, + FileInfoState, + FilterCategory, + FocalPointState, + ImageEditorAssetContext, + ImageEditorHistoryEntry, + ImageEditorOpenParams, + TransformState +} from '../models/image-editor.models'; +import { DotImageEditorService } from '../services/dot-image-editor.service'; +import { clamp, computeOutputDimensions } from '../utils/dimensions.util'; +import { buildFilterChain, buildPreviewUrl } from '../utils/image-filter-url.builder'; + +/** The editable slices captured in a history snapshot. */ +type EditableSlices = ImageEditorHistoryEntry['snapshot']; + +/** The pristine values of the editable slices, used to seed and reset history. */ +const initialEditableSlices: EditableSlices = { + adjust: initialAdjustState, + transform: initialTransformState, + crop: initialCropState, + focalPoint: initialFocalPointState, + fileInfo: initialFileInfoState +}; + +/** Extracts the editable slices from the full state for snapshotting. */ +function editableSlicesOf(state: ImageEditorState): EditableSlices { + return { + adjust: state.adjust, + transform: state.transform, + crop: state.crop, + focalPoint: state.focalPoint, + 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); otherwise any redo tail is discarded and a new + * entry is appended. + * @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` + */ +function coalesceHistory( + state: ImageEditorState, + category: FilterCategory, + label: string, + snapshot: EditableSlices +): Pick { + const head = state.history[state.historyIndex]; + + if (head && head.category === category) { + const history = state.history.map((entry, index) => + index === state.historyIndex ? { ...entry, label, snapshot } : entry + ); + + return { history, historyIndex: state.historyIndex }; + } + + const entry: ImageEditorHistoryEntry = { + id: `${category}-${Date.now()}-${state.cacheBust}`, + 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. */ +function restoreSlices(snapshot: EditableSlices): EditableSlices { + return { + adjust: snapshot.adjust, + transform: snapshot.transform, + crop: snapshot.crop, + focalPoint: snapshot.focalPoint, + fileInfo: snapshot.fileInfo + }; +} + +/** Resolves the editable slices for a given history index, or initial when empty. */ +function slicesAtIndex(history: ImageEditorHistoryEntry[], index: number): EditableSlices { + return index < 0 + ? restoreSlices(initialEditableSlices) + : restoreSlices(history[index].snapshot); +} + +/** Produces the asset context for a freshly requested asset. */ +function contextFromParams(params: ImageEditorOpenParams): ImageEditorAssetContext { + 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 != null, + byInode, + naturalWidth: 0, + naturalHeight: 0, + originalUrl: `/contentAsset/image/${idOrTempId}/${params.fieldName}` + }; +} + +const COMPRESSION_LABELS: Record = { + none: 'None', + auto: 'Auto', + jpeg: 'JPEG', + webp: 'WebP' +}; + +/** + * NgRx SignalStore for the image editor. Built entirely on the events API: every + * synchronous state transition is folded by `withReducer`, derived state is + * exposed through `withComputed`, and asynchronous side effects (asset loading, + * debounced size resolution, save and download) react to the dispatched events + * stream inside `withHooks.onInit` via `rxMethod`. The store is NOT provided in + * root — the editor dialog component supplies it so each editor instance is + * isolated. + */ +export const ImageEditorStore = signalStore( + withState(initialImageEditorState), + withReducer( + // --- Panel: color adjustments --- + on(imageEditorPanelEvents.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(imageEditorPanelEvents.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(imageEditorPanelEvents.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(imageEditorPanelEvents.grayscaleToggled, ({ payload }, state) => { + const adjust: AdjustState = { ...state.adjust, grayscale: payload }; + + return adjustPatch(state, adjust, 'grayscale', `Grayscale ${payload ? 'on' : 'off'}`); + }), + // --- Panel: transform --- + on(imageEditorPanelEvents.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, 'adjust', `Scale ${value}%`); + }), + on(imageEditorPanelEvents.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(imageEditorPanelEvents.flipHToggled, (_event, state) => { + const transform: TransformState = { ...state.transform, flipH: !state.transform.flipH }; + + return transformPatch(state, transform, state.crop, 'flip', 'Flip horizontal'); + }), + on(imageEditorPanelEvents.flipVToggled, (_event, state) => { + const transform: TransformState = { ...state.transform, flipV: !state.transform.flipV }; + + return transformPatch(state, transform, state.crop, 'flip', 'Flip vertical'); + }), + on(imageEditorPanelEvents.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, 'adjust', 'Resize'); + }), + // --- Panel: compression --- + on(imageEditorPanelEvents.compressionChanged, ({ payload }, state) => { + const fileInfo: FileInfoState = { ...state.fileInfo, compression: payload }; + + return fileInfoPatch( + state, + fileInfo, + 'compression', + `Compression ${COMPRESSION_LABELS[payload]}` + ); + }), + on(imageEditorPanelEvents.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}`); + }), + // --- Tools --- + on(imageEditorToolEvents.toolSelected, ({ payload }, state) => ({ + ...state, + activeTool: payload + })), + 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 + })), + on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => { + const focalPoint: FocalPointState = { x: payload.x, y: payload.y, active: true }; + const next: ImageEditorState = { + ...state, + focalPoint, + previewStatus: 'loading', + cacheBust: state.cacheBust + 1 + }; + + return { + ...next, + ...coalesceHistory( + next, + 'focal', + `Focal point ${payload.x.toFixed(2)}, ${payload.y.toFixed(2)}`, + editableSlicesOf(next) + ) + }; + }), + on(imageEditorToolEvents.focalPointCleared, (_event, state) => ({ + ...state, + focalPoint: initialFocalPointState, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + })), + // --- History --- + on(imageEditorHistoryEvents.editRemoved, ({ payload }, state) => { + const removedIdx = state.history.findIndex((entry) => entry.id === payload.id); + const history = state.history.filter((entry) => entry.id !== payload.id); + // 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 + })), + // --- Lifecycle --- + 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 + }, + focalPoint: payload.focalPoint ?? state.focalPoint + })), + on(imageEditorLifecycleEvents.assetLoadFailed, ({ payload }, state) => ({ + ...state, + previewStatus: 'error' as const, + error: errorMessage(payload, 'Failed to load image') + })), + on(imageEditorLifecycleEvents.previewLoaded, (_event, state) => ({ + ...state, + previewStatus: 'loaded' as const, + error: null + })), + on(imageEditorLifecycleEvents.previewErrored, (_event, state) => ({ + ...state, + previewStatus: 'error' as const, + error: 'Failed to render preview' + })), + on(imageEditorLifecycleEvents.retryRequested, (_event, state) => ({ + ...state, + previewStatus: 'loading' as const, + cacheBust: state.cacheBust + 1 + })), + on(imageEditorLifecycleEvents.previewSizeResolved, ({ payload }, state) => ({ + ...state, + fileInfo: { ...state.fileInfo, currentBytes: payload } + })), + on(imageEditorLifecycleEvents.saveRequested, (_event, state) => ({ + ...state, + saveStatus: 'saving' as const + })), + on(imageEditorLifecycleEvents.saveAsRequested, (_event, state) => ({ + ...state, + saveStatus: 'saving' as const + })), + on(imageEditorLifecycleEvents.saveSucceeded, ({ payload }, state) => ({ + ...state, + savedTempFile: payload, + saveStatus: 'saved' as const + })), + on(imageEditorLifecycleEvents.saveFailed, ({ payload }, state) => ({ + ...state, + saveStatus: 'error' as const, + error: errorMessage(payload, 'Failed to save image') + })) + ), + withComputed((store) => { + const appliedFilters = computed(() => + buildFilterChain({ + adjust: store.adjust(), + transform: store.transform(), + crop: store.crop(), + fileInfo: store.fileInfo(), + focalPoint: store.focalPoint() + }) + ); + + 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()) + ), + /** 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 })) + ), + /** The effective output dimensions of the edited image. */ + outputDimensions: computed(() => + computeOutputDimensions(store.assetContext(), store.transform(), store.crop()) + ), + /** 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), + /** Whether any edit produces a non-empty filter chain. */ + isDirty: computed(() => appliedFilters().length > 0), + /** Whether the editor is mid-flight loading a preview or saving. */ + isBusy: computed( + () => store.previewStatus() === 'loading' || store.saveStatus() === 'saving' + ), + /** Whether a save can be initiated right now. */ + canSave: computed( + () => + store.previewStatus() === 'loaded' && + store.saveStatus() !== 'saving' && + appliedFilters().length > 0 + ) + }; + }), + withHooks({ + onInit(store) { + const events = inject(Events); + const dispatcher = inject(Dispatcher); + const service = inject(DotImageEditorService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + // Load asset metadata whenever a new asset is requested. + const loadAsset = rxMethod( + pipe( + switchMap(() => + service.loadAssetMeta(store.assetContext()).pipe( + tapResponse({ + next: (meta) => + dispatcher.dispatch( + imageEditorLifecycleEvents.assetLoaded(meta) + ), + error: (error) => + dispatcher.dispatch( + imageEditorLifecycleEvents.assetLoadFailed(error) + ) + }) + ) + ) + ) + ); + loadAsset(events.on(imageEditorLifecycleEvents.assetRequested)); + + // Resolve the edited preview size, debounced against rapid edits. + const resolveSize = rxMethod( + pipe( + debounceTime(250), + distinctUntilChanged(), + switchMap((url) => + service + .getFileSize(url) + .pipe( + tap((bytes) => + dispatcher.dispatch( + imageEditorLifecycleEvents.previewSizeResolved(bytes) + ) + ) + ) + ) + ) + ); + resolveSize(toObservable(store.previewUrl)); + + // Save the edited image and dispatch the outcome. + const saveEditedImage$ = () => + service.saveEditedImage(store.previewUrl(), store.assetContext().variable).pipe( + tapResponse({ + next: (tempFile: DotCMSTempFile) => + dispatcher.dispatch(imageEditorLifecycleEvents.saveSucceeded(tempFile)), + error: (error: HttpErrorResponse) => { + // Surface the error but keep the editor open for retry. + httpErrorManager.handle(error); + dispatcher.dispatch(imageEditorLifecycleEvents.saveFailed(error)); + } + }), + // Swallow the rethrown error so the effect stream stays alive. + catchError(() => EMPTY) + ); + + // Persist the focal point first (when active), then save the image. + // `exhaustMap` ignores new save triggers while one is in flight: a + // destructive write must not be cancelled mid-flight, which would + // strand `saveStatus: 'saving'` with no terminal event. + const save = rxMethod( + pipe( + exhaustMap(() => { + const focalPoint = store.focalPoint(); + + if (!focalPoint.active) { + return saveEditedImage$(); + } + + return service + .persistFocalPoint(store.assetContext().originalUrl, { + x: focalPoint.x, + y: focalPoint.y + }) + .pipe(switchMap(() => saveEditedImage$())); + }) + ) + ); + + save( + events.on( + imageEditorLifecycleEvents.saveRequested, + imageEditorLifecycleEvents.saveAsRequested + ) + ); + + // Trigger a client-side download of the current preview. + const download = rxMethod( + pipe( + tap(() => + service.triggerDownload(store.previewUrl(), store.assetContext().fileName) + ) + ) + ); + download(events.on(imageEditorLifecycleEvents.downloadRequested)); + } + }) +); + +/** Applies an adjust-slice edit, bumps the cache and coalesces history. */ +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. */ +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. */ +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. */ +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/utils/dimensions.util.ts b/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts new file mode 100644 index 000000000000..cba94ecead0d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts @@ -0,0 +1,94 @@ +import { CropState, ImageEditorAssetContext, TransformState } from '../models/image-editor.models'; + +/** Intrinsic pixel dimensions of an image. */ +interface Dimensions { + width: number; + height: number; +} + +/** + * 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..ca489bd5d1ce --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.spec.ts @@ -0,0 +1,297 @@ +import { buildFilterChain, buildPreviewUrl, cleanUrl, toHsb } from './image-filter-url.builder'; + +import { + AdjustState, + CompressionMode, + CropState, + FileInfoState, + FocalPointState, + 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 baseFocalPoint: FocalPointState = { + x: 0.5, + y: 0.5, + active: false +}; + +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; + focalPoint: Partial; + }> = {} +) { + return buildFilterChain({ + adjust: { ...baseAdjust, ...overrides.adjust }, + transform: { ...baseTransform, ...overrides.transform }, + crop: { ...baseCrop, ...overrides.crop }, + fileInfo: { ...baseFileInfo, ...overrides.fileInfo }, + focalPoint: { ...baseFocalPoint, ...overrides.focalPoint } + }); +} + +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('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' }]); + }); + + it('builds a FocalPoint filter when active and off-center', () => { + const result = chain({ focalPoint: { active: true, x: 0.25, y: 0.75 } }); + expect(result).toEqual([{ name: 'FocalPoint', args: '/fp/0.25,0.75' }]); + }); + + it('omits FocalPoint when centered', () => { + const result = chain({ focalPoint: { active: true, x: 0.5, y: 0.5 } }); + expect(result).toEqual([]); + }); + }); + + 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); + }); + }); + + 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]> = [ + ['jpeg', '/jpeg_q/80'], + ['webp', '/webp_q/80'], + ['auto', '/quality_q/80'] + ]; + + it.each(cases)('appends %s compression last', (mode, args) => { + const result = chain({ + adjust: { grayscale: true }, + fileInfo: { compression: mode, quality: 80 } + }); + expect(result[result.length - 1]).toEqual({ + name: mode === 'jpeg' ? 'Jpeg' : mode === 'webp' ? 'WebP' : 'Quality', + 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', '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..6fcb0f5d555f --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts @@ -0,0 +1,162 @@ +import { + AdjustState, + AppliedFilter, + CompressionMode, + CropState, + FileInfoState, + FocalPointState, + ImageEditorAssetContext, + TransformState +} from '../models/image-editor.models'; + +/** State slices required to build the server filter chain. */ +interface FilterChainInput { + adjust: AdjustState; + transform: TransformState; + crop: CropState; + fileInfo: FileInfoState; + focalPoint: FocalPointState; +} + +/** + * 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}` }; + 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, and the + * compression filter is always applied last and exclusively. + * @param input - The adjust/transform/crop/fileInfo/focalPoint state slices + * @returns The applied filters in the exact order they must be concatenated + */ +export function buildFilterChain(input: FilterChainInput): AppliedFilter[] { + const { adjust, transform, crop, fileInfo, focalPoint } = input; + const filters: AppliedFilter[] = []; + + const isResizing = + transform.outputWidth != null || transform.outputHeight != null || transform.scale !== 100; + + if (isResizing) { + let args = ''; + if (transform.outputWidth != null) { + args += `/resize_w/${Math.round(transform.outputWidth)}`; + } + if (transform.outputHeight != null) { + args += `/resize_h/${Math.round(transform.outputHeight)}`; + } + if (args) { + filters.push({ name: 'Resize', args }); + } + } else if (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 }); + } + + // 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' }); + } + + 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 }); + } + + if (focalPoint.active && !(focalPoint.x === 0.5 && focalPoint.y === 0.5)) { + filters.push({ name: 'FocalPoint', args: `/fp/${focalPoint.x},${focalPoint.y}` }); + } + + 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/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/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/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4d76af7a1eef..ce16790aa3b4 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6469,6 +6469,87 @@ 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.footer.save=Save +edit.content.image-editor.footer.save-as=Save as… +edit.content.image-editor.footer.saving=Saving… +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.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.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.focal.set=Set focal point +edit.content.image-editor.focal.cancel=Cancel +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.quality=Quality +edit.content.image-editor.fileinfo.filesize=File 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.history.category.adjust=Adjust +edit.content.image-editor.history.category.crop=Crop +edit.content.image-editor.history.category.rotate=Rotate +edit.content.image-editor.history.category.flip=Flip +edit.content.image-editor.history.category.grayscale=Grayscale +edit.content.image-editor.history.category.compression=Compression +edit.content.image-editor.history.category.focal=Focal point +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. From e1f629a6541ad7dadec685652005df1a2a36f326 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 18 Jun 2026 17:21:04 -0400 Subject: [PATCH 02/29] feat(image-editor): UI/UX polish for the Angular image editor - Accordion: design-aligned header (13px title, 11px subtitle, primary icon chip when open), collapsed by default, open sections persisted to localStorage - Adjust values are editable number fields synced with the sliders (clamped) - Undo/redo keyboard shortcuts on the dialog (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y); ignored while a text field is focused - Crop: Shift-drag a corner locks the starting aspect ratio - Address bar text/icon use the design's 78%-white dark-chrome tone - Canvas/footer layout + padding, gradient adjust sliders, taller dialog - Store: split panel events into Adjust/Transform/File-info groups, one withReducer per group, and move async effects to withEventHandlers - Remove unused Storybook wiring (story + .storybook glob) and the unused legacy Dojo and noop launchers Refs #36063 --- core-web/apps/dotcms-ui/.storybook/main.js | 1 - .../src/lib/edit-content.shell.component.ts | 15 +- ...dot-edit-content-binary-field.component.ts | 16 +- .../angular-image-editor.launcher.spec.ts | 4 +- .../angular-image-editor.launcher.ts | 11 +- .../image-editor-launcher.token.ts | 5 +- .../shared/image-editor-launcher/index.ts | 2 - .../legacy-dojo-image-editor.launcher.spec.ts | 96 -------- .../legacy-dojo-image-editor.launcher.ts | 65 ------ .../noop-image-editor.launcher.spec.ts | 28 --- .../noop-image-editor.launcher.ts | 23 -- ...ot-image-editor-address-bar.component.html | 12 +- ...ot-image-editor-address-bar.component.scss | 7 +- ...image-editor-address-bar.component.spec.ts | 8 +- .../dot-image-editor-address-bar.component.ts | 18 +- .../dot-image-editor-canvas.component.html | 31 +++ .../dot-image-editor-canvas.component.scss | 85 +++++-- .../dot-image-editor-canvas.component.spec.ts | 47 ++++ .../dot-image-editor-canvas.component.ts | 37 ++- ...t-image-editor-crop-overlay.component.html | 13 -- ...t-image-editor-crop-overlay.component.scss | 15 -- ...mage-editor-crop-overlay.component.spec.ts | 47 +++- ...dot-image-editor-crop-overlay.component.ts | 122 ++++++++-- ...-image-editor-focal-overlay.component.html | 13 -- ...-image-editor-focal-overlay.component.scss | 15 -- ...age-editor-focal-overlay.component.spec.ts | 10 +- ...ot-image-editor-focal-overlay.component.ts | 23 +- ...t-image-editor-adjust-panel.component.html | 31 ++- ...t-image-editor-adjust-panel.component.scss | 69 +++++- ...mage-editor-adjust-panel.component.spec.ts | 22 ++ ...dot-image-editor-adjust-panel.component.ts | 53 ++++- ...ge-editor-fileinfo-panel.component.spec.ts | 4 +- ...t-image-editor-fileinfo-panel.component.ts | 6 +- .../dot-image-editor-panels.component.html | 51 ++-- .../dot-image-editor-panels.component.scss | 124 ++++++++++ .../dot-image-editor-panels.component.spec.ts | 33 ++- .../dot-image-editor-panels.component.ts | 30 ++- ...e-editor-transform-panel.component.spec.ts | 24 +- ...-image-editor-transform-panel.component.ts | 6 +- .../dot-image-editor-tool-rail.component.html | 59 ++++- .../dot-image-editor-tool-rail.component.scss | 37 +++ .../dot-image-editor-tool-rail.component.ts | 10 +- .../dot-image-editor.component.html | 17 +- .../dot-image-editor.component.scss | 35 +++ .../dot-image-editor.component.spec.ts | 80 ++++++- .../dot-image-editor.component.stories.ts | 183 --------------- .../dot-image-editor.component.ts | 52 ++++- .../lib/services/dot-image-editor.service.ts | 2 +- .../src/lib/store/image-editor.events.ts | 24 +- .../src/lib/store/image-editor.store.spec.ts | 78 ++++--- .../src/lib/store/image-editor.store.ts | 221 +++++++++--------- .../src/lib/utils/panel-state.storage.spec.ts | 63 +++++ .../src/lib/utils/panel-state.storage.ts | 47 ++++ 53 files changed, 1356 insertions(+), 774 deletions(-) delete mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts delete mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-panels/dot-image-editor-panels.component.scss delete mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.stories.ts create mode 100644 core-web/libs/image-editor/src/lib/utils/panel-state.storage.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/utils/panel-state.storage.ts diff --git a/core-web/apps/dotcms-ui/.storybook/main.js b/core-web/apps/dotcms-ui/.storybook/main.js index a4c63fe3a1c9..a17f082701ae 100644 --- a/core-web/apps/dotcms-ui/.storybook/main.js +++ b/core-web/apps/dotcms-ui/.storybook/main.js @@ -14,7 +14,6 @@ module.exports = { '../../../libs/template-builder/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/block-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/edit-content/**/*.stories.@(js|jsx|ts|tsx|mdx)', - '../../../libs/image-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/ui/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/portlets/**/*.stories.@(js|jsx|ts|tsx|mdx)' ], 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.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts index a8c2bfaf153c..c1f00fc21cff 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 @@ -63,7 +63,7 @@ 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 { AngularImageEditorLauncher, IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; +import { IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { ...DEFAULT_MONACO_CONFIG, @@ -99,7 +99,6 @@ type SystemOptionsType = { DotBinaryFieldStore, DotLicenseService, DotBinaryFieldValidatorService, - { provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher }, { multi: true, provide: NG_VALUE_ACCESSOR, @@ -121,7 +120,10 @@ export class DotEditContentBinaryFieldComponent readonly #dotAiService = inject(DotAiService); readonly #dialogService = inject(DialogService); readonly #destroyRef = inject(DestroyRef); - readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER); + // 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 @@ -399,12 +401,18 @@ export class DotEditContentBinaryFieldComponent * @memberof DotEditContentBinaryFieldComponent */ onEditImage() { + const launcher = this.#imageEditorLauncher; + + if (!launcher?.isAvailable()) { + return; + } + const inode = this.contentlet?.inode; const metadata = this.contentlet ? (getFileMetadata(this.contentlet) as Partial) : null; - this.#imageEditorLauncher + launcher .open({ inode, tempId: this.tempId, 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 index 2251477c42ae..ca74a72ee253 100644 --- 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 @@ -36,7 +36,7 @@ describe('AngularImageEditorLauncher', () => { expect(spectator.service.isAvailable()).toBe(true); }); - it('should open the DotImageEditorComponent with a closable, escapable dialog', () => { + it('should open the DotImageEditorComponent with a headerless, closable, escapable dialog', () => { spectator.service.open(params).subscribe(); expect(spectator.inject(DialogService).open).toHaveBeenCalledWith( @@ -44,6 +44,8 @@ describe('AngularImageEditorLauncher', () => { expect.objectContaining({ data: params, modal: true, + // The editor renders its own header; PrimeNG's chrome header is hidden. + showHeader: false, closable: true, closeOnEscape: true }) 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 index 4db6e1ffe267..4f3dde256766 100644 --- 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 @@ -32,10 +32,15 @@ export class AngularImageEditorLauncher implements DotImageEditorLauncher { */ open(params: ImageEditorOpenParams): Observable { const ref = this.#dialogService.open(DotImageEditorComponent, { - header: undefined, + // 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, - width: 'min(92vw, 75rem)', - height: '90%', + // 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, 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 index 7fb029f6ba30..f919a488db1c 100644 --- 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 @@ -7,8 +7,9 @@ export type { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/imag /** * DI seam for launching the image editor from the binary field. * - * Providers swap implementations (Angular dialog, legacy Dojo bridge, or noop) - * without the consuming field knowing which editor surfaces the result. + * The Angular edit-content shell provides the dialog-based launcher. When the + * token is left unprovided, the binary field injects it as optional and hides + * the "edit image" action, so no fallback implementation is needed. */ 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 index 866a05f98f24..fcffb3c4e3d0 100644 --- 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 @@ -4,5 +4,3 @@ export { ImageEditorOpenParams } from './image-editor-launcher.token'; export { AngularImageEditorLauncher } from './angular-image-editor.launcher'; -export { LegacyDojoImageEditorLauncher } from './legacy-dojo-image-editor.launcher'; -export { NoopImageEditorLauncher } from './noop-image-editor.launcher'; diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts deleted file mode 100644 index 8d731434c8d8..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect } from '@jest/globals'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; -import { Subscription } from 'rxjs'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { ImageEditorOpenParams } from '@dotcms/image-editor'; - -import { LegacyDojoImageEditorLauncher } from './legacy-dojo-image-editor.launcher'; - -describe('LegacyDojoImageEditorLauncher', () => { - let spectator: SpectatorService; - let subscription: Subscription | undefined; - - const params: ImageEditorOpenParams = { - inode: 'inode-1', - tempId: 'temp-1', - variable: 'binaryField', - fieldName: 'binary' - }; - - const openEventName = `binaryField-open-image-editor-${params.variable}`; - const tempEventName = `binaryField-tempfile-${params.variable}`; - const closeEventName = `binaryField-close-image-editor-${params.variable}`; - - const createService = createServiceFactory(LegacyDojoImageEditorLauncher); - - beforeEach(() => { - spectator = createService(); - }); - - afterEach(() => { - subscription?.unsubscribe(); - }); - - it('should report itself as available', () => { - expect(spectator.service.isAvailable()).toBe(true); - }); - - it('should dispatch the open event with the asset detail', () => { - const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); - - subscription = spectator.service.open(params).subscribe(); - - expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: openEventName })); - const openEvent = dispatchSpy.mock.calls - .map(([event]) => event) - .find( - (event): event is CustomEvent => - event instanceof CustomEvent && event.type === openEventName - ); - expect(openEvent?.detail).toEqual({ - inode: 'inode-1', - tempId: 'temp-1', - variable: 'binaryField' - }); - }); - - it('should resolve the temp file when the tempfile event fires', () => { - const tempFile = { id: 'temp-123' } as DotCMSTempFile; - let result: DotCMSTempFile | null | undefined; - - subscription = spectator.service.open(params).subscribe((value) => (result = value)); - - document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); - - expect(result).toEqual(tempFile); - }); - - it('should resolve null when the close event fires', () => { - let emitted = false; - let result: DotCMSTempFile | null | undefined; - - subscription = spectator.service.open(params).subscribe((value) => { - emitted = true; - result = value; - }); - - document.dispatchEvent(new CustomEvent(closeEventName)); - - expect(emitted).toBe(true); - expect(result).toBeNull(); - }); - - it('should stop listening after the first emission', () => { - const tempFile = { id: 'temp-123' } as DotCMSTempFile; - const emissions: (DotCMSTempFile | null)[] = []; - - subscription = spectator.service.open(params).subscribe((value) => emissions.push(value)); - - document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); - document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); - document.dispatchEvent(new CustomEvent(closeEventName)); - - expect(emissions).toEqual([tempFile]); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts deleted file mode 100644 index 4ae01415bdde..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/legacy-dojo-image-editor.launcher.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Observable } from 'rxjs'; - -import { Injectable } from '@angular/core'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/image-editor'; - -/** Detail of the `binaryField-tempfile-{variable}` custom event. */ -interface TempFileEventDetail { - tempFile: DotCMSTempFile; -} - -/** - * Bridges the editor launcher contract to the existing legacy Dojo image editor. - * - * Reuses the established document `CustomEvent` channel: it dispatches - * `binaryField-open-image-editor-{variable}` to open the editor and resolves the - * result from `binaryField-tempfile-{variable}` (edited image) or - * `binaryField-close-image-editor-{variable}` (cancelled). - */ -@Injectable() -export class LegacyDojoImageEditorLauncher implements DotImageEditorLauncher { - isAvailable(): boolean { - return true; - } - - /** - * Opens the legacy image editor for the given asset. - * - * @param params - Identifiers and metadata of the asset to edit - * @returns Emits the edited temp file, or `null` if the user cancelled - */ - open(params: ImageEditorOpenParams): Observable { - const { inode, tempId, variable } = params; - const tempFileEventName = `binaryField-tempfile-${variable}`; - const closeEventName = `binaryField-close-image-editor-${variable}`; - - return new Observable((subscriber) => { - const handleTempFile = (event: Event): void => { - const { detail } = event as CustomEvent; - subscriber.next(detail?.tempFile ?? null); - subscriber.complete(); - }; - - const handleClose = (): void => { - subscriber.next(null); - subscriber.complete(); - }; - - document.addEventListener(tempFileEventName, handleTempFile); - document.addEventListener(closeEventName, handleClose); - - document.dispatchEvent( - new CustomEvent(`binaryField-open-image-editor-${variable}`, { - detail: { inode, tempId, variable } - }) - ); - - return () => { - document.removeEventListener(tempFileEventName, handleTempFile); - document.removeEventListener(closeEventName, handleClose); - }; - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts deleted file mode 100644 index 13faec6c1544..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect } from '@jest/globals'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - -import { NoopImageEditorLauncher } from './noop-image-editor.launcher'; - -describe('NoopImageEditorLauncher', () => { - let spectator: SpectatorService; - - const createService = createServiceFactory(NoopImageEditorLauncher); - - beforeEach(() => { - spectator = createService(); - }); - - it('should report itself as unavailable', () => { - expect(spectator.service.isAvailable()).toBe(false); - }); - - it('should emit null when opened', () => { - let result: DotCMSTempFile | null | undefined; - - spectator.service.open().subscribe((value) => (result = value)); - - expect(result).toBeNull(); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts deleted file mode 100644 index 0e5d0d21dfce..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/noop-image-editor.launcher.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Observable, of } from 'rxjs'; - -import { Injectable } from '@angular/core'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DotImageEditorLauncher } from '@dotcms/image-editor'; - -/** - * Inert launcher used when no image editor is available in the environment. - * - * Reports itself unavailable and never opens an editor, so the binary field can - * hide the "edit image" affordance instead of branching on a missing provider. - */ -@Injectable() -export class NoopImageEditorLauncher implements DotImageEditorLauncher { - isAvailable(): boolean { - return false; - } - - open(): Observable { - return of(null); - } -} 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 index 6a0000555341..8ebcf4218e6d 100644 --- 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 @@ -1,12 +1,12 @@
- + + [title]="store.previewUrl()" + data-testid="image-editor-address-field"> + {{ store.previewUrl() }} +
+ + + 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 index 44c9cde2efda..39502801d31a 100644 --- 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 @@ -17,8 +17,12 @@ } .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; - display: block; + // 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 { @@ -29,59 +33,100 @@ align-items: center; justify-content: center; overflow: hidden; + padding: 1.5rem; +} + +// Bottom band mirroring `.canvas__address-bar`: same dark background and padding, +// always present so the stage has symmetric top and bottom bands. Hosts the +// active tool's actions, right-aligned. +.canvas__footer { + flex: 0 0 auto; + // Match the address bar's band height (top/bottom symmetry). + min-height: 3.5rem; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background-color: rgba(28, 30, 38, 0.92); + color: var(--surface-0, #ffffff); } .canvas__tool-rail { position: absolute; - top: 1rem; + top: 50%; left: 1rem; + transform: translateY(-50%); z-index: 2; + // Hidden until the user hovers the canvas (or tabs into a tool, for keyboard a11y). + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +.canvas__viewport:hover .canvas__tool-rail, +.canvas__tool-rail:focus-within { + opacity: 1; + pointer-events: auto; +} + +@media (prefers-reduced-motion: reduce) { + .canvas__tool-rail { + transition: none; + } } .canvas__stage { position: relative; - display: flex; - align-items: center; - justify-content: center; - max-width: 100%; - max-height: 100%; + // 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; } +// 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 { - display: block; + 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; } -// Stack the two layers so the pending image crossfades over the displayed one. +// Dim the current frame while a new preview renders so loading reads clearly. +.canvas__img--loading { + opacity: 0.35; +} + +// The incoming frame sits above the current one until it is promoted. .canvas__img--pending { - position: absolute; - inset: 0; - margin: auto; - opacity: 1; - transition: opacity 200ms ease-in-out; + z-index: 1; } .canvas__skeleton { display: block; } +// Centered, non-blocking loading indicator over the dimmed image. .canvas__loading { position: absolute; - top: 1rem; - right: 1rem; + inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; - padding: 0.5rem; - border-radius: 0.75rem; - background-color: rgba(28, 30, 38, 0.92); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + pointer-events: none; } .canvas__error { 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 index 8bc71d872149..36735f0eac3a 100644 --- 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 @@ -187,6 +187,53 @@ describe('DotImageEditorCanvasComponent', () => { expect(dispatchedEvent('retryRequested')).toBeDefined(); }); + describe('footer band', () => { + it('should render no action buttons when the move tool is active', () => { + activeTool.set('move'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-canvas-footer'))).toExist(); + expect(spectator.query(byTestId('image-editor-crop-apply-btn'))).not.toExist(); + expect(spectator.query(byTestId('image-editor-crop-cancel-btn'))).not.toExist(); + expect(spectator.query(byTestId('image-editor-focal-set-btn'))).not.toExist(); + expect(spectator.query(byTestId('image-editor-focal-cancel-btn'))).not.toExist(); + }); + + it('should invoke the crop overlay apply/cancel from the footer 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 invoke the focal overlay set/cancel from the footer when placing a focal point', () => { + activeTool.set('focal'); + spectator.detectChanges(); + + const focalOverlay = spectator.query(DotImageEditorFocalOverlayComponent)!; + const setSpy = jest.spyOn(focalOverlay, 'setFocalPoint'); + const cancelSpy = jest.spyOn(focalOverlay, 'cancelFocalPoint'); + + const setBtn = spectator.query(byTestId('image-editor-focal-set-btn')); + spectator.click(setBtn!.querySelector('button')!); + expect(setSpy).toHaveBeenCalled(); + + const cancelBtn = spectator.query(byTestId('image-editor-focal-cancel-btn')); + spectator.click(cancelBtn!.querySelector('button')!); + expect(cancelSpy).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]) => 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 index 39f699090b99..83f37313b0fe 100644 --- 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 @@ -38,11 +38,13 @@ const ZOOM_DEFAULT = 100; /** * Dark stage that renders the live image preview at the center of the editor. - * Hosts the address sub-bar, the floating tool rail and the crop/focal overlays, - * and 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 overlays), and the display-only zoom level. Preview loading - * outcomes are reported back to the store via {@link imageEditorLifecycleEvents}. + * Hosts the top address sub-bar, the floating tool rail, the crop/focal overlays, + * and a persistent bottom footer band that mirrors the address bar and surfaces + * the active tool's actions (apply/cancel for crop, set/cancel for focal). 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 overlays), and the display-only zoom level. Preview loading outcomes are + * reported back to the store via {@link imageEditorLifecycleEvents}. */ @Component({ selector: 'dot-image-editor-canvas', @@ -70,6 +72,11 @@ export class DotImageEditorCanvasComponent { /** 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); + /** The focal overlay, so the footer can set or cancel the active focal point. */ + protected readonly focalOverlay = viewChild(DotImageEditorFocalOverlayComponent); + /** URL of the last successfully loaded preview, shown on the bottom layer. */ protected readonly displayedUrl = signal(''); @@ -131,6 +138,26 @@ export class DotImageEditorCanvasComponent { 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(); + } + + /** Sets the active focal point via the focal overlay from the footer action. */ + protected setFocalPoint(): void { + this.focalOverlay()?.setFocalPoint(); + } + + /** Cancels the active focal point via the focal overlay from the footer action. */ + protected cancelFocalPoint(): void { + this.focalOverlay()?.cancelFocalPoint(); + } + /** Increases the zoom by one step, clamped to the maximum. */ protected zoomIn(): void { this.zoomLevel.update((level) => Math.min(ZOOM_MAX, level + ZOOM_STEP)); 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 index c8fff93a56ea..7d9a6438af6a 100644 --- 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 @@ -26,18 +26,5 @@ [attr.data-testid]="'image-editor-crop-handle-' + 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 index 5da90330d8c6..67ad8982be19 100644 --- 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 @@ -121,18 +121,3 @@ cursor: ew-resize; } } - -.crop-pill { - position: absolute; - bottom: 1rem; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.5rem; - border-radius: 9999px; - background-color: var(--surface-900, #1f2937); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - pointer-events: auto; -} 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 index cf69f42a8e71..3942cbdf342b 100644 --- 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 @@ -48,9 +48,8 @@ describe('DotImageEditorCropOverlayComponent', () => { }); }); - it('should dispatch cropApplied with a natural-pixel rect when applying', () => { - const applyBtn = spectator.query(byTestId('image-editor-crop-apply-btn')); - spectator.click(applyBtn!.querySelector('button')!); + 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. @@ -62,9 +61,8 @@ describe('DotImageEditorCropOverlayComponent', () => { ); }); - it('should dispatch cropCancelled when cancelling', () => { - const cancelBtn = spectator.query(byTestId('image-editor-crop-cancel-btn')); - spectator.click(cancelBtn!.querySelector('button')!); + it('should dispatch cropCancelled when cancelCrop is called', () => { + spectator.component.cancelCrop(); expect(dispatcher.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: expect.stringContaining('cropCancelled') }), @@ -96,6 +94,43 @@ describe('DotImageEditorCropOverlayComponent', () => { 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 stop propagation and dispatch cropCancelled on Escape', () => { const event = new KeyboardEvent('keydown', { key: 'Escape' }); const stopSpy = jest.spyOn(event, 'stopPropagation'); 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 index b928df6a7a88..2da2c8aecd9a 100644 --- 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 @@ -11,8 +11,6 @@ import { signal } from '@angular/core'; -import { ButtonModule } from 'primeng/button'; - import { DotMessagePipe } from '@dotcms/ui'; import { imageEditorOverlayEnterLeave } from '../../animations/image-editor.animations'; @@ -60,7 +58,7 @@ const HANDLES: readonly HandlePosition[] = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl @Component({ selector: 'dot-image-editor-crop-overlay', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DotMessagePipe], + imports: [DotMessagePipe], templateUrl: './dot-image-editor-crop-overlay.component.html', styleUrl: './dot-image-editor-crop-overlay.component.scss', animations: [imageEditorOverlayEnterLeave()] @@ -122,8 +120,8 @@ export class DotImageEditorCropOverlayComponent { event.stopPropagation(); const start = this.cropRect(); - this.#trackPointer(event, (dx, dy) => { - this.#resize(start, position, dx, dy); + this.#trackPointer(event, (dx, dy, shiftKey) => { + this.#resize(start, position, dx, dy, shiftKey); }); } @@ -151,15 +149,19 @@ export class DotImageEditorCropOverlayComponent { break; case 'Enter': event.preventDefault(); - this.apply(); + this.applyCrop(); break; default: break; } } - /** Applies the crop by converting the selection to natural image pixels. */ - protected apply(): void { + /** + * 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) { @@ -181,8 +183,11 @@ export class DotImageEditorCropOverlayComponent { }); } - /** Cancels cropping and restores the move tool. */ - protected cancel(): void { + /** + * 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(); } @@ -197,7 +202,7 @@ export class DotImageEditorCropOverlayComponent { } event.stopPropagation(); - this.cancel(); + this.cancelCrop(); } /** Moves the box to a new local origin, clamped within the rendered image. */ @@ -216,14 +221,34 @@ export class DotImageEditorCropOverlayComponent { }); } - /** Resizes the box from a handle, constrained within the rendered image. */ - #resize(start: LocalRect, position: HandlePosition, dx: number, dy: number): void { + /** + * Resizes the box from a handle, constrained within the rendered image. + * Holding Shift while dragging a corner locks the selection to its starting + * aspect ratio (the common "shift to keep proportions" behavior); edge handles + * and unmodified drags remain free-form. + */ + #resize( + start: LocalRect, + position: HandlePosition, + dx: number, + dy: number, + lockAspect = 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; + + if (lockAspect && isCorner && start.height > 0) { + this.cropRect.set(this.#resizeLockedAspect(start, position, dx, dy, rect)); + + return; + } + let { x, y, width, height } = start; const right = start.x + start.width; const bottom = start.y + start.height; @@ -249,13 +274,78 @@ export class DotImageEditorCropOverlayComponent { this.cropRect.set({ x, y, width, height }); } - /** Tracks pointer movement until release, reporting the delta to `onMove`. */ - #trackPointer(start: PointerEvent, onMove: (dx: number, dy: number) => void): void { + /** + * Corner resize that preserves the selection's starting 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 + ): LocalRect { + const aspect = start.width / start.height; + + // 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). + */ + #trackPointer( + start: PointerEvent, + onMove: (dx: number, dy: number, shiftKey: boolean) => void + ): void { const originX = start.clientX; const originY = start.clientY; const move = (event: PointerEvent) => - onMove(event.clientX - originX, event.clientY - originY); + onMove(event.clientX - originX, event.clientY - originY, event.shiftKey); const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('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 index 1792e10f4176..06225acce7bd 100644 --- 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 @@ -16,18 +16,5 @@ (pointerdown)="onSurfacePointerDown($event)" (keydown)="onMarkerKeydown($event)" data-testid="image-editor-focal-marker"> - -
- - -
} 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 index c60fc20d940a..da46f444d3b8 100644 --- 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 @@ -46,18 +46,3 @@ border-color: var(--primary-color, #426bf0); } } - -.focal-pill { - position: absolute; - bottom: 1rem; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.5rem; - border-radius: 9999px; - background-color: var(--surface-900, #1f2937); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - pointer-events: auto; -} 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 index 3f915ad7a789..8128d3560891 100644 --- 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 @@ -42,9 +42,8 @@ describe('DotImageEditorFocalOverlayComponent', () => { expect(spectator.query(byTestId('image-editor-focal-marker'))).toExist(); }); - it('should dispatch focalPointSet with normalized 0..1 coordinates when setting', () => { - const setBtn = spectator.query(byTestId('image-editor-focal-set-btn')); - spectator.click(setBtn!.querySelector('button')!); + 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. @@ -60,9 +59,8 @@ describe('DotImageEditorFocalOverlayComponent', () => { expect(y).toBeLessThanOrEqual(1); }); - it('should dispatch focalPointCleared when cancelling', () => { - const cancelBtn = spectator.query(byTestId('image-editor-focal-cancel-btn')); - spectator.click(cancelBtn!.querySelector('button')!); + it('should dispatch focalPointCleared when cancelFocalPoint is called', () => { + spectator.component.cancelFocalPoint(); expect(dispatchedEvent('focalPointCleared')).toBeDefined(); }); 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 index e9e836c33d56..a01f52d2ef20 100644 --- 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 @@ -12,8 +12,6 @@ import { signal } from '@angular/core'; -import { ButtonModule } from 'primeng/button'; - import { DotMessagePipe } from '@dotcms/ui'; import { focalPointPop } from '../../animations/image-editor.animations'; @@ -50,7 +48,7 @@ const NUDGE_STEP_LARGE = 0.05; @Component({ selector: 'dot-image-editor-focal-overlay', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DotMessagePipe], + imports: [DotMessagePipe], templateUrl: './dot-image-editor-focal-overlay.component.html', styleUrl: './dot-image-editor-focal-overlay.component.scss', animations: [focalPointPop()] @@ -122,21 +120,28 @@ export class DotImageEditorFocalOverlayComponent { break; case 'Enter': event.preventDefault(); - this.set(); + this.setFocalPoint(); break; default: break; } } - /** Confirms the focal point, dispatching the normalized 0..1 coordinates. */ - protected set(): void { + /** + * Confirms the focal point, dispatching the normalized 0..1 coordinates. + * Invoked by the canvas footer's "Set focal point" action and by the Enter + * key while the marker is focused. + */ + setFocalPoint(): void { const { x, y } = this.point(); this.#dispatch.focalPointSet({ x: clamp(x, 0, 1), y: clamp(y, 0, 1) }); } - /** Cancels focal placement and restores the move tool. */ - protected cancel(): void { + /** + * Cancels focal placement and restores the move tool. Invoked by the canvas + * footer's "Cancel" action and by the Escape key. + */ + cancelFocalPoint(): void { this.#dispatch.focalPointCleared(); } @@ -151,7 +156,7 @@ export class DotImageEditorFocalOverlayComponent { } event.stopPropagation(); - this.cancel(); + this.cancelFocalPoint(); } /** Converts a client-space pointer position into a normalized focal point. */ 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 index 45bb3833fd56..d501ac3bb668 100644 --- 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 @@ -2,9 +2,18 @@
- {{ brightness() }} +
- {{ hue() }} +
- {{ saturation() }} +
+ +
+ + + {{ 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.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 index 87ac6f6852b7..0c56e98ac8d6 100644 --- 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 @@ -26,6 +26,7 @@ describe('DotImageEditorFileInfoPanelComponent', () => { let dispatcher: Dispatcher; const fileInfo = signal(FILE_INFO); + const assetContext = signal({ naturalWidth: 0, naturalHeight: 0 }); const createComponent = createComponentFactory({ component: DotImageEditorFileInfoPanelComponent, @@ -33,11 +34,12 @@ describe('DotImageEditorFileInfoPanelComponent', () => { provideNoopAnimations(), mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) ], - componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { fileInfo })] + 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'); @@ -90,6 +92,23 @@ describe('DotImageEditorFileInfoPanelComponent', () => { }); }); + 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 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 index d3274b2b61d8..3dc03598248e 100644 --- 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 @@ -56,6 +56,13 @@ export class DotImageEditorFileInfoPanelComponent { /** 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); 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 index e0fe7dfe18c4..6fb75a245eae 100644 --- 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 @@ -3,10 +3,7 @@
    @for (entry of store.appliedEdits(); track entry.id) {
  • - - {{ 'edit.content.image-editor.history.category.' + entry.category | dm }} - {{ entry.label }} - + {{ entry.label }} } @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 index 519a22c88c3f..58040d9a3339 100644 --- 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 @@ -22,11 +22,7 @@ .ie-history__label { overflow: hidden; + font-size: 0.8125rem; text-overflow: ellipsis; white-space: nowrap; } - -.ie-history__empty { - margin: 0; - color: var(--text-color-secondary); -} 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 index d4273d27e3ec..452982e99775 100644 --- 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 @@ -2,6 +2,8 @@ [multiple]="true" [value]="openPanels()" (valueChange)="onOpenPanelsChange($event)" + expandIcon=" " + collapseIcon=" " transitionOptions="200ms cubic-bezier(0.86, 0, 0.07, 1)"> @@ -17,6 +19,7 @@ {{ 'edit.content.image-editor.panel.adjust.subtitle' | dm }} + @@ -38,6 +41,7 @@ {{ 'edit.content.image-editor.panel.transform.subtitle' | dm }} + @@ -59,6 +63,7 @@ {{ 'edit.content.image-editor.panel.fileinfo.subtitle' | dm }} + @@ -80,6 +85,7 @@ {{ '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 index b4c2500619f4..ba8f51d8dcc6 100644 --- 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 @@ -8,22 +8,26 @@ // 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 button: density padding + subtle hover, matching `.iem-acc-head`. + // 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; } - .p-accordionheader:hover { - background: var(--p-surface-50, #f8fafc); - } - // Body padding on all four sides (the sub-panels carry none of their own). .p-accordioncontent-content { - padding: 1rem; + padding: 1rem 1.5rem; } - // Per-section divider, last one removed (`.iem-acc` border-bottom). + // Section divider only, last one removed. .p-accordionpanel { border-bottom: 1px solid var(--p-surface-100, #f1f5f9); } @@ -32,10 +36,17 @@ 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. @@ -83,11 +94,53 @@ 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%; 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 index 87b3d276468d..58eb510e103b 100644 --- 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 @@ -2,26 +2,50 @@
    - {{ store.transform().scale }}% + + + % +
    - {{ store.transform().rotateDeg }}° + + + ° +
    @@ -32,12 +56,16 @@ [ngModel]="store.transform().flipH" [onLabel]="'edit.content.image-editor.transform.flip.horizontal' | dm" [offLabel]="'edit.content.image-editor.transform.flip.horizontal' | dm" + onIcon="pi pi-arrows-h" + offIcon="pi pi-arrows-h" data-testid="image-editor-flip-horizontal-btn" (onChange)="flipHToggled()" /> 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 index 8f3786a6da69..5d8ec170d151 100644 --- 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 @@ -16,9 +16,46 @@ 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 { 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 index dad2e5927771..f8c3c2feacf4 100644 --- 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 @@ -117,6 +117,35 @@ describe('DotImageEditorTransformPanelComponent', () => { ); }); + 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( 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 index 52aa7ba1ded1..6d0cc8387519 100644 --- 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 @@ -1,23 +1,32 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { InputNumberModule } from 'primeng/inputnumber'; -import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; +import { SliderChangeEvent, SliderModule, SliderSlideEndEvent } from 'primeng/slider'; import { ToggleButtonModule } from 'primeng/togglebutton'; import { DotMessagePipe } from '@dotcms/ui'; import { imageEditorTransformEvents } from '../../../store/image-editor.events'; import { ImageEditorStore } from '../../../store/image-editor.store'; +import { clamp } from '../../../utils/dimensions.util'; + +/** Inclusive range of the scale (%) control, matching its slider. */ +const SCALE_MIN = 1; +const SCALE_MAX = 400; +/** Inclusive range of the rotate (°) control, matching its slider. */ +const ROTATE_MIN = -180; +const ROTATE_MAX = 180; /** * 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`, letting the store own debouncing of the resulting preview. + * 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', @@ -33,21 +42,60 @@ export class DotImageEditorTransformPanelComponent { /** 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(), SCALE_MIN, 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(), ROTATE_MIN, ROTATE_MAX); + this.rotate.set(value); + this.dispatch.rotateChanged(value); + } + /** Dispatches a horizontal flip toggle. */ protected flipHToggled(): void { this.dispatch.flipHToggled(); @@ -85,4 +133,23 @@ export class DotImageEditorTransformPanelComponent { private isBelowMinimum(value: number | null): boolean { return value != null && value < 1; } + + /** Narrows the slider's number-or-range value to a single number. */ + private 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. + */ + private 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.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss index aee5d15d71b7..6b9de7f58daf 100644 --- 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 @@ -16,7 +16,6 @@ "footer footer"; height: 100%; overflow: hidden; - background-color: #ffffff; } .image-editor__header { 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 index eff2b658bb2a..82db7a0840a1 100644 --- 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 @@ -51,7 +51,9 @@ export const imageEditorToolEvents = eventGroup({ cropApplied: type(), cropCancelled: type(), focalPointSet: type<{ x: number; y: number }>(), - focalPointCleared: type() + focalPointCleared: type(), + // A crop to the given aspect ratio, centered on the current focal point. + aspectCropApplied: type<{ aspect: number; label: string }>() } }); 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 index c7b9409ff60a..b4af43bee938 100644 --- 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 @@ -39,6 +39,8 @@ export interface ImageEditorState { activeTool: ActiveTool; /** Loading lifecycle of the preview image. */ previewStatus: PreviewStatus; + /** Consecutive silent retries of the current failing preview (reset on success). */ + previewRetries: number; /** Lifecycle of the current save / save-as operation. */ saveStatus: SaveStatus; /** Temp file produced by the last successful save, or `null`. */ @@ -140,6 +142,7 @@ export const initialImageEditorState: ImageEditorState = { zoom: initialZoomState, activeTool: 'move', previewStatus: 'idle', + previewRetries: 0, saveStatus: 'idle', savedTempFile: null, error: null, 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 index e065c6b3a044..127982792da1 100644 --- 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 @@ -216,6 +216,21 @@ describe('ImageEditorStore', () => { expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); }); + + it('should apply an aspect crop centered on the focal point', () => { + lifecycle.assetRequested(OPEN_PARAMS); + lifecycle.assetLoaded({ naturalWidth: 1000, naturalHeight: 800, originalBytes: 5000 }); + tool.focalPointSet({ x: 0.8, y: 0.5 }); + + tool.aspectCropApplied({ aspect: 1, label: '1:1' }); + + // 1:1 in a 1000×800 image → an 800×800 region; centered on x=0.8 (=800px) + // it wants x=400 but clamps to the right edge (1000−800=200), y centers at 0. + expect(store.crop()).toEqual({ x: 200, y: 0, w: 800, h: 800, active: true, aspect: 1 }); + expect(store.transform().scale).toBe(100); + expect(store.activeTool()).toBe('move'); + expect(store.history().at(-1)?.category).toBe('crop'); + }); }); describe('history', () => { @@ -250,13 +265,27 @@ describe('ImageEditorStore', () => { expect(store.historyIndex()).toBe(0); expect(store.history()[0].category).toBe('rotate'); expect(store.history()[0].label).toBe('Rotate 45°'); - // Slices restore from the new head's snapshot, which carries the - // brightness that was active when the rotation was applied. + // 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(20); + 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); @@ -341,19 +370,33 @@ describe('ImageEditorStore', () => { }); describe('preview lifecycle', () => { - it('previewLoaded sets status loaded and clears error', () => { + it('silently retries the first preview failure before surfacing the error', () => { + const bustBefore = store.cacheBust(); + + // First failure: stay loading and bump the cache-bust for a fresh attempt. + lifecycle.previewErrored(); + expect(store.previewStatus()).toBe('loading'); + expect(store.cacheBust()).toBe(bustBefore + 1); + expect(store.error()).toBeNull(); + + // Second consecutive failure: surface 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', () => { + lifecycle.previewErrored(); lifecycle.previewErrored(); expect(store.previewStatus()).toBe('error'); lifecycle.previewLoaded(); expect(store.previewStatus()).toBe('loaded'); expect(store.error()).toBeNull(); - }); - it('previewErrored sets the error status', () => { + // Budget restored: a later single failure retries again rather than erroring. lifecycle.previewErrored(); - expect(store.previewStatus()).toBe('error'); - expect(store.error()).toBe('Failed to render preview'); + expect(store.previewStatus()).toBe('loading'); }); }); 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 index 61c21bf1605f..d48c391dced8 100644 --- 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 @@ -133,6 +133,116 @@ function slicesAtIndex(history: ImageEditorHistoryEntry[], index: number): Edita : restoreSlices(history[index].snapshot); } +/** The editable slices, in snapshot order. */ +const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; + +/** A field-level patch over the editable slices (only the fields that changed). */ +type SlicePatch = { + adjust?: Partial; + transform?: Partial; + crop?: Partial; + focalPoint?: Partial; + fileInfo?: Partial; +}; + +/** + * 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 }, + focalPoint: { ...base.focalPoint, ...patch.focalPoint }, + 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. + */ +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 }; + }); +} + +/** + * The largest crop of the given aspect ratio that fits the natural image, + * centered on the active focal point (or the image center when none is set) and + * clamped to the image bounds. This is what makes the focal point visible: the + * kept region follows the focal point instead of the center. + */ +function focalCenteredCrop(aspect: number, state: ImageEditorState): CropState { + const { naturalWidth, naturalHeight } = state.assetContext; + const naturalAspect = naturalWidth / naturalHeight; + + let w: number; + let h: number; + if (aspect > naturalAspect) { + w = naturalWidth; + h = Math.round(naturalWidth / aspect); + } else { + h = naturalHeight; + w = Math.round(naturalHeight * aspect); + } + + const fx = state.focalPoint.active ? state.focalPoint.x : 0.5; + const fy = state.focalPoint.active ? state.focalPoint.y : 0.5; + const x = clamp(Math.round(fx * naturalWidth - w / 2), 0, naturalWidth - w); + const y = clamp(Math.round(fy * naturalHeight - h / 2), 0, naturalHeight - h); + + return { x, y, w, h, active: true, aspect }; +} + /** Produces the asset context for a freshly requested asset. */ function contextFromParams(params: ImageEditorOpenParams): ImageEditorAssetContext { // Use `||` (not `??`) so an empty-string tempId falls through to the inode — callers @@ -165,6 +275,9 @@ const COMPRESSION_LABELS: Record = { webp: 'WebP' }; +/** Times a failed preview is silently retried before the error UI is shown. */ +const AUTO_PREVIEW_RETRY_LIMIT = 1; + /** * NgRx SignalStore for the image editor. Built entirely on the events API: * synchronous state transitions are folded by `withReducer` — one block per event @@ -294,13 +407,11 @@ export const ImageEditorStore = signalStore( activeTool: 'move' as const })), on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => { + // No preview reload: the focal point doesn't change the rendered image + // on its own — it's a saved anchor consumed by the aspect crop and + // persisted on save. Just record it (and a coalesced history entry). const focalPoint: FocalPointState = { x: payload.x, y: payload.y, active: true }; - const next: ImageEditorState = { - ...state, - focalPoint, - previewStatus: 'loading', - cacheBust: state.cacheBust + 1 - }; + const next: ImageEditorState = { ...state, focalPoint }; return { ...next, @@ -314,16 +425,48 @@ export const ImageEditorStore = signalStore( }), on(imageEditorToolEvents.focalPointCleared, (_event, state) => ({ ...state, - focalPoint: initialFocalPointState, - previewStatus: 'loading' as const, - cacheBust: state.cacheBust + 1 - })) + focalPoint: initialFocalPointState + })), + on(imageEditorToolEvents.aspectCropApplied, ({ payload }, state) => { + if (!state.assetContext.naturalWidth || !state.assetContext.naturalHeight) { + return state; + } + + const crop = focalCenteredCrop(payload.aspect, state); + // Cropping is mutually exclusive with resize, and returns to the move tool. + 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 ${payload.label}`, editableSlicesOf(next)) + }; + }) ), // Applied-edits history: remove / undo / redo / reset. withReducer( on(imageEditorHistoryEvents.editRemoved, ({ payload }, state) => { const removedIdx = state.history.findIndex((entry) => entry.id === payload.id); - const history = state.history.filter((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 = @@ -400,15 +543,33 @@ export const ImageEditorStore = signalStore( on(imageEditorLifecycleEvents.previewLoaded, (_event, state) => ({ ...state, previewStatus: 'loaded' as const, + previewRetries: 0, error: null })), - on(imageEditorLifecycleEvents.previewErrored, (_event, state) => ({ - ...state, - previewStatus: 'error' as const, - error: 'Failed to render preview' - })), + // 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 })), @@ -442,7 +603,8 @@ export const ImageEditorStore = signalStore( transform: store.transform(), crop: store.crop(), fileInfo: store.fileInfo(), - focalPoint: store.focalPoint() + naturalWidth: store.assetContext().naturalWidth, + naturalHeight: store.assetContext().naturalHeight }) ); 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 index ca489bd5d1ce..81a923bd63c2 100644 --- 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 @@ -5,7 +5,6 @@ import { CompressionMode, CropState, FileInfoState, - FocalPointState, ImageEditorAssetContext, TransformState } from '../models/image-editor.models'; @@ -36,12 +35,6 @@ const baseCrop: CropState = { aspect: null }; -const baseFocalPoint: FocalPointState = { - x: 0.5, - y: 0.5, - active: false -}; - const baseFileInfo: FileInfoState = { compression: 'none', quality: 65, @@ -70,7 +63,8 @@ function chain( transform: Partial; crop: Partial; fileInfo: Partial; - focalPoint: Partial; + naturalWidth: number; + naturalHeight: number; }> = {} ) { return buildFilterChain({ @@ -78,7 +72,8 @@ function chain( transform: { ...baseTransform, ...overrides.transform }, crop: { ...baseCrop, ...overrides.crop }, fileInfo: { ...baseFileInfo, ...overrides.fileInfo }, - focalPoint: { ...baseFocalPoint, ...overrides.focalPoint } + naturalWidth: overrides.naturalWidth ?? 1000, + naturalHeight: overrides.naturalHeight ?? 800 }); } @@ -147,16 +142,6 @@ describe('image-filter-url.builder', () => { const result = chain({ adjust: { brightness: 10 } }); expect(result).toEqual([{ name: 'Hsb', args: '/hsb_h/0.00/hsb_s/0.00/hsb_b/0.10' }]); }); - - it('builds a FocalPoint filter when active and off-center', () => { - const result = chain({ focalPoint: { active: true, x: 0.25, y: 0.75 } }); - expect(result).toEqual([{ name: 'FocalPoint', args: '/fp/0.25,0.75' }]); - }); - - it('omits FocalPoint when centered', () => { - const result = chain({ focalPoint: { active: true, x: 0.5, y: 0.5 } }); - expect(result).toEqual([]); - }); }); describe('buildFilterChain - resize removes crop', () => { @@ -176,6 +161,12 @@ describe('image-filter-url.builder', () => { }); 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', () => { 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 index 6fcb0f5d555f..25e436149d17 100644 --- 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 @@ -1,10 +1,11 @@ +import { computeResizeParams } from './dimensions.util'; + import { AdjustState, AppliedFilter, CompressionMode, CropState, FileInfoState, - FocalPointState, ImageEditorAssetContext, TransformState } from '../models/image-editor.models'; @@ -15,7 +16,10 @@ interface FilterChainInput { transform: TransformState; crop: CropState; fileInfo: FileInfoState; - focalPoint: FocalPointState; + /** Natural image width, needed to translate scale% into resize pixels. */ + naturalWidth: number; + /** Natural image height, needed to translate scale% into resize pixels. */ + naturalHeight: number; } /** @@ -57,23 +61,22 @@ function compressionFilter(mode: CompressionMode, quality: number): AppliedFilte * @returns The applied filters in the exact order they must be concatenated */ export function buildFilterChain(input: FilterChainInput): AppliedFilter[] { - const { adjust, transform, crop, fileInfo, focalPoint } = input; + const { adjust, transform, crop, fileInfo, naturalWidth, naturalHeight } = input; const filters: AppliedFilter[] = []; - const isResizing = - transform.outputWidth != null || transform.outputHeight != null || transform.scale !== 100; + // 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 }); - if (isResizing) { + if (resize.width || resize.height) { let args = ''; - if (transform.outputWidth != null) { - args += `/resize_w/${Math.round(transform.outputWidth)}`; - } - if (transform.outputHeight != null) { - args += `/resize_h/${Math.round(transform.outputHeight)}`; + if (resize.width) { + args += `/resize_w/${resize.width}`; } - if (args) { - filters.push({ name: 'Resize', args }); + if (resize.height) { + args += `/resize_h/${resize.height}`; } + filters.push({ name: 'Resize', args }); } else if (crop.active && crop.w > 0 && crop.h > 0) { const args = `/crop_w/${Math.round(crop.w)}` + @@ -108,9 +111,10 @@ export function buildFilterChain(input: FilterChainInput): AppliedFilter[] { filters.push({ name: 'Hsb', args }); } - if (focalPoint.active && !(focalPoint.x === 0.5 && focalPoint.y === 0.5)) { - filters.push({ name: 'FocalPoint', args: `/fp/${focalPoint.x},${focalPoint.y}` }); - } + // The focal point is intentionally NOT a preview filter: the dotCMS FocalPoint + // filter only writes metadata (no visible change), so emitting it just forces a + // pointless reload. It is persisted on save (persistFocalPoint) and consumed by + // the focal-centered aspect crop directly from state. const compression = compressionFilter(fileInfo.compression, fileInfo.quality); if (compression) { 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/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index ce16790aa3b4..4fa0ad7589b9 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6501,6 +6501,7 @@ edit.content.image-editor.crop.cancel=Cancel edit.content.image-editor.crop.box.aria=Crop region 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.marker.aria=Focal point marker edit.content.image-editor.panel.adjust.title=Adjust edit.content.image-editor.panel.adjust.subtitle=Color & light @@ -6529,19 +6530,13 @@ edit.content.image-editor.fileinfo.compression.jpeg=JPEG edit.content.image-editor.fileinfo.compression.webp=WebP 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.history.category.adjust=Adjust -edit.content.image-editor.history.category.crop=Crop -edit.content.image-editor.history.category.rotate=Rotate -edit.content.image-editor.history.category.flip=Flip -edit.content.image-editor.history.category.grayscale=Grayscale -edit.content.image-editor.history.category.compression=Compression -edit.content.image-editor.history.category.focal=Focal point 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. From 28e5ed9960d8edf8f0c6a8322bd9c271d4cabeae Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 11:10:23 -0400 Subject: [PATCH 04/29] fix(image-editor): render previews from verified blobs to stop truncated frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview pointed straight at /contentAsset/image/... and painted progressively, so a partially-generated server response (the server renders filters on the fly; the first request for a fresh URL races that generation) showed as a truncated band — and the browser still fired `load` because the file header carrying the dimensions arrived intact, so the decode()+dimensions guard could not catch it. A manual refresh fixed it by re-requesting the now cached, complete file. - Service: add loadPreviewImage(url) — GET the URL as a blob and return a local object URL only for a complete image. A stream truncated against Content-Length errors the request outright; an explicit length mismatch, an empty body, or an HTML/JSON error body (200 while still generating) is rejected. - Canvas: fetch each queued preview via loadPreviewImage (switchMap cancels a superseded in-flight request) and render the pending/displayed layers from the verified object URLs, so the can never paint half an image. Manage the object-URL lifecycle (promote on decode, revoke the replaced one, clean up on destroy). - Store: raise the silent preview-retry budget to 3 so a generation race resolves invisibly (mirroring a manual refresh) before the error UI is shown. Refs #36063 --- .../dot-image-editor-canvas.component.html | 10 +- .../dot-image-editor-canvas.component.spec.ts | 120 ++++++++++++++---- .../dot-image-editor-canvas.component.ts | 110 +++++++++++++--- .../services/dot-image-editor.service.spec.ts | 82 ++++++++++++ .../lib/services/dot-image-editor.service.ts | 44 +++++++ .../src/lib/store/image-editor.store.spec.ts | 30 +++-- .../src/lib/store/image-editor.store.ts | 9 +- 7 files changed, 343 insertions(+), 62 deletions(-) diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html index 56b0a626b1b2..0e1d48b0934c 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html @@ -23,20 +23,20 @@ #displayImg class="canvas__img canvas__img--displayed" [class.canvas__img--loading]="store.previewStatus() === 'loading'" - [src]="displayedUrl()" - [hidden]="!displayedUrl()" + [src]="displayedSrc()" + [hidden]="!displayedSrc()" alt="" (load)="onDisplayLoaded()" data-testid="image-editor-display-img" /> - - @if (pendingUrl(); as pending) { + + @if (pendingSrc(); as pending) { } 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 index 839b95538b6c..e22e0c0be590 100644 --- 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 @@ -1,6 +1,7 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { Dispatcher } from '@ngrx/signals/events'; import { MockComponent } from 'ng-mocks'; +import { Observable, of, throwError } from 'rxjs'; import { signal } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; @@ -10,6 +11,7 @@ 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'; @@ -19,6 +21,14 @@ import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/d 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 { @@ -32,6 +42,12 @@ Object.defineProperty(window, 'ResizeObserver', { 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. @@ -51,6 +67,7 @@ const flushDecode = () => new Promise((resolve) => setTimeout(resolve)); describe('DotImageEditorCanvasComponent', () => { let spectator: Spectator; let dispatcher: Dispatcher; + let service: jest.Mocked; const previewUrl = signal(PREVIEW_URL); const previewStatus = signal('idle'); @@ -63,7 +80,11 @@ describe('DotImageEditorCanvasComponent', () => { providers: [ provideNoopAnimations(), Dispatcher, - mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + 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, { @@ -100,15 +121,33 @@ describe('DotImageEditorCanvasComponent', () => { ] }); + /** + * 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(() => { 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'); }); @@ -125,52 +164,78 @@ describe('DotImageEditorCanvasComponent', () => { expect(spectator.query(byTestId('image-editor-loading'))).not.toExist(); }); - it('should keep the displayed image visible while loading (crossfade invariant)', () => { - // Seed a displayed frame, then begin loading the next preview. - previewUrl.set(PREVIEW_URL); - spectator.detectChanges(); + 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'); - spectator.detectChanges(); + 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 URL matches the preview so no pending layer remains. + // 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 promote the URL that loaded, not the live store URL, under rapid edits', async () => { - // The store has already advanced to a newer preview by the time the - // earlier pending image fires its load event. + 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); - const img = document.createElement('img'); - spectator.component['onPendingLoaded'](PREVIEW_URL, { target: img } as unknown as Event); + 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(); - // Layer A promotes the URL that actually loaded. const displayed = spectator.query(byTestId('image-editor-display-img')); - expect(displayed?.getAttribute('src')).toBe(PREVIEW_URL); - - // Layer A stays visible (crossfade invariant holds). - expect(displayed?.hasAttribute('hidden')).toBe(false); + expect(displayed?.getAttribute('src')).toBe(objectUrlFor(NEXT_PREVIEW_URL)); + expect(dispatchedEvent('previewLoaded')).toBeDefined(); + }); - // Layer B remains mounted for the newer URL the store advanced to. - const pending = spectator.query(byTestId('image-editor-pending-img')); - expect(pending?.getAttribute('src')).toBe(NEXT_PREVIEW_URL); + 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('previewLoaded')).toBeDefined(); + 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 load to the store (which owns the retry policy)', () => { + 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(); @@ -181,6 +246,7 @@ describe('DotImageEditorCanvasComponent', () => { new Error('decode failed') ); + settlePending(); spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); await flushDecode(); @@ -190,16 +256,20 @@ describe('DotImageEditorCanvasComponent', () => { 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, then advance the store to a new URL. + // Promote the current preview: the pending layer is released. spectator.dispatchFakeEvent(byTestId('image-editor-pending-img'), 'load'); await flushDecode(); - previewUrl.set(NEXT_PREVIEW_URL); 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(NEXT_PREVIEW_URL); + expect(pending?.getAttribute('src')).toBe(objectUrlFor(NEXT_PREVIEW_URL)); }); it('should show the error overlay and retry button when errored', () => { 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 index 67cbd01f2e73..fa0b1d7e2f70 100644 --- 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 @@ -1,4 +1,5 @@ import { injectDispatch } from '@ngrx/signals/events'; +import { EMPTY } from 'rxjs'; import { ChangeDetectionStrategy, @@ -10,13 +11,17 @@ import { signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ButtonModule } from 'primeng/button'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { SkeletonModule } from 'primeng/skeleton'; +import { catchError, map, switchMap } from 'rxjs/operators'; + import { DotMessagePipe } from '@dotcms/ui'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; import { imageEditorLifecycleEvents, imageEditorToolEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; import { DotImageEditorAddressBarComponent } from '../dot-image-editor-address-bar/dot-image-editor-address-bar.component'; @@ -67,6 +72,7 @@ export class DotImageEditorCanvasComponent { readonly #dispatch = injectDispatch(imageEditorLifecycleEvents); readonly #toolDispatch = injectDispatch(imageEditorToolEvents); readonly #destroyRef = inject(DestroyRef); + readonly #service = inject(DotImageEditorService); /** Aspect-ratio presets for the focal-point-centered crop, shown in the focal bar. */ protected readonly aspectPresets = [ @@ -88,12 +94,16 @@ export class DotImageEditorCanvasComponent { /** The focal overlay, so the footer can set or cancel the active focal point. */ protected readonly focalOverlay = viewChild(DotImageEditorFocalOverlayComponent); - /** URL of the last successfully loaded preview, shown on the bottom layer. */ + /** 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(''); + /** - * URL queued for loading on the top layer: the store's current preview when - * it differs from what is already displayed, otherwise empty. + * 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(); @@ -101,6 +111,15 @@ export class DotImageEditorCanvasComponent { 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); @@ -114,26 +133,60 @@ export class DotImageEditorCanvasComponent { #resizeObserver: ResizeObserver | null = null; constructor() { - this.#destroyRef.onDestroy(() => this.#resizeObserver?.disconnect()); + this.#destroyRef.onDestroy(() => { + this.#resizeObserver?.disconnect(); + 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); + }); } /** - * Promotes a freshly loaded pending image to the displayed layer and reports - * the successful preview to the store. The crossfade is keyed on URL identity - * so the visible frame is never blanked while the next one loads. - * - * Promotes the URL that actually finished loading — not the live store value, - * which may have advanced under rapid edits. When a newer preview already - * exists, the `pendingUrl` computed keeps Layer B mounted for that newer URL. - * @param loadedUrl - The URL of the pending image that finished loading + * 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(loadedUrl: string, event: Event): void { + protected onPendingLoaded(event: Event): void { + const pending = this.#pending; const img = event.target as HTMLImageElement; - // `load` already implies fetched + decoded, but `decode()` confirms the - // bitmap is paint-ready and rejects on a corrupt/partial image; we also - // require real dimensions. Any failure reports to the store, which owns - // the retry policy (silent retry, then the error UI). + // Guard against a stale load event after the pending blob was superseded. + if (!pending) { + return; + } + img.decode() .then(() => { if (!img.naturalWidth || !img.naturalHeight) { @@ -142,7 +195,14 @@ export class DotImageEditorCanvasComponent { return; } - this.displayedUrl.set(loadedUrl); + // 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(); }) @@ -158,6 +218,20 @@ export class DotImageEditorCanvasComponent { 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(); 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 index a2fc379651f4..f82ff0fb0c49 100644 --- 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 @@ -7,6 +7,9 @@ import { DotHttpErrorManagerService } from '@dotcms/data-access'; 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; @@ -69,6 +72,85 @@ describe('DotImageEditorService', () => { }); }); + 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('saveEditedImage', () => { const tempResponse = { id: 'temp_123', 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 index 475b794f8a9a..acaf78eba9dd 100644 --- 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 @@ -56,6 +56,50 @@ export class DotImageEditorService { ); } + /** + * 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); + }) + ); + } + /** * Persists the edited image to a temp file by hitting the filter URL with * the save tokens appended. 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 index 127982792da1..4a569664df74 100644 --- 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 @@ -370,24 +370,30 @@ describe('ImageEditorStore', () => { }); describe('preview lifecycle', () => { - it('silently retries the first preview failure before surfacing the error', () => { - const bustBefore = store.cacheBust(); - - // First failure: stay loading and bump the cache-bust for a fresh attempt. - lifecycle.previewErrored(); - expect(store.previewStatus()).toBe('loading'); - expect(store.cacheBust()).toBe(bustBefore + 1); - expect(store.error()).toBeNull(); - - // Second consecutive failure: surface the error. + 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', () => { - lifecycle.previewErrored(); - lifecycle.previewErrored(); + // 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(); 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 index d48c391dced8..ddbeb506fbd2 100644 --- 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 @@ -275,8 +275,13 @@ const COMPRESSION_LABELS: Record = { webp: 'WebP' }; -/** Times a failed preview is silently retried before the error UI is shown. */ -const AUTO_PREVIEW_RETRY_LIMIT = 1; +/** + * 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. + */ +const AUTO_PREVIEW_RETRY_LIMIT = 3; /** * NgRx SignalStore for the image editor. Built entirely on the events API: From d2c12b84a77ca6536cd342bce3397bc67c0e2fa2 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 12:13:35 -0400 Subject: [PATCH 05/29] fix(image-editor): zoom pan/fit, undo shortcut, crop-to-view + code-review fixes Functional fixes: - Undo/redo shortcut now listens on document:keydown (in a DynamicDialog focus usually sits outside the component, so a host-only keydown never fired) - Pan a zoomed-in image by dragging (grab cursor; move tool); fit and zoom-out to <=100% recenter - Switching to crop while zoomed captures the visible region, resets to fit and seeds the crop box to exactly what was framed (crop-to-current-view) Code-review fixes: - Drop the duplicate save-failure toast (service rethrows; the store is the single surface) - Zoom-aware overlay coordinates (divide getBoundingClientRect by the stage scale) so crop/focal markers don't drift at non-100% zoom - Remove dead focal-point hydration plumbing (loadAssetMeta never produced it) - Truncate the redo tail on a same-category edit after an undo - @HostListener -> host object (overlays + root), per ANGULAR_STANDARDS - onPendingLoaded re-checks #pending after decode() to avoid promoting a revoked object URL under rapid edits - a11y labels on adjust/quality sliders and the focal aspect group - private -> # in the adjust/transform panels; ImageRect moved to the models file - buildFilterChain @param no longer lists a non-existent focalPoint slice Refactor: - Extract the store's pure helpers (history coalesce/replay, focal-centered crop, context/patch builders, formatters) into image-editor.store-utils.ts; the store drops from 817 to 526 lines Refs #36063 --- .../dot-image-editor-canvas.component.html | 17 +- .../dot-image-editor-canvas.component.scss | 11 + .../dot-image-editor-canvas.component.ts | 177 +++++++++- ...dot-image-editor-crop-overlay.component.ts | 31 +- ...ot-image-editor-focal-overlay.component.ts | 14 +- ...t-image-editor-adjust-panel.component.html | 3 + ...dot-image-editor-adjust-panel.component.ts | 16 +- ...image-editor-fileinfo-panel.component.html | 1 + ...-image-editor-transform-panel.component.ts | 24 +- .../dot-image-editor.component.spec.ts | 14 +- .../dot-image-editor.component.ts | 8 +- .../src/lib/models/image-editor.models.ts | 8 + .../services/dot-image-editor.service.spec.ts | 17 +- .../lib/services/dot-image-editor.service.ts | 18 +- .../src/lib/store/image-editor.store-utils.ts | 330 ++++++++++++++++++ .../src/lib/store/image-editor.store.spec.ts | 17 + .../src/lib/store/image-editor.store.ts | 322 +---------------- .../src/lib/utils/image-filter-url.builder.ts | 2 +- .../WEB-INF/messages/Language.properties | 1 + 19 files changed, 627 insertions(+), 404 deletions(-) create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.store-utils.ts diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html index 0e1d48b0934c..e96a2b2a3a0b 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html @@ -9,7 +9,13 @@
    -
    +
    @if (store.previewStatus() === 'idle' && !displayedUrl()) { } - +
    @@ -84,7 +92,10 @@ data-testid="image-editor-crop-apply-btn" /> } @case ('focal') { -
    +
    @for (preset of aspectPresets; track preset.key) {
    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 index 6d0cc8387519..7b9229af1777 100644 --- 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 @@ -64,7 +64,7 @@ export class DotImageEditorTransformPanelComponent { /** Updates the optimistic scale field as the slider moves. */ protected onScaleChange(event: SliderChangeEvent): void { - this.scale.set(this.singleValue(event.value)); + this.scale.set(this.#singleValue(event.value)); } /** Dispatches the final scale value once the slider drag ends. */ @@ -74,14 +74,14 @@ export class DotImageEditorTransformPanelComponent { /** Commits a scale value typed into the inline number field. */ protected onScaleInput(event: Event): void { - const value = this.commitTypedValue(event, this.scale(), SCALE_MIN, SCALE_MAX); + const value = this.#commitTypedValue(event, this.scale(), SCALE_MIN, 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)); + this.rotate.set(this.#singleValue(event.value)); } /** Dispatches the final rotation value once the slider drag ends. */ @@ -91,7 +91,7 @@ export class DotImageEditorTransformPanelComponent { /** Commits a rotation value typed into the inline number field. */ protected onRotateInput(event: Event): void { - const value = this.commitTypedValue(event, this.rotate(), ROTATE_MIN, ROTATE_MAX); + const value = this.#commitTypedValue(event, this.rotate(), ROTATE_MIN, ROTATE_MAX); this.rotate.set(value); this.dispatch.rotateChanged(value); } @@ -108,34 +108,34 @@ export class DotImageEditorTransformPanelComponent { /** Dispatches a change to the explicit output width, guarding the minimum. */ protected outputWidthChanged(value: number | null): void { - this.widthError.set(this.isBelowMinimum(value)); + this.widthError.set(this.#isBelowMinimum(value)); this.dispatch.outputDimsChanged({ - width: this.toDimension(value), + width: this.#toDimension(value), height: this.store.transform().outputHeight }); } /** Dispatches a change to the explicit output height, guarding the minimum. */ protected outputHeightChanged(value: number | null): void { - this.heightError.set(this.isBelowMinimum(value)); + this.heightError.set(this.#isBelowMinimum(value)); this.dispatch.outputDimsChanged({ width: this.store.transform().outputWidth, - height: this.toDimension(value) + height: this.#toDimension(value) }); } /** Normalizes a dimension input to a positive integer, or `null` when cleared/invalid. */ - private toDimension(value: number | null): number | null { + #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. */ - private isBelowMinimum(value: number | null): boolean { + #isBelowMinimum(value: number | null): boolean { return value != null && value < 1; } /** Narrows the slider's number-or-range value to a single number. */ - private singleValue(value: SliderChangeEvent['value']): number { + #singleValue(value: SliderChangeEvent['value']): number { return Array.isArray(value) ? (value[0] ?? 0) : (value ?? 0); } @@ -144,7 +144,7 @@ export class DotImageEditorTransformPanelComponent { * 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. */ - private commitTypedValue(event: Event, fallback: number, min: number, max: number): number { + #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); 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 index bec7f14cbe7a..2f17c3905c72 100644 --- 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 @@ -182,9 +182,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should dispatch undoRequested on Ctrl/Cmd+Z when undo is available', () => { canUndo.set(true); - spectator.element.dispatchEvent( - new KeyboardEvent('keydown', { key: 'z', ctrlKey: true }) - ); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })); expect(dispatcher.dispatch).toHaveBeenCalledWith( imageEditorHistoryEvents.undoRequested(), @@ -195,7 +193,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should dispatch redoRequested on Ctrl/Cmd+Shift+Z when redo is available', () => { canRedo.set(true); - spectator.element.dispatchEvent( + document.dispatchEvent( new KeyboardEvent('keydown', { key: 'z', metaKey: true, shiftKey: true }) ); @@ -208,9 +206,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should dispatch redoRequested on Ctrl+Y when redo is available', () => { canRedo.set(true); - spectator.element.dispatchEvent( - new KeyboardEvent('keydown', { key: 'y', ctrlKey: true }) - ); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'y', ctrlKey: true })); expect(dispatcher.dispatch).toHaveBeenCalledWith( imageEditorHistoryEvents.redoRequested(), @@ -221,9 +217,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should not dispatch undo when there is nothing to undo', () => { canUndo.set(false); - spectator.element.dispatchEvent( - new KeyboardEvent('keydown', { key: 'z', ctrlKey: true }) - ); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })); expect(dispatcher.dispatch).not.toHaveBeenCalledWith( imageEditorHistoryEvents.undoRequested(), 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 index bc79017ec91a..e2ac89efa95e 100644 --- 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 @@ -1,6 +1,6 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, effect, HostListener, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; @@ -47,7 +47,10 @@ import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-im styleUrl: './dot-image-editor.component.scss', animations: [imageEditorModalScaleFade()], host: { - '[@imageEditorModalScaleFade]': '' + '[@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 { @@ -79,7 +82,6 @@ export class DotImageEditorComponent { * 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. */ - @HostListener('keydown', ['$event']) protected onKeydown(event: KeyboardEvent): void { if (!(event.metaKey || event.ctrlKey) || this.#isEditableTarget(event.target)) { return; 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 index a5c8c76d406b..df62d76523ba 100644 --- 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 @@ -13,6 +13,14 @@ export type ActiveTool = 'move' | 'crop' | 'focal'; /** Output compression strategy applied as the last filter in the chain. */ export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp'; +/** 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'; 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 index f82ff0fb0c49..4d6c2150793d 100644 --- 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 @@ -1,10 +1,8 @@ -import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { DotHttpErrorManagerService } from '@dotcms/data-access'; - import { DotImageEditorService } from './dot-image-editor.service'; // jsdom has no object-URL API; the service creates one for each verified blob. @@ -13,21 +11,15 @@ URL.createObjectURL = jest.fn(() => 'blob:mock-object-url'); describe('DotImageEditorService', () => { let spectator: SpectatorService; let httpMock: HttpTestingController; - let httpErrorManager: DotHttpErrorManagerService; const createService = createServiceFactory({ service: DotImageEditorService, - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - mockProvider(DotHttpErrorManagerService) - ] + providers: [provideHttpClient(), provideHttpClientTesting()] }); beforeEach(() => { spectator = createService(); httpMock = spectator.inject(HttpTestingController); - httpErrorManager = spectator.inject(DotHttpErrorManagerService); }); afterEach(() => { @@ -193,7 +185,7 @@ describe('DotImageEditorService', () => { req.flush(tempResponse); }); - it('should handle the error and rethrow on failure', () => { + it('should rethrow on failure without surfacing it (the store handles the error)', () => { let errored = false; spectator.service.saveEditedImage('/dA/asset.png', 'fileField').subscribe({ error: () => (errored = true) @@ -203,7 +195,8 @@ describe('DotImageEditorService', () => { .expectOne((request) => request.url.startsWith('/dA/asset.png')) .flush('boom', { status: 500, statusText: 'Server Error' }); - expect(httpErrorManager.handle).toHaveBeenCalled(); + // The service stays a pure rethrow pipe; the store's tapResponse is the + // single place that surfaces the error (avoids a double toast). expect(errored).toBe(true); }); }); 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 index acaf78eba9dd..d3f4ac9ff1cf 100644 --- 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 @@ -5,10 +5,9 @@ import { Injectable, inject } from '@angular/core'; import { catchError, map } from 'rxjs/operators'; -import { DotHttpErrorManagerService } from '@dotcms/data-access'; import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { FocalPointState, ImageEditorAssetContext } from '../models/image-editor.models'; +import { ImageEditorAssetContext } from '../models/image-editor.models'; /** Shape of the save endpoint JSON response used to build a {@link DotCMSTempFile}. */ interface SaveEditedImageResponse { @@ -29,7 +28,6 @@ interface AssetMeta { naturalWidth: number; naturalHeight: number; originalBytes: number | null; - focalPoint?: FocalPointState; } /** @@ -42,7 +40,6 @@ interface AssetMeta { @Injectable({ providedIn: 'root' }) export class DotImageEditorService { readonly #http = inject(HttpClient); - readonly #httpErrorManager = inject(DotHttpErrorManagerService); /** * Resolves the byte size of a remote asset via a HEAD request. @@ -106,8 +103,9 @@ export class DotImageEditorService { * @param filterUrl - The fully-built filter/preview URL for the edited image * @param variable - The binary field id the saved file should target * @returns The resulting temp file - * @throws Rethrows the original error after surfacing it, so callers can keep - * the editor open on failure + * @throws Rethrows the original error (without surfacing it) so the caller — + * the store — is the single place that shows the error and keeps the editor + * open on failure */ saveEditedImage(filterUrl: string, variable: string): Observable { const separator = filterUrl.includes('?') ? '&' : '?'; @@ -115,11 +113,7 @@ export class DotImageEditorService { return this.#http.get(url).pipe( map((res) => this.#toTempFile(res)), - catchError((error: HttpErrorResponse) => { - this.#httpErrorManager.handle(error); - - return throwError(() => error); - }) + catchError((error: HttpErrorResponse) => throwError(() => error)) ); } @@ -143,7 +137,7 @@ export class DotImageEditorService { * 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, original byte size and optional focal point + * @returns The natural dimensions and original byte size */ loadAssetMeta(ctx: ImageEditorAssetContext): Observable { return forkJoin({ 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..e9bfe5fdf22b --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.ts @@ -0,0 +1,330 @@ +import { + ImageEditorState, + initialAdjustState, + initialCropState, + initialFileInfoState, + initialFocalPointState, + initialTransformState +} from './image-editor.state'; + +import { + AdjustState, + CompressionMode, + CropState, + FileInfoState, + FilterCategory, + FocalPointState, + ImageEditorAssetContext, + ImageEditorHistoryEntry, + ImageEditorOpenParams, + TransformState +} from '../models/image-editor.models'; +import { clamp } from '../utils/dimensions.util'; + +/** + * Pure helpers for the {@link ImageEditorStore}: history coalescing/replay, the + * focal-centered crop geometry, 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 editable slices captured in a history snapshot. */ +export type EditableSlices = ImageEditorHistoryEntry['snapshot']; + +/** The pristine values of the editable slices, used to seed and reset history. */ +export const initialEditableSlices: EditableSlices = { + adjust: initialAdjustState, + transform: initialTransformState, + crop: initialCropState, + focalPoint: initialFocalPointState, + fileInfo: initialFileInfoState +}; + +/** Human-readable labels per compression mode, used in history entries. */ +export const COMPRESSION_LABELS: Record = { + none: 'None', + auto: 'Auto', + jpeg: 'JPEG', + webp: 'WebP' +}; + +/** + * 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; + +/** The editable slices, in snapshot order. */ +const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; + +/** A field-level patch over the editable slices (only the fields that changed). */ +type SlicePatch = { + adjust?: Partial; + transform?: Partial; + crop?: Partial; + focalPoint?: Partial; + fileInfo?: Partial; +}; + +/** 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, + focalPoint: state.focalPoint, + 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); otherwise any redo tail is discarded and a new + * entry is appended. + * @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` + */ +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 = { + id: `${category}-${Date.now()}-${state.cacheBust}`, + 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, + focalPoint: snapshot.focalPoint, + 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 }, + focalPoint: { ...base.focalPoint, ...patch.focalPoint }, + 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 }; + }); +} + +/** + * The largest crop of the given aspect ratio that fits the natural image, + * centered on the active focal point (or the image center when none is set) and + * clamped to the image bounds. This is what makes the focal point visible: the + * kept region follows the focal point instead of the center. + */ +export function focalCenteredCrop(aspect: number, state: ImageEditorState): CropState { + const { naturalWidth, naturalHeight } = state.assetContext; + const naturalAspect = naturalWidth / naturalHeight; + + let w: number; + let h: number; + if (aspect > naturalAspect) { + w = naturalWidth; + h = Math.round(naturalWidth / aspect); + } else { + h = naturalHeight; + w = Math.round(naturalHeight * aspect); + } + + const fx = state.focalPoint.active ? state.focalPoint.x : 0.5; + const fy = state.focalPoint.active ? state.focalPoint.y : 0.5; + const x = clamp(Math.round(fx * naturalWidth - w / 2), 0, naturalWidth - w); + const y = clamp(Math.round(fy * naturalHeight - h / 2), 0, naturalHeight - h); + + return { x, y, w, h, active: true, aspect }; +} + +/** 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 index 4a569664df74..03901526b35d 100644 --- 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 @@ -316,6 +316,23 @@ describe('ImageEditorStore', () => { 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); 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 index ddbeb506fbd2..713100d24436 100644 --- 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 @@ -29,260 +29,39 @@ import { } from './image-editor.events'; import { ImageEditorState, - initialAdjustState, initialCropState, - initialFileInfoState, initialFocalPointState, initialImageEditorState, - initialTransformState, RANGES } from './image-editor.state'; +import { + adjustPatch, + AUTO_PREVIEW_RETRY_LIMIT, + coalesceHistory, + COMPRESSION_LABELS, + contextFromParams, + editableSlicesOf, + errorMessage, + fileInfoPatch, + focalCenteredCrop, + initialEditableSlices, + rebuildHistory, + restoreSlices, + slicesAtIndex, + transformPatch +} from './image-editor.store-utils'; import { AdjustState, - CompressionMode, CropState, FileInfoState, - FilterCategory, FocalPointState, - ImageEditorAssetContext, - ImageEditorHistoryEntry, - ImageEditorOpenParams, TransformState } from '../models/image-editor.models'; import { DotImageEditorService } from '../services/dot-image-editor.service'; import { clamp, computeOutputDimensions } from '../utils/dimensions.util'; import { buildFilterChain, buildPreviewUrl } from '../utils/image-filter-url.builder'; -/** The editable slices captured in a history snapshot. */ -type EditableSlices = ImageEditorHistoryEntry['snapshot']; - -/** The pristine values of the editable slices, used to seed and reset history. */ -const initialEditableSlices: EditableSlices = { - adjust: initialAdjustState, - transform: initialTransformState, - crop: initialCropState, - focalPoint: initialFocalPointState, - fileInfo: initialFileInfoState -}; - -/** Extracts the editable slices from the full state for snapshotting. */ -function editableSlicesOf(state: ImageEditorState): EditableSlices { - return { - adjust: state.adjust, - transform: state.transform, - crop: state.crop, - focalPoint: state.focalPoint, - 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); otherwise any redo tail is discarded and a new - * entry is appended. - * @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` - */ -function coalesceHistory( - state: ImageEditorState, - category: FilterCategory, - label: string, - snapshot: EditableSlices -): Pick { - const head = state.history[state.historyIndex]; - - if (head && head.category === category) { - const history = state.history.map((entry, index) => - index === state.historyIndex ? { ...entry, label, snapshot } : entry - ); - - return { history, historyIndex: state.historyIndex }; - } - - const entry: ImageEditorHistoryEntry = { - id: `${category}-${Date.now()}-${state.cacheBust}`, - 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. */ -function restoreSlices(snapshot: EditableSlices): EditableSlices { - return { - adjust: snapshot.adjust, - transform: snapshot.transform, - crop: snapshot.crop, - focalPoint: snapshot.focalPoint, - fileInfo: snapshot.fileInfo - }; -} - -/** Resolves the editable slices for a given history index, or initial when empty. */ -function slicesAtIndex(history: ImageEditorHistoryEntry[], index: number): EditableSlices { - return index < 0 - ? restoreSlices(initialEditableSlices) - : restoreSlices(history[index].snapshot); -} - -/** The editable slices, in snapshot order. */ -const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; - -/** A field-level patch over the editable slices (only the fields that changed). */ -type SlicePatch = { - adjust?: Partial; - transform?: Partial; - crop?: Partial; - focalPoint?: Partial; - fileInfo?: Partial; -}; - -/** - * 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 }, - focalPoint: { ...base.focalPoint, ...patch.focalPoint }, - 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. - */ -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 }; - }); -} - -/** - * The largest crop of the given aspect ratio that fits the natural image, - * centered on the active focal point (or the image center when none is set) and - * clamped to the image bounds. This is what makes the focal point visible: the - * kept region follows the focal point instead of the center. - */ -function focalCenteredCrop(aspect: number, state: ImageEditorState): CropState { - const { naturalWidth, naturalHeight } = state.assetContext; - const naturalAspect = naturalWidth / naturalHeight; - - let w: number; - let h: number; - if (aspect > naturalAspect) { - w = naturalWidth; - h = Math.round(naturalWidth / aspect); - } else { - h = naturalHeight; - w = Math.round(naturalHeight * aspect); - } - - const fx = state.focalPoint.active ? state.focalPoint.x : 0.5; - const fy = state.focalPoint.active ? state.focalPoint.y : 0.5; - const x = clamp(Math.round(fx * naturalWidth - w / 2), 0, naturalWidth - w); - const y = clamp(Math.round(fy * naturalHeight - h / 2), 0, naturalHeight - h); - - return { x, y, w, h, active: true, aspect }; -} - -/** Produces the asset context for a freshly requested asset. */ -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}` - }; -} - -const COMPRESSION_LABELS: Record = { - none: 'None', - auto: 'Auto', - jpeg: 'JPEG', - webp: 'WebP' -}; - -/** - * 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. - */ -const AUTO_PREVIEW_RETRY_LIMIT = 3; - /** * NgRx SignalStore for the image editor. Built entirely on the events API: * synchronous state transitions are folded by `withReducer` — one block per event @@ -537,8 +316,7 @@ export const ImageEditorStore = signalStore( ...state.fileInfo, originalBytes: payload.originalBytes, currentBytes: payload.originalBytes - }, - focalPoint: payload.focalPoint ?? state.focalPoint + } })), on(imageEditorLifecycleEvents.assetLoadFailed, ({ payload }, state) => ({ ...state, @@ -746,69 +524,3 @@ export const ImageEditorStore = signalStore( }; }) ); - -/** Applies an adjust-slice edit, bumps the cache and coalesces history. */ -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. */ -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. */ -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. */ -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/utils/image-filter-url.builder.ts b/core-web/libs/image-editor/src/lib/utils/image-filter-url.builder.ts index 25e436149d17..3d8bbf0926e0 100644 --- 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 @@ -57,7 +57,7 @@ function compressionFilter(mode: CompressionMode, quality: number): AppliedFilte * mirroring the legacy ImageEditor rules: resizing removes crop, vertical flip * is expressed as a 180deg rotation plus flip-token cancellation, and the * compression filter is always applied last and exclusively. - * @param input - The adjust/transform/crop/fileInfo/focalPoint state slices + * @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[] { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4fa0ad7589b9..aaa6372c99df 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6502,6 +6502,7 @@ edit.content.image-editor.crop.box.aria=Crop region 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 From eba92bd99837478510bccd5c234064bd018af3a2 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 12:24:14 -0400 Subject: [PATCH 06/29] docs(image-editor): clarify coalesceHistory redo-tail; assert error identity in service test Code-review follow-ups (delta review of 9963b75b99): - coalesceHistory JSDoc now states the same-category branch also discards the redo tail (the summary implied in-place-only; the inline comment was already correct) - saveEditedImage failure test asserts the original HttpErrorResponse propagates unchanged (instanceof + status), not just that an error occurred Refs #36063 --- .../services/dot-image-editor.service.spec.ts | 16 +++++++++------- .../src/lib/store/image-editor.store-utils.ts | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) 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 index 4d6c2150793d..2463cc2f8e82 100644 --- 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 @@ -1,6 +1,6 @@ import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; -import { provideHttpClient } from '@angular/common/http'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { DotImageEditorService } from './dot-image-editor.service'; @@ -185,19 +185,21 @@ describe('DotImageEditorService', () => { req.flush(tempResponse); }); - it('should rethrow on failure without surfacing it (the store handles the error)', () => { - let errored = false; + it('should rethrow the original error without surfacing it (the store handles it)', () => { + let caughtError: unknown; spectator.service.saveEditedImage('/dA/asset.png', 'fileField').subscribe({ - error: () => (errored = true) + error: (error) => (caughtError = error) }); httpMock .expectOne((request) => request.url.startsWith('/dA/asset.png')) .flush('boom', { status: 500, statusText: 'Server Error' }); - // The service stays a pure rethrow pipe; the store's tapResponse is the - // single place that surfaces the error (avoids a double toast). - expect(errored).toBe(true); + // The service stays a pure rethrow pipe: the ORIGINAL HttpErrorResponse + // reaches the caller unchanged (not swallowed, not wrapped), so the + // store's tapResponse is the single place that surfaces it (no double toast). + expect(caughtError).toBeInstanceOf(HttpErrorResponse); + expect((caughtError as HttpErrorResponse).status).toBe(500); }); }); 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 index e9bfe5fdf22b..2ce16b86d68a 100644 --- 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 @@ -83,8 +83,9 @@ export function editableSlicesOf(state: ImageEditorState): EditableSlices { /** * 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); otherwise any redo tail is discarded and a new - * entry is appended. + * 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 From 31c54101fbf6ab7906f8221f7f61234987fa36e9 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 12:39:46 -0400 Subject: [PATCH 07/29] refactor(image-editor): consolidate constants and models into single library files Constants and type declarations were scattered across components, the store and services. Centralize them so the library has one home for each: - New image-editor.constants.ts: RANGES, ZOOM_*, crop/focal nudge steps, MIN_CROP_SIZE, CROP_HANDLES, BYTES_PER_KB, AUTO_PREVIEW_RETRY_LIMIT, COMPRESSION_LABELS, SLICE_KEYS, IMAGE_EDITOR_PANEL_STATE_KEY. The per-panel *_MIN/MAX duplicates (ADJUST/SCALE/ROTATE) now reference RANGES (single source). - models/image-editor.models.ts absorbs every remaining interface/type: ImageEditorState, Dimensions, FilterChainInput, ToolRailItem, CompressionOption, LocalRect, HandlePosition, NormalizedPoint, SaveEditedImageResponse, NaturalDimensions, AssetMeta, EditableSlices, SlicePatch. - state.ts keeps only the initial-state values; store-utils/store/components import the shared constants and types. No behavior change; 196 tests green. Refs #36063 --- core-web/libs/image-editor/src/index.ts | 4 +- .../dot-image-editor-canvas.component.ts | 10 +- ...dot-image-editor-crop-overlay.component.ts | 33 ++--- ...ot-image-editor-focal-overlay.component.ts | 15 +- ...dot-image-editor-adjust-panel.component.ts | 10 +- ...t-image-editor-fileinfo-panel.component.ts | 12 +- .../dot-image-editor-panels.component.spec.ts | 2 +- ...-image-editor-transform-panel.component.ts | 22 +-- .../dot-image-editor-tool-rail.component.ts | 12 +- .../src/lib/image-editor.constants.ts | 72 ++++++++++ .../src/lib/models/image-editor.models.ts | 131 ++++++++++++++++++ .../lib/services/dot-image-editor.service.ts | 28 +--- .../src/lib/store/image-editor.state.ts | 58 +------- .../src/lib/store/image-editor.store-utils.ts | 38 +---- .../src/lib/store/image-editor.store.ts | 8 +- .../src/lib/utils/dimensions.util.ts | 13 +- .../src/lib/utils/image-filter-url.builder.ts | 19 +-- .../src/lib/utils/panel-state.storage.spec.ts | 9 +- .../src/lib/utils/panel-state.storage.ts | 8 +- 19 files changed, 271 insertions(+), 233 deletions(-) create mode 100644 core-web/libs/image-editor/src/lib/image-editor.constants.ts diff --git a/core-web/libs/image-editor/src/index.ts b/core-web/libs/image-editor/src/index.ts index 12b9a44f56dd..3899a11ec43f 100644 --- a/core-web/libs/image-editor/src/index.ts +++ b/core-web/libs/image-editor/src/index.ts @@ -1,8 +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, RANGES } from './lib/store/image-editor.state'; -export type { ImageEditorState } from './lib/store/image-editor.state'; +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/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 index 3e9a6ce6bfd5..85216359500c 100644 --- 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 @@ -23,6 +23,7 @@ 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, imageEditorToolEvents } from '../../store/image-editor.events'; @@ -33,15 +34,6 @@ import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-ove import { DotImageEditorFocalOverlayComponent } from '../dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component'; import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/dot-image-editor-tool-rail.component'; -/** Smallest zoom percentage the canvas allows. */ -const ZOOM_MIN = 10; -/** Largest zoom percentage the canvas allows. */ -const ZOOM_MAX = 400; -/** Step applied per zoom-in / zoom-out request. */ -const ZOOM_STEP = 25; -/** Default (fit) zoom percentage. */ -const ZOOM_DEFAULT = 100; - /** * Dark stage that renders the live image preview at the center of the editor. * Hosts the top address sub-bar, the floating tool rail, the crop/focal overlays, 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 index 7df4a22d830e..9df62eab3310 100644 --- 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 @@ -13,32 +13,17 @@ import { import { DotMessagePipe } from '@dotcms/ui'; import { imageEditorOverlayEnterLeave } from '../../animations/image-editor.animations'; -import { ImageRect } from '../../models/image-editor.models'; +import { + CROP_HANDLES, + CROP_NUDGE_STEP, + CROP_NUDGE_STEP_LARGE, + MIN_CROP_SIZE +} from '../../image-editor.constants'; +import { 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'; -/** A crop rectangle expressed in CSS px, local to the rendered image origin. */ -interface LocalRect { - x: number; - y: number; - width: number; - height: number; -} - -/** Identifiers for the eight resize handles around the crop box. */ -type HandlePosition = 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l'; - -/** Distance in CSS px nudged per arrow keypress; Shift multiplies this. */ -const NUDGE_STEP = 1; -const NUDGE_STEP_LARGE = 10; - -/** Smallest allowed crop dimension in CSS px to keep the box usable. */ -const MIN_CROP_SIZE = 16; - -/** The eight resize handles rendered around the crop box. */ -const HANDLES: readonly HandlePosition[] = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l'] as const; - /** * Interactive crop overlay rendered on top of the image canvas while the crop * tool is active. Presents a draggable/resizable selection rectangle with a @@ -71,7 +56,7 @@ export class DotImageEditorCropOverlayComponent { readonly #dispatch = injectDispatch(imageEditorToolEvents); /** The resize handles rendered around the crop box. */ - protected readonly handles = HANDLES; + protected readonly handles = CROP_HANDLES; /** Whether the crop tool is the active canvas tool. */ protected readonly isActive = computed(() => this.#store.activeTool() === 'crop'); @@ -131,7 +116,7 @@ export class DotImageEditorCropOverlayComponent { /** Nudges or applies/cancels the crop in response to keyboard input. */ protected onBoxKeydown(event: KeyboardEvent): void { - const step = event.shiftKey ? NUDGE_STEP_LARGE : NUDGE_STEP; + const step = event.shiftKey ? CROP_NUDGE_STEP_LARGE : CROP_NUDGE_STEP; const current = this.cropRect(); switch (event.key) { 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 index 75ddf71f9ed1..8e1ca8831b5a 100644 --- 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 @@ -14,21 +14,12 @@ import { import { DotMessagePipe } from '@dotcms/ui'; import { focalPointPop } from '../../animations/image-editor.animations'; -import { ImageRect } from '../../models/image-editor.models'; +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'; -/** A normalized point in the unit square, where {x:0.5, y:0.5} is the center. */ -interface NormalizedPoint { - x: number; - y: number; -} - -/** Fraction of the image moved per arrow keypress; Shift uses the larger step. */ -const NUDGE_STEP = 0.01; -const NUDGE_STEP_LARGE = 0.05; - /** * 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 @@ -98,7 +89,7 @@ export class DotImageEditorFocalOverlayComponent { /** Moves (and commits) or finishes the focal point in response to keyboard input. */ protected onMarkerKeydown(event: KeyboardEvent): void { - const step = event.shiftKey ? NUDGE_STEP_LARGE : NUDGE_STEP; + const step = event.shiftKey ? FOCAL_NUDGE_STEP_LARGE : FOCAL_NUDGE_STEP; const current = this.point(); switch (event.key) { 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 index 2a4cd35eb10c..609881508a8c 100644 --- 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 @@ -8,13 +8,13 @@ import { SliderChangeEvent, SliderModule, SliderSlideEndEvent } from 'primeng/sl 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'; -/** Inclusive range shared by every adjustment slider and its number field. */ -const ADJUST_MIN = -100; -const ADJUST_MAX = 100; +// 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 @@ -128,8 +128,8 @@ export class DotImageEditorAdjustPanelComponent { const raw = input.valueAsNumber; const value = clamp( Math.round(Number.isFinite(raw) ? raw : fallback), - ADJUST_MIN, - ADJUST_MAX + RANGES.brightness.min, + RANGES.brightness.max ); input.value = String(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.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 index 3dc03598248e..ea702ba5ab71 100644 --- 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 @@ -8,19 +8,11 @@ import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; import { DotMessagePipe } from '@dotcms/ui'; -import { CompressionMode } from '../../../models/image-editor.models'; +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'; -/** A selectable compression option shown in the compression select button. */ -interface CompressionOption { - label: string; - value: CompressionMode; -} - -/** One kibibyte, used to format byte counts for display. */ -const BYTES_PER_KB = 1024; - /** * File info / compression panel. Lets the user pick a compression strategy and, * when compression is active, tune its quality, while surfacing the current 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 index 03d135598096..b53badd1cae2 100644 --- 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 @@ -11,7 +11,7 @@ import { DotImageEditorHistoryPanelComponent } from './dot-image-editor-history- 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 '../../utils/panel-state.storage'; +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../../image-editor.constants'; describe('DotImageEditorPanelsComponent', () => { let spectator: Spectator; 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 index 7b9229af1777..3b5408365e8f 100644 --- 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 @@ -9,17 +9,11 @@ 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'; -/** Inclusive range of the scale (%) control, matching its slider. */ -const SCALE_MIN = 1; -const SCALE_MAX = 400; -/** Inclusive range of the rotate (°) control, matching its slider. */ -const ROTATE_MIN = -180; -const ROTATE_MAX = 180; - /** * Geometric transform panel. Binds scale and rotate sliders, horizontal/vertical * flip toggles and explicit output dimension inputs to the {@link ImageEditorStore} @@ -74,7 +68,12 @@ export class DotImageEditorTransformPanelComponent { /** Commits a scale value typed into the inline number field. */ protected onScaleInput(event: Event): void { - const value = this.#commitTypedValue(event, this.scale(), SCALE_MIN, SCALE_MAX); + const value = this.#commitTypedValue( + event, + this.scale(), + RANGES.scale.min, + RANGES.scale.max + ); this.scale.set(value); this.dispatch.scaleChanged(value); } @@ -91,7 +90,12 @@ export class DotImageEditorTransformPanelComponent { /** Commits a rotation value typed into the inline number field. */ protected onRotateInput(event: Event): void { - const value = this.#commitTypedValue(event, this.rotate(), ROTATE_MIN, ROTATE_MAX); + const value = this.#commitTypedValue( + event, + this.rotate(), + RANGES.rotate.min, + RANGES.rotate.max + ); this.rotate.set(value); this.dispatch.rotateChanged(value); } diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts index 0c186c3d35c4..e48020d5e81c 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts @@ -6,20 +6,10 @@ import { TooltipModule } from 'primeng/tooltip'; import { DotMessagePipe } from '@dotcms/ui'; -import { ActiveTool } from '../../models/image-editor.models'; +import { ActiveTool, ToolRailItem } from '../../models/image-editor.models'; import { imageEditorToolEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; -/** A selectable tool on the floating canvas rail. */ -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; -} - /** * Floating vertical rail of canvas tools (move, crop, focal point). Acts as a * `toolbar` with roving tabindex: the active tool is the only focusable button, 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..8c43e4c441df --- /dev/null +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -0,0 +1,72 @@ +import { CompressionMode, HandlePosition } from './models/image-editor.models'; + +/** + * Library-wide constants for the image editor: control ranges, zoom/crop/focal + * 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; + +/** 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; + +/** 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.05; + +/** 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' +}; + +/** The editable slices, in snapshot order (used to diff/replay history). */ +export const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; + +/** localStorage key persisting which editor side panels are expanded. */ +export const IMAGE_EDITOR_PANEL_STATE_KEY = 'DOT_IMAGE_EDITOR_PANEL_STATE'; 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 index df62d76523ba..17a7898885b1 100644 --- 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 @@ -201,3 +201,134 @@ export interface DotImageEditorLauncher { */ 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, focal point, + * 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; + /** Normalized focal point slice. */ + focalPoint: FocalPointState; + /** Canvas zoom slice. */ + zoom: ZoomState; + /** 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; + /** Lifecycle of the current save / save-as operation. */ + saveStatus: SaveStatus; + /** Temp file produced by the last successful save, or `null`. */ + savedTempFile: DotCMSTempFile | null; + /** 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; +} + +// --------------------------------------------------------------------------- +// 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 select button. */ +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'; + +/** A normalized point in the unit square, where {x:0.5, y:0.5} is the center. */ +export interface NormalizedPoint { + x: number; + y: number; +} + +/** Shape of the save endpoint JSON response used to build a {@link DotCMSTempFile}. */ +export interface SaveEditedImageResponse { + id: string; + fileName: string; + length: number; + metadata?: DotCMSTempFile['metadata']; +} + +/** 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; + focalPoint?: Partial; + fileInfo?: Partial; +}; 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 index d3f4ac9ff1cf..7c365767f21b 100644 --- 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 @@ -7,28 +7,12 @@ import { catchError, map } from 'rxjs/operators'; import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { ImageEditorAssetContext } from '../models/image-editor.models'; - -/** Shape of the save endpoint JSON response used to build a {@link DotCMSTempFile}. */ -interface SaveEditedImageResponse { - id: string; - fileName: string; - length: number; - metadata?: DotCMSTempFile['metadata']; -} - -/** Natural pixel dimensions of an image resolved from the browser. */ -interface NaturalDimensions { - naturalWidth: number; - naturalHeight: number; -} - -/** Metadata resolved for an asset before editing begins. */ -interface AssetMeta { - naturalWidth: number; - naturalHeight: number; - originalBytes: number | null; -} +import { + AssetMeta, + ImageEditorAssetContext, + NaturalDimensions, + SaveEditedImageResponse +} from '../models/image-editor.models'; /** * Data-access service for the image editor: resolves asset metadata, queries 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 index b4af43bee938..9ba2d4e03491 100644 --- 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 @@ -1,70 +1,14 @@ -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - import { - ActiveTool, AdjustState, CropState, FileInfoState, FocalPointState, ImageEditorAssetContext, - ImageEditorHistoryEntry, - PreviewStatus, - SaveStatus, + ImageEditorState, TransformState, ZoomState } from '../models/image-editor.models'; -/** - * The complete, flat state of the image editor. Each slice owns a domain of the - * editing experience (color adjustment, geometric transform, crop, focal point, - * 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; - /** Normalized focal point slice. */ - focalPoint: FocalPointState; - /** Canvas zoom slice. */ - zoom: ZoomState; - /** 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; - /** Lifecycle of the current save / save-as operation. */ - saveStatus: SaveStatus; - /** Temp file produced by the last successful save, or `null`. */ - savedTempFile: DotCMSTempFile | null; - /** 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; -} - -/** Inclusive value ranges enforced by the reducer when clamping panel edits. */ -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; - /** Default empty asset context used before an asset is requested. */ const initialAssetContext: ImageEditorAssetContext = { idOrTempId: '', 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 index 2ce16b86d68a..9f14855372f1 100644 --- 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 @@ -1,5 +1,4 @@ import { - ImageEditorState, initialAdjustState, initialCropState, initialFileInfoState, @@ -7,16 +6,18 @@ import { initialTransformState } from './image-editor.state'; +import { SLICE_KEYS } from '../image-editor.constants'; import { AdjustState, - CompressionMode, CropState, + EditableSlices, FileInfoState, FilterCategory, - FocalPointState, ImageEditorAssetContext, ImageEditorHistoryEntry, ImageEditorOpenParams, + ImageEditorState, + SlicePatch, TransformState } from '../models/image-editor.models'; import { clamp } from '../utils/dimensions.util'; @@ -29,9 +30,6 @@ import { clamp } from '../utils/dimensions.util'; * in isolation. */ -/** The editable slices captured in a history snapshot. */ -export type EditableSlices = ImageEditorHistoryEntry['snapshot']; - /** The pristine values of the editable slices, used to seed and reset history. */ export const initialEditableSlices: EditableSlices = { adjust: initialAdjustState, @@ -41,34 +39,6 @@ export const initialEditableSlices: EditableSlices = { fileInfo: initialFileInfoState }; -/** Human-readable labels per compression mode, used in history entries. */ -export const COMPRESSION_LABELS: Record = { - none: 'None', - auto: 'Auto', - jpeg: 'JPEG', - webp: 'WebP' -}; - -/** - * 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; - -/** The editable slices, in snapshot order. */ -const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; - -/** A field-level patch over the editable slices (only the fields that changed). */ -type SlicePatch = { - adjust?: Partial; - transform?: Partial; - crop?: Partial; - focalPoint?: Partial; - fileInfo?: Partial; -}; - /** Extracts the editable slices from the full state for snapshotting. */ export function editableSlicesOf(state: ImageEditorState): EditableSlices { return { 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 index 713100d24436..2ff1ded7ff61 100644 --- 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 @@ -28,17 +28,13 @@ import { imageEditorTransformEvents } from './image-editor.events'; import { - ImageEditorState, initialCropState, initialFocalPointState, - initialImageEditorState, - RANGES + initialImageEditorState } from './image-editor.state'; import { adjustPatch, - AUTO_PREVIEW_RETRY_LIMIT, coalesceHistory, - COMPRESSION_LABELS, contextFromParams, editableSlicesOf, errorMessage, @@ -51,11 +47,13 @@ import { transformPatch } from './image-editor.store-utils'; +import { AUTO_PREVIEW_RETRY_LIMIT, COMPRESSION_LABELS, RANGES } from '../image-editor.constants'; import { AdjustState, CropState, FileInfoState, FocalPointState, + ImageEditorState, TransformState } from '../models/image-editor.models'; import { DotImageEditorService } from '../services/dot-image-editor.service'; 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 index cba94ecead0d..061960b4cdba 100644 --- a/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts +++ b/core-web/libs/image-editor/src/lib/utils/dimensions.util.ts @@ -1,10 +1,9 @@ -import { CropState, ImageEditorAssetContext, TransformState } from '../models/image-editor.models'; - -/** Intrinsic pixel dimensions of an image. */ -interface Dimensions { - width: number; - height: number; -} +import { + CropState, + Dimensions, + ImageEditorAssetContext, + TransformState +} from '../models/image-editor.models'; /** * Constrains a number to the inclusive `[min, max]` range. 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 index 3d8bbf0926e0..55bd0d75df82 100644 --- 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 @@ -1,27 +1,12 @@ import { computeResizeParams } from './dimensions.util'; import { - AdjustState, AppliedFilter, CompressionMode, - CropState, - FileInfoState, - ImageEditorAssetContext, - TransformState + FilterChainInput, + ImageEditorAssetContext } from '../models/image-editor.models'; -/** State slices required to build the server filter chain. */ -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; -} - /** * 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"). 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 index 009660896768..cc666b01818b 100644 --- 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 @@ -1,8 +1,7 @@ -import { - getStoredPanelState, - IMAGE_EDITOR_PANEL_STATE_KEY, - savePanelState -} from './panel-state.storage'; +import { getStoredPanelState, savePanelState } from './panel-state.storage'; + +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; + describe('panel-state.storage', () => { afterEach(() => { 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 index 3962f720ff21..230c72502e04 100644 --- 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 @@ -1,3 +1,5 @@ +import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; + /** * Persistence for the image-editor side-panel accordion's expanded sections. * @@ -9,10 +11,10 @@ * survives across browser sessions — they get the panels back the way they left * them, even after closing the tab. * - * The value is the array of open `p-accordion-panel` values (e.g. `['adjust']`). - * The default is an empty array: every section starts collapsed. + * The stored value (under {@link IMAGE_EDITOR_PANEL_STATE_KEY}) is the array of + * open `p-accordion-panel` values (e.g. `['adjust']`); the default is an empty + * array so every section starts collapsed. */ -export const IMAGE_EDITOR_PANEL_STATE_KEY = 'DOT_IMAGE_EDITOR_PANEL_STATE'; /** Every accordion section collapsed by default. */ const DEFAULT_PANEL_STATE: string[] = []; From 27249126aa71fc454c11654a457ba996d963c507 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 13:12:52 -0400 Subject: [PATCH 08/29] refactor(image-editor): group the store into vertical feature units by functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 524-line store monolith into one signalStoreFeature per area of functionality (https://ngrx.io/guide/signals/signal-store/custom-store-features), each bundling its own reducers, selectors and effects so a domain lives in one file (store/features/with-*.feature.ts): - withAdjust / withTransform / withCrop / withFocalPoint / withFileInfo / withTools - withHistory (+ appliedEdits / canUndo / canRedo) - withAsset (+ loadAsset$ effect) - withPreview (+ appliedFilters / previewUrl / isDirty / isBusy + resolveSize$) - withSave (+ canSave + save$ / download$) Cross-cutting selectors live in their owning feature; withSave declares the previewUrl / appliedFilters props it consumes from withPreview (so it composes after it). image-editor.store.ts is now a 41-line composition. Pure reorganization — the reducers still fold the full flat state; no behavior change, 196 tests green (store spec exercises the public API, unchanged). Refs #36063 --- .../src/lib/store/features/index.ts | 10 + .../lib/store/features/with-adjust.feature.ts | 49 ++ .../lib/store/features/with-asset.feature.ts | 74 +++ .../lib/store/features/with-crop.feature.ts | 49 ++ .../store/features/with-file-info.feature.ts | 37 ++ .../features/with-focal-point.feature.ts | 78 +++ .../store/features/with-history.feature.ts | 96 +++ .../store/features/with-preview.feature.ts | 114 ++++ .../lib/store/features/with-save.feature.ts | 123 ++++ .../lib/store/features/with-tools.feature.ts | 22 + .../store/features/with-transform.feature.ts | 72 +++ .../src/lib/store/image-editor.store.ts | 549 ++---------------- .../src/lib/utils/panel-state.storage.spec.ts | 1 - 13 files changed, 757 insertions(+), 517 deletions(-) create mode 100644 core-web/libs/image-editor/src/lib/store/features/index.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-asset.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-crop.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-history.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-tools.feature.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-transform.feature.ts 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..996422830dde --- /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 { 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 { withSave } from './with-save.feature'; +export { withTools } from './with-tools.feature'; +export { withTransform } from './with-transform.feature'; 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.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-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.ts b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts new file mode 100644 index 000000000000..5a4cb3cb2fc0 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts @@ -0,0 +1,78 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { + FocalPointState, + ImageEditorState, + TransformState +} from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialFocalPointState } from '../image-editor.state'; +import { coalesceHistory, editableSlicesOf, focalCenteredCrop } from '../image-editor.store-utils'; + +/** + * Focal point feature: setting/clearing the focal anchor and the focal-centered + * aspect crop. Setting the point does NOT reload the preview (it's a save-time + * anchor); the aspect crop derives a crop centered on the point and, like a manual + * crop, supersedes resize and returns to the move tool. + */ +export function withFocalPoint() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => { + // No preview reload: the focal point doesn't change the rendered image + // on its own — it's a saved anchor consumed by the aspect crop and + // persisted on save. Just record it (and a coalesced history entry). + const focalPoint: FocalPointState = { x: payload.x, y: payload.y, active: true }; + const next: ImageEditorState = { ...state, focalPoint }; + + return { + ...next, + ...coalesceHistory( + next, + 'focal', + `Focal point ${payload.x.toFixed(2)}, ${payload.y.toFixed(2)}`, + editableSlicesOf(next) + ) + }; + }), + on(imageEditorToolEvents.focalPointCleared, (_event, state) => ({ + ...state, + focalPoint: initialFocalPointState + })), + on(imageEditorToolEvents.aspectCropApplied, ({ payload }, state) => { + if (!state.assetContext.naturalWidth || !state.assetContext.naturalHeight) { + return state; + } + + const crop = focalCenteredCrop(payload.aspect, state); + // Cropping is mutually exclusive with resize, and returns to the move tool. + 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 ${payload.label}`, + editableSlicesOf(next) + ) + }; + }) + ) + ); +} 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..9dcca38a9db7 --- /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 { computed, inject } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; + +import { 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 or saving. */ + isBusy: computed( + () => store.previewStatus() === 'loading' || store.saveStatus() === 'saving' + ) + }; + }), + 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) + ) + ) + ) + ) + ) + }; + }) + ); +} diff --git a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts new file mode 100644 index 000000000000..abffeb9f8bbe --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts @@ -0,0 +1,123 @@ +import { tapResponse } from '@ngrx/operators'; +import { signalStoreFeature, type, withComputed } from '@ngrx/signals'; +import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; +import { EMPTY } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject, Signal } from '@angular/core'; + +import { catchError, exhaustMap, switchMap, tap } from 'rxjs/operators'; + +import { DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { AppliedFilter, ImageEditorState } from '../../models/image-editor.models'; +import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { imageEditorLifecycleEvents } from '../image-editor.events'; +import { errorMessage } from '../image-editor.store-utils'; + +/** + * Save feature: persisting the edited image and triggering downloads. Consumes + * the `previewUrl` and `appliedFilters` selectors from {@link withPreview} (so it + * composes after it), exposes `canSave`, persists the focal point before saving + * and keeps the editor open on failure (the store is the single error surface). + */ +export function withSave() { + return signalStoreFeature( + type<{ + state: ImageEditorState; + props: { previewUrl: Signal; appliedFilters: Signal }; + }>(), + withReducer( + on(imageEditorLifecycleEvents.saveRequested, (_event, state) => ({ + ...state, + saveStatus: 'saving' as const + })), + on(imageEditorLifecycleEvents.saveAsRequested, (_event, state) => ({ + ...state, + saveStatus: 'saving' as const + })), + on(imageEditorLifecycleEvents.saveSucceeded, ({ payload }, state) => ({ + ...state, + savedTempFile: payload, + saveStatus: 'saved' as const + })), + on(imageEditorLifecycleEvents.saveFailed, ({ payload }, state) => ({ + ...state, + saveStatus: 'error' as const, + error: errorMessage(payload, 'Failed to save image') + })) + ), + withComputed((store) => ({ + /** Whether a save can be initiated right now. */ + canSave: computed( + () => + store.previewStatus() === 'loaded' && + store.saveStatus() !== 'saving' && + store.appliedFilters().length > 0 + ) + })), + withEventHandlers((store) => { + const events = inject(Events); + const dispatcher = inject(Dispatcher); + const service = inject(DotImageEditorService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + // Save the edited image and dispatch the outcome. + const saveEditedImage$ = () => + service.saveEditedImage(store.previewUrl(), store.assetContext().variable).pipe( + tapResponse({ + next: (tempFile: DotCMSTempFile) => + dispatcher.dispatch(imageEditorLifecycleEvents.saveSucceeded(tempFile)), + error: (error: HttpErrorResponse) => { + // Surface the error but keep the editor open for retry. + httpErrorManager.handle(error); + dispatcher.dispatch(imageEditorLifecycleEvents.saveFailed(error)); + } + }), + // Swallow the rethrown error so the effect stream stays alive. + catchError(() => EMPTY) + ); + + return { + // Persist the focal point first (when active), then save the image. + // `exhaustMap` ignores new save triggers while one is in flight: a + // destructive write must not be cancelled mid-flight, which would + // strand `saveStatus: 'saving'` with no terminal event. + save$: events + .on( + imageEditorLifecycleEvents.saveRequested, + imageEditorLifecycleEvents.saveAsRequested + ) + .pipe( + exhaustMap(() => { + const focalPoint = store.focalPoint(); + + if (!focalPoint.active) { + return saveEditedImage$(); + } + + return service + .persistFocalPoint(store.assetContext().originalUrl, { + x: focalPoint.x, + y: focalPoint.y + }) + .pipe(switchMap(() => saveEditedImage$())); + }) + ), + + // Trigger a client-side download of the current preview. + 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-tools.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-tools.feature.ts new file mode 100644 index 000000000000..d17cf834d76d --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-tools.feature.ts @@ -0,0 +1,22 @@ +import { signalStoreFeature, type } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; + +import { ImageEditorState } from '../../models/image-editor.models'; +import { imageEditorToolEvents } from '../image-editor.events'; + +/** + * Tools feature: tracks which canvas tool (move / crop / focal) is active. The + * crop and focal interactions themselves live in {@link withCrop} and + * {@link withFocalPoint}; this feature only owns the tool selection. + */ +export function withTools() { + return signalStoreFeature( + type<{ state: ImageEditorState }>(), + withReducer( + on(imageEditorToolEvents.toolSelected, ({ payload }, state) => ({ + ...state, + activeTool: payload + })) + ) + ); +} 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..6676df7bc232 --- /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, 'adjust', `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, 'adjust', '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/image-editor.store.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store.ts index 2ff1ded7ff61..766976ca569f 100644 --- 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 @@ -1,524 +1,41 @@ -import { tapResponse } from '@ngrx/operators'; -import { signalStore, withComputed, withState } from '@ngrx/signals'; -import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; -import { EMPTY } from 'rxjs'; +import { signalStore, withState } from '@ngrx/signals'; -import { HttpErrorResponse } from '@angular/common/http'; -import { computed, inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; - -import { - catchError, - debounceTime, - distinctUntilChanged, - exhaustMap, - switchMap, - tap -} from 'rxjs/operators'; - -import { DotHttpErrorManagerService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - -import { - imageEditorAdjustEvents, - imageEditorFileInfoEvents, - imageEditorHistoryEvents, - imageEditorLifecycleEvents, - imageEditorToolEvents, - imageEditorTransformEvents -} from './image-editor.events'; import { - initialCropState, - initialFocalPointState, - initialImageEditorState -} from './image-editor.state'; -import { - adjustPatch, - coalesceHistory, - contextFromParams, - editableSlicesOf, - errorMessage, - fileInfoPatch, - focalCenteredCrop, - initialEditableSlices, - rebuildHistory, - restoreSlices, - slicesAtIndex, - transformPatch -} from './image-editor.store-utils'; - -import { AUTO_PREVIEW_RETRY_LIMIT, COMPRESSION_LABELS, RANGES } from '../image-editor.constants'; -import { - AdjustState, - CropState, - FileInfoState, - FocalPointState, - ImageEditorState, - TransformState -} from '../models/image-editor.models'; -import { DotImageEditorService } from '../services/dot-image-editor.service'; -import { clamp, computeOutputDimensions } from '../utils/dimensions.util'; -import { buildFilterChain, buildPreviewUrl } from '../utils/image-filter-url.builder'; + withAdjust, + withAsset, + withCrop, + withFileInfo, + withFocalPoint, + withHistory, + withPreview, + withSave, + withTools, + withTransform +} from './features'; +import { initialImageEditorState } from './image-editor.state'; /** - * NgRx SignalStore for the image editor. Built entirely on the events API: - * synchronous state transitions are folded by `withReducer` — one block per event - * group (adjust, transform, file info, tools, history, lifecycle) — derived state - * is exposed through `withComputed`, and asynchronous side effects (asset loading, - * debounced size resolution, save and download) are declared as `withEventHandlers` - * that react to the dispatched events stream. The store is NOT provided in root — - * the editor dialog component supplies it so each editor instance is isolated. + * NgRx SignalStore for the image editor, composed from one vertical feature per + * area of functionality (see + * https://ngrx.io/guide/signals/signal-store/custom-store-features). 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 `appliedFilters` / `previewUrl`, which `withSave` reads — so save + * 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), - // Adjust panel: color & light. - 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'}`); - }) - ), - // Transform panel: scale, rotate, flip, output size. - 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, 'adjust', `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, 'adjust', 'Resize'); - }) - ), - // File info panel: compression & quality. - 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}`); - }) - ), - // Canvas tools: move / crop / focal. - withReducer( - on(imageEditorToolEvents.toolSelected, ({ payload }, state) => ({ - ...state, - activeTool: payload - })), - 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 - })), - on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => { - // No preview reload: the focal point doesn't change the rendered image - // on its own — it's a saved anchor consumed by the aspect crop and - // persisted on save. Just record it (and a coalesced history entry). - const focalPoint: FocalPointState = { x: payload.x, y: payload.y, active: true }; - const next: ImageEditorState = { ...state, focalPoint }; - - return { - ...next, - ...coalesceHistory( - next, - 'focal', - `Focal point ${payload.x.toFixed(2)}, ${payload.y.toFixed(2)}`, - editableSlicesOf(next) - ) - }; - }), - on(imageEditorToolEvents.focalPointCleared, (_event, state) => ({ - ...state, - focalPoint: initialFocalPointState - })), - on(imageEditorToolEvents.aspectCropApplied, ({ payload }, state) => { - if (!state.assetContext.naturalWidth || !state.assetContext.naturalHeight) { - return state; - } - - const crop = focalCenteredCrop(payload.aspect, state); - // Cropping is mutually exclusive with resize, and returns to the move tool. - 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 ${payload.label}`, editableSlicesOf(next)) - }; - }) - ), - // Applied-edits history: remove / undo / redo / reset. - 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 - })) - ), - // Editor lifecycle: load, preview, size, download, save. - 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') - })), - 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 } - })), - on(imageEditorLifecycleEvents.saveRequested, (_event, state) => ({ - ...state, - saveStatus: 'saving' as const - })), - on(imageEditorLifecycleEvents.saveAsRequested, (_event, state) => ({ - ...state, - saveStatus: 'saving' as const - })), - on(imageEditorLifecycleEvents.saveSucceeded, ({ payload }, state) => ({ - ...state, - savedTempFile: payload, - saveStatus: 'saved' as const - })), - on(imageEditorLifecycleEvents.saveFailed, ({ payload }, state) => ({ - ...state, - saveStatus: 'error' as const, - error: errorMessage(payload, 'Failed to save image') - })) - ), - 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()) - ), - /** 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 })) - ), - /** The effective output dimensions of the edited image. */ - outputDimensions: computed(() => - computeOutputDimensions(store.assetContext(), store.transform(), store.crop()) - ), - /** 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), - /** Whether any edit produces a non-empty filter chain. */ - isDirty: computed(() => appliedFilters().length > 0), - /** Whether the editor is mid-flight loading a preview or saving. */ - isBusy: computed( - () => store.previewStatus() === 'loading' || store.saveStatus() === 'saving' - ), - /** Whether a save can be initiated right now. */ - canSave: computed( - () => - store.previewStatus() === 'loaded' && - store.saveStatus() !== 'saving' && - appliedFilters().length > 0 - ) - }; - }), - // Asynchronous side effects, declared as event handlers: each returned - // observable reacts to the dispatched event stream (or a derived signal) and - // dispatches result events. The store subscribes to them for its lifetime. - withEventHandlers((store) => { - const events = inject(Events); - const dispatcher = inject(Dispatcher); - const service = inject(DotImageEditorService); - const httpErrorManager = inject(DotHttpErrorManagerService); - - // Save the edited image and dispatch the outcome. - const saveEditedImage$ = () => - service.saveEditedImage(store.previewUrl(), store.assetContext().variable).pipe( - tapResponse({ - next: (tempFile: DotCMSTempFile) => - dispatcher.dispatch(imageEditorLifecycleEvents.saveSucceeded(tempFile)), - error: (error: HttpErrorResponse) => { - // Surface the error but keep the editor open for retry. - httpErrorManager.handle(error); - dispatcher.dispatch(imageEditorLifecycleEvents.saveFailed(error)); - } - }), - // Swallow the rethrown error so the effect stream stays alive. - catchError(() => EMPTY) - ); - - 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) - ) - }) - ) - ) - ), - - // 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) - ) - ) - ) - ) - ), - - // Persist the focal point first (when active), then save the image. - // `exhaustMap` ignores new save triggers while one is in flight: a - // destructive write must not be cancelled mid-flight, which would - // strand `saveStatus: 'saving'` with no terminal event. - save$: events - .on( - imageEditorLifecycleEvents.saveRequested, - imageEditorLifecycleEvents.saveAsRequested - ) - .pipe( - exhaustMap(() => { - const focalPoint = store.focalPoint(); - - if (!focalPoint.active) { - return saveEditedImage$(); - } - - return service - .persistFocalPoint(store.assetContext().originalUrl, { - x: focalPoint.x, - y: focalPoint.y - }) - .pipe(switchMap(() => saveEditedImage$())); - }) - ), - - // Trigger a client-side download of the current preview. - download$: events - .on(imageEditorLifecycleEvents.downloadRequested) - .pipe( - tap(() => - service.triggerDownload(store.previewUrl(), store.assetContext().fileName) - ) - ) - }; - }) + withAdjust(), + withTransform(), + withCrop(), + withFocalPoint(), + withFileInfo(), + withTools(), + withHistory(), + withAsset(), + withPreview(), + withSave() ); 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 index cc666b01818b..8dbefd1bb50f 100644 --- 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 @@ -2,7 +2,6 @@ import { getStoredPanelState, savePanelState } from './panel-state.storage'; import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; - describe('panel-state.storage', () => { afterEach(() => { localStorage.clear(); From 5f438ee52ae970c9103c91bf2d301120dc63b6e8 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 13:27:28 -0400 Subject: [PATCH 09/29] test(image-editor): per-feature store specs + store-utils unit tests (branch coverage) The single integration spec left ~40% of the store features' branches uncovered. Add focused unit specs that mount a minimal signalStore(withState, withX()) per feature and exercise every branch in isolation, plus a store-utils spec for the pure helpers: - image-editor.store-utils.spec.ts: coalesceHistory (append / in-place / redo-tail drop), rebuildHistory (first/middle/last), focalCenteredCrop (wide/tall/centered/ clamped), contextFromParams, the slice-patch helpers, errorMessage - features/with-*.feature.spec.ts: adjust (hue/grayscale on+off), transform (outputDims branches, scale===100 keep-crop, outputDimensions selector), crop, focal-point (no-natural-dims early return, no-reload anchor), file-info (quality), tools, history (not-found / redo-tail removal / undo-redo bounds / reset / appliedEdits / canUndo / canRedo) - store.spec.ts: add retryRequested and asset-load-failure cases (effect-level, kept in the integration spec) The general spec stays as the integration layer (composition, save exhaustMap, debounced size, cross-feature canSave/isBusy). Store features branch coverage 60% -> ~100%; 196 -> 257 tests. Refs #36063 --- .../features/with-adjust.feature.spec.ts | 71 ++++ .../store/features/with-crop.feature.spec.ts | 57 +++ .../features/with-file-info.feature.spec.ts | 44 ++ .../features/with-focal-point.feature.spec.ts | 83 ++++ .../features/with-history.feature.spec.ts | 152 +++++++ .../store/features/with-tools.feature.spec.ts | 35 ++ .../features/with-transform.feature.spec.ts | 114 ++++++ .../store/image-editor.store-utils.spec.ts | 378 ++++++++++++++++++ .../src/lib/store/image-editor.store.spec.ts | 26 ++ 9 files changed, 960 insertions(+) create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-adjust.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-crop.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-history.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-tools.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-transform.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/image-editor.store-utils.spec.ts 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-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-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..378292be1796 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-file-info.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 { 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); + }); +}); 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..676ce3ea8c3c --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts @@ -0,0 +1,83 @@ +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 { withFocalPoint } from './with-focal-point.feature'; + +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const FocalStore = signalStore(withState(initialImageEditorState), withFocalPoint()); +const FocalStoreSized = signalStore( + withState({ + ...initialImageEditorState, + assetContext: { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 + } + }), + withFocalPoint() +); + +function setup(StoreClass: Type) { + TestBed.configureTestingModule({ providers: [StoreClass, Dispatcher] }); + const injector = TestBed.inject(Injector); + const store = TestBed.inject(StoreClass); + let tool!: ReturnType>; + runInInjectionContext(injector, () => { + tool = injectDispatch(imageEditorToolEvents); + }); + + return { store, tool }; +} + +describe('withFocalPoint', () => { + it('records the focal point WITHOUT reloading the preview', () => { + const { store, tool } = setup(FocalStore); + + tool.focalPointSet({ x: 0.25, y: 0.75 }); + + expect(store.focalPoint()).toEqual({ x: 0.25, y: 0.75, active: true }); + expect(store.history().at(-1)?.category).toBe('focal'); + expect(store.history().at(-1)?.label).toBe('Focal point 0.25, 0.75'); + // It's a save-time anchor: no cache-bust, no loading. + expect(store.previewStatus()).toBe('idle'); + expect(store.cacheBust()).toBe(0); + }); + + it('clears the focal point back to the centered default', () => { + const { store, tool } = setup(FocalStore); + + tool.focalPointSet({ x: 0.25, y: 0.75 }); + tool.focalPointCleared(); + + expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); + }); + + it('derives a focal-centered aspect crop when natural dimensions are known', () => { + const { store, tool } = setup(FocalStoreSized); + + tool.focalPointSet({ x: 0.8, y: 0.5 }); + tool.aspectCropApplied({ aspect: 1, label: '1:1' }); + + // 1:1 of 1000×800 → 800×800; centered on x=0.8 clamps to the right edge (x=200). + expect(store.crop()).toEqual({ x: 200, y: 0, w: 800, h: 800, active: true, aspect: 1 }); + expect(store.transform().scale).toBe(100); + expect(store.activeTool()).toBe('move'); + expect(store.history().at(-1)?.category).toBe('crop'); + expect(store.previewStatus()).toBe('loading'); + }); + + it('ignores an aspect crop when natural dimensions are unknown', () => { + const { store, tool } = setup(FocalStore); // naturalWidth/Height are 0 + + tool.aspectCropApplied({ aspect: 1, label: '1:1' }); + + expect(store.crop().active).toBe(false); + expect(store.history()).toHaveLength(0); + expect(store.previewStatus()).toBe('idle'); + }); +}); 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-tools.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-tools.feature.spec.ts new file mode 100644 index 000000000000..feda4dbbcd04 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/features/with-tools.feature.spec.ts @@ -0,0 +1,35 @@ +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 { withTools } from './with-tools.feature'; + +import { imageEditorToolEvents } from '../image-editor.events'; +import { initialImageEditorState } from '../image-editor.state'; + +const ToolsStore = signalStore(withState(initialImageEditorState), withTools()); + +describe('withTools', () => { + let store: InstanceType; + let tool: ReturnType>; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ToolsStore, Dispatcher] }); + const injector = TestBed.inject(Injector); + store = TestBed.inject(ToolsStore); + runInInjectionContext(injector, () => { + tool = injectDispatch(imageEditorToolEvents); + }); + }); + + it('selects a tool without touching history', () => { + tool.toolSelected('crop'); + expect(store.activeTool()).toBe('crop'); + expect(store.history()).toHaveLength(0); + + tool.toolSelected('focal'); + expect(store.activeTool()).toBe('focal'); + }); +}); 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/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..c18844b117a9 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/store/image-editor.store-utils.spec.ts @@ -0,0 +1,378 @@ +import { initialImageEditorState } from './image-editor.state'; +import { + adjustPatch, + coalesceHistory, + contextFromParams, + editableSlicesOf, + errorMessage, + fileInfoPatch, + focalCenteredCrop, + 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 five editable slices', () => { + const slices = editableSlicesOf(initialImageEditorState); + + expect(Object.keys(slices).sort()).toEqual([ + 'adjust', + 'crop', + 'fileInfo', + 'focalPoint', + '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('focalCenteredCrop', () => { + it('uses the full width when the target aspect is wider than the image', () => { + const state = stateWith([], -1, { + assetContext: { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 + }, + focalPoint: { x: 0.5, y: 0.5, active: true } + }); + + // 16:9 (1.78) > 1000/800 (1.25): keep full width, derive a shorter height. + const crop = focalCenteredCrop(16 / 9, state); + + expect(crop.w).toBe(1000); + expect(crop.h).toBe(Math.round(1000 / (16 / 9))); + expect(crop.active).toBe(true); + }); + + it('uses the full height when the target aspect is taller than the image', () => { + const state = stateWith([], -1, { + assetContext: { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 + }, + focalPoint: { x: 0.5, y: 0.5, active: true } + }); + + // 1:1 (1.0) <= 1.25: keep full height, derive a narrower width. + const crop = focalCenteredCrop(1, state); + + expect(crop.h).toBe(800); + expect(crop.w).toBe(800); + }); + + it('centers on the image middle when no focal point is active', () => { + const state = stateWith([], -1, { + assetContext: { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 + }, + focalPoint: { x: 0.8, y: 0.8, active: false } + }); + + // Inactive focal → fx/fy default to 0.5: an 800×800 region centered → x=100, y=0. + const crop = focalCenteredCrop(1, state); + + expect(crop.x).toBe(100); + expect(crop.y).toBe(0); + }); + + it('clamps the crop origin to the image bounds for an off-center focal point', () => { + const state = stateWith([], -1, { + assetContext: { + ...initialImageEditorState.assetContext, + naturalWidth: 1000, + naturalHeight: 800 + }, + focalPoint: { x: 0.8, y: 0.5, active: true } + }); + + // 800 wide region centered on x=800 wants x=400 but clamps to 1000−800=200. + const crop = focalCenteredCrop(1, state); + + expect(crop.x).toBe(200); + expect(crop.y).toBe(0); + }); + }); + + 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.spec.ts b/core-web/libs/image-editor/src/lib/store/image-editor.store.spec.ts index 03901526b35d..3c10a67adf10 100644 --- 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 @@ -421,6 +421,23 @@ describe('ImageEditorStore', () => { 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', () => { @@ -463,6 +480,15 @@ describe('ImageEditorStore', () => { 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('save', () => { From 31f2c90699774931b47c3a41ed768e1c6be52d60 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 13:33:47 -0400 Subject: [PATCH 10/29] test(image-editor): add dimensions.util unit spec (clamp, resize, output dims) dimensions.util's pure functions were only exercised indirectly (74% branch). Add a direct spec covering every branch: clamp bounds, computeResizeParams (explicit both / width-only / height-only / scale / none) and computeOutputDimensions (natural, active crop, resize-supersedes-crop, explicit both, width-only and height-only aspect derivation, zero-height fallback, scale). 100% statements, 97% branch; 257 -> 274 tests. Refs #36063 --- .../src/lib/utils/dimensions.util.spec.ts | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 core-web/libs/image-editor/src/lib/utils/dimensions.util.spec.ts 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..2a68f5713e59 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/dimensions.util.spec.ts @@ -0,0 +1,153 @@ +import { clamp, computeOutputDimensions, computeResizeParams } from './dimensions.util'; + +import { + initialCropState, + initialImageEditorState, + initialTransformState +} from '../store/image-editor.state'; + +import { CropState, TransformState } from '../models/image-editor.models'; + +/** 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 }); + }); + }); +}); From 2ed9d8177322b082f5a0bcbf7447a733ec1d13a4 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 13:36:47 -0400 Subject: [PATCH 11/29] style(image-editor): fix import order in dimensions.util spec Refs #36063 --- .../libs/image-editor/src/lib/utils/dimensions.util.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 2a68f5713e59..ea113dbeac94 100644 --- 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 @@ -1,13 +1,12 @@ import { clamp, computeOutputDimensions, computeResizeParams } from './dimensions.util'; +import { CropState, TransformState } from '../models/image-editor.models'; import { initialCropState, initialImageEditorState, initialTransformState } from '../store/image-editor.state'; -import { CropState, TransformState } from '../models/image-editor.models'; - /** A transform slice with the given overrides on top of the defaults. */ const transform = (overrides: Partial = {}): TransformState => ({ ...initialTransformState, From c4d75eebf6b0960c25797ce7e275f18859db4b8b Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 13:56:20 -0400 Subject: [PATCH 12/29] refactor(image-editor): defer real save to a separate issue (preview + download only) Saving the edited image back to the field is its own issue, so remove the real-save path from this PR cleanly (no dead scaffolding): - service: drop saveEditedImage + persistFocalPoint (and #toTempFile) - store: replace withSave with withDownload (only the download$ effect); drop the save events (saveRequested/saveAsRequested/saveSucceeded/saveFailed), the saveStatus/savedTempFile state, SaveStatus type and SaveEditedImageResponse; isBusy now reflects only previewStatus - footer: drop the Save / Save-as split button (Cancel + Download remain) - root dialog: no longer closes with a saved temp file (closes via Cancel/Esc) - the Angular launcher already maps onClose -> null, so open() now resolves null - remove the orphaned footer.save/saving/save-as i18n keys - prune the corresponding tests The editor is now preview + edit + download; the save issue reintroduces the save flow as a cohesive unit. image-editor 258 tests green; edit-content unaffected (1961). Refs #36063 --- .../dot-image-editor-footer.component.html | 8 -- .../dot-image-editor-footer.component.spec.ts | 58 +-------- .../dot-image-editor-footer.component.ts | 47 +------ .../dot-image-editor.component.spec.ts | 23 +--- .../dot-image-editor.component.ts | 11 +- .../src/lib/models/image-editor.models.ts | 15 --- .../services/dot-image-editor.service.spec.ts | 90 +------------ .../lib/services/dot-image-editor.service.ts | 67 +--------- .../src/lib/store/features/index.ts | 2 +- .../store/features/with-download.feature.ts | 39 ++++++ .../store/features/with-preview.feature.ts | 6 +- .../lib/store/features/with-save.feature.ts | 123 ------------------ .../src/lib/store/image-editor.events.ts | 10 +- .../src/lib/store/image-editor.state.ts | 2 - .../src/lib/store/image-editor.store.spec.ts | 104 +-------------- .../src/lib/store/image-editor.store.ts | 10 +- .../WEB-INF/messages/Language.properties | 3 - 17 files changed, 73 insertions(+), 545 deletions(-) create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-download.feature.ts delete mode 100644 core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts 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 index baf984abbe2d..3ab259db1c56 100644 --- 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 @@ -13,12 +13,4 @@ [disabled]="store.isBusy()" (onClick)="onDownload()" data-testid="image-editor-download-btn" /> - -
    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 index ad4f9690c410..13ed57336818 100644 --- 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 @@ -27,38 +27,25 @@ describe('DotImageEditorFooterComponent', () => { let dispatcher: SpyObject; const isBusy = signal(false); - const canSave = signal(true); - const saveStatus = signal<'idle' | 'saving' | 'saved' | 'error'>('idle'); const createComponent = createComponentFactory({ component: DotImageEditorFooterComponent, imports: [DotMessagePipe], providers: [mockProvider(DotMessageService, { get: jest.fn((key: string) => key) })], - componentProviders: [ - Dispatcher, - mockProvider(ImageEditorStore, { - isBusy, - canSave, - saveStatus, - assetContext: () => ({ fileName: 'x.jpg' }) - }) - ] + componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { isBusy })] }); beforeEach(() => { isBusy.set(false); - canSave.set(true); - saveStatus.set('idle'); spectator = createComponent(); dispatcher = spectator.inject(Dispatcher, true); jest.spyOn(dispatcher, 'dispatch'); }); - it('should render the action buttons', () => { + 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(); - expect(spectator.query(byTestId('image-editor-save-btn'))).toBeTruthy(); }); it('should emit cancel when Cancel is clicked', () => { @@ -78,43 +65,10 @@ describe('DotImageEditorFooterComponent', () => { ); }); - it('should dispatch saveRequested when Save is clicked', () => { - spectator.click(nativeButton(spectator, 'image-editor-save-btn')); + it('should disable Download when isBusy is true', () => { + isBusy.set(true); + spectator.detectChanges(); - expect(dispatcher.dispatch).toHaveBeenCalledWith( - imageEditorLifecycleEvents.saveRequested(), - { - scope: 'self' - } - ); - }); - - it('should expose a "Save as…" menu item that dispatches saveAsRequested', () => { - const saveAsItem = spectator.component['saveMenuItems'][0]; - - expect(saveAsItem.label).toBe('edit.content.image-editor.footer.save-as'); - - saveAsItem.command?.({}); - - expect(dispatcher.dispatch).toHaveBeenCalledWith( - imageEditorLifecycleEvents.saveAsRequested({ fileName: 'x.jpg' }), - { scope: 'self' } - ); - }); - - describe('disabled states', () => { - it('should disable Save when canSave is false', () => { - canSave.set(false); - spectator.detectChanges(); - - expect(nativeButton(spectator, 'image-editor-save-btn').disabled).toBe(true); - }); - - it('should disable Download when isBusy is true', () => { - isBusy.set(true); - spectator.detectChanges(); - - expect(nativeButton(spectator, 'image-editor-download-btn').disabled).toBe(true); - }); + 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 index e8b8ddfcb521..b30e4cf03707 100644 --- 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 @@ -1,12 +1,9 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; -import { MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; -import { SplitButtonModule } from 'primeng/splitbutton'; -import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; import { imageEditorLifecycleEvents } from '../../store/image-editor.events'; @@ -14,60 +11,28 @@ import { ImageEditorStore } from '../../store/image-editor.store'; /** * Footer action bar of the image editor dialog. Reads readiness from the - * {@link ImageEditorStore} (`isBusy`, `canSave`, `saveStatus`) and dispatches the - * download / save / save-as lifecycle events. Cancel is surfaced as an output so - * the owning dialog component controls closing. + * {@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, SplitButtonModule, DotMessagePipe], + imports: [ButtonModule, DotMessagePipe], templateUrl: './dot-image-editor-footer.component.html' }) export class DotImageEditorFooterComponent { - readonly #dotMessageService = inject(DotMessageService); - /** Image editor state store, provided by the owning dialog component. */ protected readonly store = inject(ImageEditorStore); - /** Lifecycle event dispatcher for download/save/save-as actions. */ + /** 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(); - /** Label shown on the primary save button, reflecting the in-flight save status. */ - protected readonly saveLabel = computed(() => - this.store.saveStatus() === 'saving' - ? this.#dotMessageService.get('edit.content.image-editor.footer.saving') - : this.#dotMessageService.get('edit.content.image-editor.footer.save') - ); - - /** Spinner icon shown on the save button only while a save is in flight. */ - protected readonly saveIcon = computed(() => - this.store.saveStatus() === 'saving' ? 'pi pi-spin pi-spinner' : '' - ); - - /** Secondary save actions; currently only "Save as…". */ - protected readonly saveMenuItems: MenuItem[] = [ - { - label: this.#dotMessageService.get('edit.content.image-editor.footer.save-as'), - command: () => this.onSaveAs() - } - ]; - /** Dispatches a download of the current preview. */ protected onDownload(): void { this.dispatch.downloadRequested(); } - - /** Dispatches a save of the current edits. */ - protected onSave(): void { - this.dispatch.saveRequested(); - } - - /** Dispatches a save-as using the current asset file name. */ - protected onSaveAs(): void { - this.dispatch.saveAsRequested({ fileName: this.store.assetContext().fileName }); - } } 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 index 2f17c3905c72..de794126482c 100644 --- 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 @@ -15,7 +15,6 @@ import { Confirmation, ConfirmationService } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; import { DotImageEditorComponent } from './dot-image-editor.component'; @@ -30,17 +29,6 @@ import { DotImageEditorFooterComponent } from '../dot-image-editor-footer/dot-im import { DotImageEditorHeaderComponent } from '../dot-image-editor-header/dot-image-editor-header.component'; import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-image-editor-panels.component'; -const TEMP_FILE: DotCMSTempFile = { - id: 'temp_saved', - fileName: 'edited.jpg', - length: 1024, - folder: '', - image: true, - mimeType: 'image/jpeg', - referenceUrl: '', - thumbnailUrl: '' -}; - /** 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})`, () => { @@ -49,7 +37,6 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { let dialogRef: SpyObject; let confirmationService: SpyObject; - const savedTempFile = signal(null); const isDirty = signal(false); const canUndo = signal(false); const canRedo = signal(false); @@ -69,7 +56,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { // mocking only the store. componentProviders: [ ConfirmationService, - mockProvider(ImageEditorStore, { savedTempFile, isDirty, canUndo, canRedo }) + mockProvider(ImageEditorStore, { isDirty, canUndo, canRedo }) ], // Isolate the shell from the children's own store/dispatch wiring. overrideComponents: [ @@ -102,7 +89,6 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { // describe; clear it so prior tests' close() calls don't leak into the // "not closed" assertions. jest.clearAllMocks(); - savedTempFile.set(null); isDirty.set(false); canUndo.set(false); canRedo.set(false); @@ -134,13 +120,6 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { ); }); - it('should close with the saved temp file once a save succeeds', () => { - savedTempFile.set(TEMP_FILE); - spectator.detectChanges(); - - expect(dialogRef.close).toHaveBeenCalledWith(TEMP_FILE); - }); - it('should close with null when the header close is triggered and not dirty', () => { const header = spectator.query(DotImageEditorHeaderComponent)!; header.close.emit(); 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 index e2ac89efa95e..140b15a2c99c 100644 --- 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 @@ -1,6 +1,6 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; @@ -66,15 +66,6 @@ export class DotImageEditorComponent { constructor() { this.#dispatch.assetRequested(this.#config.data as ImageEditorOpenParams); - - // Close the dialog with the saved file the moment a save succeeds. - effect(() => { - const savedTempFile = this.store.savedTempFile(); - - if (savedTempFile) { - this.#dialogRef.close(savedTempFile); - } - }); } /** 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 index 17a7898885b1..9484699b4a67 100644 --- 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 @@ -24,9 +24,6 @@ export interface ImageRect { /** Loading lifecycle of the preview image. */ export type PreviewStatus = 'idle' | 'loading' | 'loaded' | 'error'; -/** Lifecycle of a save / save-as operation. */ -export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; - /** Logical category an applied edit belongs to, used for grouping and labels. */ export type FilterCategory = | 'adjust' @@ -229,10 +226,6 @@ export interface ImageEditorState { previewStatus: PreviewStatus; /** Consecutive silent retries of the current failing preview (reset on success). */ previewRetries: number; - /** Lifecycle of the current save / save-as operation. */ - saveStatus: SaveStatus; - /** Temp file produced by the last successful save, or `null`. */ - savedTempFile: DotCMSTempFile | null; /** Last error message surfaced to the user, or `null`. */ error: string | null; /** Ordered undo/redo history of applied edits. */ @@ -300,14 +293,6 @@ export interface NormalizedPoint { y: number; } -/** Shape of the save endpoint JSON response used to build a {@link DotCMSTempFile}. */ -export interface SaveEditedImageResponse { - id: string; - fileName: string; - length: number; - metadata?: DotCMSTempFile['metadata']; -} - /** Natural pixel dimensions of an image resolved from the browser. */ export interface NaturalDimensions { naturalWidth: number; 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 index 2463cc2f8e82..3531f582dfb6 100644 --- 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 @@ -1,6 +1,6 @@ import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; -import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { DotImageEditorService } from './dot-image-editor.service'; @@ -143,94 +143,6 @@ describe('DotImageEditorService', () => { }); }); - describe('saveEditedImage', () => { - const tempResponse = { - id: 'temp_123', - fileName: 'edited.png', - length: 4096, - metadata: { contentType: 'image/png' } - }; - - it('should GET a URL with binaryFieldId and _imageToolSaveFile and map to a temp file', () => { - const result: unknown[] = []; - spectator.service - .saveEditedImage('/dA/asset.png/filter/Grayscale/grayscale/1', 'fileField') - .subscribe((file) => result.push(file)); - - const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); - expect(req.request.method).toBe('GET'); - expect(req.request.urlWithParams).toContain('binaryFieldId=fileField'); - expect(req.request.urlWithParams).toContain('_imageToolSaveFile=true'); - req.flush(tempResponse); - - expect(result[0]).toEqual({ - id: 'temp_123', - fileName: 'edited.png', - length: 4096, - metadata: { contentType: 'image/png' }, - folder: '', - image: true, - mimeType: 'image/png', - referenceUrl: '', - thumbnailUrl: '' - }); - }); - - it('should append the save tokens with & when the filter URL already has a query', () => { - spectator.service.saveEditedImage('/dA/asset.png?test=1', 'fileField').subscribe(); - - const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); - expect(req.request.urlWithParams).toContain('test=1'); - expect(req.request.urlWithParams).toContain('&binaryFieldId=fileField'); - req.flush(tempResponse); - }); - - it('should rethrow the original error without surfacing it (the store handles it)', () => { - let caughtError: unknown; - spectator.service.saveEditedImage('/dA/asset.png', 'fileField').subscribe({ - error: (error) => (caughtError = error) - }); - - httpMock - .expectOne((request) => request.url.startsWith('/dA/asset.png')) - .flush('boom', { status: 500, statusText: 'Server Error' }); - - // The service stays a pure rethrow pipe: the ORIGINAL HttpErrorResponse - // reaches the caller unchanged (not swallowed, not wrapped), so the - // store's tapResponse is the single place that surfaces it (no double toast). - expect(caughtError).toBeInstanceOf(HttpErrorResponse); - expect((caughtError as HttpErrorResponse).status).toBe(500); - }); - }); - - describe('persistFocalPoint', () => { - it('should GET the FocalPoint filter URL with an overwrite cache-buster', () => { - spectator.service.persistFocalPoint('/dA/asset.png', { x: 0.25, y: 0.75 }).subscribe(); - - const req = httpMock.expectOne((request) => request.url.startsWith('/dA/asset.png')); - expect(req.request.method).toBe('GET'); - expect(req.request.urlWithParams).toContain('/filter/FocalPoint/fp/0.25,0.75/'); - expect(req.request.urlWithParams).toContain('overwrite='); - req.flush(''); - }); - - it('should complete with void on HTTP error without throwing', () => { - let completed = false; - let errored = false; - spectator.service.persistFocalPoint('/dA/asset.png', { x: 0.5, y: 0.5 }).subscribe({ - complete: () => (completed = true), - error: () => (errored = true) - }); - - httpMock - .expectOne((request) => request.url.startsWith('/dA/asset.png')) - .flush('boom', { status: 500, statusText: 'Server Error' }); - - expect(completed).toBe(true); - expect(errored).toBe(false); - }); - }); - describe('triggerDownload', () => { it('should create an anchor with href/download and click it', () => { const anchor = document.createElement('a'); 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 index 7c365767f21b..9ab36720a74d 100644 --- 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 @@ -1,25 +1,21 @@ -import { Observable, forkJoin, of, throwError } from 'rxjs'; +import { Observable, forkJoin, of } from 'rxjs'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { catchError, map } from 'rxjs/operators'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - import { AssetMeta, ImageEditorAssetContext, - NaturalDimensions, - SaveEditedImageResponse + NaturalDimensions } from '../models/image-editor.models'; /** - * Data-access service for the image editor: resolves asset metadata, queries - * file sizes, persists the saved/edited image and the focal point, and triggers - * client-side downloads. All read-only/metadata calls are non-fatal and never - * throw; only {@link saveEditedImage} rethrows so the store can keep the modal - * open on failure. + * 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 { @@ -81,41 +77,6 @@ export class DotImageEditorService { ); } - /** - * Persists the edited image to a temp file by hitting the filter URL with - * the save tokens appended. - * @param filterUrl - The fully-built filter/preview URL for the edited image - * @param variable - The binary field id the saved file should target - * @returns The resulting temp file - * @throws Rethrows the original error (without surfacing it) so the caller — - * the store — is the single place that shows the error and keeps the editor - * open on failure - */ - saveEditedImage(filterUrl: string, variable: string): Observable { - const separator = filterUrl.includes('?') ? '&' : '?'; - const url = `${filterUrl}${separator}binaryFieldId=${encodeURIComponent(variable)}&_imageToolSaveFile=true`; - - return this.#http.get(url).pipe( - map((res) => this.#toTempFile(res)), - catchError((error: HttpErrorResponse) => throwError(() => error)) - ); - } - - /** - * Persists the focal point for an asset so it is honored by future renders. - * @param originalUrl - The base URL of the unfiltered original asset - * @param fp - Normalized focal point coordinates - * @returns Completes with `void`; non-fatal and never throws - */ - persistFocalPoint(originalUrl: string, fp: { x: number; y: number }): Observable { - const url = `${originalUrl}/filter/FocalPoint/fp/${fp.x},${fp.y}/?overwrite=${Date.now()}`; - - return this.#http.get(url, { responseType: 'text' }).pipe( - map(() => void 0), - catchError(() => of(void 0)) - ); - } - /** * Resolves the metadata needed to seed the editor: natural dimensions and * the original byte size. Always emits a safe default on error and never @@ -174,18 +135,4 @@ export class DotImageEditorService { image.src = url; }); } - - #toTempFile(res: SaveEditedImageResponse): DotCMSTempFile { - return { - id: res.id, - fileName: res.fileName, - length: res.length, - metadata: res.metadata, - folder: '', - image: true, - mimeType: res.metadata?.contentType ?? '', - referenceUrl: '', - thumbnailUrl: '' - }; - } } 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 index 996422830dde..2bb39329190c 100644 --- a/core-web/libs/image-editor/src/lib/store/features/index.ts +++ b/core-web/libs/image-editor/src/lib/store/features/index.ts @@ -1,10 +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 { withSave } from './with-save.feature'; export { withTools } from './with-tools.feature'; export { withTransform } from './with-transform.feature'; 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-preview.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts index 9dcca38a9db7..80810e74d258 100644 --- 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 @@ -81,10 +81,8 @@ export function withPreview() { ), /** Whether any edit produces a non-empty filter chain. */ isDirty: computed(() => appliedFilters().length > 0), - /** Whether the editor is mid-flight loading a preview or saving. */ - isBusy: computed( - () => store.previewStatus() === 'loading' || store.saveStatus() === 'saving' - ) + /** Whether the editor is mid-flight loading a preview. */ + isBusy: computed(() => store.previewStatus() === 'loading') }; }), withEventHandlers((store) => { diff --git a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts deleted file mode 100644 index abffeb9f8bbe..000000000000 --- a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { tapResponse } from '@ngrx/operators'; -import { signalStoreFeature, type, withComputed } from '@ngrx/signals'; -import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; -import { EMPTY } from 'rxjs'; - -import { HttpErrorResponse } from '@angular/common/http'; -import { computed, inject, Signal } from '@angular/core'; - -import { catchError, exhaustMap, switchMap, tap } from 'rxjs/operators'; - -import { DotHttpErrorManagerService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - -import { AppliedFilter, ImageEditorState } from '../../models/image-editor.models'; -import { DotImageEditorService } from '../../services/dot-image-editor.service'; -import { imageEditorLifecycleEvents } from '../image-editor.events'; -import { errorMessage } from '../image-editor.store-utils'; - -/** - * Save feature: persisting the edited image and triggering downloads. Consumes - * the `previewUrl` and `appliedFilters` selectors from {@link withPreview} (so it - * composes after it), exposes `canSave`, persists the focal point before saving - * and keeps the editor open on failure (the store is the single error surface). - */ -export function withSave() { - return signalStoreFeature( - type<{ - state: ImageEditorState; - props: { previewUrl: Signal; appliedFilters: Signal }; - }>(), - withReducer( - on(imageEditorLifecycleEvents.saveRequested, (_event, state) => ({ - ...state, - saveStatus: 'saving' as const - })), - on(imageEditorLifecycleEvents.saveAsRequested, (_event, state) => ({ - ...state, - saveStatus: 'saving' as const - })), - on(imageEditorLifecycleEvents.saveSucceeded, ({ payload }, state) => ({ - ...state, - savedTempFile: payload, - saveStatus: 'saved' as const - })), - on(imageEditorLifecycleEvents.saveFailed, ({ payload }, state) => ({ - ...state, - saveStatus: 'error' as const, - error: errorMessage(payload, 'Failed to save image') - })) - ), - withComputed((store) => ({ - /** Whether a save can be initiated right now. */ - canSave: computed( - () => - store.previewStatus() === 'loaded' && - store.saveStatus() !== 'saving' && - store.appliedFilters().length > 0 - ) - })), - withEventHandlers((store) => { - const events = inject(Events); - const dispatcher = inject(Dispatcher); - const service = inject(DotImageEditorService); - const httpErrorManager = inject(DotHttpErrorManagerService); - - // Save the edited image and dispatch the outcome. - const saveEditedImage$ = () => - service.saveEditedImage(store.previewUrl(), store.assetContext().variable).pipe( - tapResponse({ - next: (tempFile: DotCMSTempFile) => - dispatcher.dispatch(imageEditorLifecycleEvents.saveSucceeded(tempFile)), - error: (error: HttpErrorResponse) => { - // Surface the error but keep the editor open for retry. - httpErrorManager.handle(error); - dispatcher.dispatch(imageEditorLifecycleEvents.saveFailed(error)); - } - }), - // Swallow the rethrown error so the effect stream stays alive. - catchError(() => EMPTY) - ); - - return { - // Persist the focal point first (when active), then save the image. - // `exhaustMap` ignores new save triggers while one is in flight: a - // destructive write must not be cancelled mid-flight, which would - // strand `saveStatus: 'saving'` with no terminal event. - save$: events - .on( - imageEditorLifecycleEvents.saveRequested, - imageEditorLifecycleEvents.saveAsRequested - ) - .pipe( - exhaustMap(() => { - const focalPoint = store.focalPoint(); - - if (!focalPoint.active) { - return saveEditedImage$(); - } - - return service - .persistFocalPoint(store.assetContext().originalUrl, { - x: focalPoint.x, - y: focalPoint.y - }) - .pipe(switchMap(() => saveEditedImage$())); - }) - ), - - // Trigger a client-side download of the current preview. - 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/image-editor.events.ts b/core-web/libs/image-editor/src/lib/store/image-editor.events.ts index 82db7a0840a1..57a66fb6733e 100644 --- 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 @@ -1,8 +1,6 @@ import { type } from '@ngrx/signals'; import { eventGroup } from '@ngrx/signals/events'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - import { ActiveTool, CompressionMode, @@ -68,7 +66,7 @@ export const imageEditorHistoryEvents = eventGroup({ } }); -/** Events covering the editor lifecycle: load, preview, download and save. */ +/** Events covering the editor lifecycle: load, preview and download. */ export const imageEditorLifecycleEvents = eventGroup({ source: 'Image Editor Lifecycle', events: { @@ -77,8 +75,6 @@ export const imageEditorLifecycleEvents = eventGroup({ previewErrored: type(), retryRequested: type(), downloadRequested: type(), - saveRequested: type(), - saveAsRequested: type<{ fileName: string }>(), assetLoaded: type<{ naturalWidth: number; naturalHeight: number; @@ -86,8 +82,6 @@ export const imageEditorLifecycleEvents = eventGroup({ focalPoint?: FocalPointState; }>(), assetLoadFailed: type(), - previewSizeResolved: type(), - saveSucceeded: type(), - saveFailed: 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 index 9ba2d4e03491..218be94fa166 100644 --- 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 @@ -87,8 +87,6 @@ export const initialImageEditorState: ImageEditorState = { activeTool: 'move', previewStatus: 'idle', previewRetries: 0, - saveStatus: 'idle', - savedTempFile: null, error: null, history: [], historyIndex: -1, 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 index 3c10a67adf10..52312bcba8ed 100644 --- 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 @@ -1,13 +1,11 @@ import { mockProvider, SpyObject } from '@ngneat/spectator/jest'; import { Dispatcher, injectDispatch } from '@ngrx/signals/events'; -import { of, Subject, throwError } from 'rxjs'; +import { of, throwError } from 'rxjs'; -import { HttpErrorResponse } from '@angular/common/http'; import { Injector, runInInjectionContext } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; +import { DotMessageService } from '@dotcms/data-access'; import { imageEditorAdjustEvents, @@ -22,17 +20,6 @@ import { ImageEditorStore } from './image-editor.store'; import { ImageEditorOpenParams } from '../models/image-editor.models'; import { DotImageEditorService } from '../services/dot-image-editor.service'; -const TEMP_FILE: DotCMSTempFile = { - id: 'temp-123', - fileName: 'edited.png', - folder: '', - image: true, - length: 2048, - mimeType: 'image/png', - referenceUrl: '', - thumbnailUrl: '' -}; - const OPEN_PARAMS: ImageEditorOpenParams = { inode: 'inode-1', variable: 'fileAsset', @@ -44,7 +31,6 @@ const OPEN_PARAMS: ImageEditorOpenParams = { describe('ImageEditorStore', () => { let store: InstanceType; let service: SpyObject; - let httpErrorManager: SpyObject; let injector: Injector; // Self-dispatching event groups, resolved within the injection context. @@ -69,13 +55,8 @@ describe('ImageEditorStore', () => { .mockReturnValue( of({ naturalWidth: 800, naturalHeight: 600, originalBytes: 5000 }) ), - persistFocalPoint: jest.fn().mockReturnValue(of(void 0)), - saveEditedImage: jest.fn().mockReturnValue(of(TEMP_FILE)), triggerDownload: jest.fn() }), - mockProvider(DotHttpErrorManagerService, { - handle: jest.fn().mockReturnValue(of({})) - }), mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) ] }); @@ -84,9 +65,6 @@ describe('ImageEditorStore', () => { // Instantiating the store runs its `onInit` effects. store = TestBed.inject(ImageEditorStore); service = TestBed.inject(DotImageEditorService) as SpyObject; - httpErrorManager = TestBed.inject( - DotHttpErrorManagerService - ) as SpyObject; runInInjectionContext(injector, () => { adjust = injectDispatch(imageEditorAdjustEvents); @@ -374,16 +352,6 @@ describe('ImageEditorStore', () => { lifecycle.previewLoaded(); expect(store.isBusy()).toBe(false); }); - - it('canSave requires loaded preview, not saving and dirty', () => { - expect(store.canSave()).toBe(false); - - adjust.brightnessChanged(20); - expect(store.canSave()).toBe(false); // still loading - - lifecycle.previewLoaded(); - expect(store.canSave()).toBe(true); - }); }); describe('preview lifecycle', () => { @@ -491,74 +459,6 @@ describe('ImageEditorStore', () => { }); }); - describe('save', () => { - it('success sets the saved temp file and saved status', () => { - lifecycle.assetRequested(OPEN_PARAMS); - adjust.brightnessChanged(20); - lifecycle.saveRequested(); - - expect(service.saveEditedImage).toHaveBeenCalled(); - expect(store.savedTempFile()).toEqual(TEMP_FILE); - expect(store.saveStatus()).toBe('saved'); - }); - - it('persists the focal point before saving when active', () => { - lifecycle.assetRequested(OPEN_PARAMS); - tool.focalPointSet({ x: 0.3, y: 0.4 }); - lifecycle.saveRequested(); - - expect(service.persistFocalPoint).toHaveBeenCalledWith(expect.any(String), { - x: 0.3, - y: 0.4 - }); - expect(service.saveEditedImage).toHaveBeenCalled(); - }); - - it('does not persist the focal point when inactive', () => { - lifecycle.assetRequested(OPEN_PARAMS); - adjust.brightnessChanged(20); - lifecycle.saveRequested(); - - expect(service.persistFocalPoint).not.toHaveBeenCalled(); - }); - - it('error path handles the error and sets save status to error', () => { - const error = new HttpErrorResponse({ status: 500 }); - service.saveEditedImage.mockReturnValue(throwError(() => error)); - - lifecycle.assetRequested(OPEN_PARAMS); - adjust.brightnessChanged(20); - lifecycle.saveRequested(); - - expect(httpErrorManager.handle).toHaveBeenCalledWith(error); - expect(store.saveStatus()).toBe('error'); - }); - - it('ignores a second save while one is in flight and never strands saving', () => { - // Defer the first save so a second request arrives mid-flight. - const inFlight = new Subject(); - service.saveEditedImage.mockReturnValue(inFlight.asObservable()); - - lifecycle.assetRequested(OPEN_PARAMS); - adjust.brightnessChanged(20); - - lifecycle.saveRequested(); - expect(store.saveStatus()).toBe('saving'); - - // A second trigger while saving must be ignored (exhaustMap), not - // cancel the first and strand the store in 'saving'. - lifecycle.saveAsRequested(); - - // Complete the original save. - inFlight.next(TEMP_FILE); - inFlight.complete(); - - expect(service.saveEditedImage).toHaveBeenCalledTimes(1); - expect(store.savedTempFile()).toEqual(TEMP_FILE); - expect(store.saveStatus()).toBe('saved'); - }); - }); - describe('download', () => { it('triggers a download of the current preview', () => { lifecycle.assetRequested(OPEN_PARAMS); 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 index 766976ca569f..2c618fb2b3c5 100644 --- 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 @@ -4,11 +4,11 @@ import { withAdjust, withAsset, withCrop, + withDownload, withFileInfo, withFocalPoint, withHistory, withPreview, - withSave, withTools, withTransform } from './features'; @@ -22,9 +22,9 @@ import { initialImageEditorState } from './image-editor.state'; * single place (`features/with-*.feature.ts`). * * Order matters only where features consume each other's selectors: `withPreview` - * derives `appliedFilters` / `previewUrl`, which `withSave` reads — so save - * composes after preview. The store is NOT provided in root; the editor dialog - * supplies it so each editor instance is isolated. + * 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), @@ -37,5 +37,5 @@ export const ImageEditorStore = signalStore( withHistory(), withAsset(), withPreview(), - withSave() + withDownload() ); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index aaa6372c99df..f22bce98d58b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6475,9 +6475,6 @@ 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.footer.save=Save -edit.content.image-editor.footer.save-as=Save as… -edit.content.image-editor.footer.saving=Saving… 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 From 7294651593bc9786c4d693979bba5ea37d81ca1a Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 22 Jun 2026 16:09:22 -0400 Subject: [PATCH 13/29] feat(image-editor): full-screen toggle with animated resize - Repurpose the canvas maximize control as a full-screen toggle that expands the dialog to the viewport and back, easing the resize and honouring prefers-reduced-motion; move fit-to-screen onto the zoom-value control so it is preserved. - Resize the host PrimeNG dialog via the injected Dialog instance (container()) rather than a DOM query. - Group the editor's transient view state into one withView feature (active tool + isFullscreen), replacing withTools. - Consolidate full-screen style/transition constants into image-editor.constants.ts; add full-screen i18n keys. Refs #36062 --- ...ot-image-editor-address-bar.component.html | 32 ++++++++--- ...ot-image-editor-address-bar.component.scss | 12 +++++ ...image-editor-address-bar.component.spec.ts | 30 +++++++++-- .../dot-image-editor-address-bar.component.ts | 18 +++++-- .../dot-image-editor.component.spec.ts | 4 +- .../dot-image-editor.component.ts | 54 ++++++++++++++++++- .../src/lib/image-editor.constants.ts | 17 ++++++ .../src/lib/models/image-editor.models.ts | 2 + .../src/lib/store/features/index.ts | 2 +- .../lib/store/features/with-tools.feature.ts | 22 -------- ...ture.spec.ts => with-view.feature.spec.ts} | 26 ++++++--- .../lib/store/features/with-view.feature.ts | 29 ++++++++++ .../src/lib/store/image-editor.events.ts | 8 +++ .../src/lib/store/image-editor.state.ts | 3 +- .../src/lib/store/image-editor.store.ts | 6 +-- .../WEB-INF/messages/Language.properties | 2 + 16 files changed, 215 insertions(+), 52 deletions(-) delete mode 100644 core-web/libs/image-editor/src/lib/store/features/with-tools.feature.ts rename core-web/libs/image-editor/src/lib/store/features/{with-tools.feature.spec.ts => with-view.feature.spec.ts} (51%) create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-view.feature.ts 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 index 8ebcf4218e6d..47a88cf0be4a 100644 --- 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 @@ -30,9 +30,16 @@ [pTooltip]="'edit.content.image-editor.zoom.out.aria' | dm" data-testid="image-editor-zoom-out-btn" (click)="zoomOut.emit()"> - + + + [attr.aria-pressed]="store.isFullscreen()" + [attr.aria-label]=" + (store.isFullscreen() + ? 'edit.content.image-editor.fullscreen.exit.aria' + : 'edit.content.image-editor.fullscreen.enter.aria' + ) | dm + " + [pTooltip]=" + (store.isFullscreen() + ? 'edit.content.image-editor.fullscreen.exit.aria' + : 'edit.content.image-editor.fullscreen.enter.aria' + ) | dm + " + data-testid="image-editor-fullscreen-btn" + (click)="toggleFullscreen()">
    @if (store.previewStatus() === 'loading') { 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 index 85240007fe90..77e65e94d92e 100644 --- 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 @@ -72,6 +72,22 @@ describe('DotImageEditorFocalOverlayComponent', () => { 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(); 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 index 8e1ca8831b5a..e89953579e26 100644 --- 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 @@ -41,6 +41,15 @@ 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); @@ -154,9 +163,14 @@ export class DotImageEditorFocalOverlayComponent { 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 localX = clientX - host.left - rect.x; - const localY = clientY - host.top - rect.y; + 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); } From a49fa4881c6611603cf8dfeb0abae7799adb4623 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 24 Jun 2026 10:20:30 -0400 Subject: [PATCH 18/29] =?UTF-8?q?style(image-editor):=20light=20canvas=20?= =?UTF-8?q?=E2=80=94=20white=20viewer,=20off-white=20header/footer=20bands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the editor canvas from dark chrome to a light theme: the image viewer is white and the address-bar header and footer bands sit one shade darker than white (surface-100) with muted dark text/icons and hairline dividers. The focal aspect pills and error overlay are re-toned for the light surface; the floating tool rail stays dark over the image for contrast. --- ...ot-image-editor-address-bar.component.scss | 11 +++++---- .../dot-image-editor-canvas.component.scss | 23 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) 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 index 229a95b7e811..2f778a8caf3b 100644 --- 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 @@ -4,10 +4,11 @@ justify-content: space-between; gap: 1rem; padding: 0.5rem 0.75rem; - background-color: rgba(28, 30, 38, 0.92); - // Slightly-less-than-white text, matching the design's dark-chrome treatment - // (resting elements at 78% white; full white is reserved for hover/active). - color: rgba(255, 255, 255, 0.78); + // Header band a touch darker than the white viewer, with a hairline divider. + background-color: var(--surface-100, #f1f3f5); + // Muted dark text/icons at rest; full-strength dark is reserved for hover/active. + color: var(--surface-600, #4b5563); + border-bottom: 1px solid var(--surface-200, #e5e7eb); } .address-bar__group { @@ -43,6 +44,6 @@ cursor: pointer; &:hover { - color: #fff; + color: var(--surface-900, #111827); } } 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 index ca7806e72781..75f93963e370 100644 --- 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 @@ -12,8 +12,8 @@ width: 100%; height: 100%; min-height: 0; - background-color: #11131a; - color: var(--surface-0, #ffffff); + background-color: var(--surface-0, #ffffff); + color: var(--surface-900, #1f2937); } .canvas__address-bar { @@ -36,7 +36,7 @@ padding: 1.5rem; } -// Bottom band mirroring `.canvas__address-bar`: same dark background and padding, +// Bottom band mirroring `.canvas__address-bar`: same light background and padding, // always present so the stage has symmetric top and bottom bands. Hosts the // active tool's actions, right-aligned. .canvas__footer { @@ -48,11 +48,12 @@ justify-content: flex-end; gap: 0.5rem; padding: 0.5rem 0.75rem; - background-color: rgba(28, 30, 38, 0.92); - color: var(--surface-0, #ffffff); + background-color: var(--surface-100, #f1f3f5); + color: var(--surface-700, #374151); + border-top: 1px solid var(--surface-200, #e5e7eb); } -// Aspect presets in the (dark) focal crop bar: light pills, the selected one +// Aspect presets in the (light) focal crop bar: outlined pills, the selected one // filled with the primary color. .canvas__aspects { display: flex; @@ -61,10 +62,10 @@ .canvas__aspect { padding: 0.25rem 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid var(--surface-300, #cbd5e1); border-radius: 4px; background: transparent; - color: rgba(255, 255, 255, 0.78); + color: var(--surface-700, #374151); font-size: 0.8125rem; font-variant-numeric: tabular-nums; cursor: pointer; @@ -74,8 +75,8 @@ } .canvas__aspect:hover { - background: rgba(255, 255, 255, 0.1); - color: #ffffff; + background: var(--surface-200, #e5e7eb); + color: var(--surface-900, #111827); } .canvas__aspect--active { @@ -193,7 +194,7 @@ gap: 0.75rem; padding: 2rem; text-align: center; - background-color: rgba(17, 19, 26, 0.85); + background-color: rgba(255, 255, 255, 0.85); } .canvas__error-icon { From 71dc266a8bfed2d561df20da66fddd4014048e65 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 24 Jun 2026 13:45:33 -0400 Subject: [PATCH 19/29] refactor(image-editor): move full-screen toggle to the dialog header next to close Relocate the full-screen toggle from the canvas address bar to the editor header, grouped with the close (X) button as the dialog's window controls. The header now dispatches imageEditorViewEvents; the address bar keeps the URL, zoom and undo/redo controls. Tests moved accordingly. --- ...ot-image-editor-address-bar.component.html | 21 --------- ...image-editor-address-bar.component.spec.ts | 26 +---------- .../dot-image-editor-address-bar.component.ts | 20 +++------ .../dot-image-editor-header.component.html | 36 ++++++++++++--- .../dot-image-editor-header.component.spec.ts | 44 +++++++++++++++++-- .../dot-image-editor-header.component.ts | 26 +++++++++-- 6 files changed, 100 insertions(+), 73 deletions(-) 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 index 47a88cf0be4a..c81fabbcef87 100644 --- 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 @@ -50,27 +50,6 @@ [pTooltip]="'edit.content.image-editor.zoom.in.aria' | dm" data-testid="image-editor-zoom-in-btn" (click)="zoomIn.emit()"> -
    @if (store.previewStatus() === 'loading') { @@ -74,44 +74,71 @@ data-testid="image-editor-retry-btn" /> } - - - - 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 index 75f93963e370..4ec04ccb922d 100644 --- 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 @@ -36,25 +36,55 @@ padding: 1.5rem; } -// Bottom band mirroring `.canvas__address-bar`: same light background and padding, -// always present so the stage has symmetric top and bottom bands. Hosts the -// active tool's actions, right-aligned. -.canvas__footer { - flex: 0 0 auto; - // Match the address bar's band height (top/bottom symmetry). - min-height: 3.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; - justify-content: flex-end; gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--surface-100, #f1f3f5); + // 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); - border-top: 1px solid var(--surface-200, #e5e7eb); + // 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) focal crop bar: outlined pills, the selected one -// filled with the primary color. +// Aspect presets in the (light) floating crop action bar: plain dark-gray text +// pills, the selected one filled with the primary color. .canvas__aspects { display: flex; gap: 0.25rem; @@ -62,10 +92,10 @@ .canvas__aspect { padding: 0.25rem 0.75rem; - border: 1px solid var(--surface-300, #cbd5e1); - border-radius: 4px; + border: none; + border-radius: 6px; background: transparent; - color: var(--surface-700, #374151); + color: var(--surface-600, #4b5563); font-size: 0.8125rem; font-variant-numeric: tabular-nums; cursor: pointer; @@ -75,12 +105,16 @@ } .canvas__aspect:hover { - background: var(--surface-200, #e5e7eb); - color: var(--surface-900, #111827); + background: var(--surface-100, #f3f4f6); + color: var(--surface-900, #1f2937); } .canvas__aspect--active { - border-color: var(--primary-color, #6366f1); + background: var(--primary-color, #6366f1); + color: #ffffff; +} + +.canvas__aspect--active:hover { background: var(--primary-color, #6366f1); color: #ffffff; } @@ -91,6 +125,53 @@ } } +// 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__tool-rail { position: absolute; top: 50%; 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 index e22e0c0be590..afc4ddd06884 100644 --- 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 @@ -1,6 +1,6 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { Dispatcher } from '@ngrx/signals/events'; -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockInstance } from 'ng-mocks'; import { Observable, of, throwError } from 'rxjs'; import { signal } from '@angular/core'; @@ -15,7 +15,6 @@ 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'; -import { DotImageEditorFocalOverlayComponent } from '../dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component'; import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/dot-image-editor-tool-rail.component'; const PREVIEW_URL = '/contentAsset/image/inode-1/fileAsset?byInode=true&r=1'; @@ -65,6 +64,15 @@ Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', { 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; @@ -72,7 +80,7 @@ describe('DotImageEditorCanvasComponent', () => { const previewUrl = signal(PREVIEW_URL); const previewStatus = signal('idle'); const zoom = signal({ level: 100, fitToScreen: true }); - const activeTool = signal<'move' | 'crop' | 'focal'>('move'); + const activeTool = signal<'move' | 'crop'>('move'); const assetContext = signal({ naturalWidth: 800, naturalHeight: 600 }); const createComponent = createComponentFactory({ @@ -104,16 +112,14 @@ describe('DotImageEditorCanvasComponent', () => { imports: [ DotImageEditorAddressBarComponent, DotImageEditorToolRailComponent, - DotImageEditorCropOverlayComponent, - DotImageEditorFocalOverlayComponent + DotImageEditorCropOverlayComponent ] }, add: { imports: [ MockComponent(DotImageEditorAddressBarComponent), MockComponent(DotImageEditorToolRailComponent), - MockComponent(DotImageEditorCropOverlayComponent), - MockComponent(DotImageEditorFocalOverlayComponent) + MockComponent(DotImageEditorCropOverlayComponent) ] } } @@ -133,6 +139,12 @@ describe('DotImageEditorCanvasComponent', () => { }; 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 }); @@ -156,7 +168,6 @@ describe('DotImageEditorCanvasComponent', () => { expect(spectator.query('dot-image-editor-address-bar')).toExist(); expect(spectator.query('dot-image-editor-tool-rail')).toExist(); expect(spectator.query('dot-image-editor-crop-overlay')).toExist(); - expect(spectator.query('dot-image-editor-focal-overlay')).toExist(); }); it('should show the skeleton and no spinner when idle', () => { @@ -290,18 +301,65 @@ describe('DotImageEditorCanvasComponent', () => { expect(dispatchedEvent('retryRequested')).toBeDefined(); }); - describe('footer band', () => { - it('should render no action buttons when the move tool is active', () => { + 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'))).toExist(); + 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(); - expect(spectator.query(byTestId('image-editor-focal-crop-btn'))).not.toExist(); }); - it('should invoke the crop overlay apply/cancel from the footer when cropping', () => { + 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 presets including Free in the crop bar', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('image-editor-aspect-free'))).toExist(); + expect(spectator.query(byTestId('image-editor-aspect-square'))).toExist(); + expect(spectator.query(byTestId('image-editor-aspect-standard'))).toExist(); + expect(spectator.query(byTestId('image-editor-aspect-wide'))).toExist(); + }); + + it('should lock the crop aspect when a preset pill is clicked and clear it on Free', () => { + activeTool.set('crop'); + spectator.detectChanges(); + + // The component owns cropAspect, which it binds to the crop overlay's + // `aspect` input ([aspect]="cropAspect()"). + const cropAspect = () => + ( + spectator.component as unknown as { cropAspect: () => number | null } + ).cropAspect(); + + spectator.click(spectator.query(byTestId('image-editor-aspect-wide'))!); + spectator.detectChanges(); + + // The selected pill is highlighted and the locked ratio is stored. + const wide = spectator.query(byTestId('image-editor-aspect-wide')); + expect(wide).toHaveClass('canvas__aspect--active'); + expect(cropAspect()).toBeCloseTo(16 / 9, 5); + + // Free clears the lock back to free-form (null) and highlights instead. + spectator.click(spectator.query(byTestId('image-editor-aspect-free'))!); + spectator.detectChanges(); + expect(cropAspect()).toBeNull(); + expect(spectator.query(byTestId('image-editor-aspect-free'))).toHaveClass( + 'canvas__aspect--active' + ); + expect(wide).not.toHaveClass('canvas__aspect--active'); + }); + + it('should invoke the crop overlay apply/cancel from the action bar when cropping', () => { activeTool.set('crop'); spectator.detectChanges(); @@ -318,18 +376,75 @@ describe('DotImageEditorCanvasComponent', () => { expect(cancelSpy).toHaveBeenCalled(); }); - it('should crop to the selected aspect from the focal bar', () => { - activeTool.set('focal'); + it('should render the width/height size inputs when cropping', () => { + activeTool.set('crop'); spectator.detectChanges(); - // Select 16:9, then crop. - spectator.click(spectator.query(byTestId('image-editor-aspect-wide'))!); - const cropBtn = spectator.query(byTestId('image-editor-focal-crop-btn')); - spectator.click(cropBtn!.querySelector('button')!); + 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.click(spectator.query(byTestId('image-editor-aspect-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.click(spectator.query(byTestId('image-editor-aspect-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); - const event = dispatchedEvent('aspectCropApplied'); - expect(event).toBeDefined(); - expect(event!.payload).toEqual({ aspect: 16 / 9, label: '16:9' }); + expect(setNaturalCropSize).not.toHaveBeenCalled(); }); }); 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 index 85216359500c..a6f4255d86d7 100644 --- 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 @@ -14,8 +14,10 @@ import { 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 { SkeletonModule } from 'primeng/skeleton'; @@ -26,56 +28,71 @@ 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, imageEditorToolEvents } from '../../store/image-editor.events'; +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'; import { DotImageEditorToolRailComponent } from '../dot-image-editor-tool-rail/dot-image-editor-tool-rail.component'; /** * Dark stage that renders the live image preview at the center of the editor. - * Hosts the top address sub-bar, the floating tool rail, the crop/focal overlays, - * and a persistent bottom footer band that mirrors the address bar and surfaces - * the active tool's actions (apply/cancel for crop, set/cancel for focal). 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 overlays), and the display-only zoom level. Preview loading outcomes are - * reported back to the store via {@link imageEditorLifecycleEvents}. + * Hosts the top address sub-bar, the floating tool rail, 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, SkeletonModule, DotMessagePipe, DotImageEditorAddressBarComponent, DotImageEditorToolRailComponent, - DotImageEditorCropOverlayComponent, - DotImageEditorFocalOverlayComponent + DotImageEditorCropOverlayComponent ], changeDetection: ChangeDetectionStrategy.OnPush }) export class DotImageEditorCanvasComponent { protected readonly store = inject(ImageEditorStore); readonly #dispatch = injectDispatch(imageEditorLifecycleEvents); - readonly #toolDispatch = injectDispatch(imageEditorToolEvents); readonly #destroyRef = inject(DestroyRef); readonly #service = inject(DotImageEditorService); - /** Aspect-ratio presets for the focal-point-centered crop, shown in the focal bar. */ - protected readonly aspectPresets = [ + /** + * Aspect-ratio presets shown in the crop action bar. Selecting one reshapes and + * locks the crop box to that ratio; `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 } ]; - /** The aspect preset currently selected in the focal crop bar. */ - protected readonly selectedAspect = signal(this.aspectPresets[0]); + /** The locked crop aspect ratio (width / height), or `null` for free-form. */ + protected readonly cropAspect = signal(null); + + /** + * 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'); @@ -84,8 +101,6 @@ export class DotImageEditorCanvasComponent { /** The crop overlay, so the footer can apply or cancel the active crop. */ protected readonly cropOverlay = viewChild(DotImageEditorCropOverlayComponent); - /** The focal overlay, so the footer can set or cancel the active focal point. */ - protected readonly focalOverlay = viewChild(DotImageEditorFocalOverlayComponent); /** Filter URL of the last successfully loaded preview (the bottom layer's identity). */ protected readonly displayedUrl = signal(''); @@ -198,6 +213,9 @@ export class DotImageEditorCanvasComponent { untracked(() => { if (tool !== 'crop') { this.capturedCropRect.set(undefined); + // Leaving crop clears the locked aspect so the next crop session + // starts free-form. + this.cropAspect.set(null); return; } @@ -302,10 +320,34 @@ export class DotImageEditorCanvasComponent { this.cropOverlay()?.cancelCrop(); } - /** Crops to the selected aspect, centered on the focal point (then exits the tool). */ - protected applyFocalCrop(): void { - const { aspect, label } = this.selectedAspect(); - this.#toolDispatch.aspectCropApplied({ aspect, label }); + /** + * 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. */ @@ -385,8 +427,8 @@ export class DotImageEditorCanvasComponent { } /** - * Measures the displayed image relative to the stage origin so the crop and - * focal overlays can position themselves over the rendered pixels. + * Measures the displayed image relative to the stage origin so the crop overlay + * can position itself over the rendered pixels. */ #measureImageRect(): void { const stage = this.stage()?.nativeElement; @@ -398,9 +440,9 @@ export class DotImageEditorCanvasComponent { // The stage carries the zoom as a CSS `transform: scale(...)`, so // getBoundingClientRect returns painted (scaled) values. Divide back out so - // the rect is in the stage's logical CSS px — the same space the overlays - // (children of the scaled stage) position themselves in. Without this the - // crop/focal markers drift at any zoom other than 100%. + // the rect is in the stage's logical CSS px — the same space the crop overlay + // (a child of the scaled stage) positions itself in. Without this the crop + // box drifts at any zoom other than 100%. const scale = this.zoomLevel() / 100; const stageBox = stage.getBoundingClientRect(); const imgBox = img.getBoundingClientRect(); 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 index 7d9a6438af6a..3be7e7009261 100644 --- 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 @@ -1,5 +1,21 @@ @if (isActive() && imageRect()) {
    + +
    +
    +
    +
    +
    { ); }); + 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')); @@ -131,6 +149,83 @@ describe('DotImageEditorCropOverlayComponent', () => { 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'); 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 index 9df62eab3310..7efcc357df4a 100644 --- 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 @@ -7,7 +7,9 @@ import { effect, inject, input, - signal + NgZone, + signal, + untracked } from '@angular/core'; import { DotMessagePipe } from '@dotcms/ui'; @@ -19,7 +21,7 @@ import { CROP_NUDGE_STEP_LARGE, MIN_CROP_SIZE } from '../../image-editor.constants'; -import { HandlePosition, ImageRect, LocalRect } from '../../models/image-editor.models'; +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'; @@ -52,8 +54,17 @@ export class DotImageEditorCropOverlayComponent { */ 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; @@ -64,6 +75,36 @@ export class DotImageEditorCropOverlayComponent { /** 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(); @@ -77,20 +118,89 @@ export class DotImageEditorCropOverlayComponent { }; }); + /** + * 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. */ @@ -180,6 +290,44 @@ export class DotImageEditorCropOverlayComponent { 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. @@ -210,17 +358,19 @@ export class DotImageEditorCropOverlayComponent { } /** - * Resizes the box from a handle, constrained within the rendered image. - * Holding Shift while dragging a corner locks the selection to its starting - * aspect ratio (the common "shift to keep proportions" behavior); edge handles - * and unmodified drags remain free-form. + * 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, - lockAspect = false + shiftKey = false ): void { const rect = this.imageRect(); @@ -230,9 +380,12 @@ export class DotImageEditorCropOverlayComponent { // 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) { - this.cropRect.set(this.#resizeLockedAspect(start, position, dx, dy, rect)); + const ratio = locked ?? start.width / start.height; + this.cropRect.set(this.#resizeLockedAspect(start, position, dx, dy, rect, ratio)); return; } @@ -263,20 +416,19 @@ export class DotImageEditorCropOverlayComponent { } /** - * Corner resize that preserves the selection's starting 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. + * 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 + rect: ImageRect, + aspect: number ): LocalRect { - const aspect = start.width / start.height; - // The dragged handle grows the box left/up (sign -1) or right/down (+1) // from the opposite, anchored corner. const growLeft = position.includes('l'); @@ -324,6 +476,13 @@ export class DotImageEditorCropOverlayComponent { /** * 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, @@ -331,15 +490,41 @@ export class DotImageEditorCropOverlayComponent { ): void { const originX = start.clientX; const originY = start.clientY; - - const move = (event: PointerEvent) => - onMove(event.clientX - originX, event.clientY - originY, event.shiftKey); + 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(); }; - window.addEventListener('pointermove', move); - window.addEventListener('pointerup', up); + 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 deleted file mode 100644 index 06225acce7bd..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html +++ /dev/null @@ -1,20 +0,0 @@ -@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 deleted file mode 100644 index da46f444d3b8..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss +++ /dev/null @@ -1,48 +0,0 @@ -: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 deleted file mode 100644 index 77e65e94d92e..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e89953579e26..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { injectDispatch } from '@ngrx/signals/events'; - -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - ElementRef, - inject, - input, - signal -} from '@angular/core'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { focalPointPop } from '../../animations/image-editor.animations'; -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', - animations: [focalPointPop()], - 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-tool-rail/dot-image-editor-tool-rail.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html index ed5c1a23a5b4..25c9d258609f 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html @@ -42,23 +42,6 @@ } - @case ('focal') { - - } } } diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts index a38461ab6390..177279d3de9d 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts @@ -14,8 +14,7 @@ import { ImageEditorStore } from '../../store/image-editor.store'; const messageServiceMock = new MockDotMessageService({ '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.tool.crop': 'Crop' }); describe('DotImageEditorToolRailComponent', () => { @@ -42,18 +41,15 @@ describe('DotImageEditorToolRailComponent', () => { jest.spyOn(dispatcher, 'dispatch'); }); - it('should render the three tools with testids and aria-labels', () => { + it('should render the two tools with testids and aria-labels', () => { const move = spectator.query(byTestId('image-editor-tool-move')); const crop = spectator.query(byTestId('image-editor-tool-crop')); - const focal = spectator.query(byTestId('image-editor-tool-focal')); expect(move).toBeTruthy(); expect(crop).toBeTruthy(); - expect(focal).toBeTruthy(); expect(move).toHaveAttribute('aria-label', 'Move'); expect(crop).toHaveAttribute('aria-label', 'Crop'); - expect(focal).toHaveAttribute('aria-label', 'Focal point'); }); it('should expose a vertical toolbar role on the host', () => { diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts index e48020d5e81c..defd901fa809 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts @@ -11,9 +11,9 @@ import { imageEditorToolEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; /** - * Floating vertical rail of canvas tools (move, crop, focal point). Acts as a - * `toolbar` with roving tabindex: the active tool is the only focusable button, - * matching the WAI-ARIA toolbar pattern. Selecting a tool dispatches + * Floating vertical rail of canvas tools (move, crop). Acts as a `toolbar` with + * roving tabindex: the active tool is the only focusable button, matching the + * WAI-ARIA toolbar pattern. Selecting a tool dispatches * {@link imageEditorToolEvents.toolSelected} so the store owns the active tool. */ @Component({ @@ -42,11 +42,6 @@ export class DotImageEditorToolRailComponent { id: 'crop', label: 'edit.content.image-editor.tool.crop', testId: 'image-editor-tool-crop' - }, - { - id: 'focal', - label: 'edit.content.image-editor.tool.focal', - testId: 'image-editor-tool-focal' } ]; 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 index 73c783e306e7..5ec3dbde9bd4 100644 --- a/core-web/libs/image-editor/src/lib/image-editor.constants.ts +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -1,7 +1,7 @@ import { CompressionMode, HandlePosition } from './models/image-editor.models'; /** - * Library-wide constants for the image editor: control ranges, zoom/crop/focal + * 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. @@ -42,10 +42,6 @@ export const CROP_HANDLES: readonly HandlePosition[] = [ 'l' ] as const; -/** 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.05; - /** One kibibyte, used to format byte counts for display. */ export const BYTES_PER_KB = 1024; @@ -66,7 +62,7 @@ export const COMPRESSION_LABELS: Record = { }; /** The editable slices, in snapshot order (used to diff/replay history). */ -export const SLICE_KEYS = ['adjust', 'transform', 'crop', 'focalPoint', 'fileInfo'] as const; +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'; 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 index 118847d393bc..88a4077937d2 100644 --- 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 @@ -6,9 +6,8 @@ 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 */ -export type ActiveTool = 'move' | 'crop' | 'focal'; +export type ActiveTool = 'move' | 'crop'; /** Output compression strategy applied as the last filter in the chain. */ export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp'; @@ -25,14 +24,7 @@ export interface ImageRect { export type PreviewStatus = 'idle' | 'loading' | 'loaded' | 'error'; /** Logical category an applied edit belongs to, used for grouping and labels. */ -export type FilterCategory = - | 'adjust' - | 'crop' - | 'rotate' - | 'flip' - | 'grayscale' - | 'compression' - | 'focal'; +export type FilterCategory = 'adjust' | 'crop' | 'rotate' | 'flip' | 'grayscale' | 'compression'; /** Server-side filter name as understood by the dotCMS image filter endpoint. */ export type FilterName = @@ -42,7 +34,6 @@ export type FilterName = | 'Flip' | 'Grayscale' | 'Hsb' - | 'FocalPoint' | 'Jpeg' | 'WebP' | 'Quality'; @@ -115,13 +106,6 @@ export interface CropState { aspect: number | null; } -/** Focal point as normalized 0..1 coordinates. `active` gates application. */ -export interface FocalPointState { - x: number; - y: number; - active: boolean; -} - /** Compression configuration and the resulting/original file sizes in bytes. */ export interface FileInfoState { compression: CompressionMode; @@ -167,7 +151,6 @@ export interface ImageEditorHistoryEntry { adjust: AdjustState; transform: TransformState; crop: CropState; - focalPoint: FocalPointState; fileInfo: FileInfoState; }; } @@ -201,7 +184,7 @@ export interface DotImageEditorLauncher { /** * The complete, flat state of the image editor. Each slice owns a domain of the - * editing experience (color adjustment, geometric transform, crop, focal point, + * 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). */ @@ -216,8 +199,6 @@ export interface ImageEditorState { crop: CropState; /** Compression configuration and resulting/original file sizes. */ fileInfo: FileInfoState; - /** Normalized focal point slice. */ - focalPoint: FocalPointState; /** Canvas zoom slice. */ zoom: ZoomState; /** Tool currently selected on the canvas. */ @@ -289,12 +270,6 @@ export interface LocalRect { /** Identifiers for the eight resize handles around the crop box. */ export type HandlePosition = 'tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l'; -/** A normalized point in the unit square, where {x:0.5, y:0.5} is the center. */ -export interface NormalizedPoint { - x: number; - y: number; -} - /** Natural pixel dimensions of an image resolved from the browser. */ export interface NaturalDimensions { naturalWidth: number; @@ -316,6 +291,5 @@ export type SlicePatch = { adjust?: Partial; transform?: Partial; crop?: Partial; - focalPoint?: Partial; fileInfo?: Partial; }; 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 index f323309868b4..ba0ef7cdf6af 100644 --- a/core-web/libs/image-editor/src/lib/store/features/index.ts +++ b/core-web/libs/image-editor/src/lib/store/features/index.ts @@ -3,7 +3,6 @@ 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'; 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 deleted file mode 100644 index 676ce3ea8c3c..000000000000 --- a/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 { withFocalPoint } from './with-focal-point.feature'; - -import { imageEditorToolEvents } from '../image-editor.events'; -import { initialImageEditorState } from '../image-editor.state'; - -const FocalStore = signalStore(withState(initialImageEditorState), withFocalPoint()); -const FocalStoreSized = signalStore( - withState({ - ...initialImageEditorState, - assetContext: { - ...initialImageEditorState.assetContext, - naturalWidth: 1000, - naturalHeight: 800 - } - }), - withFocalPoint() -); - -function setup(StoreClass: Type) { - TestBed.configureTestingModule({ providers: [StoreClass, Dispatcher] }); - const injector = TestBed.inject(Injector); - const store = TestBed.inject(StoreClass); - let tool!: ReturnType>; - runInInjectionContext(injector, () => { - tool = injectDispatch(imageEditorToolEvents); - }); - - return { store, tool }; -} - -describe('withFocalPoint', () => { - it('records the focal point WITHOUT reloading the preview', () => { - const { store, tool } = setup(FocalStore); - - tool.focalPointSet({ x: 0.25, y: 0.75 }); - - expect(store.focalPoint()).toEqual({ x: 0.25, y: 0.75, active: true }); - expect(store.history().at(-1)?.category).toBe('focal'); - expect(store.history().at(-1)?.label).toBe('Focal point 0.25, 0.75'); - // It's a save-time anchor: no cache-bust, no loading. - expect(store.previewStatus()).toBe('idle'); - expect(store.cacheBust()).toBe(0); - }); - - it('clears the focal point back to the centered default', () => { - const { store, tool } = setup(FocalStore); - - tool.focalPointSet({ x: 0.25, y: 0.75 }); - tool.focalPointCleared(); - - expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); - }); - - it('derives a focal-centered aspect crop when natural dimensions are known', () => { - const { store, tool } = setup(FocalStoreSized); - - tool.focalPointSet({ x: 0.8, y: 0.5 }); - tool.aspectCropApplied({ aspect: 1, label: '1:1' }); - - // 1:1 of 1000×800 → 800×800; centered on x=0.8 clamps to the right edge (x=200). - expect(store.crop()).toEqual({ x: 200, y: 0, w: 800, h: 800, active: true, aspect: 1 }); - expect(store.transform().scale).toBe(100); - expect(store.activeTool()).toBe('move'); - expect(store.history().at(-1)?.category).toBe('crop'); - expect(store.previewStatus()).toBe('loading'); - }); - - it('ignores an aspect crop when natural dimensions are unknown', () => { - const { store, tool } = setup(FocalStore); // naturalWidth/Height are 0 - - tool.aspectCropApplied({ aspect: 1, label: '1:1' }); - - expect(store.crop().active).toBe(false); - expect(store.history()).toHaveLength(0); - expect(store.previewStatus()).toBe('idle'); - }); -}); 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 deleted file mode 100644 index 5a4cb3cb2fc0..000000000000 --- a/core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { signalStoreFeature, type } from '@ngrx/signals'; -import { on, withReducer } from '@ngrx/signals/events'; - -import { - FocalPointState, - ImageEditorState, - TransformState -} from '../../models/image-editor.models'; -import { imageEditorToolEvents } from '../image-editor.events'; -import { initialFocalPointState } from '../image-editor.state'; -import { coalesceHistory, editableSlicesOf, focalCenteredCrop } from '../image-editor.store-utils'; - -/** - * Focal point feature: setting/clearing the focal anchor and the focal-centered - * aspect crop. Setting the point does NOT reload the preview (it's a save-time - * anchor); the aspect crop derives a crop centered on the point and, like a manual - * crop, supersedes resize and returns to the move tool. - */ -export function withFocalPoint() { - return signalStoreFeature( - type<{ state: ImageEditorState }>(), - withReducer( - on(imageEditorToolEvents.focalPointSet, ({ payload }, state) => { - // No preview reload: the focal point doesn't change the rendered image - // on its own — it's a saved anchor consumed by the aspect crop and - // persisted on save. Just record it (and a coalesced history entry). - const focalPoint: FocalPointState = { x: payload.x, y: payload.y, active: true }; - const next: ImageEditorState = { ...state, focalPoint }; - - return { - ...next, - ...coalesceHistory( - next, - 'focal', - `Focal point ${payload.x.toFixed(2)}, ${payload.y.toFixed(2)}`, - editableSlicesOf(next) - ) - }; - }), - on(imageEditorToolEvents.focalPointCleared, (_event, state) => ({ - ...state, - focalPoint: initialFocalPointState - })), - on(imageEditorToolEvents.aspectCropApplied, ({ payload }, state) => { - if (!state.assetContext.naturalWidth || !state.assetContext.naturalHeight) { - return state; - } - - const crop = focalCenteredCrop(payload.aspect, state); - // Cropping is mutually exclusive with resize, and returns to the move tool. - 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 ${payload.label}`, - editableSlicesOf(next) - ) - }; - }) - ) - ); -} 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 index db1545d34647..ebdf11fc2bc8 100644 --- 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 @@ -31,8 +31,8 @@ describe('withView', () => { expect(store.activeTool()).toBe('crop'); expect(store.history()).toHaveLength(0); - tool.toolSelected('focal'); - expect(store.activeTool()).toBe('focal'); + tool.toolSelected('move'); + expect(store.activeTool()).toBe('move'); }); it('toggles full-screen on and off', () => { 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 index f8d45e79c3da..d8fd71f40329 100644 --- 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 @@ -7,10 +7,10 @@ import { imageEditorToolEvents, imageEditorViewEvents } from '../image-editor.ev /** * 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 / focal) and whether the dialog is full-screen. The crop - * and focal interactions themselves live in {@link withCrop} and - * {@link withFocalPoint}; resizing the dialog to full-screen is the root - * component's job — this feature only owns the `isFullscreen` flag it reads. + * 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( 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 index 7b895edfd952..b3814110dff2 100644 --- 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 @@ -5,7 +5,6 @@ import { ActiveTool, CompressionMode, CropState, - FocalPointState, ImageEditorOpenParams } from '../models/image-editor.models'; @@ -49,17 +48,13 @@ export const imageEditorViewEvents = eventGroup({ } }); -/** Events emitted by the canvas tools (move/crop/focal). */ +/** Events emitted by the canvas tools (move/crop). */ export const imageEditorToolEvents = eventGroup({ source: 'Image Editor Tool', events: { toolSelected: type(), cropApplied: type(), - cropCancelled: type(), - focalPointSet: type<{ x: number; y: number }>(), - focalPointCleared: type(), - // A crop to the given aspect ratio, centered on the current focal point. - aspectCropApplied: type<{ aspect: number; label: string }>() + cropCancelled: type() } }); @@ -87,7 +82,6 @@ export const imageEditorLifecycleEvents = eventGroup({ naturalWidth: number; naturalHeight: number; originalBytes: number | null; - focalPoint?: FocalPointState; }>(), 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 index bf164ddcabe2..434ebc1e005d 100644 --- 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 @@ -2,7 +2,6 @@ import { AdjustState, CropState, FileInfoState, - FocalPointState, ImageEditorAssetContext, ImageEditorState, TransformState, @@ -62,13 +61,6 @@ export const initialFileInfoState: FileInfoState = { originalBytes: null }; -/** Default focal point slice: centered and inactive. */ -export const initialFocalPointState: FocalPointState = { - x: 0.5, - y: 0.5, - active: false -}; - /** Default zoom slice: 100% and fitted to screen. */ export const initialZoomState: ZoomState = { level: 100, @@ -82,7 +74,6 @@ export const initialImageEditorState: ImageEditorState = { transform: initialTransformState, crop: initialCropState, fileInfo: initialFileInfoState, - focalPoint: initialFocalPointState, zoom: initialZoomState, activeTool: 'move', previewStatus: 'idle', 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 index c18844b117a9..d1fae18544ef 100644 --- 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 @@ -6,7 +6,6 @@ import { editableSlicesOf, errorMessage, fileInfoPatch, - focalCenteredCrop, initialEditableSlices, rebuildHistory, restoreSlices, @@ -45,16 +44,10 @@ function stateWith( describe('image-editor.store-utils', () => { describe('editableSlicesOf', () => { - it('extracts exactly the five editable slices', () => { + it('extracts exactly the four editable slices', () => { const slices = editableSlicesOf(initialImageEditorState); - expect(Object.keys(slices).sort()).toEqual([ - 'adjust', - 'crop', - 'fileInfo', - 'focalPoint', - 'transform' - ]); + expect(Object.keys(slices).sort()).toEqual(['adjust', 'crop', 'fileInfo', 'transform']); expect(slices.adjust).toBe(initialImageEditorState.adjust); }); }); @@ -211,77 +204,6 @@ describe('image-editor.store-utils', () => { }); }); - describe('focalCenteredCrop', () => { - it('uses the full width when the target aspect is wider than the image', () => { - const state = stateWith([], -1, { - assetContext: { - ...initialImageEditorState.assetContext, - naturalWidth: 1000, - naturalHeight: 800 - }, - focalPoint: { x: 0.5, y: 0.5, active: true } - }); - - // 16:9 (1.78) > 1000/800 (1.25): keep full width, derive a shorter height. - const crop = focalCenteredCrop(16 / 9, state); - - expect(crop.w).toBe(1000); - expect(crop.h).toBe(Math.round(1000 / (16 / 9))); - expect(crop.active).toBe(true); - }); - - it('uses the full height when the target aspect is taller than the image', () => { - const state = stateWith([], -1, { - assetContext: { - ...initialImageEditorState.assetContext, - naturalWidth: 1000, - naturalHeight: 800 - }, - focalPoint: { x: 0.5, y: 0.5, active: true } - }); - - // 1:1 (1.0) <= 1.25: keep full height, derive a narrower width. - const crop = focalCenteredCrop(1, state); - - expect(crop.h).toBe(800); - expect(crop.w).toBe(800); - }); - - it('centers on the image middle when no focal point is active', () => { - const state = stateWith([], -1, { - assetContext: { - ...initialImageEditorState.assetContext, - naturalWidth: 1000, - naturalHeight: 800 - }, - focalPoint: { x: 0.8, y: 0.8, active: false } - }); - - // Inactive focal → fx/fy default to 0.5: an 800×800 region centered → x=100, y=0. - const crop = focalCenteredCrop(1, state); - - expect(crop.x).toBe(100); - expect(crop.y).toBe(0); - }); - - it('clamps the crop origin to the image bounds for an off-center focal point', () => { - const state = stateWith([], -1, { - assetContext: { - ...initialImageEditorState.assetContext, - naturalWidth: 1000, - naturalHeight: 800 - }, - focalPoint: { x: 0.8, y: 0.5, active: true } - }); - - // 800 wide region centered on x=800 wants x=400 but clamps to 1000−800=200. - const crop = focalCenteredCrop(1, state); - - expect(crop.x).toBe(200); - expect(crop.y).toBe(0); - }); - }); - describe('contextFromParams', () => { it('prefers the temp file id when present and marks it a temp file', () => { const ctx = contextFromParams({ 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 index 9f14855372f1..af752d00e76b 100644 --- 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 @@ -2,7 +2,6 @@ import { initialAdjustState, initialCropState, initialFileInfoState, - initialFocalPointState, initialTransformState } from './image-editor.state'; @@ -20,14 +19,12 @@ import { SlicePatch, TransformState } from '../models/image-editor.models'; -import { clamp } from '../utils/dimensions.util'; /** - * Pure helpers for the {@link ImageEditorStore}: history coalescing/replay, the - * focal-centered crop geometry, 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. + * 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. */ @@ -35,7 +32,6 @@ export const initialEditableSlices: EditableSlices = { adjust: initialAdjustState, transform: initialTransformState, crop: initialCropState, - focalPoint: initialFocalPointState, fileInfo: initialFileInfoState }; @@ -45,7 +41,6 @@ export function editableSlicesOf(state: ImageEditorState): EditableSlices { adjust: state.adjust, transform: state.transform, crop: state.crop, - focalPoint: state.focalPoint, fileInfo: state.fileInfo }; } @@ -99,7 +94,6 @@ export function restoreSlices(snapshot: EditableSlices): EditableSlices { adjust: snapshot.adjust, transform: snapshot.transform, crop: snapshot.crop, - focalPoint: snapshot.focalPoint, fileInfo: snapshot.fileInfo }; } @@ -145,7 +139,6 @@ function applySlicePatch(base: EditableSlices, patch: SlicePatch): EditableSlice adjust: { ...base.adjust, ...patch.adjust }, transform: { ...base.transform, ...patch.transform }, crop: { ...base.crop, ...patch.crop }, - focalPoint: { ...base.focalPoint, ...patch.focalPoint }, fileInfo: { ...base.fileInfo, ...patch.fileInfo } }; } @@ -181,34 +174,6 @@ export function rebuildHistory( }); } -/** - * The largest crop of the given aspect ratio that fits the natural image, - * centered on the active focal point (or the image center when none is set) and - * clamped to the image bounds. This is what makes the focal point visible: the - * kept region follows the focal point instead of the center. - */ -export function focalCenteredCrop(aspect: number, state: ImageEditorState): CropState { - const { naturalWidth, naturalHeight } = state.assetContext; - const naturalAspect = naturalWidth / naturalHeight; - - let w: number; - let h: number; - if (aspect > naturalAspect) { - w = naturalWidth; - h = Math.round(naturalWidth / aspect); - } else { - h = naturalHeight; - w = Math.round(naturalHeight * aspect); - } - - const fx = state.focalPoint.active ? state.focalPoint.x : 0.5; - const fy = state.focalPoint.active ? state.focalPoint.y : 0.5; - const x = clamp(Math.round(fx * naturalWidth - w / 2), 0, naturalWidth - w); - const y = clamp(Math.round(fy * naturalHeight - h / 2), 0, naturalHeight - h); - - return { x, y, w, h, active: true, aspect }; -} - /** 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 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 index 52312bcba8ed..e7d7019b8efe 100644 --- 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 @@ -84,7 +84,6 @@ describe('ImageEditorStore', () => { expect(store.adjust().brightness).toBe(0); expect(store.transform().scale).toBe(100); expect(store.fileInfo().quality).toBe(85); - expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); expect(store.activeTool()).toBe('move'); expect(store.previewStatus()).toBe('idle'); expect(store.history()).toEqual([]); @@ -183,32 +182,6 @@ describe('ImageEditorStore', () => { expect(store.crop().active).toBe(false); expect(store.activeTool()).toBe('move'); }); - - it('should set and clear the focal point', () => { - tool.focalPointSet({ x: 0.25, y: 0.75 }); - - expect(store.focalPoint()).toEqual({ x: 0.25, y: 0.75, active: true }); - expect(store.history().at(-1)?.category).toBe('focal'); - - tool.focalPointCleared(); - - expect(store.focalPoint()).toEqual({ x: 0.5, y: 0.5, active: false }); - }); - - it('should apply an aspect crop centered on the focal point', () => { - lifecycle.assetRequested(OPEN_PARAMS); - lifecycle.assetLoaded({ naturalWidth: 1000, naturalHeight: 800, originalBytes: 5000 }); - tool.focalPointSet({ x: 0.8, y: 0.5 }); - - tool.aspectCropApplied({ aspect: 1, label: '1:1' }); - - // 1:1 in a 1000×800 image → an 800×800 region; centered on x=0.8 (=800px) - // it wants x=400 but clamps to the right edge (1000−800=200), y centers at 0. - expect(store.crop()).toEqual({ x: 200, y: 0, w: 800, h: 800, active: true, aspect: 1 }); - expect(store.transform().scale).toBe(100); - expect(store.activeTool()).toBe('move'); - expect(store.history().at(-1)?.category).toBe('crop'); - }); }); describe('history', () => { 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 index eb83abdf1554..91cbe69d7d92 100644 --- 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 @@ -6,7 +6,6 @@ import { withCrop, withDownload, withFileInfo, - withFocalPoint, withHistory, withPreview, withTransform, @@ -31,7 +30,6 @@ export const ImageEditorStore = signalStore( withAdjust(), withTransform(), withCrop(), - withFocalPoint(), withFileInfo(), withView(), withHistory(), 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 index 1a5b95365526..f545bcee80fa 100644 --- 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 @@ -106,11 +106,6 @@ export function buildFilterChain(input: FilterChainInput): AppliedFilter[] { filters.push({ name: 'Hsb', args }); } - // The focal point is intentionally NOT a preview filter: the dotCMS FocalPoint - // filter only writes metadata (no visible change), so emitting it just forces a - // pointless reload. It is persisted on save (persistFocalPoint) and consumed by - // the focal-centered aspect crop directly from state. - const compression = compressionFilter(fileInfo.compression, fileInfo.quality); if (compression) { filters.push(compression); From 171bd286384ab13aed93236edfdea10caa83a18b Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 24 Jun 2026 14:30:35 -0400 Subject: [PATCH 21/29] feat(image-editor): Material Symbols icons + UVE-style address bar with crop/grab tools - Replace all PrimeIcons and the inline tool-rail SVGs with the official Material Symbols font (global; projected into PrimeNG buttons via #icon templates, pButton child content, and toggleButton #content). - Move the move(grab) + crop tools out of the floating tool rail (now deleted) into the address bar, restyled as UVE toolbar-center rounded pills: tools | URL | zoom. The URL pill grows to fill, pushing the tool and zoom pills to the edges; the active tool renders as a white circle inside its pill (the editor's mode). - White header band; the dialog window-control icons (open_in_full / close) use the Material Symbols size (Tailwind ! to beat the global 24px). --- ...ot-image-editor-address-bar.component.html | 130 +++++++++++++----- ...ot-image-editor-address-bar.component.scss | 54 ++++++-- ...image-editor-address-bar.component.spec.ts | 75 ++++++++-- .../dot-image-editor-address-bar.component.ts | 35 ++++- .../dot-image-editor-canvas.component.html | 13 +- .../dot-image-editor-canvas.component.scss | 30 +--- .../dot-image-editor-canvas.component.spec.ts | 8 +- .../dot-image-editor-canvas.component.ts | 10 +- .../dot-image-editor-footer.component.html | 7 +- .../dot-image-editor-header.component.html | 14 +- .../dot-image-editor-header.component.spec.ts | 18 ++- .../dot-image-editor-header.component.ts | 7 +- ...-image-editor-history-panel.component.html | 14 +- ...-image-editor-history-panel.component.scss | 6 + .../dot-image-editor-panels.component.html | 24 ++-- .../dot-image-editor-panels.component.scss | 4 +- ...mage-editor-transform-panel.component.html | 18 ++- ...mage-editor-transform-panel.component.scss | 20 +++ .../dot-image-editor-tool-rail.component.html | 47 ------- .../dot-image-editor-tool-rail.component.scss | 51 ------- ...t-image-editor-tool-rail.component.spec.ts | 82 ----------- .../dot-image-editor-tool-rail.component.ts | 52 ------- 22 files changed, 359 insertions(+), 360 deletions(-) delete mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html delete mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss delete mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts delete mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts 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 index c81fabbcef87..99ebee37bcd7 100644 --- 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 @@ -1,75 +1,137 @@ -
    - - + +
    + @for (tool of tools; track tool.id) { + + + @switch (tool.id) { + @case ('move') { + pan_tool + } + @case ('crop') { + crop + } + } + + + } +
    + + +
    + + + content_copy + + {{ store.previewUrl() }} -
    -
    - + (onClick)="zoomOut.emit()"> + + remove + + - - - + (onClick)="redo()"> + + 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 index 2f778a8caf3b..ed506c221e43 100644 --- 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 @@ -1,35 +1,71 @@ :host { display: flex; align-items: center; - justify-content: space-between; - gap: 1rem; + gap: 0.5rem; padding: 0.5rem 0.75rem; - // Header band a touch darker than the white viewer, with a hairline divider. - background-color: var(--surface-100, #f1f3f5); + // White header band; the gray pills sit on it (UVE toolbar-center look). + background-color: var(--surface-0, #ffffff); // Muted dark text/icons at rest; full-strength dark is reserved for hover/active. color: var(--surface-600, #4b5563); border-bottom: 1px solid var(--surface-200, #e5e7eb); } -.address-bar__group { +// 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.5rem; + gap: 0.25rem; min-width: 0; + padding: 0.125rem; + border-radius: 1000px; + background-color: var(--surface-200, #e5e7eb); } -.address-bar__link-icon { - flex: 0 0 auto; +// The URL pill grows to fill the row, pushing the tools pill hard to the left edge +// and the zoom pill to the right edge; the URL truncates inside it. +.address-bar__pill--url { + flex: 1 1 auto; + 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 { + background-color: var(--surface-0, #ffffff) !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; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); + color: var(--surface-900, #111827) !important; } .address-bar__field { flex: 1 1 auto; min-width: 0; - max-width: 28rem; 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(--surface-300, #d1d5db); +} + .address-bar__zoom-value { min-width: 3.5rem; text-align: center; diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts index d6d239d511c3..355c7f0736a8 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts @@ -10,14 +10,17 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotImageEditorAddressBarComponent } from './dot-image-editor-address-bar.component'; -import { imageEditorHistoryEvents } from '../../store/image-editor.events'; +import { ActiveTool } from '../../models/image-editor.models'; +import { imageEditorHistoryEvents, imageEditorToolEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; const PREVIEW_URL = '/contentAsset/image/inode-1/fileAsset?byInode=true'; const messageServiceMock = new MockDotMessageService({ 'edit.content.image-editor.address.copy.success': 'Copied', - 'edit.content.image-editor.address.copy.error': 'Could not copy' + 'edit.content.image-editor.address.copy.error': 'Could not copy', + 'edit.content.image-editor.tool.move': 'Move', + 'edit.content.image-editor.tool.crop': 'Crop' }); describe('DotImageEditorAddressBarComponent', () => { @@ -29,6 +32,7 @@ describe('DotImageEditorAddressBarComponent', () => { const zoom = signal({ level: 100, fitToScreen: true }); const canUndo = signal(true); const canRedo = signal(true); + const activeTool = signal('move'); const createComponent = createComponentFactory({ component: DotImageEditorAddressBarComponent, @@ -40,16 +44,21 @@ describe('DotImageEditorAddressBarComponent', () => { previewUrl, zoom, canUndo, - canRedo + canRedo, + activeTool }) ] }); + /** Resolves the inner native
    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 index 1a2485ae0b43..15dd8c0835ba 100644 --- 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 @@ -68,12 +68,22 @@ describe('DotImageEditorHeaderComponent', () => { ); }); - it('should swap the full-screen icon to "minimize" while full-screen', () => { + 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(); - expect( - spectator.query(byTestId('image-editor-fullscreen-btn'))?.querySelector('.pi') - ).toHaveClass('pi-window-minimize'); + 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 index 84f5bc018d23..7e08fc88ed2c 100644 --- 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 @@ -1,6 +1,6 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; import { ButtonModule } from 'primeng/button'; import { TooltipModule } from 'primeng/tooltip'; @@ -31,6 +31,11 @@ export class DotImageEditorHeaderComponent { /** 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-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 index 6fb75a245eae..38788670eed7 100644 --- 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 @@ -8,20 +8,26 @@ [text]="true" [rounded]="true" severity="secondary" - icon="pi pi-times" [attr.aria-label]="'edit.content.image-editor.history.remove.aria' | dm" (onClick)="onRemove(entry.id)" - data-testid="image-editor-history-remove" /> + data-testid="image-editor-history-remove"> + + close + +
  • }
+ data-testid="image-editor-history-reset"> + + 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 index 58040d9a3339..890a8a4422c0 100644 --- 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 @@ -4,6 +4,12 @@ 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; 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 index 452982e99775..b6c3d5b2b216 100644 --- 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 @@ -9,7 +9,7 @@ - + tune @@ -19,7 +19,9 @@ {{ 'edit.content.image-editor.panel.adjust.subtitle' | dm }} - + @@ -31,7 +33,7 @@ - + transform @@ -41,7 +43,9 @@ {{ 'edit.content.image-editor.panel.transform.subtitle' | dm }} - + @@ -53,7 +57,7 @@ - + description @@ -63,7 +67,9 @@ {{ 'edit.content.image-editor.panel.fileinfo.subtitle' | dm }} - + @@ -75,7 +81,7 @@ - + history @@ -85,7 +91,9 @@ {{ '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 index ba8f51d8dcc6..9d9411d2d99e 100644 --- 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 @@ -62,8 +62,8 @@ flex-shrink: 0; } - .ie-panel-head__icon .pi { - font-size: 14px; + .ie-panel-head__icon .material-symbols-outlined { + font-size: 16px; } // Open section: chip turns primary (`.iem-acc.open .iem-acc-icon`). 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 index 58eb510e103b..f14b5a834517 100644 --- 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 @@ -56,18 +56,24 @@ [ngModel]="store.transform().flipH" [onLabel]="'edit.content.image-editor.transform.flip.horizontal' | dm" [offLabel]="'edit.content.image-editor.transform.flip.horizontal' | dm" - onIcon="pi pi-arrows-h" - offIcon="pi pi-arrows-h" data-testid="image-editor-flip-horizontal-btn" - (onChange)="flipHToggled()" /> + (onChange)="flipHToggled()"> + + flip + {{ 'edit.content.image-editor.transform.flip.horizontal' | dm }} + + + (onChange)="flipVToggled()"> + + flip + {{ 'edit.content.image-editor.transform.flip.vertical' | 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 index 5d8ec170d151..b58d9f0d4ac1 100644 --- 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 @@ -63,6 +63,26 @@ 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; diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html deleted file mode 100644 index 25c9d258609f..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.html +++ /dev/null @@ -1,47 +0,0 @@ -@for (tool of tools; track tool.id) { - -} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss deleted file mode 100644 index f737a8dcc7ae..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.scss +++ /dev/null @@ -1,51 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.5rem; - border-radius: 0.75rem; - background-color: rgba(28, 30, 38, 0.92); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); -} - -.tool-rail__button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - padding: 0; - border: none; - border-radius: 0.625rem; - appearance: none; - cursor: pointer; - // Light glyph so the icon is clearly visible on the dark rail. - color: rgba(255, 255, 255, 0.75); - background-color: transparent; - transition: - background-color 150ms ease, - color 150ms ease; - - &:hover { - color: #ffffff; - background-color: rgba(255, 255, 255, 0.1); - } - - &:focus-visible { - outline: 2px solid var(--p-primary-color, #6366f1); - outline-offset: 2px; - } -} - -// Active tool: filled indigo rounded square with a white icon. -.tool-rail__button--active, -.tool-rail__button--active:hover { - color: #ffffff; - background-color: var(--p-primary-color, #6366f1); -} - -// Inline SVG glyphs inherit the button color via `stroke="currentColor"`. -.tool-rail__icon { - width: 1.25rem; - height: 1.25rem; -} diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts deleted file mode 100644 index 177279d3de9d..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { Dispatcher } from '@ngrx/signals/events'; - -import { signal } from '@angular/core'; - -import { DotMessageService } from '@dotcms/data-access'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotImageEditorToolRailComponent } from './dot-image-editor-tool-rail.component'; - -import { ActiveTool } from '../../models/image-editor.models'; -import { imageEditorToolEvents } from '../../store/image-editor.events'; -import { ImageEditorStore } from '../../store/image-editor.store'; - -const messageServiceMock = new MockDotMessageService({ - 'edit.content.image-editor.tool.move': 'Move', - 'edit.content.image-editor.tool.crop': 'Crop' -}); - -describe('DotImageEditorToolRailComponent', () => { - let spectator: Spectator; - let dispatcher: Dispatcher; - - const activeTool = signal('move'); - - const createComponent = createComponentFactory({ - component: DotImageEditorToolRailComponent, - providers: [{ provide: DotMessageService, useValue: messageServiceMock }], - componentProviders: [ - Dispatcher, - mockProvider(ImageEditorStore, { - activeTool - }) - ] - }); - - beforeEach(() => { - activeTool.set('move'); - spectator = createComponent(); - dispatcher = spectator.inject(Dispatcher, true); - jest.spyOn(dispatcher, 'dispatch'); - }); - - it('should render the two tools with testids and aria-labels', () => { - const move = spectator.query(byTestId('image-editor-tool-move')); - const crop = spectator.query(byTestId('image-editor-tool-crop')); - - expect(move).toBeTruthy(); - expect(crop).toBeTruthy(); - - expect(move).toHaveAttribute('aria-label', 'Move'); - expect(crop).toHaveAttribute('aria-label', 'Crop'); - }); - - it('should expose a vertical toolbar role on the host', () => { - expect(spectator.element).toHaveAttribute('role', 'toolbar'); - expect(spectator.element).toHaveAttribute('aria-orientation', 'vertical'); - }); - - it('should dispatch toolSelected with the crop tool when crop is clicked', () => { - spectator.click(byTestId('image-editor-tool-crop')); - - expect(dispatcher.dispatch).toHaveBeenCalledWith( - imageEditorToolEvents.toolSelected('crop'), - { scope: 'self' } - ); - }); - - it('should mark the active tool with aria-pressed true and others false', () => { - activeTool.set('crop'); - spectator.detectChanges(); - - expect(spectator.query(byTestId('image-editor-tool-crop'))).toHaveAttribute( - 'aria-pressed', - 'true' - ); - expect(spectator.query(byTestId('image-editor-tool-move'))).toHaveAttribute( - 'aria-pressed', - 'false' - ); - }); -}); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts deleted file mode 100644 index defd901fa809..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-tool-rail/dot-image-editor-tool-rail.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { injectDispatch } from '@ngrx/signals/events'; - -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; - -import { TooltipModule } from 'primeng/tooltip'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { ActiveTool, ToolRailItem } from '../../models/image-editor.models'; -import { imageEditorToolEvents } from '../../store/image-editor.events'; -import { ImageEditorStore } from '../../store/image-editor.store'; - -/** - * Floating vertical rail of canvas tools (move, crop). Acts as a `toolbar` with - * roving tabindex: the active tool is the only focusable button, matching the - * WAI-ARIA toolbar pattern. Selecting a tool dispatches - * {@link imageEditorToolEvents.toolSelected} so the store owns the active tool. - */ -@Component({ - selector: 'dot-image-editor-tool-rail', - templateUrl: './dot-image-editor-tool-rail.component.html', - styleUrl: './dot-image-editor-tool-rail.component.scss', - imports: [TooltipModule, DotMessagePipe], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - role: 'toolbar', - 'aria-orientation': 'vertical' - } -}) -export class DotImageEditorToolRailComponent { - protected readonly store = inject(ImageEditorStore); - readonly #dispatch = injectDispatch(imageEditorToolEvents); - - /** The ordered tools rendered on the rail. */ - protected readonly tools: ToolRailItem[] = [ - { - id: 'move', - label: 'edit.content.image-editor.tool.move', - testId: 'image-editor-tool-move' - }, - { - id: 'crop', - label: 'edit.content.image-editor.tool.crop', - testId: 'image-editor-tool-crop' - } - ]; - - /** Selects a tool, delegating the state change to the store. */ - protected selectTool(id: ActiveTool): void { - this.#dispatch.toolSelected(id); - } -} From 2611d712663d6a843f002c241f74da700937fdf9 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 24 Jun 2026 15:45:07 -0400 Subject: [PATCH 22/29] fix(image-editor): crop box reaches image edges; long URL no longer overflows header - Measure the image via its layout box (offsetLeft/Top/Width/Height) instead of getBoundingClientRect()/scale. The stage's transform transition let a mid-flight measurement stick ~2% small, so the default crop box fell a few px short of the image edges; the layout box is transform-independent and always matches. - Address-bar pills no longer shrink (flex: 0 0 auto) and the URL pill flexes from basis 0, so a long filter-chain URL truncates instead of pushing the zoom and undo/redo controls out of the header. --- ...ot-image-editor-address-bar.component.scss | 11 +++++-- .../dot-image-editor-canvas.component.ts | 30 +++++++++---------- 2 files changed, 23 insertions(+), 18 deletions(-) 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 index ed506c221e43..549a1924ecef 100644 --- 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 @@ -16,16 +16,21 @@ 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; background-color: var(--surface-200, #e5e7eb); } -// The URL pill grows to fill the row, pushing the tools pill hard to the left edge -// and the zoom pill to the right edge; the URL truncates inside it. +// 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 auto; + flex: 1 1 0; padding-right: 0.75rem; } 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 index e8224413e222..c9dd32342a1a 100644 --- 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 @@ -429,27 +429,27 @@ export class DotImageEditorCanvasComponent { * can position itself over the rendered pixels. */ #measureImageRect(): void { - const stage = this.stage()?.nativeElement; const img = this.displayImg()?.nativeElement; - if (!stage || !img) { + if (!img) { return; } - // The stage carries the zoom as a CSS `transform: scale(...)`, so - // getBoundingClientRect returns painted (scaled) values. Divide back out so - // the rect is in the stage's logical CSS px — the same space the crop overlay - // (a child of the scaled stage) positions itself in. Without this the crop - // box drifts at any zoom other than 100%. - const scale = this.zoomLevel() / 100; - const stageBox = stage.getBoundingClientRect(); - const imgBox = img.getBoundingClientRect(); - + // 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: (imgBox.left - stageBox.left) / scale, - y: (imgBox.top - stageBox.top) / scale, - width: imgBox.width / scale, - height: imgBox.height / scale + x: img.offsetLeft, + y: img.offsetTop, + width: img.offsetWidth, + height: img.offsetHeight }); } From d27f70d1c49df3b3be2d7a0dc53b6c801864f7b7 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 24 Jun 2026 16:02:18 -0400 Subject: [PATCH 23/29] fix(image-editor): align header grays to design tokens; clamp pan when zoomed - Address bar: use --p-surface-* tokens (PrimeNG 21) instead of the undefined --surface-* that fell back to hardcoded gray-200. Pill fill -> surface-100, active tool -> primary-50/700 tint (matches the design's .iem-iconbtn.active), border/divider -> theme slate tokens. - Canvas: constrain the pan offset so a zoomed image always covers the stage; dragging can no longer pull empty space past the top/left (or bottom/right) edge. Re-clamp on zoom in/out. --- ...ot-image-editor-address-bar.component.scss | 22 +++++---- .../dot-image-editor-canvas.component.ts | 49 +++++++++++++++++-- 2 files changed, 58 insertions(+), 13 deletions(-) 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 index 549a1924ecef..14deccfdddba 100644 --- 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 @@ -4,10 +4,10 @@ gap: 0.5rem; padding: 0.5rem 0.75rem; // White header band; the gray pills sit on it (UVE toolbar-center look). - background-color: var(--surface-0, #ffffff); + background-color: var(--p-surface-0, #ffffff); // Muted dark text/icons at rest; full-strength dark is reserved for hover/active. - color: var(--surface-600, #4b5563); - border-bottom: 1px solid var(--surface-200, #e5e7eb); + 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 @@ -22,7 +22,10 @@ min-width: 0; padding: 0.125rem; border-radius: 1000px; - background-color: var(--surface-200, #e5e7eb); + // 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` @@ -48,12 +51,13 @@ // theme's text-button background. :host ::ng-deep .p-button.address-bar__tool--active, :host ::ng-deep .p-button.address-bar__tool--active:hover { - background-color: var(--surface-0, #ffffff) !important; + // 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; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); - color: var(--surface-900, #111827) !important; + color: var(--p-primary-700, #3747a9) !important; } .address-bar__field { @@ -68,7 +72,7 @@ flex: 0 0 auto; width: 1px; height: 1rem; - background-color: var(--surface-300, #d1d5db); + background-color: var(--p-surface-300, #cbd5e1); } .address-bar__zoom-value { @@ -85,6 +89,6 @@ cursor: pointer; &:hover { - color: var(--surface-900, #111827); + color: var(--p-surface-900, #111827); } } 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 index c9dd32342a1a..58254cc579c7 100644 --- 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 @@ -351,6 +351,8 @@ export class DotImageEditorCanvasComponent { /** 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. */ @@ -360,6 +362,11 @@ export class DotImageEditorCanvasComponent { 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)); } } @@ -387,10 +394,12 @@ export class DotImageEditorCanvasComponent { const origin = this.panOffset(); const move = (moveEvent: PointerEvent) => { - this.panOffset.set({ - x: origin.x + (moveEvent.clientX - startX), - y: origin.y + (moveEvent.clientY - startY) - }); + this.panOffset.set( + this.#clampPan( + origin.x + (moveEvent.clientX - startX), + origin.y + (moveEvent.clientY - startY) + ) + ); }; const up = () => { this.panning.set(false); @@ -412,6 +421,38 @@ export class DotImageEditorCanvasComponent { 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; From 5acbe474191b1af5223486185bb3d24b66a2c0ff Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 10:22:22 -0400 Subject: [PATCH 24/29] feat(image-editor): add AVIF output format; move compression to a dropdown - Add `avif` compression mode -> builds the libvips-only `/filter/avif/avif_q/{q}` chain (same 0..100 quality as jpeg/webp). Registered lowercase as `avif` in VipsImageFilterApiImpl; requires IMAGE_API_USE_LIBVIPS=true on the server. - Replace the compression p-selectButton with a p-select dropdown now that there are five options (none/auto/jpeg/webp/avif); translated item/selectedItem templates, appendTo body. - Wire model (CompressionMode, FilterName), COMPRESSION_LABELS, i18n key, and builder/panel tests. --- ...-image-editor-fileinfo-panel.component.html | 13 ++++++++----- ...age-editor-fileinfo-panel.component.spec.ts | 8 ++++---- ...ot-image-editor-fileinfo-panel.component.ts | 7 ++++--- .../src/lib/image-editor.constants.ts | 3 ++- .../src/lib/models/image-editor.models.ts | 8 +++++--- .../lib/utils/image-filter-url.builder.spec.ts | 18 ++++++++---------- .../src/lib/utils/image-filter-url.builder.ts | 4 ++++ .../WEB-INF/messages/Language.properties | 1 + 8 files changed, 36 insertions(+), 26 deletions(-) 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 index 4c032b5d4dbd..5a251b971a8b 100644 --- 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 @@ -1,19 +1,22 @@

- + (onChange)="compressionChanged($event.value)"> + + {{ option.label | dm }} + {{ option.label | dm }} - +
@if (isCompressing()) { 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 index 0c56e98ac8d6..2ad70360516b 100644 --- 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 @@ -4,7 +4,7 @@ import { Dispatcher } from '@ngrx/signals/events'; import { signal } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { SelectButton } from 'primeng/selectbutton'; +import { Select } from 'primeng/select'; import { DotMessageService } from '@dotcms/data-access'; @@ -50,12 +50,12 @@ describe('DotImageEditorFileInfoPanelComponent', () => { }); it('should dispatch compressionChanged on the select change', () => { - const select = spectator.query(SelectButton); - select!.onChange.emit({ originalEvent: new Event('click'), value: 'webp' }); + 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('webp'); + expect(event!.payload).toBe('avif'); }); describe('when compression is none', () => { 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 index ea702ba5ab71..8aef5205a28e 100644 --- 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 @@ -3,7 +3,7 @@ import { injectDispatch } from '@ngrx/signals/events'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { SelectButtonModule } from 'primeng/selectbutton'; +import { SelectModule } from 'primeng/select'; import { SliderModule, SliderSlideEndEvent } from 'primeng/slider'; import { DotMessagePipe } from '@dotcms/ui'; @@ -23,7 +23,7 @@ import { ImageEditorStore } from '../../../store/image-editor.store'; @Component({ selector: 'dot-image-editor-fileinfo-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, SelectButtonModule, SliderModule, DotMessagePipe], + imports: [FormsModule, SelectModule, SliderModule, DotMessagePipe], templateUrl: './dot-image-editor-fileinfo-panel.component.html', styleUrl: './dot-image-editor-fileinfo-panel.component.scss' }) @@ -39,7 +39,8 @@ export class DotImageEditorFileInfoPanelComponent { { 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.webp', value: 'webp' }, + { label: 'edit.content.image-editor.fileinfo.compression.avif', value: 'avif' } ]; /** Whether a compression strategy is active (so quality applies). */ 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 index 5ec3dbde9bd4..70f9d2456353 100644 --- a/core-web/libs/image-editor/src/lib/image-editor.constants.ts +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -58,7 +58,8 @@ export const COMPRESSION_LABELS: Record = { none: 'None', auto: 'Auto', jpeg: 'JPEG', - webp: 'WebP' + webp: 'WebP', + avif: 'AVIF' }; /** The editable slices, in snapshot order (used to diff/replay history). */ 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 index 88a4077937d2..9d27569cfd1a 100644 --- 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 @@ -10,7 +10,7 @@ import { DotCMSTempFile } from '@dotcms/dotcms-models'; export type ActiveTool = 'move' | 'crop'; /** Output compression strategy applied as the last filter in the chain. */ -export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp'; +export type CompressionMode = 'none' | 'auto' | 'jpeg' | 'webp' | 'avif'; /** Axis-aligned rectangle of the rendered image inside the canvas, in CSS px. */ export interface ImageRect { @@ -36,7 +36,9 @@ export type FilterName = | 'Hsb' | 'Jpeg' | 'WebP' - | 'Quality'; + | 'Quality' + // libvips-only modern format (AV1); registered lowercase as `avif`. + | 'avif'; /** * Resolved information about the asset being edited, including the source @@ -253,7 +255,7 @@ export interface ToolRailItem { testId: string; } -/** A selectable compression option shown in the compression select button. */ +/** A selectable compression option shown in the compression dropdown. */ export interface CompressionOption { label: string; value: CompressionMode; 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 index 0450ca701717..722283ac8380 100644 --- 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 @@ -210,21 +210,19 @@ describe('image-filter-url.builder', () => { }); describe('buildFilterChain - compression', () => { - const cases: Array<[CompressionMode, string]> = [ - ['jpeg', '/jpeg_q/80'], - ['webp', '/webp_q/80'], - ['auto', '/quality_q/80'] + 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) => { + 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: mode === 'jpeg' ? 'Jpeg' : mode === 'webp' ? 'WebP' : 'Quality', - args - }); + expect(result[result.length - 1]).toEqual({ name, args }); }); it('adds no compression filter for mode none', () => { @@ -235,7 +233,7 @@ describe('image-filter-url.builder', () => { it('applies only one compression filter (mutual exclusion)', () => { const result = chain({ fileInfo: { compression: 'webp', quality: 50 } }); const compressionFilters = result.filter((f) => - ['Jpeg', 'WebP', 'Quality'].includes(f.name) + ['Jpeg', 'WebP', 'avif', 'Quality'].includes(f.name) ); expect(compressionFilters).toHaveLength(1); }); 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 index f545bcee80fa..936128178dba 100644 --- 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 @@ -29,6 +29,10 @@ function compressionFilter(mode: CompressionMode, quality: number): AppliedFilte 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': diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 8e93f725c23a..96f6881d33b3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6528,6 +6528,7 @@ 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 From fe49dea053b7d9eadb278a33fdf97cc368c5f2b0 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 10:30:59 -0400 Subject: [PATCH 25/29] fix(edit-content): use getFeatureFlag after DotPropertiesService API change main dropped DotPropertiesService.getFeatureFlagWithDefault(key, default) in favor of getFeatureFlag(key): Observable (the convention the other new-editor flags use). The rebase surfaced this as a build break in the image editor launcher. Switch to getFeatureFlag; the flag still ships as false in dotmarketing-config.properties for rollback safety, and toSignal keeps it off until the server replies. --- .../angular-image-editor.launcher.spec.ts | 9 +++------ .../angular-image-editor.launcher.ts | 10 ++++------ 2 files changed, 7 insertions(+), 12 deletions(-) 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 index f59c7f963ff8..5ca27f31711d 100644 --- 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 @@ -16,7 +16,7 @@ describe('AngularImageEditorLauncher', () => { // 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 getFeatureFlagWithDefault = jest.fn(() => featureFlag$); + const getFeatureFlag = jest.fn(() => featureFlag$); const params: ImageEditorOpenParams = { inode: 'inode-1', @@ -26,7 +26,7 @@ describe('AngularImageEditorLauncher', () => { const createService = createServiceFactory({ service: AngularImageEditorLauncher, - providers: [mockProvider(DotPropertiesService, { getFeatureFlagWithDefault })], + providers: [mockProvider(DotPropertiesService, { getFeatureFlag })], mocks: [DialogService] }); @@ -41,10 +41,7 @@ describe('AngularImageEditorLauncher', () => { it('should be available when FEATURE_FLAG_NEW_IMAGE_EDITOR is on', () => { expect(spectator.service.isAvailable()).toBe(true); - expect(getFeatureFlagWithDefault).toHaveBeenCalledWith( - FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR, - false - ); + expect(getFeatureFlag).toHaveBeenCalledWith(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR); }); it('should NOT be available when the feature flag is off', () => { 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 index 947677fc573c..5d8755cdbb37 100644 --- 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 @@ -17,8 +17,9 @@ import { * 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 (off by default), so the - * binary field falls back to the legacy Dojo editor until an admin enables it. + * `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 { @@ -27,10 +28,7 @@ export class AngularImageEditorLauncher implements DotImageEditorLauncher { /** Resolved value of the new-image-editor feature flag (off until the server replies). */ readonly #enabled = toSignal( - this.#propertiesService.getFeatureFlagWithDefault( - FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR, - false - ), + this.#propertiesService.getFeatureFlag(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR), { initialValue: false } ); From 403a6656690dd51e077516eb7617e2beda8cd6c6 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 10:53:13 -0400 Subject: [PATCH 26/29] feat(image-editor): crop aspect dropdown + orientation, natural-relative zoom %, panels open by default - Crop bar: replace the aspect-preset button row with a dropdown (Free/1:1/16:9/4:3) and add a landscape/portrait orientation toggle. cropAspect is now derived as the preset ratio inverted for portrait; orientation is disabled for Free and 1:1. - Zoom readout: show the scale relative to the image's NATURAL pixels (fitRatio x zoomLevel), so a huge image shown whole reads as e.g. 30% instead of a misleading 100%; 100% means 1:1. Internal zoom mechanics unchanged. - Accordion panels: default to all sections open (adjust/transform/fileinfo/history) when there is no stored layout, instead of all collapsed. - Add the missing crop aspect/width/height i18n keys plus orientation labels. --- .../dot-image-editor-canvas.component.html | 58 +++++++++--- .../dot-image-editor-canvas.component.scss | 43 ++++++--- .../dot-image-editor-canvas.component.spec.ts | 91 ++++++++++++++----- .../dot-image-editor-canvas.component.ts | 75 +++++++++++++-- .../dot-image-editor-panels.component.spec.ts | 9 +- .../dot-image-editor-panels.component.ts | 6 +- .../src/lib/utils/panel-state.storage.spec.ts | 11 ++- .../src/lib/utils/panel-state.storage.ts | 10 +- .../WEB-INF/messages/Language.properties | 6 ++ 9 files changed, 238 insertions(+), 71 deletions(-) diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html index f8e1602680e6..3ef30285a2fb 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html @@ -1,7 +1,7 @@
@@ -82,20 +82,52 @@ width/height readout/editor and apply/cancel actions, centered at the bottom of the viewport and revealed on hover (like the tool rail). -->
+ + + + + +
- @for (preset of aspectPresets; track preset.key) { - - } + [attr.aria-label]="'edit.content.image-editor.crop.orientation' | dm"> + +
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 index 8e5fb72278a6..a07c84853df2 100644 --- 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 @@ -85,42 +85,63 @@ // Aspect presets in the (light) floating crop action bar: plain dark-gray text // pills, the selected one filled with the primary color. -.canvas__aspects { +// 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__aspect { - padding: 0.25rem 0.75rem; +.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); - font-size: 0.8125rem; - font-variant-numeric: tabular-nums; cursor: pointer; transition: background 150ms ease, color 150ms ease; + + .material-symbols-outlined { + font-size: 1.125rem; + } } -.canvas__aspect:hover { +.canvas__orient-btn:hover:not(:disabled) { background: var(--surface-100, #f3f4f6); color: var(--surface-900, #1f2937); } -.canvas__aspect--active { +.canvas__orient-btn--active, +.canvas__orient-btn--active:hover { background: var(--primary-color, #6366f1); color: #ffffff; } -.canvas__aspect--active:hover { - background: var(--primary-color, #6366f1); - color: #ffffff; +.canvas__orient-btn:disabled { + opacity: 0.4; + cursor: default; } @media (prefers-reduced-motion: reduce) { - .canvas__aspect { + .canvas__orient-btn { 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 index c4679aafb5d7..2609c3104933 100644 --- 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 @@ -6,6 +6,8 @@ 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'; @@ -320,43 +322,78 @@ describe('DotImageEditorCanvasComponent', () => { expect(spectator.query(byTestId('image-editor-crop-cancel-btn'))).toExist(); }); - it('should render the aspect presets including Free in the crop bar', () => { + 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-free'))).toExist(); - expect(spectator.query(byTestId('image-editor-aspect-square'))).toExist(); - expect(spectator.query(byTestId('image-editor-aspect-standard'))).toExist(); - expect(spectator.query(byTestId('image-editor-aspect-wide'))).toExist(); + 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 lock the crop aspect when a preset pill is clicked and clear it on Free', () => { + it('should derive the locked aspect from the selected preset and orientation', () => { activeTool.set('crop'); spectator.detectChanges(); - // The component owns cropAspect, which it binds to the crop overlay's - // `aspect` input ([aspect]="cropAspect()"). - const cropAspect = () => - ( - spectator.component as unknown as { cropAspect: () => number | null } - ).cropAspect(); + // 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); + }); - spectator.click(spectator.query(byTestId('image-editor-aspect-wide'))!); + it('should flip orientation from the toolbar buttons', () => { + activeTool.set('crop'); spectator.detectChanges(); - // The selected pill is highlighted and the locked ratio is stored. - const wide = spectator.query(byTestId('image-editor-aspect-wide')); - expect(wide).toHaveClass('canvas__aspect--active'); - expect(cropAspect()).toBeCloseTo(16 / 9, 5); + const cmp = spectator.component as unknown as { + cropPreset: { set: (value: string) => void }; + cropOrientation: () => 'landscape' | 'portrait'; + }; + cmp.cropPreset.set('wide'); + spectator.detectChanges(); - // Free clears the lock back to free-form (null) and highlights instead. - spectator.click(spectator.query(byTestId('image-editor-aspect-free'))!); + spectator.click(spectator.query(byTestId('image-editor-orient-portrait'))!); spectator.detectChanges(); - expect(cropAspect()).toBeNull(); - expect(spectator.query(byTestId('image-editor-aspect-free'))).toHaveClass( - 'canvas__aspect--active' + + expect(cmp.cropOrientation()).toBe('portrait'); + expect(spectator.query(byTestId('image-editor-orient-portrait'))).toHaveClass( + 'canvas__orient-btn--active' ); - expect(wide).not.toHaveClass('canvas__aspect--active'); }); it('should invoke the crop overlay apply/cancel from the action bar when cropping', () => { @@ -405,7 +442,9 @@ describe('DotImageEditorCanvasComponent', () => { expect(heightInput().disabled).toBe(true); // Selecting a preset locks a ratio and makes the fields editable. - spectator.click(spectator.query(byTestId('image-editor-aspect-square'))!); + ( + spectator.component as unknown as { cropPreset: { set: (value: string) => void } } + ).cropPreset.set('square'); spectator.detectChanges(); await Promise.resolve(); spectator.detectChanges(); @@ -419,7 +458,9 @@ describe('DotImageEditorCanvasComponent', () => { spectator.detectChanges(); // Lock to 1:1 so the height follows the typed width. - spectator.click(spectator.query(byTestId('image-editor-aspect-square'))!); + ( + 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 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 index 58254cc579c7..1616071a30c6 100644 --- 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 @@ -19,7 +19,9 @@ 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'; @@ -54,7 +56,9 @@ import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-ove ButtonModule, InputNumberModule, ProgressSpinnerModule, + SelectModule, SkeletonModule, + TooltipModule, DotMessagePipe, DotImageEditorAddressBarComponent, DotImageEditorCropOverlayComponent @@ -68,9 +72,9 @@ export class DotImageEditorCanvasComponent { readonly #service = inject(DotImageEditorService); /** - * Aspect-ratio presets shown in the crop action bar. Selecting one reshapes and - * locks the crop box to that ratio; `Free` (a `null` ratio) clears the lock and - * returns to free-form cropping. + * 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 }, @@ -79,8 +83,37 @@ export class DotImageEditorCanvasComponent { { key: 'standard', label: '4:3', aspect: 4 / 3 } ]; - /** The locked crop aspect ratio (width / height), or `null` for free-form. */ - protected readonly cropAspect = signal(null); + /** 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 @@ -129,9 +162,17 @@ export class DotImageEditorCanvasComponent { /** Rendered bounds of the displayed image within the stage, in CSS px. */ protected readonly imageRect = signal(undefined); - /** Display-only zoom percentage applied as a CSS transform to the stage. */ + /** 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 }); @@ -150,6 +191,14 @@ export class DotImageEditorCanvasComponent { 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 @@ -212,8 +261,9 @@ export class DotImageEditorCanvasComponent { if (tool !== 'crop') { this.capturedCropRect.set(undefined); // Leaving crop clears the locked aspect so the next crop session - // starts free-form. - this.cropAspect.set(null); + // starts free-form (and landscape). + this.cropPreset.set('free'); + this.cropOrientation.set('landscape'); return; } @@ -492,6 +542,15 @@ export class DotImageEditorCanvasComponent { 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 + ); } /** 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 index b53badd1cae2..05ae932e58db 100644 --- 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 @@ -62,10 +62,15 @@ describe('DotImageEditorPanelsComponent', () => { expect(spectator.query(byTestId('image-editor-panel-history'))).toExist(); }); - it('should start with every section collapsed by default', () => { + it('should start with every section open by default', () => { spectator = createComponent(); - expect(spectator.component['openPanels']()).toEqual([]); + expect(spectator.component['openPanels']()).toEqual([ + 'adjust', + 'transform', + 'fileinfo', + 'history' + ]); }); it('should seed the open sections from localStorage', () => { 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 index cd65b058866d..b71f8c64982f 100644 --- 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 @@ -19,9 +19,9 @@ import { getStoredPanelState, savePanelState } from '../../utils/panel-state.sto * 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 — empty, so every - * section is collapsed on first use — and an effect writes the set back whenever - * it changes, so the user's layout is remembered the way they left it. + * 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', 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 index 8dbefd1bb50f..d9f72d74787c 100644 --- 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 @@ -2,6 +2,9 @@ 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(); @@ -9,8 +12,8 @@ describe('panel-state.storage', () => { }); describe('getStoredPanelState', () => { - it('returns an empty array (all collapsed) when nothing is stored', () => { - expect(getStoredPanelState()).toEqual([]); + it('returns every section (all open) when nothing is stored', () => { + expect(getStoredPanelState()).toEqual(ALL_OPEN); }); it('returns the persisted open sections', () => { @@ -25,13 +28,13 @@ describe('panel-state.storage', () => { it('falls back to the default when the stored value is corrupt', () => { localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, '{ not json'); - expect(getStoredPanelState()).toEqual([]); + 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([]); + expect(getStoredPanelState()).toEqual(ALL_OPEN); }); }); 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 index 230c72502e04..b2569a649359 100644 --- 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 @@ -12,16 +12,16 @@ import { IMAGE_EDITOR_PANEL_STATE_KEY } from '../image-editor.constants'; * 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 is an empty - * array so every section starts collapsed. + * 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 collapsed by default. */ -const DEFAULT_PANEL_STATE: string[] = []; +/** 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 collapsed) when nothing is stored or the stored value is unusable. + * (all sections open) when nothing is stored or the stored value is unusable. */ export const getStoredPanelState = (): string[] => { try { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 96f6881d33b3..ff4ed3005c22 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6498,6 +6498,12 @@ 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 From efca8b20defa42ca3d1b0057058b9a2345d4c65f Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 14:23:16 -0400 Subject: [PATCH 27/29] feat(image-editor): preview-in-new-tab button + focal point tool (editor state) - Address bar: add an "open in new tab" button in the URL pill that opens the current preview URL in a new tab (same absolute URL the copy action uses). - Re-add the focal point as a canvas tool: a header toggle next to crop plus a draggable marker overlay that records a normalized 0..1 point in the store (withFocalPoint reducer; not in the filter chain or history). - The focal point is intentionally NOT persisted on its own. In dotCMS the focal write only stages to a temp slot (FocalPointImageFilter prepends TMP::, committed at check-in), so it belongs to the Save flow (#36067). The marker just records the point in editor state for Save to persist later. --- ...ot-image-editor-address-bar.component.html | 18 ++ ...image-editor-address-bar.component.spec.ts | 31 ++- .../dot-image-editor-address-bar.component.ts | 13 ++ .../dot-image-editor-canvas.component.html | 2 + .../dot-image-editor-canvas.component.ts | 4 +- ...-image-editor-focal-overlay.component.html | 19 ++ ...-image-editor-focal-overlay.component.scss | 48 +++++ ...age-editor-focal-overlay.component.spec.ts | 117 +++++++++++ ...ot-image-editor-focal-overlay.component.ts | 197 ++++++++++++++++++ .../src/lib/image-editor.constants.ts | 4 + .../src/lib/models/image-editor.models.ts | 14 +- .../src/lib/store/features/index.ts | 1 + .../features/with-focal-point.feature.spec.ts | 44 ++++ .../features/with-focal-point.feature.ts | 28 +++ .../src/lib/store/image-editor.events.ts | 8 +- .../src/lib/store/image-editor.state.ts | 5 + .../src/lib/store/image-editor.store.ts | 2 + .../WEB-INF/messages/Language.properties | 1 + 18 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.html create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.scss create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/components/dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.spec.ts create mode 100644 core-web/libs/image-editor/src/lib/store/features/with-focal-point.feature.ts 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 index 99ebee37bcd7..dd7e51d8ca1a 100644 --- 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 @@ -29,6 +29,9 @@ @case ('crop') { crop } + @case ('focal') { + center_focus_strong + } } @@ -58,6 +61,21 @@ data-testid="image-editor-address-field"> {{ store.previewUrl() }} + + + + open_in_new + +
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts index 355c7f0736a8..cec27056d898 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts @@ -20,7 +20,8 @@ const messageServiceMock = new MockDotMessageService({ 'edit.content.image-editor.address.copy.success': 'Copied', 'edit.content.image-editor.address.copy.error': 'Could not copy', 'edit.content.image-editor.tool.move': 'Move', - 'edit.content.image-editor.tool.crop': 'Crop' + 'edit.content.image-editor.tool.crop': 'Crop', + 'edit.content.image-editor.tool.focal': 'Focal point' }); describe('DotImageEditorAddressBarComponent', () => { @@ -87,6 +88,19 @@ describe('DotImageEditorAddressBarComponent', () => { expect(writeText).toHaveBeenCalledWith(document.location.origin + PREVIEW_URL); }); + it('should open the absolute preview URL in a new tab when the preview button is clicked', () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + spectator.click(button('image-editor-preview-url-btn')); + + expect(openSpy).toHaveBeenCalledWith( + document.location.origin + PREVIEW_URL, + '_blank', + 'noopener' + ); + openSpy.mockRestore(); + }); + it('should dispatch undoRequested when undo is clicked', () => { spectator.click(button('image-editor-undo-btn')); @@ -156,6 +170,19 @@ describe('DotImageEditorAddressBarComponent', () => { ); }); + it('should render the focal tool toggle and dispatch toolSelected when clicked', () => { + const focal = spectator.query(byTestId('image-editor-tool-focal')); + expect(focal).toBeTruthy(); + expect(focal).toHaveAttribute('aria-label', 'Focal point'); + + spectator.click(button('image-editor-tool-focal')); + + expect(dispatcher.dispatch).toHaveBeenCalledWith( + imageEditorToolEvents.toolSelected('focal'), + { scope: 'self' } + ); + }); + it('should reflect the store active tool with aria-pressed and the active styleClass', () => { activeTool.set('crop'); spectator.detectChanges(); @@ -176,8 +203,10 @@ describe('DotImageEditorAddressBarComponent', () => { it('should expose the expected testids', () => { expect(spectator.query(byTestId('image-editor-address-field'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-copy-url-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-preview-url-btn'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-tool-move'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-tool-crop'))).toBeTruthy(); + expect(spectator.query(byTestId('image-editor-tool-focal'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-zoom-out-btn'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-zoom-in-btn'))).toBeTruthy(); expect(spectator.query(byTestId('image-editor-fit-btn'))).toBeTruthy(); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts index 8a4100537232..8ee0ae5a8f21 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts @@ -49,6 +49,11 @@ export class DotImageEditorAddressBarComponent { id: 'crop', label: 'edit.content.image-editor.tool.crop', testId: 'image-editor-tool-crop' + }, + { + id: 'focal', + label: 'edit.content.image-editor.tool.focal', + testId: 'image-editor-tool-focal' } ]; @@ -86,6 +91,14 @@ export class DotImageEditorAddressBarComponent { } } + /** + * Opens the current preview URL (the same absolute URL `copyUrl` copies) in a new + * browser tab so the user can inspect the server-rendered result on its own. + */ + protected openPreview(): void { + this.#document.defaultView?.open(this.#absoluteUrl(), '_blank', 'noopener'); + } + /** * Resolves the store's root-relative preview URL against the current origin so the * copied value is a complete, shareable URL rather than just the path. diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html index 3ef30285a2fb..bd9fdf43db92 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html @@ -49,6 +49,8 @@ [imageRect]="imageRect()" [initialRect]="capturedCropRect()" [aspect]="cropAspect()" /> + +
@if (store.previewStatus() === 'loading') { 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 index 1616071a30c6..f76a27586350 100644 --- 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 @@ -35,6 +35,7 @@ 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'; /** * Dark stage that renders the live image preview at the center of the editor. @@ -61,7 +62,8 @@ import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-ove TooltipModule, DotMessagePipe, DotImageEditorAddressBarComponent, - DotImageEditorCropOverlayComponent + DotImageEditorCropOverlayComponent, + DotImageEditorFocalOverlayComponent ], changeDetection: ChangeDetectionStrategy.OnPush }) 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/image-editor.constants.ts b/core-web/libs/image-editor/src/lib/image-editor.constants.ts index 70f9d2456353..02cea86e2651 100644 --- a/core-web/libs/image-editor/src/lib/image-editor.constants.ts +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -27,6 +27,10 @@ export const ZOOM_DEFAULT = 100; 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; 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 index 9d27569cfd1a..c19f3594c722 100644 --- 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 @@ -6,8 +6,15 @@ 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'; +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'; @@ -203,6 +210,11 @@ export interface ImageEditorState { 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. */ 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 index ba0ef7cdf6af..f323309868b4 100644 --- a/core-web/libs/image-editor/src/lib/store/features/index.ts +++ b/core-web/libs/image-editor/src/lib/store/features/index.ts @@ -3,6 +3,7 @@ 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'; 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/image-editor.events.ts b/core-web/libs/image-editor/src/lib/store/image-editor.events.ts index b3814110dff2..a55a7e2bde6e 100644 --- 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 @@ -5,7 +5,8 @@ import { ActiveTool, CompressionMode, CropState, - ImageEditorOpenParams + ImageEditorOpenParams, + NormalizedPoint } from '../models/image-editor.models'; /** Events emitted by the Adjust panel (color & light). */ @@ -48,13 +49,14 @@ export const imageEditorViewEvents = eventGroup({ } }); -/** Events emitted by the canvas tools (move/crop). */ +/** Events emitted by the canvas tools (move/crop/focal). */ export const imageEditorToolEvents = eventGroup({ source: 'Image Editor Tool', events: { toolSelected: type(), cropApplied: type(), - cropCancelled: type() + cropCancelled: type(), + focalPointSet: 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 index 434ebc1e005d..2c6afa0a0b9c 100644 --- 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 @@ -4,6 +4,7 @@ import { FileInfoState, ImageEditorAssetContext, ImageEditorState, + NormalizedPoint, TransformState, ZoomState } from '../models/image-editor.models'; @@ -67,6 +68,9 @@ export const initialZoomState: ZoomState = { 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, @@ -75,6 +79,7 @@ export const initialImageEditorState: ImageEditorState = { crop: initialCropState, fileInfo: initialFileInfoState, zoom: initialZoomState, + focalPoint: initialFocalPointState, activeTool: 'move', previewStatus: 'idle', previewRetries: 0, 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 index 91cbe69d7d92..792bced0ec0b 100644 --- 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 @@ -6,6 +6,7 @@ import { withCrop, withDownload, withFileInfo, + withFocalPoint, withHistory, withPreview, withTransform, @@ -31,6 +32,7 @@ export const ImageEditorStore = signalStore( withTransform(), withCrop(), withFileInfo(), + withFocalPoint(), withView(), withHistory(), withAsset(), diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index ff4ed3005c22..b3b1e5d4aa8b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6484,6 +6484,7 @@ 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 From 1978d226e69fdd981dadb5dd03e1dbe97a2cb3de Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 16:54:48 -0400 Subject: [PATCH 28/29] fix(image-editor): clean up comment in image-editor.store.ts - Simplify the comment regarding the NgRx SignalStore structure by removing a redundant link and streamlining the description of feature organization. --- core-web/libs/image-editor/src/lib/store/image-editor.store.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 792bced0ec0b..b15eb74bf769 100644 --- 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 @@ -16,8 +16,7 @@ import { initialImageEditorState } from './image-editor.state'; /** * NgRx SignalStore for the image editor, composed from one vertical feature per - * area of functionality (see - * https://ngrx.io/guide/signals/signal-store/custom-store-features). Each feature + * 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`). * From b3e24d1a6a257e8cf4444f335525c443cfd21e0c Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 25 Jun 2026 17:28:03 -0400 Subject: [PATCH 29/29] fix(image-editor): address PR review findings - Give scale/resize their own 'resize' history category (no longer coalesce with brightness/hue/saturation under 'adjust'). - Undo/redo guard also treats role=combobox (PrimeNG select/dropdown/autocomplete) as an editable target so shortcuts don't hijack keys inside open controls. - Binary field launcher subscribe gets an error callback (no silent failure). - Transform panel dispatches the displayed (computed outputDimensions) value for the unchanged axis, so persisted output dims match what the field shows. - History entry ids use a monotonic counter instead of Date.now() (collision-free, fake-timer stable). - with-preview resolveSize$ gets catchError so a dispatch throw can't freeze the size readout; remove console.warn from panel-state storage; fix canvas/launcher-token comments. Test additions: quality lower-bound clamp, download append/remove asserts. --- ...dot-edit-content-binary-field.component.ts | 9 ++++++++- .../image-editor-launcher.token.ts | 7 ++++--- .../dot-image-editor-canvas.component.ts | 2 +- ...e-editor-transform-panel.component.spec.ts | 8 +++++--- ...-image-editor-transform-panel.component.ts | 7 +++++-- .../dot-image-editor.component.ts | 16 ++++++++------- .../src/lib/models/image-editor.models.ts | 9 ++++++++- .../services/dot-image-editor.service.spec.ts | 7 +++++++ .../features/with-file-info.feature.spec.ts | 3 +++ .../store/features/with-preview.feature.ts | 20 ++++++++++--------- .../store/features/with-transform.feature.ts | 4 ++-- .../src/lib/store/image-editor.store-utils.ts | 7 ++++++- .../src/lib/utils/panel-state.storage.ts | 9 +++++---- 13 files changed, 74 insertions(+), 34 deletions(-) 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 f4be024ba35d..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 @@ -435,7 +435,14 @@ export class DotEditContentBinaryFieldComponent filter((tempFile): tempFile is DotCMSTempFile => !!tempFile), takeUntilDestroyed(this.#destroyRef) ) - .subscribe((tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile)); + .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/image-editor-launcher.token.ts b/core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/image-editor-launcher.token.ts index f919a488db1c..f559b583c2df 100644 --- 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 @@ -7,9 +7,10 @@ export type { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/imag /** * DI seam for launching the image editor from the binary field. * - * The Angular edit-content shell provides the dialog-based launcher. When the - * token is left unprovided, the binary field injects it as optional and hides - * the "edit image" action, so no fallback implementation is needed. + * 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/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 index f76a27586350..55756239c157 100644 --- 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 @@ -38,7 +38,7 @@ import { DotImageEditorCropOverlayComponent } from '../dot-image-editor-crop-ove import { DotImageEditorFocalOverlayComponent } from '../dot-image-editor-focal-overlay/dot-image-editor-focal-overlay.component'; /** - * Dark stage that renders the live image preview at the center of the editor. + * 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 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 index f8c3c2feacf4..339ed2990d2a 100644 --- 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 @@ -93,7 +93,9 @@ describe('DotImageEditorTransformPanelComponent', () => { const event = dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type); expect(event).toBeDefined(); - expect(event!.payload).toEqual({ width: 1024, height: null }); + // 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', () => { @@ -101,7 +103,7 @@ describe('DotImageEditorTransformPanelComponent', () => { const event = dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type); expect(event).toBeDefined(); - expect(event!.payload).toEqual({ width: null, height: 768 }); + expect(event!.payload).toEqual({ width: 800, height: 768 }); }); it('should show the min error and dispatch a null dimension when width is below 1', () => { @@ -112,7 +114,7 @@ describe('DotImageEditorTransformPanelComponent', () => { expect(dispatchedEvent(imageEditorTransformEvents.outputDimsChanged.type)!.payload).toEqual( { width: null, - height: null + height: 600 } ); }); 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 index 3b5408365e8f..7d5501cfba86 100644 --- 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 @@ -113,9 +113,12 @@ export class DotImageEditorTransformPanelComponent { /** 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.transform().outputHeight + height: this.store.outputDimensions().height }); } @@ -123,7 +126,7 @@ export class DotImageEditorTransformPanelComponent { protected outputHeightChanged(value: number | null): void { this.heightError.set(this.#isBelowMinimum(value)); this.dispatch.outputDimsChanged({ - width: this.store.transform().outputWidth, + width: this.store.outputDimensions().width, height: this.#toDimension(value) }); } 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 index 733519a2d01f..60723e55da40 100644 --- 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 @@ -186,17 +186,19 @@ export class DotImageEditorComponent { /** Whether the event originated from an editable control (keeps its native undo). */ #isEditableTarget(target: EventTarget | null): boolean { - const el = target as HTMLElement | null; - - if (!el) { + if (!(target instanceof HTMLElement)) { return false; } return ( - el.tagName === 'INPUT' || - el.tagName === 'TEXTAREA' || - el.tagName === 'SELECT' || - el.isContentEditable + 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/models/image-editor.models.ts b/core-web/libs/image-editor/src/lib/models/image-editor.models.ts index c19f3594c722..befc49b3d9f8 100644 --- 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 @@ -31,7 +31,14 @@ export interface ImageRect { export type PreviewStatus = 'idle' | 'loading' | 'loaded' | 'error'; /** Logical category an applied edit belongs to, used for grouping and labels. */ -export type FilterCategory = 'adjust' | 'crop' | 'rotate' | 'flip' | 'grayscale' | 'compression'; +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 = 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 index 3531f582dfb6..9840849604af 100644 --- 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 @@ -150,15 +150,22 @@ describe('DotImageEditorService', () => { 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/store/features/with-file-info.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-file-info.feature.spec.ts index 378292be1796..37e920d405ed 100644 --- 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 @@ -40,5 +40,8 @@ describe('withFileInfo', () => { 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-preview.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-preview.feature.ts index 80810e74d258..a298dafe1317 100644 --- 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 @@ -1,10 +1,11 @@ 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 { debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators'; +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'; @@ -95,15 +96,16 @@ export function withPreview() { debounceTime(250), distinctUntilChanged(), switchMap((url) => - service - .getFileSize(url) - .pipe( - tap((bytes) => - dispatcher.dispatch( - imageEditorLifecycleEvents.previewSizeResolved(bytes) - ) + 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.ts b/core-web/libs/image-editor/src/lib/store/features/with-transform.feature.ts index 6676df7bc232..418d27400b5b 100644 --- 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 @@ -26,7 +26,7 @@ export function withTransform() { const transform: TransformState = { ...state.transform, scale: value }; const crop = value !== 100 ? initialCropState : state.crop; - return transformPatch(state, transform, crop, 'adjust', `Scale ${value}%`); + return transformPatch(state, transform, crop, 'resize', `Scale ${value}%`); }), on(imageEditorTransformEvents.rotateChanged, ({ payload }, state) => { const value = clamp(payload, RANGES.rotate.min, RANGES.rotate.max); @@ -59,7 +59,7 @@ export function withTransform() { const isResizing = payload.width != null || payload.height != null; const crop = isResizing ? initialCropState : state.crop; - return transformPatch(state, transform, crop, 'adjust', 'Resize'); + return transformPatch(state, transform, crop, 'resize', 'Resize'); }) ), withComputed((store) => ({ 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 index af752d00e76b..5c2f2c23476f 100644 --- 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 @@ -57,6 +57,9 @@ export function editableSlicesOf(state: ImageEditorState): EditableSlices { * @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, @@ -78,7 +81,9 @@ export function coalesceHistory( } const entry: ImageEditorHistoryEntry = { - id: `${category}-${Date.now()}-${state.cacheBust}`, + // 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 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 index b2569a649359..32435e661ef6 100644 --- 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 @@ -32,8 +32,9 @@ export const getStoredPanelState = (): string[] => { return parsed; } } - } catch (e) { - console.warn('Error reading image editor panel state from localStorage:', e); + } 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]; @@ -43,7 +44,7 @@ export const getStoredPanelState = (): string[] => { export const savePanelState = (state: string[]): void => { try { localStorage.setItem(IMAGE_EDITOR_PANEL_STATE_KEY, JSON.stringify(state)); - } catch (e) { - console.warn('Error saving image editor panel state to localStorage:', e); + } catch { + // Persisting the layout is best-effort; ignore storage errors (quota/blocked). } };