Updating auth
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import {
|
||||
useRef, useEffect, useCallback, useState,
|
||||
forwardRef, useImperativeHandle,
|
||||
} from 'react';
|
||||
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import TokenDialog from './TokenDialog';
|
||||
import './Grid.css';
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type {
|
||||
GridCell,
|
||||
GridToken,
|
||||
Tool,
|
||||
ServerMessage,
|
||||
ClientMessage,
|
||||
} from "../types";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import TokenDialog from "./TokenDialog";
|
||||
import "./Grid.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -16,9 +26,9 @@ const MIN_ZOOM = 8;
|
||||
const MAX_ZOOM = 160;
|
||||
const ZOOM_STEP = 1.12;
|
||||
|
||||
const BG_COLOR = '#111827';
|
||||
const GRID_COLOR = 'rgba(255,255,255,0.07)';
|
||||
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)';
|
||||
const BG_COLOR = "#111827";
|
||||
const GRID_COLOR = "rgba(255,255,255,0.07)";
|
||||
const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)";
|
||||
|
||||
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
|
||||
const MAX_FLOOD_CELLS = 2500;
|
||||
@@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string {
|
||||
return `${x},${y}`;
|
||||
}
|
||||
|
||||
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
|
||||
function canvasToCell(
|
||||
cx: number,
|
||||
cy: number,
|
||||
cam: Camera,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.floor(cx / cam.zoom + cam.offsetX),
|
||||
y: Math.floor(cy / cam.zoom + cam.offsetY),
|
||||
};
|
||||
}
|
||||
|
||||
function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } {
|
||||
function cellToCanvas(
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
cam: Camera,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (cellX - cam.offsetX) * cam.zoom,
|
||||
y: (cellY - cam.offsetY) * cam.zoom,
|
||||
@@ -84,7 +102,7 @@ function drawToken(
|
||||
const cy = py + zoom / 2;
|
||||
const r = zoom * 0.38;
|
||||
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowColor = "rgba(0,0,0,0.6)";
|
||||
ctx.shadowBlur = 5;
|
||||
|
||||
ctx.beginPath();
|
||||
@@ -92,7 +110,7 @@ function drawToken(
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.55)";
|
||||
ctx.lineWidth = Math.max(1, zoom * 0.04);
|
||||
ctx.stroke();
|
||||
|
||||
@@ -104,10 +122,10 @@ function drawToken(
|
||||
words.length >= 2
|
||||
? (words[0][0] + words[1][0]).toUpperCase()
|
||||
: label.slice(0, 2).toUpperCase();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(initials, cx, cy);
|
||||
}
|
||||
}
|
||||
@@ -133,8 +151,10 @@ function floodFill(
|
||||
visited.add(cellKey(startX, startY));
|
||||
|
||||
const dirs = [
|
||||
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
|
||||
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
|
||||
{ dx: 1, dy: 0 },
|
||||
{ dx: -1, dy: 0 },
|
||||
{ dx: 0, dy: 1 },
|
||||
{ dx: 0, dy: -1 },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -170,9 +190,9 @@ function clampCameraToContent(
|
||||
) {
|
||||
if (cells.size === 0 && tokens.size === 0) return;
|
||||
|
||||
const viewLeft = cam.offsetX;
|
||||
const viewRight = cam.offsetX + canvasW / cam.zoom;
|
||||
const viewTop = cam.offsetY;
|
||||
const viewLeft = cam.offsetX;
|
||||
const viewRight = cam.offsetX + canvasW / cam.zoom;
|
||||
const viewTop = cam.offsetY;
|
||||
const viewBottom = cam.offsetY + canvasH / cam.zoom;
|
||||
|
||||
// Quick visibility check
|
||||
@@ -180,8 +200,10 @@ function clampCameraToContent(
|
||||
|
||||
cellLoop: for (const cell of cells.values()) {
|
||||
if (
|
||||
cell.x + 1 > viewLeft && cell.x < viewRight &&
|
||||
cell.y + 1 > viewTop && cell.y < viewBottom
|
||||
cell.x + 1 > viewLeft &&
|
||||
cell.x < viewRight &&
|
||||
cell.y + 1 > viewTop &&
|
||||
cell.y < viewBottom
|
||||
) {
|
||||
anyVisible = true;
|
||||
break cellLoop;
|
||||
@@ -191,8 +213,10 @@ function clampCameraToContent(
|
||||
if (!anyVisible) {
|
||||
for (const tok of tokens.values()) {
|
||||
if (
|
||||
tok.x + 1 > viewLeft && tok.x < viewRight &&
|
||||
tok.y + 1 > viewTop && tok.y < viewBottom
|
||||
tok.x + 1 > viewLeft &&
|
||||
tok.x < viewRight &&
|
||||
tok.y + 1 > viewTop &&
|
||||
tok.y < viewBottom
|
||||
) {
|
||||
anyVisible = true;
|
||||
break;
|
||||
@@ -203,8 +227,10 @@ function clampCameraToContent(
|
||||
if (anyVisible) return;
|
||||
|
||||
// Find the bounding box of all content
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const c of cells.values()) {
|
||||
if (c.x < minX) minX = c.x;
|
||||
@@ -248,43 +274,54 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
|
||||
const cameraRef = useRef<Camera>({
|
||||
offsetX: -2,
|
||||
offsetY: -2,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
});
|
||||
|
||||
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
||||
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
||||
const tokensRef = useRef<Map<string, GridToken>>(new Map());
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const redraw = useCallback(() => setTick(n => n + 1), []);
|
||||
const redraw = useCallback(() => setTick((n) => n + 1), []);
|
||||
|
||||
// ---- Mouse interaction state (refs to avoid stale closures) ----
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null);
|
||||
const isDrawing = useRef(false);
|
||||
const isErasing = useRef(false);
|
||||
const lastPainted = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const dragTokenId = useRef<string | null>(null);
|
||||
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef<{
|
||||
mx: number;
|
||||
my: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
} | null>(null);
|
||||
const isDrawing = useRef(false);
|
||||
const isErasing = useRef(false);
|
||||
const lastPainted = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const dragTokenId = useRef<string | null>(null);
|
||||
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// ---- WASD state ----
|
||||
const keysHeld = useRef<Set<string>>(new Set());
|
||||
const rafId = useRef<number | null>(null);
|
||||
const keysHeld = useRef<Set<string>>(new Set());
|
||||
const rafId = useRef<number | null>(null);
|
||||
const lastFrameTime = useRef<number | null>(null);
|
||||
|
||||
// ---- Stable send ref so handlers never go stale ----
|
||||
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
|
||||
|
||||
const [cursor, setCursor] = useState<string>('default');
|
||||
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [cursor, setCursor] = useState<string>("default");
|
||||
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Imperative handle — lets App.tsx trigger a color WS update
|
||||
// -------------------------------------------------------------------------
|
||||
useImperativeHandle(ref, () => ({
|
||||
sendColorUpdate(colors: string[]) {
|
||||
sendRef.current({ type: 'update_colors', colors });
|
||||
sendRef.current({ type: "update_colors", colors });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -293,11 +330,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
redraw();
|
||||
};
|
||||
@@ -313,77 +350,92 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// -------------------------------------------------------------------------
|
||||
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
||||
const onColorsLoadedRef = useRef(onColorsLoaded);
|
||||
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]);
|
||||
useEffect(() => {
|
||||
onColorsLoadedRef.current = onColorsLoaded;
|
||||
}, [onColorsLoaded]);
|
||||
|
||||
const handleMessage = useCallback((msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'state': {
|
||||
cellsRef.current.clear();
|
||||
tokensRef.current.clear();
|
||||
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c));
|
||||
msg.tokens.forEach(t => tokensRef.current.set(t.id, t));
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cell_painted': {
|
||||
const key = cellKey(msg.x, msg.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: msg.x, y: msg.y, color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cells_batch_painted': {
|
||||
msg.cells.forEach(c => {
|
||||
const key = cellKey(c.x, c.y);
|
||||
const handleMessage = useCallback(
|
||||
(msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case "state": {
|
||||
cellsRef.current.clear();
|
||||
tokensRef.current.clear();
|
||||
msg.cells.forEach((c) => cellsRef.current.set(cellKey(c.x, c.y), c));
|
||||
msg.tokens.forEach((t) => tokensRef.current.set(t.id, t));
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "cell_painted": {
|
||||
const key = cellKey(msg.x, msg.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: c.x, y: c.y, color: c.color,
|
||||
x: msg.x,
|
||||
y: msg.y,
|
||||
color: msg.color,
|
||||
});
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cell_erased': {
|
||||
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'token_added': {
|
||||
tokensRef.current.set(msg.id, {
|
||||
id: msg.id, map_id: mapId,
|
||||
x: msg.x, y: msg.y, label: msg.label, color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'token_moved': {
|
||||
const tok = tokensRef.current.get(msg.id);
|
||||
if (tok) {
|
||||
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "cells_batch_painted": {
|
||||
msg.cells.forEach((c) => {
|
||||
const key = cellKey(c.x, c.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
color: c.color,
|
||||
});
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "cell_erased": {
|
||||
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "token_added": {
|
||||
tokensRef.current.set(msg.id, {
|
||||
id: msg.id,
|
||||
map_id: mapId,
|
||||
x: msg.x,
|
||||
y: msg.y,
|
||||
label: msg.label,
|
||||
color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "token_moved": {
|
||||
const tok = tokensRef.current.get(msg.id);
|
||||
if (tok) {
|
||||
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
||||
redraw();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "token_deleted": {
|
||||
tokensRef.current.delete(msg.id);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "colors_updated": {
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
console.error("[Grid WS]", msg.message);
|
||||
break;
|
||||
}
|
||||
case 'token_deleted': {
|
||||
tokensRef.current.delete(msg.id);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'colors_updated': {
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('[Grid WS]', msg.message);
|
||||
break;
|
||||
}
|
||||
}, [mapId, redraw]);
|
||||
},
|
||||
[mapId, redraw],
|
||||
);
|
||||
|
||||
const { send } = useWebSocket(mapId, handleMessage);
|
||||
useEffect(() => { sendRef.current = send; }, [send]);
|
||||
useEffect(() => {
|
||||
sendRef.current = send;
|
||||
}, [send]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Canvas draw
|
||||
@@ -391,11 +443,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const cam = cameraRef.current;
|
||||
const { offsetX, offsetY, zoom } = cam;
|
||||
|
||||
@@ -403,13 +455,19 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const startCX = Math.floor(offsetX) - 1;
|
||||
const endCX = Math.ceil(offsetX + W / zoom) + 1;
|
||||
const endCX = Math.ceil(offsetX + W / zoom) + 1;
|
||||
const startCY = Math.floor(offsetY) - 1;
|
||||
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
||||
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
||||
|
||||
// Painted cells
|
||||
cellsRef.current.forEach(cell => {
|
||||
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return;
|
||||
cellsRef.current.forEach((cell) => {
|
||||
if (
|
||||
cell.x < startCX ||
|
||||
cell.x > endCX ||
|
||||
cell.y < startCY ||
|
||||
cell.y > endCY
|
||||
)
|
||||
return;
|
||||
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
|
||||
ctx.fillStyle = cell.color;
|
||||
ctx.fillRect(px, py, zoom, zoom);
|
||||
@@ -437,7 +495,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
}
|
||||
|
||||
// Tokens (skip the one being dragged)
|
||||
tokensRef.current.forEach(token => {
|
||||
tokensRef.current.forEach((token) => {
|
||||
if (isDragging.current && dragTokenId.current === token.id) return;
|
||||
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
|
||||
});
|
||||
@@ -447,7 +505,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const tok = tokensRef.current.get(dragTokenId.current);
|
||||
if (tok) {
|
||||
ctx.globalAlpha = 0.6;
|
||||
drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam);
|
||||
drawToken(
|
||||
ctx,
|
||||
dragCellPos.current.x,
|
||||
dragCellPos.current.y,
|
||||
tok.label,
|
||||
tok.color,
|
||||
cam,
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -460,21 +525,24 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
clampCameraToContent(
|
||||
cameraRef.current, cellsRef.current, tokensRef.current,
|
||||
canvas.width, canvas.height,
|
||||
cameraRef.current,
|
||||
cellsRef.current,
|
||||
tokensRef.current,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}
|
||||
redraw();
|
||||
}
|
||||
|
||||
function applyZoom(canvasX: number, canvasY: number, factor: number) {
|
||||
const cam = cameraRef.current;
|
||||
const cam = cameraRef.current;
|
||||
const worldX = canvasX / cam.zoom + cam.offsetX;
|
||||
const worldY = canvasY / cam.zoom + cam.offsetY;
|
||||
const newZoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
cam.offsetX = worldX - canvasX / newZoom;
|
||||
cam.offsetY = worldY - canvasY / newZoom;
|
||||
cam.zoom = newZoom;
|
||||
cam.zoom = newZoom;
|
||||
applyClampAndRedraw();
|
||||
}
|
||||
|
||||
@@ -486,14 +554,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
if (!canvas) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||
applyZoom(cx, cy, factor);
|
||||
};
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', onWheel);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", onWheel);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -508,34 +576,45 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = lastFrameTime.current !== null
|
||||
? (timestamp - lastFrameTime.current) / 1000
|
||||
: 0;
|
||||
const dt =
|
||||
lastFrameTime.current !== null
|
||||
? (timestamp - lastFrameTime.current) / 1000
|
||||
: 0;
|
||||
lastFrameTime.current = timestamp;
|
||||
|
||||
const cam = cameraRef.current;
|
||||
const cam = cameraRef.current;
|
||||
const speed = WASD_PAN_SPEED;
|
||||
|
||||
if (keys.has('a')) cam.offsetX -= speed * dt;
|
||||
if (keys.has('d')) cam.offsetX += speed * dt;
|
||||
if (keys.has('w')) cam.offsetY -= speed * dt;
|
||||
if (keys.has('s')) cam.offsetY += speed * dt;
|
||||
if (keys.has("a")) cam.offsetX -= speed * dt;
|
||||
if (keys.has("d")) cam.offsetX += speed * dt;
|
||||
if (keys.has("w")) cam.offsetY -= speed * dt;
|
||||
if (keys.has("s")) cam.offsetY += speed * dt;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height);
|
||||
clampCameraToContent(
|
||||
cam,
|
||||
cellsRef.current,
|
||||
tokensRef.current,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}
|
||||
setTick(n => n + 1);
|
||||
setTick((n) => n + 1);
|
||||
|
||||
rafId.current = requestAnimationFrame(rafTick);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (['w', 'a', 's', 'd'].includes(key)) {
|
||||
if (["w", "a", "s", "d"].includes(key)) {
|
||||
e.preventDefault();
|
||||
keysHeld.current.add(key);
|
||||
if (rafId.current === null) {
|
||||
@@ -549,11 +628,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
keysHeld.current.delete(e.key.toLowerCase());
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
if (rafId.current !== null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
@@ -585,22 +664,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
|
||||
// ---- Pan tool ----
|
||||
if (tool === 'pan' && e.button === 0) {
|
||||
if (tool === "pan" && e.button === 0) {
|
||||
isPanning.current = true;
|
||||
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
|
||||
setCursor('grabbing');
|
||||
panStart.current = {
|
||||
mx,
|
||||
my,
|
||||
ox: cameraRef.current.offsetX,
|
||||
oy: cameraRef.current.offsetY,
|
||||
};
|
||||
setCursor("grabbing");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Zoom tool ----
|
||||
if (tool === 'zoom') {
|
||||
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
||||
if (tool === "zoom") {
|
||||
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
||||
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Draw tool ----
|
||||
if (tool === 'draw') {
|
||||
if (tool === "draw") {
|
||||
if (e.button === 0) {
|
||||
if (e.shiftKey) {
|
||||
// Shift+click → flood fill uncolored region
|
||||
@@ -609,42 +693,52 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const region = floodFill(cell.x, cell.y, cellsRef.current);
|
||||
if (region === null || region.length === 1) {
|
||||
// Unbounded or trivially single cell → paint one cell
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
} else {
|
||||
// Bounded enclosed region → batch paint
|
||||
sendRef.current({
|
||||
type: 'paint_cells',
|
||||
type: "paint_cells",
|
||||
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isDrawing.current = true;
|
||||
isDrawing.current = true;
|
||||
lastPainted.current = cellKey(cell.x, cell.y);
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
}
|
||||
} else if (e.button === 2) {
|
||||
isErasing.current = true;
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
isErasing.current = true;
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
lastPainted.current = key;
|
||||
if (cellsRef.current.has(key)) {
|
||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
||||
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Token tool ----
|
||||
if (tool === 'token') {
|
||||
if (tool === "token") {
|
||||
if (e.button === 2) {
|
||||
const tok = tokenAtCell(cell.x, cell.y);
|
||||
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
|
||||
if (tok) sendRef.current({ type: "delete_token", id: tok.id });
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
const tok = tokenAtCell(cell.x, cell.y);
|
||||
if (tok) {
|
||||
isDragging.current = true;
|
||||
isDragging.current = true;
|
||||
dragTokenId.current = tok.id;
|
||||
dragCellPos.current = { x: cell.x, y: cell.y };
|
||||
redraw();
|
||||
@@ -661,8 +755,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Pan
|
||||
if (isPanning.current && panStart.current) {
|
||||
const cam = cameraRef.current;
|
||||
const dx = (mx - panStart.current.mx) / cam.zoom;
|
||||
const dy = (my - panStart.current.my) / cam.zoom;
|
||||
const dx = (mx - panStart.current.mx) / cam.zoom;
|
||||
const dy = (my - panStart.current.my) / cam.zoom;
|
||||
cam.offsetX = panStart.current.ox - dx;
|
||||
cam.offsetY = panStart.current.oy - dy;
|
||||
applyClampAndRedraw();
|
||||
@@ -672,13 +766,18 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Draw / erase stroke
|
||||
if (isDrawing.current || isErasing.current) {
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
if (lastPainted.current !== key) {
|
||||
lastPainted.current = key;
|
||||
if (isDrawing.current) {
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
} else if (isErasing.current && cellsRef.current.has(key)) {
|
||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
||||
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -687,7 +786,10 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Token drag
|
||||
if (isDragging.current && dragCellPos.current) {
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) {
|
||||
if (
|
||||
dragCellPos.current.x !== cell.x ||
|
||||
dragCellPos.current.y !== cell.y
|
||||
) {
|
||||
dragCellPos.current = { x: cell.x, y: cell.y };
|
||||
redraw();
|
||||
}
|
||||
@@ -697,29 +799,32 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
function handleMouseUp(_e: React.MouseEvent) {
|
||||
if (isPanning.current) {
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
setCursor('grab');
|
||||
panStart.current = null;
|
||||
setCursor("grab");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDrawing.current || isErasing.current) {
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
lastPainted.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
|
||||
const tok = tokensRef.current.get(dragTokenId.current);
|
||||
if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) {
|
||||
if (
|
||||
tok &&
|
||||
(tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)
|
||||
) {
|
||||
sendRef.current({
|
||||
type: 'move_token',
|
||||
type: "move_token",
|
||||
id: dragTokenId.current,
|
||||
x: dragCellPos.current.x,
|
||||
y: dragCellPos.current.y,
|
||||
});
|
||||
}
|
||||
isDragging.current = false;
|
||||
isDragging.current = false;
|
||||
dragTokenId.current = null;
|
||||
dragCellPos.current = null;
|
||||
redraw();
|
||||
@@ -727,13 +832,13 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
lastPainted.current = null;
|
||||
if (isDragging.current) {
|
||||
isDragging.current = false;
|
||||
isDragging.current = false;
|
||||
dragTokenId.current = null;
|
||||
dragCellPos.current = null;
|
||||
redraw();
|
||||
@@ -743,16 +848,30 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Sync cursor CSS to active tool
|
||||
useEffect(() => {
|
||||
switch (tool) {
|
||||
case 'pan': setCursor('grab'); break;
|
||||
case 'zoom': setCursor('zoom-in'); break;
|
||||
case 'draw': setCursor('crosshair'); break;
|
||||
case 'token': setCursor('crosshair'); break;
|
||||
case "pan":
|
||||
setCursor("grab");
|
||||
break;
|
||||
case "zoom":
|
||||
setCursor("zoom-in");
|
||||
break;
|
||||
case "draw":
|
||||
setCursor("crosshair");
|
||||
break;
|
||||
case "token":
|
||||
setCursor("crosshair");
|
||||
break;
|
||||
}
|
||||
}, [tool]);
|
||||
|
||||
function handleAddToken(label: string, color: string) {
|
||||
if (!dialogPos) return;
|
||||
sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color });
|
||||
sendRef.current({
|
||||
type: "add_token",
|
||||
x: dialogPos.x,
|
||||
y: dialogPos.y,
|
||||
label,
|
||||
color,
|
||||
});
|
||||
setDialogPos(null);
|
||||
}
|
||||
|
||||
@@ -766,7 +885,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
{dialogPos && (
|
||||
<TokenDialog
|
||||
|
||||
Reference in New Issue
Block a user