P20 fix: crisp device-resolution map rendering + live draw previews

Render the map on a single viewport-sized canvas with a camera transform at
devicePixelRatio (image, fog, drawings, overlay) and draw the grid as crisp 1px
device-space lines — instead of CSS-scaling a natural-resolution bitmap, which
blurred the grid/fog/text at any zoom. Tokens now position in screen space so
their labels stay sharp too. Add live tool feedback via a 'hover' phase: polygon
shows its outline + vertices + rubber-band + fill preview, the brush shows its
footprint, and circle/square AoE preview under the cursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:56:28 +02:00
parent 360d9ff842
commit 2879059ba5
3 changed files with 236 additions and 137 deletions
+14 -15
View File
@@ -46,23 +46,22 @@ test('maps: zoom controls scale the canvas and fit resets', async ({ page }) =>
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
const world = page.getByTestId('map-world');
await expect(world).toBeVisible();
// World layer is sized to the image's natural pixels (not shrunk to a cap).
await expect(world).toHaveCSS('width', '512px');
const scaleOf = async () => {
const t = await world.evaluate((el) => getComputedStyle(el).transform);
const m = /matrix\(([^,]+)/.exec(t);
return m ? Number(m[1]) : 1;
};
const before = await scaleOf();
await page.getByRole('button', { name: 'Zoom in' }).click();
await page.getByRole('button', { name: 'Zoom in' }).click();
expect(await scaleOf()).toBeGreaterThan(before);
const viewport = page.getByTestId('map-viewport');
await expect(viewport).toBeVisible();
const scaleOf = async () => Number(await viewport.getAttribute('data-zoom'));
// Let the initial fit settle, then zooming in must increase the zoom…
await page.getByRole('button', { name: 'Fit map to view' }).click();
expect(await scaleOf()).toBeCloseTo(before, 1);
const fitZoom = await scaleOf();
expect(fitZoom).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Zoom in' }).click();
await page.getByRole('button', { name: 'Zoom in' }).click();
const zoomedIn = await scaleOf();
expect(zoomedIn).toBeGreaterThan(fitZoom);
// …and Fit brings it back to the fit value.
await page.getByRole('button', { name: 'Fit map to view' }).click();
expect(await scaleOf()).toBeCloseTo(fitZoom, 5);
});
test('maps: place a party token from the palette', async ({ page }) => {
+205 -118
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { Point, Viewport } from '@/lib/map';
import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map';
import { fitViewport, zoomToPoint } from '@/lib/map';
import { cn } from '@/lib/cn';
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
@@ -41,36 +41,46 @@ export interface Overlay {
line?: { a: Point; b: Point } | undefined;
label?: { at: Point; text: string } | undefined;
draft?: CanvasDrawing | undefined;
/** in-progress polygon: committed vertices + a rubber-band point at the cursor */
poly?: { points: Point[]; cursor?: Point | undefined } | undefined;
/** brush footprint at the cursor (radius in cells) */
brush?: { col: number; row: number; size: number } | undefined;
}
type Phase = 'down' | 'move' | 'up' | 'hover';
interface Props {
view: CanvasView;
/** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */
/** CSS height of the clip viewport (e.g. 'calc(100vh - 14rem)') */
viewportHeight?: string;
readOnly?: boolean;
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
playerFog?: boolean;
overlay?: Overlay;
onPointer?: (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => void;
onPointer?: (phase: Phase, p: { point: Point; col: number; row: number }) => void;
onLeave?: () => void;
onTokenMove?: (id: string, col: number, row: number) => void;
onTokenClick?: (id: string) => void;
tokensDraggable?: boolean;
onReady?: (info: { cols: number; rows: number }) => void;
}
const MAX_DPR = 2.5;
/**
* Shared map renderer. The image + all layers live in a "world" div sized to the
* image's natural pixels; a CSS transform (translate+scale) provides zoom/pan so
* large maps render crisp at full resolution instead of being shrunk to fit.
* Shared map renderer. A single canvas draws the image + grid + fog + drawings +
* overlay with a camera transform (translate+scale) at the device's pixel
* resolution, so everything stays crisp at any zoom (unlike CSS-scaling a
* natural-resolution bitmap). Tokens are DOM, in a matching transformed layer.
*/
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
const [size, setSize] = useState({ w: 0, h: 0 });
const outerRef = useRef<HTMLDivElement>(null);
const worldRef = useRef<HTMLDivElement>(null);
const fogRef = useRef<HTMLCanvasElement>(null);
const drawRef = useRef<HTMLCanvasElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const fittedRef = useRef<string | undefined>(undefined);
const gridSize = view.gridSize;
const W = natural?.w ?? 0;
@@ -78,24 +88,35 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
const cols = natural ? Math.ceil(W / gridSize) : 0;
const rows = natural ? Math.ceil(H / gridSize) : 0;
// Load the image bitmap (used both for sizing and for canvas drawImage).
useEffect(() => {
if (!view.image) { setNatural(null); return; }
if (!view.image) { setNatural(null); imgRef.current = null; return; }
const img = new Image();
img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); };
img.src = view.image;
}, [view.image]);
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
// Fit the image into the viewport whenever a new image loads.
const fit = useCallback(() => {
// Track the viewport's CSS size.
useEffect(() => {
const el = outerRef.current;
if (!el || !natural) return;
setVp(fitViewport(el.clientWidth, el.clientHeight, natural.w, natural.h));
}, [natural]);
useEffect(() => { fit(); }, [fit]);
if (!el) return;
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Wheel zoom (native, non-passive so we can prevent page scroll).
// Fit once per image, as soon as both the image and viewport size are known.
useEffect(() => {
if (!natural || size.w === 0 || fittedRef.current === view.image) return;
fittedRef.current = view.image;
setVp(fitViewport(size.w, size.h, natural.w, natural.h));
}, [natural, size, view.image]);
// Wheel zoom (native + non-passive so the page doesn't scroll).
useEffect(() => {
const el = outerRef.current;
if (!el) return;
@@ -108,62 +129,25 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
return () => el.removeEventListener('wheel', onWheel);
}, []);
// Fog
// Redraw whenever camera/content/size changes (rAF-coalesced).
useEffect(() => {
const cv = fogRef.current;
if (!cv || !natural) return;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, W, H);
if (!view.fogEnabled) return;
const revealed = new Set(view.revealed);
ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * gridSize, r * gridSize, gridSize + 0.5, gridSize + 0.5);
}
}, [view.revealed, view.fogEnabled, playerFog, W, H, gridSize, cols, rows, natural]);
// Drawings
useEffect(() => {
const cv = drawRef.current;
if (!cv || !natural) return;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, W, H);
for (const d of view.drawings) drawShape(ctx, d);
}, [view.drawings, W, H, natural]);
// Overlay (AoE/measure/draft)
useEffect(() => {
const cv = overlayRef.current;
if (!cv || !natural) return;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, W, H);
if (overlay?.cells?.length) {
ctx.fillStyle = 'rgba(212,175,55,0.35)';
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * gridSize, r! * gridSize, gridSize, gridSize); }
}
if (overlay?.draft) drawShape(ctx, overlay.draft);
if (overlay?.line) {
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(overlay.line.a.x, overlay.line.a.y); ctx.lineTo(overlay.line.b.x, overlay.line.b.y); ctx.stroke();
}
if (overlay?.label) {
ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
const x = overlay.label.at.x + 6, y = overlay.label.at.y - 6;
ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
}
}, [overlay, W, H, gridSize, natural]);
const raf = requestAnimationFrame(() => {
drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
vp, W, H, gridSize, cols, rows,
showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed,
playerFog: !!playerFog, drawings: view.drawings, overlay,
});
});
return () => cancelAnimationFrame(raf);
});
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
const rect = (worldRef.current ?? (e.currentTarget as HTMLElement)).getBoundingClientRect();
const pt = worldFromScreen(vp.zoom, e.clientX, e.clientY, rect.left, rect.top);
return { point: pt, col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) };
const r = outerRef.current!.getBoundingClientRect();
const x = (e.clientX - r.left - vp.panX) / vp.zoom;
const y = (e.clientY - r.top - vp.panY) / vp.zoom;
return { point: { x, y }, col: Math.floor(x / gridSize), row: Math.floor(y / gridSize) };
};
// Pan (drag) state. Pan happens on middle/right button always, or on primary
// button when no tool owns the canvas (move tool / read-only player view).
const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null);
const interactive = !readOnly && !!onPointer;
@@ -179,51 +163,40 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) }));
return;
}
if (interactive && e.buttons) onPointer!('move', toPoint(e));
if (interactive) onPointer!(e.buttons ? 'move' : 'hover', toPoint(e));
};
const ixUp = (e: React.PointerEvent) => {
if (pan.current) { pan.current = null; return; }
if (interactive) onPointer!('up', toPoint(e));
};
const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2));
const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); };
if (!view.image) return <p className="text-sm text-muted">No image.</p>;
const zoomCentered = (factor: number) => {
const el = outerRef.current; if (!el) return;
setVp((v) => zoomToPoint(v, factor, el.clientWidth / 2, el.clientHeight / 2));
};
return (
<div ref={outerRef} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
{!natural ? (
<p className="p-3 text-sm text-muted">Loading map</p>
) : (
<div
ref={worldRef}
data-testid="map-world"
className="absolute left-0 top-0"
style={{ width: W, height: H, transform: `translate(${vp.panX}px, ${vp.panY}px) scale(${vp.zoom})`, transformOrigin: '0 0', willChange: 'transform' }}
>
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
{view.showGrid && (
<div className="pointer-events-none absolute inset-0" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
backgroundSize: `${gridSize}px ${gridSize}px`,
}} />
)}
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<div
className="absolute inset-0"
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
onPointerDown={ixDown}
onPointerMove={ixMove}
onPointerUp={ixUp}
onContextMenu={(e) => e.preventDefault()}
/>
<div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
{!natural && <p className="p-3 text-sm text-muted">Loading map</p>}
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
{/* pointer surface (below tokens so token drags win; tokens are small) */}
<div
className="absolute inset-0"
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
onPointerDown={ixDown}
onPointerMove={ixMove}
onPointerUp={ixUp}
onPointerLeave={() => { if (interactive) onLeave?.(); }}
onContextMenu={(e) => e.preventDefault()}
/>
{/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */}
{natural && (
<div className="pointer-events-none absolute inset-0">
{view.tokens.map((t) => (
<TokenChip key={t.id} token={t} gridSize={gridSize} zoom={vp.zoom} cols={cols} rows={rows}
<TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
))}
</div>
@@ -234,13 +207,125 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1 / 1.25)} aria-label="Zoom out"></button>
<span className="w-10 text-center tabular-nums text-muted">{Math.round(vp.zoom * 100)}%</span>
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1.25)} aria-label="Zoom in">+</button>
<button className="grid h-6 px-1.5 place-items-center rounded hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit"></button>
<button className="grid h-6 place-items-center rounded px-1.5 hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit"></button>
</div>
)}
</div>
);
}
interface SceneParams {
vp: Viewport;
W: number; H: number; gridSize: number; cols: number; rows: number;
showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean;
drawings: CanvasDrawing[]; overlay?: Overlay | undefined;
}
/** Draw the whole scene at device resolution with a camera transform. */
function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | null, vw: number, vh: number, p: SceneParams): void {
if (!canvas || vw === 0 || vh === 0) return;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr);
if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; }
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { zoom, panX, panY } = p.vp;
// Camera maps world px → CSS px → device px. Device transform = dpr only.
const cam = () => ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr);
const dev = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dev();
ctx.clearRect(0, 0, vw, vh);
if (!img) return;
// Map image
cam();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, p.W, p.H);
// Fog (world space)
if (p.fogEnabled) {
const revealed = new Set(p.revealed);
ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)';
for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) {
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * p.gridSize, r * p.gridSize, p.gridSize + 0.5, p.gridSize + 0.5);
}
}
// Grid (device space → crisp 1px lines)
if (p.showGrid) {
const step = p.gridSize * zoom;
if (step >= 4) {
dev();
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1;
ctx.beginPath();
const x0 = Math.max(0, panX), x1 = Math.min(vw, panX + p.W * zoom);
const y0 = Math.max(0, panY), y1 = Math.min(vh, panY + p.H * zoom);
for (let c = Math.max(0, Math.floor((-panX) / step)); c <= p.cols; c++) {
const x = Math.round(panX + c * step) + 0.5;
if (x < x0 - 1 || x > x1 + 1) continue;
ctx.moveTo(x, y0); ctx.lineTo(x, y1);
}
for (let r = Math.max(0, Math.floor((-panY) / step)); r <= p.rows; r++) {
const y = Math.round(panY + r * step) + 0.5;
if (y < y0 - 1 || y > y1 + 1) continue;
ctx.moveTo(x0, y); ctx.lineTo(x1, y);
}
ctx.stroke();
}
}
// Drawings (world space; crisp under the camera transform)
cam();
for (const d of p.drawings) drawShape(ctx, d);
// Overlay (AoE/measure/draft/poly/brush)
const o = p.overlay;
if (o) {
cam();
if (o.cells?.length) {
ctx.fillStyle = 'rgba(212,175,55,0.35)';
for (const k of o.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * p.gridSize, r! * p.gridSize, p.gridSize, p.gridSize); }
}
if (o.draft) drawShape(ctx, o.draft);
if (o.brush) {
ctx.strokeStyle = 'rgba(212,175,55,0.95)';
ctx.lineWidth = 2 / zoom;
const s = o.brush.size;
ctx.strokeRect((o.brush.col - s) * p.gridSize, (o.brush.row - s) * p.gridSize, (s * 2 + 1) * p.gridSize, (s * 2 + 1) * p.gridSize);
}
if (o.poly && o.poly.points.length) {
const pts = o.poly.points;
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2 / zoom;
ctx.beginPath();
ctx.moveTo(pts[0]!.x, pts[0]!.y);
for (const pt of pts.slice(1)) ctx.lineTo(pt.x, pt.y);
if (o.poly.cursor) ctx.lineTo(o.poly.cursor.x, o.poly.cursor.y);
if (pts.length >= 2) ctx.closePath();
ctx.stroke();
ctx.fillStyle = '#fff';
for (const pt of pts) { ctx.beginPath(); ctx.arc(pt.x, pt.y, 4 / zoom, 0, Math.PI * 2); ctx.fill(); }
}
if (o.line) {
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom;
ctx.beginPath(); ctx.moveTo(o.line.a.x, o.line.a.y); ctx.lineTo(o.line.b.x, o.line.b.y); ctx.stroke();
}
if (o.label) {
// label in screen space → fixed, readable size at any zoom
dev();
ctx.font = '600 13px sans-serif';
ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = 3;
const x = panX + o.label.at.x * zoom + 8, y = panY + o.label.at.y * zoom - 8;
ctx.strokeText(o.label.text, x, y); ctx.fillText(o.label.text, x, y);
}
}
dev();
}
/** Draw a vector annotation in world coordinates (crisp under the camera). */
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
const p = d.points;
@@ -268,14 +353,16 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
ctx.stroke();
}
function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: {
token: CanvasToken; gridSize: number; zoom: number; cols: number; rows: number;
function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: {
token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number;
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
}) {
const ref = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
const [drift, setDrift] = useState({ dx: 0, dy: 0 });
const span = gridSize * token.size;
const { zoom, panX, panY } = vp;
const span = gridSize * token.size * zoom; // on-screen px
const box = span * 0.92;
const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
const down = (e: React.PointerEvent) => {
@@ -293,9 +380,8 @@ function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onCli
ref.current?.releasePointerCapture(e.pointerId);
const moved = drag.current.moved;
if (moved && onMove) {
// screen-px drift → world-px (÷zoom) → cell delta (÷gridSize)
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / zoom / gridSize)));
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / zoom / gridSize)));
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
onMove(token.id, col, row);
} else if (!moved && onClick) onClick(token.id);
drag.current = null; setDrift({ dx: 0, dy: 0 });
@@ -307,11 +393,12 @@ function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onCli
onPointerDown={down} onPointerMove={move} onPointerUp={up}
title={token.label}
data-testid="map-token"
className={cn('absolute grid place-items-center rounded-full border-2 border-black/50 text-[11px] font-bold text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
className={cn('pointer-events-auto absolute grid place-items-center rounded-full border-2 border-black/50 font-bold leading-none text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
style={{
width: span * 0.92, height: span * 0.92,
left: token.col * gridSize + span * 0.04 + drift.dx / zoom,
top: token.row * gridSize + span * 0.04 + drift.dy / zoom,
width: box, height: box,
left: panX + token.col * gridSize * zoom + span * 0.04 + drift.dx,
top: panY + token.row * gridSize * zoom + span * 0.04 + drift.dy,
fontSize: Math.max(8, Math.min(22, span * 0.3)),
background: token.color,
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
touchAction: 'none',
+17 -4
View File
@@ -68,11 +68,21 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const commitReveal = (cells: string[], reveal: boolean) =>
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
const onPointer = (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => {
const onPointer = (phase: 'down' | 'move' | 'up' | 'hover', p: { point: Point; col: number; row: number }) => {
// Live previews when no button is held.
if (phase === 'hover') {
if (tool === 'reveal' || tool === 'hide') {
if (fogShape === 'brush') setOverlay({ brush: { col: p.col, row: p.row, size: brush } });
else if (fogShape === 'poly') setOverlay({ poly: { points: poly, cursor: p.point }, ...(poly.length >= 2 ? { cells: polygonCells([...poly, p.point], gridSpec()) } : {}) });
} else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) {
setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) });
}
return;
}
const reveal = tool === 'reveal';
if (tool === 'reveal' || tool === 'hide') {
if (fogShape === 'brush') {
if (phase !== 'up') commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal);
if (phase !== 'up') { commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal); setOverlay({ brush: { col: p.col, row: p.row, size: brush } }); }
} else if (fogShape === 'rect') {
if (phase === 'down') anchor.current = p.point;
else if (anchor.current) {
@@ -82,7 +92,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
else { commitReveal(cells, reveal); anchor.current = null; setOverlay({}); }
}
} else if (fogShape === 'poly') {
if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ cells: polygonCells(next, gridSpec()) }); }
if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ poly: { points: next, cursor: p.point }, ...(next.length >= 3 ? { cells: polygonCells(next, gridSpec()) } : {}) }); }
}
return;
}
@@ -138,6 +148,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
// Clear transient hover previews when the cursor leaves, but keep an in-progress polygon.
const onLeave = () => setOverlay(poly.length ? { poly: { points: poly } } : {});
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
@@ -213,7 +226,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
overlay={overlay}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer } : {})}
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}