Skip to content

feat(image-editor): add Angular image editor#36236

Open
oidacra wants to merge 17 commits into
mainfrom
issue-36063-image-editor-build-dotimageeditorcomponent-modal-s
Open

feat(image-editor): add Angular image editor#36236
oidacra wants to merge 17 commits into
mainfrom
issue-36063-image-editor-build-dotimageeditorcomponent-modal-s

Conversation

@oidacra

@oidacra oidacra commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the legacy Dojo ImageEditor.js with a new standalone Angular library
@dotcms/image-editor, wired into Edit Content's binary field through an
IMAGE_EDITOR_LAUNCHER seam (Angular-only path).

The editor is a viewer of a dotCMS endpoint: the <img> src is a computed
/contentAsset/image/{id}/{field}/filter/... URL, so every control change rebuilds
the URL and the server renders the adjusted image — no client-side pixel work.

State is an events-based NgRx Signal Store (@ngrx/signals/events), composed of
one vertical signalStoreFeature per area of functionality (adjust, transform,
crop, focal point, file info, tools, history, asset, preview, download) — each
bundling its own reducers, selectors and effects.

Scope note

This PR delivers the editor shell, the server-side preview pipeline, the panels and
the crop/focal tools, plus Download. Saving the edited image back to the field
is intentionally deferred to #36067
— the editor currently previews and downloads.

What's included

  • Library @dotcms/image-editor: root dialog, header, canvas (crossfade, zoom +
    pan, crop/focal overlays), side panels (Adjust / Transform / File info / History),
    footer (Cancel + Download).
  • Server-side preview via the filter-URL builder (single source of truth) and Download.
  • Command history: removable applied-edits list + undo/redo, keyboard shortcuts
    (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y), ignored while a text field is focused.
  • Launcher seam in libs/edit-content (token + Angular launcher) and binary-field wiring.
  • Canvas interactions: zoom with drag-to-pan, fit-to-screen, crop-to-current-view,
    focal-centered aspect crop.

Preview robustness

  • Preview fetched as a complete, verified blob (local object URL), so a
    partially-generated server response can't paint a truncated frame; incomplete
    responses are detected and retried silently.
  • decode() + natural-dimension gate before promoting a frame.

Code organization

  • Constants in one image-editor.constants.ts; types/models in models/image-editor.models.ts.
  • Store split into store/features/with-*.feature.ts + store-utils.ts; store.ts is a thin composition.

Testing

  • nx lint image-editor / nx lint edit-content — clean
  • nx test image-editor258 passing (per-feature unit specs + store-utils + dimensions.util + integration; store-feature branch coverage ~100%)
  • nx test edit-content1961 passing (28 skipped)
  • Verified live (local dotCMS, demo content): preview, crop, focal, zoom/pan, fit, download, undo/redo (incl. Cmd+Z), panel persistence.

Issues

…Store (#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
@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @oidacra's task in 5m 50s —— View job


Rollback Safety Analysis

  • Gather context and understand request
  • Read rollback-unsafe categories reference
  • Get full PR diff
  • Analyze diff against all unsafe categories
  • Apply appropriate label and post results

Summary

Label applied: AI: Not Safe To Rollback

One rollback-unsafe category was identified:

🟡 MEDIUM — M-3: REST API Contract Change

The ThemeSourceView returned by the page render endpoint (/api/v1/page/render) has a breaking JSON shape change:

  • Field files (a List<FileRefView> with path, identifier, extension) is renamed to vtls (a List<VtlFileRefView> with only path and identifier)
  • The extension field is removed entirely
  • Only VTL files are now included (previously all theme files were returned)

Any consumer deployed against the N contract reading theme.vtls will break after rollback to N-1, which still returns theme.files.

Affected files:

  • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/ThemeSourceView.java
  • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/FileRefView.java (deleted)
  • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/VtlFileRefView.java (new)
  • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java

The image-editor library itself (the primary change in this PR — new Angular components, NgRx signal store, feature flag) is pure frontend and is fully safe to roll back.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Bedrock Review — deepseek.v3.2

[🟡 Medium] core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts:125#imageEditorLauncher is injected with { optional: true } but onEditImage() doesn't handle the case where it's null (when isAvailable() returns false). The method will call launcher.open() on null, causing a runtime error. This matters because the feature flag could be off, or the launcher might not be provided in non-Angular contexts.

[🟡 Medium] core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts:395 — The onEditImage method calls launcher.open() without checking if launcher is defined after the !launcher?.isAvailable() guard. If launcher is null but isAvailable() would be false, the guard passes (because !null?.isAvailable() is true), then the method proceeds to the legacy path. This is correct, but the comment on line 125 incorrectly states it "safely no-ops". It should clarify that the fallback works.

[🟡 Medium] core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.ts:34#enabled signal uses initialValue: false. This means isAvailable() returns false until the server responds, causing a brief flash of the legacy editor even if the feature flag is enabled. This could cause a race condition where the user clicks "edit" before the flag loads, triggering the legacy editor incorrectly. Consider if this is acceptable or if the UI should be disabled until the flag is known.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts:452 — The #cropOverlay view child is typed as DotImageEditorCropOverlayComponent but the template uses dot-image-editor-crop-overlay without a template reference variable (#). The view child will not find the component instance, causing applyCrop() and cancelCrop() calls in the footer to fail. This matters because the crop overlay's apply/cancel actions will be broken.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts:454 — The #focalOverlay view child has the same issue: no template reference variable, so it will be undefined. The focal overlay interactions may fail.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.ts:456 — The #displayImg view child is used to observe the image's rendered rect via ResizeObserver. However, the displayImg element is referenced in the template only by #displayImg on the <img> tag, which is correct. Ensure the ResizeObserver is properly cleaned up in DestroyRef to prevent memory leaks.

[🟠 High] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts:40 — The test suite globally mocks URL.createObjectURL and URL.revokeObjectURL. This is a shared resource; other tests in the same test run may rely on the real implementation. This could cause flaky tests or false positives. It's safer to mock per-test or restore after each test.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts:45 — The test stubs HTMLImageElement.prototype.decode globally. This affects all tests running in the same context, potentially breaking other specs. Use jest.spyOn(instance, 'decode') instead.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts:48naturalWidth and naturalHeight are defined as getters on the prototype, affecting all image elements. This could cause unrealistic test behavior elsewhere. Prefer mocking per instance.

[🟡 Medium] core-web/libs/image-editor/src/lib/components/dot-image-editor-canvas/dot-image-editor-canvas.component.spec.ts:344 — The dispatchedEvent helper function searches for events by type suffix, which is fragile if event types change. It's used across multiple tests; a change in event naming could break all tests. Consider a more robust method.

[🟡 Medium] core-web/libs/edit-content/src/lib/edit-content.shell.component.ts:25DialogService is provided at the component level. This is correct for scoping, but ensure that any parent injector doesn't already provide DialogService (which could cause conflicts). The comment explains the intent, which is good.

[🟡 Medium] core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts:359 — The test 'should fall back to the legacy Dojo editor when the new editor is disabled' mocks DotBinaryFieldEditImageService.prototype.openImageEditor. This modifies the prototype, which could affect other tests running in the same suite. Use jest.spyOn(instance, 'openImageEditor') instead.

[🟡 Medium] core-web/libs/edit-content/src/lib/fields/shared/image-editor-launcher/angular-image-editor.launcher.spec.ts:15featureFlag$ is a BehaviorSubject but the test uses featureFlag$.next(false) to simulate the flag being off. However, the service's #enabled signal is derived from getFeatureFlagWithDefault, which returns the subject. Changing the subject after service creation tests the reactive behavior, which is correct. Ensure the service is recreated for each test to avoid state leakage.

The PR introduces a new image editor with a feature flag and fallback to legacy. The changes are mostly clean, but there are a few issues with optional injection handling, view child references, and test pollution that need attention.


Run: #28042028043 · tokens: in: 21199 · out: 1407 · total: 22606

@oidacra oidacra force-pushed the issue-36063-image-editor-build-dotimageeditorcomponent-modal-s branch from 03448ba to c2f4635 Compare June 18, 2026 21:27
@oidacra oidacra force-pushed the issue-36063-image-editor-build-dotimageeditorcomponent-modal-s branch from c2f4635 to a1d5b80 Compare June 19, 2026 14:46
- 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
@oidacra oidacra force-pushed the issue-36063-image-editor-build-dotimageeditorcomponent-modal-s branch from a1d5b80 to 5f38ef3 Compare June 19, 2026 14:51
…y/focal fixes

- Accordion: flatten app-wide via CustomLaraPreset (square corners, opaque
  sticky section headers, animated chevron); smaller control labels/values
- Transform: editable Scale/Rotate number inputs; Scale now resizes
  (scale% x natural size) instead of being a no-op
- Preview: store-owned auto-retry on transient load failure, decode() +
  natural-dimension completeness guard, hidden pending preloader so
  half-painted frames never show
- History: removing an applied edit replays the remaining flow (field-level
  delta replay) instead of leaving stale effects baked in; de-duplicated and
  shrunk applied-edit labels
- Focal point: set live on drag (no Set click), no preview reload; focal-
  centered aspect crop (1:1 / 16:9 / 4:3) presented in the canvas dark bar;
  removed the no-op FocalPoint preview filter (save-time anchor only)
- File info: Original Size row
- Store: split panel events into Adjust/Transform/File-info groups with
  per-group reducers; effects via withEventHandlers
- i18n: remove orphaned history.category.*, transform.aspect, focal.done keys

Refs #36063
@oidacra oidacra changed the title feat(image-editor): add Angular image editor with events-based signalStore feat(image-editor): add Angular image editor Jun 19, 2026
…ted frames

The preview <img> 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 <img> 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
…eview 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
…dentity in service test

Code-review follow-ups (delta review of 9963b75):
- 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
…(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
…put 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
…+ 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
oidacra added 2 commits June 22, 2026 16:09
- 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
…_IMAGE_EDITOR

The new @dotcms/image-editor opens only when the flag is on; otherwise the
binary field falls back to the legacy Dojo image editor, so behavior is
unchanged by default.

- Frontend: add FEATURE_FLAG_NEW_IMAGE_EDITOR to FeaturedFlags; the Angular
  launcher's isAvailable() resolves the flag via DotPropertiesService;
  onEditImage falls back to the legacy editor when off.
- Backend: declare the flag in FeatureFlagName, expose it in
  ConfigurationResource (boolean set + white list), default it false in
  dotmarketing-config.properties.

Refs #36062
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code and removed AI: Safe To Rollback labels Jun 22, 2026
The crop box is drawn in the displayed (flipped/rotated) preview's
coordinates, but buildFilterChain emitted Crop first, so the server cropped
the original image and then flipped it — mirroring the selected region (a
left crop returned the right side under horizontal flip). Emit Crop after
Rotate/Flip so it acts on the image as displayed, matching the legacy editor
which appends Crop to the already-transformed chain.

Refs #36066
… primary, Discard outlined)

Mirror the edit-content unsaved-changes prompt (unsavedChangesGuard) so the
"Discard changes?" confirmation no longer renders two primary buttons. Keep
editing is the primary (accept) action; Discard is the secondary outlined
(reject) action. Dismissals (X / ESC / mask click) now keep editing instead of
discarding. Frontend-only: reuses existing message keys.
The focal overlay converted a painted (zoom-scaled) pointer offset against
imageRect, which is in unscaled (logical) px, so clicks landed off-target at
any zoom other than 100%. Pass the stage zoom scale to the overlay and divide
the painted offset by it in #setFromClient, fixing both click and drag
placement while zoomed in or out.
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: M-3 — REST / GraphQL / Headless API Contract Change
  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: ThemeSourceView (returned inside PageRenderSourcesView by the page render endpoint) renames the JSON field filesvtls and drops the per-entry extension field entirely (replacing List<FileRefView> with List<VtlFileRefView>). Any headless consumer, integration, or admin frontend that was updated in release N to read theme.vtls (or that used theme.files[*].extension) will break after a rollback to N-1, which still returns the old theme.files shape.
  • Code that makes it unsafe:
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/ThemeSourceView.java — field renamed from files (List) to vtls (List); getter renamed getFiles()getVtls()
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/FileRefView.java — record deleted; it previously exposed path, identifier, and extension
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/VtlFileRefView.java — new class with only path and identifier (no extension)
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.javabuildThemeSourceView() now populates vtls (VTL-only) instead of files (all file types)
  • Alternative (if possible): Follow the two-phase contract change from M-3: in Release N, keep the files field present (as a deprecated alias populated identically) alongside the new vtls field, so that consumers on N-1's response format still work. Remove the files alias only in Release N+1 after N-1 is retired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Not Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

1 participant