Skip to content

Commit 2c623b3

Browse files
committed
feat(edit-content): add Angular image editor with events-based signalStore (#36063)
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
1 parent b40a64f commit 2c623b3

82 files changed

Lines changed: 7268 additions & 40 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core-web/apps/dotcms-ui/.storybook/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
'../../../libs/template-builder/**/*.stories.@(js|jsx|ts|tsx|mdx)',
1515
'../../../libs/block-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)',
1616
'../../../libs/edit-content/**/*.stories.@(js|jsx|ts|tsx|mdx)',
17+
'../../../libs/image-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)',
1718
'../../../libs/ui/**/*.stories.@(js|jsx|ts|tsx|mdx)',
1819
'../../../libs/portlets/**/*.stories.@(js|jsx|ts|tsx|mdx)'
1920
],

core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ import {
44
createComponentFactory,
55
createHostFactory,
66
Spectator,
7-
SpectatorHost,
8-
SpyObject
7+
SpectatorHost
98
} from '@ngneat/spectator/jest';
109
import { of } from 'rxjs';
1110

1211
import { provideHttpClient } from '@angular/common/http';
13-
import { Component, NgZone } from '@angular/core';
14-
import { fakeAsync, tick } from '@angular/core/testing';
12+
import { Component } from '@angular/core';
1513
import {
1614
ControlContainer,
1715
FormControl,
@@ -46,6 +44,7 @@ import { getUiMessage } from './utils/binary-field-utils';
4644
import { CONTENTTYPE_FIELDS_MESSAGE_MOCK, fileMetaData } from './utils/mock';
4745

4846
import { BINARY_FIELD_MOCK, createFormGroupDirectiveMock } from '../../utils/mocks';
47+
import { IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher';
4948

5049
const TEMP_FILE_MOCK: DotCMSTempFile = {
5150
fileName: 'image.png',
@@ -81,21 +80,24 @@ const MOCK_DOTCMS_FILE = {
8180
binaryFieldMetaData: fileMetaData
8281
};
8382

83+
const imageEditorLauncherMock = {
84+
isAvailable: jest.fn().mockReturnValue(true),
85+
open: jest.fn().mockReturnValue(of(null))
86+
};
87+
8488
describe('DotEditContentBinaryFieldComponent', () => {
8589
let spectator: Spectator<DotEditContentBinaryFieldComponent>;
8690
let store: DotBinaryFieldStore;
87-
88-
let dotBinaryFieldEditImageService: SpyObject<DotBinaryFieldEditImageService>;
8991
let dotAiService: DotAiService;
90-
let ngZone: NgZone;
9192

9293
const createComponent = createComponentFactory({
9394
component: DotEditContentBinaryFieldComponent,
9495
componentProviders: [
9596
DotBinaryFieldStore,
9697
DotBinaryFieldEditImageService,
9798
DotAiService,
98-
DialogService
99+
DialogService,
100+
{ provide: IMAGE_EDITOR_LAUNCHER, useValue: imageEditorLauncherMock }
99101
],
100102
componentViewProviders: [
101103
{ provide: ControlContainer, useValue: createFormGroupDirectiveMock() }
@@ -135,6 +137,9 @@ describe('DotEditContentBinaryFieldComponent', () => {
135137
// which can cause these async setups to hang/flap. Force real timers for isolation.
136138
jest.useRealTimers();
137139

140+
imageEditorLauncherMock.isAvailable.mockReturnValue(true);
141+
imageEditorLauncherMock.open.mockReturnValue(of(null));
142+
138143
spectator = createComponent({
139144
detectChanges: false,
140145
props: {
@@ -145,9 +150,7 @@ describe('DotEditContentBinaryFieldComponent', () => {
145150
}
146151
});
147152
store = spectator.inject(DotBinaryFieldStore, true);
148-
dotBinaryFieldEditImageService = spectator.inject(DotBinaryFieldEditImageService, true);
149153
dotAiService = spectator.inject(DotAiService, true);
150-
ngZone = spectator.inject(NgZone);
151154
});
152155

153156
it('shouldnt show url import button if not setted in settings', () => {
@@ -301,38 +304,59 @@ describe('DotEditContentBinaryFieldComponent', () => {
301304
});
302305

303306
describe('Edit Image', () => {
304-
it('should open edit image dialog when click on edit image button', () => {
307+
it('should launch the image editor through the launcher seam on edit image', () => {
305308
spectator.detectChanges();
306-
const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor');
307309
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);
308-
expect(spy).toHaveBeenCalled();
310+
311+
expect(imageEditorLauncherMock.open).toHaveBeenCalledWith(
312+
expect.objectContaining({
313+
variable: BINARY_FIELD_MOCK.variable,
314+
fieldName: BINARY_FIELD_MOCK.name,
315+
byInode: false
316+
})
317+
);
309318
});
310319

311-
it('should emit the tempId of the edited image', () => {
312-
// Needed because the openImageEditor method is using a DOM custom event
313-
ngZone.run(
314-
fakeAsync(() => {
315-
const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor');
316-
const spyTempFile = jest.spyOn(store, 'setFileFromTemp');
317-
const dotBinaryFieldPreviewComponent = spectator.fixture.debugElement.query(
318-
By.css('dot-binary-field-preview')
319-
);
320-
dotBinaryFieldPreviewComponent.triggerEventHandler('editImage');
321-
const customEvent = new CustomEvent(
322-
`binaryField-tempfile-${BINARY_FIELD_MOCK.variable}`,
323-
{
324-
detail: { tempFile: TEMP_FILE_MOCK }
325-
}
326-
);
327-
document.dispatchEvent(customEvent);
320+
it('should map asset identifiers from the contentlet when launching', () => {
321+
spectator.setInput('contentlet', {
322+
...MOCK_DOTCMS_FILE,
323+
inode: 'inode-123',
324+
fileName: 'photo.png'
325+
});
326+
spectator.detectChanges();
328327

329-
tick(1000);
328+
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);
330329

331-
expect(spy).toHaveBeenCalled();
332-
expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK);
330+
expect(imageEditorLauncherMock.open).toHaveBeenCalledWith(
331+
expect.objectContaining({
332+
inode: 'inode-123',
333+
variable: BINARY_FIELD_MOCK.variable,
334+
fieldName: BINARY_FIELD_MOCK.name,
335+
byInode: true,
336+
fileName: 'photo.png'
333337
})
334338
);
335339
});
340+
341+
it('should apply the edited temp file through the store', () => {
342+
imageEditorLauncherMock.open.mockReturnValue(of(TEMP_FILE_MOCK));
343+
const spyTempFile = jest.spyOn(store, 'setFileFromTemp');
344+
spectator.detectChanges();
345+
346+
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);
347+
348+
expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK);
349+
});
350+
351+
it('should not apply a temp file when the editor is cancelled', () => {
352+
imageEditorLauncherMock.open.mockReturnValue(of(null));
353+
const spyTempFile = jest.spyOn(store, 'setFileFromTemp');
354+
spectator.detectChanges();
355+
356+
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);
357+
358+
expect(spyTempFile).not.toHaveBeenCalled();
359+
});
336360
});
337361
});
338362

core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
DotCMSContentTypeField,
3939
DotCMSContentTypeFieldVariable,
4040
DotCMSTempFile,
41+
DotFileMetadata,
4142
DotGeneratedAIImage
4243
} from '@dotcms/dotcms-models';
4344
import {
@@ -58,10 +59,11 @@ import { BinaryFieldMode, BinaryFieldStatus } from './interfaces';
5859
import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service';
5960
import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service';
6061
import { DotBinaryFieldStore } from './store/binary-field.store';
61-
import { getUiMessage } from './utils/binary-field-utils';
62+
import { getFileMetadata, getUiMessage } from './utils/binary-field-utils';
6263

6364
import { DEFAULT_MONACO_CONFIG } from '../../models/dot-edit-content-field.constant';
6465
import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util';
66+
import { AngularImageEditorLauncher, IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher';
6567

6668
export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = {
6769
...DEFAULT_MONACO_CONFIG,
@@ -97,6 +99,7 @@ type SystemOptionsType = {
9799
DotBinaryFieldStore,
98100
DotLicenseService,
99101
DotBinaryFieldValidatorService,
102+
{ provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher },
100103
{
101104
multi: true,
102105
provide: NG_VALUE_ACCESSOR,
@@ -118,6 +121,7 @@ export class DotEditContentBinaryFieldComponent
118121
readonly #dotAiService = inject(DotAiService);
119122
readonly #dialogService = inject(DialogService);
120123
readonly #destroyRef = inject(DestroyRef);
124+
readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER);
121125

122126
$isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), {
123127
initialValue: false
@@ -389,14 +393,32 @@ export class DotEditContentBinaryFieldComponent
389393
/**
390394
* Open Image Editor
391395
*
396+
* Launches the editor through the {@link IMAGE_EDITOR_LAUNCHER} seam and applies
397+
* the edited image back to the field via the binary field store.
398+
*
392399
* @memberof DotEditContentBinaryFieldComponent
393400
*/
394401
onEditImage() {
395-
this.#dotBinaryFieldEditImageService.openImageEditor({
396-
inode: this.contentlet?.inode,
397-
tempId: this.tempId,
398-
variable: this.variable
399-
});
402+
const inode = this.contentlet?.inode;
403+
const metadata = this.contentlet
404+
? (getFileMetadata(this.contentlet) as Partial<DotFileMetadata>)
405+
: null;
406+
407+
this.#imageEditorLauncher
408+
.open({
409+
inode,
410+
tempId: this.tempId,
411+
variable: this.variable,
412+
fieldName: this.$field()?.name,
413+
byInode: !!inode,
414+
fileName: this.contentlet?.fileName ?? metadata?.name,
415+
mimeType: metadata?.contentType
416+
})
417+
.pipe(
418+
filter((tempFile): tempFile is DotCMSTempFile => !!tempFile),
419+
takeUntilDestroyed(this.#destroyRef)
420+
)
421+
.subscribe((tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile));
400422
}
401423

402424
/**
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect } from '@jest/globals';
2+
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
3+
import { Subject } from 'rxjs';
4+
5+
import { DialogService } from 'primeng/dynamicdialog';
6+
7+
import { DotMessageService } from '@dotcms/data-access';
8+
import { DotCMSTempFile } from '@dotcms/dotcms-models';
9+
import { DotImageEditorComponent, ImageEditorOpenParams } from '@dotcms/image-editor';
10+
11+
import { AngularImageEditorLauncher } from './angular-image-editor.launcher';
12+
13+
describe('AngularImageEditorLauncher', () => {
14+
let spectator: SpectatorService<AngularImageEditorLauncher>;
15+
let onClose: Subject<DotCMSTempFile | undefined>;
16+
17+
const params: ImageEditorOpenParams = {
18+
inode: 'inode-1',
19+
variable: 'binaryField',
20+
fieldName: 'binary'
21+
};
22+
23+
const createService = createServiceFactory({
24+
service: AngularImageEditorLauncher,
25+
providers: [mockProvider(DotMessageService)],
26+
mocks: [DialogService]
27+
});
28+
29+
beforeEach(() => {
30+
onClose = new Subject<DotCMSTempFile | undefined>();
31+
spectator = createService();
32+
spectator.inject(DialogService).open.mockReturnValue({ onClose });
33+
});
34+
35+
it('should report itself as available', () => {
36+
expect(spectator.service.isAvailable()).toBe(true);
37+
});
38+
39+
it('should open the DotImageEditorComponent with a closable, escapable dialog', () => {
40+
spectator.service.open(params).subscribe();
41+
42+
expect(spectator.inject(DialogService).open).toHaveBeenCalledWith(
43+
DotImageEditorComponent,
44+
expect.objectContaining({
45+
data: params,
46+
modal: true,
47+
closable: true,
48+
closeOnEscape: true
49+
})
50+
);
51+
});
52+
53+
it('should resolve the temp file emitted on close', () => {
54+
const tempFile = { id: 'temp-123' } as DotCMSTempFile;
55+
let result: DotCMSTempFile | null | undefined;
56+
57+
spectator.service.open(params).subscribe((value) => (result = value));
58+
onClose.next(tempFile);
59+
60+
expect(result).toEqual(tempFile);
61+
});
62+
63+
it('should resolve null when the dialog closes without a value', () => {
64+
let result: DotCMSTempFile | null | undefined;
65+
66+
spectator.service.open(params).subscribe((value) => (result = value));
67+
onClose.next(undefined);
68+
69+
expect(result).toBeNull();
70+
});
71+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Observable, map, take } from 'rxjs';
2+
3+
import { Injectable, inject } from '@angular/core';
4+
5+
import { DialogService } from 'primeng/dynamicdialog';
6+
7+
import { DotMessageService } from '@dotcms/data-access';
8+
import { DotCMSTempFile } from '@dotcms/dotcms-models';
9+
import {
10+
DotImageEditorComponent,
11+
DotImageEditorLauncher,
12+
ImageEditorOpenParams
13+
} from '@dotcms/image-editor';
14+
15+
/**
16+
* Launches the Angular `@dotcms/image-editor` modal through PrimeNG's `DialogService`.
17+
*/
18+
@Injectable()
19+
export class AngularImageEditorLauncher implements DotImageEditorLauncher {
20+
readonly #dialogService = inject(DialogService);
21+
readonly #dotMessageService = inject(DotMessageService);
22+
23+
isAvailable(): boolean {
24+
return true;
25+
}
26+
27+
/**
28+
* Opens the image editor dialog for the given asset.
29+
*
30+
* @param params - Identifiers and metadata of the asset to edit
31+
* @returns Emits the saved temp file, or `null` if the user cancelled
32+
*/
33+
open(params: ImageEditorOpenParams): Observable<DotCMSTempFile | null> {
34+
const ref = this.#dialogService.open(DotImageEditorComponent, {
35+
header: undefined,
36+
data: params,
37+
width: 'min(92vw, 75rem)',
38+
height: '90%',
39+
modal: true,
40+
draggable: false,
41+
resizable: false,
42+
closable: true,
43+
closeOnEscape: true,
44+
dismissableMask: false,
45+
contentStyle: { height: '100%', overflow: 'hidden', padding: '0' },
46+
styleClass: 'dot-image-editor-dialog'
47+
});
48+
49+
return ref.onClose.pipe(
50+
map((tempFile?: DotCMSTempFile) => tempFile ?? null),
51+
take(1)
52+
);
53+
}
54+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
import { DotImageEditorLauncher } from '@dotcms/image-editor';
4+
5+
export type { DotImageEditorLauncher, ImageEditorOpenParams } from '@dotcms/image-editor';
6+
7+
/**
8+
* DI seam for launching the image editor from the binary field.
9+
*
10+
* Providers swap implementations (Angular dialog, legacy Dojo bridge, or noop)
11+
* without the consuming field knowing which editor surfaces the result.
12+
*/
13+
export const IMAGE_EDITOR_LAUNCHER = new InjectionToken<DotImageEditorLauncher>(
14+
'IMAGE_EDITOR_LAUNCHER'
15+
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export {
2+
IMAGE_EDITOR_LAUNCHER,
3+
DotImageEditorLauncher,
4+
ImageEditorOpenParams
5+
} from './image-editor-launcher.token';
6+
export { AngularImageEditorLauncher } from './angular-image-editor.launcher';
7+
export { LegacyDojoImageEditorLauncher } from './legacy-dojo-image-editor.launcher';
8+
export { NoopImageEditorLauncher } from './noop-image-editor.launcher';

0 commit comments

Comments
 (0)