From d9d8c772e16ffe83e6fa403fe2d4c30e19717e80 Mon Sep 17 00:00:00 2001 From: Keanan Koppenhaver Date: Thu, 23 Apr 2026 12:19:51 -0500 Subject: [PATCH] Add pixel-art-editor component Co-Authored-By: Claude Opus 4.7 (1M context) --- components/pixel-art-editor/README.md | 57 ++++ components/pixel-art-editor/cover.png | Bin 0 -> 93508 bytes components/pixel-art-editor/grid.test.ts | 60 ++++ components/pixel-art-editor/metadata.json | 7 + components/pixel-art-editor/package.json | 28 ++ .../src/PixelArtEditor.module.css | 151 ++++++++++ .../pixel-art-editor/src/PixelArtEditor.tsx | 272 ++++++++++++++++++ .../pixel-art-editor/src/engine/constants.ts | 12 + .../pixel-art-editor/src/engine/export.ts | 44 +++ .../pixel-art-editor/src/engine/grid.ts | 35 +++ .../pixel-art-editor/src/engine/renderer.ts | 72 +++++ .../pixel-art-editor/src/engine/tools.ts | 72 +++++ .../pixel-art-editor/src/engine/types.ts | 22 ++ components/pixel-art-editor/src/index.tsx | 1 + components/pixel-art-editor/tools.test.ts | 96 +++++++ components/pixel-art-editor/tsconfig.json | 15 + 16 files changed, 944 insertions(+) create mode 100644 components/pixel-art-editor/README.md create mode 100644 components/pixel-art-editor/cover.png create mode 100644 components/pixel-art-editor/grid.test.ts create mode 100644 components/pixel-art-editor/metadata.json create mode 100644 components/pixel-art-editor/package.json create mode 100644 components/pixel-art-editor/src/PixelArtEditor.module.css create mode 100644 components/pixel-art-editor/src/PixelArtEditor.tsx create mode 100644 components/pixel-art-editor/src/engine/constants.ts create mode 100644 components/pixel-art-editor/src/engine/export.ts create mode 100644 components/pixel-art-editor/src/engine/grid.ts create mode 100644 components/pixel-art-editor/src/engine/renderer.ts create mode 100644 components/pixel-art-editor/src/engine/tools.ts create mode 100644 components/pixel-art-editor/src/engine/types.ts create mode 100644 components/pixel-art-editor/src/index.tsx create mode 100644 components/pixel-art-editor/tools.test.ts create mode 100644 components/pixel-art-editor/tsconfig.json diff --git a/components/pixel-art-editor/README.md b/components/pixel-art-editor/README.md new file mode 100644 index 0000000..1eba49d --- /dev/null +++ b/components/pixel-art-editor/README.md @@ -0,0 +1,57 @@ +# Pixel Art Editor + +A 32ร—32 canvas pixel-art editor for Retool. Draw with a pencil, erase, or flood-fill with a built-in palette (or custom color picker), then export the art as a PNG โ€” the drawing is pushed back to Retool as a base64 data URL on every change. + +## Features + +- โœ๏ธ **Pencil, eraser, and flood-fill** tools with live cursor highlight +- ๐ŸŽจ **24-swatch palette** plus a native color picker for custom colors +- ๐Ÿงฑ **32ร—32 grid** rendered at 512ร—512 with pixelated upscaling +- ๐Ÿ”ฅ **Fires `change` event** on every stroke with the full PNG as a data URL +- ๐Ÿ’พ **Export PNG button** downloads the art locally and fires an `export` event +- ๐Ÿงน **Clear button** resets the canvas and pushes an empty state back to Retool + +## Installation + +```bash +npm install +npx retool-ccl login +npx retool-ccl init +npx retool-ccl dev +``` + +## Usage in Retool + +1. Drag the component onto your canvas. +2. Wire the `change` event to save the drawing (for example, an upload query using `{{ pixelArtEditor1.imageDataUrl }}`). +3. Read the base64 PNG out via `{{ pixelArtEditor1.imageDataUrl }}` โ€” it's a standard `data:image/png;base64,โ€ฆ` URL, usable directly as an `` source or decoded server-side. + +## Inspector Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `disabled` | boolean | `false` | Disable all drawing and palette controls | + +## Output Properties + +| Property | Type | Description | +|----------|------|-------------| +| `imageDataUrl` | string | Current canvas as a 512ร—512 PNG data URL (empty string when the canvas is blank) | +| `currentTool` | string | Active tool: `draw`, `erase`, or `fill` | +| `currentColor` | string | Active hex color (e.g. `#ff0000`) | +| `isEmpty` | boolean | True when nothing is drawn on the canvas | + +## Events + +| Event | Description | +|-------|-------------| +| `change` | Fires after every stroke, fill, or clear | +| `export` | Fires when the user clicks **Export PNG** | + +## Tech Stack + +- React 18 +- TypeScript +- HTML Canvas 2D +- @tryretool/custom-component-support +- Zero external dependencies diff --git a/components/pixel-art-editor/cover.png b/components/pixel-art-editor/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..a7fe6c1ad29138b40970a56a796991293663d2a6 GIT binary patch literal 93508 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 QMFoFuUwOD@FJ8a@9}Lg-lK=n! literal 0 HcmV?d00001 diff --git a/components/pixel-art-editor/grid.test.ts b/components/pixel-art-editor/grid.test.ts new file mode 100644 index 0000000..7a5ac44 --- /dev/null +++ b/components/pixel-art-editor/grid.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { createGrid, getPixel, setPixel, clearGrid, colorsMatch } from './src/engine/grid' + +describe('createGrid', () => { + it('creates a grid with correct dimensions', () => { + const grid = createGrid(32, 32) + expect(grid.width).toBe(32) + expect(grid.height).toBe(32) + expect(grid.pixels.length).toBe(32 * 32 * 4) + }) + + it('initializes all pixels to transparent', () => { + const grid = createGrid(4, 4) + for (let i = 0; i < grid.pixels.length; i++) { + expect(grid.pixels[i]).toBe(0) + } + }) +}) + +describe('getPixel / setPixel', () => { + it('round-trips a pixel correctly', () => { + const grid = createGrid(4, 4) + const color = { r: 255, g: 128, b: 64, a: 255 } + setPixel(grid, 2, 3, color) + expect(getPixel(grid, 2, 3)).toEqual(color) + }) + + it('does not affect other pixels', () => { + const grid = createGrid(4, 4) + setPixel(grid, 0, 0, { r: 255, g: 0, b: 0, a: 255 }) + expect(getPixel(grid, 1, 0)).toEqual({ r: 0, g: 0, b: 0, a: 0 }) + }) +}) + +describe('clearGrid', () => { + it('resets all pixels to transparent', () => { + const grid = createGrid(4, 4) + setPixel(grid, 0, 0, { r: 255, g: 0, b: 0, a: 255 }) + setPixel(grid, 3, 3, { r: 0, g: 255, b: 0, a: 255 }) + clearGrid(grid) + expect(getPixel(grid, 0, 0)).toEqual({ r: 0, g: 0, b: 0, a: 0 }) + expect(getPixel(grid, 3, 3)).toEqual({ r: 0, g: 0, b: 0, a: 0 }) + }) +}) + +describe('colorsMatch', () => { + it('returns true for matching colors', () => { + expect(colorsMatch( + { r: 255, g: 128, b: 64, a: 255 }, + { r: 255, g: 128, b: 64, a: 255 } + )).toBe(true) + }) + + it('returns false for different colors', () => { + expect(colorsMatch( + { r: 255, g: 0, b: 0, a: 255 }, + { r: 0, g: 255, b: 0, a: 255 } + )).toBe(false) + }) +}) diff --git a/components/pixel-art-editor/metadata.json b/components/pixel-art-editor/metadata.json new file mode 100644 index 0000000..e410020 --- /dev/null +++ b/components/pixel-art-editor/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "pixel-art-editor", + "title": "Pixel Art Editor", + "author": "@KeananKoppenhaver", + "shortDescription": "A 32ร—32 canvas pixel-art editor with pencil, eraser, flood-fill, color palette, and PNG export โ€” drawing data is pushed back to Retool as a base64 data URL.", + "tags": ["Editors", "UI Components", "React", "Custom"] +} diff --git a/components/pixel-art-editor/package.json b/components/pixel-art-editor/package.json new file mode 100644 index 0000000..53d5bfc --- /dev/null +++ b/components/pixel-art-editor/package.json @@ -0,0 +1,28 @@ +{ + "name": "pixel-art-editor", + "version": "1.0.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "typescript": "^5.0.0" + }, + "retoolCustomComponentLibraryConfig": { + "name": "PixelArtEditor", + "label": "Pixel Art Editor", + "description": "A 32x32 canvas pixel-art editor with pencil, eraser, fill, palette, and PNG export", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} diff --git a/components/pixel-art-editor/src/PixelArtEditor.module.css b/components/pixel-art-editor/src/PixelArtEditor.module.css new file mode 100644 index 0000000..0c67925 --- /dev/null +++ b/components/pixel-art-editor/src/PixelArtEditor.module.css @@ -0,0 +1,151 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: #f5f5f5; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + overflow: hidden; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: #ffffff; + border-bottom: 1px solid #e0e0e0; + flex-wrap: wrap; + flex-shrink: 0; +} + +.toolGroup { + display: flex; + gap: 4px; +} + +.toolButton { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + color: #555; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + display: flex; + align-items: center; + gap: 4px; +} + +.toolButton:hover { + background: #f0f0f0; + border-color: #ccc; +} + +.toolButtonActive { + background: #e3f2fd; + border-color: #2196f3; + color: #1565c0; +} + +.separator { + width: 1px; + height: 24px; + background: #e0e0e0; +} + +/* Palette */ +.palette { + display: flex; + gap: 3px; + flex-wrap: wrap; + max-width: 210px; +} + +.swatch { + width: 20px; + height: 20px; + border-radius: 3px; + border: 2px solid transparent; + cursor: pointer; + transition: border-color 0.1s; + padding: 0; +} + +.swatch:hover { + border-color: #999; +} + +.swatchActive { + border-color: #333; + box-shadow: 0 0 0 1px #fff, 0 0 0 3px #333; +} + +.colorPicker { + width: 20px; + height: 20px; + border: 1px solid #ddd; + border-radius: 3px; + padding: 0; + cursor: pointer; + background: none; +} + +.colorPicker::-webkit-color-swatch-wrapper { + padding: 0; +} + +.colorPicker::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +/* Action buttons */ +.actionButton { + padding: 6px 14px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + color: #555; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.actionButton:hover { + background: #f0f0f0; +} + +.exportButton { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} + +.exportButton:hover { + background: #1d4ed8; +} + +/* Canvas area */ +.canvasWrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.canvas { + display: block; + cursor: crosshair; + image-rendering: pixelated; + border: 1px solid #ddd; + border-radius: 4px; + max-width: 100%; + max-height: 100%; +} diff --git a/components/pixel-art-editor/src/PixelArtEditor.tsx b/components/pixel-art-editor/src/PixelArtEditor.tsx new file mode 100644 index 0000000..94c297c --- /dev/null +++ b/components/pixel-art-editor/src/PixelArtEditor.tsx @@ -0,0 +1,272 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import { Tool, Color } from './engine/types' +import { GRID_SIZE, DISPLAY_SIZE, PALETTE } from './engine/constants' +import { createGrid } from './engine/grid' +import { renderGrid } from './engine/renderer' +import { applyDraw, applyErase, applyFill, getLinePoints } from './engine/tools' +import { exportAsPng, gridToDataUrl, downloadBlob } from './engine/export' +import styles from './PixelArtEditor.module.css' + +function hexToColor(hex: string): Color { + const n = parseInt(hex.slice(1), 16) + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: 255 } +} + +export const PixelArtEditor: FC = () => { + Retool.useComponentSettings({ defaultWidth: 30, defaultHeight: 40 }) + + const [disabled] = Retool.useStateBoolean({ + name: 'disabled', + initialValue: false, + label: 'Disabled', + inspector: 'checkbox' + }) + const [, setImageDataUrl] = Retool.useStateString({ + name: 'imageDataUrl', + initialValue: '', + inspector: 'hidden' + }) + const [, setCurrentTool] = Retool.useStateString({ + name: 'currentTool', + initialValue: 'draw', + inspector: 'hidden' + }) + const [, setCurrentColor] = Retool.useStateString({ + name: 'currentColor', + initialValue: '#000000', + inspector: 'hidden' + }) + const [, setIsEmpty] = Retool.useStateBoolean({ + name: 'isEmpty', + initialValue: true, + inspector: 'hidden' + }) + const onChange = Retool.useEventCallback({ name: 'change' }) + const onExport = Retool.useEventCallback({ name: 'export' }) + + const [tool, setTool] = useState('draw') + const [colorHex, setColorHex] = useState('#000000') + + const canvasRef = useRef(null) + const gridRef = useRef(createGrid(GRID_SIZE, GRID_SIZE)) + const isDrawingRef = useRef(false) + const lastPosRef = useRef<{ x: number; y: number } | null>(null) + const cursorRef = useRef<{ x: number; y: number } | null>(null) + + const redraw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + const cursor = cursorRef.current + renderGrid(ctx, DISPLAY_SIZE, DISPLAY_SIZE, gridRef.current, { + showGrid: true, + cursorX: cursor?.x ?? null, + cursorY: cursor?.y ?? null, + currentColor: hexToColor(colorHex), + currentTool: tool + }) + }, [colorHex, tool]) + + useEffect(() => { + redraw() + }, [redraw]) + + useEffect(() => { + setCurrentTool(tool) + }, [tool, setCurrentTool]) + + useEffect(() => { + setCurrentColor(colorHex) + }, [colorHex, setCurrentColor]) + + const emitGridState = useCallback(() => { + const grid = gridRef.current + let empty = true + for (let i = 3; i < grid.pixels.length; i += 4) { + if (grid.pixels[i] !== 0) { + empty = false + break + } + } + setIsEmpty(empty) + setImageDataUrl(empty ? '' : gridToDataUrl(grid)) + onChange() + }, [setIsEmpty, setImageDataUrl, onChange]) + + const canvasToGrid = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return null + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const x = Math.floor(((e.clientX - rect.left) * scaleX) / (canvas.width / GRID_SIZE)) + const y = Math.floor(((e.clientY - rect.top) * scaleY) / (canvas.height / GRID_SIZE)) + if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE) return null + return { x, y } + }, + [] + ) + + const applyToolAt = useCallback( + (x: number, y: number) => { + const grid = gridRef.current + const color = hexToColor(colorHex) + if (tool === 'draw') applyDraw(grid, x, y, color) + else if (tool === 'erase') applyErase(grid, x, y) + else if (tool === 'fill') applyFill(grid, x, y, color) + }, + [tool, colorHex] + ) + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + if (disabled) return + const pos = canvasToGrid(e) + if (!pos) return + + if (tool === 'fill') { + applyToolAt(pos.x, pos.y) + redraw() + emitGridState() + return + } + + isDrawingRef.current = true + lastPosRef.current = pos + applyToolAt(pos.x, pos.y) + redraw() + }, + [canvasToGrid, applyToolAt, tool, redraw, emitGridState, disabled] + ) + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + if (disabled) return + const pos = canvasToGrid(e) + cursorRef.current = pos + + if (isDrawingRef.current && pos && (tool === 'draw' || tool === 'erase')) { + const last = lastPosRef.current + if (last) { + const points = getLinePoints(last.x, last.y, pos.x, pos.y) + for (const [px, py] of points) { + applyToolAt(px, py) + } + } else { + applyToolAt(pos.x, pos.y) + } + lastPosRef.current = pos + } + + redraw() + }, + [canvasToGrid, applyToolAt, tool, redraw, disabled] + ) + + const onMouseUp = useCallback(() => { + if (!isDrawingRef.current) return + isDrawingRef.current = false + lastPosRef.current = null + emitGridState() + }, [emitGridState]) + + const onMouseLeave = useCallback(() => { + const wasDrawing = isDrawingRef.current + isDrawingRef.current = false + lastPosRef.current = null + cursorRef.current = null + redraw() + if (wasDrawing) emitGridState() + }, [redraw, emitGridState]) + + const handleClear = useCallback(() => { + if (disabled) return + gridRef.current = createGrid(GRID_SIZE, GRID_SIZE) + redraw() + emitGridState() + }, [redraw, emitGridState, disabled]) + + const handleExport = useCallback(async () => { + const blob = await exportAsPng(gridRef.current) + downloadBlob(blob, 'pixel-art.png') + onExport() + }, [onExport]) + + return ( +
+
+
+ {(['draw', 'erase', 'fill'] as Tool[]).map((t) => ( + + ))} +
+ +
+ +
+ {PALETTE.map((hex) => ( +
+ +
+ + + +
+ +
+ +
+
+ ) +} diff --git a/components/pixel-art-editor/src/engine/constants.ts b/components/pixel-art-editor/src/engine/constants.ts new file mode 100644 index 0000000..4a8be10 --- /dev/null +++ b/components/pixel-art-editor/src/engine/constants.ts @@ -0,0 +1,12 @@ +export const GRID_SIZE = 32 +export const DISPLAY_SIZE = 512 + +export const PALETTE: string[] = [ + '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#ffffff', + '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', + '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', + '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', + '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', +] + +export const TRANSPARENT: [number, number, number, number] = [0, 0, 0, 0] diff --git a/components/pixel-art-editor/src/engine/export.ts b/components/pixel-art-editor/src/engine/export.ts new file mode 100644 index 0000000..750c933 --- /dev/null +++ b/components/pixel-art-editor/src/engine/export.ts @@ -0,0 +1,44 @@ +import { GridData } from './types' + +const EXPORT_SIZE = 512 + +function renderToCanvas(grid: GridData): HTMLCanvasElement { + const native = document.createElement('canvas') + native.width = grid.width + native.height = grid.height + const nCtx = native.getContext('2d')! + const imageData = nCtx.createImageData(grid.width, grid.height) + imageData.data.set(grid.pixels) + nCtx.putImageData(imageData, 0, 0) + + const canvas = document.createElement('canvas') + canvas.width = EXPORT_SIZE + canvas.height = EXPORT_SIZE + const ctx = canvas.getContext('2d')! + ctx.imageSmoothingEnabled = false + ctx.drawImage(native, 0, 0, EXPORT_SIZE, EXPORT_SIZE) + return canvas +} + +export function exportAsPng(grid: GridData): Promise { + const canvas = renderToCanvas(grid) + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob) + else reject(new Error('Failed to export PNG')) + }, 'image/png') + }) +} + +export function gridToDataUrl(grid: GridData): string { + return renderToCanvas(grid).toDataURL('image/png') +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} diff --git a/components/pixel-art-editor/src/engine/grid.ts b/components/pixel-art-editor/src/engine/grid.ts new file mode 100644 index 0000000..54f253f --- /dev/null +++ b/components/pixel-art-editor/src/engine/grid.ts @@ -0,0 +1,35 @@ +import { Color, GridData } from './types' + +export function createGrid(width: number, height: number): GridData { + return { + width, + height, + pixels: new Uint8Array(width * height * 4) + } +} + +export function getPixel(grid: GridData, x: number, y: number): Color { + const i = (y * grid.width + x) * 4 + return { + r: grid.pixels[i], + g: grid.pixels[i + 1], + b: grid.pixels[i + 2], + a: grid.pixels[i + 3] + } +} + +export function setPixel(grid: GridData, x: number, y: number, color: Color): void { + const i = (y * grid.width + x) * 4 + grid.pixels[i] = color.r + grid.pixels[i + 1] = color.g + grid.pixels[i + 2] = color.b + grid.pixels[i + 3] = color.a +} + +export function clearGrid(grid: GridData): void { + grid.pixels.fill(0) +} + +export function colorsMatch(a: Color, b: Color): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a +} diff --git a/components/pixel-art-editor/src/engine/renderer.ts b/components/pixel-art-editor/src/engine/renderer.ts new file mode 100644 index 0000000..7ce1043 --- /dev/null +++ b/components/pixel-art-editor/src/engine/renderer.ts @@ -0,0 +1,72 @@ +import { GridData, RenderOptions } from './types' + +const CHECKER_LIGHT = '#e8e8e8' +const CHECKER_DARK = '#d0d0d0' +const GRID_LINE_COLOR = 'rgba(0, 0, 0, 0.08)' + +export function renderGrid( + ctx: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, + grid: GridData, + options: RenderOptions +): void { + const cellW = canvasWidth / grid.width + const cellH = canvasHeight / grid.height + + // Draw checkerboard background (shows transparency) + for (let y = 0; y < grid.height; y++) { + for (let x = 0; x < grid.width; x++) { + ctx.fillStyle = (x + y) % 2 === 0 ? CHECKER_LIGHT : CHECKER_DARK + ctx.fillRect(x * cellW, y * cellH, cellW, cellH) + } + } + + // Draw filled pixels + const pixels = grid.pixels + for (let y = 0; y < grid.height; y++) { + for (let x = 0; x < grid.width; x++) { + const i = (y * grid.width + x) * 4 + const a = pixels[i + 3] + if (a === 0) continue + + ctx.fillStyle = `rgba(${pixels[i]}, ${pixels[i + 1]}, ${pixels[i + 2]}, ${a / 255})` + ctx.fillRect(x * cellW, y * cellH, cellW, cellH) + } + } + + // Draw grid lines + if (options.showGrid) { + ctx.strokeStyle = GRID_LINE_COLOR + ctx.lineWidth = 1 + ctx.beginPath() + for (let x = 0; x <= grid.width; x++) { + const px = Math.round(x * cellW) + 0.5 + ctx.moveTo(px, 0) + ctx.lineTo(px, canvasHeight) + } + for (let y = 0; y <= grid.height; y++) { + const py = Math.round(y * cellH) + 0.5 + ctx.moveTo(0, py) + ctx.lineTo(canvasWidth, py) + } + ctx.stroke() + } + + // Draw cursor highlight + if (options.cursorX !== null && options.cursorY !== null) { + const cx = options.cursorX + const cy = options.cursorY + if (cx >= 0 && cx < grid.width && cy >= 0 && cy < grid.height) { + if (options.currentTool === 'erase') { + ctx.strokeStyle = 'rgba(255, 0, 0, 0.6)' + ctx.lineWidth = 2 + ctx.strokeRect(cx * cellW + 1, cy * cellH + 1, cellW - 2, cellH - 2) + } else { + const c = options.currentColor + ctx.fillStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 0.4)` + ctx.fillRect(cx * cellW, cy * cellH, cellW, cellH) + } + } + } +} diff --git a/components/pixel-art-editor/src/engine/tools.ts b/components/pixel-art-editor/src/engine/tools.ts new file mode 100644 index 0000000..32c14ba --- /dev/null +++ b/components/pixel-art-editor/src/engine/tools.ts @@ -0,0 +1,72 @@ +import { Color, GridData } from './types' +import { getPixel, setPixel, colorsMatch } from './grid' + +export function applyDraw(grid: GridData, x: number, y: number, color: Color): void { + if (x < 0 || x >= grid.width || y < 0 || y >= grid.height) return + setPixel(grid, x, y, color) +} + +export function applyErase(grid: GridData, x: number, y: number): void { + if (x < 0 || x >= grid.width || y < 0 || y >= grid.height) return + setPixel(grid, x, y, { r: 0, g: 0, b: 0, a: 0 }) +} + +export function applyFill(grid: GridData, x: number, y: number, color: Color): void { + if (x < 0 || x >= grid.width || y < 0 || y >= grid.height) return + + const target = getPixel(grid, x, y) + if (colorsMatch(target, color)) return + + const queue: [number, number][] = [[x, y]] + const visited = new Uint8Array(grid.width * grid.height) + + while (queue.length > 0) { + const [cx, cy] = queue.shift()! + const vi = cy * grid.width + cx + + if (cx < 0 || cx >= grid.width || cy < 0 || cy >= grid.height) continue + if (visited[vi]) continue + visited[vi] = 1 + + const current = getPixel(grid, cx, cy) + if (!colorsMatch(current, target)) continue + + setPixel(grid, cx, cy, color) + + queue.push([cx + 1, cy]) + queue.push([cx - 1, cy]) + queue.push([cx, cy + 1]) + queue.push([cx, cy - 1]) + } +} + +// Bresenham line interpolation for smooth drag drawing +export function getLinePoints( + x0: number, y0: number, + x1: number, y1: number +): [number, number][] { + const points: [number, number][] = [] + const dx = Math.abs(x1 - x0) + const dy = Math.abs(y1 - y0) + const sx = x0 < x1 ? 1 : -1 + const sy = y0 < y1 ? 1 : -1 + let err = dx - dy + let cx = x0 + let cy = y0 + + while (true) { + points.push([cx, cy]) + if (cx === x1 && cy === y1) break + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + cx += sx + } + if (e2 < dx) { + err += dx + cy += sy + } + } + + return points +} diff --git a/components/pixel-art-editor/src/engine/types.ts b/components/pixel-art-editor/src/engine/types.ts new file mode 100644 index 0000000..5ef6922 --- /dev/null +++ b/components/pixel-art-editor/src/engine/types.ts @@ -0,0 +1,22 @@ +export type Tool = 'draw' | 'erase' | 'fill' + +export interface Color { + r: number + g: number + b: number + a: number +} + +export interface GridData { + width: number + height: number + pixels: Uint8Array // length = width * height * 4 (RGBA) +} + +export interface RenderOptions { + showGrid: boolean + cursorX: number | null + cursorY: number | null + currentColor: Color + currentTool: Tool +} diff --git a/components/pixel-art-editor/src/index.tsx b/components/pixel-art-editor/src/index.tsx new file mode 100644 index 0000000..5b9f2fa --- /dev/null +++ b/components/pixel-art-editor/src/index.tsx @@ -0,0 +1 @@ +export { PixelArtEditor } from './PixelArtEditor' diff --git a/components/pixel-art-editor/tools.test.ts b/components/pixel-art-editor/tools.test.ts new file mode 100644 index 0000000..8ef6456 --- /dev/null +++ b/components/pixel-art-editor/tools.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { createGrid, getPixel, setPixel } from './src/engine/grid' +import { applyDraw, applyErase, applyFill, getLinePoints } from './src/engine/tools' + +const RED = { r: 255, g: 0, b: 0, a: 255 } +const BLUE = { r: 0, g: 0, b: 255, a: 255 } +const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 } + +describe('applyDraw', () => { + it('sets a pixel to the given color', () => { + const grid = createGrid(4, 4) + applyDraw(grid, 1, 2, RED) + expect(getPixel(grid, 1, 2)).toEqual(RED) + }) + + it('ignores out-of-bounds coordinates', () => { + const grid = createGrid(4, 4) + applyDraw(grid, -1, 0, RED) + applyDraw(grid, 4, 0, RED) + // No error thrown, grid unchanged + expect(getPixel(grid, 0, 0)).toEqual(TRANSPARENT) + }) +}) + +describe('applyErase', () => { + it('sets a pixel to transparent', () => { + const grid = createGrid(4, 4) + applyDraw(grid, 1, 1, RED) + applyErase(grid, 1, 1) + expect(getPixel(grid, 1, 1)).toEqual(TRANSPARENT) + }) +}) + +describe('applyFill', () => { + it('fills an empty grid entirely', () => { + const grid = createGrid(4, 4) + applyFill(grid, 0, 0, RED) + for (let y = 0; y < 4; y++) { + for (let x = 0; x < 4; x++) { + expect(getPixel(grid, x, y)).toEqual(RED) + } + } + }) + + it('fills only the connected region', () => { + const grid = createGrid(4, 4) + // Draw a vertical blue wall at x=2 + for (let y = 0; y < 4; y++) { + setPixel(grid, 2, y, BLUE) + } + // Fill left side with red + applyFill(grid, 0, 0, RED) + // Left side should be red + expect(getPixel(grid, 0, 0)).toEqual(RED) + expect(getPixel(grid, 1, 1)).toEqual(RED) + // Wall should still be blue + expect(getPixel(grid, 2, 0)).toEqual(BLUE) + // Right side should still be transparent + expect(getPixel(grid, 3, 0)).toEqual(TRANSPARENT) + }) + + it('is a no-op when fill color matches target', () => { + const grid = createGrid(4, 4) + applyDraw(grid, 0, 0, RED) + applyFill(grid, 0, 0, RED) + // Should not infinite loop, just return + expect(getPixel(grid, 0, 0)).toEqual(RED) + }) + + it('ignores out-of-bounds coordinates', () => { + const grid = createGrid(4, 4) + applyFill(grid, -1, 0, RED) + expect(getPixel(grid, 0, 0)).toEqual(TRANSPARENT) + }) +}) + +describe('getLinePoints', () => { + it('returns a single point for same start and end', () => { + expect(getLinePoints(2, 3, 2, 3)).toEqual([[2, 3]]) + }) + + it('returns correct points for a horizontal line', () => { + const points = getLinePoints(0, 0, 3, 0) + expect(points).toEqual([[0, 0], [1, 0], [2, 0], [3, 0]]) + }) + + it('returns correct points for a vertical line', () => { + const points = getLinePoints(0, 0, 0, 3) + expect(points).toEqual([[0, 0], [0, 1], [0, 2], [0, 3]]) + }) + + it('returns correct points for a diagonal line', () => { + const points = getLinePoints(0, 0, 2, 2) + expect(points).toEqual([[0, 0], [1, 1], [2, 2]]) + }) +}) diff --git a/components/pixel-art-editor/tsconfig.json b/components/pixel-art-editor/tsconfig.json new file mode 100644 index 0000000..55be51b --- /dev/null +++ b/components/pixel-art-editor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["**/*.tsx", "**/*.ts"] +}