{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-curves",
  "title": "Curves",
  "description": "RGB and per-channel tone curves over a live histogram.",
  "dependencies": [
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "context-menu",
    "field",
    "https://photocn.dev/r/image-editor-panel-header.json",
    "tabs",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/curves-panel.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\nimport {\n  clamp,\n  type CurveChannelPoints,\n  type CurveChannels,\n  type CurvePoint,\n} from \"photocn\";\nimport { useControllableState } from \"photocn/hooks\";\nimport { drawRgbHistogram, type RgbHistogram } from \"photocn/dom\";\nimport { useImageEditor } from \"photocn/react\";\n\nimport { PanelResetHeader } from \"./panel-header\";\nimport { FieldGroup, FieldLegend, FieldSet } from \"@/components/ui/field\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CurvesChannel = \"rgb\" | \"r\" | \"g\" | \"b\";\nexport type { CurveChannelPoints, CurveChannels, CurvePoint };\n\nexport interface CurvesPanelProps {\n  value: CurveChannels | null;\n  activeChannel?: CurvesChannel;\n  disabled?: boolean;\n  className?: string;\n  onChange: (next: CurveChannels) => void;\n  onCommit?: () => void;\n  onActiveChannelChange?: (channel: CurvesChannel) => void;\n  onReset?: () => void;\n  size?: number;\n  /** Live RGB histogram drawn as the curves grid backdrop. Adjusting curves\n   *  visually appears to \"shape\" the histogram, like Lightroom. Pass `null`\n   *  to render without a backdrop. */\n  histogram?: RgbHistogram | null;\n}\n\nconst CHANNEL_ORDER: readonly CurvesChannel[] = [\"rgb\", \"r\", \"g\", \"b\"];\nconst CHANNEL_INDEX: Record<CurvesChannel, 0 | 1 | 2 | 3> = {\n  rgb: 0,\n  r: 1,\n  g: 2,\n  b: 3,\n};\nconst CHANNEL_LABEL: Record<CurvesChannel, string> = {\n  rgb: \"RGB\",\n  r: \"R\",\n  g: \"G\",\n  b: \"B\",\n};\n// Stroke colors per channel (active curve).\nconst CHANNEL_STROKE: Record<CurvesChannel, string> = {\n  rgb: \"currentColor\",\n  r: \"#ef4444\",\n  g: \"#22c55e\",\n  b: \"#3b82f6\",\n};\n\nconst DEFAULT_SIZE = 240;\n// Curves operate in normalized [0,1] coordinate space — that's the contract\n// of `filterCurves` (see packages/mini-gl/src/filters/filterCurves.ts).\n// Default points sit on the quarter grid so users have draggable handles\n// without clicking first; staying on y=x keeps it a no-op until dragged.\nconst IDENTITY_POINTS: CurvePoint[] = [\n  [0, 0],\n  [0.25, 0.25],\n  [0.5, 0.5],\n  [0.75, 0.75],\n  [1, 1],\n];\n// Minimum gap between adjacent control points (~1/255 — one byte of LUT\n// precision) so neighbours never collide.\nconst POINT_EPSILON = 1 / 255;\n\nfunction emptyChannels(): CurveChannels {\n  return [null, null, null, null];\n}\n\nfunction ensureChannels(value: CurveChannels | null): CurveChannels {\n  if (value) return [value[0], value[1], value[2], value[3]];\n  return emptyChannels();\n}\n\nfunction getActivePoints(\n  value: CurveChannels | null,\n  channel: CurvesChannel,\n): CurvePoint[] {\n  const idx = CHANNEL_INDEX[channel];\n  const existing = value?.[idx];\n  if (existing && existing.length >= 2) return existing;\n  return IDENTITY_POINTS;\n}\n\nfunction isIdentity(points: CurvePoint[] | null | undefined): boolean {\n  if (!points) return true;\n  // Any set whose points all lie on y=x is the identity curve.\n  return points.every(([x, y]) => Math.abs(y - x) < POINT_EPSILON);\n}\n\nfunction hasAnyEdits(value: CurveChannels | null): boolean {\n  if (!value) return false;\n  return value.some((channel) => channel !== null && !isIdentity(channel));\n}\n\n// Monotonic cubic (Fritsch–Carlson) — produces a smooth curve that does not\n// overshoot, which is what photo curves need.\nfunction buildSmoothPath(points: CurvePoint[], size: number): string {\n  if (points.length < 2) return \"\";\n  const sorted = [...points].sort((a, b) => a[0] - b[0]);\n  const n = sorted.length;\n  const xs = sorted.map((p) => p[0]);\n  const ys = sorted.map((p) => p[1]);\n\n  const dx: number[] = new Array(n - 1);\n  const dy: number[] = new Array(n - 1);\n  const slopes: number[] = new Array(n - 1);\n  for (let i = 0; i < n - 1; i++) {\n    dx[i] = xs[i + 1] - xs[i] || 1e-9;\n    dy[i] = ys[i + 1] - ys[i];\n    slopes[i] = dy[i] / dx[i];\n  }\n  const tangents: number[] = new Array(n);\n  tangents[0] = slopes[0];\n  tangents[n - 1] = slopes[n - 2];\n  for (let i = 1; i < n - 1; i++) {\n    if (slopes[i - 1] * slopes[i] <= 0) tangents[i] = 0;\n    else tangents[i] = (slopes[i - 1] + slopes[i]) / 2;\n  }\n  for (let i = 0; i < n - 1; i++) {\n    if (slopes[i] === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / slopes[i];\n    const b = tangents[i + 1] / slopes[i];\n    const h = a * a + b * b;\n    if (h > 9) {\n      const t = 3 / Math.sqrt(h);\n      tangents[i] = t * a * slopes[i];\n      tangents[i + 1] = t * b * slopes[i];\n    }\n  }\n\n  const toScreenX = (x: number) => x * size;\n  const toScreenY = (y: number) => size - y * size;\n\n  let d = `M ${toScreenX(xs[0]).toFixed(2)} ${toScreenY(ys[0]).toFixed(2)}`;\n  for (let i = 0; i < n - 1; i++) {\n    const h = dx[i];\n    const x1 = xs[i] + h / 3;\n    const y1 = ys[i] + (tangents[i] * h) / 3;\n    const x2 = xs[i + 1] - h / 3;\n    const y2 = ys[i + 1] - (tangents[i + 1] * h) / 3;\n    d += ` C ${toScreenX(x1).toFixed(2)} ${toScreenY(y1).toFixed(2)}, ${toScreenX(x2).toFixed(2)} ${toScreenY(y2).toFixed(2)}, ${toScreenX(xs[i + 1]).toFixed(2)} ${toScreenY(ys[i + 1]).toFixed(2)}`;\n  }\n  return d;\n}\n\n// Linear-interp y at x along a sorted point list (used for inserting new\n// control points that sit on the current curve).\nfunction interpolateY(points: CurvePoint[], x: number): number {\n  if (points.length === 0) return x;\n  if (x <= points[0][0]) return points[0][1];\n  if (x >= points[points.length - 1][0]) return points[points.length - 1][1];\n  for (let i = 0; i < points.length - 1; i++) {\n    const [x0, y0] = points[i];\n    const [x1, y1] = points[i + 1];\n    if (x >= x0 && x <= x1) {\n      const t = (x - x0) / (x1 - x0 || 1);\n      return y0 + t * (y1 - y0);\n    }\n  }\n  return x;\n}\n\ninterface DragState {\n  pointerId: number;\n  index: number;\n}\n\nexport function CurvesPanel({\n  value,\n  activeChannel: activeChannelProp,\n  disabled = false,\n  className,\n  onChange,\n  onCommit,\n  onActiveChannelChange,\n  onReset,\n  size = DEFAULT_SIZE,\n  histogram,\n}: CurvesPanelProps) {\n  const panelId = useId();\n  const tabsLabelId = `${panelId}-channel`;\n\n  const [activeChannel, applyChannelChange] = useControllableState<CurvesChannel>({\n    value: activeChannelProp,\n    defaultValue: \"rgb\",\n    onChange: onActiveChannelChange,\n  });\n\n  const svgRef = useRef<SVGSVGElement | null>(null);\n  const dragRef = useRef<DragState | null>(null);\n  const histogramCanvasRef = useRef<HTMLCanvasElement | null>(null);\n\n  // Repaint the histogram backdrop whenever the histogram data changes.\n  // Uses the existing `drawRgbHistogram` helper — same path as the sidebar\n  // histogram strip, just at a larger canvas size that mirrors the curve grid.\n  // Lighter alpha + dimmed channels so the backdrop reads as background and\n  // the active curve stays visually dominant.\n  useEffect(() => {\n    const canvas = histogramCanvasRef.current;\n    if (!canvas) return;\n    canvas.width = size;\n    canvas.height = size;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    if (!histogram || !histogram.pixels) {\n      ctx.clearRect(0, 0, size, size);\n      return;\n    }\n    drawRgbHistogram(ctx, histogram, {\n      // Match the channel stroke colors of the curves so the backdrop and\n      // the active curve feel like the same family.\n      red: CHANNEL_STROKE.r,\n      green: CHANNEL_STROKE.g,\n      blue: CHANNEL_STROKE.b,\n      alpha: 0.35,\n    });\n  }, [histogram, size]);\n\n  const dirty = hasAnyEdits(value);\n  const resetDisabled = disabled || !onReset || !dirty;\n\n  const activePoints = useMemo(\n    () => getActivePoints(value, activeChannel),\n    [value, activeChannel],\n  );\n  const sortedActive = useMemo(\n    () => [...activePoints].sort((a, b) => a[0] - b[0]),\n    [activePoints],\n  );\n  const lastIndex = sortedActive.length - 1;\n\n  const activePath = useMemo(\n    () => buildSmoothPath(sortedActive, size),\n    [sortedActive, size],\n  );\n  const ghostPaths = useMemo(() => {\n    return CHANNEL_ORDER.filter((c) => c !== activeChannel).map((c) => {\n      const idx = CHANNEL_INDEX[c];\n      const pts = value?.[idx];\n      if (!pts || isIdentity(pts)) return null;\n      return { channel: c, d: buildSmoothPath(pts, size) };\n    }).filter((entry): entry is { channel: CurvesChannel; d: string } => entry !== null);\n  }, [value, activeChannel, size]);\n\n  const handleChannelChange = (next: string) => {\n    const channel = (CHANNEL_ORDER as readonly string[]).includes(next)\n      ? (next as CurvesChannel)\n      : \"rgb\";\n    applyChannelChange(channel);\n  };\n\n  const writeChannelPoints = (points: CurvePoint[]) => {\n    const next = ensureChannels(value);\n    next[CHANNEL_INDEX[activeChannel]] = points;\n    onChange(next);\n  };\n\n  const eventToCurveCoords = (event: {\n    clientX: number;\n    clientY: number;\n  }): CurvePoint => {\n    const svg = svgRef.current;\n    if (!svg) return [0, 0];\n    const rect = svg.getBoundingClientRect();\n    const px = clamp(event.clientX - rect.left, 0, rect.width);\n    const py = clamp(event.clientY - rect.top, 0, rect.height);\n    const x = px / rect.width;\n    const y = 1 - py / rect.height;\n    return [x, y];\n  };\n\n  /** Remove a control point. No-ops on endpoints (x=0, x=1) — those are\n   *  structural anchors of the curve and removing them would leave the\n   *  curve undefined past either edge. */\n  const removePointAt = (index: number): void => {\n    if (disabled) return;\n    if (index === 0 || index === lastIndex) return;\n    const next = sortedActive.filter((_, i) => i !== index);\n    writeChannelPoints(next);\n    onCommit?.();\n  };\n\n  /** Insert a new control point at the given normalized 0..1 coordinates,\n   *  clamped to a sub-epsilon away from any existing point so neighbours\n   *  never collide. Identity-respecting: if `y` is omitted, we interpolate\n   *  the current curve at `x` so the new point starts on-curve. */\n  const addPointAt = (x: number, y?: number): void => {\n    if (disabled) return;\n    const insertMin = POINT_EPSILON;\n    const insertMax = 1 - POINT_EPSILON;\n    let insertX = clamp(x, insertMin, insertMax);\n    while (sortedActive.some((p) => Math.abs(p[0] - insertX) < POINT_EPSILON / 2)) {\n      insertX = clamp(insertX + POINT_EPSILON, insertMin, insertMax);\n      if (insertX >= insertMax) break;\n    }\n    const newY = clamp(\n      y !== undefined ? y : interpolateY(sortedActive, insertX),\n      0,\n      1,\n    );\n    const newPoint: CurvePoint = [insertX, newY];\n    const next: CurvePoint[] = [...sortedActive, newPoint].sort(\n      (a, b) => a[0] - b[0],\n    );\n    writeChannelPoints(next);\n    onCommit?.();\n  };\n\n  const handlePointPointerDown = (\n    event: ReactPointerEvent<SVGCircleElement>,\n    index: number,\n  ) => {\n    if (disabled) return;\n    event.stopPropagation();\n    // Shift-click removes a non-endpoint control point.\n    if (event.shiftKey) {\n      removePointAt(index);\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    dragRef.current = { pointerId: event.pointerId, index };\n  };\n\n  /** Double-click on a node removes it (alternative to shift-click).\n   *  Endpoints stay — `removePointAt` handles that no-op. */\n  const handlePointDoubleClick = (\n    event: ReactMouseEvent<SVGCircleElement>,\n    index: number,\n  ) => {\n    event.stopPropagation();\n    removePointAt(index);\n  };\n\n  const handlePointPointerMove = (\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => {\n    const drag = dragRef.current;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    const [rawX, rawY] = eventToCurveCoords(event);\n    const next = sortedActive.map((p) => [p[0], p[1]] as CurvePoint);\n    const isFirst = drag.index === 0;\n    const isLast = drag.index === lastIndex;\n    const minX = isFirst ? 0 : next[drag.index - 1][0] + POINT_EPSILON;\n    const maxX = isLast ? 1 : next[drag.index + 1][0] - POINT_EPSILON;\n    const x = isFirst ? 0 : isLast ? 1 : clamp(rawX, minX, maxX);\n    const y = clamp(rawY, 0, 1);\n    next[drag.index] = [x, y];\n    writeChannelPoints(next);\n  };\n\n  const handlePointPointerUp = (\n    event: ReactPointerEvent<SVGCircleElement>,\n  ) => {\n    const drag = dragRef.current;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    try {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    } catch {\n      // ignore: capture may already be released\n    }\n    dragRef.current = null;\n    onCommit?.();\n  };\n\n  const handleSurfacePointerDown = (\n    event: ReactPointerEvent<SVGSVGElement>,\n  ) => {\n    if (disabled) return;\n    if (event.target !== event.currentTarget) return; // points handle their own\n    // Right-click goes to the context menu, not surface-add.\n    if (event.button !== 0) return;\n    const [x] = eventToCurveCoords(event);\n    addPointAt(x);\n  };\n\n  // Context-menu target tracking. We use one ContextMenu wrapping the SVG;\n  // the menu items are conditional on what was right-clicked (a node circle\n  // vs. the empty surface). We capture the position + index in onContextMenu\n  // before the menu opens.\n  const [contextTarget, setContextTarget] = useState<\n    | { type: \"node\"; index: number }\n    | { type: \"surface\"; x: number; y: number }\n    | null\n  >(null);\n\n  const handleContextMenu = (event: ReactMouseEvent<SVGSVGElement>) => {\n    if (disabled) return;\n    const target = event.target as SVGElement;\n    const indexAttr = target.getAttribute(\"data-curve-index\");\n    if (indexAttr !== null) {\n      setContextTarget({ type: \"node\", index: Number(indexAttr) });\n    } else {\n      const [x, y] = eventToCurveCoords(event);\n      setContextTarget({ type: \"surface\", x, y });\n    }\n  };\n\n  const strokeColor = CHANNEL_STROKE[activeChannel];\n\n  return (\n    <TooltipProvider>\n      <section\n        aria-label=\"Curves\"\n        className={cn(\n          \"flex h-full min-w-0 flex-col bg-background text-foreground\",\n          className,\n        )}\n        data-slot=\"curves-panel\"\n      >\n        <PanelResetHeader\n          title=\"Curves\"\n          description=\"RGB and per-channel tone curves\"\n          onReset={onReset}\n          resetDisabled={resetDisabled}\n        />\n\n        <FieldGroup className=\"min-h-0 flex-1 gap-4 overflow-auto px-4 py-4\">\n          <FieldSet className=\"gap-3\">\n            <FieldLegend id={tabsLabelId} className=\"sr-only\">\n              Channel\n            </FieldLegend>\n            <Tabs\n              onValueChange={handleChannelChange}\n              value={activeChannel}\n            >\n              <TabsList className=\"grid w-full grid-cols-4\">\n                {CHANNEL_ORDER.map((channel) => (\n                  <Tooltip key={channel}>\n                    <TooltipTrigger\n                      render={\n                        <TabsTrigger\n                          aria-label={`${CHANNEL_LABEL[channel]} channel`}\n                          disabled={disabled}\n                          value={channel}\n                        >\n                          <span\n                            aria-hidden=\"true\"\n                            className=\"inline-block size-2 rounded-full\"\n                            style={{ backgroundColor: CHANNEL_STROKE[channel] }}\n                          />\n                          <span>{CHANNEL_LABEL[channel]}</span>\n                        </TabsTrigger>\n                      }\n                    />\n                    <TooltipContent side=\"bottom\">\n                      {channel === \"rgb\"\n                        ? \"Composite (applied first)\"\n                        : `${CHANNEL_LABEL[channel]} channel`}\n                    </TooltipContent>\n                  </Tooltip>\n                ))}\n              </TabsList>\n            </Tabs>\n\n            <div className=\"flex flex-col items-center gap-2\">\n              <div\n                className=\"relative overflow-hidden rounded-md border bg-muted/30\"\n                style={{ width: size, height: size }}\n              >\n                {/* Histogram backdrop. Pointer-events:none so all interaction\n                    falls through to the SVG curve editor sitting on top. */}\n                <canvas\n                  ref={histogramCanvasRef}\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 size-full\"\n                  width={size}\n                  height={size}\n                  style={{ pointerEvents: \"none\" }}\n                />\n                <ContextMenu>\n                <ContextMenuTrigger\n                  render={\n                    <svg\n                    ref={svgRef}\n                    className={cn(\n                      \"relative block touch-none\",\n                      disabled && \"pointer-events-none opacity-60\",\n                    )}\n                    height={size}\n                    onPointerDown={handleSurfacePointerDown}\n                    onContextMenu={handleContextMenu}\n                    role=\"img\"\n                    aria-label={`${CHANNEL_LABEL[activeChannel]} curve editor`}\n                    viewBox={`0 0 ${size} ${size}`}\n                    width={size}\n                  >\n                    {/* grid */}\n                    <g\n                      aria-hidden=\"true\"\n                      pointerEvents=\"none\"\n                      stroke=\"currentColor\"\n                      strokeOpacity={0.12}\n                    >\n                      {[1, 2, 3].map((i) => (\n                        <line\n                          key={`vx-${i}`}\n                          x1={(i * size) / 4}\n                          y1={0}\n                          x2={(i * size) / 4}\n                          y2={size}\n                        />\n                      ))}\n                      {[1, 2, 3].map((i) => (\n                        <line\n                          key={`hy-${i}`}\n                          x1={0}\n                          y1={(i * size) / 4}\n                          x2={size}\n                          y2={(i * size) / 4}\n                        />\n                      ))}\n                    </g>\n                    {/* identity diagonal */}\n                    <line\n                      aria-hidden=\"true\"\n                      pointerEvents=\"none\"\n                      stroke=\"currentColor\"\n                      strokeOpacity={0.2}\n                      strokeDasharray=\"4 4\"\n                      x1={0}\n                      y1={size}\n                      x2={size}\n                      y2={0}\n                    />\n                    {/* dimmed ghost curves for inactive channels */}\n                    {ghostPaths.map(({ channel, d }) => (\n                      <path\n                        key={channel}\n                        d={d}\n                        fill=\"none\"\n                        pointerEvents=\"none\"\n                        stroke={CHANNEL_STROKE[channel]}\n                        strokeOpacity={0.25}\n                        strokeWidth={1.5}\n                      />\n                    ))}\n                    {/* active curve — visual only; clicks fall through to the\n                        SVG surface handler so the hit target is the whole grid */}\n                    <path\n                      d={activePath}\n                      fill=\"none\"\n                      pointerEvents=\"none\"\n                      stroke={strokeColor}\n                      strokeWidth={2}\n                    />\n                    {/* control points */}\n                    {sortedActive.map((p, i) => {\n                      const cx = p[0] * size;\n                      const cy = size - p[1] * size;\n                      const isEndpoint = i === 0 || i === lastIndex;\n                      return (\n                        <circle\n                          key={`pt-${i}`}\n                          // Read by `handleContextMenu` to identify which\n                          // node was right-clicked (vs. the empty surface).\n                          data-curve-index={i}\n                          cx={cx}\n                          cy={cy}\n                          r={6}\n                          fill=\"var(--background, #fff)\"\n                          stroke={strokeColor}\n                          strokeWidth={2}\n                          style={{\n                            cursor: disabled\n                              ? \"not-allowed\"\n                              : isEndpoint\n                                ? \"ns-resize\"\n                                : \"grab\",\n                          }}\n                          onPointerDown={(event) =>\n                            handlePointPointerDown(event, i)\n                          }\n                          onPointerMove={handlePointPointerMove}\n                          onPointerUp={handlePointPointerUp}\n                          onPointerCancel={handlePointPointerUp}\n                          onDoubleClick={(event) =>\n                            handlePointDoubleClick(event, i)\n                          }\n                        />\n                      );\n                    })}\n                  </svg>\n                  }\n                />\n                <ContextMenuContent>\n                  {contextTarget?.type === \"node\" ? (\n                    <ContextMenuItem\n                      disabled={\n                        contextTarget.index === 0 ||\n                        contextTarget.index === lastIndex\n                      }\n                      onClick={() => removePointAt(contextTarget.index)}\n                    >\n                      Remove point\n                    </ContextMenuItem>\n                  ) : null}\n                  {contextTarget?.type === \"surface\" ? (\n                    <ContextMenuItem\n                      onClick={() =>\n                        addPointAt(contextTarget.x, contextTarget.y)\n                      }\n                    >\n                      Add point here\n                    </ContextMenuItem>\n                  ) : null}\n                </ContextMenuContent>\n                </ContextMenu>\n              </div>\n              <p className=\"text-center text-xs text-muted-foreground\">\n                Click to add. Drag to move. Double-click or right-click to\n                remove.\n              </p>\n            </div>\n          </FieldSet>\n        </FieldGroup>\n      </section>\n    </TooltipProvider>\n  );\n}\n\nexport type ImageEditorCurvesProps = Partial<CurvesPanelProps>;\n\n/** Tone curves (RGB + per channel) over a live histogram, wired to the nearest `<ImageEditorProvider>`. */\nexport function ImageEditorCurves(props: ImageEditorCurvesProps) {\n  const editor = useImageEditor();\n  return (\n    <CurvesPanel\n      disabled={editor.disabled}\n      histogram={editor.curves.histogram}\n      onChange={editor.curves.set}\n      onCommit={editor.curves.commit}\n      onReset={editor.curves.reset}\n      value={editor.curves.value}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/curves-panel.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}