Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
356b5e9
feat(edit-content): add Angular image editor with events-based signal…
oidacra Jun 18, 2026
e1f629a
feat(image-editor): UI/UX polish for the Angular image editor
oidacra Jun 18, 2026
ab9ce62
fix(image-editor): editor UX polish, preview robustness, scale/histor…
oidacra Jun 19, 2026
28e5ed9
fix(image-editor): render previews from verified blobs to stop trunca…
oidacra Jun 22, 2026
d2c12b8
fix(image-editor): zoom pan/fit, undo shortcut, crop-to-view + code-r…
oidacra Jun 22, 2026
eba92bd
docs(image-editor): clarify coalesceHistory redo-tail; assert error i…
oidacra Jun 22, 2026
31c5410
refactor(image-editor): consolidate constants and models into single …
oidacra Jun 22, 2026
2724912
refactor(image-editor): group the store into vertical feature units b…
oidacra Jun 22, 2026
5f438ee
test(image-editor): per-feature store specs + store-utils unit tests …
oidacra Jun 22, 2026
31f2c90
test(image-editor): add dimensions.util unit spec (clamp, resize, out…
oidacra Jun 22, 2026
2ed9d81
style(image-editor): fix import order in dimensions.util spec
oidacra Jun 22, 2026
c4d75ee
refactor(image-editor): defer real save to a separate issue (preview …
oidacra Jun 22, 2026
7294651
feat(image-editor): full-screen toggle with animated resize
oidacra Jun 22, 2026
c34cd70
feat(edit-content): gate Angular image editor behind FEATURE_FLAG_NEW…
oidacra Jun 22, 2026
074acb8
fix(image-editor): crop the displayed image, not the pre-flip original
oidacra Jun 22, 2026
fe603b0
fix(image-editor): differentiate discard-dialog buttons (Keep editing…
oidacra Jun 23, 2026
13be039
fix(image-editor): place focal point under the cursor at any zoom level
oidacra Jun 23, 2026
a49fa48
style(image-editor): light canvas — white viewer, off-white header/fo…
oidacra Jun 24, 2026
71dc266
refactor(image-editor): move full-screen toggle to the dialog header …
oidacra Jun 24, 2026
e308cf0
feat(image-editor): crop aspect presets + size inputs, remove focal p…
oidacra Jun 24, 2026
171bd28
feat(image-editor): Material Symbols icons + UVE-style address bar wi…
oidacra Jun 24, 2026
2611d71
fix(image-editor): crop box reaches image edges; long URL no longer o…
oidacra Jun 24, 2026
d27f70d
fix(image-editor): align header grays to design tokens; clamp pan whe…
oidacra Jun 24, 2026
5acbe47
feat(image-editor): add AVIF output format; move compression to a dro…
oidacra Jun 25, 2026
fe49dea
fix(edit-content): use getFeatureFlag after DotPropertiesService API …
oidacra Jun 25, 2026
403a665
feat(image-editor): crop aspect dropdown + orientation, natural-relat…
oidacra Jun 25, 2026
efca8b2
feat(image-editor): preview-in-new-tab button + focal point tool (edi…
oidacra Jun 25, 2026
1978d22
fix(image-editor): clean up comment in image-editor.store.ts
oidacra Jun 25, 2026
b3e24d1
fix(image-editor): address PR review findings
oidacra Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core-web/libs/dotcms-models/src/lib/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export const enum FeaturedFlags {
FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION = 'FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION',
FEATURE_FLAG_NEW_BLOCK_EDITOR = 'FEATURE_FLAG_NEW_BLOCK_EDITOR',
FEATURE_FLAG_REPORT_ISSUE_ENABLED = 'FEATURE_FLAG_REPORT_ISSUE_ENABLED',
FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2'
FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2',
FEATURE_FLAG_NEW_IMAGE_EDITOR = 'FEATURE_FLAG_NEW_IMAGE_EDITOR'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a new member to a const enum. TYPESCRIPT_STANDARDS.md prohibits enums in favor of as const objects. The enum is pre-existing, so this isn't a new violation on its own, but it extends a prohibited pattern — worth tracking the migration to an as const map.

}

export const enum DotConfigurationVariables {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p-toast /> <router-outlet />',
styleUrls: ['./edit-content.shell.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -81,21 +80,24 @@ const MOCK_DOTCMS_FILE = {
binaryFieldMetaData: fileMetaData
};

const imageEditorLauncherMock = {
isAvailable: jest.fn().mockReturnValue(true),
open: jest.fn().mockReturnValue(of(null))
};

describe('DotEditContentBinaryFieldComponent', () => {
let spectator: Spectator<DotEditContentBinaryFieldComponent>;
let store: DotBinaryFieldStore;

let dotBinaryFieldEditImageService: SpyObject<DotBinaryFieldEditImageService>;
let dotAiService: DotAiService;
let ngZone: NgZone;

const createComponent = createComponentFactory({
component: DotEditContentBinaryFieldComponent,
componentProviders: [
DotBinaryFieldStore,
DotBinaryFieldEditImageService,
DotAiService,
DialogService
DialogService,
{ provide: IMAGE_EDITOR_LAUNCHER, useValue: imageEditorLauncherMock }
],
componentViewProviders: [
{ provide: ControlContainer, useValue: createFormGroupDirectiveMock() }
Expand Down Expand Up @@ -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: {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -301,37 +304,77 @@ describe('DotEditContentBinaryFieldComponent', () => {
});

describe('Edit Image', () => {
it('should open edit image dialog when click on edit image button', () => {
it('should launch the image editor through the launcher seam on edit image', () => {
spectator.detectChanges();
const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor');
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);
expect(spy).toHaveBeenCalled();

expect(imageEditorLauncherMock.open).toHaveBeenCalledWith(
expect.objectContaining({
variable: BINARY_FIELD_MOCK.variable,
fieldName: BINARY_FIELD_MOCK.name,
byInode: false
})
);
});

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

spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);

expect(imageEditorLauncherMock.open).toHaveBeenCalledWith(
expect.objectContaining({
inode: 'inode-123',
variable: BINARY_FIELD_MOCK.variable,
fieldName: BINARY_FIELD_MOCK.name,
byInode: true,
fileName: 'photo.png'
})
);
});

it('should apply the edited temp file through the store', () => {
imageEditorLauncherMock.open.mockReturnValue(of(TEMP_FILE_MOCK));
const spyTempFile = jest.spyOn(store, 'setFileFromTemp');
spectator.detectChanges();

spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);

expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK);
});

it('should not apply a temp file when the editor is cancelled', () => {
imageEditorLauncherMock.open.mockReturnValue(of(null));
const spyTempFile = jest.spyOn(store, 'setFileFromTemp');
spectator.detectChanges();

spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);

expect(spyTempFile).not.toHaveBeenCalled();
});

it('should fall back to the legacy Dojo editor when the new editor is disabled', () => {
imageEditorLauncherMock.isAvailable.mockReturnValue(false);
const spyLegacy = jest
.spyOn(DotBinaryFieldEditImageService.prototype, 'openImageEditor')
.mockImplementation();
spectator.setInput('contentlet', { ...MOCK_DOTCMS_FILE, inode: 'inode-123' });
spectator.detectChanges();

tick(1000);
spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null);

expect(spy).toHaveBeenCalled();
expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK);
expect(spyLegacy).toHaveBeenCalledWith(
expect.objectContaining({
inode: 'inode-123',
variable: BINARY_FIELD_MOCK.variable
})
);
expect(imageEditorLauncherMock.open).not.toHaveBeenCalled();
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
DotCMSContentTypeField,
DotCMSContentTypeFieldVariable,
DotCMSTempFile,
DotFileMetadata,
DotGeneratedAIImage
} from '@dotcms/dotcms-models';
import {
Expand All @@ -58,10 +59,11 @@ import { BinaryFieldMode, BinaryFieldStatus } from './interfaces';
import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service';
import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service';
import { DotBinaryFieldStore } from './store/binary-field.store';
import { getUiMessage } from './utils/binary-field-utils';
import { getFileMetadata, getUiMessage } from './utils/binary-field-utils';

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

export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = {
...DEFAULT_MONACO_CONFIG,
Expand Down Expand Up @@ -118,6 +120,10 @@ export class DotEditContentBinaryFieldComponent
readonly #dotAiService = inject(DotAiService);
readonly #dialogService = inject(DialogService);
readonly #destroyRef = inject(DestroyRef);
// Optional: the launcher is provided by the Angular edit-content shell, so the new
// image editor only activates there. When absent (e.g. a non-Angular host), or when
// isAvailable() is false, `onEditImage()` safely no-ops.
readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER, { optional: true });

$isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), {
initialValue: false
Expand Down Expand Up @@ -389,14 +395,54 @@ export class DotEditContentBinaryFieldComponent
/**
* Open Image Editor
*
* Launches the editor through the {@link IMAGE_EDITOR_LAUNCHER} seam and applies
* the edited image back to the field via the binary field store.
*
* @memberof DotEditContentBinaryFieldComponent
*/
onEditImage() {
this.#dotBinaryFieldEditImageService.openImageEditor({
inode: this.contentlet?.inode,
tempId: this.tempId,
variable: this.variable
});
const launcher = this.#imageEditorLauncher;

// The new Angular editor is gated by FEATURE_FLAG_NEW_IMAGE_EDITOR (via the
// launcher's `isAvailable()`). When it's off — or no launcher is provided in
// this context — fall back to the legacy Dojo image editor.
if (!launcher?.isAvailable()) {
this.#dotBinaryFieldEditImageService.openImageEditor({
inode: this.contentlet?.inode,
tempId: this.tempId,
variable: this.variable
});

return;
}

const inode = this.contentlet?.inode;
const metadata = this.contentlet
? (getFileMetadata(this.contentlet) as Partial<DotFileMetadata>)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as Partial<DotFileMetadata> is an unvalidated assertion. The same pattern appears elsewhere in the PR (as HTMLElement | undefined on the dialog container in dot-image-editor.component.ts, and as unknown as Record<string, unknown> / as SlicePatch in image-editor.store-utils.ts). Prefer satisfies, an instanceof guard, or making the helper generic over keyof EditableSlices so the cast isn't needed.

: null;

launcher
.open({
inode,
tempId: this.tempId,
variable: this.variable,
fieldName: this.$field()?.name,
byInode: !!inode,
fileName: this.contentlet?.fileName ?? metadata?.name,
mimeType: metadata?.contentType
})
.pipe(
filter((tempFile): tempFile is DotCMSTempFile => !!tempFile),
takeUntilDestroyed(this.#destroyRef)
)
.subscribe({
next: (tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile),
// The dialog stream isn't expected to error, but guard it so an
// unexpected failure surfaces in the console instead of being swallowed
// by the global handler with the edited image silently never applied.
error: (error) =>
console.error('Image editor failed to apply the edited image', error)
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect } from '@jest/globals';
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
import { BehaviorSubject, Subject } from 'rxjs';

import { DialogService } from 'primeng/dynamicdialog';

import { DotPropertiesService } from '@dotcms/data-access';
import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models';
import { DotImageEditorComponent, ImageEditorOpenParams } from '@dotcms/image-editor';

import { AngularImageEditorLauncher } from './angular-image-editor.launcher';

describe('AngularImageEditorLauncher', () => {
let spectator: SpectatorService<AngularImageEditorLauncher>;
let onClose: Subject<DotCMSTempFile | undefined>;
// 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<boolean>(true);
const getFeatureFlag = jest.fn(() => featureFlag$);

const params: ImageEditorOpenParams = {
inode: 'inode-1',
variable: 'binaryField',
fieldName: 'binary'
};

const createService = createServiceFactory({
service: AngularImageEditorLauncher,
providers: [mockProvider(DotPropertiesService, { getFeatureFlag })],
mocks: [DialogService]
});

beforeEach(() => {
// Default the flag ON so the open() tests below run the Angular path; the
// gating itself is covered by the dedicated tests.
featureFlag$.next(true);
onClose = new Subject<DotCMSTempFile | undefined>();
spectator = createService();
spectator.inject(DialogService).open.mockReturnValue({ onClose });
});

it('should be available when FEATURE_FLAG_NEW_IMAGE_EDITOR is on', () => {
expect(spectator.service.isAvailable()).toBe(true);
expect(getFeatureFlag).toHaveBeenCalledWith(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR);
});

it('should NOT be available when the feature flag is off', () => {
featureFlag$.next(false);

expect(spectator.service.isAvailable()).toBe(false);
});

it('should open the DotImageEditorComponent with a headerless, closable, escapable dialog', () => {
spectator.service.open(params).subscribe();

expect(spectator.inject(DialogService).open).toHaveBeenCalledWith(
DotImageEditorComponent,
expect.objectContaining({
data: params,
modal: true,
// The editor renders its own header; PrimeNG's chrome header is hidden.
showHeader: false,
closable: true,
closeOnEscape: true
})
);
});

it('should resolve the temp file emitted on close', () => {
const tempFile = { id: 'temp-123' } as DotCMSTempFile;
let result: DotCMSTempFile | null | undefined;

spectator.service.open(params).subscribe((value) => (result = value));
onClose.next(tempFile);

expect(result).toEqual(tempFile);
});

it('should resolve null when the dialog closes without a value', () => {
let result: DotCMSTempFile | null | undefined;

spectator.service.open(params).subscribe((value) => (result = value));
onClose.next(undefined);

expect(result).toBeNull();
});
});
Loading