Skip to content
UI Kit
Accent color

AI image editor

Editing a picture with AI. Paint over an area and say what goes there, compare before and after, choose one of several results for the area, step back through the edits and tune the look with sliders. Every edited picture is marked, nothing is kept until you decide.

5 variantsOne of 5 components in AI writing and creation

Change one area, keep the rest

An AI image editor changes one existing picture region by region instead of generating a new one. Nothing is kept until the person decides, and every edited picture carries an AI mark, so the result stays honest wherever it ends up.

Paint and replace has brush and eraser on the picture, ready made areas for the keyboard and a prompt for what goes there, the result grows out of the marked area. Before and after compares with a slider that runs with pointer and arrow keys, or shows both pictures side by side, and the edited area can be outlined. Choose a result lays four results for the same area next to the original, with a zoom on the area and new results on demand. Edit history lists every edit as a step with tool, prompt and time, any step can be restored and later steps are set aside, not deleted. Tune the look offers presets and sliders for warmth, contrast, color and softness, and holding the button shows the original.

Let people mark the area first and write the prompt second, the result then matches what they meant. Keep keep and discard as two equal buttons. The editor draws the interface, you connect the image model of your choice through props.

Variants

Paint and replaceNew in 1.2

Brush and eraser on the picture, ready made areas for the keyboard, a prompt for what goes there. The result grows out of the marked area and stays only when you keep it.

Before and afterNew in 1.2

A slider that runs with pointer and arrow keys, or both pictures side by side. The edited area can be outlined, keeping or discarding is your call.

Choose a resultNew in 1.2

Four results for the same area next to the original, a zoom on the area and new results on demand. Arrow keys move through the choice.

Edit historyNew in 1.2

Every edit as a step with tool, prompt and time. Any step can be viewed and restored, later steps are set aside, not deleted. A rail of thumbnails on phones.

Tune the lookNew in 1.2

Looks as presets, sliders for warmth, contrast, color and softness with a rough live preview, the composition kept on request. Holding the button shows the original.

Details

What AI image editor brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/ai-image-editor.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 65 texts of its own
Studio
Adjust in code

Props

The AiImageEditorProps interface from components/ui/ai-image-editor.tsx, with the comments from the code.

  • variantoptional
    AiImageEditorVariant

    inpaintbefore-aftervariantshistorystyle-controls

  • titleoptional
    string
  • descriptionoptional
    string
  • imageoptional
    React.ReactNode

    The image before the edit

  • resultoptional
    React.ReactNode

    The image after the edit

  • promptoptional
    string

    The edit in words

  • maskoptional
    ImageEditMask

    The edited area

  • regionsoptional
    ImageEditRegion[]

    inpaint: areas to select without drawing

  • optionsoptional
    ImageEditOption[]

    variants: results for the area to choose from

  • stepsoptional
    ImageEditStep[]

    history: the edits so far, the first is the original

  • stylesoptional
    ImageEditStyle[]

    style-controls

  • presetsoptional
    ImageEditPreset[]
  • modeloptional
    string

    e.g. "Atlas Image 3"

  • costoptional
    string

    Credits one run costs, shown on the button

  • noteoptional
    string

    Line under the editor, e.g. on usage rights

  • onGenerateoptional
    (prompt: string, mask: ImageEditMask | null) => void
  • onActionoptional
    (action: 'keep' | 'discard' | 'apply' | 'restore', id?: string) => void

    Keep, discard, apply an option, restore a step

Built-in texts

AI image editor writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 65 texts
canvasEnglish Image to editGerman Bild zum Bearbeiten
toolsEnglish ToolsGerman Werkzeuge
brushEnglish BrushGerman Pinsel
eraserEnglish EraserGerman Radierer
sizeEnglish Brush sizeGerman Pinselgröße
undoEnglish Undo strokeGerman Strich zurücknehmen
clearEnglish Clear areaGerman Bereich leeren
areasEnglish Select an areaGerman Bereich wählen
areaOnEnglish {name} selectedGerman {name} gewählt
paintHintEnglish Paint over the part you want to changeGerman Male über den Teil, der sich ändern soll
markedEnglish Area markedGerman Bereich markiert
noMaskEnglish Mark an area firstGerman Markier zuerst einen Bereich
askEnglish What should go there?German Was soll dort hin?
askPlaceholderEnglish A tall plant in a stone potGerman Eine hohe Pflanze im Steintopf
generateEnglish GenerateGerman Erzeugen
generatingEnglish GeneratingGerman Wird erzeugt
keepEnglish KeepGerman Behalten
keptEnglish KeptGerman Behalten
discardEnglish DiscardGerman Verwerfen
discardedEnglish Back to the originalGerman Zurück zum Original
compareEnglish Hold to see the originalGerman Gedrückt halten für das Original
compareShortEnglish OriginalGerman Original
aiEditedEnglish Edited with AIGerman Mit KI bearbeitet
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
dividerEnglish Compare before and afterGerman Vorher und nachher vergleichen
sliderEnglish SliderGerman Regler
sideBySideEnglish Side by sideGerman Nebeneinander
viewEnglish ViewGerman Ansicht
showAreaEnglish Show the edited areaGerman Bearbeiteten Bereich zeigen
editEnglish The editGerman Die Änderung
modelEnglish ModelGerman Modell
decideEnglish Your decisionGerman Deine Entscheidung
decideTextEnglish Nothing changes in your files until you keep the result.German An deinen Dateien ändert sich erst etwas, wenn du das Ergebnis behältst.
optionsEnglish Results for this areaGerman Ergebnisse für diesen Bereich
optionEnglish Use {name}German {name} verwenden
originalEnglish OriginalGerman Original
keepOriginalEnglish Keep the originalGerman Original behalten
moreEnglish New resultsGerman Neue Ergebnisse
applyEnglish Use this resultGerman Dieses Ergebnis verwenden
appliedEnglish Result in the imageGerman Ergebnis im Bild
zoomEnglish Show only the areaGerman Nur den Bereich zeigen
stepsEnglish EditsGerman Bearbeitungen
stepEnglish Step {n}German Schritt {n}
currentEnglish CurrentGerman Aktuell
viewingEnglish Viewing step {n}German Ansicht von Schritt {n}
restoreEnglish Restore this stepGerman Diesen Schritt wiederherstellen
restoredEnglish Restored step {n}. Later edits are kept below, in case you change your mind.German Schritt {n} wiederhergestellt. Spätere Bearbeitungen bleiben unten erhalten, falls du es dir anders überlegst.
toCurrentEnglish Back to the latestGerman Zur neuesten Fassung
droppedEnglish Set asideGerman Beiseitegelegt
redoEnglish Bring backGerman Zurückholen
original_English OriginalGerman Original
eraseEnglish RemovedGerman Entfernt
fillEnglish Filled inGerman Ergänzt
expandEnglish ExtendedGerman Erweitert
styleEnglish StyleGerman Stil
upscaleEnglish UpscaledGerman Vergrößert
presetsEnglish LooksGerman Looks
resetEnglish ResetGerman Zurücksetzen
previewEnglish Rough preview. The real result comes when you generate.German Grobe Vorschau. Das echte Ergebnis entsteht beim Erzeugen.
seedEnglish Keep the compositionGerman Bildaufbau beibehalten
seedTextEnglish Same layout and objects, only the look changes.German Gleicher Aufbau und gleiche Dinge, nur der Look ändert sich.
generateWithEnglish Generate with these settingsGerman Mit diesen Werten erzeugen
doneEnglish New version readyGerman Neue Fassung fertig
honestEnglish AI can invent details. Check faces, text and signs before you use the image.German KI kann Details erfinden. Prüfe Gesichter, Schrift und Schilder, bevor du das Bild nutzt.

Accessibility

Read from the source of the component.

  • AI image editor handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Page up, Page down and Space itself.
  • Sets aria-checked, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles group, img, radio, radiogroup and slider.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,226 lines
components/ui/ai-image-editor.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { useScrollFade } from '@/components/ui/motion';

export type AiImageEditorVariant = 'inpaint' | 'before-after' | 'variants' | 'history' | 'style-controls';

/** An area of the image in percent of its width and height */
export interface ImageEditMask {
  x: number;
  y: number;
  w: number;
  h: number;
  shape?: 'ellipse' | 'rect';
}

/** inpaint: a ready made area to select with one click or the keyboard */
export interface ImageEditRegion {
  id: string;
  label: string;
  mask: ImageEditMask;
}

/** variants: one result for the masked area */
export interface ImageEditOption {
  id: string;
  label: string;
  media?: React.ReactNode;
}

export type ImageEditTool = 'original' | 'erase' | 'fill' | 'expand' | 'style' | 'upscale';

/** history: one edit, oldest first */
export interface ImageEditStep {
  id: string;
  tool: ImageEditTool;
  /** What was asked, e.g. "Remove the boat" */
  prompt: string;
  /** e.g. "10:42" */
  time?: string;
  media?: React.ReactNode;
}

/** style-controls: one slider */
export interface ImageEditStyle {
  id: string;
  label: string;
  /** 0 to 100 */
  value: number;
  /** Words at both ends of the slider, e.g. "Close to the original" and "Free" */
  low?: string;
  high?: string;
  /** What the slider changes in the rough preview */
  effect?: 'warmth' | 'contrast' | 'saturation' | 'softness' | 'none';
}

/** style-controls: a named set of slider values */
export interface ImageEditPreset {
  id: string;
  label: string;
  values: Record<string, number>;
}

export interface AiImageEditorProps {
  variant?: AiImageEditorVariant;
  title?: string;
  description?: string;
  /** The image before the edit */
  image?: React.ReactNode;
  /** The image after the edit */
  result?: React.ReactNode;
  /** The edit in words */
  prompt?: string;
  /** The edited area */
  mask?: ImageEditMask;
  /** inpaint: areas to select without drawing */
  regions?: ImageEditRegion[];
  /** variants: results for the area to choose from */
  options?: ImageEditOption[];
  /** history: the edits so far, the first is the original */
  steps?: ImageEditStep[];
  /** style-controls */
  styles?: ImageEditStyle[];
  presets?: ImageEditPreset[];
  /** e.g. "Atlas Image 3" */
  model?: string;
  /** Credits one run costs, shown on the button */
  cost?: string;
  /** Line under the editor, e.g. on usage rights */
  note?: string;
  onGenerate?: (prompt: string, mask: ImageEditMask | null) => void;
  /** Keep, discard, apply an option, restore a step */
  onAction?: (action: 'keep' | 'discard' | 'apply' | 'restore', id?: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    canvas: 'Image to edit',
    tools: 'Tools',
    brush: 'Brush',
    eraser: 'Eraser',
    size: 'Brush size',
    undo: 'Undo stroke',
    clear: 'Clear area',
    areas: 'Select an area',
    areaOn: '{name} selected',
    paintHint: 'Paint over the part you want to change',
    marked: 'Area marked',
    noMask: 'Mark an area first',
    ask: 'What should go there?',
    askPlaceholder: 'A tall plant in a stone pot',
    generate: 'Generate',
    generating: 'Generating',
    keep: 'Keep',
    kept: 'Kept',
    discard: 'Discard',
    discarded: 'Back to the original',
    compare: 'Hold to see the original',
    compareShort: 'Original',
    aiEdited: 'Edited with AI',
    before: 'Before',
    after: 'After',
    divider: 'Compare before and after',
    slider: 'Slider',
    sideBySide: 'Side by side',
    view: 'View',
    showArea: 'Show the edited area',
    edit: 'The edit',
    model: 'Model',
    decide: 'Your decision',
    decideText: 'Nothing changes in your files until you keep the result.',
    options: 'Results for this area',
    option: 'Use {name}',
    original: 'Original',
    keepOriginal: 'Keep the original',
    more: 'New results',
    apply: 'Use this result',
    applied: 'Result in the image',
    zoom: 'Show only the area',
    steps: 'Edits',
    step: 'Step {n}',
    current: 'Current',
    viewing: 'Viewing step {n}',
    restore: 'Restore this step',
    restored: 'Restored step {n}. Later edits are kept below, in case you change your mind.',
    toCurrent: 'Back to the latest',
    dropped: 'Set aside',
    redo: 'Bring back',
    original_: 'Original',
    erase: 'Removed',
    fill: 'Filled in',
    expand: 'Extended',
    style: 'Style',
    upscale: 'Upscaled',
    presets: 'Looks',
    reset: 'Reset',
    preview: 'Rough preview. The real result comes when you generate.',
    seed: 'Keep the composition',
    seedText: 'Same layout and objects, only the look changes.',
    generateWith: 'Generate with these settings',
    done: 'New version ready',
    honest: 'AI can invent details. Check faces, text and signs before you use the image.',
  },
  de: {
    canvas: 'Bild zum Bearbeiten',
    tools: 'Werkzeuge',
    brush: 'Pinsel',
    eraser: 'Radierer',
    size: 'Pinselgröße',
    undo: 'Strich zurücknehmen',
    clear: 'Bereich leeren',
    areas: 'Bereich wählen',
    areaOn: '{name} gewählt',
    paintHint: 'Male über den Teil, der sich ändern soll',
    marked: 'Bereich markiert',
    noMask: 'Markier zuerst einen Bereich',
    ask: 'Was soll dort hin?',
    askPlaceholder: 'Eine hohe Pflanze im Steintopf',
    generate: 'Erzeugen',
    generating: 'Wird erzeugt',
    keep: 'Behalten',
    kept: 'Behalten',
    discard: 'Verwerfen',
    discarded: 'Zurück zum Original',
    compare: 'Gedrückt halten für das Original',
    compareShort: 'Original',
    aiEdited: 'Mit KI bearbeitet',
    before: 'Vorher',
    after: 'Nachher',
    divider: 'Vorher und nachher vergleichen',
    slider: 'Regler',
    sideBySide: 'Nebeneinander',
    view: 'Ansicht',
    showArea: 'Bearbeiteten Bereich zeigen',
    edit: 'Die Änderung',
    model: 'Modell',
    decide: 'Deine Entscheidung',
    decideText: 'An deinen Dateien ändert sich erst etwas, wenn du das Ergebnis behältst.',
    options: 'Ergebnisse für diesen Bereich',
    option: '{name} verwenden',
    original: 'Original',
    keepOriginal: 'Original behalten',
    more: 'Neue Ergebnisse',
    apply: 'Dieses Ergebnis verwenden',
    applied: 'Ergebnis im Bild',
    zoom: 'Nur den Bereich zeigen',
    steps: 'Bearbeitungen',
    step: 'Schritt {n}',
    current: 'Aktuell',
    viewing: 'Ansicht von Schritt {n}',
    restore: 'Diesen Schritt wiederherstellen',
    restored: 'Schritt {n} wiederhergestellt. Spätere Bearbeitungen bleiben unten erhalten, falls du es dir anders überlegst.',
    toCurrent: 'Zur neuesten Fassung',
    dropped: 'Beiseitegelegt',
    redo: 'Zurückholen',
    original_: 'Original',
    erase: 'Entfernt',
    fill: 'Ergänzt',
    expand: 'Erweitert',
    style: 'Stil',
    upscale: 'Vergrößert',
    presets: 'Looks',
    reset: 'Zurücksetzen',
    preview: 'Grobe Vorschau. Das echte Ergebnis entsteht beim Erzeugen.',
    seed: 'Bildaufbau beibehalten',
    seedText: 'Gleicher Aufbau und gleiche Dinge, nur der Look ändert sich.',
    generateWith: 'Mit diesen Werten erzeugen',
    done: 'Neue Fassung fertig',
    honest: 'KI kann Details erfinden. Prüfe Gesichter, Schrift und Schilder, bevor du das Bild nutzt.',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiImageEditorProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value, (k, v) => (k === 'media' ? undefined : v));
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes aie-ants{to{stroke-dashoffset:-16}}@keyframes aie-up{from{opacity:0;transform:translateY(6px)}}@keyframes aie-sheen{from{transform:translateX(-120%)}to{transform:translateX(120%)}}@keyframes aie-develop{from{filter:blur(14px) saturate(.5)}}' +
  '@media (prefers-reduced-motion:no-preference){.aie-ants{animation:aie-ants 1.2s linear infinite}.aie-up{animation:aie-up .4s var(--ease-kit) var(--aie-d,0ms) both}.aie-sheen{animation:aie-sheen 1.5s ease-in-out infinite}.aie-develop{animation:aie-develop 1s var(--ease-kit) both}.aie-reveal{transition:clip-path 1s var(--ease-kit)}}';

function Style() {
  return (
    <style href="kit-ai-image-editor" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const TOOL_ICON: Record<ImageEditTool, IconName> = { original: 'image', erase: 'eraser', fill: 'brush', expand: 'maximize', style: 'palette', upscale: 'zoom-in' };

const btn = 'inline-flex h-11 shrink-0 items-center whitespace-nowrap justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium transition-[background-color,color,opacity] duration-200 disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

function Stage({ title, description, children, className }: { title?: string; description?: string; children: React.ReactNode; className?: string }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className="mx-auto w-full max-w-[68rem]">
          {(title || description) && (
            <Reveal className="mb-6 sm:mb-8">
              {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** The mark that says the picture was changed by AI */
function AiMark({ t, className }: { t: Texts; className?: string }) {
  return (
    <span className={cn('pointer-events-none absolute bottom-2.5 left-2.5 z-[3] inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,black_58%,transparent)] px-1.5 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm', className)}>
      <Icon name="sparkle" size={11} />
      {t.aiEdited}
    </span>
  );
}

/** A label on the image, top left or right */
function Tag({ children, side = 'left', className }: { children: React.ReactNode; side?: 'left' | 'right'; className?: string }) {
  return <span className={cn('pointer-events-none absolute top-2.5 z-[3] rounded-md bg-[color-mix(in_oklab,black_58%,transparent)] px-2 py-1 text-[11.5px] font-medium text-white backdrop-blur-sm', side === 'left' ? 'left-2.5' : 'right-2.5', className)}>{children}</span>;
}

/** The image fills its layer like object-fit cover */
function Layer({ children, className, style }: { children?: React.ReactNode; className?: string; style?: React.CSSProperties }) {
  return (
    <div data-clip-ok className={cn('absolute inset-0 [&>*]:size-full', className)} style={style}>
      {children}
    </div>
  );
}

/** The outline of the edited area with marching ants */
function MaskOutline({ mask, fillArea = false }: { mask: ImageEditMask; fillArea?: boolean }) {
  const common = { fill: fillArea ? 'color-mix(in oklab, var(--primary) 26%, transparent)' : 'none', stroke: 'white', strokeWidth: 1.6, vectorEffect: 'non-scaling-stroke' as const };
  const shape = (extra: React.SVGProps<SVGEllipseElement & SVGRectElement>) =>
    mask.shape === 'rect' ? <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} rx={1.5} {...common} {...extra} /> : <ellipse cx={round(mask.x + mask.w / 2)} cy={round(mask.y + mask.h / 2)} rx={round(mask.w / 2)} ry={round(mask.h / 2)} {...common} {...extra} />;
  return (
    <svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 z-[2] size-full">
      {shape({ stroke: 'rgb(0 0 0 / 0.55)', fill: common.fill })}
      {shape({ strokeDasharray: '5 3', className: 'aie-ants', fill: 'none' } as never)}
    </svg>
  );
}

/** Grain and a sheen over an area while it renders */
function Rendering({ mask, progress }: { mask?: ImageEditMask | null; progress?: number }) {
  const box = mask ?? { x: 0, y: 0, w: 100, h: 100 };
  return (
    <div aria-hidden className="pointer-events-none absolute inset-0 z-[2]">
      <div className={cn('absolute overflow-hidden bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] backdrop-blur-md', mask?.shape === 'rect' ? 'rounded-md' : 'rounded-[50%]')} style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }}>
        <div className="absolute inset-0 bg-[radial-gradient(rgb(255_255_255/0.28)_1px,transparent_1.5px)] [background-size:6px_6px]" />
        <div className="aie-sheen absolute inset-y-0 w-2/3 bg-[linear-gradient(100deg,transparent,rgb(255_255_255/0.35),transparent)]" />
      </div>
      {progress !== undefined && (
        <div className="absolute inset-x-3 bottom-3 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,black_40%,transparent)]">
          <div className="h-full origin-left rounded-full bg-primary transition-transform duration-150" style={{ transform: `scaleX(${round(progress / 100, 3)})` }} />
        </div>
      )}
    </div>
  );
}

/** Runs a fake render with progress, then calls done */
function useRender() {
  const [progress, setProgress] = useState<number | null>(null);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearInterval(timer.current), []);
  const start = (done: () => void) => {
    if (prefersReducedMotion()) return done();
    setProgress(0);
    window.clearInterval(timer.current);
    let p = 0;
    timer.current = window.setInterval(() => {
      p += 5;
      if (p < 100) return setProgress(p);
      window.clearInterval(timer.current);
      setProgress(null);
      done();
    }, 60);
  };
  return { progress, busy: progress !== null, start };
}

/** Center of a mask in percent */
const center = (m?: ImageEditMask | null) => (m ? { x: round(m.x + m.w / 2), y: round(m.y + m.h / 2) } : { x: 50, y: 50 });

/** A button you hold to see the original */
function HoldCompare({ t, on, className }: { t: Texts; on: (v: boolean) => void; className?: string }) {
  return (
    <button
      type="button"
      onPointerDown={() => on(true)}
      onPointerUp={() => on(false)}
      onPointerLeave={() => on(false)}
      onPointerCancel={() => on(false)}
      onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && (e.preventDefault(), on(true))}
      onKeyUp={() => on(false)}
      onBlur={() => on(false)}
      title={t.compare}
      className={cn(quietBtn, 'select-none touch-none', className)}
    >
      <Icon name="eye" size={15} />
      <span>{t.compareShort}</span>
      <span className="sr-only">. {t.compare}</span>
    </button>
  );
}

function Note({ children }: { children: React.ReactNode }) {
  return (
    <p className="mt-4 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
      <Icon name="info" size={14} className="mt-px shrink-0" />
      <span>{children}</span>
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* inpaint                                                             */
/* ------------------------------------------------------------------ */

type Stroke = { id: number; erase: boolean; size: number; pts: [number, number][] } | { id: number; erase: false; region: ImageEditMask };
const VW = 400;
const VH = 300;

const strokePath = (pts: [number, number][]) => (pts.length === 1 ? `M${pts[0][0]} ${pts[0][1]}l0.01 0` : pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(''));

/** Bounding box of the painted strokes in percent */
function bounds(strokes: Stroke[]): ImageEditMask | null {
  let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
  for (const s of strokes) {
    if (s.erase) continue;
    if ('region' in s) {
      const r = s.region;
      x0 = Math.min(x0, r.x);
      y0 = Math.min(y0, r.y);
      x1 = Math.max(x1, r.x + r.w);
      y1 = Math.max(y1, r.y + r.h);
      continue;
    }
    for (const [x, y] of s.pts) {
      const rx = (s.size / 2 / VW) * 100;
      const ry = (s.size / 2 / VH) * 100;
      x0 = Math.min(x0, (x / VW) * 100 - rx);
      y0 = Math.min(y0, (y / VH) * 100 - ry);
      x1 = Math.max(x1, (x / VW) * 100 + rx);
      y1 = Math.max(y1, (y / VH) * 100 + ry);
    }
  }
  if (!Number.isFinite(x0)) return null;
  const c = (n: number) => Math.max(0, Math.min(100, n));
  return { x: round(c(x0)), y: round(c(y0)), w: round(c(x1) - c(x0)), h: round(c(y1) - c(y0)), shape: 'ellipse' };
}

function Inpaint({ t, title, description, image, result, prompt = '', mask, regions = [], cost, note, onGenerate, onAction, className }: P) {
  const uid = useId();
  const seq = useRef(1);
  const initial: Stroke[] = mask ? [{ id: 0, erase: false, region: mask }] : [];
  const [strokes, setStrokes] = useSynced(initial);
  const [tool, setTool] = useState<'brush' | 'eraser'>('brush');
  const [size, setSize] = useState(36);
  const [text, setText] = useState(prompt);
  const [done, setDone] = useState<'shown' | 'kept' | 'discarded' | null>(null);
  const [reveal, setReveal] = useState(false);
  const [peek, setPeek] = useState(false);
  const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
  const svg = useRef<SVGSVGElement>(null);
  const drawing = useRef<number | null>(null);
  const run = useRender();
  const box = bounds(strokes);
  const c = center(box);

  const point = (e: React.PointerEvent): [number, number] => {
    const r = svg.current!.getBoundingClientRect();
    return [round(((e.clientX - r.left) / r.width) * VW, 1), round(((e.clientY - r.top) / r.height) * VH, 1)];
  };
  const down = (e: React.PointerEvent<SVGSVGElement>) => {
    if (run.busy || e.button !== 0) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const id = seq.current++;
    drawing.current = id;
    if (done) (setDone(null), setReveal(false));
    setStrokes((s) => [...s, { id, erase: tool === 'eraser', size, pts: [point(e)] }]);
  };
  const move = (e: React.PointerEvent<SVGSVGElement>) => {
    if (e.pointerType === 'mouse') {
      const r = svg.current!.getBoundingClientRect();
      setCursor({ x: e.clientX - r.left, y: e.clientY - r.top });
    }
    const id = drawing.current;
    if (id === null) return;
    const p = point(e);
    setStrokes((s) => s.map((x) => (x.id === id && 'pts' in x ? { ...x, pts: [...x.pts, p] } : x)));
  };
  const up = () => {
    drawing.current = null;
  };
  const pickRegion = (r: ImageEditRegion) => {
    if (done) (setDone(null), setReveal(false));
    setStrokes((s) => [...s, { id: seq.current++, erase: false, region: r.mask }]);
  };
  const generate = () => {
    if (!box || !text.trim() || run.busy) return;
    onGenerate?.(text, box);
    setDone(null);
    setReveal(false);
    run.start(() => {
      setDone('shown');
      requestAnimationFrame(() => requestAnimationFrame(() => setReveal(true)));
    });
  };
  const showResult = done === 'shown' || done === 'kept';
  const scale = svg.current ? svg.current.getBoundingClientRect().width / VW : 1;

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[auto_minmax(0,1fr)_19rem] lg:items-start lg:gap-5">
        {/* Tools: a rail on the desktop, a row on tablets and phones */}
        <Reveal delay={40} className="lg:sticky lg:top-6">
          <div role="group" aria-label={t.tools} className={cn(card, 'flex items-center gap-1 p-1.5 lg:flex-col')}>
            {(['brush', 'eraser'] as const).map((k) => (
              <button key={k} type="button" aria-pressed={tool === k} aria-label={t[k]} title={t[k]} onClick={() => setTool(k)} className={cn('grid size-11 place-items-center rounded-lg transition-colors', tool === k ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
                <Icon name={k === 'brush' ? 'brush' : 'eraser'} size={18} />
              </button>
            ))}
            <span aria-hidden className="mx-1 h-6 w-px bg-border lg:mx-0 lg:my-1 lg:h-px lg:w-6" />
            <label className="flex min-w-0 flex-1 items-center gap-2 px-1 lg:hidden">
              <span className="sr-only">{t.size}</span>
              <Icon name="circle" size={10} className="shrink-0 text-muted-foreground" />
              <input type="range" min={12} max={80} value={size} onChange={(e) => setSize(Number(e.target.value))} className="h-11 w-full min-w-0 accent-[var(--primary)]" />
              <Icon name="circle" size={18} className="shrink-0 text-muted-foreground" />
            </label>
            <button type="button" aria-label={t.undo} title={t.undo} disabled={!strokes.length || run.busy} onClick={() => setStrokes((s) => s.slice(0, -1))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40">
              <Icon name="undo" size={18} />
            </button>
            <button type="button" aria-label={t.clear} title={t.clear} disabled={!strokes.length || run.busy} onClick={() => (setStrokes([]), setDone(null), setReveal(false))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40">
              <Icon name="trash" size={18} />
            </button>
          </div>
        </Reveal>

        <Reveal effect="scale" delay={80} className="min-w-0">
          <div className={cn(card, 'p-2')}>
            <div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2" aria-label={t.canvas} role="img">
              <Layer>{image}</Layer>
              {showResult && result && (
                <Layer className={cn('aie-reveal z-[1]', peek && 'invisible opacity-0')} style={{ clipPath: reveal ? `circle(150% at ${c.x}% ${c.y}%)` : `circle(0% at ${c.x}% ${c.y}%)` }}>
                  {result}
                </Layer>
              )}
              {/* The painted area, brush strokes with eraser strokes cut out */}
              <svg
                ref={svg}
                viewBox={`0 0 ${VW} ${VH}`}
                preserveAspectRatio="none"
                aria-hidden
                onPointerDown={down}
                onPointerMove={move}
                onPointerUp={up}
                onPointerCancel={up}
                onPointerLeave={() => setCursor(null)}
                className={cn('absolute inset-0 z-[2] size-full touch-none transition-opacity duration-300', showResult ? 'pointer-events-none opacity-0' : 'cursor-none')}
              >
                <defs>
                  <mask id={`${uid}-m`} maskUnits="userSpaceOnUse" x="0" y="0" width={VW} height={VH}>
                    <rect width={VW} height={VH} fill="white" />
                    {strokes.map((s) => s.erase && 'pts' in s && <path key={s.id} d={strokePath(s.pts)} stroke="black" strokeWidth={s.size} strokeLinecap="round" strokeLinejoin="round" fill="none" />)}
                  </mask>
                </defs>
                <g mask={`url(#${uid}-m)`} opacity="0.55">
                  {strokes.map((s) =>
                    s.erase ? null : 'region' in s ? (
                      <ellipse key={s.id} cx={round(((s.region.x + s.region.w / 2) / 100) * VW)} cy={round(((s.region.y + s.region.h / 2) / 100) * VH)} rx={round((s.region.w / 200) * VW)} ry={round((s.region.h / 200) * VH)} fill="var(--primary)" />
                    ) : (
                      <path key={s.id} d={strokePath(s.pts)} stroke="var(--primary)" strokeWidth={s.size} strokeLinecap="round" strokeLinejoin="round" fill="none" />
                    ),
                  )}
                </g>
              </svg>
              {cursor && !showResult && !run.busy && (
                <span aria-hidden className="pointer-events-none absolute z-[3] rounded-full border-[1.5px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.5)]" style={{ left: cursor.x, top: cursor.y, width: round(size * scale), height: round(size * scale), transform: 'translate(-50%,-50%)' }} />
              )}
              {run.busy && <Rendering mask={box} progress={run.progress ?? undefined} />}
              {!strokes.length && !showResult && (
                <span className="pointer-events-none absolute inset-x-0 top-1/2 z-[3] mx-auto w-fit -translate-y-1/2 rounded-lg bg-[color-mix(in_oklab,black_58%,transparent)] px-3 py-2 text-[13px] font-medium text-white backdrop-blur-sm">
                  <Icon name="brush" size={14} className="mr-1.5 inline align-[-2px]" />
                  {t.paintHint}
                </span>
              )}
              {showResult && !peek && <AiMark t={t} />}
              {showResult && peek && <Tag>{t.original}</Tag>}
            </div>
            <div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
              <label className="hidden min-w-0 flex-1 items-center gap-2.5 lg:flex">
                <span className="text-[12.5px] text-muted-foreground">{t.size}</span>
                <input type="range" min={12} max={80} value={size} onChange={(e) => setSize(Number(e.target.value))} className="h-9 w-full max-w-[12rem] min-w-0 accent-[var(--primary)]" />
                <span className="w-8 text-[12.5px] tabular-nums text-muted-foreground">{size}</span>
              </label>
              <p className="min-w-0 flex-1 text-[12.5px] text-muted-foreground lg:hidden" aria-live="polite">
                {box ? t.marked : t.noMask}
              </p>
              {showResult && <HoldCompare t={t} on={setPeek} />}
            </div>
          </div>
        </Reveal>

        <Reveal delay={160} className="space-y-4 md:grid md:grid-cols-2 md:items-start md:gap-4 md:space-y-0 lg:sticky lg:top-6 lg:block lg:space-y-4">
          {regions.length > 0 && (
            <div className={cn(card, 'p-4')}>
              <p className="text-[13px] font-medium text-foreground">{t.areas}</p>
              <div className="mt-3 flex flex-wrap gap-2">
                {regions.map((r) => (
                  <button key={r.id} type="button" disabled={run.busy} onClick={() => pickRegion(r)} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 sm:min-h-9">
                    <Icon name="target" size={14} className="text-muted-foreground" />
                    {r.label}
                  </button>
                ))}
              </div>
            </div>
          )}
          <form
            className={cn(card, 'p-4')}
            onSubmit={(e) => {
              e.preventDefault();
              generate();
            }}
          >
            <label htmlFor={`${uid}-ask`} className="text-[13px] font-medium text-foreground">
              {t.ask}
            </label>
            <textarea id={`${uid}-ask`} rows={3} value={text} onChange={(e) => setText(e.target.value)} placeholder={t.askPlaceholder} className="mt-2 block w-full resize-none rounded-lg border border-border bg-background px-3 py-2.5 text-[14px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]" />
            <p className="mt-2 hidden items-center gap-1.5 text-[12.5px] text-muted-foreground lg:flex" aria-live="polite">
              <span className={cn('size-1.5 rounded-full', box ? 'bg-primary' : 'bg-muted-foreground')} />
              {box ? t.marked : t.noMask}
            </p>
            <button type="submit" disabled={!box || !text.trim() || run.busy} className={cn(primaryBtn, 'mt-3 w-full')}>
              <Icon name={run.busy ? 'loader' : 'sparkle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
              {run.busy ? t.generating : t.generate}
              {cost && !run.busy && <span className="rounded-md bg-[rgb(255_255_255/0.18)] px-1.5 py-px text-[11.5px] tabular-nums">{cost}</span>}
            </button>
            {showResult && (
              <div className="aie-up mt-3 grid grid-cols-2 gap-2" aria-live="polite">
                <button type="button" onClick={() => (setDone(null), setReveal(false), onAction?.('discard'))} className={quietBtn}>
                  <Icon name="undo" size={15} />
                  {t.discard}
                </button>
                <button type="button" disabled={done === 'kept'} onClick={() => (setDone('kept'), onAction?.('keep'))} className={cn(quietBtn, done === 'kept' && 'text-success-foreground opacity-100!')}>
                  <Icon name="check" size={15} />
                  {done === 'kept' ? t.kept : t.keep}
                </button>
              </div>
            )}
          </form>
        </Reveal>
      </div>
      <Note>{note ?? t.honest}</Note>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* before-after                                                        */
/* ------------------------------------------------------------------ */

function BeforeAfter({ t, title, description, image, result, prompt, mask, model, note, onAction, className }: P) {
  const locale = useLocale();
  const [pos, setPos] = useState(55);
  const [view, setView] = useState<'slider' | 'side'>('slider');
  const [area, setArea] = useState(false);
  const [decision, setDecision] = useState<'keep' | 'discard' | null>(null);
  const frame = useRef<HTMLDivElement>(null);
  const dragging = useRef(false);
  const setFrom = (clientX: number) => {
    const r = frame.current!.getBoundingClientRect();
    setPos(round(Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)), 1));
  };
  const key = (e: React.KeyboardEvent) => {
    const step = e.shiftKey ? 10 : 2;
    const next = e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? pos - step : e.key === 'ArrowRight' || e.key === 'ArrowUp' ? pos + step : e.key === 'Home' ? 0 : e.key === 'End' ? 100 : e.key === 'PageDown' ? pos - 10 : e.key === 'PageUp' ? pos + 10 : null;
    if (next === null) return;
    e.preventDefault();
    setPos(round(Math.max(0, Math.min(100, next)), 1));
  };
  const views = [
    { id: 'slider' as const, label: t.slider, icon: 'columns' as IconName },
    { id: 'side' as const, label: t.sideBySide, icon: 'layout' as IconName },
  ];

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-6">
        <Reveal effect="scale" delay={60} className="min-w-0">
          <div className={cn(card, 'p-2')}>
            {view === 'slider' ? (
              <div
                ref={frame}
                className="relative isolate aspect-[4/3] touch-pan-y select-none overflow-hidden rounded-xl bg-surface-2"
                onPointerDown={(e) => {
                  if (e.button !== 0) return;
                  dragging.current = true;
                  e.currentTarget.setPointerCapture(e.pointerId);
                  setFrom(e.clientX);
                }}
                onPointerMove={(e) => dragging.current && setFrom(e.clientX)}
                onPointerUp={() => (dragging.current = false)}
                onPointerCancel={() => (dragging.current = false)}
              >
                <Layer>{image}</Layer>
                <Layer className="z-[1]" style={{ clipPath: `inset(0 0 0 ${pos}%)` }}>
                  {result}
                </Layer>
                {area && mask && <MaskOutline mask={mask} />}
                <Tag className={cn('transition-opacity duration-200', pos < 14 && 'invisible opacity-0')}>{t.before}</Tag>
                <Tag side="right" className={cn('transition-opacity duration-200', pos > 86 && 'invisible opacity-0')}>{t.after}</Tag>
                <AiMark t={t} className={cn('left-auto right-2.5 transition-opacity duration-200', pos > 70 && 'invisible opacity-0')} />
                <div aria-hidden className="pointer-events-none absolute inset-y-0 z-[3] w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.25)]" style={{ left: `${pos}%` }} />
                <button
                  type="button"
                  role="slider"
                  aria-label={t.divider}
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={Math.round(pos)}
                  aria-valuetext={`${formatPercent(Math.round(100 - pos), locale)} ${t.after}`}
                  onKeyDown={key}
                  className="absolute top-1/2 z-[4] grid size-11 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize place-items-center rounded-full bg-white text-[#111] shadow-[0_2px_10px_rgb(0_0_0/0.35)] focus-visible:outline-offset-4"
                  style={{ left: `${pos}%` }}
                >
                  <Icon name="arrows-left-right" size={18} />
                </button>
              </div>
            ) : (
              <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
                {[
                  { label: t.before, media: image, ai: false },
                  { label: t.after, media: result, ai: true },
                ].map((s) => (
                  <figure key={s.label} className="aie-up relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
                    <Layer>{s.media}</Layer>
                    {area && mask && <MaskOutline mask={mask} />}
                    <figcaption>
                      <Tag>{s.label}</Tag>
                    </figcaption>
                    {s.ai && <AiMark t={t} />}
                  </figure>
                ))}
              </div>
            )}
            <div className="flex flex-wrap items-center justify-between gap-2 px-1 pb-0.5 pt-2.5">
              <div role="radiogroup" aria-label={t.view} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
                {views.map((v) => (
                  <button
                    key={v.id}
                    type="button"
                    role="radio"
                    aria-checked={view === v.id}
                    tabIndex={view === v.id ? 0 : -1}
                    onClick={() => setView(v.id)}
                    onKeyDown={(e) => {
                      if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
                      e.preventDefault();
                      const next = view === 'slider' ? 'side' : 'slider';
                      setView(next);
                      (e.currentTarget.parentElement?.querySelector(`[data-v="${next}"]`) as HTMLElement | null)?.focus();
                    }}
                    data-v={v.id}
                    className={cn('inline-flex h-11 items-center gap-1.5 rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', view === v.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
                  >
                    <Icon name={v.icon} size={14} />
                    {v.label}
                  </button>
                ))}
              </div>
              {mask && (
                <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-2 text-[12.5px] font-medium text-muted-foreground hover:text-foreground sm:min-h-9">
                  <input type="checkbox" checked={area} onChange={(e) => setArea(e.target.checked)} className="size-4 accent-[var(--primary)]" />
                  {t.showArea}
                </label>
              )}
            </div>
          </div>
        </Reveal>

        <Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
          <div className={cn(card, 'p-4')}>
            <p className="text-[12px] font-medium text-subtle-foreground">{t.edit}</p>
            {prompt && <p className="mt-1.5 text-pretty text-[14.5px] leading-relaxed text-foreground">{prompt}</p>}
            {model && (
              <dl className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3 text-[12.5px]">
                <dt className="text-muted-foreground">{t.model}</dt>
                <dd className="font-medium text-foreground">{model}</dd>
              </dl>
            )}
          </div>
          <div className={cn(card, 'p-4')}>
            <p className="text-[13px] font-medium text-foreground">{t.decide}</p>
            <p className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground">{t.decideText}</p>
            <div className="mt-3 grid grid-cols-2 gap-2">
              <button type="button" aria-pressed={decision === 'discard'} onClick={() => (setDecision('discard'), setPos(100), onAction?.('discard'))} className={cn(quietBtn, decision === 'discard' && 'border-border-strong bg-accent')}>
                <Icon name="undo" size={15} />
                {t.discard}
              </button>
              <button type="button" aria-pressed={decision === 'keep'} onClick={() => (setDecision('keep'), setPos(0), onAction?.('keep'))} className={primaryBtn}>
                <Icon name="check" size={15} />
                {t.keep}
              </button>
            </div>
            <p className="mt-2 min-h-5 text-[12.5px] font-medium" aria-live="polite">
              {decision === 'keep' && <span className="aie-up inline-flex items-center gap-1.5 text-success-foreground"><Icon name="check-circle" size={14} />{t.kept}</span>}
              {decision === 'discard' && <span className="aie-up inline-flex items-center gap-1.5 text-muted-foreground"><Icon name="undo" size={14} />{t.discarded}</span>}
            </p>
          </div>
        </Reveal>
      </div>
      <Note>{note ?? t.honest}</Note>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* variants                                                            */
/* ------------------------------------------------------------------ */

function Variants({ t, title, description, image, mask, options = [], prompt, cost, note, onAction, className }: P) {
  const uid = useId();
  const [sel, setSel] = useState(1);
  const [round_, setRound] = useState(0);
  const [zoom, setZoom] = useState(false);
  const [applied, setApplied] = useState<string | null>(null);
  const run = useRender();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const all = [{ id: '__original', label: t.original, media: image }, ...Array.from({ length: Math.min(4, options.length) }, (_, i) => options[(i + round_) % options.length])];
  const cur = all[Math.min(sel, all.length - 1)];
  // Zoom on the area: scale the picture so the area fills the tile
  const zoomStyle = (m?: ImageEditMask): React.CSSProperties | undefined => {
    if (!zoom || !m) return undefined;
    const s = round(Math.min(3, 70 / Math.max(m.w, m.h)), 2);
    const c = center(m);
    return { transform: `scale(${s})`, transformOrigin: `${c.x}% ${c.y}%` };
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start lg:gap-6">
        <Reveal effect="scale" delay={60} className="min-w-0">
          <div className={cn(card, 'p-2')}>
            <div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
              <Layer key={`${cur.id}-${round_}`} className={cn('transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', sel > 0 && 'aie-develop')} style={zoomStyle(mask)}>
                {cur.media}
              </Layer>
              {mask && !zoom && <MaskOutline mask={mask} />}
              {run.busy && <Rendering mask={zoom ? null : mask} progress={run.progress ?? undefined} />}
              <Tag>{sel === 0 ? t.original : cur.label}</Tag>
              {sel > 0 && <AiMark t={t} />}
            </div>
            <div className="flex flex-wrap items-center justify-between gap-2 px-1 pb-0.5 pt-2.5">
              {prompt && <p className="min-w-0 flex-1 basis-full text-[13px] leading-snug text-muted-foreground sm:basis-0">{prompt}</p>}
              {mask && (
                <button type="button" aria-pressed={zoom} onClick={() => setZoom((z) => !z)} className={cn(quietBtn, zoom && 'border-border-strong bg-accent')}>
                  <Icon name={zoom ? 'zoom-out' : 'zoom-in'} size={15} />
                  {t.zoom}
                </button>
              )}
            </div>
          </div>
        </Reveal>

        <Reveal delay={160} className="lg:sticky lg:top-6">
          <div className={cn(card, 'p-4')}>
            <p id={`${uid}-o`} className="text-[13px] font-medium text-foreground">{t.options}</p>
            <div role="radiogroup" aria-labelledby={`${uid}-o`} className="mt-3 grid grid-cols-2 gap-x-2 gap-y-2.5 sm:grid-cols-5 lg:grid-cols-2">
              {all.map((o, i) => {
                const on = i === sel;
                return (
                  <button
                    key={`${o.id}-${i === 0 ? 0 : round_}`}
                    ref={(el) => {
                      refs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    aria-label={i === 0 ? t.keepOriginal : fill(t.option, { name: o.label })}
                    tabIndex={on ? 0 : -1}
                    disabled={run.busy}
                    onClick={() => (setSel(i), setApplied(null))}
                    onKeyDown={(e) => {
                      const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                      if (!d) return;
                      e.preventDefault();
                      const n = (i + d + all.length) % all.length;
                      setSel(n);
                      setApplied(null);
                      refs.current[n]?.focus();
                    }}
                    className={cn('aie-up group min-w-0 rounded-[12px] text-left', i === 0 && 'col-span-2 sm:col-span-1 lg:col-span-2')}
                    style={{ '--aie-d': `${i * 70}ms` } as React.CSSProperties}
                  >
                    <span className={cn('block rounded-[12px] p-[3px] transition-colors duration-200', on ? 'bg-primary' : 'bg-transparent group-hover:bg-border')}>
                    <span className={cn('relative block overflow-hidden rounded-[9px] bg-surface-2', i === 0 ? 'aspect-[2/1] sm:aspect-[4/3] lg:aspect-[2/1]' : 'aspect-[4/3]')}>
                      <Layer style={zoomStyle(mask)} className="transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none">
                        {o.media}
                      </Layer>
                      {run.busy && i > 0 && <Rendering />}
                      {on && !run.busy && (
                        <span className="absolute right-1.5 top-1.5 z-[3] grid size-5 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-md)]">
                          <Icon name="check" size={11} strokeWidth={2.6} />
                        </span>
                      )}
                    </span>
                    </span>
                    <span className={cn('mt-1 block truncate px-1 text-[11.5px]', on ? 'font-medium text-foreground' : 'text-muted-foreground')}>{o.label}</span>
                  </button>
                );
              })}
            </div>
            <div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-1">
              <button type="button" disabled={run.busy || options.length < 2} onClick={() => run.start(() => (setRound((r) => r + 1), setSel(1), setApplied(null)))} className={quietBtn}>
                <Icon name={run.busy ? 'loader' : 'shuffle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
                {run.busy ? t.generating : t.more}
                {cost && !run.busy && <span className="text-[11.5px] tabular-nums text-muted-foreground">{cost}</span>}
              </button>
              <button type="button" disabled={run.busy || applied === cur.id} onClick={() => (setApplied(cur.id), onAction?.('apply', cur.id))} className={primaryBtn}>
                <Icon name="check" size={15} />
                {sel === 0 ? t.keepOriginal : t.apply}
              </button>
            </div>
            <p className="mt-2 min-h-5 text-[12.5px] font-medium text-success-foreground" aria-live="polite">
              {applied && (
                <span className="aie-up inline-flex items-center gap-1.5">
                  <Icon name="check-circle" size={14} />
                  {t.applied}
                </span>
              )}
            </p>
          </div>
        </Reveal>
      </div>
      <Note>{note ?? t.honest}</Note>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* history                                                             */
/* ------------------------------------------------------------------ */

function History({ t, title, description, steps: initial = [], note, onAction, className }: P) {
  const [steps] = useSynced(initial);
  const [head, setHead] = useState(Math.max(0, initial.length - 1));
  const [view, setView] = useState(Math.max(0, initial.length - 1));
  const [msg, setMsg] = useState('');
  const [rail, fade] = useScrollFade<HTMLOListElement>();
  const cur = steps[Math.min(view, steps.length - 1)];
  const latest = steps.length - 1;
  const label = (s: ImageEditStep, i: number) => (i === 0 ? t.original_ : t[s.tool === 'original' ? 'original_' : s.tool]);

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
        <Reveal effect="scale" delay={60} className="min-w-0">
          <div className={cn(card, 'p-2')}>
            <div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
              <Layer key={view} className="aie-develop">
                {cur?.media}
              </Layer>
              <Tag>{view === head ? t.current : fill(t.viewing, { n: view + 1 })}</Tag>
              {view > 0 && <AiMark t={t} />}
            </div>
            <div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
              <p className="min-w-0 flex-1 text-[13px] text-foreground">
                <span className="text-muted-foreground">{fill(t.step, { n: view + 1 })} · </span>
                {cur?.prompt}
              </p>
              {view !== head && (
                <>
                  <button type="button" onClick={() => (setView(head), setMsg(''))} className={quietBtn}>
                    {t.toCurrent}
                  </button>
                  <button type="button" onClick={() => (setHead(view), setMsg(fill(t.restored, { n: view + 1 })), onAction?.('restore', cur?.id))} className={primaryBtn}>
                    <Icon name="history" size={15} />
                    {t.restore}
                  </button>
                </>
              )}
            </div>
            <p className="min-h-0 px-1 text-[12.5px] leading-snug text-muted-foreground empty:hidden" aria-live="polite">
              {msg}
            </p>
          </div>
        </Reveal>

        <Reveal delay={160} className="min-w-0 lg:sticky lg:top-6">
          <div className={cn(card, 'p-4 max-lg:px-0')}>
            <p className="text-[13px] font-medium text-foreground max-lg:px-4">{t.steps}</p>
            {/* A rail of thumbnails on phones and tablets, a list with the line on the desktop */}
            <ol ref={rail} tabIndex={-1} className={cn('kit-no-scrollbar relative mt-3 flex snap-x scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 lg:block lg:space-y-0 lg:overflow-visible lg:px-0 lg:pb-0', fade, 'lg:[mask-image:none]')}>
              {steps.map((s, i) => {
                const dropped = i > head;
                const on = i === view;
                return (
                  <li key={s.id} className="relative w-[8.5rem] shrink-0 snap-start lg:flex lg:w-auto lg:gap-3 lg:pb-3 last:lg:pb-0">
                    {i < steps.length - 1 && <span aria-hidden className={cn('absolute left-[19px] top-10 bottom-0 hidden w-px lg:block', i < head ? 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'bg-border')} />}
                    <span aria-hidden className={cn('relative z-[1] hidden size-10 shrink-0 place-items-center rounded-full border lg:grid', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'border-border bg-card text-muted-foreground', dropped && 'border-dashed')}>
                      <Icon name={TOOL_ICON[i === 0 ? 'original' : s.tool]} size={16} />
                    </span>
                    <button
                      type="button"
                      aria-current={on ? 'step' : undefined}
                      onClick={() => (setView(i), setMsg(''))}
                      className={cn('group block w-full min-w-0 rounded-xl p-1.5 text-left transition-colors lg:-mt-1 lg:flex-1', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]' : 'hover:bg-accent', dropped && 'opacity-60')}
                    >
                      <span className="flex gap-2.5 max-lg:flex-col">
                        <span data-clip-ok className="relative block aspect-[4/3] w-full shrink-0 overflow-hidden rounded-md bg-surface-2 lg:w-16 [&>*]:size-full">{s.media}</span>
                        <span className="block min-w-0">
                          <span className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
                            <span className="font-medium text-foreground">{label(s, i)}</span>
                            {s.time && <span className="tabular-nums">· {s.time}</span>}
                          </span>
                          <span className="mt-0.5 line-clamp-2 text-[12.5px] leading-snug text-muted-foreground">{s.prompt}</span>
                          {dropped && <span className="mt-1 inline-block rounded-[5px] border border-dashed border-border-strong px-1.5 text-[11px] text-muted-foreground">{t.dropped}</span>}
                          {i === head && i !== latest && <span className="mt-1 inline-block rounded-[5px] bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.current}</span>}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ol>
            {head < latest && (
              <div className="mt-3 max-lg:px-4">
                <button type="button" onClick={() => (setHead(latest), setView(latest), setMsg(''))} className={cn(quietBtn, 'w-full')}>
                  <Icon name="redo" size={15} />
                  {t.redo}
                </button>
              </div>
            )}
          </div>
        </Reveal>
      </div>
      <Note>{note ?? t.honest}</Note>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* style-controls                                                      */
/* ------------------------------------------------------------------ */

function filterFor(styles: ImageEditStyle[], values: Record<string, number>) {
  const parts: string[] = [];
  for (const s of styles) {
    const v = ((values[s.id] ?? s.value) - 50) / 50; // -1 to 1
    if (s.effect === 'warmth') parts.push(`sepia(${round(Math.max(0, v) * 0.35)}) hue-rotate(${round(Math.min(0, v) * -14)}deg)`);
    if (s.effect === 'contrast') parts.push(`contrast(${round(1 + v * 0.25)})`);
    if (s.effect === 'saturation') parts.push(`saturate(${round(1 + v * 0.6)})`);
    if (s.effect === 'softness') parts.push(`blur(${round(Math.max(0, v) * 1.4)}px) brightness(${round(1 + Math.max(0, v) * 0.06)})`);
  }
  return parts.join(' ') || undefined;
}

function StyleControls({ t, title, description, image, result, styles: initial = [], presets = [], cost, note, onGenerate, prompt = '', className }: P) {
  const locale = useLocale();
  const uid = useId();
  const [styles] = useSynced(initial);
  const start = Object.fromEntries(initial.map((s) => [s.id, s.value]));
  const [values, setValues] = useState<Record<string, number>>(start);
  const [preset, setPreset] = useState<string | null>(null);
  const [keep, setKeep] = useState(true);
  const [peek, setPeek] = useState(false);
  const [version, setVersion] = useState(0);
  const [msg, setMsg] = useState('');
  const run = useRender();
  const presetRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const changed = styles.some((s) => (values[s.id] ?? s.value) !== s.value);

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start lg:gap-6">
        <Reveal effect="scale" delay={60} className="min-w-0 lg:sticky lg:top-6">
          <div className={cn(card, 'p-2')}>
            <div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
              <Layer>{image}</Layer>
              <Layer key={version} className={cn('z-[1] transition-[filter,opacity] duration-300 motion-reduce:transition-none', version > 0 && 'aie-develop', peek && 'invisible opacity-0')} style={{ filter: filterFor(styles, values) }}>
                {result}
              </Layer>
              {run.busy && <Rendering progress={run.progress ?? undefined} />}
              {peek ? <Tag>{t.original}</Tag> : <AiMark t={t} />}
            </div>
            <div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
              <p className="min-w-0 flex-1 text-[12.5px] leading-snug text-muted-foreground">{t.preview}</p>
              <HoldCompare t={t} on={setPeek} />
            </div>
          </div>
        </Reveal>

        <Reveal delay={160}>
          <form
            className={cn(card, 'p-4')}
            onSubmit={(e) => {
              e.preventDefault();
              if (run.busy) return;
              onGenerate?.(prompt, null);
              run.start(() => (setVersion((v) => v + 1), setMsg(t.done)));
            }}
          >
            {presets.length > 0 && (
              <>
                <p id={`${uid}-p`} className="text-[13px] font-medium text-foreground">{t.presets}</p>
                <div role="radiogroup" aria-labelledby={`${uid}-p`} className="mt-2.5 grid grid-cols-2 gap-2">
                  {presets.map((p, i) => {
                    const on = preset === p.id;
                    return (
                      <button
                        key={p.id}
                        ref={(el) => {
                          presetRefs.current[i] = el;
                        }}
                        type="button"
                        role="radio"
                        aria-checked={on}
                        tabIndex={on || (preset === null && i === 0) ? 0 : -1}
                        onClick={() => (setPreset(p.id), setValues((v) => ({ ...v, ...p.values })), setMsg(''))}
                        onKeyDown={(e) => {
                          const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                          if (!d) return;
                          e.preventDefault();
                          const n = (i + d + presets.length) % presets.length;
                          setPreset(presets[n].id);
                          setValues((v) => ({ ...v, ...presets[n].values }));
                          presetRefs.current[n]?.focus();
                        }}
                        className={cn('inline-flex min-h-11 items-center justify-center rounded-lg border px-3 text-[13px] font-medium transition-colors sm:min-h-9', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}
                      >
                        {p.label}
                      </button>
                    );
                  })}
                </div>
                <span aria-hidden className="my-4 block h-px bg-border" />
              </>
            )}
            <div className="space-y-4">
              {styles.map((s) => {
                const v = values[s.id] ?? s.value;
                return (
                  <div key={s.id}>
                    <div className="flex items-baseline justify-between gap-3">
                      <label htmlFor={`${uid}-${s.id}`} className="text-[13px] font-medium text-foreground">
                        {s.label}
                      </label>
                      <output htmlFor={`${uid}-${s.id}`} className="text-[12.5px] tabular-nums text-muted-foreground">
                        {formatPercent(v, locale)}
                      </output>
                    </div>
                    <input
                      id={`${uid}-${s.id}`}
                      type="range"
                      min={0}
                      max={100}
                      value={v}
                      aria-valuetext={formatPercent(v, locale)}
                      onChange={(e) => (setValues((x) => ({ ...x, [s.id]: Number(e.target.value) })), setPreset(null), setMsg(''))}
                      className="h-11 w-full accent-[var(--primary)] sm:h-7"
                    />
                    {(s.low || s.high) && (
                      <div aria-hidden className="flex justify-between gap-3 text-[11.5px] text-subtle-foreground">
                        <span>{s.low}</span>
                        <span className="text-right">{s.high}</span>
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
            <label className="mt-4 flex cursor-pointer items-start gap-3 rounded-lg border border-border p-3">
              <input type="checkbox" checked={keep} onChange={(e) => setKeep(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
              <span>
                <span className="block text-[13px] font-medium text-foreground">{t.seed}</span>
                <span className="mt-0.5 block text-[12.5px] leading-snug text-muted-foreground">{t.seedText}</span>
              </span>
            </label>
            <div className="mt-4 grid grid-cols-1 gap-2">
              <button type="submit" disabled={run.busy} className={cn(primaryBtn, 'w-full')}>
                <Icon name={run.busy ? 'loader' : 'sparkle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
                <span>{run.busy ? t.generating : t.generateWith}</span>
                {cost && !run.busy && <span className="rounded-md bg-[rgb(255_255_255/0.18)] px-1.5 py-px text-[11.5px] tabular-nums">{cost}</span>}
              </button>
              <button type="button" disabled={!changed || run.busy} onClick={() => (setValues(start), setPreset(null), setMsg(''))} className={cn(quietBtn, 'w-full')}>
                <Icon name="rotate-ccw" size={15} />
                {t.reset}
              </button>
            </div>
            <p className="mt-2 min-h-5 text-[12.5px] font-medium text-success-foreground" aria-live="polite">
              {msg && (
                <span className="aie-up inline-flex items-center gap-1.5">
                  <Icon name="check-circle" size={14} />
                  {msg}
                </span>
              )}
            </p>
          </form>
        </Reveal>
      </div>
      <Note>{note ?? t.honest}</Note>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * An AI image editor: paint over an area and say what goes there
 * (inpainting), compare before and after with a slider or side by side,
 * choose one of several results for the area, step back through the edits
 * and tune the look with sliders and presets. Every edited picture carries
 * a mark that AI changed it, nothing is kept before you decide.
 */
export function AiImageEditor({ variant = 'inpaint', labels, ...props }: AiImageEditorProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  switch (variant) {
    case 'before-after':
      return <BeforeAfter {...p} />;
    case 'variants':
      return <Variants {...p} />;
    case 'history':
      return <History {...p} />;
    case 'style-controls':
      return <StyleControls {...p} />;
    default:
      return <Inpaint {...p} />;
  }
}

Frequent questions

How do I install AI image editor?

With aiw add @ai-website/ai-image-editor. The command puts components/ui/ai-image-editor.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does AI image editor have?

5 variants, Paint and replace, Before and after, Choose a result, Edit history and Tune the look. You pick one with the variant prop, for example variant="before-after".

Can I translate the texts of AI image editor?

Yes. AI image editor carries 65 texts of its own in English and German, such as “Image to edit”, “Tools” and “Brush”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI image editor?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI image editor with aiw_components and install it with aiw_add @ai-website/ai-image-editor. Name the variant in your prompt, for example variant="inpaint", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard