Live Painting Mode

This commit is contained in:
T
2025-07-23 09:36:20 +02:00
parent 5b2f7e4b5e
commit 6eb6cec170
4 changed files with 584 additions and 9 deletions

465
html/draw.html Normal file
View File

@@ -0,0 +1,465 @@
<!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>