diff --git a/README.md b/README.md
index d0f8bfa..d6c616a 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. The underlying editor needs to add support before `onChange` can replace polling.
+
+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)
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..91ba519 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,11 @@ 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.
+ * Requires embed support; the default CDN editor 2.13.0 does not emit it yet.
+ */
+ 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(