'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([ '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('adjust'); const [edits, setEdits] = useState({ adjustments: {} }); const [aiBusy, setAiBusy] = useState(false); const [aiError, setAiError] = useState(null); const [aiResultUrl, setAiResultUrl] = useState(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(null); const [annTool, setAnnTool] = useState('rect'); const [annColor, setAnnColor] = useState('#ff3b30'); const [cropRatio, setCropRatio] = useState(null); const [baking, setBaking] = useState(false); const aiBlobRef = useRef(null); const dialogRef = useRef(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> => { 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 (
Edit · {item.name}
{item.name} {tab !== 'crop' ? ( ) : null} {tab === 'crop' ? ( ) : null} {!aiResultUrl && tab !== 'crop' && adj.vignette > 0 ? (
) : null} {aiBusy ? (
Generating…
) : null}
{tabs.map((t) => ( ))}
{tab === 'adjust' ? (
{ADJUST_FIELDS.map((f) => (
{f.label} {Math.round((adj[f.key] ?? 0) * 100)}
setAdj(f.key, Number(e.target.value))} />
))}
) : null} {tab === 'filters' ? (
{Object.entries(FILTER_PRESETS).map(([key, preset]) => ( ))}
) : null} {tab === 'crop' ? (

Drag the box to crop. Pick a ratio to lock the shape (a square stays square). Applied when you press Save.

Aspect ratio
{RATIOS.map((r) => ( ))}
{edits.crop ? ( ) : null}
Straighten {Math.round(edits.straighten ?? 0)}°
setEdits((prev) => ({ ...prev, straighten: Number(e.target.value) }))} />
{(edits.straighten ?? 0) !== 0 ? ( ) : null} {provider?.estimateTilt ? ( ) : null} {tiltError ? (

{tiltError}

) : null}
) : null} {tab === 'markup' ? (

Draw shapes, arrows and measurements. Use Measure for a double‑arrow with a centered label (e.g. “12 cm”). Pick a tool, then drag on the photo.

{ANN_TOOLS.map((t) => ( ))}
{provider?.transcribeAudio ? ( { 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}
Color
{ANN_COLORS.map((c) => (
{annotations.length} annotation(s)
) : null} {tab === 'ai' ? (

Generative edits run through your configured AI backend. Results replace the photo when you press Save.

{AI_OPS.map((a) => ( ))}
Edit strength {Math.round(aiStrength * 100)}%
setAiStrength(Number(e.target.value))} />
Subtle Strong
setAiPrompt(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && aiPrompt.trim()) void runAI({ type: 'prompt', prompt: aiPrompt.trim() }); }} />

Brush & expand tools — inpaint / outpaint.

{aiResultUrl ? ( ) : null} {aiError ? (

{aiError}

) : null}
) : null}
{maskMode ? ( 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} ); } function loadCrossOriginImage(src: string): Promise { 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 { 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); }); }