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(