Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
37c4f48
feat(image-editor): wire Save temp-file flow and focal-point persistence
oidacra Jun 29, 2026
96414df
feat(edit-content): seed focal on open and tolerate metadata-less tem…
oidacra Jun 29, 2026
dedc5f4
fix(image): populate temp metadata on image-tool save and expose foca…
oidacra Jun 29, 2026
f90d991
fix(edit-content): seed image editor focal point from the field metad…
oidacra Jun 29, 2026
b143fd5
fix(image-editor): red focal marker and keep it aligned on dialog resize
oidacra Jun 29, 2026
e022789
chore(image-editor): address local code-review findings
oidacra Jun 29, 2026
8ac580b
fix(image-editor): persist focal point on image-tool save temp
oidacra Jun 29, 2026
35d8240
docs(edit-content): correct stale comment on binary-field save-temp m…
oidacra Jun 29, 2026
f4a24f6
fix(image-editor): URL-encode binaryFieldId in save URL
oidacra Jun 29, 2026
af17608
feat(image-editor): GA the new image editor in the new Edit Content (…
oidacra Jun 29, 2026
2a2652c
chore(dotcms-models): drop FEATURE_FLAG_NEW_IMAGE_EDITOR enum + fix p…
oidacra Jun 29, 2026
cf9676a
Merge remote-tracking branch 'origin/main' into issue-36067-wire-imag…
oidacra Jun 30, 2026
a6c002f
feat(image-editor): port focal-point seeding to the unified file-field
oidacra Jun 30, 2026
3b15607
fix(image-editor): use exhaustMap for the save effect
oidacra Jun 30, 2026
5025a83
fix(sdk-ai): default generate-spec to corpsites-headless instance
oidacra Jun 30, 2026
00659db
fix(image-editor): seed focal point on in-session reopen after save
oidacra Jun 30, 2026
aa2c709
Merge remote-tracking branch 'origin/main' into issue-36067-wire-imag…
oidacra Jun 30, 2026
7f85a41
test(e2e): assert the new Angular image editor opens in the new Edit …
oidacra Jun 30, 2026
8d619e9
test(edit-content): fix month-boundary day math in calendar TIME 'now…
oidacra Jun 30, 2026
acce7c3
refactor(image-editor): enhance coalescing behavior for history entries
adrianjm-dotCMS Jul 1, 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
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ test.describe('Binary field image editor — new editor', () => {
}
});

// The unified Binary field shows "Edit image" for image files. The new
// editor opens the legacy image editor JSP inside a PrimeNG dialog iframe.
test('import image and Edit opens legacy image editor dialog @critical', async ({ page }) => {
// The unified Binary field shows "Edit image" for image files. In the new
// Edit Content the new Angular image editor opens (the FEATURE_FLAG_NEW_IMAGE_EDITOR
// gate was removed, so it is always used here; the legacy Dojo editor only runs in
// the legacy Edit Content — see the describe block below).
test('import image and Edit opens the new Angular image editor @critical', async ({ page }) => {
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,17 @@ export class BinaryField extends FileField {
}

/**
* New editor: the legacy image editor JSP is embedded in a PrimeNG dialog
* iframe (`dot-legacy-image-editor-dialog`), not the Dojo `#dotImageDialog`.
* New editor: clicking "Edit image" opens the Angular image editor
* ({@link DotImageEditorComponent}, `data-testid="image-editor-root"`) inside a
* PrimeNG dialog. The flag that used to gate it (FEATURE_FLAG_NEW_IMAGE_EDITOR) was
* removed, so the new editor is always used in the new Edit Content — there is no
* legacy Dojo iframe here.
*/
async expectImageEditorOpen() {
const dialog = this.page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 15000 });

const iframe = dialog.getByTestId('legacy-image-editor-iframe');
await expect(iframe).toBeVisible({ timeout: 30000 });

const editorFrame = this.page.frameLocator('[data-testid="legacy-image-editor-iframe"]');
await expect(editorFrame.locator('#dotImageDialog, #imageToolIframe').first()).toBeVisible({
timeout: 30000
});
await expect(dialog.getByTestId('image-editor-root')).toBeVisible({ timeout: 30000 });
}

async openImageEditorInNewEditor() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { DotCMSContentType, DotPageContainer, DotPageContent } from '@dotcms/dotcms-models';
import { DotCMSContentType } from './dot-content-types.model';
import { DotPageContainer } from './dot-page-container.model';
import { DotPageContent } from './dot-page-content.model';
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.

export interface DotContentletEvent<T = Record<string, unknown>> {
name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ export interface DotFileMetadata {
height?: number;
width?: number;
editableAsText?: boolean;
/**
* Focal point as an `"x,y"` string (normalized 0..1), exposed by the backend on image
* binary metadata and used to re-seed the image editor's focal marker on reopen.
*/
focalPoint?: string;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface DotHttpRequestOptions {
method: string;
headers: { [key: string]: string };
body: any;
body: XMLHttpRequestBodyInit | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface DotCMSSystemAction {

export interface DotCMSWorkflowInput {
id: string;
body: any;
body: Record<string, unknown>;
}

export interface DotCMSContentletWorkflowActions {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { DotPageContainer } from '@dotcms/dotcms-models';

import { DotPageContainer } from './dot-page-container.model';
import { PageModelChangeEventType } from './page-model-change-event.type';
Comment thread
oidacra marked this conversation as resolved.

export interface PageModelChangeEvent {
Expand Down
3 changes: 1 addition & 2 deletions core-web/libs/dotcms-models/src/lib/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ 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_NEW_IMAGE_EDITOR = 'FEATURE_FLAG_NEW_IMAGE_EDITOR'
FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2'
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
adrianjm-dotCMS marked this conversation as resolved.
}
Comment thread
oidacra marked this conversation as resolved.

export const enum DotConfigurationVariables {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,18 @@ describe('DotEditContentCalendarFieldUtil - TDD Approach', () => {
const todayInServerTz = getCurrentServerTime(SERVER_TIMEZONE_MOCKS.GULF);
expect(result?.displayValue.getDate()).toBe(todayInServerTz.getDate());

// formValue (UTC) might be different day due to timezone conversion, allow ±2 days
const expectedFormDate = todayInServerTz.getDate();
const actualFormDate = result?.formValue.getDate();
expect(actualFormDate).toBeDefined();
expect(Math.abs(actualFormDate - expectedFormDate)).toBeLessThanOrEqual(2);
// formValue (UTC) might fall on a different calendar day due to timezone
// conversion; allow ±2 days. Compare the whole-day distance, not getDate()
// day-of-month subtraction — the latter wraps at month boundaries (e.g.
// Jun 30 vs Jul 1 would read as |30 - 1| = 29 instead of a 1-day difference).
const formValue = result?.formValue;
expect(formValue).toBeDefined();
const dayMs = 24 * 60 * 60 * 1000;
const dayAtMidnight = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDistance =
Math.abs(dayAtMidnight(formValue!) - dayAtMidnight(todayInServerTz)) / dayMs;
expect(dayDistance).toBeLessThanOrEqual(2);
});

it('should handle "now" defaultValue correctly for DATE fields', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from './../../services/image-editor';
import { DotFileFieldUploadService } from './../../services/upload-file/upload-file.service';
import { FileFieldStore } from './../../store/file-field.store';
import { parseFocalPoint } from './../../utils/focal-point.util';
import { getUiMessage } from './../../utils/messages';
import { DotFileFieldPreviewComponent } from './../dot-file-field-preview/dot-file-field-preview.component';
import { DotFileFieldUiMessageComponent } from './../dot-file-field-ui-message/dot-file-field-ui-message.component';
Expand Down Expand Up @@ -104,10 +105,10 @@ export class DotFileFieldComponent
readonly #legacyDojoImageEditorLauncher = inject(LegacyDojoImageEditorLauncher);
/**
* New Angular image editor launcher. Provided by the Angular edit-content shell
* ({@link EditContentShellComponent}) and gated behind the
* `FEATURE_FLAG_NEW_IMAGE_EDITOR` flag via its `isAvailable()`. Injected as
* `{ optional: true }`: when absent (e.g. the legacy web-component host) or its
* flag is off, `onEditImage()` falls back to the legacy launchers.
* ({@link EditContentShellComponent}); its `isAvailable()` is always true now that
* the new editor is GA in the new Edit Content. Injected as `{ optional: true }`:
* when absent (e.g. the legacy web-component host), `onEditImage()` falls back to
* the legacy launchers.
*/
readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER, { optional: true });
/**
Expand Down Expand Up @@ -408,13 +409,16 @@ export class DotFileFieldComponent
: variable;

// Prefer the new Angular image editor when its launcher is provided (Angular
// edit-content shell) and its feature flag is on. Otherwise fall back to the
// legacy editor: Dojo DOM events for the web-component bridge, dialog iframe
// elsewhere.
// edit-content shell). Otherwise fall back to the legacy editor: Dojo DOM events
// for the web-component bridge, dialog iframe elsewhere.
const newLauncher = this.#imageEditorLauncher;

if (newLauncher?.isAvailable()) {
const metadata = this.#currentMetadata();
// Seed the editor with the asset's stored focal point (exposed on the binary
// metadata as an "x,y" string by DefaultTransformStrategy) so reopening restores
// the marker instead of resetting it to centre.
const focalPoint = parseFocalPoint(metadata?.focalPoint);
Comment thread
oidacra marked this conversation as resolved.

this.#applyEditedImage(
newLauncher.open({
Expand All @@ -424,7 +428,8 @@ export class DotFileFieldComponent
fieldName: editorVariable,
byInode: !!inode,
fileName: this.$currentFileName() || undefined,
mimeType: metadata?.contentType
mimeType: metadata?.contentType,
focalPoint
})
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { parseFocalPoint } from './focal-point.util';

describe('parseFocalPoint', () => {
it('parses an "x,y" focal point string into a point', () => {
expect(parseFocalPoint('0.88,0.31')).toEqual({ x: 0.88, y: 0.31 });
});

it('returns undefined for an unset/empty value', () => {
expect(parseFocalPoint(undefined)).toBeUndefined();
expect(parseFocalPoint(null)).toBeUndefined();
expect(parseFocalPoint('')).toBeUndefined();
});

it('treats the (0,0) "no focal point" sentinel as undefined', () => {
// The backend uses (0,0) to mean "no focal point" -> editor opens centred.
expect(parseFocalPoint('0,0')).toBeUndefined();
});

it('returns undefined for a malformed or single-value string', () => {
// "0.0" has no comma -> one token -> the y axis parses to NaN.
expect(parseFocalPoint('0.0')).toBeUndefined();
expect(parseFocalPoint('not-a-point')).toBeUndefined();
expect(parseFocalPoint('abc,xyz')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NormalizedPoint } from '@dotcms/image-editor';

/**
* Parses a focal point stored as an `"x,y"` string (the backend exposes it on the
* binary field metadata as a custom `focalPoint` attribute) into a normalized 0..1
* point for seeding the image editor.
*
* Returns `undefined` for an unset/empty value, the `(0,0)` "no focal point" sentinel,
* or a malformed/single-value string, so the editor opens centred instead of seeding a
* bogus marker.
*
* @param value - The raw `focalPoint` metadata string (e.g. `"0.88,0.31"`).
* @returns The parsed point, or `undefined` when there is no usable focal point.
*/
export function parseFocalPoint(value: string | null | undefined): NormalizedPoint | undefined {
if (!value) {
return undefined;
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
}

const [x, y] = value.split(',').map(Number);

if (!Number.isFinite(x) || !Number.isFinite(y)) {
return undefined;
}

// (0,0) is the backend's "no focal point" sentinel -> open centred.
if (x === 0 && y === 0) {
return undefined;
}

return { x, y };
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import { expect } from '@jest/globals';
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
import { BehaviorSubject, Subject } from 'rxjs';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { Subject } from 'rxjs';

import { DialogService } from 'primeng/dynamicdialog';

import { DotPropertiesService } from '@dotcms/data-access';
import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models';
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<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',
Expand All @@ -26,28 +21,17 @@ describe('AngularImageEditorLauncher', () => {

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

Comment thread
adrianjm-dotCMS marked this conversation as resolved.
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', () => {
it('should always be available (the new editor is the editor for the new Edit Content)', () => {
expect(spectator.service.isAvailable()).toBe(true);
Comment thread
oidacra marked this conversation as resolved.
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 dialog that owns Esc', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Observable, map, take } from 'rxjs';

import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';

import { DialogService } from 'primeng/dynamicdialog';

import { DotPropertiesService } from '@dotcms/data-access';
import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models';
import { DotCMSTempFile } from '@dotcms/dotcms-models';
import {
DotImageEditorComponent,
DotImageEditorLauncher,
Expand All @@ -16,24 +14,17 @@ 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, 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.
* Provided by the new Edit Content shell, so the new image editor is used whenever this
* launcher is injected. The binary field falls back to the legacy Dojo editor only when
* no launcher is present (i.e. the field renders outside the new Edit Content).
*/
@Injectable()
export class AngularImageEditorLauncher implements DotImageEditorLauncher {
readonly #dialogService = inject(DialogService);
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
Comment thread
adrianjm-dotCMS marked this conversation as resolved.
readonly #propertiesService = inject(DotPropertiesService);

/** Resolved value of the new-image-editor feature flag (off until the server replies). */
readonly #enabled = toSignal(
this.#propertiesService.getFeatureFlag(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR),
{ initialValue: false }
);

/** Always available: the new image editor is the editor for the new Edit Content. */
isAvailable(): boolean {
return this.#enabled();
return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export class DotImageEditorCanvasComponent {
};
}

/** Lazily attaches a single ResizeObserver to the displayed image element. */
/** Lazily attaches a single ResizeObserver to the displayed image and its stage. */
#observeDisplayImg(): void {
const img = this.$displayImg()?.nativeElement;

Expand All @@ -525,6 +525,19 @@ export class DotImageEditorCanvasComponent {

Comment thread
oidacra marked this conversation as resolved.
this.#resizeObserver = new ResizeObserver(() => this.#measureImageRect());
this.#resizeObserver.observe(img);

// Also observe the stage. Toggling full-screen resizes the dialog, which
// re-centres the image within the stage WITHOUT changing the image's own size
// — a ResizeObserver on the image alone never reports that, leaving
// imageRect.x/y (img.offsetLeft/Top) stale and the focal/crop overlays
// mispositioned (notably on the full-screen -> windowed transition). The stage
// (the image's offsetParent) always resizes with the dialog, so observing it
// re-measures the image's offset box and keeps the overlays aligned.
const stage = this.$stage()?.nativeElement;

if (stage) {
this.#resizeObserver.observe(stage);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
background-color: var(--p-primary-color, #426bf0);
// Red target dot: high-contrast against arbitrary image content and the
// conventional colour for a focal-point marker (distinct from the UI primary).
background-color: var(--p-red-500, #ef4444);
border-radius: 9999px;
}

&:focus-visible {
border-color: var(--p-primary-color, #426bf0);
border-color: var(--p-red-500, #ef4444);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@
<i class="material-symbols-outlined text-[1.25rem]">download</i>
</ng-template>
</p-button>

<p-button
[label]="'edit.content.image-editor.footer.save' | dm"
[attr.aria-label]="'edit.content.image-editor.footer.save.aria' | dm"
Comment thread
oidacra marked this conversation as resolved.
Comment thread
oidacra marked this conversation as resolved.
[loading]="store.isBusy() || store.saveStatus() === 'saving'"
(onClick)="onSave()"
data-testid="image-editor-save-btn" />
</div>
Loading
Loading