From e0cf802ec28235dc05442c8fbc23a90ba6bce51a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 16 Apr 2026 21:22:19 +0700 Subject: [PATCH] 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 --- src/client/App.svelte | 61 ++++- src/client/components/CanvasRenderer.svelte | 248 +++++++++++++------- src/client/components/DrawToolbar.svelte | 80 +++++++ src/lib/constants.js | 2 +- src/lib/pixel-buffer.js | 79 +++++++ 5 files changed, 379 insertions(+), 91 deletions(-) create mode 100644 src/client/components/DrawToolbar.svelte create mode 100644 src/lib/pixel-buffer.js 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; + }, + }; +}