Add UI improvements + refactor frontend to event-driven architecture
Some checks failed
CI / ci (push) Failing after 1m9s
Some checks failed
CI / ci (push) Failing after 1m9s
Features: disabled place button during cooldown, beep on cooldown end, hand/paint mode toggle with Space shortcut, soldier count pop animation. Architecture: centralized DOM refs, AppState store replacing localStorage polling, event bus decoupling socket from handlers, keyboard manager, tool manager with Strategy pattern (paint-tool, hand-tool).
This commit is contained in:
@@ -85,6 +85,21 @@
|
|||||||
</p>
|
</p>
|
||||||
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div>
|
<div id="countdown" class="z-20 text-xs">You can place a pixel now</div>
|
||||||
<div class="z-20 flex items-center gap-2 mx-1">
|
<div class="z-20 flex items-center gap-2 mx-1">
|
||||||
|
<button
|
||||||
|
id="mode-paint"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded shadow bg-slate-200 hover:bg-slate-300 ring-2 ring-offset-2 ring-cyan-400"
|
||||||
|
title="Paint mode"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="8" y1="1" x2="8" y2="6"/><line x1="8" y1="10" x2="8" y2="15"/><line x1="1" y1="8" x2="6" y2="8"/><line x1="10" y1="8" x2="15" y2="8"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mode-hand"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded shadow bg-slate-200 hover:bg-slate-300"
|
||||||
|
title="Move mode (hold Space)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="8" y1="1" x2="8" y2="15"/><line x1="1" y1="8" x2="15" y2="8"/><polyline points="5,3 8,1 11,3"/><polyline points="5,13 8,15 11,13"/><polyline points="3,5 1,8 3,11"/><polyline points="13,5 15,8 13,11"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-6 bg-slate-300"></div>
|
||||||
<button
|
<button
|
||||||
id="zoom-out"
|
id="zoom-out"
|
||||||
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
|
class="w-8 h-8 text-lg font-bold rounded shadow bg-slate-200 hover:bg-slate-300"
|
||||||
|
|||||||
20
painter-js/src/domain/app-state.js
Normal file
20
painter-js/src/domain/app-state.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const createAppState = (initial) => {
|
||||||
|
const state = { ...initial };
|
||||||
|
const listeners = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (key) => state[key],
|
||||||
|
set: (key, value) => {
|
||||||
|
const old = state[key];
|
||||||
|
state[key] = value;
|
||||||
|
(listeners[key] || []).forEach((fn) => fn(value, old));
|
||||||
|
},
|
||||||
|
subscribe: (key, fn) => {
|
||||||
|
if (!listeners[key]) listeners[key] = [];
|
||||||
|
listeners[key].push(fn);
|
||||||
|
return () => {
|
||||||
|
listeners[key] = listeners[key].filter((f) => f !== fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
17
painter-js/src/infrastructure/event-bus.js
Normal file
17
painter-js/src/infrastructure/event-bus.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const createEventBus = () => {
|
||||||
|
const handlers = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: (event, fn) => {
|
||||||
|
if (!handlers[event]) handlers[event] = [];
|
||||||
|
handlers[event].push(fn);
|
||||||
|
},
|
||||||
|
off: (event, fn) => {
|
||||||
|
if (!handlers[event]) return;
|
||||||
|
handlers[event] = handlers[event].filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
emit: (event, ...args) => {
|
||||||
|
(handlers[event] || []).forEach((fn) => fn(...args));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
51
painter-js/src/infrastructure/keyboard.js
Normal file
51
painter-js/src/infrastructure/keyboard.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export const createKeyboard = () => {
|
||||||
|
const actions = {};
|
||||||
|
const handlers = {};
|
||||||
|
const heldKeys = new Set();
|
||||||
|
|
||||||
|
const dispatch = (actionName) => {
|
||||||
|
(handlers[actionName] || []).forEach((fn) => fn());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
const action = actions[event.key];
|
||||||
|
if (!action) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (action.hold) {
|
||||||
|
if (!event.repeat && !heldKeys.has(event.key)) {
|
||||||
|
heldKeys.add(event.key);
|
||||||
|
dispatch(`${action.name}:down`);
|
||||||
|
}
|
||||||
|
} else if (!event.repeat) {
|
||||||
|
dispatch(action.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("keyup", (event) => {
|
||||||
|
const action = actions[event.key];
|
||||||
|
if (!action) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (action.hold && heldKeys.has(event.key)) {
|
||||||
|
heldKeys.delete(event.key);
|
||||||
|
dispatch(`${action.name}:up`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("blur", () => {
|
||||||
|
for (const key of heldKeys) {
|
||||||
|
const action = actions[key];
|
||||||
|
if (action?.hold) dispatch(`${action.name}:up`);
|
||||||
|
}
|
||||||
|
heldKeys.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
register: (key, name, opts = {}) => {
|
||||||
|
actions[key] = { name, ...opts };
|
||||||
|
},
|
||||||
|
onAction: (actionName, fn) => {
|
||||||
|
if (!handlers[actionName]) handlers[actionName] = [];
|
||||||
|
handlers[actionName].push(fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,35 +1,118 @@
|
|||||||
import { createSocketConnection } from "./infrastructure/socket-client.js";
|
import { createSocketConnection } from "./infrastructure/socket-client.js";
|
||||||
import { checkServer } from "./infrastructure/api.js";
|
import { checkServer } from "./infrastructure/api.js";
|
||||||
|
import { createEventBus } from "./infrastructure/event-bus.js";
|
||||||
|
import { createKeyboard } from "./infrastructure/keyboard.js";
|
||||||
|
import { createAppState } from "./domain/app-state.js";
|
||||||
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
|
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
|
||||||
import { createColorPalette } from "./ui/color-palette.js";
|
import { createColorPalette } from "./ui/color-palette.js";
|
||||||
import { createPixelPlacer } from "./ui/pixel-placer.js";
|
|
||||||
import { startCooldownDisplay } from "./ui/cooldown-display.js";
|
import { startCooldownDisplay } from "./ui/cooldown-display.js";
|
||||||
import { createCanvasViewport } from "./ui/canvas-viewport.js";
|
import { createCanvasViewport } from "./ui/canvas-viewport.js";
|
||||||
|
import { createToolManager } from "./ui/tool-manager.js";
|
||||||
|
import { createPaintTool } from "./tools/paint-tool.js";
|
||||||
|
import { createHandTool } from "./tools/hand-tool.js";
|
||||||
import { setPixel } from "./domain/canvas-state.js";
|
import { setPixel } from "./domain/canvas-state.js";
|
||||||
import { getCanvasCoords } from "./domain/coords.js";
|
import { getCanvasCoords } from "./domain/coords.js";
|
||||||
|
|
||||||
const canvasEl = document.getElementById("canvas");
|
const els = {
|
||||||
const coordsText = document.getElementById("coords");
|
canvas: document.getElementById("canvas"),
|
||||||
const currentSoldiersSpan = document.getElementById("current-soldiers");
|
viewport: document.getElementById("canvas-viewport"),
|
||||||
const statusEl = document.getElementById("connection-status");
|
status: document.getElementById("connection-status"),
|
||||||
|
countdown: document.getElementById("countdown"),
|
||||||
|
placeBtn: document.getElementById("place-pixel"),
|
||||||
|
soldiers: document.getElementById("current-soldiers"),
|
||||||
|
coords: document.getElementById("coords"),
|
||||||
|
zoomIn: document.getElementById("zoom-in"),
|
||||||
|
zoomOut: document.getElementById("zoom-out"),
|
||||||
|
zoomLabel: document.getElementById("zoom-level"),
|
||||||
|
modePaint: document.getElementById("mode-paint"),
|
||||||
|
modeHand: document.getElementById("mode-hand"),
|
||||||
|
colorPicker: document.getElementById("color-picker"),
|
||||||
|
colorSpan: document.getElementById("current-color-span"),
|
||||||
|
saveCanvas: document.getElementById("save-canvas"),
|
||||||
|
};
|
||||||
|
|
||||||
const savedDisplay = canvasEl.style.display;
|
const savedDisplay = els.canvas.style.display;
|
||||||
canvasEl.style.display = "none";
|
els.canvas.style.display = "none";
|
||||||
|
|
||||||
let canvasState = [];
|
const appState = createAppState({
|
||||||
|
canvasPixels: [],
|
||||||
const renderer = createCanvasRenderer(canvasEl);
|
lastPlacementTime: parseInt(localStorage.getItem("lastPixelTime") || "0"),
|
||||||
const palette = createColorPalette();
|
selectedColor: localStorage.getItem("currentColor") || "#000000",
|
||||||
|
soldierCount: 0,
|
||||||
startCooldownDisplay();
|
|
||||||
createCanvasViewport(canvasEl);
|
|
||||||
|
|
||||||
canvasEl.addEventListener("mousemove", (event) => {
|
|
||||||
const { x, y } = getCanvasCoords(event, canvasEl);
|
|
||||||
coordsText.textContent = `${x}, ${y}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("save-canvas").addEventListener("click", () => {
|
appState.subscribe("lastPlacementTime", (val) => {
|
||||||
|
localStorage.setItem("lastPixelTime", val.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
appState.subscribe("selectedColor", (val) => {
|
||||||
|
localStorage.setItem("currentColor", val);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bus = createEventBus();
|
||||||
|
const keyboard = createKeyboard();
|
||||||
|
const renderer = createCanvasRenderer(els.canvas);
|
||||||
|
|
||||||
|
createColorPalette({
|
||||||
|
colorPicker: els.colorPicker,
|
||||||
|
colorSpan: els.colorSpan,
|
||||||
|
appState,
|
||||||
|
});
|
||||||
|
|
||||||
|
startCooldownDisplay({
|
||||||
|
countdown: els.countdown,
|
||||||
|
placeBtn: els.placeBtn,
|
||||||
|
appState,
|
||||||
|
});
|
||||||
|
|
||||||
|
createCanvasViewport({
|
||||||
|
canvas: els.canvas,
|
||||||
|
viewport: els.viewport,
|
||||||
|
zoomIn: els.zoomIn,
|
||||||
|
zoomOut: els.zoomOut,
|
||||||
|
zoomLabel: els.zoomLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateModeButtons = (name) => {
|
||||||
|
els.modePaint.classList.toggle("ring-2", name === "paint");
|
||||||
|
els.modePaint.classList.toggle("ring-offset-2", name === "paint");
|
||||||
|
els.modePaint.classList.toggle("ring-cyan-400", name === "paint");
|
||||||
|
els.modeHand.classList.toggle("ring-2", name === "hand");
|
||||||
|
els.modeHand.classList.toggle("ring-offset-2", name === "hand");
|
||||||
|
els.modeHand.classList.toggle("ring-cyan-400", name === "hand");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolManager = createToolManager({
|
||||||
|
canvas: els.canvas,
|
||||||
|
viewport: els.viewport,
|
||||||
|
onActiveChange: updateModeButtons,
|
||||||
|
});
|
||||||
|
|
||||||
|
toolManager.register(
|
||||||
|
createPaintTool({ canvas: els.canvas, renderer, appState, bus }),
|
||||||
|
);
|
||||||
|
toolManager.register(createHandTool({ viewport: els.viewport }));
|
||||||
|
toolManager.setActive("paint");
|
||||||
|
|
||||||
|
els.modePaint.addEventListener("click", () => toolManager.setActive("paint"));
|
||||||
|
els.modeHand.addEventListener("click", () => toolManager.setActive("hand"));
|
||||||
|
els.placeBtn.addEventListener("click", () => toolManager.confirm());
|
||||||
|
|
||||||
|
keyboard.register("Enter", "confirm-placement");
|
||||||
|
keyboard.register("Escape", "cancel-preview");
|
||||||
|
keyboard.register(" ", "hold-hand-mode", { hold: true });
|
||||||
|
|
||||||
|
keyboard.onAction("confirm-placement", () => toolManager.confirm());
|
||||||
|
keyboard.onAction("cancel-preview", () => toolManager.cancel());
|
||||||
|
keyboard.onAction("hold-hand-mode:down", () => toolManager.holdTool("hand"));
|
||||||
|
keyboard.onAction("hold-hand-mode:up", () => toolManager.releaseTool());
|
||||||
|
|
||||||
|
els.canvas.addEventListener("mousemove", (event) => {
|
||||||
|
const { x, y } = getCanvasCoords(event, els.canvas);
|
||||||
|
els.coords.textContent = `${x}, ${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
els.saveCanvas.addEventListener("click", () => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = renderer.toDataURL();
|
a.href = renderer.toDataURL();
|
||||||
a.download = "canvas.png";
|
a.download = "canvas.png";
|
||||||
@@ -37,50 +120,65 @@ document.getElementById("save-canvas").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showStatus = (message, isError) => {
|
const showStatus = (message, isError) => {
|
||||||
if (!statusEl) return;
|
els.status.textContent = message;
|
||||||
statusEl.textContent = message;
|
els.status.className = isError
|
||||||
statusEl.className = isError
|
|
||||||
? "text-red-500 text-sm"
|
? "text-red-500 text-sm"
|
||||||
: "text-green-500 text-sm";
|
: "text-green-500 text-sm";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
els.soldiers.addEventListener("animationend", () => {
|
||||||
|
els.soldiers.classList.remove("soldier-pop");
|
||||||
|
});
|
||||||
|
|
||||||
|
appState.subscribe("soldierCount", (count, prev) => {
|
||||||
|
els.soldiers.textContent = count;
|
||||||
|
if (count > prev && prev > 0) {
|
||||||
|
els.soldiers.classList.remove("soldier-pop");
|
||||||
|
void els.soldiers.offsetWidth;
|
||||||
|
els.soldiers.classList.add("soldier-pop");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("canvas_state", (data) => {
|
||||||
|
appState.set("canvasPixels", data);
|
||||||
|
renderer.drawState(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("pixel-updated", (update) => {
|
||||||
|
renderer.drawPixel(update.x, update.y, update.color);
|
||||||
|
setPixel(appState.get("canvasPixels"), update.x, update.y, update.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("current_soldiers", (count) => {
|
||||||
|
appState.set("soldierCount", count);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("connect", () => {
|
||||||
|
els.canvas.style.display = savedDisplay;
|
||||||
|
showStatus("Connected", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("disconnect", () => {
|
||||||
|
showStatus("Disconnected — reconnecting...", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on("error", (message) => showStatus(message, true));
|
||||||
|
|
||||||
checkServer()
|
checkServer()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error("Server unavailable");
|
if (!response.ok) throw new Error("Server unavailable");
|
||||||
|
|
||||||
const socket = createSocketConnection();
|
const socket = createSocketConnection();
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => bus.emit("connect"));
|
||||||
canvasEl.style.display = savedDisplay;
|
socket.on("canvas_state", (data) => bus.emit("canvas_state", data));
|
||||||
showStatus("Connected", false);
|
socket.on("pixel-updated", (data) => bus.emit("pixel-updated", data));
|
||||||
});
|
socket.on("current_soldiers", (count) => bus.emit("current_soldiers", count));
|
||||||
|
socket.on("error", (message) => bus.emit("error", message));
|
||||||
|
socket.on("disconnect", () => bus.emit("disconnect"));
|
||||||
|
|
||||||
socket.on("canvas_state", (data) => {
|
bus.on("pixel-placed", (update) => {
|
||||||
canvasState = data;
|
socket.emit("place-pixel", update);
|
||||||
renderer.drawState(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("error", (message) => showStatus(message, true));
|
|
||||||
|
|
||||||
socket.on("current_soldiers", (count) => {
|
|
||||||
currentSoldiersSpan.textContent = count;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("pixel-updated", (update) => {
|
|
||||||
renderer.drawPixel(update.x, update.y, update.color);
|
|
||||||
setPixel(canvasState, update.x, update.y, update.color);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
|
||||||
showStatus("Disconnected — reconnecting...", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
createPixelPlacer({
|
|
||||||
canvas: canvasEl,
|
|
||||||
renderer,
|
|
||||||
getColor: palette.getColor,
|
|
||||||
getState: () => canvasState,
|
|
||||||
socket,
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -16,3 +16,17 @@
|
|||||||
image-rendering: crisp-edges;
|
image-rendering: crisp-edges;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#current-soldiers {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes soldier-pop {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.4); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.soldier-pop {
|
||||||
|
animation: soldier-pop 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
34
painter-js/src/tools/hand-tool.js
Normal file
34
painter-js/src/tools/hand-tool.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const createHandTool = ({ viewport }) => {
|
||||||
|
let isDragging = false;
|
||||||
|
let dragStartX = 0;
|
||||||
|
let dragStartY = 0;
|
||||||
|
let scrollStartLeft = 0;
|
||||||
|
let scrollStartTop = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "hand",
|
||||||
|
cursor: "grab",
|
||||||
|
onMouseDown: (event) => {
|
||||||
|
isDragging = true;
|
||||||
|
dragStartX = event.clientX;
|
||||||
|
dragStartY = event.clientY;
|
||||||
|
scrollStartLeft = viewport.scrollLeft;
|
||||||
|
scrollStartTop = viewport.scrollTop;
|
||||||
|
viewport.style.cursor = "grabbing";
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
onMouseMove: (event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
viewport.scrollLeft = scrollStartLeft - (event.clientX - dragStartX);
|
||||||
|
viewport.scrollTop = scrollStartTop - (event.clientY - dragStartY);
|
||||||
|
},
|
||||||
|
onMouseUp: () => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
isDragging = false;
|
||||||
|
viewport.style.cursor = "grab";
|
||||||
|
},
|
||||||
|
onDeactivate: () => {
|
||||||
|
isDragging = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
46
painter-js/src/tools/paint-tool.js
Normal file
46
painter-js/src/tools/paint-tool.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { hexToU32 } from "../domain/color.js";
|
||||||
|
import { canPlacePixel } from "../domain/cooldown.js";
|
||||||
|
import { setPixel } from "../domain/canvas-state.js";
|
||||||
|
import { getCanvasCoords } from "../domain/coords.js";
|
||||||
|
|
||||||
|
export const createPaintTool = ({ canvas, renderer, appState, bus }) => {
|
||||||
|
let previewPixel = null;
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
if (!previewPixel) return;
|
||||||
|
renderer.restorePixel(
|
||||||
|
appState.get("canvasPixels"),
|
||||||
|
previewPixel.x,
|
||||||
|
previewPixel.y,
|
||||||
|
);
|
||||||
|
previewPixel = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "paint",
|
||||||
|
cursor: "crosshair",
|
||||||
|
onClick: (event) => {
|
||||||
|
const { x, y } = getCanvasCoords(event, canvas);
|
||||||
|
const color = hexToU32(appState.get("selectedColor"));
|
||||||
|
clearPreview();
|
||||||
|
previewPixel = { x, y, color };
|
||||||
|
renderer.drawPreview(x, y, color);
|
||||||
|
},
|
||||||
|
confirm: () => {
|
||||||
|
if (!previewPixel) return;
|
||||||
|
if (!canPlacePixel(appState.get("lastPlacementTime"))) return;
|
||||||
|
|
||||||
|
bus.emit("pixel-placed", previewPixel);
|
||||||
|
setPixel(
|
||||||
|
appState.get("canvasPixels"),
|
||||||
|
previewPixel.x,
|
||||||
|
previewPixel.y,
|
||||||
|
previewPixel.color,
|
||||||
|
);
|
||||||
|
appState.set("lastPlacementTime", Date.now());
|
||||||
|
clearPreview();
|
||||||
|
},
|
||||||
|
cancel: () => clearPreview(),
|
||||||
|
onDeactivate: () => clearPreview(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
const MIN_ZOOM = 1;
|
const MIN_ZOOM = 1;
|
||||||
const MAX_ZOOM = 40;
|
const MAX_ZOOM = 40;
|
||||||
|
|
||||||
export const createCanvasViewport = (canvas) => {
|
export const createCanvasViewport = ({
|
||||||
const viewport = canvas.parentElement;
|
canvas,
|
||||||
const zoomLabel = document.getElementById("zoom-level");
|
viewport,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
zoomLabel,
|
||||||
|
}) => {
|
||||||
let zoom = 1;
|
let zoom = 1;
|
||||||
let baseSize = viewport.clientWidth;
|
let baseSize = viewport.clientWidth;
|
||||||
|
|
||||||
@@ -59,12 +63,12 @@ export const createCanvasViewport = (canvas) => {
|
|||||||
lastPinchDist = 0;
|
lastPinchDist = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("zoom-in")?.addEventListener("click", () => {
|
zoomIn?.addEventListener("click", () => {
|
||||||
setZoom(zoom * 1.5);
|
setZoom(zoom * 1.5);
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("zoom-out")?.addEventListener("click", () => {
|
zoomOut?.addEventListener("click", () => {
|
||||||
setZoom(zoom / 1.5);
|
setZoom(zoom / 1.5);
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,20 +14,17 @@ const PALETTE_IDS = [
|
|||||||
"brown",
|
"brown",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createColorPalette = () => {
|
export const createColorPalette = ({ colorPicker, colorSpan, appState }) => {
|
||||||
const colorPicker = document.getElementById("color-picker");
|
|
||||||
const currentColorSpan = document.getElementById("current-color-span");
|
|
||||||
let activeButton = null;
|
let activeButton = null;
|
||||||
|
|
||||||
let currentColor = localStorage.getItem("currentColor") || "#000000";
|
const color = appState.get("selectedColor");
|
||||||
colorPicker.value = currentColor;
|
colorPicker.value = color;
|
||||||
currentColorSpan.style.backgroundColor = currentColor;
|
colorSpan.style.backgroundColor = color;
|
||||||
|
|
||||||
const setColor = (hex, button) => {
|
const setColor = (hex, button) => {
|
||||||
currentColor = hex;
|
appState.set("selectedColor", hex);
|
||||||
currentColorSpan.textContent = hex;
|
colorSpan.textContent = hex;
|
||||||
currentColorSpan.style.backgroundColor = hex;
|
colorSpan.style.backgroundColor = hex;
|
||||||
localStorage.setItem("currentColor", hex);
|
|
||||||
|
|
||||||
if (activeButton)
|
if (activeButton)
|
||||||
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
|
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
|
||||||
@@ -48,6 +45,4 @@ export const createColorPalette = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getColor: () => currentColor };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,51 @@
|
|||||||
import { timeRemaining } from "../domain/cooldown.js";
|
import { timeRemaining } from "../domain/cooldown.js";
|
||||||
|
|
||||||
export const startCooldownDisplay = () => {
|
let audioCtx = null;
|
||||||
const countdownDiv = document.getElementById("countdown");
|
|
||||||
|
|
||||||
setInterval(() => {
|
const playBeep = () => {
|
||||||
const lastPlacementTime = parseInt(
|
try {
|
||||||
localStorage.getItem("lastPixelTime") || "0",
|
if (!audioCtx) audioCtx = new AudioContext();
|
||||||
);
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtx.destination);
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(523, audioCtx.currentTime);
|
||||||
|
osc.frequency.setValueAtTime(659, audioCtx.currentTime + 0.1);
|
||||||
|
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(audioCtx.currentTime + 0.25);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startCooldownDisplay = ({ countdown, placeBtn, appState }) => {
|
||||||
|
let wasCoolingDown = false;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const lastPlacementTime = appState.get("lastPlacementTime");
|
||||||
const remaining = timeRemaining(lastPlacementTime);
|
const remaining = timeRemaining(lastPlacementTime);
|
||||||
countdownDiv.textContent =
|
const isCoolingDown = remaining > 0;
|
||||||
remaining > 0
|
|
||||||
|
countdown.textContent = isCoolingDown
|
||||||
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
|
||||||
: "You can place a pixel now";
|
: "You can place a pixel now";
|
||||||
}, 1000);
|
|
||||||
|
placeBtn.disabled = isCoolingDown;
|
||||||
|
if (isCoolingDown) {
|
||||||
|
placeBtn.classList.add("opacity-50", "cursor-not-allowed");
|
||||||
|
} else {
|
||||||
|
placeBtn.classList.remove("opacity-50", "cursor-not-allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasCoolingDown && !isCoolingDown && lastPlacementTime > 0) {
|
||||||
|
playBeep();
|
||||||
|
}
|
||||||
|
|
||||||
|
wasCoolingDown = isCoolingDown;
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
setInterval(update, 1000);
|
||||||
|
appState.subscribe("lastPlacementTime", update);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { hexToU32 } from "../domain/color.js";
|
|
||||||
import { canPlacePixel } from "../domain/cooldown.js";
|
|
||||||
import { setPixel } from "../domain/canvas-state.js";
|
|
||||||
import { getCanvasCoords } from "../domain/coords.js";
|
|
||||||
|
|
||||||
export const createPixelPlacer = ({
|
|
||||||
canvas,
|
|
||||||
renderer,
|
|
||||||
getColor,
|
|
||||||
getState,
|
|
||||||
socket,
|
|
||||||
}) => {
|
|
||||||
let previewPixel = null;
|
|
||||||
|
|
||||||
const place = (update) => {
|
|
||||||
const lastPixelTime = parseInt(
|
|
||||||
localStorage.getItem("lastPixelTime") || "0",
|
|
||||||
);
|
|
||||||
if (!canPlacePixel(lastPixelTime)) return;
|
|
||||||
|
|
||||||
socket.emit("place-pixel", update);
|
|
||||||
setPixel(getState(), update.x, update.y, update.color);
|
|
||||||
localStorage.setItem("lastPixelTime", Date.now().toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearPreview = () => {
|
|
||||||
if (!previewPixel) return;
|
|
||||||
renderer.restorePixel(getState(), previewPixel.x, previewPixel.y);
|
|
||||||
previewPixel = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmPlacement = () => {
|
|
||||||
if (!previewPixel) return;
|
|
||||||
place(previewPixel);
|
|
||||||
clearPreview();
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectPixel = (event) => {
|
|
||||||
const { x, y } = getCanvasCoords(event, canvas);
|
|
||||||
const color = hexToU32(getColor());
|
|
||||||
|
|
||||||
clearPreview();
|
|
||||||
previewPixel = { x, y, color };
|
|
||||||
renderer.drawPreview(x, y, color);
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.addEventListener("click", selectPixel);
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Enter") confirmPlacement();
|
|
||||||
if (event.key === "Escape") clearPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
document
|
|
||||||
.getElementById("place-pixel")
|
|
||||||
.addEventListener("click", confirmPlacement);
|
|
||||||
};
|
|
||||||
65
painter-js/src/ui/tool-manager.js
Normal file
65
painter-js/src/ui/tool-manager.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export const createToolManager = ({ canvas, viewport, onActiveChange }) => {
|
||||||
|
const tools = {};
|
||||||
|
let activeName = null;
|
||||||
|
let heldName = null;
|
||||||
|
let previousName = null;
|
||||||
|
|
||||||
|
const setActive = (name) => {
|
||||||
|
if (activeName === name) return;
|
||||||
|
const prev = tools[activeName];
|
||||||
|
if (prev?.onDeactivate) prev.onDeactivate();
|
||||||
|
activeName = name;
|
||||||
|
const tool = tools[activeName];
|
||||||
|
viewport.style.cursor = tool.cursor || "default";
|
||||||
|
if (tool.onActivate) tool.onActivate();
|
||||||
|
if (onActiveChange) onActiveChange(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("click", (event) => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.onClick) tool.onClick(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
viewport.addEventListener("mousedown", (event) => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.onMouseDown) tool.onMouseDown(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", (event) => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.onMouseMove) tool.onMouseMove(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mouseup", (event) => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.onMouseUp) tool.onMouseUp(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
register: (tool) => {
|
||||||
|
tools[tool.name] = tool;
|
||||||
|
},
|
||||||
|
setActive,
|
||||||
|
getActive: () => activeName,
|
||||||
|
holdTool: (name) => {
|
||||||
|
if (heldName) return;
|
||||||
|
previousName = activeName;
|
||||||
|
heldName = name;
|
||||||
|
setActive(name);
|
||||||
|
},
|
||||||
|
releaseTool: () => {
|
||||||
|
if (!heldName) return;
|
||||||
|
heldName = null;
|
||||||
|
setActive(previousName);
|
||||||
|
previousName = null;
|
||||||
|
},
|
||||||
|
confirm: () => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.confirm) tool.confirm();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
const tool = tools[activeName];
|
||||||
|
if (tool?.cancel) tool.cancel();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user