Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The component works out of the box in React Server Components environments (e.g.
| `style` | `CSSProperties` | Styles applied to the container div. Overrides the default `flex: 1`. |
| `wrapperStyle` | `CSSProperties` | Styles applied to the outer wrapper div, which owns `minHeight` and the flex layout. Set this to drop the editor into a non-flex layout. |
| `ariaLabel` | `string` | Accessible name for the editor region. Defaults to `'Image editor'`. |
| `placeholder` | `ReactNode` | Rendered centred over the container until the editor mounts — a spinner or skeleton for the embed's cold-cache load. Stays visible if the mount fails. |
| `onLoad` | `(editor) => void` | Called with the editor instance once it is mounted. |
| `onSave` | `({ dataUrl, blob }) => void` | Called when the user saves the edited image. |
| `onCancel` | `() => void` | Called when the user cancels editing. |
Expand Down Expand Up @@ -88,6 +89,16 @@ const dataUrl = editorRef.current?.editor?.getImage();
| Any other `options` key | Full remount — the editor is destroyed and recreated with the new configuration. |
| `onSave` / `onCancel` / `onLoadError` / `onLoad` / `onError` | Always call the latest handler; changing them never remounts. |

## Loading state

The embed script and its versioned bundle are fetched at mount. On a cold cache that is a visible gap, so pass a `placeholder` to fill it:

```jsx
<ImageEditor image={url} placeholder={<Spinner />} />
```

It is rendered centred over the container and removed once the editor is live. It is **not** removed when the mount fails, so a failed load never leaves a blank box — pair it with `onError` to swap in a failure message.

## Error handling

Two distinct channels:
Expand Down
22 changes: 22 additions & 0 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function ImageEditorInner(
style = {},
wrapperStyle = {},
ariaLabel = 'Image editor',
placeholder,
} = props;

const [editor, setEditor] = useState<ImageEditorInstance | null>(null);
Expand Down Expand Up @@ -202,12 +203,20 @@ function ImageEditorInner(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, updatableKey]);

// Rendered as an overlay sibling rather than a child of the mount
// container: the embed owns that container's DOM, and React must not
// reconcile children in and out from under it.
const showPlaceholder = placeholder !== undefined && editor === null;

return (
<div
style={{
flex: 1,
display: 'flex',
minHeight: minHeight,
// Only when needed, so existing layouts are untouched.
...(showPlaceholder ? { position: 'relative' } : null),
// Last, so a consumer can still override position if they need to.
...wrapperStyle,
}}
>
Expand All @@ -222,6 +231,19 @@ function ImageEditorInner(
// flex first: a default the consumer's style can override.
style={{ flex: 1, ...style }}
/>
{showPlaceholder && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{placeholder}
</div>
)}
</div>
);
}
Expand Down
9 changes: 8 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CSSProperties } from 'react';
import { CSSProperties, ReactNode } from 'react';

import type {
Features,
Expand Down Expand Up @@ -113,6 +113,13 @@ export interface ImageEditorProps {
* and no name of its own. Defaults to 'Image editor'.
*/
ariaLabel?: string;
/**
* Rendered centred over the container until the editor is mounted — a
* spinner or skeleton for the embed's cold-cache load. Stays visible if
* the mount fails, so a blank box is never the end state; pair it with
* `onError` to swap in a failure message.
*/
placeholder?: ReactNode;
/**
* Override the embed script URL (e.g. to pin an environment). One embed
* per page: the first loader to run installs window.ImageEditor and wins
Expand Down
67 changes: 67 additions & 0 deletions test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,73 @@ it('gives the editor region an accessible name, overridable via ariaLabel', asyn
expect(container.getAttribute('aria-label')).toBe('Avatar cropper');
});

it('shows the placeholder until the editor mounts, then removes it', async () => {
const created = defer<ImageEditorInstance>();
createEditor.mockReturnValueOnce(created.promise);

render(<ImageEditor image="img-a" placeholder={<p>Loading…</p>} />);

// Visible for the whole embed load + createEditor round trip.
expect(document.body.textContent).toContain('Loading…');

await act(async () => {
created.resolve(mockInstance as unknown as ImageEditorInstance);
});

expect(document.body.textContent).not.toContain('Loading…');
});

it('renders no placeholder element when the prop is absent', async () => {
const { container } = render(<ImageEditor image="img-a" />);
await flush();

const wrapper = container.firstElementChild as HTMLElement;
// Just the mount container — no overlay, and no positioning added.
expect(wrapper.children).toHaveLength(1);
expect(wrapper.style.position).toBe('');
});

it('keeps the placeholder visible when the mount fails', async () => {
createEditor.mockRejectedValueOnce(new Error('bundle 404'));

render(
<ImageEditor
image="img-a"
placeholder={<p>Loading…</p>}
onError={vi.fn()}
/>
);
await flush();

// A failed mount must not leave a blank box behind.
expect(document.body.textContent).toContain('Loading…');
});

it('brings the placeholder back while a remount is in flight', async () => {
const { rerender } = render(
<ImageEditor image="img-a" placeholder={<p>Loading…</p>} />
);
await flush();
expect(document.body.textContent).not.toContain('Loading…');

const second = defer<ImageEditorInstance>();
createEditor.mockReturnValueOnce(second.promise);
rerender(
<ImageEditor
image="img-a"
placeholder={<p>Loading…</p>}
options={{ projectId: 1 }}
/>
);

expect(document.body.textContent).toContain('Loading…');

await act(async () => {
second.resolve(makeInstance() as unknown as ImageEditorInstance);
});
expect(document.body.textContent).not.toContain('Loading…');
});

it('passes onCancel and onLoadError through to the editor', async () => {
const onCancel = vi.fn();
const onLoadError = vi.fn();
Expand Down
Loading