feat(image-editor): add Angular image editor#36236
Conversation
…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 finished @oidacra's task in 1m 32s —— View job Rollback Safety Analysis
Result: ✅ Safe to Roll BackThis PR introduces the new Angular image editor library (
What changed (backend):
The feature flag defaults to Label applied: AI: Safe To Rollback |
🤖 Bedrock Review —
|
03448ba to
c2f4635
Compare
c2f4635 to
a1d5b80
Compare
- 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
a1d5b80 to
5f38ef3
Compare
…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
…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
…library files Constants and type declarations were scattered across components, the store and services. Centralize them so the library has one home for each: - New image-editor.constants.ts: RANGES, ZOOM_*, crop/focal nudge steps, MIN_CROP_SIZE, CROP_HANDLES, BYTES_PER_KB, AUTO_PREVIEW_RETRY_LIMIT, COMPRESSION_LABELS, SLICE_KEYS, IMAGE_EDITOR_PANEL_STATE_KEY. The per-panel *_MIN/MAX duplicates (ADJUST/SCALE/ROTATE) now reference RANGES (single source). - models/image-editor.models.ts absorbs every remaining interface/type: ImageEditorState, Dimensions, FilterChainInput, ToolRailItem, CompressionOption, LocalRect, HandlePosition, NormalizedPoint, SaveEditedImageResponse, NaturalDimensions, AssetMeta, EditableSlices, SlicePatch. - state.ts keeps only the initial-state values; store-utils/store/components import the shared constants and types. No behavior change; 196 tests green. Refs #36063
…y functionality Split the 524-line store monolith into one signalStoreFeature per area of functionality (https://ngrx.io/guide/signals/signal-store/custom-store-features), each bundling its own reducers, selectors and effects so a domain lives in one file (store/features/with-*.feature.ts): - withAdjust / withTransform / withCrop / withFocalPoint / withFileInfo / withTools - withHistory (+ appliedEdits / canUndo / canRedo) - withAsset (+ loadAsset$ effect) - withPreview (+ appliedFilters / previewUrl / isDirty / isBusy + resolveSize$) - withSave (+ canSave + save$ / download$) Cross-cutting selectors live in their owning feature; withSave declares the previewUrl / appliedFilters props it consumes from withPreview (so it composes after it). image-editor.store.ts is now a 41-line composition. Pure reorganization — the reducers still fold the full flat state; no behavior change, 196 tests green (store spec exercises the public API, unchanged). 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
- 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
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
Summary
Replaces the legacy Dojo
ImageEditor.jswith a new standalone Angular library@dotcms/image-editor, wired into Edit Content's binary field through anIMAGE_EDITOR_LAUNCHERseam (Angular-only path).The editor is a viewer of a dotCMS endpoint: the
<img>srcis a computed/contentAsset/image/{id}/{field}/filter/...URL, so every control change rebuildsthe 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 ofone vertical
signalStoreFeatureper 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
@dotcms/image-editor: root dialog, header, canvas (crossfade, zoom +pan, crop/focal overlays), side panels (Adjust / Transform / File info / History),
footer (Cancel + Download).
(Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y), ignored while a text field is focused.
libs/edit-content(token + Angular launcher) and binary-field wiring.focal-centered aspect crop.
Preview robustness
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
image-editor.constants.ts; types/models inmodels/image-editor.models.ts.store/features/with-*.feature.ts+store-utils.ts;store.tsis a thin composition.Testing
nx lint image-editor/nx lint edit-content— cleannx test image-editor— 258 passing (per-feature unit specs + store-utils + dimensions.util + integration; store-feature branch coverage ~100%)nx test edit-content— 1961 passing (28 skipped)Issues
/contentAsset/imagefilter URLsAngularImageEditorLaunchermodal is implemented, but true full-screen sizing is not working yet (follow-up there)