Skip to content

Commit 41dcda9

Browse files
committed
feat: add collaborative pixel art editor
1 parent b83b67f commit 41dcda9

File tree

7 files changed

+1072
-2
lines changed

7 files changed

+1072
-2
lines changed

js-peer/src/components/nav.tsx

+5-1
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(' ')
+363
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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(
92+
pixel.x * pixelSize,
93+
pixel.y * pixelSize,
94+
pixelSize,
95+
pixelSize
96+
)
97+
})
98+
}
99+
100+
// Handle canvas resize
101+
useEffect(() => {
102+
const handleResize = () => {
103+
// Adjust canvas size based on window width
104+
const containerWidth = Math.min(window.innerWidth - 40, 512)
105+
setCanvasSize(containerWidth)
106+
}
107+
108+
handleResize()
109+
window.addEventListener('resize', handleResize)
110+
111+
return () => {
112+
window.removeEventListener('resize', handleResize)
113+
}
114+
}, [])
115+
116+
// Draw the canvas whenever the pixel art state changes or canvas size changes
117+
useEffect(() => {
118+
drawCanvas()
119+
setPixelCount(pixelArtState.grid.length)
120+
}, [pixelArtState, canvasSize, showGrid])
121+
122+
// Convert mouse/touch position to grid coordinates
123+
const getGridCoordinates = (clientX: number, clientY: number) => {
124+
const canvas = canvasRef.current
125+
if (!canvas) return { x: -1, y: -1 }
126+
127+
const rect = canvas.getBoundingClientRect()
128+
const x = Math.floor((clientX - rect.left) / pixelSize)
129+
const y = Math.floor((clientY - rect.top) / pixelSize)
130+
131+
// Ensure coordinates are within grid bounds
132+
if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
133+
return { x, y }
134+
}
135+
136+
return { x: -1, y: -1 }
137+
}
138+
139+
// Mouse/touch event handlers
140+
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
141+
setIsDrawing(true)
142+
const { x, y } = getGridCoordinates(e.clientX, e.clientY)
143+
if (x >= 0 && y >= 0) {
144+
setPixel(x, y, selectedColor)
145+
}
146+
}
147+
148+
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
149+
if (!isDrawing) return
150+
151+
const { x, y } = getGridCoordinates(e.clientX, e.clientY)
152+
if (x >= 0 && y >= 0) {
153+
setPixel(x, y, selectedColor)
154+
}
155+
}
156+
157+
const handleMouseUp = () => {
158+
setIsDrawing(false)
159+
}
160+
161+
const handleMouseLeave = () => {
162+
setIsDrawing(false)
163+
}
164+
165+
// Touch event handlers
166+
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
167+
e.preventDefault()
168+
setIsDrawing(true)
169+
170+
const touch = e.touches[0]
171+
const { x, y } = getGridCoordinates(touch.clientX, touch.clientY)
172+
if (x >= 0 && y >= 0) {
173+
setPixel(x, y, selectedColor)
174+
}
175+
}
176+
177+
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
178+
e.preventDefault()
179+
if (!isDrawing) return
180+
181+
const touch = e.touches[0]
182+
const { x, y } = getGridCoordinates(touch.clientX, touch.clientY)
183+
if (x >= 0 && y >= 0) {
184+
setPixel(x, y, selectedColor)
185+
}
186+
}
187+
188+
const handleTouchEnd = () => {
189+
setIsDrawing(false)
190+
}
191+
192+
// Handle loading a preset
193+
const handleLoadPreset = (presetName: string) => {
194+
const presetFunction = presets[presetName as keyof typeof presets]
195+
if (presetFunction) {
196+
loadPreset(presetFunction())
197+
setShowPresets(false) // Hide presets after selection
198+
}
199+
}
200+
201+
// Handle refreshing the canvas
202+
const handleRefreshCanvas = () => {
203+
setIsRefreshing(true)
204+
requestFullState()
205+
206+
// Reset the refreshing state after a timeout
207+
setTimeout(() => {
208+
setIsRefreshing(false)
209+
}, 2000)
210+
}
211+
212+
// Handle broadcasting the full state
213+
const handleBroadcastState = () => {
214+
if (pixelArtState.grid.length > 0) {
215+
broadcastFullState()
216+
}
217+
}
218+
219+
// Toggle grid visibility
220+
const toggleGrid = () => {
221+
setShowGrid(!showGrid)
222+
}
223+
224+
return (
225+
<div className="flex flex-col items-center p-4">
226+
<h1 className="text-2xl font-bold mb-4">Collaborative Pixel Art</h1>
227+
<p className="text-gray-600 mb-4">Draw together with peers in real-time! 🎨</p>
228+
229+
<div className="mb-4 bg-white rounded-lg shadow-md p-4">
230+
<canvas
231+
ref={canvasRef}
232+
width={canvasSize}
233+
height={canvasSize}
234+
className="border border-gray-300 rounded-lg cursor-crosshair"
235+
onMouseDown={handleMouseDown}
236+
onMouseMove={handleMouseMove}
237+
onMouseUp={handleMouseUp}
238+
onMouseLeave={handleMouseLeave}
239+
onTouchStart={handleTouchStart}
240+
onTouchMove={handleTouchMove}
241+
onTouchEnd={handleTouchEnd}
242+
/>
243+
<div className="mt-2 flex justify-between items-center">
244+
<div className="text-sm text-gray-500">
245+
32 x 32 grid
246+
</div>
247+
<button
248+
onClick={toggleGrid}
249+
className={`text-sm px-2 py-1 rounded ${showGrid ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}
250+
>
251+
{showGrid ? 'Hide Grid' : 'Show Grid'}
252+
</button>
253+
</div>
254+
</div>
255+
256+
<div className="mb-4">
257+
<div className="flex flex-wrap justify-center gap-2 mb-4 max-w-md mx-auto">
258+
{colorPalette.map((color) => (
259+
<button
260+
key={color}
261+
className={`w-6 h-6 rounded-full border-2 ${
262+
selectedColor === color ? 'border-black' : 'border-gray-300'
263+
}`}
264+
style={{ backgroundColor: color }}
265+
onClick={() => setSelectedColor(color)}
266+
aria-label={`Select color ${color}`}
267+
/>
268+
))}
269+
</div>
270+
271+
<div className="flex justify-center gap-2 mb-4">
272+
<Button
273+
onClick={clearCanvas}
274+
color="red"
275+
className="px-4 py-2"
276+
>
277+
Clear Canvas
278+
</Button>
279+
280+
<Button
281+
onClick={() => setShowPresets(!showPresets)}
282+
color="blue"
283+
className="px-4 py-2"
284+
>
285+
{showPresets ? 'Hide Presets' : 'Show Presets'}
286+
</Button>
287+
288+
<Button
289+
onClick={handleRefreshCanvas}
290+
color="green"
291+
className="px-4 py-2 flex items-center"
292+
disabled={isRefreshing}
293+
>
294+
{isRefreshing ? 'Refreshing...' : 'Refresh Canvas'}
295+
{isRefreshing && (
296+
<span className="ml-2 inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
297+
)}
298+
</Button>
299+
</div>
300+
301+
{showPresets && (
302+
<div className="mt-4 p-4 border border-gray-200 rounded-lg bg-gray-50">
303+
<h3 className="text-lg font-semibold mb-2">Preset Art</h3>
304+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
305+
{Object.keys(presets).map((presetName) => (
306+
<Button
307+
key={presetName}
308+
onClick={() => handleLoadPreset(presetName as keyof typeof presets)}
309+
color="indigo"
310+
className="px-3 py-1 text-sm"
311+
>
312+
{presetName}
313+
</Button>
314+
))}
315+
</div>
316+
</div>
317+
)}
318+
</div>
319+
320+
<div className="text-sm text-gray-500 mt-2">
321+
<p>Connected peers will see your artwork in real-time!</p>
322+
<p>New peers will automatically receive the current canvas state.</p>
323+
<p className="mt-1">Current pixel count: <span className="font-semibold">{pixelCount}</span></p>
324+
325+
<div className="mt-3 flex items-center">
326+
<input
327+
type="checkbox"
328+
id="debug-mode"
329+
checked={debugMode}
330+
onChange={() => setDebugMode(!debugMode)}
331+
className="mr-2"
332+
/>
333+
<label htmlFor="debug-mode">Debug Mode</label>
334+
</div>
335+
336+
{debugMode && (
337+
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono overflow-auto max-h-40">
338+
<p>Pixel Data (most recent 5):</p>
339+
<ul>
340+
{pixelArtState.grid
341+
.sort((a, b) => b.timestamp - a.timestamp)
342+
.slice(0, 5)
343+
.map((pixel, index) => (
344+
<li key={index}>
345+
({pixel.x}, {pixel.y}) - {pixel.color} - {new Date(pixel.timestamp).toLocaleTimeString()} - {pixel.peerId.substring(0, 8)}...
346+
</li>
347+
))}
348+
</ul>
349+
<div className="mt-2">
350+
<button
351+
onClick={handleBroadcastState}
352+
className="bg-blue-500 text-white px-2 py-1 rounded text-xs"
353+
disabled={pixelArtState.grid.length === 0}
354+
>
355+
Broadcast Full State
356+
</button>
357+
</div>
358+
</div>
359+
)}
360+
</div>
361+
</div>
362+
)
363+
}

js-peer/src/context/ctx.tsx

+6-1
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,11 @@ export function AppWrapper({ children }: WrapperProps) {
5859

5960
return (
6061
<libp2pContext.Provider value={{ libp2p }}>
61-
<ChatProvider>{children}</ChatProvider>
62+
<ChatProvider>
63+
<PixelArtProvider>
64+
{children}
65+
</PixelArtProvider>
66+
</ChatProvider>
6267
</libp2pContext.Provider>
6368
)
6469
}

0 commit comments

Comments
 (0)