diff --git a/src/client/App.svelte b/src/client/App.svelte
index 4bc19b7..d8b9464 100644
--- a/src/client/App.svelte
+++ b/src/client/App.svelte
@@ -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;
+ }
+ }
+
+
credits = c}
{zoom}
+ {mode}
onZoomChange={(z) => zoom = z}
onCursorMove={(pos) => cursorPos = pos}
+ onBufferChange={(s) => bufferState = s}
/>
zoom = 1}
{cursorPos}
/>
+ mode = m}
+ onSubmit={handleSubmit}
+ onUndo={() => canvasRenderer?.undo()}
+ onRedo={() => canvasRenderer?.redo()}
+ onClear={() => canvasRenderer?.clearPending()}
+ canUndo={bufferState.canUndo}
+ canRedo={bufferState.canRedo}
+ pixelCount={bufferState.pixelCount}
+ {submitting}
+ />
selectedColor = i} />
diff --git a/src/client/components/CanvasRenderer.svelte b/src/client/components/CanvasRenderer.svelte
index 82e2046..78fddec 100644
--- a/src/client/components/CanvasRenderer.svelte
+++ b/src/client/components/CanvasRenderer.svelte
@@ -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"
>
diff --git a/src/lib/constants.js b/src/lib/constants.js
index 7bf446b..2d66ff1 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -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;
diff --git a/src/lib/pixel-buffer.js b/src/lib/pixel-buffer.js
new file mode 100644
index 0000000..2d1eb2b
--- /dev/null
+++ b/src/lib/pixel-buffer.js
@@ -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;
+ },
+ };
+}