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
60 changes: 46 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,22 @@ The component works out of the box in React Server Components environments (e.g.

## Props

| Prop | Type | Description |
| -------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | `string` (required) | Image URL or base64 data URL to edit. |
| `options` | `ImageEditorOptions` | Editor configuration: `projectId`, `user`, `features`, `theme`, `locale`, `translations`, `env`, `offline`, `licenseUrl`, `defaultPrompt`, `autoSubmitPrompt`, `aiAssistantOpenState`. |
| `editorId` | `string` | id for the container div. Cosmetic — the editor mounts by element reference. |
| `minHeight` | `number \| string` | Minimum height of the editor container. Defaults to `500`. |
| `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'`. |
| `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. |
| `onLoadError` | `() => void` | Called when the image fails to load into the canvas (CORS, 404, decode error). |
| `onError` | `(error: Error) => void` | Wrapper-level failures: embed script load, editor creation, or image reset. Falls back to `console.error` when absent. |
| Prop | Type | Description |
| ------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | `string` (required) | Image URL or base64 data URL to edit. |
| `options` | `ImageEditorOptions` | Editor configuration: `projectId`, `user`, `features`, `theme`, `locale`, `translations`, `env`, `offline`, `licenseUrl`, `defaultPrompt`, `autoSubmitPrompt`, `aiAssistantOpenState`. |
| `editorId` | `string` | id for the container div. Cosmetic — the editor mounts by element reference. |
| `minHeight` | `number \| string` | Minimum height of the editor container. Defaults to `500`. Pass `0` (or `'100%'`) in a full-height layout so the editor fits a phone-sized viewport instead of overflowing it. |
| `style` | `CSSProperties` | Styles applied to the container div. Overrides the default `flex: 1`. |
| `className` | `string` | Class name for the container div — the hook for a breakpoint or a stylesheet. |
| `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. |
| `wrapperClassName` | `string` | Class name for the outer wrapper div. |
| `ariaLabel` | `string` | Accessible name for the editor region. Defaults to `'Image editor'`. |
| `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. |
| `onLoadError` | `() => void` | Called when the image fails to load into the canvas (CORS, 404, decode error). |
| `onError` | `(error: Error) => void` | Wrapper-level failures: embed script load, editor creation, or image reset. Falls back to `console.error` when absent. |

## Editor instance (ref)

Expand All @@ -88,6 +90,36 @@ 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. |

## Sizing and mobile

The editor fills the container it is given. It has no desktop/phone layout switch of its own — the
tool rail, panels and toolbar come from the embed bundle — so a small screen is about the three
rules below, plus a breakpoint of your own if you want to rearrange that chrome.

**The wrapper never widens its parent.** A flex item defaults to `min-width: auto`, refusing to
shrink below its content, and this editor's content is very wide: the sticker and shape panels lay
hundreds of tiles out in rows that never wrap. Both the wrapper and the container therefore default
to `min-width: 0`, so a narrow container really does constrain the editor and those wide rows scroll
inside their own panel. (`style` / `wrapperStyle` can put the old behaviour back.)

**Give the container a definite height.** The wrapper owns `minHeight`, which defaults to `500`. In
a full-height layout pass `minHeight={0}` or `minHeight="100%"`; otherwise a phone viewport overflows
and the whole page scrolls.

**Adapt the embed's chrome with a breakpoint.** `className` and `wrapperClassName` reach the container
and the wrapper, so a stylesheet can target what the embed renders inside them:

```css
@media (max-width: 640px) {
.my-editor .image-editor-root .toolbar {
/* ... */
}
}
```

The embed's own class names are not part of this component's API — pin `scriptUrl` to a specific
editor version before depending on them.

## Error handling

Two distinct channels:
Expand Down
17 changes: 15 additions & 2 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ function ImageEditorInner(
scriptUrl,
minHeight = 500,
style = {},
className,
wrapperStyle = {},
wrapperClassName,
ariaLabel = 'Image editor',
} = props;

Expand Down Expand Up @@ -213,25 +215,36 @@ function ImageEditorInner(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, updatableKey]);

// The editor's content is enormously wide: the sticker and shape panels
// lay hundreds of tiles out in rows that never wrap, well over 10,000px of
// min-content width. A flex item refuses to shrink below its content
// unless its min-width is 0, so without the two minWidth defaults below the
// editor stretches whatever contains it — on a phone the page grows a
// horizontal scrollbar and the tool rail is pushed out of view — instead of
// scrolling inside the panel. With them, any container constrains the
// editor and responsive tweaks are a breakpoint away.
return (
<div
className={wrapperClassName}
style={{
flex: 1,
display: 'flex',
minWidth: 0,
minHeight: minHeight,
...wrapperStyle,
}}
>
<div
id={editorId}
ref={containerRef}
className={className}
// The embed renders an unlabelled generic div inside, so without a
// role and a name the whole editing surface is anonymous to
// assistive tech.
role="region"
aria-label={ariaLabel}
// flex first: a default the consumer's style can override.
style={{ flex: 1, ...style }}
// flex and minWidth first: defaults the consumer's style can override.
style={{ flex: 1, minWidth: 0, ...style }}
/>
</div>
);
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,19 @@ export interface ImageEditorProps {
minHeight?: number | string;
/** Styles applied to the container div. Overrides the default `flex: 1`. */
style?: CSSProperties;
/**
* Class name for the container div (the element the editor mounts into).
* Combine with `editorId` (e.g. `#my-editor`) to target the editor from a
* stylesheet and rearrange its chrome below a breakpoint.
*/
className?: string;
/**
* 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.
*/
wrapperStyle?: CSSProperties;
/** Class name for the outer wrapper div. */
wrapperClassName?: string;
/**
* Accessible name for the editor region. The embed renders no landmark
* and no name of its own. Defaults to 'Image editor'.
Expand Down
36 changes: 36 additions & 0 deletions test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,42 @@ it('lets style override the container default and wrapperStyle reach the wrapper
expect(wrapper.style.minHeight).toBe('0px');
});

it('does not let the editor widen its container', async () => {
render(<ImageEditor editorId="narrow" image="img-a" />);
await flush();

const container = document.querySelector('#narrow') as HTMLElement;
const wrapper = container.parentElement as HTMLElement;

// Both are flex items, and a flex item defaults to min-width:auto — it
// refuses to shrink below its content. The editor's content is far wider
// than any phone (the sticker and shape panels lay hundreds of tiles out
// in rows that never wrap), so without an explicit 0 the editor stretches
// its container instead of fitting it.
expect(container.style.minWidth).toBe('0px');
expect(wrapper.style.minWidth).toBe('0px');
});

it('reaches both elements with a class name, and lets style override minWidth', async () => {
render(
<ImageEditor
editorId="hooked"
image="img-a"
className="my-editor"
wrapperClassName="my-editor-shell"
style={{ minWidth: 320 }}
/>
);
await flush();

const container = document.querySelector('#hooked') as HTMLElement;
const wrapper = container.parentElement as HTMLElement;
expect(container.className).toBe('my-editor');
expect(wrapper.className).toBe('my-editor-shell');
// The min-width:0 above is a default, not a hardcoded override.
expect(container.style.minWidth).toBe('320px');
});

it('gives the editor region an accessible name, overridable via ariaLabel', async () => {
const { rerender } = render(<ImageEditor editorId="a11y" image="img-a" />);
await flush();
Expand Down