{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "glass-image-editor", "type": "registry:ui", "title": "Glass Image Editor", "description": "iOS 26 liquid-glass image editor with drop zone, zoom/pan canvas, thumbnail strip, and toolbar.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/glass-image-editor.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/* ── sound ─────────────────────────────────────────────── */\nlet _a: AudioContext, _b: AudioBuffer;\nconst tick = () => {\n if (typeof window === \"undefined\") return;\n if (!_a) {\n _a = new AudioContext();\n _b = _a.createBuffer(1, (_a.sampleRate * 0.003) | 0, _a.sampleRate);\n const d = _b.getChannelData(0);\n for (let i = 0; i < d.length; i++)\n d[i] = (Math.random() * 2 - 1) * (1 - i / d.length) ** 4;\n }\n const s = _a.createBufferSource();\n s.buffer = _b;\n const g = _a.createGain();\n g.gain.value = 0.08;\n s.connect(g).connect(_a.destination);\n s.start();\n};\n\n/* ── theme ─────────────────────────────────────────────── */\n/* Light: frosted-white panels, soft outer shadows */\n/* Dark : white-tint glass, inner glow */\nconst IMG_CSS = `\n.gi{\n --gi-glass:linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.62));\n --gi-border:rgba(0,0,0,0.06);\n --gi-shadow:0 0 1px rgba(0,0,0,0.04),0 2px 8px rgba(0,0,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);\n --gi-dim:rgba(0,0,0,0.35);\n --gi-mid:rgba(0,0,0,0.5);\n --gi-hi:rgba(0,0,0,0.88);\n --gi-bar:rgba(255,255,255,0.85);\n --gi-bar-border:rgba(0,0,0,0.08);\n --gi-bar-shadow:0 2px 12px rgba(0,0,0,0.06);\n --gi-active:rgba(0,0,0,0.06);\n --gi-canvas:rgba(0,0,0,0.03)\n}\n.dark .gi,[data-theme=\"dark\"] .gi{\n --gi-glass:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));\n --gi-border:rgba(255,255,255,0.07);\n --gi-shadow:0 1px 3px rgba(0,0,0,0.08),inset 0 1px 0 rgba(255,255,255,0.04);\n --gi-dim:rgba(255,255,255,0.28);\n --gi-mid:rgba(255,255,255,0.5);\n --gi-hi:rgba(255,255,255,0.88);\n --gi-bar:rgba(255,255,255,0.08);\n --gi-bar-border:rgba(255,255,255,0.06);\n --gi-bar-shadow:0 2px 12px rgba(0,0,0,0.15);\n --gi-active:rgba(255,255,255,0.08);\n --gi-canvas:rgba(0,0,0,0.2)\n}`;\n\nconst I = {\n glass: \"var(--gi-glass)\",\n border: \"var(--gi-border)\",\n shadow: \"var(--gi-shadow)\",\n dim: \"var(--gi-dim)\",\n mid: \"var(--gi-mid)\",\n hi: \"var(--gi-hi)\",\n bar: \"var(--gi-bar)\",\n barBorder: \"var(--gi-bar-border)\",\n barShadow: \"var(--gi-bar-shadow)\",\n active: \"var(--gi-active)\",\n canvas: \"var(--gi-canvas)\",\n};\n\n/* ── context ───────────────────────────────────────────── */\ninterface State {\n images: string[];\n active: number;\n zoom: number;\n rotation: number;\n pan: { x: number; y: number };\n}\n\ninterface Ctx extends State {\n add: (files: FileList | File[]) => void;\n remove: (i: number) => void;\n select: (i: number) => void;\n setZoom: (z: number) => void;\n rotate: () => void;\n setPan: (p: { x: number; y: number }) => void;\n sound: boolean;\n}\n\nconst RESET = { zoom: 1, rotation: 0, pan: { x: 0, y: 0 } };\nconst Cx = React.createContext(null!);\n\n/* ═══════════════════════════════════════════════════════════\n ImageEditor — wrapper + state provider\n ═══════════════════════════════════════════════════════════ */\ninterface ImageEditorProps {\n children: React.ReactNode;\n sound?: boolean;\n style?: React.CSSProperties;\n onChange?: (images: string[]) => void;\n}\n\nfunction ImageEditor({\n children,\n sound = true,\n style,\n onChange,\n}: ImageEditorProps) {\n const [s, set] = React.useState({\n images: [],\n active: 0,\n ...RESET,\n });\n\n const add = React.useCallback(\n (files: FileList | File[]) => {\n const valid = Array.from(files).filter((f) =>\n f.type.startsWith(\"image/\"),\n );\n if (!valid.length) return;\n Promise.all(\n valid.map(\n (f) =>\n new Promise((res) => {\n const r = new FileReader();\n r.onload = () => res(r.result as string);\n r.readAsDataURL(f);\n }),\n ),\n ).then((urls) => {\n set((p) => {\n const next = [...p.images, ...urls];\n onChange?.(next);\n return {\n images: next,\n active: p.images.length === 0 ? 0 : p.active,\n ...RESET,\n };\n });\n if (sound) tick();\n });\n },\n [sound, onChange],\n );\n\n const remove = React.useCallback(\n (i: number) => {\n set((p) => {\n const next = p.images.filter((_, idx) => idx !== i);\n onChange?.(next);\n return {\n images: next,\n active: Math.min(p.active, Math.max(0, next.length - 1)),\n ...RESET,\n };\n });\n if (sound) tick();\n },\n [sound, onChange],\n );\n\n const select = React.useCallback(\n (i: number) => {\n set((p) => ({ ...p, active: i, ...RESET }));\n if (sound) tick();\n },\n [sound],\n );\n\n const setZoom = React.useCallback((z: number) => {\n set((p) => {\n const clamped = Math.max(1, Math.min(5, z));\n return {\n ...p,\n zoom: clamped,\n pan: clamped <= 1 ? { x: 0, y: 0 } : p.pan,\n };\n });\n }, []);\n\n const rotate = React.useCallback(() => {\n set((p) => ({ ...p, rotation: p.rotation + 90 }));\n if (sound) tick();\n }, [sound]);\n\n const setPan = React.useCallback((pan: { x: number; y: number }) => {\n set((p) => ({ ...p, pan }));\n }, []);\n\n return (\n \n \n \n {children}\n \n \n );\n}\n\n/* ═══════════════════════════════════════════════════════════\n ImageDropZone — glass upload area\n ═══════════════════════════════════════════════════════════ */\ninterface ImageDropZoneProps {\n accept?: string;\n multiple?: boolean;\n}\n\nfunction ImageDropZone({\n accept = \"image/*\",\n multiple = true,\n}: ImageDropZoneProps) {\n const { add, images } = React.useContext(Cx);\n const ref = React.useRef(null);\n const [over, setOver] = React.useState(false);\n\n if (images.length > 0) return null;\n\n return (\n {\n e.preventDefault();\n setOver(true);\n }}\n onDragLeave={() => setOver(false)}\n onDrop={(e) => {\n e.preventDefault();\n setOver(false);\n if (e.dataTransfer.files.length) add(e.dataTransfer.files);\n }}\n onClick={() => ref.current?.click()}\n whileTap={{ scale: 0.985 }}\n style={{\n borderRadius: 14,\n background: I.glass,\n border: `1px solid ${over ? I.mid : I.border}`,\n boxShadow: I.shadow,\n backdropFilter: \"blur(24px)\",\n WebkitBackdropFilter: \"blur(24px)\",\n padding: \"52px 24px\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: 8,\n cursor: \"pointer\",\n transition: \"border-color 150ms\",\n }}\n >\n {\n if (e.target.files?.length) add(e.target.files);\n e.target.value = \"\";\n }}\n style={{ display: \"none\" }}\n />\n
\n \n \n \n \n \n
\n
\n Drop image here\n
\n
or click to browse
\n \n );\n}\n\n/* ═══════════════════════════════════════════════════════════\n ImageCanvas — main image view (zoom · pan · rotate)\n ═══════════════════════════════════════════════════════════ */\nfunction ImageCanvas() {\n const { images, active, zoom, rotation, pan, setZoom, setPan } =\n React.useContext(Cx);\n const boxRef = React.useRef(null);\n const dragging = React.useRef(false);\n const origin = React.useRef({ x: 0, y: 0 });\n const panOrigin = React.useRef({ x: 0, y: 0 });\n const zoomRef = React.useRef(zoom);\n zoomRef.current = zoom;\n\n /* wheel → zoom (non-passive so we can preventDefault) */\n React.useEffect(() => {\n const el = boxRef.current;\n if (!el) return;\n const h = (e: WheelEvent) => {\n e.preventDefault();\n const next = zoomRef.current + -e.deltaY * 0.003;\n setZoom(next);\n };\n el.addEventListener(\"wheel\", h, { passive: false });\n return () => el.removeEventListener(\"wheel\", h);\n }, [setZoom]);\n\n const src = images[active];\n if (!src) return null;\n\n return (\n {\n if (zoom <= 1) return;\n dragging.current = true;\n origin.current = { x: e.clientX, y: e.clientY };\n panOrigin.current = { ...pan };\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n }}\n onPointerMove={(e) => {\n if (!dragging.current) return;\n setPan({\n x: panOrigin.current.x + (e.clientX - origin.current.x),\n y: panOrigin.current.y + (e.clientY - origin.current.y),\n });\n }}\n onPointerUp={() => {\n dragging.current = false;\n }}\n style={{\n borderRadius: 14,\n background: I.canvas,\n border: `1px solid ${I.border}`,\n boxShadow: I.shadow,\n overflow: \"hidden\",\n position: \"relative\",\n height: 280,\n cursor: zoom > 1 ? \"grab\" : \"default\",\n userSelect: \"none\",\n touchAction: \"none\",\n }}\n >\n \n \n \n\n {/* zoom badge */}\n \n {zoom > 1 && (\n \n {Math.round(zoom * 100)}%\n \n )}\n \n \n );\n}\n\n/* ═══════════════════════════════════════════════════════════\n ImageStrip — thumbnail row\n ═══════════════════════════════════════════════════════════ */\ninterface ImageStripProps {\n showAdd?: boolean;\n accept?: string;\n}\n\nfunction ImageStrip({ showAdd = true, accept = \"image/*\" }: ImageStripProps) {\n const { images, active, select, add } = React.useContext(Cx);\n const ref = React.useRef(null);\n\n if (!images.length) return null;\n\n return (\n \n {images.map((src, i) => (\n select(i)}\n whileTap={{ scale: 0.92 }}\n style={{\n width: 48,\n height: 48,\n borderRadius: 10,\n overflow: \"hidden\",\n border:\n i === active ? `2px solid ${I.hi}` : `1px solid ${I.border}`,\n opacity: i === active ? 1 : 0.55,\n cursor: \"pointer\",\n padding: 0,\n background: I.canvas,\n flexShrink: 0,\n transition: \"opacity 150ms, border-color 150ms\",\n }}\n >\n \n \n ))}\n\n {showAdd && (\n <>\n ref.current?.click()}\n whileTap={{ scale: 0.92 }}\n style={{\n width: 48,\n height: 48,\n borderRadius: 10,\n border: `1px dashed ${I.border}`,\n background: \"transparent\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n flexShrink: 0,\n color: I.dim,\n }}\n >\n \n \n \n \n \n {\n if (e.target.files?.length) add(e.target.files);\n e.target.value = \"\";\n }}\n style={{ display: \"none\" }}\n />\n \n )}\n \n );\n}\n\n/* ═══════════════════════════════════════════════════════════\n ImageBar — glass toolbar\n ═══════════════════════════════════════════════════════════ */\nfunction ImageBar() {\n const ctx = React.useContext(Cx);\n\n if (!ctx.images.length) return null;\n\n const btns: {\n label: string;\n icon: React.ReactNode;\n fn: () => void;\n off?: boolean;\n }[] = [\n {\n label: \"Rotate\",\n icon: (\n \n \n \n \n ),\n fn: ctx.rotate,\n },\n {\n label: \"Zoom out\",\n icon: (\n \n \n \n \n ),\n fn: () => ctx.setZoom(ctx.zoom - 0.25),\n off: ctx.zoom <= 1,\n },\n {\n label: \"Zoom in\",\n icon: (\n \n \n \n \n \n ),\n fn: () => ctx.setZoom(ctx.zoom + 0.25),\n off: ctx.zoom >= 5,\n },\n {\n label: \"Remove\",\n icon: (\n \n \n \n \n ),\n fn: () => ctx.remove(ctx.active),\n },\n ];\n\n return (\n \n {btns.map((b) => (\n \n ))}\n \n );\n}\n\n/* ── toolbar button ───────────────────────────────────── */\nfunction Btn({\n label,\n icon,\n fn,\n off,\n}: {\n label: string;\n icon: React.ReactNode;\n fn: () => void;\n off?: boolean;\n}) {\n const [h, setH] = React.useState(false);\n\n return (\n setH(true)}\n onMouseLeave={() => setH(false)}\n style={{\n width: 36,\n height: 32,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n borderRadius: 8,\n border: \"none\",\n background: h && !off ? I.active : \"transparent\",\n color: off ? I.dim : I.hi,\n cursor: off ? \"default\" : \"pointer\",\n opacity: off ? 0.4 : 1,\n transition: \"background 120ms, opacity 120ms\",\n padding: 0,\n }}\n >\n {icon}\n \n );\n}\n\n/* ── exports ─────────────────────────────────────────── */\nexport { ImageEditor, ImageDropZone, ImageCanvas, ImageStrip, ImageBar };\nexport type { ImageEditorProps, ImageDropZoneProps, ImageStripProps };\n", "type": "registry:ui", "target": "components/ruixen/glass-image-editor.tsx" } ] }