diff --git a/ui/src/components/ColorPanel.tsx b/ui/src/components/ColorPanel.tsx
index 8430977..0265e41 100644
--- a/ui/src/components/ColorPanel.tsx
+++ b/ui/src/components/ColorPanel.tsx
@@ -17,16 +17,17 @@ export default function ColorPanel({
// One hidden color input ref per slot
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
- // Keys 1–9 select the corresponding color slot (plain, no shift)
+ // Shift+1–9 select the corresponding color slot
useEffect(() => {
const handler = (e: KeyboardEvent) => {
- if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
+ if (!e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
- const num = parseInt(e.key, 10);
+ if (!e.code.startsWith("Digit")) return;
+ const num = parseInt(e.code.replace("Digit", ""), 10);
if (num >= 1 && num <= colors.length) {
onColorChange(colors[num - 1]);
}
@@ -57,9 +58,9 @@ export default function ColorPanel({
style={{ background: c }}
onClick={() => onColorChange(c)}
onDoubleClick={() => handleDoubleClick(i)}
- title={`${c} (${i + 1}) — double-click to edit`}
+ title={`${c} (Shift+${i + 1}) — double-click to edit`}
>
- {i + 1}
+ ⇧{i + 1}
{/* Hidden color picker for this slot */}
,
title: "Pan – drag to move the map",
- shortcut: "Shift+1",
+ shortcut: "1",
},
{
id: "zoom",
icon: ,
title: "Zoom – click to zoom in/out",
- shortcut: "Shift+2",
+ shortcut: "2",
},
{
id: "draw",
icon: ,
title:
"Draw – left-click to paint, right-click to erase, Shift+click to fill/replace",
- shortcut: "Shift+3",
+ shortcut: "3",
},
{
id: "token",
icon: ,
title:
"Token – click to place, drag to move, Shift+click to resize, double-click to edit, right-click to delete",
- shortcut: "Shift+4",
+ shortcut: "4",
},
];
@@ -61,7 +61,7 @@ export default function ControlPanel({
onUndo,
onRedo,
}: Props) {
- // Keyboard shortcuts: Shift+1/2/3/4 for tools
+ // Keyboard shortcuts: 1/2/3/4 for tools
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (
@@ -69,21 +69,17 @@ export default function ControlPanel({
e.target instanceof HTMLTextAreaElement
)
return;
- if (!e.shiftKey) return;
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key) {
- case "!": // Shift+1 on many layouts
case "1":
onToolChange("pan");
break;
- case "@": // Shift+2
case "2":
onToolChange("zoom");
break;
- case "#": // Shift+3
case "3":
onToolChange("draw");
break;
- case "$": // Shift+4
case "4":
onToolChange("token");
break;
diff --git a/ui/src/components/Grid.css b/ui/src/components/Grid/Grid.css
similarity index 100%
rename from ui/src/components/Grid.css
rename to ui/src/components/Grid/Grid.css
diff --git a/ui/src/components/Grid/constants.ts b/ui/src/components/Grid/constants.ts
new file mode 100644
index 0000000..25b163d
--- /dev/null
+++ b/ui/src/components/Grid/constants.ts
@@ -0,0 +1,17 @@
+export const DEFAULT_ZOOM = 40;
+export const MIN_ZOOM = 8;
+export const MAX_ZOOM = 160;
+export const ZOOM_STEP = 1.12;
+
+export const BG_COLOR = "#111827";
+export const GRID_COLOR = "rgba(255,255,255,0.07)";
+export 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. */
+export const MAX_FLOOD_CELLS = 2500;
+
+/** World units per second for WASD keyboard panning. */
+export const WASD_PAN_SPEED = 12;
+
+/** Maximum token size (NxN). */
+export const MAX_TOKEN_SIZE = 9;
diff --git a/ui/src/components/Grid.tsx b/ui/src/components/Grid/index.tsx
similarity index 85%
rename from ui/src/components/Grid.tsx
rename to ui/src/components/Grid/index.tsx
index d4ac32b..ba5875d 100644
--- a/ui/src/components/Grid.tsx
+++ b/ui/src/components/Grid/index.tsx
@@ -6,383 +6,42 @@ import {
forwardRef,
useImperativeHandle,
} from "react";
-import type {
- GridCell,
- GridToken,
- Tool,
- MovementRule,
- ServerMessage,
- ClientMessage,
-} from "../types";
-import { useWebSocket } from "../hooks/useWebSocket";
-import TokenDialog from "./TokenDialog";
-import TokenStackPicker from "./TokenStackPicker";
+import type { GridCell, GridToken, ServerMessage, ClientMessage } from "../../types";
+import { useWebSocket } from "../../hooks/useWebSocket";
+import TokenDialog from "../TokenDialog";
+import TokenStackPicker from "../TokenStackPicker";
import "./Grid.css";
-const DEFAULT_ZOOM = 40;
-const MIN_ZOOM = 8;
-const MAX_ZOOM = 160;
-const ZOOM_STEP = 1.12;
+import {
+ DEFAULT_ZOOM,
+ MIN_ZOOM,
+ MAX_ZOOM,
+ ZOOM_STEP,
+ BG_COLOR,
+ GRID_COLOR,
+ GRID_COLOR_MAJOR,
+ WASD_PAN_SPEED,
+ MAX_TOKEN_SIZE,
+} from "./constants";
+import type { Camera, UndoAction, Props, GridHandle } from "./types";
+import {
+ cellKey,
+ parseCellKey,
+ canvasToCell,
+ canvasToCenterAnchor,
+ cellToCanvas,
+ clamp,
+ calcDndSquares,
+} from "./utils";
+import {
+ drawToken,
+ drawStackBadge,
+ floodFill,
+ floodFillColored,
+ clampCameraToContent,
+} from "./render";
-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;
-
-/** World units per second for WASD keyboard panning. */
-const WASD_PAN_SPEED = 12;
-
-/** Maximum token size (NxN). */
-const MAX_TOKEN_SIZE = 9;
-
-interface Camera {
- offsetX: number;
- offsetY: number;
- zoom: number;
-}
-
-interface UndoAction {
- revert: () => void;
- apply: () => void;
-}
-
-interface Props {
- mapId: string;
- tool: Tool;
- paintColor: string;
- tokenColor: string;
- onColorsLoaded: (colors: string[]) => void;
- /** How many real-world units one grid square represents (default 5). */
- unitsPerSquare: number;
- /** Label for the unit, e.g. "ft" or "m". */
- unitLabel: string;
- /** Which diagonal movement rule to use. */
- movementRule: MovementRule;
- /** Called whenever the undo/redo stack availability changes. */
- onUndoStateChange?: (canUndo: boolean, canRedo: boolean) => void;
-}
-
-export interface GridHandle {
- sendColorUpdate: (colors: string[]) => void;
- undo: () => void;
- redo: () => void;
-}
-
-function cellKey(x: number, y: number): string {
- return `${x},${y}`;
-}
-
-function parseCellKey(key: string): { x: number; y: number } {
- const [xs, ys] = key.split(",");
- return { x: Number(xs), y: Number(ys) };
-}
-
-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),
- };
-}
-
-/**
- * Convert a canvas point to the token anchor whose CENTER is closest to the cursor.
- * For a size-N token: anchor = round(cursorWorld - N/2).
- * For size=1 this is equivalent to canvasToCell (Math.floor).
- */
-function canvasToCenterAnchor(
- cx: number,
- cy: number,
- size: number,
- cam: Camera,
-): { x: number; y: number } {
- const worldX = cx / cam.zoom + cam.offsetX;
- const worldY = cy / cam.zoom + cam.offsetY;
- return {
- x: Math.round(worldX - size / 2),
- y: Math.round(worldY - size / 2),
- };
-}
-
-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,
- };
-}
-
-function drawToken(
- ctx: CanvasRenderingContext2D,
- cellX: number,
- cellY: number,
- label: string,
- color: string,
- cam: Camera,
- size: number = 1,
-) {
- const zoom = cam.zoom;
- const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
- // Center of the NxN area
- const cx = px + (zoom * size) / 2;
- const cy = py + (zoom * size) / 2;
- const r = zoom * size * 0.38;
-
- ctx.shadowColor = "rgba(0,0,0,0.6)";
- ctx.shadowBlur = 5;
-
- ctx.beginPath();
- ctx.arc(cx, cy, r, 0, Math.PI * 2);
- ctx.fillStyle = color;
- ctx.fill();
-
- ctx.strokeStyle = "rgba(255,255,255,0.55)";
- ctx.lineWidth = Math.max(1, zoom * 0.04);
- ctx.stroke();
-
- ctx.shadowBlur = 0;
-
- if (zoom >= 14) {
- const words = label.trim().split(/\s+/);
- const initials =
- words.length >= 2
- ? (words[0][0] + words[1][0]).toUpperCase()
- : label.slice(0, 2).toUpperCase();
- ctx.fillStyle = "#ffffff";
- ctx.font = `bold ${Math.round(zoom * size * 0.3)}px system-ui, sans-serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText(initials, cx, cy);
- }
-}
-
-/** Draw a small count badge at the top-right of the token circle. */
-function drawStackBadge(
- ctx: CanvasRenderingContext2D,
- cellX: number,
- cellY: number,
- count: number,
- cam: Camera,
- size: number = 1,
-) {
- const zoom = cam.zoom;
- const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
- const tokenR = zoom * size * 0.38;
- // Badge centre: top-right of the token circle
- const bx = px + (zoom * size) / 2 + tokenR * 0.68;
- const by = py + (zoom * size) / 2 - tokenR * 0.68;
- const br = Math.max(5, zoom * 0.18);
-
- ctx.beginPath();
- ctx.arc(bx, by, br, 0, Math.PI * 2);
- ctx.fillStyle = "#ef4444";
- ctx.fill();
- ctx.strokeStyle = "#1f2937";
- ctx.lineWidth = Math.max(1, zoom * 0.025);
- ctx.stroke();
-
- if (zoom >= 14) {
- ctx.fillStyle = "#ffffff";
- ctx.font = `bold ${Math.round(br * 1.2)}px system-ui, sans-serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText(String(count), bx, by);
- }
-}
-
-/**
- * Calculate movement distance in grid squares using D&D rules.
- */
-function calcDndSquares(dx: number, dy: number, rule: MovementRule): number {
- const adx = Math.abs(dx);
- const ady = Math.abs(dy);
- const straight = Math.abs(adx - ady);
- const diagonal = Math.min(adx, ady);
- if (rule === "free") {
- return straight + diagonal;
- }
- return straight + diagonal + Math.floor(diagonal / 2);
-}
-
-function clamp(v: number, lo: number, hi: number) {
- return Math.max(lo, Math.min(hi, v));
-}
-
-/**
- * BFS flood fill from (startX, startY) through uncolored (empty) cells.
- */
-function floodFill(
- startX: number,
- startY: number,
- cells: Map,
-): Array<{ x: number; y: number }> | null {
- const visited = new Set();
- const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
- const found: Array<{ x: number; y: number }> = [];
-
- visited.add(cellKey(startX, startY));
-
- const dirs = [
- { dx: 1, dy: 0 },
- { dx: -1, dy: 0 },
- { dx: 0, dy: 1 },
- { dx: 0, dy: -1 },
- ];
-
- while (queue.length > 0) {
- const { x, y } = queue.shift()!;
- found.push({ x, y });
-
- if (found.length > MAX_FLOOD_CELLS) return null;
-
- for (const { dx, dy } of dirs) {
- const nx = x + dx;
- const ny = y + dy;
- const key = cellKey(nx, ny);
- if (!visited.has(key) && !cells.has(key)) {
- visited.add(key);
- queue.push({ x: nx, y: ny });
- }
- }
- }
-
- return found;
-}
-
-/**
- * BFS flood fill from (startX, startY) through cells matching targetColor.
- * Used for shift+click color replacement.
- */
-function floodFillColored(
- startX: number,
- startY: number,
- targetColor: string,
- cells: Map,
-): Array<{ x: number; y: number }> | null {
- const visited = new Set();
- const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
- const found: Array<{ x: number; y: number }> = [];
-
- visited.add(cellKey(startX, startY));
-
- const dirs = [
- { dx: 1, dy: 0 },
- { dx: -1, dy: 0 },
- { dx: 0, dy: 1 },
- { dx: 0, dy: -1 },
- ];
-
- while (queue.length > 0) {
- const { x, y } = queue.shift()!;
- found.push({ x, y });
-
- if (found.length > MAX_FLOOD_CELLS) return null;
-
- for (const { dx, dy } of dirs) {
- const nx = x + dx;
- const ny = y + dy;
- const key = cellKey(nx, ny);
- if (!visited.has(key)) {
- const neighbor = cells.get(key);
- if (neighbor && neighbor.color === targetColor) {
- visited.add(key);
- queue.push({ x: nx, y: ny });
- }
- }
- }
- }
-
- return found;
-}
-
-/**
- * Clamp the camera so at least one colored cell or token remains visible.
- */
-function clampCameraToContent(
- cam: Camera,
- cells: Map,
- tokens: Map,
- canvasW: number,
- canvasH: number,
-) {
- if (cells.size === 0 && tokens.size === 0) return;
-
- const viewLeft = cam.offsetX;
- const viewRight = cam.offsetX + canvasW / cam.zoom;
- const viewTop = cam.offsetY;
- const viewBottom = cam.offsetY + canvasH / cam.zoom;
-
- let anyVisible = false;
-
- cellLoop: for (const cell of cells.values()) {
- if (
- cell.x + 1 > viewLeft &&
- cell.x < viewRight &&
- cell.y + 1 > viewTop &&
- cell.y < viewBottom
- ) {
- anyVisible = true;
- break cellLoop;
- }
- }
-
- if (!anyVisible) {
- for (const tok of tokens.values()) {
- if (
- tok.x + 1 > viewLeft &&
- tok.x < viewRight &&
- tok.y + 1 > viewTop &&
- tok.y < viewBottom
- ) {
- anyVisible = true;
- break;
- }
- }
- }
-
- if (anyVisible) return;
-
- let minX = Infinity,
- maxX = -Infinity;
- let minY = Infinity,
- maxY = -Infinity;
-
- for (const c of cells.values()) {
- if (c.x < minX) minX = c.x;
- if (c.x > maxX) maxX = c.x;
- if (c.y < minY) minY = c.y;
- if (c.y > maxY) maxY = c.y;
- }
- for (const t of tokens.values()) {
- if (t.x < minX) minX = t.x;
- if (t.x > maxX) maxX = t.x;
- if (t.y < minY) minY = t.y;
- if (t.y > maxY) maxY = t.y;
- }
-
- const viewW = canvasW / cam.zoom;
- const viewH = canvasH / cam.zoom;
-
- if (maxX + 1 <= viewLeft) {
- cam.offsetX = maxX;
- } else if (minX >= viewRight) {
- cam.offsetX = minX - viewW + 1;
- }
-
- if (maxY + 1 <= viewTop) {
- cam.offsetY = maxY;
- } else if (minY >= viewBottom) {
- cam.offsetY = minY - viewH + 1;
- }
-}
+export type { GridHandle };
const Grid = forwardRef(function Grid(
{
diff --git a/ui/src/components/Grid/render.ts b/ui/src/components/Grid/render.ts
new file mode 100644
index 0000000..74e95df
--- /dev/null
+++ b/ui/src/components/Grid/render.ts
@@ -0,0 +1,250 @@
+import type { GridCell, GridToken } from "../../types";
+import type { Camera } from "./types";
+import { cellKey, cellToCanvas } from "./utils";
+import { MAX_FLOOD_CELLS } from "./constants";
+
+export function drawToken(
+ ctx: CanvasRenderingContext2D,
+ cellX: number,
+ cellY: number,
+ label: string,
+ color: string,
+ cam: Camera,
+ size: number = 1,
+) {
+ const zoom = cam.zoom;
+ const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
+ // Center of the NxN area
+ const cx = px + (zoom * size) / 2;
+ const cy = py + (zoom * size) / 2;
+ const r = zoom * size * 0.38;
+
+ ctx.shadowColor = "rgba(0,0,0,0.6)";
+ ctx.shadowBlur = 5;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ ctx.strokeStyle = "rgba(255,255,255,0.55)";
+ ctx.lineWidth = Math.max(1, zoom * 0.04);
+ ctx.stroke();
+
+ ctx.shadowBlur = 0;
+
+ if (zoom >= 14) {
+ const words = label.trim().split(/\s+/);
+ const initials =
+ words.length >= 2
+ ? (words[0][0] + words[1][0]).toUpperCase()
+ : label.slice(0, 2).toUpperCase();
+ ctx.fillStyle = "#ffffff";
+ ctx.font = `bold ${Math.round(zoom * size * 0.3)}px system-ui, sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(initials, cx, cy);
+ }
+}
+
+/** Draw a small count badge at the top-right of the token circle. */
+export function drawStackBadge(
+ ctx: CanvasRenderingContext2D,
+ cellX: number,
+ cellY: number,
+ count: number,
+ cam: Camera,
+ size: number = 1,
+) {
+ const zoom = cam.zoom;
+ const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
+ const tokenR = zoom * size * 0.38;
+ // Badge centre: top-right of the token circle
+ const bx = px + (zoom * size) / 2 + tokenR * 0.68;
+ const by = py + (zoom * size) / 2 - tokenR * 0.68;
+ const br = Math.max(5, zoom * 0.18);
+
+ ctx.beginPath();
+ ctx.arc(bx, by, br, 0, Math.PI * 2);
+ ctx.fillStyle = "#ef4444";
+ ctx.fill();
+ ctx.strokeStyle = "#1f2937";
+ ctx.lineWidth = Math.max(1, zoom * 0.025);
+ ctx.stroke();
+
+ if (zoom >= 14) {
+ ctx.fillStyle = "#ffffff";
+ ctx.font = `bold ${Math.round(br * 1.2)}px system-ui, sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(String(count), bx, by);
+ }
+}
+
+/**
+ * BFS flood fill from (startX, startY) through uncolored (empty) cells.
+ */
+export function floodFill(
+ startX: number,
+ startY: number,
+ cells: Map,
+): Array<{ x: number; y: number }> | null {
+ const visited = new Set();
+ const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
+ const found: Array<{ x: number; y: number }> = [];
+
+ visited.add(cellKey(startX, startY));
+
+ const dirs = [
+ { dx: 1, dy: 0 },
+ { dx: -1, dy: 0 },
+ { dx: 0, dy: 1 },
+ { dx: 0, dy: -1 },
+ ];
+
+ while (queue.length > 0) {
+ const { x, y } = queue.shift()!;
+ found.push({ x, y });
+
+ if (found.length > MAX_FLOOD_CELLS) return null;
+
+ for (const { dx, dy } of dirs) {
+ const nx = x + dx;
+ const ny = y + dy;
+ const key = cellKey(nx, ny);
+ if (!visited.has(key) && !cells.has(key)) {
+ visited.add(key);
+ queue.push({ x: nx, y: ny });
+ }
+ }
+ }
+
+ return found;
+}
+
+/**
+ * BFS flood fill from (startX, startY) through cells matching targetColor.
+ * Used for shift+click color replacement.
+ */
+export function floodFillColored(
+ startX: number,
+ startY: number,
+ targetColor: string,
+ cells: Map,
+): Array<{ x: number; y: number }> | null {
+ const visited = new Set();
+ const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
+ const found: Array<{ x: number; y: number }> = [];
+
+ visited.add(cellKey(startX, startY));
+
+ const dirs = [
+ { dx: 1, dy: 0 },
+ { dx: -1, dy: 0 },
+ { dx: 0, dy: 1 },
+ { dx: 0, dy: -1 },
+ ];
+
+ while (queue.length > 0) {
+ const { x, y } = queue.shift()!;
+ found.push({ x, y });
+
+ if (found.length > MAX_FLOOD_CELLS) return null;
+
+ for (const { dx, dy } of dirs) {
+ const nx = x + dx;
+ const ny = y + dy;
+ const key = cellKey(nx, ny);
+ if (!visited.has(key)) {
+ const neighbor = cells.get(key);
+ if (neighbor && neighbor.color === targetColor) {
+ visited.add(key);
+ queue.push({ x: nx, y: ny });
+ }
+ }
+ }
+ }
+
+ return found;
+}
+
+/**
+ * Clamp the camera so at least one colored cell or token remains visible.
+ */
+export function clampCameraToContent(
+ cam: Camera,
+ cells: Map,
+ tokens: Map,
+ canvasW: number,
+ canvasH: number,
+) {
+ if (cells.size === 0 && tokens.size === 0) return;
+
+ const viewLeft = cam.offsetX;
+ const viewRight = cam.offsetX + canvasW / cam.zoom;
+ const viewTop = cam.offsetY;
+ const viewBottom = cam.offsetY + canvasH / cam.zoom;
+
+ let anyVisible = false;
+
+ cellLoop: for (const cell of cells.values()) {
+ if (
+ cell.x + 1 > viewLeft &&
+ cell.x < viewRight &&
+ cell.y + 1 > viewTop &&
+ cell.y < viewBottom
+ ) {
+ anyVisible = true;
+ break cellLoop;
+ }
+ }
+
+ if (!anyVisible) {
+ for (const tok of tokens.values()) {
+ if (
+ tok.x + 1 > viewLeft &&
+ tok.x < viewRight &&
+ tok.y + 1 > viewTop &&
+ tok.y < viewBottom
+ ) {
+ anyVisible = true;
+ break;
+ }
+ }
+ }
+
+ if (anyVisible) return;
+
+ let minX = Infinity,
+ maxX = -Infinity;
+ let minY = Infinity,
+ maxY = -Infinity;
+
+ for (const c of cells.values()) {
+ if (c.x < minX) minX = c.x;
+ if (c.x > maxX) maxX = c.x;
+ if (c.y < minY) minY = c.y;
+ if (c.y > maxY) maxY = c.y;
+ }
+ for (const t of tokens.values()) {
+ if (t.x < minX) minX = t.x;
+ if (t.x > maxX) maxX = t.x;
+ if (t.y < minY) minY = t.y;
+ if (t.y > maxY) maxY = t.y;
+ }
+
+ const viewW = canvasW / cam.zoom;
+ const viewH = canvasH / cam.zoom;
+
+ if (maxX + 1 <= viewLeft) {
+ cam.offsetX = maxX;
+ } else if (minX >= viewRight) {
+ cam.offsetX = minX - viewW + 1;
+ }
+
+ if (maxY + 1 <= viewTop) {
+ cam.offsetY = maxY;
+ } else if (minY >= viewBottom) {
+ cam.offsetY = minY - viewH + 1;
+ }
+}
diff --git a/ui/src/components/Grid/types.ts b/ui/src/components/Grid/types.ts
new file mode 100644
index 0000000..2960586
--- /dev/null
+++ b/ui/src/components/Grid/types.ts
@@ -0,0 +1,34 @@
+import type { MovementRule, Tool } from "../../types";
+
+export interface Camera {
+ offsetX: number;
+ offsetY: number;
+ zoom: number;
+}
+
+export interface UndoAction {
+ revert: () => void;
+ apply: () => void;
+}
+
+export interface Props {
+ mapId: string;
+ tool: Tool;
+ paintColor: string;
+ tokenColor: string;
+ onColorsLoaded: (colors: string[]) => void;
+ /** How many real-world units one grid square represents (default 5). */
+ unitsPerSquare: number;
+ /** Label for the unit, e.g. "ft" or "m". */
+ unitLabel: string;
+ /** Which diagonal movement rule to use. */
+ movementRule: MovementRule;
+ /** Called whenever the undo/redo stack availability changes. */
+ onUndoStateChange?: (canUndo: boolean, canRedo: boolean) => void;
+}
+
+export interface GridHandle {
+ sendColorUpdate: (colors: string[]) => void;
+ undo: () => void;
+ redo: () => void;
+}
diff --git a/ui/src/components/Grid/utils.ts b/ui/src/components/Grid/utils.ts
new file mode 100644
index 0000000..65e2052
--- /dev/null
+++ b/ui/src/components/Grid/utils.ts
@@ -0,0 +1,74 @@
+import type { Camera } from "./types";
+import type { MovementRule } from "../../types";
+
+export function cellKey(x: number, y: number): string {
+ return `${x},${y}`;
+}
+
+export function parseCellKey(key: string): { x: number; y: number } {
+ const [xs, ys] = key.split(",");
+ return { x: Number(xs), y: Number(ys) };
+}
+
+export 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),
+ };
+}
+
+/**
+ * Convert a canvas point to the token anchor whose CENTER is closest to the cursor.
+ * For a size-N token: anchor = round(cursorWorld - N/2).
+ * For size=1 this is equivalent to canvasToCell (Math.floor).
+ */
+export function canvasToCenterAnchor(
+ cx: number,
+ cy: number,
+ size: number,
+ cam: Camera,
+): { x: number; y: number } {
+ const worldX = cx / cam.zoom + cam.offsetX;
+ const worldY = cy / cam.zoom + cam.offsetY;
+ return {
+ x: Math.round(worldX - size / 2),
+ y: Math.round(worldY - size / 2),
+ };
+}
+
+export 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,
+ };
+}
+
+export function clamp(v: number, lo: number, hi: number) {
+ return Math.max(lo, Math.min(hi, v));
+}
+
+/**
+ * Calculate movement distance in grid squares using D&D rules.
+ */
+export function calcDndSquares(
+ dx: number,
+ dy: number,
+ rule: MovementRule,
+): number {
+ const adx = Math.abs(dx);
+ const ady = Math.abs(dy);
+ const straight = Math.abs(adx - ady);
+ const diagonal = Math.min(adx, ady);
+ if (rule === "free") {
+ return straight + diagonal;
+ }
+ return straight + diagonal + Math.floor(diagonal / 2);
+}
diff --git a/ui/src/components/TokenStackPicker.tsx b/ui/src/components/TokenStackPicker.tsx
index f3135a3..4f64fbe 100644
--- a/ui/src/components/TokenStackPicker.tsx
+++ b/ui/src/components/TokenStackPicker.tsx
@@ -59,7 +59,7 @@ export default function TokenStackPicker({
className="stack-picker-item stack-picker-move-all"
onClick={onMoveStack}
>
- Move entire stack
+ Move stack
>