mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
eef6879ff2
commit
e0cf802ec2
5 files changed
+379
-91
No files matched your search
+58
-3
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user