Add brush cursor preview, keyboard shortcuts, and command system

This commit is contained in:
Jared Miller 2026-02-07 21:10:24 -05:00
parent ce49530ff4
commit 3517f61219
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -82,11 +82,23 @@
border-width: 4px;
}
#canvas {
#canvas-wrapper {
position: relative;
display: inline-block;
}
#canvas, #overlay-canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
#overlay-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.actions button {
display: block;
width: 100%;
@ -275,7 +287,10 @@
</div>
<div id="main">
<canvas id="canvas"></canvas>
<div id="canvas-wrapper">
<canvas id="canvas"></canvas>
<canvas id="overlay-canvas"></canvas>
</div>
</div>
<script>
@ -306,9 +321,12 @@
let lastPaintedCell = null;
let brushSize = 1;
let brushShape = 'circle';
let lastHoveredCell = { x: -1, y: -1 }; // for cursor overlay
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const overlayCanvas = document.getElementById('overlay-canvas');
const overlayCtx = overlayCanvas.getContext('2d');
const widthInput = document.getElementById('width');
const heightInput = document.getElementById('height');
const m1Slot = document.getElementById('m1');
@ -355,6 +373,9 @@
canvas.width = gridWidth;
canvas.height = gridHeight;
}
// Sync overlay canvas size
overlayCanvas.width = canvas.width;
overlayCanvas.height = canvas.height;
fitCanvas();
}
@ -375,6 +396,9 @@
}
canvas.style.width = Math.floor(cssW) + 'px';
canvas.style.height = Math.floor(cssH) + 'px';
// Sync overlay canvas CSS size
overlayCanvas.style.width = Math.floor(cssW) + 'px';
overlayCanvas.style.height = Math.floor(cssH) + 'px';
}
window.addEventListener('resize', fitCanvas);
@ -441,6 +465,34 @@
renderCanvas();
}
function getCellsInBrush(cx, cy) {
const cells = [];
if (brushSize === 1) {
cells.push({ x: cx, y: cy });
return cells;
}
for (let dy = -(brushSize - 1); dy < brushSize; dy++) {
for (let dx = -(brushSize - 1); dx < brushSize; dx++) {
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue;
if (brushShape === 'circle') {
if (dx * dx + dy * dy < brushSize * brushSize) {
cells.push({ x: nx, y: ny });
}
} else { // square
if (Math.abs(dx) < brushSize && Math.abs(dy) < brushSize) {
cells.push({ x: nx, y: ny });
}
}
}
}
return cells;
}
function paintBrush(x, y, symbol) {
if (brushSize === 1) {
paintCell(x, y, symbol);
@ -451,23 +503,9 @@
const oldLastPainted = lastPaintedCell;
lastPaintedCell = null;
for (let dy = -(brushSize - 1); dy < brushSize; dy++) {
for (let dx = -(brushSize - 1); dx < brushSize; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue;
if (brushShape === 'circle') {
if (dx * dx + dy * dy < brushSize * brushSize) {
grid[ny][nx] = symbol;
}
} else { // square
if (Math.abs(dx) < brushSize && Math.abs(dy) < brushSize) {
grid[ny][nx] = symbol;
}
}
}
const cells = getCellsInBrush(x, y);
for (const cell of cells) {
grid[cell.y][cell.x] = symbol;
}
lastPaintedCell = `${x},${y}`;
@ -823,6 +861,7 @@ ${paletteStr}"""
symbols.m1 = entry.char;
m1Slot.textContent = entry.char;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
@ -833,6 +872,7 @@ ${paletteStr}"""
symbols.m2 = entry.char;
m2Slot.textContent = entry.char;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
@ -881,6 +921,36 @@ ${paletteStr}"""
document.getElementById('palette-form').style.display = 'none';
}
function renderCursorOverlay(cellX, cellY) {
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
if (cellX < 0 || cellY < 0) return;
const cells = getCellsInBrush(cellX, cellY);
let fillColor;
if (currentTool === 'paintbrush') {
const activeChar = symbols[activeSlot];
const entry = palette.find(e => e.char === activeChar);
const rgb = entry ? hexToRgb(entry.color) : [255, 255, 255];
fillColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.35)`;
} else {
fillColor = 'rgba(220, 50, 50, 0.35)';
}
overlayCtx.fillStyle = fillColor;
if (viewMode === 'text') {
for (const cell of cells) {
overlayCtx.fillRect(cell.x * CELL_SIZE, cell.y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
} else {
for (const cell of cells) {
overlayCtx.fillRect(cell.x, cell.y, 1, 1);
}
}
}
function savePaletteEntry() {
const form = document.getElementById('palette-form');
const char = document.getElementById('palette-char').value;
@ -951,11 +1021,24 @@ ${paletteStr}"""
});
canvas.addEventListener('mousemove', (e) => {
const { x, y } = getGridCoords(e);
lastHoveredCell = { x, y };
renderCursorOverlay(x, y);
if (isDrawing) {
handlePaint(e, drawButton);
}
});
canvas.addEventListener('mouseenter', () => {
overlayCanvas.style.display = 'block';
});
canvas.addEventListener('mouseleave', () => {
overlayCanvas.style.display = 'none';
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
});
// Listen on document so releasing outside canvas still stops drawing
document.addEventListener('mouseup', () => {
if (isDrawing) {
@ -980,6 +1063,7 @@ ${paletteStr}"""
document.querySelectorAll('input[name="tool"]').forEach(radio => {
radio.addEventListener('change', (e) => {
currentTool = e.target.value;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
});
});
@ -987,18 +1071,21 @@ ${paletteStr}"""
document.getElementById('brushSize').addEventListener('input', (e) => {
brushSize = parseInt(e.target.value);
document.getElementById('brushSizeValue').textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
document.getElementById('brushCircle').addEventListener('click', () => {
brushShape = 'circle';
updateBrushUI();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
document.getElementById('brushSquare').addEventListener('click', () => {
brushShape = 'square';
updateBrushUI();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
@ -1006,16 +1093,72 @@ ${paletteStr}"""
m1Slot.addEventListener('click', () => {
activeSlot = 'm1';
updateActiveSlot();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
m2Slot.addEventListener('click', () => {
activeSlot = 'm2';
updateActiveSlot();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
// Keyboard input for symbols
// commands - add new commands here
const commands = {
'brush-size-up': () => {
if (brushSize < 8) {
brushSize++;
const slider = document.getElementById('brushSize');
const valueDisplay = document.getElementById('brushSizeValue');
slider.value = brushSize;
valueDisplay.textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
},
'brush-size-down': () => {
if (brushSize > 1) {
brushSize--;
const slider = document.getElementById('brushSize');
const valueDisplay = document.getElementById('brushSizeValue');
slider.value = brushSize;
valueDisplay.textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
},
'swap-symbols': () => {
const temp = symbols.m1;
symbols.m1 = symbols.m2;
symbols.m2 = temp;
m1Slot.textContent = symbols.m1;
m2Slot.textContent = symbols.m2;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
},
};
// keybinding map - key string to command name
const keybindings = {
']': 'brush-size-up',
'[': 'brush-size-down',
'x': 'swap-symbols',
};
// Keyboard event handlers
document.addEventListener('keydown', (e) => {
const tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const command = keybindings[e.key];
if (command && commands[command]) {
e.preventDefault();
commands[command]();
}
});
document.addEventListener('keypress', (e) => {
if (e.target.tagName === 'INPUT') return;
@ -1028,6 +1171,7 @@ ${paletteStr}"""
m2Slot.textContent = char;
}
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
});