Skip to content

Commit 03448ba

Browse files
committed
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 Refs #36063
1 parent 2c623b3 commit 03448ba

39 files changed

Lines changed: 1162 additions & 206 deletions

File tree

core-web/libs/edit-content/src/lib/edit-content.shell.component.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
22
import { RouterModule } from '@angular/router';
33

44
import { MessageService } from 'primeng/api';
5+
import { DialogService } from 'primeng/dynamicdialog';
56
import { ToastModule } from 'primeng/toast';
67

8+
import {
9+
AngularImageEditorLauncher,
10+
IMAGE_EDITOR_LAUNCHER
11+
} from './fields/shared/image-editor-launcher';
12+
713
@Component({
814
selector: 'dot-edit-content',
915
imports: [RouterModule, ToastModule],
10-
providers: [MessageService],
16+
providers: [
17+
MessageService,
18+
// Scope the new Angular image editor to the edit-content shell so it only
19+
// activates here and never leaks into the legacy/web-component path. DialogService
20+
// is required by AngularImageEditorLauncher to open the modal.
21+
DialogService,
22+
{ provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher }
23+
],
1124
template: '<p-toast /> <router-outlet />',
1225
styleUrls: ['./edit-content.shell.component.scss'],
1326
changeDetection: ChangeDetectionStrategy.OnPush

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import { getFileMetadata, getUiMessage } from './utils/binary-field-utils';
6363

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

6868
export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = {
6969
...DEFAULT_MONACO_CONFIG,
@@ -99,7 +99,6 @@ type SystemOptionsType = {
9999
DotBinaryFieldStore,
100100
DotLicenseService,
101101
DotBinaryFieldValidatorService,
102-
{ provide: IMAGE_EDITOR_LAUNCHER, useClass: AngularImageEditorLauncher },
103102
{
104103
multi: true,
105104
provide: NG_VALUE_ACCESSOR,
@@ -121,7 +120,10 @@ export class DotEditContentBinaryFieldComponent
121120
readonly #dotAiService = inject(DotAiService);
122121
readonly #dialogService = inject(DialogService);
123122
readonly #destroyRef = inject(DestroyRef);
124-
readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER);
123+
// Optional: the launcher is provided by the Angular edit-content shell, so the new
124+
// image editor only activates there. When absent (e.g. a non-Angular host), or when
125+
// isAvailable() is false, `onEditImage()` safely no-ops.
126+
readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER, { optional: true });
125127

126128
$isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), {
127129
initialValue: false
@@ -399,12 +401,18 @@ export class DotEditContentBinaryFieldComponent
399401
* @memberof DotEditContentBinaryFieldComponent
400402
*/
401403
onEditImage() {
404+
const launcher = this.#imageEditorLauncher;
405+
406+
if (!launcher?.isAvailable()) {
407+
return;
408+
}
409+
402410
const inode = this.contentlet?.inode;
403411
const metadata = this.contentlet
404412
? (getFileMetadata(this.contentlet) as Partial<DotFileMetadata>)
405413
: null;
406414

407-
this.#imageEditorLauncher
415+
launcher
408416
.open({
409417
inode,
410418
tempId: this.tempId,

core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@ describe('AngularImageEditorLauncher', () => {
3636
expect(spectator.service.isAvailable()).toBe(true);
3737
});
3838

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

4242
expect(spectator.inject(DialogService).open).toHaveBeenCalledWith(
4343
DotImageEditorComponent,
4444
expect.objectContaining({
4545
data: params,
4646
modal: true,
47+
// The editor renders its own header; PrimeNG's chrome header is hidden.
48+
showHeader: false,
4749
closable: true,
4850
closeOnEscape: true
4951
})

core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ export class AngularImageEditorLauncher implements DotImageEditorLauncher {
3232
*/
3333
open(params: ImageEditorOpenParams): Observable<DotCMSTempFile | null> {
3434
const ref = this.#dialogService.open(DotImageEditorComponent, {
35-
header: undefined,
35+
// The editor renders its own header (title + close ✕), so hide PrimeNG's
36+
// chrome header to avoid a duplicate. Closing is handled by the internal ✕
37+
// (DotImageEditorHeaderComponent) and the Esc key (closeOnEscape).
38+
showHeader: false,
3639
data: params,
37-
width: 'min(92vw, 75rem)',
38-
height: '90%',
40+
// Large landscape dialog: wider than tall. Generous caps keep it big on wide
41+
// screens while staying within the viewport on smaller ones.
42+
width: 'min(96vw, 90rem)',
43+
height: 'min(96vh, 60rem)',
3944
modal: true,
4045
draggable: false,
4146
resizable: false,

core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<div class="address-bar__group">
22
<i class="pi pi-link address-bar__link-icon" aria-hidden="true"></i>
3-
<input
4-
pInputText
5-
type="text"
6-
readonly
3+
<!-- Read-only, server-generated URL — plain text, not an editable input. -->
4+
<span
75
class="address-bar__field truncate font-mono"
8-
[value]="store.previewUrl()"
9-
data-testid="image-editor-address-field" />
6+
[title]="store.previewUrl()"
7+
data-testid="image-editor-address-field">
8+
{{ store.previewUrl() }}
9+
</span>
1010
<button
1111
pButton
1212
type="button"

core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.scss

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
gap: 1rem;
66
padding: 0.5rem 0.75rem;
77
background-color: rgba(28, 30, 38, 0.92);
8-
color: var(--surface-0, #ffffff);
8+
// Slightly-less-than-white text, matching the design's dark-chrome treatment
9+
// (resting elements at 78% white; full white is reserved for hover/active).
10+
color: rgba(255, 255, 255, 0.78);
911
}
1012

1113
.address-bar__group {
@@ -17,13 +19,14 @@
1719

1820
.address-bar__link-icon {
1921
flex: 0 0 auto;
20-
opacity: 0.7;
2122
}
2223

2324
.address-bar__field {
2425
flex: 1 1 auto;
2526
min-width: 0;
2627
max-width: 28rem;
28+
color: inherit;
29+
font-size: 0.8125rem;
2730
}
2831

2932
.address-bar__zoom-value {

core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,7 @@ describe('DotImageEditorAddressBarComponent', () => {
6363
});
6464

6565
it('should render the preview URL in the address field', () => {
66-
const field = spectator.query<HTMLInputElement>(byTestId('image-editor-address-field'));
67-
68-
expect(field?.value).toBe(PREVIEW_URL);
66+
expect(spectator.query(byTestId('image-editor-address-field'))).toHaveText(PREVIEW_URL);
6967
});
7068

7169
it('should render the zoom level from the zoomLevel input', () => {
@@ -74,10 +72,10 @@ describe('DotImageEditorAddressBarComponent', () => {
7472
expect(spectator.query(byTestId('image-editor-zoom-value'))).toHaveText('125%');
7573
});
7674

77-
it('should copy the preview URL to the clipboard when the copy button is clicked', () => {
75+
it('should copy the full (absolute) preview URL to the clipboard when the copy button is clicked', () => {
7876
spectator.click(byTestId('image-editor-copy-url-btn'));
7977

80-
expect(writeText).toHaveBeenCalledWith(PREVIEW_URL);
78+
expect(writeText).toHaveBeenCalledWith(document.location.origin + PREVIEW_URL);
8179
});
8280

8381
it('should dispatch undoRequested when undo is clicked', () => {

core-web/libs/image-editor/src/lib/components/dot-image-editor-address-bar/dot-image-editor-address-bar.component.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { injectDispatch } from '@ngrx/signals/events';
22

3+
import { DOCUMENT } from '@angular/common';
34
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
45

56
import { MessageService } from 'primeng/api';
67
import { ButtonModule } from 'primeng/button';
7-
import { InputTextModule } from 'primeng/inputtext';
88
import { TooltipModule } from 'primeng/tooltip';
99

1010
import { DotMessageService } from '@dotcms/data-access';
@@ -24,14 +24,15 @@ import { ImageEditorStore } from '../../store/image-editor.store';
2424
selector: 'dot-image-editor-address-bar',
2525
templateUrl: './dot-image-editor-address-bar.component.html',
2626
styleUrl: './dot-image-editor-address-bar.component.scss',
27-
imports: [ButtonModule, InputTextModule, TooltipModule, DotMessagePipe],
27+
imports: [ButtonModule, TooltipModule, DotMessagePipe],
2828
changeDetection: ChangeDetectionStrategy.OnPush
2929
})
3030
export class DotImageEditorAddressBarComponent {
3131
protected readonly store = inject(ImageEditorStore);
3232
readonly #dispatch = injectDispatch(imageEditorHistoryEvents);
3333
readonly #messageService = inject(MessageService);
3434
readonly #dotMessageService = inject(DotMessageService);
35+
readonly #document = inject(DOCUMENT);
3536

3637
/** Current canvas zoom percentage to display; owned and updated by the canvas. */
3738
zoomLevel = input<number>(100);
@@ -46,7 +47,7 @@ export class DotImageEditorAddressBarComponent {
4647
/** Copies the current preview URL to the clipboard, surfacing a toast. */
4748
protected async copyUrl(): Promise<void> {
4849
try {
49-
await navigator.clipboard.writeText(this.store.previewUrl());
50+
await navigator.clipboard.writeText(this.#absoluteUrl());
5051
this.#messageService.add({
5152
severity: 'success',
5253
detail: this.#dotMessageService.get(
@@ -62,6 +63,17 @@ export class DotImageEditorAddressBarComponent {
6263
}
6364
}
6465

66+
/**
67+
* Resolves the store's root-relative preview URL against the current origin so the
68+
* copied value is a complete, shareable URL rather than just the path.
69+
*/
70+
#absoluteUrl(): string {
71+
const url = this.store.previewUrl();
72+
const origin = this.#document.location?.origin ?? '';
73+
74+
return /^https?:\/\//.test(url) ? url : `${origin}${url}`;
75+
}
76+
6577
/** Steps back one entry in the edit history. */
6678
protected undo(): void {
6779
this.#dispatch.undoRequested();

core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<img
2323
#displayImg
2424
class="canvas__img canvas__img--displayed"
25+
[class.canvas__img--loading]="store.previewStatus() === 'loading'"
2526
[src]="displayedUrl()"
2627
[hidden]="!displayedUrl()"
2728
alt=""
@@ -66,4 +67,34 @@
6667
</div>
6768
}
6869
</div>
70+
71+
<!-- Bottom band mirroring the top address bar; hosts the active tool's actions. -->
72+
<div class="canvas__footer" data-testid="image-editor-canvas-footer">
73+
@switch (store.activeTool()) {
74+
@case ('crop') {
75+
<p-button
76+
[text]="true"
77+
severity="secondary"
78+
[label]="'edit.content.image-editor.crop.cancel' | dm"
79+
(onClick)="cancelCrop()"
80+
data-testid="image-editor-crop-cancel-btn" />
81+
<p-button
82+
[label]="'edit.content.image-editor.crop.apply' | dm"
83+
(onClick)="applyCrop()"
84+
data-testid="image-editor-crop-apply-btn" />
85+
}
86+
@case ('focal') {
87+
<p-button
88+
[text]="true"
89+
severity="secondary"
90+
[label]="'edit.content.image-editor.focal.cancel' | dm"
91+
(onClick)="cancelFocalPoint()"
92+
data-testid="image-editor-focal-cancel-btn" />
93+
<p-button
94+
[label]="'edit.content.image-editor.focal.set' | dm"
95+
(onClick)="setFocalPoint()"
96+
data-testid="image-editor-focal-set-btn" />
97+
}
98+
}
99+
</div>
69100
</div>

0 commit comments

Comments
 (0)