Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
65 changes: 63 additions & 2 deletions test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(<ImageEditor image="img-a" />);
await flush();

const notify = mountOptionsOf().onChange!;
expect(() => notify()).not.toThrow();

rerender(<ImageEditor image="img-a" onChange={first} />);
notify();
expect(first).toHaveBeenCalledTimes(1);

rerender(<ImageEditor image="img-a" onChange={second} />);
notify();
notify();
expect(second).toHaveBeenCalledTimes(2);
expect(first).toHaveBeenCalledTimes(1);

rerender(<ImageEditor image="img-a" />);
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<ImageEditorInstance>();
createEditor.mockImplementationOnce(() => pending.promise);
const onChange = vi.fn();
const { rerender, unmount } = render(
<ImageEditor image="img-a" options={{ projectId: 1 }} onChange={onChange} />
);
await flush();
const oldNotify = mountOptionsOf().onChange!;

rerender(
<ImageEditor image="img-a" options={{ projectId: 2 }} onChange={onChange} />
);
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(
<ImageEditor
Expand Down