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 0000000..a7fe6c1 Binary files /dev/null and b/components/pixel-art-editor/cover.png differ 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) => ( + setTool(t)} + disabled={disabled} + > + {t === 'draw' ? 'Pencil' : t === 'erase' ? 'Eraser' : 'Fill'} + + ))} + + + + + + {PALETTE.map((hex) => ( + setColorHex(hex)} + title={hex} + disabled={disabled} + /> + ))} + setColorHex(e.target.value)} + title="Custom color" + disabled={disabled} + /> + + + + + + Clear + + + Export PNG + + + + + + + + ) +} 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"] +}