Skip to content

Commit 3fb8e93

Browse files
committed
feat: add collaborative pixel art editor
1 parent b83b67f commit 3fb8e93

File tree

7 files changed

+1034
-2
lines changed

7 files changed

+1034
-2
lines changed

js-peer/src/components/nav.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import Link from 'next/link'
55
import Image from 'next/image'
66
import { useRouter } from 'next/router'
77

8-
const navigationItems = [{ name: 'Source', href: 'https://github.com/libp2p/universal-connectivity' }]
8+
const navigationItems = [
9+
{ name: 'Chat', href: '/' },
10+
{ name: 'Pixel Art', href: '/pixel-art' },
11+
{ name: 'Source', href: 'https://github.com/libp2p/universal-connectivity' },
12+
]
913

1014
function classNames(...classes: string[]) {
1115
return classes.filter(Boolean).join(' ')
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import React, { useEffect, useRef, useState } from 'react'
2+
import { usePixelArtContext, GRID_SIZE } from '@/context/pixel-art-ctx'
3+
import { Button } from './button'
4+
import { presets } from '@/lib/pixel-art-presets'
5+
6+
// Predefined color palette
7+
const colorPalette = [
8+
'#000000', // Black
9+
'#FFFFFF', // White
10+
'#FF0000', // Red
11+
'#00FF00', // Green
12+
'#0000FF', // Blue
13+
'#FFFF00', // Yellow
14+
'#FF00FF', // Magenta
15+
'#00FFFF', // Cyan
16+
'#FFA500', // Orange
17+
'#800080', // Purple
18+
'#008000', // Dark Green
19+
'#800000', // Maroon
20+
'#008080', // Teal
21+
'#FFC0CB', // Pink
22+
'#A52A2A', // Brown
23+
'#808080', // Gray
24+
'#C0C0C0', // Silver
25+
'#000080', // Navy
26+
'#FFD700', // Gold
27+
'#4B0082', // Indigo
28+
]
29+
30+
export default function PixelArtEditor() {
31+
const {
32+
pixelArtState,
33+
setPixel,
34+
selectedColor,
35+
setSelectedColor,
36+
clearCanvas,
37+
loadPreset,
38+
requestFullState,
39+
broadcastFullState,
40+
} = usePixelArtContext()
41+
const canvasRef = useRef<HTMLCanvasElement>(null)
42+
const [isDrawing, setIsDrawing] = useState(false)
43+
const [canvasSize, setCanvasSize] = useState(512) // Default canvas size
44+
const [showPresets, setShowPresets] = useState(false)
45+
const [isRefreshing, setIsRefreshing] = useState(false)
46+
const [pixelCount, setPixelCount] = useState(0)
47+
const [debugMode, setDebugMode] = useState(false)
48+
const [showGrid, setShowGrid] = useState(true)
49+
const pixelSize = canvasSize / GRID_SIZE
50+
51+
// Function to draw the grid and pixels
52+
const drawCanvas = () => {
53+
const canvas = canvasRef.current
54+
if (!canvas) return
55+
56+
const ctx = canvas.getContext('2d')
57+
if (!ctx) return
58+
59+
// Clear the canvas
60+
ctx.clearRect(0, 0, canvas.width, canvas.height)
61+
62+
// Draw the background (white)
63+
ctx.fillStyle = '#FFFFFF'
64+
ctx.fillRect(0, 0, canvas.width, canvas.height)
65+
66+
// Draw the grid lines if enabled
67+
if (showGrid) {
68+
ctx.strokeStyle = '#EEEEEE'
69+
ctx.lineWidth = 1
70+
71+
// Draw vertical grid lines
72+
for (let x = 0; x <= GRID_SIZE; x++) {
73+
ctx.beginPath()
74+
ctx.moveTo(x * pixelSize, 0)
75+
ctx.lineTo(x * pixelSize, canvas.height)
76+
ctx.stroke()
77+
}
78+
79+
// Draw horizontal grid lines
80+
for (let y = 0; y <= GRID_SIZE; y++) {
81+
ctx.beginPath()
82+
ctx.moveTo(0, y * pixelSize)
83+
ctx.lineTo(canvas.width, y * pixelSize)
84+
ctx.stroke()
85+
}
86+
}
87+
88+
// Draw the pixels
89+
pixelArtState.grid.forEach((pixel) => {
90+
ctx.fillStyle = pixel.color
91+
ctx.fillRect(pixel.x * pixelSize, pixel.y * pixelSize, pixelSize, pixelSize)
92+
})
93+
}
94+
95+
// Handle canvas resize
96+
useEffect(() => {
97+
const handleResize = () => {
98+
// Adjust canvas size based on window width
99+
const containerWidth = Math.min(window.innerWidth - 40, 512)
100+
setCanvasSize(containerWidth)
101+
}
102+
103+
handleResize()
104+
window.addEventListener('resize', handleResize)
105+
106+
return () => {
107+
window.removeEventListener('resize', handleResize)
108+
}
109+
}, [])
110+
111+
// Draw the canvas whenever the pixel art state changes or canvas size changes
112+
useEffect(() => {
113+
drawCanvas()
114+
setPixelCount(pixelArtState.grid.length)
115+
}, [pixelArtState, canvasSize, showGrid])
116+
117+
// Convert mouse/touch position to grid coordinates
118+
const getGridCoordinates = (clientX: number, clientY: number) => {
119+
const canvas = canvasRef.current
120+
if (!canvas) return { x: -1, y: -1 }
121+
122+
const rect = canvas.getBoundingClientRect()
123+
const x = Math.floor((clientX - rect.left) / pixelSize)
124+
const y = Math.floor((clientY - rect.top) / pixelSize)
125+
126+
// Ensure coordinates are within grid bounds
127+
if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
128+
return { x, y }
129+
}
130+
131+
return { x: -1, y: -1 }
132+
}
133+
134+
// Mouse/touch event handlers
135+
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
136+
setIsDrawing(true)
137+
const { x, y } = getGridCoordinates(e.clientX, e.clientY)
138+
if (x >= 0 && y >= 0) {
139+
setPixel(x, y, selectedColor)
140+
}
141+
}
142+
143+
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
144+
if (!isDrawing) return
145+
146+
const { x, y } = getGridCoordinates(e.clientX, e.clientY)
147+
if (x >= 0 && y >= 0) {
148+
setPixel(x, y, selectedColor)
149+
}
150+
}
151+
152+
const handleMouseUp = () => {
153+
setIsDrawing(false)
154+
}
155+
156+
const handleMouseLeave = () => {
157+
setIsDrawing(false)
158+
}
159+
160+
// Touch event handlers
161+
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
162+
e.preventDefault()
163+
setIsDrawing(true)
164+
165+
const touch = e.touches[0]
166+
const { x, y } = getGridCoordinates(touch.clientX, touch.clientY)
167+
if (x >= 0 && y >= 0) {
168+
setPixel(x, y, selectedColor)
169+
}
170+
}
171+
172+
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
173+
e.preventDefault()
174+
if (!isDrawing) return
175+
176+
const touch = e.touches[0]
177+
const { x, y } = getGridCoordinates(touch.clientX, touch.clientY)
178+
if (x >= 0 && y >= 0) {
179+
setPixel(x, y, selectedColor)
180+
}
181+
}
182+
183+
const handleTouchEnd = () => {
184+
setIsDrawing(false)
185+
}
186+
187+
// Handle loading a preset
188+
const handleLoadPreset = (presetName: string) => {
189+
const presetFunction = presets[presetName as keyof typeof presets]
190+
if (presetFunction) {
191+
loadPreset(presetFunction())
192+
setShowPresets(false) // Hide presets after selection
193+
}
194+
}
195+
196+
// Handle refreshing the canvas
197+
const handleRefreshCanvas = () => {
198+
setIsRefreshing(true)
199+
requestFullState()
200+
201+
// Reset the refreshing state after a timeout
202+
setTimeout(() => {
203+
setIsRefreshing(false)
204+
}, 2000)
205+
}
206+
207+
// Handle broadcasting the full state
208+
const handleBroadcastState = () => {
209+
if (pixelArtState.grid.length > 0) {
210+
broadcastFullState()
211+
}
212+
}
213+
214+
// Toggle grid visibility
215+
const toggleGrid = () => {
216+
setShowGrid(!showGrid)
217+
}
218+
219+
return (
220+
<div className="flex flex-col items-center p-4">
221+
<h1 className="text-2xl font-bold mb-4">Collaborative Pixel Art</h1>
222+
<p className="text-gray-600 mb-4">Draw together with peers in real-time! 🎨</p>
223+
224+
<div className="mb-4 bg-white rounded-lg shadow-md p-4">
225+
<canvas
226+
ref={canvasRef}
227+
width={canvasSize}
228+
height={canvasSize}
229+
className="border border-gray-300 rounded-lg cursor-crosshair"
230+
onMouseDown={handleMouseDown}
231+
onMouseMove={handleMouseMove}
232+
onMouseUp={handleMouseUp}
233+
onMouseLeave={handleMouseLeave}
234+
onTouchStart={handleTouchStart}
235+
onTouchMove={handleTouchMove}
236+
onTouchEnd={handleTouchEnd}
237+
/>
238+
<div className="mt-2 flex justify-between items-center">
239+
<div className="text-sm text-gray-500">32 x 32 grid</div>
240+
<button
241+
onClick={toggleGrid}
242+
className={`text-sm px-2 py-1 rounded ${showGrid ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}
243+
>
244+
{showGrid ? 'Hide Grid' : 'Show Grid'}
245+
</button>
246+
</div>
247+
</div>
248+
249+
<div className="mb-4">
250+
<div className="flex flex-wrap justify-center gap-2 mb-4 max-w-md mx-auto">
251+
{colorPalette.map((color) => (
252+
<button
253+
key={color}
254+
className={`w-6 h-6 rounded-full border-2 ${
255+
selectedColor === color ? 'border-black' : 'border-gray-300'
256+
}`}
257+
style={{ backgroundColor: color }}
258+
onClick={() => setSelectedColor(color)}
259+
aria-label={`Select color ${color}`}
260+
/>
261+
))}
262+
</div>
263+
264+
<div className="flex justify-center gap-2 mb-4">
265+
<Button onClick={clearCanvas} color="red" className="px-4 py-2">
266+
Clear Canvas
267+
</Button>
268+
269+
<Button onClick={() => setShowPresets(!showPresets)} color="blue" className="px-4 py-2">
270+
{showPresets ? 'Hide Presets' : 'Show Presets'}
271+
</Button>
272+
273+
<Button
274+
onClick={handleRefreshCanvas}
275+
color="green"
276+
className="px-4 py-2 flex items-center"
277+
disabled={isRefreshing}
278+
>
279+
{isRefreshing ? 'Refreshing...' : 'Refresh Canvas'}
280+
{isRefreshing && (
281+
<span className="ml-2 inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
282+
)}
283+
</Button>
284+
</div>
285+
286+
{showPresets && (
287+
<div className="mt-4 p-4 border border-gray-200 rounded-lg bg-gray-50">
288+
<h3 className="text-lg font-semibold mb-2">Preset Art</h3>
289+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
290+
{Object.keys(presets).map((presetName) => (
291+
<Button
292+
key={presetName}
293+
onClick={() => handleLoadPreset(presetName as keyof typeof presets)}
294+
color="indigo"
295+
className="px-3 py-1 text-sm"
296+
>
297+
{presetName}
298+
</Button>
299+
))}
300+
</div>
301+
</div>
302+
)}
303+
</div>
304+
305+
<div className="text-sm text-gray-500 mt-2">
306+
<p>Connected peers will see your artwork in real-time!</p>
307+
<p>New peers will automatically receive the current canvas state.</p>
308+
<p className="mt-1">
309+
Current pixel count: <span className="font-semibold">{pixelCount}</span>
310+
</p>
311+
312+
<div className="mt-3 flex items-center">
313+
<input
314+
type="checkbox"
315+
id="debug-mode"
316+
checked={debugMode}
317+
onChange={() => setDebugMode(!debugMode)}
318+
className="mr-2"
319+
/>
320+
<label htmlFor="debug-mode">Debug Mode</label>
321+
</div>
322+
323+
{debugMode && (
324+
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono overflow-auto max-h-40">
325+
<p>Pixel Data (most recent 5):</p>
326+
<ul>
327+
{pixelArtState.grid
328+
.sort((a, b) => b.timestamp - a.timestamp)
329+
.slice(0, 5)
330+
.map((pixel, index) => (
331+
<li key={index}>
332+
({pixel.x}, {pixel.y}) - {pixel.color} - {new Date(pixel.timestamp).toLocaleTimeString()} -{' '}
333+
{pixel.peerId.substring(0, 8)}...
334+
</li>
335+
))}
336+
</ul>
337+
<div className="mt-2">
338+
<button
339+
onClick={handleBroadcastState}
340+
className="bg-blue-500 text-white px-2 py-1 rounded text-xs"
341+
disabled={pixelArtState.grid.length === 0}
342+
>
343+
Broadcast Full State
344+
</button>
345+
</div>
346+
</div>
347+
)}
348+
</div>
349+
</div>
350+
)
351+
}

js-peer/src/context/ctx.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
22
import { startLibp2p } from '../lib/libp2p'
33
import { ChatProvider } from './chat-ctx'
4+
import { PixelArtProvider } from './pixel-art-ctx'
45
import type { Libp2p, PubSub } from '@libp2p/interface'
56
import type { Identify } from '@libp2p/identify'
67
import type { DirectMessage } from '@/lib/direct-message'
@@ -58,7 +59,9 @@ export function AppWrapper({ children }: WrapperProps) {
5859

5960
return (
6061
<libp2pContext.Provider value={{ libp2p }}>
61-
<ChatProvider>{children}</ChatProvider>
62+
<ChatProvider>
63+
<PixelArtProvider>{children}</PixelArtProvider>
64+
</ChatProvider>
6265
</libp2pContext.Provider>
6366
)
6467
}

0 commit comments

Comments
 (0)