Zubeyr··PagesmediaeditorImage Edit
A product image editor: light and colour adjustments, stackable filter presets, crop with aspect ratios, zoom, straighten, rotate, flip and drag to reposition, hold-to-compare, undo/redo and a floating Cancel and Save toolbar.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/image-edit.json- →Installs
lucide-reactfrom npm. - →Pulls in
sidebar. - →Pulls in
button. - →Pulls in
use-history. - →Pulls in
toast. - →Files land in
components/dashboard/image-edit.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add lucide-reactUsage
import { ImageEdit } from '@/components/dashboard/image-edit/image-edit'
export default function Demo() {
return <ImageEdit />
}Source code
'use client'
import { useRef, useState } from 'react'
import {
BarChart3,
Eye,
Home,
ImageIcon,
Package,
Redo2,
Settings,
ShoppingBag,
Undo2,
Users,
X,
} from 'lucide-react'
import { Button } from '@/registry/dashboard/components/button/button'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarItem,
} from '@/registry/dashboard/components/sidebar/sidebar'
import { toast, Toaster } from '@/registry/dashboard/components/toast/toast'
import { useHistory } from '@/registry/dashboard/components/use-history/use-history'
import { cn } from '@/lib/utils'
import { defaultEdit, type Edit, type StageImage } from './edits'
import { Panel, type Tab } from './panel'
import { Stage } from './stage'
type Doc = { images: StageImage[]; edits: Record<string, Edit> }
const initial: Doc = {
images: [
{ id: 'front', name: 'Front', background: '#ece4d8', fill: '#c2410c' },
{ id: 'back', name: 'Back', background: '#dfe5e2', fill: '#3f6212' },
{ id: 'detail', name: 'Detail', background: '#e7e0ef', fill: '#44403c', scale: 2.2 },
],
edits: {
back: { ...defaultEdit, filter: 'warm', adjust: { ...defaultEdit.adjust, contrast: 12 } },
},
}
export function ImageEdit() {
const history = useHistory(initial, { hotkeys: true })
const doc = history.value
const [saved, setSaved] = useState(initial)
const [active, setActive] = useState(0)
const [tab, setTab] = useState<Tab>('adjust')
const [comparing, setComparing] = useState(false)
const [justSaved, setJustSaved] = useState(false)
const gesture = useRef(0)
const index = Math.min(active, Math.max(0, doc.images.length - 1))
const image = doc.images[index]
const edit = (image && doc.edits[image.id]) ?? defaultEdit
const dirty = JSON.stringify(doc) !== JSON.stringify(saved)
const setEdit = (next: Edit, merge?: string) => {
if (!image) return
history.set(
(d) => ({ ...d, edits: { ...d.edits, [image.id]: next } }),
merge ? { merge: `${image.id}-${merge}-${gesture.current}` } : undefined,
)
}
const setImages = (images: StageImage[]) => {
history.set((d) => ({
images,
edits: Object.fromEntries(
Object.entries(d.edits).filter(([id]) => images.some((i) => i.id === id)),
),
}))
}
const cancel = () => {
history.reset(saved)
setActive(0)
}
const save = () => {
setSaved(doc)
history.reset(doc)
setJustSaved(true)
setTimeout(() => setJustSaved(false), 1500)
toast.success('Images saved', {
description: `${doc.images.length} image${doc.images.length === 1 ? '' : 's'} updated.`,
})
}
return (
<div className="flex h-svh overflow-hidden bg-sidebar text-foreground">
<Sidebar className="border-r-0 max-lg:hidden">
<SidebarHeader
logo={<span className="size-8 rounded-lg bg-sidebar-primary-foreground" />}
title="Tomei"
subtitle="Store admin"
/>
<SidebarContent>
<SidebarGroup>
<SidebarItem icon={<Home />}>Home</SidebarItem>
<SidebarItem icon={<ShoppingBag />} count={4}>
Orders
</SidebarItem>
<SidebarItem
icon={<Package />}
active
submenu={
<>
<SidebarItem>Collections</SidebarItem>
<SidebarItem>Inventory</SidebarItem>
<SidebarItem active>Media</SidebarItem>
<SidebarItem>Gift cards</SidebarItem>
</>
}
>
Products
</SidebarItem>
<SidebarItem icon={<Users />}>Customers</SidebarItem>
<SidebarItem icon={<BarChart3 />}>Analytics</SidebarItem>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarItem icon={<Settings />}>Settings</SidebarItem>
</SidebarFooter>
</Sidebar>
<main className="min-w-0 flex-1 overflow-y-auto bg-card lg:my-2 lg:mr-2 lg:rounded-2xl lg:border">
<header className="sticky top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-3 bg-card/90 px-4 py-3 backdrop-blur">
<div className="flex min-w-0 items-center gap-2 text-sm">
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">Wool Overshirt</span>
{image && (
<span className="truncate text-muted-foreground max-sm:hidden">/ {image.name}</span>
)}
{dirty && (
<span className="text-amber-600 max-md:hidden dark:text-amber-400">
· Unsaved changes
</span>
)}
</div>
<div
role="toolbar"
aria-label="Edit actions"
className="flex items-center gap-1 rounded-xl bg-primary p-1 text-primary-foreground shadow-lg"
>
<button
type="button"
disabled={!dirty}
onClick={cancel}
className="h-7 rounded-lg px-3 text-sm font-medium hover:bg-primary-foreground/10 disabled:opacity-40 disabled:hover:bg-transparent"
>
Cancel
</button>
<button
type="button"
disabled={!dirty}
onClick={save}
className={cn(
'h-7 rounded-lg bg-primary-foreground px-3 text-sm font-medium text-primary disabled:opacity-50',
justSaved && 'disabled:opacity-100',
)}
>
{justSaved ? 'Saved ✓' : 'Save'}
</button>
</div>
<div className="flex justify-end gap-1.5">
<Button
size="icon"
variant="ghost"
aria-label="Undo"
title="Undo (Ctrl+Z)"
disabled={!history.canUndo}
onClick={history.undo}
>
<Undo2 />
</Button>
<Button
size="icon"
variant="ghost"
aria-label="Redo"
title="Redo (Ctrl+Shift+Z)"
disabled={!history.canRedo}
onClick={history.redo}
>
<Redo2 />
</Button>
<Button
size="icon"
aria-label="Hold to compare with original"
title="Hold to compare"
aria-pressed={comparing}
disabled={!image}
onPointerDown={() => setComparing(true)}
onPointerUp={() => setComparing(false)}
onPointerLeave={() => setComparing(false)}
onKeyDown={(e) => e.key === ' ' && setComparing(true)}
onKeyUp={() => setComparing(false)}
>
<Eye />
</Button>
<Button size="icon" variant="ghost" aria-label="Close">
<X />
</Button>
</div>
</header>
<div className="mx-auto grid max-w-[1200px] gap-10 px-6 pt-6 pb-16 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div
className="flex items-start justify-center rounded-3xl py-6"
style={{
background:
'radial-gradient(circle at 50% 40%, color-mix(in oklab, var(--chart-5) 10%, transparent), transparent 65%)',
}}
>
<Stage
images={doc.images}
edits={doc.edits}
active={index}
cropping={tab === 'crop'}
comparing={comparing}
onSelect={setActive}
onImages={setImages}
onPan={(x, y, start) => {
if (start) gesture.current++
else setEdit({ ...edit, crop: { ...edit.crop, x, y } }, 'pan')
}}
/>
</div>
<Panel
tab={tab}
image={image}
edit={edit}
onTab={setTab}
onEdit={setEdit}
onStart={() => gesture.current++}
/>
</div>
</main>
<Toaster />
</div>
)
}export type Adjust = {
exposure: number
contrast: number
saturation: number
warmth: number
hue: number
fade: number
vignette: number
}
export type Crop = {
aspect: string
zoom: number
x: number
y: number
straighten: number
turns: number
flipX: boolean
flipY: boolean
}
export type Edit = { filter: string; adjust: Adjust; crop: Crop }
export type StageImage = {
id: string
name: string
src?: string
background?: string
fill?: string
scale?: number
}
export const adjustments: { key: keyof Adjust; label: string; min: number; max: number }[] = [
{ key: 'exposure', label: 'Exposure', min: -100, max: 100 },
{ key: 'contrast', label: 'Contrast', min: -100, max: 100 },
{ key: 'saturation', label: 'Saturation', min: -100, max: 100 },
{ key: 'warmth', label: 'Warmth', min: -100, max: 100 },
{ key: 'hue', label: 'Hue', min: -180, max: 180 },
{ key: 'fade', label: 'Fade', min: 0, max: 100 },
{ key: 'vignette', label: 'Vignette', min: 0, max: 100 },
]
export const aspects = [
{ id: 'original', label: 'Original', ratio: 4 / 5 },
{ id: 'square', label: '1:1', ratio: 1 },
{ id: 'portrait', label: '3:4', ratio: 3 / 4 },
{ id: 'story', label: '9:16', ratio: 9 / 16 },
{ id: 'wide', label: '16:9', ratio: 16 / 9 },
]
export const filters: { id: string; label: string; adjust: Partial<Adjust> }[] = [
{ id: 'none', label: 'None', adjust: {} },
{ id: 'vivid', label: 'Vivid', adjust: { saturation: 35, contrast: 15 } },
{ id: 'warm', label: 'Warm', adjust: { warmth: 45, exposure: 5 } },
{ id: 'cool', label: 'Cool', adjust: { warmth: -45, saturation: -10 } },
{ id: 'fade', label: 'Fade', adjust: { fade: 45, contrast: -15 } },
{ id: 'mono', label: 'Mono', adjust: { saturation: -100, contrast: 20 } },
{
id: 'noir',
label: 'Noir',
adjust: { saturation: -100, contrast: 45, exposure: -15, vignette: 50 },
},
]
export const defaultEdit: Edit = {
filter: 'none',
adjust: { exposure: 0, contrast: 0, saturation: 0, warmth: 0, hue: 0, fade: 0, vignette: 0 },
crop: {
aspect: 'original',
zoom: 1,
x: 0,
y: 0,
straighten: 0,
turns: 0,
flipX: false,
flipY: false,
},
}
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
export function combined(edit: Edit): Adjust {
const preset = filters.find((f) => f.id === edit.filter)?.adjust ?? {}
const result = { ...edit.adjust }
for (const { key, min, max } of adjustments) {
result[key] = clamp(edit.adjust[key] + (preset[key] ?? 0), min, max)
}
return result
}
export function ratioOf(edit: Edit) {
const ratio = aspects.find((a) => a.id === edit.crop.aspect)?.ratio ?? 4 / 5
return edit.crop.turns % 2 && edit.crop.aspect === 'original' ? 1 / ratio : ratio
}
export function coverScale(edit: Edit) {
const r = ratioOf(edit)
const angle = ((edit.crop.turns * 90 + edit.crop.straighten) * Math.PI) / 180
const cos = Math.abs(Math.cos(angle))
const sin = Math.abs(Math.sin(angle))
return Math.max(cos + sin / r, sin * r + cos)
}
export function panLimit(edit: Edit) {
return ((edit.crop.zoom * coverScale(edit) - 1) / 2) * 100
}'use client'
import {
Crop,
FlipHorizontal2,
FlipVertical2,
RotateCcw,
RotateCw,
Sparkles,
SunMedium,
} from 'lucide-react'
import { Button } from '@/registry/dashboard/components/button/button'
import { cn } from '@/lib/utils'
import { adjustments, aspects, defaultEdit, filters, type Edit, type StageImage } from './edits'
import { Picture } from './picture'
import { Slider } from './slider'
export type Tab = 'adjust' | 'filters' | 'crop'
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'adjust', label: 'Adjust', icon: <SunMedium /> },
{ id: 'filters', label: 'Filters', icon: <Sparkles /> },
{ id: 'crop', label: 'Crop', icon: <Crop /> },
]
type PanelProps = {
tab: Tab
image?: StageImage
edit: Edit
onTab: (tab: Tab) => void
onEdit: (next: Edit, merge?: string) => void
onStart: () => void
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4">
<h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">{title}</h3>
{children}
</section>
)
}
export function Panel({ tab, image, edit, onTab, onEdit, onStart }: PanelProps) {
const edited = JSON.stringify(edit) !== JSON.stringify(defaultEdit)
const crop = (patch: Partial<Edit['crop']>, merge?: string) =>
onEdit({ ...edit, crop: { ...edit.crop, ...patch } }, merge)
return (
<aside className="flex flex-col gap-6 lg:sticky lg:top-20 lg:self-start">
<div
role="tablist"
aria-label="Edit tools"
className="grid grid-cols-3 gap-1 rounded-xl bg-muted p-1"
>
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => onTab(t.id)}
className={cn(
'flex h-8 items-center justify-center gap-1.5 rounded-lg text-sm font-medium transition-colors [&_svg]:size-3.5',
tab === t.id
? 'bg-card text-foreground shadow-(--shadow-button)'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t.icon}
{t.label}
</button>
))}
</div>
{tab === 'adjust' && (
<Section title="Light & colour">
{adjustments.map((a) => (
<Slider
key={a.key}
label={a.label}
min={a.min}
max={a.max}
value={edit.adjust[a.key]}
onStart={onStart}
onChange={(value) =>
onEdit({ ...edit, adjust: { ...edit.adjust, [a.key]: value } }, a.key)
}
/>
))}
</Section>
)}
{tab === 'filters' && (
<Section title="Presets">
<div className="grid grid-cols-3 gap-3">
{filters.map((f) => (
<button
key={f.id}
type="button"
aria-pressed={edit.filter === f.id}
onClick={() => onEdit({ ...edit, filter: f.id })}
className="group flex flex-col gap-1.5 text-left"
>
{image ? (
<Picture
image={image}
edit={{ ...edit, filter: f.id }}
crop={false}
className={cn(
'aspect-square w-full rounded-lg ring-offset-2 ring-offset-card transition',
edit.filter === f.id ? 'ring-2 ring-foreground' : 'group-hover:opacity-90',
)}
/>
) : (
<div className="aspect-square w-full rounded-lg bg-muted" />
)}
<span
className={cn(
'text-xs',
edit.filter === f.id ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{f.label}
</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">Presets stack on top of your adjustments.</p>
</Section>
)}
{tab === 'crop' && (
<>
<Section title="Aspect ratio">
<div className="flex flex-wrap gap-1.5">
{aspects.map((a) => (
<button
key={a.id}
type="button"
aria-pressed={edit.crop.aspect === a.id}
onClick={() => crop({ aspect: a.id, x: 0, y: 0 })}
className={cn(
'h-7 rounded-full border px-3 text-xs font-medium transition-colors',
edit.crop.aspect === a.id
? 'border-foreground bg-foreground text-background'
: 'text-muted-foreground hover:border-ring hover:text-foreground',
)}
>
{a.label}
</button>
))}
</div>
</Section>
<Section title="Transform">
<Slider
label="Zoom"
min={1}
max={3}
step={0.05}
origin={1}
format={(v) => `${v.toFixed(2)}×`}
value={edit.crop.zoom}
onStart={onStart}
onChange={(zoom) => crop({ zoom, x: 0, y: 0 }, 'zoom')}
/>
<Slider
label="Straighten"
min={-45}
max={45}
format={(v) => `${v}°`}
value={edit.crop.straighten}
onStart={onStart}
onChange={(straighten) => crop({ straighten, x: 0, y: 0 }, 'straighten')}
/>
<div className="grid grid-cols-4 gap-1.5">
<Button
size="icon"
className="w-full"
aria-label="Rotate left"
onClick={() => crop({ turns: (edit.crop.turns + 3) % 4, x: 0, y: 0 })}
>
<RotateCcw />
</Button>
<Button
size="icon"
className="w-full"
aria-label="Rotate right"
onClick={() => crop({ turns: (edit.crop.turns + 1) % 4, x: 0, y: 0 })}
>
<RotateCw />
</Button>
<Button
size="icon"
className="w-full"
aria-label="Flip horizontal"
aria-pressed={edit.crop.flipX}
onClick={() => crop({ flipX: !edit.crop.flipX })}
>
<FlipHorizontal2 />
</Button>
<Button
size="icon"
className="w-full"
aria-label="Flip vertical"
aria-pressed={edit.crop.flipY}
onClick={() => crop({ flipY: !edit.crop.flipY })}
>
<FlipVertical2 />
</Button>
</div>
<p className="text-xs text-muted-foreground">Drag the image to reposition it.</p>
</Section>
</>
)}
<div className="border-t pt-4">
<Button
variant="ghost"
className="w-full"
disabled={!edited}
onClick={() => onEdit(defaultEdit)}
>
Reset all edits
</Button>
</div>
</aside>
)
}import { cn } from '@/lib/utils'
import { combined, coverScale, type Edit, type StageImage } from './edits'
function Art({ image }: { image: StageImage }) {
if (image.src) {
return <img src={image.src} alt="" draggable={false} className="size-full object-cover" />
}
return (
<div className="size-full" style={{ background: image.background }}>
<svg
viewBox="0 0 200 250"
className="size-full"
style={{ transform: `scale(${image.scale ?? 1})` }}
aria-hidden
>
<ellipse cx="100" cy="228" rx="70" ry="8" fill="rgb(0 0 0 / 0.12)" />
<path
d="M72 34 L100 44 L128 34 L166 56 L182 112 L160 120 L154 98 L154 222 L46 222 L46 98 L40 120 L18 112 L34 56 Z"
fill={image.fill}
/>
<path d="M72 34 L100 64 L128 34" fill="none" stroke="rgb(0 0 0 / 0.25)" strokeWidth="3" />
<path d="M100 64 V222" stroke="rgb(0 0 0 / 0.2)" strokeWidth="2" />
{[84, 112, 140, 168, 196].map((y) => (
<circle key={y} cx="104" cy={y} r="2.5" fill="rgb(255 255 255 / 0.6)" />
))}
<rect x="60" y="92" width="26" height="28" rx="3" fill="rgb(0 0 0 / 0.12)" />
<rect x="114" y="92" width="26" height="28" rx="3" fill="rgb(0 0 0 / 0.12)" />
</svg>
</div>
)
}
export function Picture({
image,
edit,
crop = true,
className,
}: {
image: StageImage
edit: Edit
crop?: boolean
className?: string
}) {
const a = combined(edit)
const { zoom, x, y, straighten, turns, flipX, flipY } = edit.crop
const scale = crop ? zoom * coverScale(edit) : 1
const transform = crop
? `translate(${x}%, ${y}%) rotate(${turns * 90 + straighten}deg) scale(${scale * (flipX ? -1 : 1)}, ${scale * (flipY ? -1 : 1)})`
: undefined
const filter = [
`brightness(${1 + a.exposure / 200})`,
`contrast(${1 + a.contrast / 100 - a.fade / 400})`,
`saturate(${1 + a.saturation / 100})`,
`hue-rotate(${a.hue}deg)`,
].join(' ')
return (
<div className={cn('relative overflow-hidden', className)}>
<div className="absolute inset-0" style={{ transform, filter }}>
<Art image={image} />
</div>
{a.warmth !== 0 && (
<div
className="absolute inset-0 mix-blend-soft-light"
style={{
background: a.warmth > 0 ? '#ff8a00' : '#0066ff',
opacity: (Math.abs(a.warmth) / 100) * 0.6,
}}
/>
)}
{a.fade > 0 && (
<div className="absolute inset-0 bg-white" style={{ opacity: (a.fade / 100) * 0.3 }} />
)}
{a.vignette > 0 && (
<div
className="absolute inset-0"
style={{
background: `radial-gradient(ellipse at center, transparent 45%, rgb(0 0 0 / ${(a.vignette / 100) * 0.75}))`,
}}
/>
)}
</div>
)
}'use client'
import { useRef } from 'react'
import { cn } from '@/lib/utils'
type SliderProps = {
label: string
value: number
min: number
max: number
step?: number
origin?: number
format?: (value: number) => string
onStart?: () => void
onChange: (value: number) => void
}
export function Slider({
label,
value,
min,
max,
step = 1,
origin = Math.max(min, Math.min(max, 0)),
format = (v) => (v > 0 && min < 0 ? `+${v}` : `${v}`),
onStart,
onChange,
}: SliderProps) {
const track = useRef<HTMLDivElement>(null)
const pct = (v: number) => ((v - min) / (max - min)) * 100
const snap = (v: number) => {
const next = Math.round(Math.min(max, Math.max(min, v)) / step) * step
return Number(next.toFixed(4))
}
const fromPointer = (clientX: number) => {
const rect = track.current!.getBoundingClientRect()
onChange(snap(min + ((clientX - rect.left) / rect.width) * (max - min)))
}
const from = Math.min(pct(value), pct(origin))
const to = Math.max(pct(value), pct(origin))
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<button
type="button"
onClick={() => {
onStart?.()
onChange(origin)
}}
title="Reset"
className={cn(
'rounded px-1 font-mono text-xs tabular-nums',
value === origin ? 'text-muted-foreground' : 'text-foreground hover:bg-muted',
)}
>
{format(value)}
</button>
</div>
<div
ref={track}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={format(value)}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId)
onStart?.()
fromPointer(e.clientX)
}}
onPointerMove={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e.clientX)
}}
onDoubleClick={() => {
onStart?.()
onChange(origin)
}}
onKeyDown={(e) => {
const big = (max - min) / 10
const moves: Record<string, number> = {
ArrowLeft: -step,
ArrowDown: -step,
ArrowRight: step,
ArrowUp: step,
PageDown: -big,
PageUp: big,
}
let next: number
if (e.key in moves) next = snap(value + moves[e.key] * (e.shiftKey ? 10 : 1))
else if (e.key === 'Home') next = min
else if (e.key === 'End') next = max
else return
e.preventDefault()
if (!e.repeat) onStart?.()
onChange(next)
}}
className="group relative flex h-5 cursor-pointer touch-none items-center rounded-full outline-none"
>
<div className="absolute inset-x-0 h-1 rounded-full bg-muted" />
<div
className="absolute h-1 rounded-full bg-foreground"
style={{ left: `${from}%`, width: `${to - from}%` }}
/>
{min < origin && (
<div
className="absolute h-2.5 w-px -translate-x-1/2 bg-muted-foreground/50"
style={{ left: `${pct(origin)}%` }}
/>
)}
<div
className="absolute size-4 -translate-x-1/2 rounded-full border bg-card shadow-sm transition-transform group-focus-visible:ring-2 group-focus-visible:ring-ring/60 group-active:scale-110"
style={{ left: `${pct(value)}%` }}
/>
</div>
</div>
)
}'use client'
import { useRef, useState } from 'react'
import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { defaultEdit, panLimit, ratioOf, type Edit, type StageImage } from './edits'
import { Picture } from './picture'
type StageProps = {
images: StageImage[]
edits: Record<string, Edit>
active: number
cropping: boolean
comparing: boolean
onSelect: (index: number) => void
onImages: (images: StageImage[]) => void
onPan: (x: number, y: number, start: boolean) => void
}
const fromFile = (file: File): StageImage => ({
id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2, 7)}`,
name: file.name.replace(/\.[^.]+$/, ''),
src: URL.createObjectURL(file),
})
export function Stage({
images,
edits,
active,
cropping,
comparing,
onSelect,
onImages,
onPan,
}: StageProps) {
const replaceInput = useRef<HTMLInputElement>(null)
const addInput = useRef<HTMLInputElement>(null)
const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null)
const [dragging, setDragging] = useState(false)
const current = images[active]
const edit = (current && edits[current.id]) ?? defaultEdit
const shown = comparing ? defaultEdit : edit
const ratio = ratioOf(shown)
const replace = (file?: File) => {
if (!file?.type.startsWith('image/')) return
if (!current) return onImages([fromFile(file)])
onImages(images.map((image, i) => (i === active ? fromFile(file) : image)))
}
const add = (files: FileList | null) => {
const added = [...(files ?? [])].filter((f) => f.type.startsWith('image/')).map(fromFile)
if (!added.length) return
onImages([...images, ...added])
onSelect(images.length)
}
const remove = () => {
onImages(images.filter((_, i) => i !== active))
onSelect(Math.max(0, active - 1))
}
return (
<div className="flex w-full flex-col items-center gap-5">
<div
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
replace(e.dataTransfer.files[0])
}}
className={cn(
'group relative overflow-hidden rounded-2xl bg-muted shadow-(--shadow-card) transition-[width,aspect-ratio] duration-300 motion-reduce:transition-none',
dragging && 'ring-2 ring-ring ring-offset-4',
)}
style={{ aspectRatio: ratio, width: `min(100%, calc(min(64svh, 600px) * ${ratio}))` }}
>
{current ? (
<div
className={cn('size-full', cropping && 'cursor-grab touch-none active:cursor-grabbing')}
onPointerDown={(e) => {
if (!cropping) return
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = { px: e.clientX, py: e.clientY, x: edit.crop.x, y: edit.crop.y }
onPan(edit.crop.x, edit.crop.y, true)
}}
onPointerMove={(e) => {
if (!drag.current) return
const rect = e.currentTarget.getBoundingClientRect()
const limit = panLimit(edit)
const clamp = (v: number) =>
Math.round(Math.max(-limit, Math.min(limit, v)) * 10) / 10
onPan(
clamp(drag.current.x + ((e.clientX - drag.current.px) / rect.width) * 100),
clamp(drag.current.y + ((e.clientY - drag.current.py) / rect.height) * 100),
false,
)
}}
onPointerUp={() => (drag.current = null)}
onPointerCancel={() => (drag.current = null)}
>
<Picture image={current} edit={shown} className="size-full" />
</div>
) : (
<button
type="button"
onClick={() => replaceInput.current?.click()}
className="flex size-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground"
>
<ImagePlus className="size-6" />
Drop an image or click to upload
</button>
)}
{cropping && current && (
<div className="pointer-events-none absolute inset-0 grid grid-cols-3 grid-rows-3 border-2 border-white/80">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className="border-[0.5px] border-white/40" />
))}
</div>
)}
{comparing && (
<span className="absolute top-3 left-3 rounded-md bg-black/60 px-2 py-1 text-xs font-medium text-white">
Original
</span>
)}
{current && !cropping && (
<div className="absolute inset-x-0 bottom-0 flex justify-center gap-2 bg-gradient-to-t from-black/50 to-transparent p-4 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<button
type="button"
onClick={() => replaceInput.current?.click()}
className="flex h-8 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium text-neutral-900"
>
<Upload className="size-3.5" /> Replace
</button>
<button
type="button"
onClick={remove}
className="flex h-8 items-center gap-1.5 rounded-lg bg-white/90 px-3 text-sm font-medium text-red-600"
>
<Trash2 className="size-3.5" /> Remove
</button>
</div>
)}
{dragging && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 text-sm font-medium">
Drop to replace
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-2">
{images.map((image, i) => (
<button
key={image.id}
type="button"
aria-label={`Edit ${image.name}`}
aria-pressed={i === active}
onClick={() => onSelect(i)}
className={cn(
'size-14 overflow-hidden rounded-lg ring-offset-2 ring-offset-card transition',
i === active ? 'ring-2 ring-foreground' : 'opacity-70 hover:opacity-100',
)}
>
<Picture image={image} edit={edits[image.id] ?? defaultEdit} className="size-full" />
</button>
))}
<button
type="button"
aria-label="Add images"
onClick={() => addInput.current?.click()}
className="flex size-14 items-center justify-center rounded-lg border border-dashed text-muted-foreground hover:border-ring hover:text-foreground"
>
<Plus className="size-4" />
</button>
</div>
<input
ref={replaceInput}
type="file"
accept="image/*"
hidden
onChange={(e) => {
replace(e.target.files?.[0])
e.target.value = ''
}}
/>
<input
ref={addInput}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => {
add(e.target.files)
e.target.value = ''
}}
/>
</div>
)
}