feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations

This commit is contained in:
2026-07-23 07:39:01 +05:30
parent 7bcd1a2a2d
commit 2613c767a6
107 changed files with 20699 additions and 7 deletions
@@ -0,0 +1,821 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import type { GenerativeEditOp } from '../../ai/types';
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../../constants';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { editFilterCss, editTransformCss } from '../../lib/edits';
import { summarizeEdits } from '../../lib/versions';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import type { Annotation, EditAdjustments, EditState, MediaId, MediaItem } from '../../types';
import { bakeEdits, hasGeometry } from '../../lib/bake';
import { addToAlbumPicker, confirmAction } from '../modals';
import { useAIProvider } from '../aiContext';
import { Annotations, type AnnotationTool } from './Annotations';
import { CropBox, type CropRect } from './CropBox';
import { MaskBrush } from './MaskBrush';
import { VoiceButton } from './VoiceButton';
const RATIOS: Array<{ label: string; value: number | null }> = [
{ label: 'Free', value: null },
{ label: 'Square', value: 1 },
{ label: '4:3', value: 4 / 3 },
{ label: '3:4', value: 3 / 4 },
{ label: '16:9', value: 16 / 9 },
{ label: '9:16', value: 9 / 16 },
];
function centeredCrop(ratio: number | null, imgAspect: number): CropRect {
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
let w = 1;
let h = imgAspect / ratio;
if (h > 1) {
h = 1;
w = ratio / imgAspect;
}
return { x: (1 - w) / 2, y: (1 - h) / 2, width: w, height: h };
}
type Tab = 'adjust' | 'filters' | 'crop' | 'markup' | 'ai';
const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: IconName }> = [
{ tool: 'select', label: 'Select', icon: 'check' },
{ tool: 'rect', label: 'Rectangle', icon: 'aspect' },
{ tool: 'ellipse', label: 'Oval', icon: 'filters' },
{ tool: 'line', label: 'Line', icon: 'minus' },
{ tool: 'arrow', label: 'Arrow', icon: 'chevron-right' },
{ tool: 'double-arrow', label: 'Measure', icon: 'crop' },
{ tool: 'text', label: 'Text', icon: 'tag' },
{ tool: 'freehand', label: 'Draw', icon: 'wand' },
];
const ANN_COLORS = ['#ff3b30', '#ff9f0a', '#ffd60a', '#34c759', '#0a84ff', '#bf5af2', '#ffffff', '#000000'];
const ADJUST_FIELDS: Array<{ key: keyof EditAdjustments; label: string }> = [
{ key: 'exposure', label: 'Exposure' },
{ key: 'brilliance', label: 'Brilliance' },
{ key: 'highlights', label: 'Highlights' },
{ key: 'shadows', label: 'Shadows' },
{ key: 'contrast', label: 'Contrast' },
{ key: 'brightness', label: 'Brightness' },
{ key: 'blackPoint', label: 'Black Point' },
{ key: 'saturation', label: 'Saturation' },
{ key: 'vibrance', label: 'Vibrance' },
{ key: 'warmth', label: 'Warmth' },
{ key: 'tint', label: 'Tint' },
{ key: 'sharpness', label: 'Sharpness' },
{ key: 'definition', label: 'Definition' },
{ key: 'vignette', label: 'Vignette' },
];
const AI_OPS: Array<{ label: string; op: GenerativeEditOp; icon: 'wand' | 'image' | 'crop' }> = [
{ label: 'Remove Background', op: { type: 'remove-background' }, icon: 'wand' },
{ label: 'Restore & Enhance', op: { type: 'restore' }, icon: 'wand' },
{ label: 'Colorize', op: { type: 'colorize' }, icon: 'wand' },
{ label: 'Replace Sky', op: { type: 'replace-sky' }, icon: 'image' },
];
/** Ops whose result varies with the "edit strength" slider (the backend maps it per model). */
const STRENGTH_OPS = new Set<GenerativeEditOp['type']>([
'prompt',
'replace-sky',
'magic-eraser',
'generative-fill',
]);
export function PhotoEditor() {
const api = useGalleryStoreApi();
const editorId = useGallery((s) => s.editorId);
const media = useGallery((s) => s.media);
const provider = useAIProvider();
const aiAvailable = Boolean(provider?.generativeEdit);
const item = media.find((m) => m.id === editorId) ?? null;
const [tab, setTab] = useState<Tab>('adjust');
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
const [aiBusy, setAiBusy] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [aiResultUrl, setAiResultUrl] = useState<string | null>(null);
const [aiPrompt, setAiPrompt] = useState('');
const [aiStrength, setAiStrength] = useState(0.5);
const [maskMode, setMaskMode] = useState<'magic-eraser' | 'generative-fill' | null>(null);
const [tiltBusy, setTiltBusy] = useState(false);
const [tiltError, setTiltError] = useState<string | null>(null);
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState<string>('#ff3b30');
const [cropRatio, setCropRatio] = useState<number | null>(null);
const [baking, setBaking] = useState(false);
const aiBlobRef = useRef<Blob | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape routes through the same dirty-check as the Cancel button (assigned below).
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
useFocusTrap(dialogRef, Boolean(item), () => cancelRef.current());
// Reset state when the editor opens on a new item.
useEffect(() => {
if (item) {
setEdits(item.edits ?? { adjustments: {} });
setTab('adjust');
setAiError(null);
setAiPrompt('');
setCropRatio(null);
aiBlobRef.current = null;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Videos are handled by the dedicated VideoEditor.
if (!item || item.kind === 'video') return null;
const adj = { ...ZERO_ADJUSTMENTS, ...edits.adjustments };
const setAdj = (key: keyof EditAdjustments, value: number) =>
setEdits((e) => ({ ...e, adjustments: { ...e.adjustments, [key]: value } }));
const annotations = edits.annotations ?? [];
const setAnnotations = (next: Annotation[]) => setEdits((e) => ({ ...e, annotations: next }));
const imgAspect = item.width / Math.max(1, item.height);
const cropRect: CropRect = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const setCrop = (r: CropRect) => setEdits((e) => ({ ...e, crop: r }));
const pickRatio = (value: number | null) => {
setCropRatio(value);
setCrop(centeredCrop(value, imgAspect));
};
const filterCss = aiResultUrl ? undefined : editFilterCss(edits);
// Apply rotate / straighten (tilt) / flip live in ALL tabs (incl. Crop) so the
// user sees them immediately; the crop box overlays the transformed frame.
const transformCss = aiResultUrl ? undefined : editTransformCss(edits);
const runAI = async (op: GenerativeEditOp) => {
if (!provider?.generativeEdit) return;
setAiBusy(true);
setAiError(null);
try {
const img = await loadCrossOriginImage(item.src);
// Attach the current "edit strength" to ops that support it.
const opToRun = STRENGTH_OPS.has(op.type)
? ({ ...op, strength: aiStrength } as GenerativeEditOp)
: op;
const blob = await provider.generativeEdit(item, img, opToRun);
aiBlobRef.current = blob;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI edit failed. Please try again.');
} finally {
setAiBusy(false);
}
};
const autoStraighten = async () => {
if (!provider?.estimateTilt) return;
setTiltBusy(true);
setTiltError(null);
try {
const img = await loadCrossOriginImage(item.src);
const t = await provider.estimateTilt(item, img);
const roll = Math.max(-45, Math.min(45, Math.round(t.rollDegrees)));
setEdits((e) => ({ ...e, straighten: roll }));
} catch (err) {
setTiltError(err instanceof Error ? err.message : 'Could not estimate tilt.');
} finally {
setTiltBusy(false);
}
};
const clearAI = () => {
aiBlobRef.current = null;
setAiError(null);
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
/** True when there are unsaved changes worth confirming before discard. */
const isDirty = () =>
aiBlobRef.current !== null ||
!!edits.filter ||
hasGeometry(edits) ||
(edits.annotations?.length ?? 0) > 0 ||
Object.keys(edits.adjustments ?? {}).length > 0;
/**
* Build the result patch for a target id. Uploads a new blob (AI / baked
* geometry) under that id when needed; returns the MediaItem patch to apply.
*/
const buildPatch = async (targetId: MediaId): Promise<Partial<MediaItem>> => {
if (aiBlobRef.current) {
const uploaded = await api.getState().uploadBlob(targetId, aiBlobRef.current);
const src = uploaded ?? (await blobToDataUrl(aiBlobRef.current));
return { src, thumbnail: undefined, edits: undefined, editedAt: Date.now(), analyzedAt: undefined };
}
if (hasGeometry(edits)) {
const img = await loadCrossOriginImage(item.src);
const { blob, width, height, annotations: remapped } = await bakeEdits(img, edits);
const uploaded = await api.getState().uploadBlob(targetId, blob);
const src = uploaded ?? (await blobToDataUrl(blob));
return {
src,
thumbnail: undefined,
width,
height,
edits: remapped.length ? { adjustments: {}, annotations: remapped } : undefined,
editedAt: Date.now(),
analyzedAt: undefined,
};
}
// Non-destructive (filter/adjust/markup only): keep the same src, store edits.
return { edits, editedAt: Date.now() };
};
const save = async (asCopy: boolean) => {
setBaking(true);
// Human-readable audit log for this edit (same for copy + overwrite).
const changes = aiBlobRef.current
? ['AI edit', ...summarizeEdits(edits).filter((c) => c !== 'Edited')]
: summarizeEdits(edits);
try {
if (asCopy) {
// Upload (if any) under a fresh id, create the copy, then let the user file it.
const copyId = nanoid(10) as MediaId;
const patch = await buildPatch(copyId);
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: copyId }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
const patch = await buildPatch(item.id);
// Non-destructive save: preserve the original as v1 and append a new
// version with an audit log of what changed instead of overwriting.
api.getState().addVersion(item.id, patch, changes);
api.getState().closeEditor();
}
} catch {
// Bake/upload failed (e.g. cross-origin) — still record a version so history
// is never lost. Copies still get their own 2-entry history via the fallback.
if (asCopy) {
const copyId = nanoid(10) as MediaId;
const newId = api
.getState()
.duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
}
} finally {
setBaking(false);
}
};
const cancel = () => {
if (!isDirty()) {
api.getState().closeEditor();
return;
}
confirmAction({
title: 'Discard changes?',
message: 'Your edits to this photo have not been saved.',
confirmLabel: 'Discard',
danger: true,
onConfirm: () => api.getState().closeEditor(),
});
};
cancelRef.current = cancel;
const revert = () => {
clearAI();
setEdits({ adjustments: {} });
};
const tabs: Tab[] = aiAvailable
? ['adjust', 'filters', 'crop', 'markup', 'ai']
: ['adjust', 'filters', 'crop', 'markup'];
const tabLabel = (t: Tab) =>
t === 'adjust' ? 'Adjust' : t === 'filters' ? 'Filters' : t === 'crop' ? 'Crop' : t === 'markup' ? 'Markup' : 'AI';
const previewSrc = aiResultUrl ?? item.src;
return (
<AnimatePresence>
<motion.div
ref={dialogRef}
className="apg-editor"
role="dialog"
aria-modal="true"
aria-label={`Edit ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="apg-editor__bar">
<button
type="button"
className="apg-iconbtn"
aria-label="Cancel"
onClick={cancel}
>
<Icon name="close" />
</button>
<div style={{ fontWeight: 600 }}>Edit · {item.name}</div>
<div style={{ flex: 1 }} />
<button type="button" className="apg-btn" onClick={revert} disabled={baking}>
Revert
</button>
<button
type="button"
className="apg-btn"
onClick={() => void save(true)}
disabled={baking || aiBusy}
title="Save the result as a new photo (you choose the album)"
>
Save as Copy
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void save(false)}
disabled={baking || aiBusy}
title="Overwrite this photo with your edits"
>
{baking ? 'Saving…' : 'Save'}
</button>
</div>
<div className="apg-editor__body">
<div className="apg-editor__canvaswrap">
<div style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={previewSrc}
alt={item.name}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '78vh',
borderRadius: 4,
filter: filterCss,
transform: transformCss,
transition: 'filter 0.08s linear',
// Checkerboard shows through transparent (background-removed) results.
background: aiResultUrl
? 'repeating-conic-gradient(#3a3a3c 0% 25%, #2a2a2c 0% 50%) 50% / 20px 20px'
: undefined,
}}
/>
{tab !== 'crop' ? (
<Annotations
annotations={annotations}
editable={tab === 'markup'}
tool={annTool}
color={annColor}
onChange={setAnnotations}
/>
) : null}
{tab === 'crop' ? (
<CropBox rect={cropRect} ratio={cropRatio} onChange={setCrop} />
) : null}
{!aiResultUrl && tab !== 'crop' && adj.vignette > 0 ? (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 4,
boxShadow: `inset 0 0 ${60 + adj.vignette * 140}px rgba(0,0,0,${(
adj.vignette * 0.8
).toFixed(2)})`,
}}
/>
) : null}
{aiBusy ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,0,0,0.45)',
borderRadius: 4,
color: '#fff',
gap: 10,
flexDirection: 'column',
}}
>
<span className="apg-ai-spinner" style={{ width: 26, height: 26 }} />
<span style={{ fontSize: 13 }}>Generating</span>
</div>
) : null}
</div>
</div>
<div className="apg-editor__panel">
<div className="apg-editor__tabs">
{tabs.map((t) => (
<button
key={t}
type="button"
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setTab(t)}
>
{tabLabel(t)}
</button>
))}
</div>
{tab === 'adjust' ? (
<div>
{ADJUST_FIELDS.map((f) => (
<div className="apg-slider-row" key={f.key}>
<div className="apg-slider-row__head">
<span>{f.label}</span>
<span>{Math.round((adj[f.key] ?? 0) * 100)}</span>
</div>
<input
className="apg-slider"
type="range"
min={-1}
max={1}
step={0.01}
value={adj[f.key] ?? 0}
onChange={(e) => setAdj(f.key, Number(e.target.value))}
/>
</div>
))}
</div>
) : null}
{tab === 'filters' ? (
<div className="apg-editor__filters">
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className={[
'apg-editor__filter',
edits.filter === key || (!edits.filter && key === 'original')
? 'apg-editor__filter--active'
: '',
].join(' ')}
onClick={() =>
setEdits((e) => ({ ...e, filter: key === 'original' ? undefined : key }))
}
>
{preset.label}
</button>
))}
</div>
) : null}
{tab === 'crop' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Drag the box to crop. Pick a ratio to lock the shape (a square stays square). Applied
when you press Save.
</p>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>Aspect ratio</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{RATIOS.map((r) => (
<button
key={r.label}
type="button"
className={['apg-editor__tab', cropRatio === r.value ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(r.value)}
>
{r.label}
</button>
))}
<button
type="button"
className={['apg-editor__tab', cropRatio === imgAspect ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(imgAspect)}
>
Original
</button>
</div>
{edits.crop ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => {
setCropRatio(null);
setEdits((e) => ({ ...e, crop: undefined }));
}}
>
Reset Crop
</button>
) : null}
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, rotation: ((e.rotation ?? 0) + 90) % 360 }))}
>
<Icon name="rotate" size={16} /> Rotate 90°
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipH: !e.flipH }))}
>
Flip Horizontal
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipV: !e.flipV }))}
>
Flip Vertical
</button>
<div className="apg-slider-row" style={{ marginTop: 8 }}>
<div className="apg-slider-row__head">
<span>Straighten</span>
<span>{Math.round(edits.straighten ?? 0)}°</span>
</div>
<input
className="apg-slider"
type="range"
min={-45}
max={45}
step={1}
value={edits.straighten ?? 0}
onChange={(e) => setEdits((prev) => ({ ...prev, straighten: Number(e.target.value) }))}
/>
</div>
{(edits.straighten ?? 0) !== 0 ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, straighten: 0 }))}
>
Reset Straighten
</button>
) : null}
{provider?.estimateTilt ? (
<button
type="button"
className="apg-editor__tab"
disabled={tiltBusy}
onClick={() => void autoStraighten()}
>
<Icon name="wand" size={15} />{' '}
{tiltBusy ? 'Analyzing tilt…' : 'Auto-straighten (fix camera tilt)'}
</button>
) : null}
{tiltError ? (
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{tiltError}</p>
) : null}
</div>
) : null}
{tab === 'markup' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Draw shapes, arrows and measurements. Use <strong>Measure</strong> for a doublearrow
with a centered label (e.g. 12 cm). Pick a tool, then drag on the photo.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-editor__tab', annTool === t.tool ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
</div>
{provider?.transcribeAudio ? (
<VoiceButton
label="Speak → add text"
onText={(t) => {
setAnnotations([
...annotations,
{
id: nanoid(8),
shape: 'text',
color: annColor,
strokeWidth: 2,
x1: 0.08,
y1: 0.08,
x2: 0.55,
y2: 0.17,
text: t,
},
]);
setAnnTool('select');
}}
/>
) : null}
<div>
<div style={{ fontSize: 12, color: '#9b9ba1', marginBottom: 6 }}>Color</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{ANN_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setAnnColor(c)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c,
border: annColor === c ? '2px solid #fff' : '2px solid rgba(255,255,255,0.25)',
outline: annColor === c ? '2px solid var(--apg-accent)' : 'none',
cursor: 'pointer',
}}
/>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations(annotations.slice(0, -1))}
>
<Icon name="rotate" size={15} /> Undo
</button>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations([])}
>
<Icon name="trash" size={15} /> Clear
</button>
</div>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>{annotations.length} annotation(s)</div>
</div>
) : null}
{tab === 'ai' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: '0 0 4px' }}>
Generative edits run through your configured AI backend. Results replace the photo when you press Save.
</p>
{AI_OPS.map((a) => (
<button
key={a.label}
type="button"
className="apg-editor__tab"
disabled={aiBusy}
onClick={() => void runAI(a.op)}
>
<Icon name={a.icon} size={16} /> {a.label}
</button>
))}
<div className="apg-slider-row" style={{ marginTop: 6 }}>
<div className="apg-slider-row__head">
<span>Edit strength</span>
<span>{Math.round(aiStrength * 100)}%</span>
</div>
<input
className="apg-slider"
type="range"
min={0}
max={1}
step={0.05}
value={aiStrength}
disabled={aiBusy}
onChange={(e) => setAiStrength(Number(e.target.value))}
/>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#9b9ba1',
marginTop: 2,
}}
>
<span>Subtle</span>
<span>Strong</span>
</div>
</div>
<div style={{ marginTop: 6 }}>
<input
className="apg-modal__input"
style={{ width: '100%', background: 'rgba(255,255,255,0.08)', color: '#fff', borderColor: 'rgba(255,255,255,0.15)' }}
placeholder="Describe an edit, e.g. 'make it golden hour'"
value={aiPrompt}
disabled={aiBusy}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && aiPrompt.trim()) void runAI({ type: 'prompt', prompt: aiPrompt.trim() });
}}
/>
<button
type="button"
className="apg-btn apg-btn--primary"
style={{ width: '100%', marginTop: 8 }}
disabled={aiBusy || !aiPrompt.trim()}
onClick={() => void runAI({ type: 'prompt', prompt: aiPrompt.trim() })}
>
Apply Prompt
</button>
</div>
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)', margin: '4px 0' }} />
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Brush &amp; expand tools inpaint / outpaint.
</p>
<button
type="button"
className="apg-editor__tab"
disabled={aiBusy}
onClick={() => setMaskMode('magic-eraser')}
>
<Icon name="wand" size={16} /> Magic Eraser (remove an object)
</button>
<button
type="button"
className="apg-editor__tab"
disabled={aiBusy}
title="Paint an area, then it fills it — type a prompt above to control what appears (optional)"
onClick={() => setMaskMode('generative-fill')}
>
<Icon name="image" size={16} /> Generative Fill (paint + prompt)
</button>
<button
type="button"
className="apg-editor__tab"
disabled={aiBusy}
onClick={() => void runAI({ type: 'outpaint', prompt: aiPrompt.trim() || undefined })}
>
<Icon name="crop" size={16} /> Expand Image (Outpaint)
</button>
{aiResultUrl ? (
<button type="button" className="apg-editor__tab" onClick={clearAI} disabled={aiBusy}>
Discard AI result
</button>
) : null}
{aiError ? (
<p style={{ color: '#ff6b6b', fontSize: 12 }}>{aiError}</p>
) : null}
</div>
) : null}
</div>
</div>
{maskMode ? (
<MaskBrush
src={item.src}
aspect={imgAspect}
title={
maskMode === 'magic-eraser'
? 'Paint over what to remove'
: 'Paint the area to replace (uses your prompt)'
}
onCancel={() => setMaskMode(null)}
onApply={(mask) => {
const m = maskMode;
setMaskMode(null);
if (m === 'generative-fill') {
void runAI({ type: 'generative-fill', prompt: aiPrompt.trim() || 'fill naturally', mask });
} else {
void runAI({ type: 'magic-eraser', mask });
}
}}
/>
) : null}
</motion.div>
</AnimatePresence>
);
}
function loadCrossOriginImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not load the image for editing.'));
img.src = src;
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('Failed to read edited image.'));
reader.readAsDataURL(blob);
});
}