From 676027c9ca66bcdd532fbfc07813d1ffab16fe7f Mon Sep 17 00:00:00 2001 From: Anand-240 Date: Fri, 2 Oct 2026 03:50:16 +0530 Subject: [PATCH 1/2] feat: add React image change callback bridge --- README.md | 9 +++++++ src/ImageEditor.tsx | 3 +++ src/types.ts | 6 ++++- test/index.test.tsx | 65 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d0f8bfa..85f9310 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,17 @@ The component works out of the box in React Server Components environments (e.g. | `onSave` | `({ dataUrl, blob }) => void` | Called when the user saves the edited image. | | `onCancel` | `() => void` | Called when the user cancels editing. | | `onLoadError` | `() => void` | Called when the image fails to load into the canvas (CORS, 404, decode error). | +| `onChange` | `() => void` | Forwards image change notifications from an embed that supports `MountOptions.onChange`. See the compatibility note below. | | `onError` | `(error: Error) => void` | Wrapper-level failures: embed script load, editor creation, or image reset. Falls back to `console.error` when absent. | +## Image change notifications + +The proposed `onChange` prop forwards notifications from the underlying editor without polling or exporting the image. Updating the handler does not remount the editor. Notifications from a cancelled mount are ignored. + +**Compatibility:** the default CDN editor, version 2.13.0, does not emit this callback yet. This API needs a corresponding change in the underlying editor before it can replace polling. Until that support is released, `onChange` will not fire with the default embed. + +The intended event covers changes to the visible image, including drawing, adjustments, undo, redo, and resets. The embed must define when notifications fire during a stroke and when the updated image is available through `getImage()`. Tool selection and viewport changes should not emit image change notifications. Applications can throttle their own snapshots without requiring an export on every event. + ## Editor instance (ref) The `ref` exposes `{ editor }` — `null` until the editor mounts, then an instance with: diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx index c6c994a..7ebcf11 100644 --- a/src/ImageEditor.tsx +++ b/src/ImageEditor.tsx @@ -123,6 +123,9 @@ function ImageEditorInner( onSave: (result) => latestPropsRef.current.onSave?.(result), onCancel: () => latestPropsRef.current.onCancel?.(), onLoadError: () => latestPropsRef.current.onLoadError?.(), + onChange: () => { + if (!cancelled) latestPropsRef.current.onChange?.(); + }, }); if (cancelled) { diff --git a/src/types.ts b/src/types.ts index fb1b831..8823410 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,8 @@ export interface MountOptions { * proxy/URL, a 404, or a decode error). */ onLoadError?: () => void; + /** Proposed image change notification. Requires support in the embed's mount API. */ + onChange?: () => void; } /** @@ -89,7 +91,7 @@ export interface ImageEditorInstance { */ export type ImageEditorOptions = Omit< MountOptions, - 'container' | 'image' | 'onSave' | 'onCancel' | 'onLoadError' + 'container' | 'image' | 'onSave' | 'onCancel' | 'onLoadError' | 'onChange' >; export interface ImageEditorProps { @@ -133,6 +135,8 @@ export interface ImageEditorProps { onCancel?(): void; /** Called when the image fails to load into the canvas (decode/CORS/404). */ onLoadError?(): void; + /** Called when the embed reports an image change. Does not export the image. */ + onChange?(): void; } export interface ImageEditorRef { diff --git a/test/index.test.tsx b/test/index.test.tsx index 706b7ed..a3faadd 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -145,8 +145,15 @@ it('passes every documented option through to createEditor unchanged', async () await flush(); const received = mountOptionsOf(); - const { container, image, onSave, onCancel, onLoadError, ...passedThrough } = - received; + const { + container, + image, + onSave, + onCancel, + onLoadError, + onChange, + ...passedThrough + } = received; expect(passedThrough).toEqual(options); // Nested configs arrive by reference, not a lossy copy. expect(received.features).toBe(options.features); @@ -418,6 +425,60 @@ it('always invokes the latest callbacks', async () => { expect(firstOnSave).not.toHaveBeenCalled(); }); +it('uses the latest onChange handler without remounting or exporting', async () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = render(); + await flush(); + + const notify = mountOptionsOf().onChange!; + expect(() => notify()).not.toThrow(); + + rerender(); + notify(); + expect(first).toHaveBeenCalledTimes(1); + + rerender(); + notify(); + notify(); + expect(second).toHaveBeenCalledTimes(2); + expect(first).toHaveBeenCalledTimes(1); + + rerender(); + notify(); + expect(second).toHaveBeenCalledTimes(2); + expect(createEditor).toHaveBeenCalledTimes(1); + expect(mockInstance.getImage).not.toHaveBeenCalled(); + expect(mockInstance.hasChanges).not.toHaveBeenCalled(); +}); + +it('ignores change notifications from cancelled mounts', async () => { + const pending = defer(); + createEditor.mockImplementationOnce(() => pending.promise); + const onChange = vi.fn(); + const { rerender, unmount } = render( + + ); + await flush(); + const oldNotify = mountOptionsOf().onChange!; + + rerender( + + ); + oldNotify(); + expect(onChange).not.toHaveBeenCalled(); + + pending.resolve(mockInstance as unknown as ImageEditorInstance); + await flush(); + const notify = mountOptionsOf(1).onChange!; + notify(); + expect(onChange).toHaveBeenCalledTimes(1); + + unmount(); + notify(); + expect(onChange).toHaveBeenCalledTimes(1); +}); + it('applies minHeight and style to the container', async () => { render( Date: Fri, 2 Oct 2026 03:59:57 +0530 Subject: [PATCH 2/2] docs: clarify change callback embed requirement --- README.md | 4 ++-- src/types.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 85f9310..d6c616a 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ The component works out of the box in React Server Components environments (e.g. The proposed `onChange` prop forwards notifications from the underlying editor without polling or exporting the image. Updating the handler does not remount the editor. Notifications from a cancelled mount are ignored. -**Compatibility:** the default CDN editor, version 2.13.0, does not emit this callback yet. This API needs a corresponding change in the underlying editor before it can replace polling. Until that support is released, `onChange` will not fire with the default embed. +**Compatibility:** the default CDN editor, version 2.13.0, does not emit this callback. The underlying editor needs to add support before `onChange` can replace polling. -The intended event covers changes to the visible image, including drawing, adjustments, undo, redo, and resets. The embed must define when notifications fire during a stroke and when the updated image is available through `getImage()`. Tool selection and viewport changes should not emit image change notifications. Applications can throttle their own snapshots without requiring an export on every event. +The proposed event covers changes to the visible image, including drawing, adjustments, undo, redo, and resets. The embed must define its timing during brush strokes and when `getImage()` reflects the change. Tool selection and viewport changes should not trigger it. Applications control snapshot timing and throttling. ## Editor instance (ref) diff --git a/src/types.ts b/src/types.ts index 8823410..91ba519 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,7 +135,10 @@ export interface ImageEditorProps { onCancel?(): void; /** Called when the image fails to load into the canvas (decode/CORS/404). */ onLoadError?(): void; - /** Called when the embed reports an image change. Does not export the image. */ + /** + * Called when the embed reports an image change. Does not export the image. + * Requires embed support; the default CDN editor 2.13.0 does not emit it yet. + */ onChange?(): void; }