feat: batch drawing with paint/draw modes, undo/redo, and submit

Replace per-pixel immediate placement with local buffer system:
- Paint mode (click) and Draw mode (drag) for pixel placement
- Undo/Redo strokes (Ctrl+Z/Y) before submitting
- Submit button sends batch to server for storage + broadcast
- Right-click drag to pan in draw mode
- Increase MAX_BATCH_SIZE to 512 for larger batches
This commit is contained in:
tiennm99 committed 2026-04-16 21:22:19 +07:00
1 parent eef6879ff2
commit e0cf802ec2
5 files changed
+379 -91

No files matched your search

+58 -3
View File
@@ -3,17 +3,21 @@
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
import DrawToolbar from './components/DrawToolbar.svelte';
import UserInfo from './components/UserInfo.svelte';
let selectedColor = $state(27); // black
let credits = $state(MAX_CREDITS);
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
let mode = $state('paint');
let submitting = $state(false);
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
/** @type {CanvasRenderer} */
let canvasRenderer;
// Client-side credit regeneration (server corrects on placement)
// Client-side credit regeneration (server corrects on submit)
$effect(() => {
const interval = setInterval(() => {
if (credits < MAX_CREDITS) {
@@ -53,17 +57,56 @@
const ws = connectWebSocket();
return () => ws.close();
});
// Keyboard shortcuts
function handleKeyDown(e) {
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
canvasRenderer?.undo();
} else if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
e.preventDefault();
canvasRenderer?.redo();
}
}
// Submit all pending pixels to server
async function handleSubmit() {
const pixels = canvasRenderer?.getPendingPixels();
if (!pixels?.length) return;
submitting = true;
try {
const res = await fetch('/api/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pixels }),
});
const data = await res.json();
if (data.ok) {
credits = data.credits;
canvasRenderer.commitPending();
} else {
console.warn('Submit rejected:', data.error, data);
}
} catch (err) {
console.error('Submit failed:', err);
} finally {
submitting = false;
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<main>
<CanvasRenderer
bind:this={canvasRenderer}
{selectedColor}
{credits}
onCreditsChange={(c) => credits = c}
{zoom}
{mode}
onZoomChange={(z) => zoom = z}
onCursorMove={(pos) => cursorPos = pos}
onBufferChange={(s) => bufferState = s}
/>
<CanvasControls
{zoom}
@@ -72,6 +115,18 @@
onResetZoom={() => zoom = 1}
{cursorPos}
/>
<DrawToolbar
{mode}
onModeChange={(m) => mode = m}
onSubmit={handleSubmit}
onUndo={() => canvasRenderer?.undo()}
onRedo={() => canvasRenderer?.redo()}
onClear={() => canvasRenderer?.clearPending()}
canUndo={bufferState.canUndo}
canRedo={bufferState.canRedo}
pixelCount={bufferState.pixelCount}
{submitting}
/>
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
<UserInfo {credits} />
</main>
+161 -87
View File
@@ -2,25 +2,32 @@
import { onMount } from 'svelte';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '../../lib/constants.js';
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
let { selectedColor, credits, onCreditsChange, zoom, onZoomChange, onCursorMove } = $props();
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange } = $props();
let canvasEl;
let imageData = null;
/** Committed color index per pixel (server-confirmed state) */
let committedColors = null;
let pan = { x: 0, y: 0 };
let dragging = $state(false);
let lastMouse = { x: 0, y: 0 };
let loading = $state(true);
// Active stroke being drawn (not yet in buffer)
let currentStroke = [];
let currentStrokeKeys = new Set();
const buffer = createPixelBuffer();
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
// Touch state
let lastTouchDist = 0;
let touchStartTime = 0;
let touchMoved = false;
// Cached offscreen canvas
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
function render() {
if (!canvasEl || !imageData) return;
const ctx = canvasEl.getContext('2d');
@@ -42,56 +49,109 @@
};
}
function updatePixel(x, y, colorIndex) {
function setPixelRgba(x, y, colorIndex) {
if (!imageData) return;
const color = COLORS_RGBA[colorIndex];
const offset = (y * CANVAS_WIDTH + x) * 4;
imageData.data[offset] = color[0];
imageData.data[offset + 1] = color[1];
imageData.data[offset + 2] = color[2];
imageData.data[offset + 3] = color[3];
const rgba = COLORS_RGBA[colorIndex];
const off = (y * CANVAS_WIDTH + x) * 4;
imageData.data[off] = rgba[0];
imageData.data[off + 1] = rgba[1];
imageData.data[off + 2] = rgba[2];
imageData.data[off + 3] = rgba[3];
}
async function placePixel(x, y) {
function notifyBuffer() {
onBufferChange({
canUndo: buffer.canUndo, canRedo: buffer.canRedo, pixelCount: buffer.pixelCount,
});
}
/** Restore pixel to committed color, or re-apply pending if another stroke covers it */
function restorePixel(x, y) {
const pending = buffer.getColorAt(x, y);
setPixelRgba(x, y, pending >= 0 ? pending : committedColors[y * CANVAS_WIDTH + x]);
}
function addToStroke(x, y) {
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
if (credits <= 0) return;
// Optimistic deduction + render
onCreditsChange(credits - 1);
updatePixel(x, y, selectedColor);
const key = y * 65536 + x;
if (currentStrokeKeys.has(key)) return;
currentStrokeKeys.add(key);
currentStroke.push({ x, y, color: selectedColor });
setPixelRgba(x, y, selectedColor);
render();
try {
const res = await fetch('/api/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
});
const data = await res.json();
if (data.ok) {
onCreditsChange(data.credits);
} else {
// Server rejected — log reason (pixel stays rendered, WS will correct)
console.warn('Place rejected:', data.error);
}
} catch (err) {
console.error('Failed to place pixel:', err);
}
}
function finishStroke() {
if (!currentStroke.length) return;
buffer.addStroke(currentStroke);
currentStroke = [];
currentStrokeKeys = new Set();
notifyBuffer();
}
// --- Public API (called by App.svelte) ---
export function applyUpdates(pixels) {
for (const { x, y, color } of pixels) {
updatePixel(x, y, color);
committedColors[y * CANVAS_WIDTH + x] = color;
// Only update display if no pending or active stroke covers this pixel
if (buffer.getColorAt(x, y) < 0 && !currentStrokeKeys.has(y * 65536 + x)) {
setPixelRgba(x, y, color);
}
}
render();
}
export function undo() {
const stroke = buffer.undo();
if (!stroke) return;
for (const { x, y } of stroke) restorePixel(x, y);
render();
notifyBuffer();
}
export function redo() {
const stroke = buffer.redo();
if (!stroke) return;
for (const { x, y, color } of stroke) setPixelRgba(x, y, color);
render();
notifyBuffer();
}
export function clearPending() {
const keys = buffer.getAffectedKeys();
buffer.clear();
for (const key of keys) {
const x = key % 65536;
const y = Math.floor(key / 65536);
setPixelRgba(x, y, committedColors[y * CANVAS_WIDTH + x]);
}
render();
notifyBuffer();
}
export function getPendingPixels() { return buffer.getAllPixels(); }
export function commitPending() {
for (const { x, y, color } of buffer.getAllPixels()) {
committedColors[y * CANVAS_WIDTH + x] = color;
}
buffer.clear();
notifyBuffer();
}
// --- Mouse handlers ---
function handleMouseDown(e) {
if (e.button === 0) {
dragging = false;
lastMouse = { x: e.clientX, y: e.clientY };
if (mode === 'draw') {
const pos = screenToCanvas(e.clientX, e.clientY);
addToStroke(pos.x, pos.y);
}
} else if (e.button === 2) {
lastMouse = { x: e.clientX, y: e.clientY };
}
}
@@ -102,49 +162,62 @@
y: Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1)),
});
if (e.buttons === 1) {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragging = true;
pan.x += dx;
pan.y += dy;
if (e.buttons & 1) {
if (mode === 'draw') {
addToStroke(pos.x, pos.y);
} else {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragging = true;
pan.x += dx;
pan.y += dy;
lastMouse = { x: e.clientX, y: e.clientY };
render();
}
}
// Right button — pan in any mode
if (e.buttons & 2) {
pan.x += e.clientX - lastMouse.x;
pan.y += e.clientY - lastMouse.y;
lastMouse = { x: e.clientX, y: e.clientY };
render();
}
}
function handleMouseUp(e) {
if (e.button === 0 && !dragging) {
const pos = screenToCanvas(e.clientX, e.clientY);
placePixel(pos.x, pos.y);
if (e.button === 0) {
if (mode === 'draw') {
finishStroke();
} else if (!dragging) {
const pos = screenToCanvas(e.clientX, e.clientY);
addToStroke(pos.x, pos.y);
finishStroke();
}
}
dragging = false;
}
function handleContextMenu(e) { e.preventDefault(); }
function handleWheel(e) {
e.preventDefault();
const factor = e.deltaY < 0 ? 2 : 0.5;
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
const cx = e.clientX;
const cy = e.clientY;
pan.x = cx - (cx - pan.x) * (newZoom / zoom);
pan.y = cy - (cy - pan.y) * (newZoom / zoom);
pan.x = e.clientX - (e.clientX - pan.x) * (newZoom / zoom);
pan.y = e.clientY - (e.clientY - pan.y) * (newZoom / zoom);
onZoomChange(newZoom);
}
// --- Touch handlers (pinch-zoom, drag-pan, long-press to place) ---
// --- Touch handlers ---
function getTouchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
function getTouchDist(t) {
const dx = t[0].clientX - t[1].clientX;
const dy = t[0].clientY - t[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getTouchCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
function getTouchCenter(t) {
return { x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 };
}
function handleTouchStart(e) {
@@ -153,7 +226,12 @@
touchStartTime = Date.now();
touchMoved = false;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (mode === 'draw') {
const pos = screenToCanvas(e.touches[0].clientX, e.touches[0].clientY);
addToStroke(pos.x, pos.y);
}
} else if (e.touches.length === 2) {
if (currentStroke.length) finishStroke();
lastTouchDist = getTouchDist(e.touches);
lastMouse = getTouchCenter(e.touches);
}
@@ -165,25 +243,27 @@
const dx = e.touches[0].clientX - lastMouse.x;
const dy = e.touches[0].clientY - lastMouse.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) touchMoved = true;
pan.x += dx;
pan.y += dy;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
render();
if (mode === 'draw') {
const pos = screenToCanvas(e.touches[0].clientX, e.touches[0].clientY);
addToStroke(pos.x, pos.y);
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else {
pan.x += dx;
pan.y += dy;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
render();
}
} else if (e.touches.length === 2) {
touchMoved = true;
const dist = getTouchDist(e.touches);
const center = getTouchCenter(e.touches);
const scale = dist / lastTouchDist;
const newZoom = Math.max(0.25, Math.min(64, zoom * scale));
// Zoom toward pinch center
pan.x = center.x - (center.x - pan.x) * (newZoom / zoom);
pan.y = center.y - (center.y - pan.y) * (newZoom / zoom);
// Pan with pinch movement
pan.x += center.x - lastMouse.x;
pan.y += center.y - lastMouse.y;
lastTouchDist = dist;
lastMouse = center;
onZoomChange(newZoom);
@@ -191,14 +271,13 @@
}
function handleTouchEnd(e) {
// Long-press to place pixel (>300ms, no movement, single touch)
if (!touchMoved && e.changedTouches.length === 1) {
const elapsed = Date.now() - touchStartTime;
if (elapsed > 300) {
const t = e.changedTouches[0];
const pos = screenToCanvas(t.clientX, t.clientY);
placePixel(pos.x, pos.y);
}
if (mode === 'draw') {
finishStroke();
} else if (!touchMoved && e.changedTouches.length === 1 && Date.now() - touchStartTime > 300) {
const t = e.changedTouches[0];
const pos = screenToCanvas(t.clientX, t.clientY);
addToStroke(pos.x, pos.y);
finishStroke();
}
}
@@ -216,8 +295,9 @@
try {
const res = await fetch('/api/canvas');
const buffer = await res.arrayBuffer();
const indices = decodeCanvas(buffer);
const buf = await res.arrayBuffer();
const indices = decodeCanvas(buf);
committedColors = new Uint8Array(indices);
const rgba = indicesToRgba(indices);
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
render();
@@ -241,23 +321,17 @@
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
style="cursor: {dragging ? 'grabbing' : 'crosshair'}; touch-action: none"
style="cursor: {mode === 'draw' ? 'crosshair' : dragging ? 'grabbing' : 'crosshair'}; touch-action: none"
></canvas>
</div>
<style>
.canvas-container {
width: 100%;
height: 100%;
}
canvas {
display: block;
}
.canvas-container { width: 100%; height: 100%; }
canvas { display: block; }
.loading {
position: absolute;
top: 50%;
+80
View File
@@ -0,0 +1,80 @@
<script>
let { mode, onModeChange, onSubmit, onUndo, onRedo, onClear,
canUndo, canRedo, pixelCount, submitting } = $props();
</script>
<div class="toolbar">
<div class="group">
<button class:active={mode === 'paint'} onclick={() => onModeChange('paint')}
title="Paint mode: click to place pixel">Paint</button>
<button class:active={mode === 'draw'} onclick={() => onModeChange('draw')}
title="Draw mode: drag to draw, right-drag to pan">Draw</button>
</div>
<div class="group">
<button onclick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">Undo</button>
<button onclick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Y)">Redo</button>
<button class="clear-btn" onclick={onClear} disabled={pixelCount === 0}
title="Clear all pending pixels">Clear</button>
</div>
<button class="submit-btn" onclick={onSubmit} disabled={pixelCount === 0 || submitting}>
{submitting ? 'Sending...' : `Submit${pixelCount > 0 ? ` (${pixelCount})` : ''}`}
</button>
</div>
<style>
.toolbar {
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
}
.group {
display: flex;
gap: 4px;
}
button {
padding: 6px 12px;
border: 1px solid #555;
border-radius: 4px;
background: #333;
color: #ccc;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
button:hover:not(:disabled) { background: #555; }
button:disabled { opacity: 0.4; cursor: default; }
button.active {
background: #4a6;
border-color: #4a6;
color: #fff;
}
.clear-btn:hover:not(:disabled) {
background: #a33;
border-color: #a33;
}
.submit-btn {
background: #2563eb;
border-color: #2563eb;
color: #fff;
font-weight: bold;
}
.submit-btn:hover:not(:disabled) { background: #1d4ed8; }
</style>
+1 -1
View File
@@ -8,7 +8,7 @@ export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const MAX_BATCH_SIZE = 32;
export const MAX_BATCH_SIZE = 512;
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;
+79
View File
@@ -0,0 +1,79 @@
/**
* Manages pending pixel strokes with undo/redo support.
* Pixels accumulate locally until explicit submit.
*/
export function createPixelBuffer() {
let strokes = [];
let undone = [];
return {
/** Add a completed stroke (array of {x, y, color}) */
addStroke(pixels) {
if (!pixels.length) return;
strokes.push([...pixels]);
undone = [];
},
/** Remove last stroke and push to redo stack */
undo() {
if (!strokes.length) return null;
const stroke = strokes.pop();
undone.push(stroke);
return stroke;
},
/** Re-apply last undone stroke */
redo() {
if (!undone.length) return null;
const stroke = undone.pop();
strokes.push(stroke);
return stroke;
},
/** Clear all pending strokes and redo history */
clear() {
strokes = [];
undone = [];
},
/** Deduplicated pending pixels (last stroke wins per coord) */
getAllPixels() {
const map = new Map();
for (const stroke of strokes) {
for (const { x, y, color } of stroke) {
map.set(y * 65536 + x, { x, y, color });
}
}
return [...map.values()];
},
/** Get pending color at (x,y), or -1 if not pending */
getColorAt(x, y) {
for (let i = strokes.length - 1; i >= 0; i--) {
for (const p of strokes[i]) {
if (p.x === x && p.y === y) return p.color;
}
}
return -1;
},
/** Set of all affected coordinate keys (y*65536+x) */
getAffectedKeys() {
const set = new Set();
for (const stroke of strokes) {
for (const { x, y } of stroke) set.add(y * 65536 + x);
}
return set;
},
get canUndo() { return strokes.length > 0; },
get canRedo() { return undone.length > 0; },
get pixelCount() {
const set = new Set();
for (const stroke of strokes) {
for (const { x, y } of stroke) set.add(y * 65536 + x);
}
return set.size;
},
};
}