{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A PixelBrushCanvas component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type PixelBrushCanvasBrush =\n  | \"pencil\"\n  | \"eraser\"\n  | \"fill\"\n  | \"line\"\n  | \"rect\";\n\nexport interface PixelBrushCanvasHandle {\n  /** Erases every pixel back to the empty state. */\n  clear: () => void;\n  /** Returns a copy of the raw pixel buffer (palette indices, 0 = empty). */\n  getPixels: () => Uint8Array;\n  /** Exports the current drawing as a PNG data URL. */\n  toDataURL: () => string;\n  /** Reverts the last committed stroke, fill, line, rect or clear. */\n  undo: () => void;\n}\n\nexport interface PixelBrushCanvasProps {\n  /** Currently active brush tool. Uncontrolled if omitted. */\n  brush?: PixelBrushCanvasBrush;\n  className?: string;\n  /** Index into `palette` for the active paint color. Uncontrolled if omitted. */\n  color?: number;\n  /** Show gridlines between pixel cells. */\n  grid?: boolean;\n  /** Height of the art grid, in pixel cells (not CSS px). */\n  height?: number;\n  /** Optional initial artwork as a 2D array (rows of `height`, cols of `width`) of palette indices. Negative values mean empty. */\n  initial?: number[][];\n  onBrushChange?: (brush: PixelBrushCanvasBrush) => void;\n  /** Fires with a snapshot of the pixel buffer on every mutation. */\n  onChange?: (pixels: Uint8Array) => void;\n  onColorChange?: (color: number) => void;\n  /** Available paint colors, as CSS color strings. */\n  palette?: string[];\n  /** Size of a single pixel cell, in CSS px. */\n  pixelSize?: number;\n  ref?: Ref<PixelBrushCanvasHandle>;\n  /** Render the built-in color/brush palette below the canvas. */\n  showTools?: boolean;\n  /** Width of the art grid, in pixel cells (not CSS px). */\n  width?: number;\n}\n\ninterface Cell {\n  x: number;\n  y: number;\n}\n\ninterface BoundingBox {\n  maxX: number;\n  maxY: number;\n  minX: number;\n  minY: number;\n}\n\ninterface BrushOption {\n  label: string;\n  shortcut?: string;\n  value: PixelBrushCanvasBrush;\n}\n\nconst DEFAULT_GRID_WIDTH = 16;\nconst DEFAULT_GRID_HEIGHT = 16;\nconst DEFAULT_PIXEL_SIZE = 20;\nconst MAX_DEVICE_PIXEL_RATIO = 2;\nconst MAX_UNDO_STEPS = 50;\nconst GRID_LINE_WIDTH = 1;\nconst EMPTY_CELL_VALUE = 0;\nconst GRID_LINE_COLOR = \"rgba(120, 120, 120, 0.35)\";\n\nconst DEFAULT_PALETTE: string[] = [\n  \"#1f2937\",\n  \"#ef4444\",\n  \"#f97316\",\n  \"#facc15\",\n  \"#22c55e\",\n  \"#0ea5e9\",\n  \"#6366f1\",\n  \"#ec4899\",\n  \"#ffffff\",\n];\n\nconst BRUSH_OPTIONS: BrushOption[] = [\n  { label: \"Pencil\", shortcut: \"B\", value: \"pencil\" },\n  { label: \"Eraser\", shortcut: \"E\", value: \"eraser\" },\n  { label: \"Fill\", shortcut: \"G\", value: \"fill\" },\n  { label: \"Line\", value: \"line\" },\n  { label: \"Rectangle\", value: \"rect\" },\n];\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\n\nconst createInitialPixels = (\n  gridWidth: number,\n  gridHeight: number,\n  initial?: number[][]\n): Uint8Array => {\n  const pixels = new Uint8Array(gridWidth * gridHeight);\n  if (!initial) {\n    return pixels;\n  }\n  for (let y = 0; y < gridHeight; y++) {\n    const row = initial[y];\n    if (!row) {\n      continue;\n    }\n    for (let x = 0; x < gridWidth; x++) {\n      const value = row[x];\n      if (value === undefined || value < 0) {\n        continue;\n      }\n      pixels[y * gridWidth + x] = value + 1;\n    }\n  }\n  return pixels;\n};\n\nconst drawCell = (\n  ctx: CanvasRenderingContext2D,\n  pixels: Uint8Array,\n  gridWidth: number,\n  x: number,\n  y: number,\n  cellSize: number,\n  palette: string[],\n  showGrid: boolean\n) => {\n  const value = pixels[y * gridWidth + x];\n  const left = x * cellSize;\n  const top = y * cellSize;\n  ctx.clearRect(left, top, cellSize, cellSize);\n  if (value !== EMPTY_CELL_VALUE) {\n    ctx.fillStyle = palette[value - 1] ?? palette[0] ?? \"#000000\";\n    ctx.fillRect(left, top, cellSize, cellSize);\n  }\n  if (showGrid) {\n    ctx.strokeStyle = GRID_LINE_COLOR;\n    ctx.lineWidth = GRID_LINE_WIDTH;\n    ctx.strokeRect(left + 0.5, top + 0.5, cellSize - 1, cellSize - 1);\n  }\n};\n\nconst drawRegion = (\n  ctx: CanvasRenderingContext2D,\n  pixels: Uint8Array,\n  gridWidth: number,\n  gridHeight: number,\n  cellSize: number,\n  palette: string[],\n  showGrid: boolean,\n  box: BoundingBox\n) => {\n  const startX = Math.max(0, box.minX);\n  const startY = Math.max(0, box.minY);\n  const endX = Math.min(gridWidth - 1, box.maxX);\n  const endY = Math.min(gridHeight - 1, box.maxY);\n  for (let y = startY; y <= endY; y++) {\n    for (let x = startX; x <= endX; x++) {\n      drawCell(ctx, pixels, gridWidth, x, y, cellSize, palette, showGrid);\n    }\n  }\n};\n\n/** Iterative (stack-based) flood fill — never recursive, so it cannot blow the call stack. */\nconst floodFillIterative = (\n  pixels: Uint8Array,\n  gridWidth: number,\n  gridHeight: number,\n  startX: number,\n  startY: number,\n  newValue: number\n): BoundingBox | null => {\n  const targetValue = pixels[startY * gridWidth + startX];\n  if (targetValue === newValue) {\n    return null;\n  }\n  const stack: Cell[] = [{ x: startX, y: startY }];\n  const box: BoundingBox = {\n    maxX: startX,\n    maxY: startY,\n    minX: startX,\n    minY: startY,\n  };\n  while (stack.length > 0) {\n    const cell = stack.pop();\n    if (!cell) {\n      break;\n    }\n    const { x, y } = cell;\n    if (x < 0 || x >= gridWidth || y < 0 || y >= gridHeight) {\n      continue;\n    }\n    if (pixels[y * gridWidth + x] !== targetValue) {\n      continue;\n    }\n    pixels[y * gridWidth + x] = newValue;\n    box.minX = Math.min(box.minX, x);\n    box.maxX = Math.max(box.maxX, x);\n    box.minY = Math.min(box.minY, y);\n    box.maxY = Math.max(box.maxY, y);\n    stack.push(\n      { x: x + 1, y },\n      { x: x - 1, y },\n      { x, y: y + 1 },\n      { x, y: y - 1 }\n    );\n  }\n  return box;\n};\n\n/** Bresenham line rasterization, bounded by the known max step count. */\nconst getLineCells = (start: Cell, end: Cell): Cell[] => {\n  const cells: Cell[] = [];\n  const dx = Math.abs(end.x - start.x);\n  const dy = Math.abs(end.y - start.y);\n  const stepX = end.x >= start.x ? 1 : -1;\n  const stepY = end.y >= start.y ? 1 : -1;\n  const maxSteps = Math.max(dx, dy) + 1;\n  let { x, y } = start;\n  let error = dx - dy;\n  for (let step = 0; step < maxSteps; step++) {\n    cells.push({ x, y });\n    if (x === end.x && y === end.y) {\n      break;\n    }\n    const doubledError = error * 2;\n    if (doubledError > -dy) {\n      error -= dy;\n      x += stepX;\n    }\n    if (doubledError < dx) {\n      error += dx;\n      y += stepY;\n    }\n  }\n  return cells;\n};\n\nconst getRectOutlineCells = (start: Cell, end: Cell): Cell[] => {\n  const minX = Math.min(start.x, end.x);\n  const maxX = Math.max(start.x, end.x);\n  const minY = Math.min(start.y, end.y);\n  const maxY = Math.max(start.y, end.y);\n  const cells: Cell[] = [];\n  for (let x = minX; x <= maxX; x++) {\n    cells.push({ x, y: minY });\n    if (maxY !== minY) {\n      cells.push({ x, y: maxY });\n    }\n  }\n  for (let y = minY; y <= maxY; y++) {\n    cells.push({ x: minX, y });\n    if (maxX !== minX) {\n      cells.push({ x: maxX, y });\n    }\n  }\n  return cells;\n};\n\nconst getCellFromPoint = (\n  clientX: number,\n  clientY: number,\n  rect: DOMRect,\n  gridWidth: number,\n  gridHeight: number,\n  cellSize: number\n): Cell => {\n  const scaleX = rect.width / (gridWidth * cellSize) || 1;\n  const scaleY = rect.height / (gridHeight * cellSize) || 1;\n  const rawX = (clientX - rect.left) / scaleX;\n  const rawY = (clientY - rect.top) / scaleY;\n  return {\n    x: clamp(Math.floor(rawX / cellSize), 0, gridWidth - 1),\n    y: clamp(Math.floor(rawY / cellSize), 0, gridHeight - 1),\n  };\n};\n\nconst describeValue = (value: number, palette: string[]): string =>\n  value === EMPTY_CELL_VALUE\n    ? \"empty\"\n    : (palette[value - 1] ?? \"unknown color\");\n\ninterface ToolPaletteProps {\n  brush: PixelBrushCanvasBrush;\n  colorIndex: number;\n  onSelectBrush: (brush: PixelBrushCanvasBrush) => void;\n  onSelectColor: (index: number) => void;\n  palette: string[];\n}\n\nconst ToolPalette = ({\n  brush,\n  colorIndex,\n  onSelectBrush,\n  onSelectColor,\n  palette,\n}: ToolPaletteProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const brushIndicatorId = `pixel-brush-tool-${useId()}`;\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-3\">\n      <fieldset\n        aria-label=\"Color palette\"\n        className=\"m-0 flex items-center gap-1.5 border-0 p-0\"\n      >\n        {palette.map((hex, index) => {\n          const isSelected = index === colorIndex;\n          return (\n            <button\n              aria-label={`Select color ${index + 1}: ${hex}`}\n              aria-pressed={isSelected}\n              className={cn(\n                \"relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full\",\n                \"shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] outline-none dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.16)]\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n              )}\n              // biome-ignore lint/suspicious/noArrayIndexKey: palette is a flat color list with no stable id and colors may repeat\n              key={`${hex}-${index}`}\n              onClick={() => onSelectColor(index)}\n              style={{ backgroundColor: hex }}\n              type=\"button\"\n            >\n              {isSelected && (\n                // A brand ring around a swatch fights the colour the swatch is\n                // showing. The selection halo is neutral chrome instead — the\n                // gap-then-ring every colour picker uses — so it reads the same\n                // against every hue in the palette.\n                <motion.span\n                  animate={{ opacity: 1, scale: 1 }}\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 rounded-full ring-2 ring-foreground ring-offset-2 ring-offset-background\"\n                  initial={\n                    shouldReduceMotion\n                      ? { opacity: 1, scale: 1 }\n                      : { opacity: 0, scale: 0.6 }\n                  }\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n                  }\n                />\n              )}\n            </button>\n          );\n        })}\n      </fieldset>\n      {/* Tool selection borrows the language of `animated-tabs`: the whole\n          group is a recessed track and the active tool is a solid chip raised\n          out of it, which reads instantly at this size without any fill. */}\n      <fieldset\n        aria-label=\"Brush tools\"\n        className=\"m-0 flex items-center gap-0.5 rounded-lg border-0 bg-foreground/[0.05] p-1 dark:bg-foreground/[0.09]\"\n      >\n        {BRUSH_OPTIONS.map((option) => {\n          const isActive = option.value === brush;\n          return (\n            <button\n              aria-label={\n                option.shortcut\n                  ? `${option.label} tool (shortcut ${option.shortcut})`\n                  : `${option.label} tool`\n              }\n              aria-pressed={isActive}\n              className={cn(\n                \"relative cursor-pointer rounded-md px-2.5 py-1.5 font-medium text-xs outline-none\",\n                \"transition-colors duration-150 ease-out\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n                isActive\n                  ? \"text-foreground\"\n                  : \"text-muted-foreground hover:text-foreground\"\n              )}\n              key={option.value}\n              onClick={() => onSelectBrush(option.value)}\n              type=\"button\"\n            >\n              {isActive && (\n                <motion.span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 rounded-md bg-background shadow-[0_0_0_1px_rgb(0_0_0/0.07),0_1px_2px_rgb(0_0_0/0.08)] dark:bg-foreground/[0.14] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.10)]\"\n                  layoutId={brushIndicatorId}\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n                  }\n                />\n              )}\n              <span className=\"relative\">{option.label}</span>\n            </button>\n          );\n        })}\n      </fieldset>\n    </div>\n  );\n};\n\nconst PixelBrushCanvas = ({\n  brush,\n  className,\n  color,\n  grid = false,\n  height = DEFAULT_GRID_HEIGHT,\n  initial,\n  onBrushChange,\n  onChange,\n  onColorChange,\n  palette = DEFAULT_PALETTE,\n  pixelSize = DEFAULT_PIXEL_SIZE,\n  ref,\n  showTools = true,\n  width = DEFAULT_GRID_WIDTH,\n}: PixelBrushCanvasProps) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const pixelsRef = useRef<Uint8Array>(\n    createInitialPixels(width, height, initial)\n  );\n  const undoStackRef = useRef<Uint8Array[]>([]);\n  const isPaintingRef = useRef(false);\n  const lastPaintedRef = useRef<Cell | null>(null);\n  const dragStartRef = useRef<Cell | null>(null);\n  const anchorRef = useRef<Cell | null>(null);\n\n  const [internalColor, setInternalColor] = useState(color ?? 0);\n  const [internalBrush, setInternalBrush] = useState<PixelBrushCanvasBrush>(\n    brush ?? \"pencil\"\n  );\n  const [cursor, setCursor] = useState<Cell>({ x: 0, y: 0 });\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  useEffect(() => {\n    if (color !== undefined) {\n      setInternalColor(color);\n    }\n  }, [color]);\n\n  useEffect(() => {\n    if (brush !== undefined) {\n      setInternalBrush(brush);\n    }\n  }, [brush]);\n\n  useLayoutEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    if (pixelsRef.current.length !== width * height) {\n      pixelsRef.current = createInitialPixels(width, height, initial);\n      undoStackRef.current = [];\n    }\n    const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);\n    const cssWidth = width * pixelSize;\n    const cssHeight = height * pixelSize;\n    canvas.style.width = `${cssWidth}px`;\n    canvas.style.height = `${cssHeight}px`;\n    canvas.width = Math.round(cssWidth * dpr);\n    canvas.height = Math.round(cssHeight * dpr);\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n      return;\n    }\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    drawRegion(\n      ctx,\n      pixelsRef.current,\n      width,\n      height,\n      pixelSize,\n      palette,\n      grid,\n      {\n        maxX: width - 1,\n        maxY: height - 1,\n        minX: 0,\n        minY: 0,\n      }\n    );\n  }, [width, height, pixelSize, palette, grid, initial]);\n\n  const redrawRegion = useCallback(\n    (box: BoundingBox) => {\n      const ctx = canvasRef.current?.getContext(\"2d\");\n      if (!ctx) {\n        return;\n      }\n      drawRegion(\n        ctx,\n        pixelsRef.current,\n        width,\n        height,\n        pixelSize,\n        palette,\n        grid,\n        box\n      );\n    },\n    [width, height, pixelSize, palette, grid]\n  );\n\n  const notifyChange = useCallback(() => {\n    onChange?.(pixelsRef.current.slice());\n  }, [onChange]);\n\n  const pushUndoSnapshot = useCallback(() => {\n    const stack = undoStackRef.current;\n    if (stack.length >= MAX_UNDO_STEPS) {\n      stack.shift();\n    }\n    stack.push(pixelsRef.current.slice());\n  }, []);\n\n  const announceFocusedCell = (x: number, y: number) => {\n    const value = pixelsRef.current[y * width + x];\n    setAnnouncement(\n      `Column ${x + 1}, row ${y + 1}. Color: ${describeValue(value, palette)}.`\n    );\n  };\n\n  const setCellValue = (x: number, y: number, value: number): boolean => {\n    if (pixelsRef.current[y * width + x] === value) {\n      return false;\n    }\n    pixelsRef.current[y * width + x] = value;\n    redrawRegion({ maxX: x, maxY: y, minX: x, minY: y });\n    return true;\n  };\n\n  const commitCells = (cells: Cell[], value: number) => {\n    pushUndoSnapshot();\n    let changed = false;\n    const box: BoundingBox = { maxX: -1, maxY: -1, minX: width, minY: height };\n    for (const cell of cells) {\n      if (cell.x < 0 || cell.x >= width || cell.y < 0 || cell.y >= height) {\n        continue;\n      }\n      if (pixelsRef.current[cell.y * width + cell.x] === value) {\n        continue;\n      }\n      pixelsRef.current[cell.y * width + cell.x] = value;\n      changed = true;\n      box.minX = Math.min(box.minX, cell.x);\n      box.minY = Math.min(box.minY, cell.y);\n      box.maxX = Math.max(box.maxX, cell.x);\n      box.maxY = Math.max(box.maxY, cell.y);\n    }\n    if (changed) {\n      redrawRegion(box);\n      notifyChange();\n    } else {\n      undoStackRef.current.pop();\n    }\n  };\n\n  const applyFillAt = (x: number, y: number) => {\n    pushUndoSnapshot();\n    const box = floodFillIterative(\n      pixelsRef.current,\n      width,\n      height,\n      x,\n      y,\n      internalColor + 1\n    );\n    if (box) {\n      redrawRegion(box);\n      notifyChange();\n    } else {\n      undoStackRef.current.pop();\n    }\n  };\n\n  const clear = useCallback(() => {\n    pushUndoSnapshot();\n    pixelsRef.current.fill(EMPTY_CELL_VALUE);\n    redrawRegion({ maxX: width - 1, maxY: height - 1, minX: 0, minY: 0 });\n    notifyChange();\n  }, [width, height, pushUndoSnapshot, redrawRegion, notifyChange]);\n\n  const undo = useCallback(() => {\n    const snapshot = undoStackRef.current.pop();\n    if (!snapshot) {\n      return;\n    }\n    pixelsRef.current = snapshot;\n    redrawRegion({ maxX: width - 1, maxY: height - 1, minX: 0, minY: 0 });\n    notifyChange();\n  }, [width, height, redrawRegion, notifyChange]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      clear,\n      getPixels: () => pixelsRef.current.slice(),\n      toDataURL: () => canvasRef.current?.toDataURL() ?? \"\",\n      undo,\n    }),\n    [clear, undo]\n  );\n\n  const selectBrush = (next: PixelBrushCanvasBrush) => {\n    setInternalBrush(next);\n    anchorRef.current = null;\n    onBrushChange?.(next);\n  };\n\n  const selectColor = (index: number) => {\n    setInternalColor(index);\n    onColorChange?.(index);\n  };\n\n  const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const cell = getCellFromPoint(\n      event.clientX,\n      event.clientY,\n      rect,\n      width,\n      height,\n      pixelSize\n    );\n    wrapperRef.current?.focus();\n    setCursor(cell);\n\n    if (internalBrush === \"pencil\" || internalBrush === \"eraser\") {\n      event.currentTarget.setPointerCapture(event.pointerId);\n      isPaintingRef.current = true;\n      lastPaintedRef.current = null;\n      pushUndoSnapshot();\n      const value =\n        internalBrush === \"eraser\" ? EMPTY_CELL_VALUE : internalColor + 1;\n      if (setCellValue(cell.x, cell.y, value)) {\n        notifyChange();\n      } else {\n        undoStackRef.current.pop();\n      }\n      lastPaintedRef.current = cell;\n    } else if (internalBrush === \"fill\") {\n      applyFillAt(cell.x, cell.y);\n    } else {\n      dragStartRef.current = cell;\n    }\n    announceFocusedCell(cell.x, cell.y);\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {\n    if (!isPaintingRef.current) {\n      return;\n    }\n    const rect = event.currentTarget.getBoundingClientRect();\n    const cell = getCellFromPoint(\n      event.clientX,\n      event.clientY,\n      rect,\n      width,\n      height,\n      pixelSize\n    );\n    const last = lastPaintedRef.current;\n    if (last && last.x === cell.x && last.y === cell.y) {\n      return;\n    }\n    const value =\n      internalBrush === \"eraser\" ? EMPTY_CELL_VALUE : internalColor + 1;\n    if (setCellValue(cell.x, cell.y, value)) {\n      notifyChange();\n    }\n    lastPaintedRef.current = cell;\n    setCursor(cell);\n  };\n\n  const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {\n    if (isPaintingRef.current) {\n      isPaintingRef.current = false;\n      lastPaintedRef.current = null;\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId);\n      }\n      return;\n    }\n    const start = dragStartRef.current;\n    if (start && (internalBrush === \"line\" || internalBrush === \"rect\")) {\n      const rect = event.currentTarget.getBoundingClientRect();\n      const end = getCellFromPoint(\n        event.clientX,\n        event.clientY,\n        rect,\n        width,\n        height,\n        pixelSize\n      );\n      const cells =\n        internalBrush === \"line\"\n          ? getLineCells(start, end)\n          : getRectOutlineCells(start, end);\n      commitCells(cells, internalColor + 1);\n      announceFocusedCell(end.x, end.y);\n    }\n    dragStartRef.current = null;\n  };\n\n  const moveCursor = (dx: number, dy: number) => {\n    const nextX = clamp(cursor.x + dx, 0, width - 1);\n    const nextY = clamp(cursor.y + dy, 0, height - 1);\n    setCursor({ x: nextX, y: nextY });\n    announceFocusedCell(nextX, nextY);\n  };\n\n  const paintAtCursor = () => {\n    if (internalBrush === \"pencil\" || internalBrush === \"eraser\") {\n      pushUndoSnapshot();\n      const value =\n        internalBrush === \"eraser\" ? EMPTY_CELL_VALUE : internalColor + 1;\n      if (setCellValue(cursor.x, cursor.y, value)) {\n        notifyChange();\n      } else {\n        undoStackRef.current.pop();\n      }\n      announceFocusedCell(cursor.x, cursor.y);\n    } else if (internalBrush === \"fill\") {\n      applyFillAt(cursor.x, cursor.y);\n      announceFocusedCell(cursor.x, cursor.y);\n    } else {\n      const anchor = anchorRef.current;\n      if (!anchor) {\n        anchorRef.current = { x: cursor.x, y: cursor.y };\n        setAnnouncement(\n          `Anchor set at column ${cursor.x + 1}, row ${cursor.y + 1}. Move and press space again to finish.`\n        );\n        return;\n      }\n      const cells =\n        internalBrush === \"line\"\n          ? getLineCells(anchor, cursor)\n          : getRectOutlineCells(anchor, cursor);\n      commitCells(cells, internalColor + 1);\n      anchorRef.current = null;\n      announceFocusedCell(cursor.x, cursor.y);\n    }\n  };\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const isUndoShortcut =\n      (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"z\";\n    if (isUndoShortcut) {\n      event.preventDefault();\n      undo();\n      return;\n    }\n    switch (event.key) {\n      case \"ArrowUp\": {\n        event.preventDefault();\n        moveCursor(0, -1);\n        return;\n      }\n      case \"ArrowDown\": {\n        event.preventDefault();\n        moveCursor(0, 1);\n        return;\n      }\n      case \"ArrowLeft\": {\n        event.preventDefault();\n        moveCursor(-1, 0);\n        return;\n      }\n      case \"ArrowRight\": {\n        event.preventDefault();\n        moveCursor(1, 0);\n        return;\n      }\n      case \" \":\n      case \"Spacebar\": {\n        event.preventDefault();\n        paintAtCursor();\n        return;\n      }\n      default:\n        break;\n    }\n    const lowerKey = event.key.toLowerCase();\n    if (lowerKey === \"e\") {\n      selectBrush(\"eraser\");\n    } else if (lowerKey === \"b\") {\n      selectBrush(\"pencil\");\n    } else if (lowerKey === \"g\") {\n      selectBrush(\"fill\");\n    }\n  };\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-3\", className)}>\n      <div className=\"inline-block rounded-lg border border-border bg-muted/40 p-2\">\n        {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role=\"application\" is the keyboard-operable pixel grid; no semantic element models 2D arrow-key navigation */}\n        <div\n          aria-label=\"Pixel art drawing canvas\"\n          className=\"relative inline-block rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-brand\"\n          onKeyDown={handleKeyDown}\n          ref={wrapperRef}\n          role=\"application\"\n          // biome-ignore lint/a11y/noNoninteractiveTabindex: role=\"application\" must be focusable to be keyboard-operable\n          tabIndex={0}\n        >\n          <canvas\n            className=\"block touch-none rounded-sm\"\n            onPointerDown={handlePointerDown}\n            onPointerLeave={handlePointerUp}\n            onPointerMove={handlePointerMove}\n            onPointerUp={handlePointerUp}\n            ref={canvasRef}\n          />\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute border-2 border-brand\"\n            style={{\n              height: pixelSize,\n              left: cursor.x * pixelSize,\n              top: cursor.y * pixelSize,\n              width: pixelSize,\n            }}\n          />\n          <span aria-live=\"polite\" className=\"sr-only\">\n            {announcement}\n          </span>\n        </div>\n      </div>\n      {showTools ? (\n        <ToolPalette\n          brush={internalBrush}\n          colorIndex={internalColor}\n          onSelectBrush={selectBrush}\n          onSelectColor={selectColor}\n          palette={palette}\n        />\n      ) : null}\n    </div>\n  );\n};\n\nexport default PixelBrushCanvas;\n","path":"index.tsx","target":"components/smoothui/pixel-brush-canvas/index.tsx","type":"registry:ui"}],"name":"pixel-brush-canvas","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Pixel Brush Canvas","type":"registry:ui"}