466 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
| <!DOCTYPE html>
 | |
| <html lang="en">
 | |
| <head>
 | |
|     <meta charset="UTF-8">
 | |
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | |
|     <title>Pixel Art Canvas</title>
 | |
|     <style>
 | |
|         /* --- Reset and Base Styles --- */
 | |
|         :root {
 | |
|             --bg-color: #f7fafc;
 | |
|             --text-color: #2d3748;
 | |
|             --grid-border: #cbd5e0;
 | |
|             --header-color: #1a202c;
 | |
|             --subheader-color: #718096;
 | |
|             --toolbar-bg: #ffffff;
 | |
|             --button-bg: #e53e3e;
 | |
|             --button-hover-bg: #c53030;
 | |
|             --swatch-border: #e2e8f0;
 | |
|             --active-swatch-border: #3b82f6;
 | |
|             --input-bg: #f7fafc;
 | |
|             --input-border: #e2e8f0;
 | |
|             --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
 | |
|             --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
 | |
|         }
 | |
| 
 | |
|         /* --- Dark Mode --- */
 | |
|         @media (prefers-color-scheme: dark) {
 | |
|             :root {
 | |
|                 --bg-color: #1a202c;
 | |
|                 --text-color: #e2e8f0;
 | |
|                 --grid-border: #4a5568;
 | |
|                 --header-color: #ffffff;
 | |
|                 --subheader-color: #a0aec0;
 | |
|                 --toolbar-bg: #2d3748;
 | |
|                 --swatch-border: #4a5568;
 | |
|                 --input-bg: #4a5568;
 | |
|                 --input-border: #718096;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         body {
 | |
|             margin: 0;
 | |
|             font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
 | |
|             background-color: var(--bg-color);
 | |
|             color: var(--text-color);
 | |
|             display: flex;
 | |
|             flex-direction: column;
 | |
|             align-items: center;
 | |
|             justify-content: center;
 | |
|             min-height: 100vh;
 | |
|             padding: 1rem;
 | |
|             box-sizing: border-box;
 | |
|             touch-action: none; /* Disable browser default touch actions like pan/zoom */
 | |
|         }
 | |
| 
 | |
|         /* --- Layout and Components --- */
 | |
|         .main-container {
 | |
|             width: 100%;
 | |
|             max-width: 1200px;
 | |
|             display: flex;
 | |
|             flex-direction: column;
 | |
|             align-items: center;
 | |
|         }
 | |
| 
 | |
|         header {
 | |
|             text-align: center;
 | |
|             margin-bottom: 1.5rem;
 | |
|         }
 | |
| 
 | |
|         h1 {
 | |
|             font-size: 2.25rem;
 | |
|             font-weight: 700;
 | |
|             color: var(--header-color);
 | |
|             margin: 0;
 | |
|         }
 | |
| 
 | |
|         header p {
 | |
|             margin-top: 0.25rem;
 | |
|             color: var(--subheader-color);
 | |
|         }
 | |
| 
 | |
|         .toolbar {
 | |
|             display: flex;
 | |
|             flex-wrap: wrap;
 | |
|             align-items: center;
 | |
|             justify-content: center;
 | |
|             gap: 1.5rem;
 | |
|             margin-bottom: 1.5rem;
 | |
|             padding: 1rem;
 | |
|             background-color: var(--toolbar-bg);
 | |
|             border-radius: 0.5rem;
 | |
|             box-shadow: var(--shadow-md);
 | |
|         }
 | |
| 
 | |
|         .control-group {
 | |
|             display: flex;
 | |
|             align-items: center;
 | |
|             gap: 0.75rem;
 | |
|         }
 | |
| 
 | |
|         label {
 | |
|             font-weight: 500;
 | |
|         }
 | |
| 
 | |
|         #colorPicker {
 | |
|             width: 2.5rem;
 | |
|             height: 2.5rem;
 | |
|             padding: 0;
 | |
|             border: none;
 | |
|             border-radius: 0.375rem;
 | |
|             background: none;
 | |
|             cursor: pointer;
 | |
|         }
 | |
| 
 | |
|         #colorPicker::-webkit-color-swatch-wrapper {
 | |
|             padding: 0;
 | |
|         }
 | |
| 
 | |
|         #colorPicker::-webkit-color-swatch {
 | |
|             border: 1px solid var(--swatch-border);
 | |
|             border-radius: 0.375rem;
 | |
|         }
 | |
| 
 | |
|         .color-palette {
 | |
|             display: flex;
 | |
|             gap: 0.5rem;
 | |
|             flex-wrap: wrap;
 | |
|         }
 | |
| 
 | |
|         .color-swatch {
 | |
|             width: 2rem;
 | |
|             height: 2rem;
 | |
|             border-radius: 50%;
 | |
|             border: 2px solid var(--swatch-border);
 | |
|             cursor: pointer;
 | |
|             transition: transform 0.1s ease, border-color 0.1s ease;
 | |
|         }
 | |
| 
 | |
|         .color-swatch:hover {
 | |
|             transform: scale(1.1);
 | |
|         }
 | |
| 
 | |
|         .color-swatch.active {
 | |
|             border-color: var(--active-swatch-border);
 | |
|             transform: scale(1.15);
 | |
|             box-shadow: 0 0 0 2px var(--active-swatch-border);
 | |
|         }
 | |
| 
 | |
|         select {
 | |
|             padding: 0.5rem;
 | |
|             border-radius: 0.375rem;
 | |
|             border: 1px solid var(--input-border);
 | |
|             background-color: var(--input-bg);
 | |
|             color: var(--text-color);
 | |
|         }
 | |
| 
 | |
|         #clearCanvas {
 | |
|             padding: 0.6rem 1rem;
 | |
|             background-color: var(--button-bg);
 | |
|             color: white;
 | |
|             border: none;
 | |
|             border-radius: 0.5rem;
 | |
|             font-weight: 500;
 | |
|             cursor: pointer;
 | |
|             transition: background-color 0.2s ease;
 | |
|         }
 | |
| 
 | |
|         #clearCanvas:hover {
 | |
|             background-color: var(--button-hover-bg);
 | |
|         }
 | |
| 
 | |
|         #grid-container {
 | |
|             display: grid;
 | |
|             grid-template-columns: repeat(80, 1fr);
 | |
|             border: 1px solid var(--grid-border);
 | |
|             width: 100%;
 | |
|             box-shadow: var(--shadow-lg);
 | |
|             cursor: crosshair;
 | |
|         }
 | |
| 
 | |
|         .pixel {
 | |
|             width: 100%;
 | |
|             padding-bottom: 100%; /* Creates a square aspect ratio */
 | |
|             background-color: #ffffff;
 | |
|             transition: background-color 0.1s ease;
 | |
|         }
 | |
|     </style>
 | |
| </head>
 | |
| <body>
 | |
| 
 | |
| <div class="main-container">
 | |
|     <header>
 | |
|         <h1>Pixel Art Canvas</h1>
 | |
|         <p>Click and drag to paint. Your changes are live.</p>
 | |
|     </header>
 | |
| 
 | |
|     <div class="toolbar">
 | |
|         <div class="control-group">
 | |
|             <label for="colorPicker">Color:</label>
 | |
|             <div id="color-palette" class="color-palette"></div>
 | |
|             <input type="color" id="colorPicker" value="#3b82f6">
 | |
|         </div>
 | |
|         <div class="control-group">
 | |
|             <label for="brushSize">Brush Size:</label>
 | |
|             <select id="brushSize">
 | |
|                 <option value="1">1x1</option>
 | |
|                 <option value="3">3x3</option>
 | |
|                 <option value="5">5x5</option>
 | |
|             </select>
 | |
|         </div>
 | |
|         <div class="control-group">
 | |
|             <button id="clearCanvas">Clear</button>
 | |
|         </div>
 | |
|     </div>
 | |
| 
 | |
|     <div id="grid-container"></div>
 | |
| </div>
 | |
| 
 | |
| <script>
 | |
|     document.addEventListener('DOMContentLoaded', () => {
 | |
|         // --- DOM Elements ---
 | |
|         const gridContainer = document.getElementById('grid-container');
 | |
|         const colorPicker = document.getElementById('colorPicker');
 | |
|         const paletteContainer = document.getElementById('color-palette');
 | |
|         const brushSizeSelect = document.getElementById('brushSize');
 | |
|         const clearCanvasBtn = document.getElementById('clearCanvas');
 | |
| 
 | |
|         // --- Grid Configuration ---
 | |
|         const GRID_ROWS = 40;
 | |
|         const GRID_COLS = 80;
 | |
|         const DEFAULT_COLOR = '#ffffff';
 | |
|         const PRESET_COLORS = [
 | |
|             '#ffffff', '#c0c0c0', '#ff0000', '#ffa500', '#ffff00',
 | |
|             '#008000', '#0000ff', '#4b0082', '#ee82ee', '#000000'
 | |
|         ];
 | |
| 
 | |
|         // --- State Management ---
 | |
|         let pixelData = [];
 | |
|         let isPainting = false;
 | |
|         let currentColor = colorPicker.value;
 | |
| 
 | |
|         // --- Functions ---
 | |
| 
 | |
|         function initializePalette() {
 | |
|             paletteContainer.innerHTML = '';
 | |
|             PRESET_COLORS.forEach(color => {
 | |
|                 const swatch = document.createElement('div');
 | |
|                 swatch.classList.add('color-swatch');
 | |
|                 swatch.style.backgroundColor = color;
 | |
|                 swatch.dataset.color = color;
 | |
|                 paletteContainer.appendChild(swatch);
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         function setActiveColor(color) {
 | |
|             currentColor = color;
 | |
|             colorPicker.value = color;
 | |
|             document.querySelectorAll('.color-swatch').forEach(sw => {
 | |
|                 sw.classList.toggle('active', sw.dataset.color === color);
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         function initializeGrid() {
 | |
|             gridContainer.innerHTML = '';
 | |
|             pixelData = Array.from({length: GRID_ROWS}, () => Array(GRID_COLS).fill(DEFAULT_COLOR));
 | |
|             for (let r = 0; r < GRID_ROWS; r++) {
 | |
|                 for (let c = 0; c < GRID_COLS; c++) {
 | |
|                     const pixel = document.createElement('div');
 | |
|                     pixel.classList.add('pixel');
 | |
|                     pixel.dataset.row = r;
 | |
|                     pixel.dataset.col = c;
 | |
|                     gridContainer.appendChild(pixel);
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         /**
 | |
|          * Applies the current brush to the grid centered at the given coordinates.
 | |
|          * @param {number} centerRow - The center row for the brush.
 | |
|          * @param {number} centerCol - The center column for the brush.
 | |
|          */
 | |
|         function applyBrush(centerRow, centerCol) {
 | |
|             const size = parseInt(brushSizeSelect.value, 10);
 | |
|             const offset = Math.floor((size - 1) / 2);
 | |
| 
 | |
|             for (let r_offset = 0; r_offset < size; r_offset++) {
 | |
|                 for (let c_offset = 0; c_offset < size; c_offset++) {
 | |
|                     const targetRow = centerRow - offset + r_offset;
 | |
|                     const targetCol = centerCol - offset + c_offset;
 | |
| 
 | |
|                     // Check if the target pixel is within the grid bounds
 | |
|                     if (targetRow >= 0 && targetRow < GRID_ROWS && targetCol >= 0 && targetCol < GRID_COLS) {
 | |
|                         if (pixelData[targetRow][targetCol] !== currentColor) {
 | |
|                             pixelData[targetRow][targetCol] = currentColor;
 | |
| 
 | |
|                             // This could be slow on very large brushes, but is fine for this size.
 | |
|                             // A more optimized approach might update the DOM after the loop.
 | |
|                             const pixelEl = gridContainer.querySelector(`[data-row='${targetRow}'][data-col='${targetCol}']`);
 | |
|                             if (pixelEl) {
 | |
|                                 pixelEl.style.backgroundColor = currentColor;
 | |
|                                 const [r, g, b] = hexToRGB(currentColor);
 | |
|                                 if (ws)
 | |
|                                     ws.send(JSON.stringify({
 | |
|                                         ty: "pixel",
 | |
|                                         x: targetCol, y: targetRow,
 | |
|                                         r, g, b,
 | |
|                                     }));
 | |
|                                 else
 | |
|                                     fetch(`/pixel/${targetCol}/${targetRow}/${r}/${g}/${b}/0`);
 | |
|                             }
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         function rgbToHex(r, g, b, w) {
 | |
|             let hex = ((((r << 8) + g) << 8) + b).toString(16);
 | |
|             while (hex.length < 6)
 | |
|                 hex = "0" + hex;
 | |
|             return "#" + hex;
 | |
|         }
 | |
| 
 | |
|         function hexToRGB(hex) {
 | |
|             const dec = parseInt(hex.substring(1), 16);
 | |
|             const b = dec & 0xff;
 | |
|             const g = (dec >> 8) & 0xff;
 | |
|             const r = (dec >> 16) & 0xff;
 | |
|             return [r, g, b];
 | |
|         }
 | |
| 
 | |
|         async function loadFrame() {
 | |
|             let data = await (await fetch("/frame")).json();
 | |
|             setFrame(data);
 | |
|         }
 | |
| 
 | |
|         function setFrame(data) {
 | |
|             for (const pixel of document.getElementsByClassName("pixel")) {
 | |
|                 const row = parseInt(pixel.dataset.row, 10);
 | |
|                 const col = parseInt(pixel.dataset.col, 10);
 | |
|                 const data_base = (row * GRID_COLS + col) * data.channels;
 | |
|                 let color = rgbToHex(
 | |
|                     data.data[data_base],
 | |
|                     data.data[data_base + 1],
 | |
|                     data.data[data_base + 2],
 | |
|                     data.data[data_base + 3]);
 | |
| 
 | |
|                 pixelData[row][col] = color;
 | |
|                 pixel.style.backgroundColor = color;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // --- Event Handlers ---
 | |
| 
 | |
|         function handlePaint(event) {
 | |
|             const targetPixel = event.target.closest('.pixel');
 | |
|             if (targetPixel) {
 | |
|                 const row = parseInt(targetPixel.dataset.row, 10);
 | |
|                 const col = parseInt(targetPixel.dataset.col, 10);
 | |
|                 applyBrush(row, col);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         function handleTouchPaint(touch) {
 | |
|             const targetPixel = document.elementFromPoint(touch.clientX, touch.clientY);
 | |
|             if (targetPixel && targetPixel.classList.contains('pixel')) {
 | |
|                 const row = parseInt(targetPixel.dataset.row, 10);
 | |
|                 const col = parseInt(targetPixel.dataset.col, 10);
 | |
|                 applyBrush(row, col);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // --- Event Listeners ---
 | |
| 
 | |
|         colorPicker.addEventListener('input', (e) => setActiveColor(e.target.value));
 | |
| 
 | |
|         paletteContainer.addEventListener('click', (e) => {
 | |
|             const swatch = e.target.closest('.color-swatch');
 | |
|             if (swatch) setActiveColor(swatch.dataset.color);
 | |
|         });
 | |
| 
 | |
|         gridContainer.addEventListener('mousedown', (e) => {
 | |
|             e.preventDefault();
 | |
|             isPainting = true;
 | |
|             handlePaint(e);
 | |
|         });
 | |
| 
 | |
|         gridContainer.addEventListener('touchstart', (e) => {
 | |
|             e.preventDefault();
 | |
|             isPainting = true;
 | |
|             if (e.touches.length > 0) handleTouchPaint(e.touches[0]);
 | |
|         });
 | |
| 
 | |
|         document.addEventListener('mouseup', () => isPainting = false);
 | |
|         document.addEventListener('touchend', () => isPainting = false);
 | |
|         document.addEventListener('touchcancel', () => isPainting = false);
 | |
| 
 | |
|         gridContainer.addEventListener('mousemove', (e) => {
 | |
|             if (isPainting) handlePaint(e);
 | |
|         });
 | |
| 
 | |
|         gridContainer.addEventListener('touchmove', (e) => {
 | |
|             if (isPainting) {
 | |
|                 e.preventDefault(); // Prevent scrolling while painting
 | |
|                 if (e.touches.length > 0) handleTouchPaint(e.touches[0]);
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         clearCanvasBtn.addEventListener('click', () => {
 | |
|             initializeGrid();
 | |
|             ws.send(JSON.stringify({
 | |
|                 ty: "fill",
 | |
|                 r: 0, g: 0, b: 0,
 | |
|             }))
 | |
|         });
 | |
| 
 | |
|         // --- Initial Load ---
 | |
|         initializePalette();
 | |
|         initializeGrid();
 | |
|         setActiveColor(colorPicker.value);
 | |
|         // loadFrame();
 | |
|         // setInterval(loadFrame, 500);
 | |
| 
 | |
| 
 | |
|         let ws = ({
 | |
|             connect() {
 | |
|                 if (this.ws)
 | |
|                     this.ws.close()
 | |
| 
 | |
|                 this.ws = new WebSocket("/frame_ws");
 | |
|                 this.ws.onopen = () => {
 | |
|                     clearInterval(this.frame_interval);
 | |
|                     this.frame_interval = setInterval(() => {
 | |
|                         console.log(this);
 | |
|                         this.ws.send(JSON.stringify({ty: "frame", time: this.frame_time, x: 2}))
 | |
|                     }, 100);
 | |
|                 };
 | |
|                 this.ws.onclose = () => {
 | |
|                     clearInterval(this.frame_interval);
 | |
|                     this.ws = undefined;
 | |
|                 }
 | |
|                 this.ws.onmessage = this.onmessage.bind(this);
 | |
|             },
 | |
|             onmessage(msg) {
 | |
|                 const data = JSON.parse(msg.data)
 | |
|                 switch (data.ty) {
 | |
|                     case "frame":
 | |
|                         this.frame_time = data.time;
 | |
|                         console.log(this);
 | |
|                         setFrame(data);
 | |
|                         break;
 | |
|                 }
 | |
|             },
 | |
|             send(data) {
 | |
|                 if (!this.ws)
 | |
|                     this.connect();
 | |
|                 this.ws.send(data);
 | |
|             },
 | |
|         });
 | |
|         ws.connect();
 | |
| 
 | |
|     });
 | |
| </script>
 | |
| </body>
 | |
| </html>
 | 
