Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions components/pixel-art-editor/README.md
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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
Binary file added components/pixel-art-editor/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions components/pixel-art-editor/grid.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 7 additions & 0 deletions components/pixel-art-editor/metadata.json
Original file line number Diff line number Diff line change
@@ -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"]
}
28 changes: 28 additions & 0 deletions components/pixel-art-editor/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
151 changes: 151 additions & 0 deletions components/pixel-art-editor/src/PixelArtEditor.module.css
Original file line number Diff line number Diff line change
@@ -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%;
}
Loading
Loading