@coolms/image-editor-angular 2.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry Popov / CoolMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,391 @@
1
+ # @coolms/image-editor-angular
2
+
3
+ Modern image editor for CoolMS DXP. Built on fabric.js v6 via a
4
+ custom `FabricEngineAdapter`, wrapped by a CoolMS-native UI shell.
5
+
6
+ ## Status
7
+
8
+ **Feature-complete on the fabric.js v6 engine.** The shell
9
+ (top toolbar, canvas mount, right sidebar, properties panel) drives
10
+ all editing operations through the engine API. The save flow is
11
+ integrated end-to-end with the Media Library and the VFS file
12
+ manager: open an image asset (or any VFS image file), edit, Save
13
+ (overwrite) or Save as new, and the source surface reflects the
14
+ change after thumbnail regeneration.
15
+
16
+ The engine adapter pattern was the foundation that made the engine
17
+ swap from Toast UI to fabric.js v6 viable without touching the UI
18
+ shell or any tool component.
19
+
20
+ ## Features
21
+
22
+ ### Editing tools
23
+
24
+ **Transform** (4 tools):
25
+ - **Crop** — interactive cropzone overlay built from a fabric `Rect`
26
+ with corner controls; aspect ratio presets (Free, 1:1, 4:3, 16:9,
27
+ 3:2). Apply uses the bake-and-replace strategy: the image
28
+ is rendered with its current rotation/flip/filters into a canvas
29
+ at the bounding-box dimensions, the cropped sub-region is
30
+ extracted, and the FabricImage is replaced. The cropped output
31
+ is always axis-aligned regardless of how the image was rotated
32
+ when the user drew the crop overlay.
33
+ - **Rotate** — preset buttons (90° CW, 90° CCW, 180°) for instant
34
+ commits; free-rotation slider live-bound to the image's actual
35
+ angle (drag rotates in real time, single undo entry per drag
36
+ session)
37
+ - **Flip** — horizontal and vertical toggles. Button highlights
38
+ read from `state.imageFlipX/Y` so they stay accurate after
39
+ mouse-driven manipulations and undo/redo
40
+ - **Resize** — width and height numeric inputs with aspect lock,
41
+ live-synced to the visually-current dimensions (mouse-scaling on
42
+ the canvas updates the inputs in real time, until the user starts
43
+ typing). Apply commits a real image-data resize via fabric's
44
+ `toCanvasElement` round-trip (rotation, flip, filters all baked
45
+ into the new pixel data)
46
+
47
+ **Filters** (7 tools):
48
+ - **Brightness, Contrast, Saturation** — sliders, range −1..1
49
+ - **Blur** — slider, range 0..1
50
+ - **Sepia, Grayscale, Sharpen** — toggles, parameter-less
51
+
52
+ Slider filters live-preview during drag with a coalesced
53
+ `undo -> applyFilter` cycle so the undo stack stays at one entry per
54
+ preview session. `clearRedoStack()` on commit prevents stale preview
55
+ entries from leaking into the redo stack.
56
+
57
+ ### Top toolbar
58
+
59
+ - **Undo** / **Redo** — bound to engine history events. The custom
60
+ command stack records one entry per logical
61
+ operation; mouse manipulations and sidebar-driven changes both
62
+ push commands through the same path.
63
+ - **Reset** — re-loads the original image and applies fit-to-viewport
64
+ zoom centred. Identity viewport transform is restored so the next
65
+ fit calculation lands the image at the viewport centre regardless
66
+ of where the user had zoomed/panned.
67
+ - **Zoom out** / **Zoom %** / **Zoom in** — viewport zoom only,
68
+ clamped 5%..1000% with a 1.25× step. Click the percentage to jump
69
+ to fit. **Ctrl+wheel** (Cmd+wheel on Mac, also covers trackpad
70
+ pinch) zooms with a 1.1× factor anchored at canvas centre.
71
+ Viewport zoom does not affect saved bytes (pure fabric viewport
72
+ transform).
73
+ - **Hand tool** — pan the canvas with mouse drag. Single click
74
+ toggles on, second click toggles off. While active, fabric's
75
+ selection and hit-testing are disabled so a drag on empty canvas
76
+ always pans rather than drawing a marquee.
77
+ - **Dimensions badge** — current image size in natural pixels.
78
+
79
+ ### Direct mouse manipulation
80
+
81
+ Click the image to select it; fabric's corner controls render at
82
+ the image edges. Drag a corner to scale, the rotation handle
83
+ (top-centre) to rotate, or the body to move. On `mouseup` the
84
+ adapter pushes a single transform command spanning the entire
85
+ gesture, so undo reverts the whole drag in one step rather than
86
+ per-pixel. The sidebar tool inputs (Rotate slider, Resize
87
+ width/height) live-sync to the image's transform via the
88
+ `imageTransformed` event — they reflect mouse-driven changes in
89
+ real time, and changes from the sidebar drive the canvas the same
90
+ way. Both paths are authoritative; they cannot drift.
91
+
92
+ ### Canvas frame
93
+
94
+ A thin orange rectangle (`rgba(245, 166, 35, 0.5)`) tracks the
95
+ image bounds as a visual hint of the image extent against the dark
96
+ canvas background. The frame mirrors the image's `angle / scaleX /
97
+ scaleY / flipX / flipY` so it follows rotation and flip natively;
98
+ `strokeWidth = 2 / zoom` keeps the visible stroke at a constant 1
99
+ visual pixel regardless of viewport zoom. It is `excludeFromExport:
100
+ true`, hidden during crop mode (so the dashed cropzone is the only
101
+ visible rectangle), and never reaches the saved bytes — the export
102
+ path renders only the FabricImage object.
103
+
104
+ ### Save flow
105
+
106
+ - **Save** — overwrite the original asset (with confirmation dialog)
107
+ - **Save as...** — create a new asset with editable filename
108
+ (default `{stem}-edited{ext}`)
109
+ - **Cancel** — close without changes
110
+
111
+ The host calls `shell.deactivateActiveTool()` immediately before
112
+ `engine.export()` so any mid-edit tool (most importantly the
113
+ cropper, which renders a fabric overlay) cleans up before pixels
114
+ are read. Export uses fabric v6's `image.toBlob({format, quality})`
115
+ and `image.toDataURL` directly — they render only the FabricImage
116
+ object, so the canvas frame and any leftover overlay are never in
117
+ the output regardless of `excludeFromExport`.
118
+
119
+ ## Architecture
120
+
121
+ Three layers, separated for swappability:
122
+
123
+ ```
124
+ UI shell --► ImageEditorEngine --► FabricEngineAdapter --► fabric.js v6
125
+ (interface) (this package) (npm peer dep)
126
+ ```
127
+
128
+ 1. **UI shell** — `<coolms-image-editor>` host component with three
129
+ panes (top toolbar, canvas mount, right sidebar). Tools register
130
+ themselves into a `ToolRegistryService`; the right sidebar uses
131
+ `NgComponentOutlet` with `inputs` to render the active tool's
132
+ properties panel.
133
+ 2. **Engine adapter** — `FabricEngineAdapter` implements
134
+ `ImageEditorEngine`. Owns canvas dimensions (no vendor CSS
135
+ auto-fit layer), normalises the public API (lowercase filter
136
+ names, `imageTransformed` event for bidirectional sync, single
137
+ `setCanvasDimensions` for viewport-driven resizing), maintains
138
+ a custom undo / redo command stack, and bumps fabric's filter
139
+ `textureSize` config to fit images larger than 4096 px before
140
+ the first WebGL filter pass.
141
+ 3. **fabric.js v6** (vendor, MIT) — canvas rendering (`Canvas`),
142
+ image object (`FabricImage`), interactive controls (corner /
143
+ rotation handles), filter pipeline (WebGL backend with Canvas2D
144
+ fallback). Imported via `from 'fabric/es'` for tree-shaking.
145
+
146
+ ### Why an adapter
147
+
148
+ - **Lazy loading**: the editor module is split into its own lazy
149
+ chunk and only loaded when the user opens the dialog.
150
+ - **Stable API**: lowercase filter names; `imageTransformed` event
151
+ abstracts mouse vs sidebar origin; viewport zoom semantics
152
+ (clamped, anchored at canvas centre) live in one place. Vendor
153
+ renames or replacement don't leak to the shell.
154
+ - **Engine swap proven viable**: replaced Toast UI with
155
+ fabric v6 with zero changes to the UI shell or tool components.
156
+ The interface is the firewall.
157
+
158
+ ### Filter conventions
159
+
160
+ Public API uses lowercase names ('brightness') and normalised
161
+ ranges (see `src/types/filter.types.ts`). The adapter maps them to
162
+ fabric `BaseFilter` classes (`Brightness`, `Contrast`, etc.).
163
+ Sharpen resolves to a stock `Convolute` filter constructed with
164
+ the canonical 3×3 sharpen kernel `[0,-1,0,-1,5,-1,0,-1,0]` —
165
+ identical to Toast UI's old extension, but with no extension
166
+ import needed.
167
+
168
+ The adapter calls `ensureFilterBackendFitsImage(image)` before each
169
+ `applyFilters` invocation. If the image's longest dimension exceeds
170
+ `config.textureSize` (default 4096), `textureSize` is bumped to the
171
+ next power of two (capped at 16384) and the filter backend is
172
+ re-initialised. Without this, fabric's WebGL filter pipeline
173
+ allocates a tile-sized GL canvas that's too small for the image,
174
+ leaving pixels past the tile boundary un-rendered (the right-edge
175
+ crop bug this surfaced). The factory's `WebGLProbe`
176
+ falls back to `Canvas2dFilterBackend` automatically if the GPU
177
+ can't support the new size.
178
+
179
+ ## Canvas vs Image semantics
180
+
181
+ The editor uses a **transform-the-image** model rather than the
182
+ Photoshop-style separate-canvas model.
183
+
184
+ - The **image** is the document. Crop, resize, rotate, flip, and
185
+ filters all mutate the image's pixels or its transform state.
186
+ - The **canvas** is a viewport. It is sized to the host container
187
+ via `engine.setCanvasDimensions(viewport)` and only exists to
188
+ display the image; it has no independent dimensions, no
189
+ background colour, no separate save-bounds.
190
+ - **Save** outputs the current image with all transforms applied
191
+ (`image.toBlob` / `image.toDataURL`). What you see is what gets
192
+ saved; nothing outside the image bounds is ever in the output.
193
+
194
+ Practical consequences:
195
+
196
+ - An image cannot be moved relative to a canvas, because the
197
+ canvas is the viewport, not a separate workspace.
198
+ - Crop bakes rotation, flip, and prior crops into the new pixel
199
+ data, then drops the transform state back to identity. The
200
+ cropped output is always axis-aligned regardless of how the
201
+ image was rotated when the user drew the crop overlay.
202
+ - Rotation is reversible (undo / redo) until the next crop, after
203
+ which the rotated pixels become part of the image content.
204
+ - Viewport zoom is purely visual and never affects saved bytes.
205
+
206
+ A true Photoshop canvas model (fixed-size workspace, image as a
207
+ positioned layer, separate Canvas Size / Image Size / Trim tools)
208
+ is deferred to. It is significant scope and only worth
209
+ building if the editor becomes a distributable product. For
210
+ complex multi-layer compositions today, recommend a dedicated
211
+ desktop tool.
212
+
213
+ ## Usage
214
+
215
+ ### As a CDK Dialog (the primary integration path)
216
+
217
+ ```typescript
218
+ import { Dialog } from '@angular/cdk/dialog';
219
+ import { firstValueFrom } from 'rxjs';
220
+ import {
221
+ CoolmsImageEditorHostComponent,
222
+ type CoolmsImageEditorHostData,
223
+ type CoolmsImageEditorHostResult,
224
+ } from '@coolms/image-editor-angular';
225
+
226
+ const data: CoolmsImageEditorHostData = {
227
+ asset: {
228
+ uuid: '...', // MediaAsset.id
229
+ originalUrl: '/media/photo.jpg',
230
+ filename: 'photo.jpg',
231
+ mimeType: 'image/jpeg',
232
+ dimensions: { width: 1920, height: 1080 },
233
+ },
234
+ };
235
+
236
+ const dialogRef = dialog.open<CoolmsImageEditorHostResult, CoolmsImageEditorHostData>(
237
+ CoolmsImageEditorHostComponent,
238
+ {
239
+ data,
240
+ backdropClass: 'cdk-overlay-dark-backdrop',
241
+ disableClose: true,
242
+ },
243
+ );
244
+
245
+ const result = await firstValueFrom(dialogRef.closed);
246
+ if (result?.kind === 'saved') {
247
+ // result.mode is 'overwrite' or 'save_as_new'
248
+ // result.assetUuid is the resulting MediaAsset id
249
+ }
250
+ ```
251
+
252
+ ### Direct engine use (for embedding outside a CDK Dialog)
253
+
254
+ ```typescript
255
+ import { FabricEngineAdapter, type ImageEditorEngine } from '@coolms/image-editor-angular';
256
+
257
+ const engine: ImageEditorEngine = await FabricEngineAdapter.create({
258
+ container: document.querySelector('#editor')!,
259
+ });
260
+
261
+ // Size the canvas backstore + display to your viewport. The adapter
262
+ // owns canvas dimensions; without this call the canvas defaults to
263
+ // the container's `clientWidth/Height` at construction time.
264
+ engine.setCanvasDimensions({ width: 1200, height: 800 });
265
+
266
+ await engine.loadImage('https://example.com/photo.jpg');
267
+ await engine.applyFilter('brightness', { brightness: 0.2 });
268
+ await engine.crop({ x: 100, y: 50, width: 800, height: 600 });
269
+ await engine.rotate(90);
270
+
271
+ // Fit-to-viewport zoom (with a 5 % margin around image edges)
272
+ const fit = engine.getFitZoom({ width: 1200, height: 800 });
273
+ engine.setZoom(fit);
274
+
275
+ const blob = await engine.export('jpeg', 0.92);
276
+
277
+ engine.on('historyStateChanged', ({ canUndo, canRedo }) => {
278
+ // wire your own undo / redo buttons
279
+ });
280
+
281
+ engine.on('imageTransformed', t => {
282
+ // mirror to your own sidebar: angle, scaleX/Y, flipX/Y, left, top
283
+ // t.source is 'mouse' (corner-handle drag) or 'sidebar' (engine.rotate / engine.flip / etc.)
284
+ });
285
+
286
+ engine.destroy();
287
+ ```
288
+
289
+ ## Theming
290
+
291
+ Every component in the package styles itself through CMS CSS
292
+ variables (`--cms-surface`, `--cms-text`, `--cms-accent`,
293
+ `--cms-border`, `--cms-canvas-bg`, `--cms-overlay-scrim`, etc.)
294
+ defined in the host project's `src/styles.scss`. There is no
295
+ per-component colour configuration and no theme prop — the editor
296
+ follows whatever the host theme dictates.
297
+
298
+ When the host project ships a CMS-wide light/dark switch later
299
+ (flipping the values of those variables at the `:root`), the editor
300
+ reflects the new theme immediately on the next render. No remount,
301
+ no `theme` config object to keep in sync.
302
+
303
+ Two intentionally hard-coded values exist:
304
+ - `#ffffff` text on `--cms-overlay-scrim`. The scrim is dark by
305
+ design (legibility over the canvas during loading / saving), so
306
+ the contrast pair is fixed.
307
+ - `rgba(245, 166, 35, .15)` focus-ring shadow on the resize tool's
308
+ numeric inputs. Matches the project's existing focus-ring pattern
309
+ (used by `.cms-input`, `.form-control`, etc.); promote together
310
+ if a `--cms-accent-focus-ring` token lands at the project level.
311
+
312
+ ## Performance
313
+
314
+ Measured on Chrome for Windows (, NVIDIA GTX
315
+ 1650 via ANGLE D3D11, `WebGLFilterBackend` confirmed,
316
+ `MAX_TEXTURE_SIZE=16384`):
317
+
318
+ | Metric | Toast UI baseline | (fabric v6) | Delta |
319
+ |---|---|---|---|
320
+ | Editor lazy chunk (transfer / gzipped) | 167.60 kB | **78.5 kB** | **−53%** |
321
+ | Editor lazy chunk (decoded / raw) | ~722 kB | **290.4 kB** | **−60%** |
322
+
323
+ Heap behaviour over 10 open / load 4608×3456 image / close cycles
324
+ on a 4 GB heap-limit Chrome:
325
+
326
+ | Checkpoint | Heap used | Canvases | Editor host instances |
327
+ |---|---|---|---|
328
+ | Baseline | 15.79 MB | 0 | 0 |
329
+ | After cycle 1 | 15.82 MB | 0 | 0 |
330
+ | After cycle 5 | 15.87 MB | 0 | 0 |
331
+ | After cycle 10 | 15.88 MB | 0 | 0 |
332
+
333
+ Heap delta across 10 cycles: +0.09 MB (~9 kB / cycle, well within
334
+ Angular's per-cycle change-detection noise). A single leaked
335
+ decoded image at 4608×3456 RGBA would be 64 MB; we see 9 kB.
336
+ `engine.destroy()` is fully effective.
337
+
338
+ End-to-end Sharpen (3×3 Convolute) on a 4608×3456 image: ~331 ms
339
+ (click -> image rendered, including DOM dispatch + Angular CD +
340
+ WebGL filter pass + canvas blit). The actual GPU filter time is a
341
+ fraction of that — most is Angular CD + paint.
342
+
343
+ ## Browser compatibility
344
+
345
+ - Chrome, Firefox, Edge, Safari (modern, evergreen)
346
+ - WebGL preferred. fabric's `getFilterBackend` factory falls back
347
+ to `Canvas2dFilterBackend` automatically when WebGL is unavailable
348
+ or when the requested `textureSize` exceeds `MAX_TEXTURE_SIZE`.
349
+ - Recommended ≥ 4 GB RAM for images larger than 4096 px on the
350
+ long edge (the WebGL tile bumps to 8192 for those).
351
+ - Tested in Chrome. Firefox / Edge / Safari verified
352
+ conceptually but not in this round; report any browser-specific
353
+ issues against the engine adapter, not the UI shell.
354
+
355
+ ## Roadmap
356
+
357
+ ### Done
358
+
359
+ - Headless mount, engine adapter, UI shell, 11 tools, theming, save flow
360
+ - `FabricEngineAdapter` rewrite (~640 LOC) and the state-service switch
361
+ - Canvas-space crop (bake-and-replace), Sharpen `textureSize` fix, blob-URL undo
362
+ - Bidirectional sync (`imageTransformed` event), live-bound rotate slider,
363
+ flip / resize signals
364
+ - Canvas frame overlay
365
+ - Toast UI dependency removed (640 LOC adapter deleted)
366
+ - Bundle, memory and functional verification
367
+
368
+ ### Planned
369
+
370
+ - Shapes (Free Draw, Rectangle, Circle, Text, Icons, Mask)
371
+ - Color picker / eyedropper
372
+ - WebP / AVIF export options
373
+ - Rich-text inline widget integration
374
+ - Server-side preset crops (Media Library thumbnail / banner sizes)
375
+ - Drag-and-drop image into the editor
376
+ - AI integrations (background removal, smart crop, upscaling)
377
+ - Mobile responsive (touch gestures, pinch-zoom)
378
+ - Keyboard shortcuts (Ctrl+0 fit, Ctrl+1 actual size, etc.)
379
+
380
+ ### Further out
381
+
382
+ - **Framework adapters** — React, Vue, Svelte wrappers around the
383
+ same `ImageEditorEngine` interface
384
+ - **Plugin tool registration** — open `ToolRegistryService` to
385
+ consumer-defined tools
386
+ - **Video editor companion** sharing the engine-adapter pattern
387
+
388
+ ## License
389
+
390
+ MIT (CoolMS DXP). The runtime peer dependency `fabric` is also MIT.
391
+ See `ATTRIBUTION.md` for the full vendor acknowledgement chain.