e.preventDefault()}
onDrop={onStageDrop}
onPointerDown={(e) => {
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
}
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d) return;
if (d.kind === 'pan') {
const cr = contentRect;
if (!cr) return;
const dx = e.clientX - d.lastX;
const dy = e.clientY - d.lastY;
d.lastX = e.clientX;
d.lastY = e.clientY;
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewPanBy(v, { containW, containH, dx, dy });
});
return;
}
if (d.kind === 'moveTrap') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateTrap(d.trapId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeTrap') {
const p = hostToNorm(e.clientX, e.clientY);
const trap = trapsRef.current.find((t) => t.id === d.trapId);
if (!p || !trap) return;
const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateTrap(d.trapId, {
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
});
return;
}
if (d.kind === 'moveToken') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateToken(d.tokenId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeToken') {
const p = hostToNorm(e.clientX, e.clientY);
const tok = tokensRef.current.find((t) => t.id === d.tokenId);
if (!p || !tok) return;
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateToken(d.tokenId, {
sizeN: clampSceneTokenSizeN(d.startSize * ratio),
});
return;
}
if (d.kind === 'moveNpcToken') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateNpcToken(d.tokenId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeNpcToken') {
const p = hostToNorm(e.clientX, e.clientY);
const tok = npcTokensRef.current.find((t) => t.id === d.tokenId);
if (!p || !tok) return;
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateNpcToken(d.tokenId, {
sizeN: clampSceneNpcTokenSizeN(d.startSize * ratio),
});
return;
}
if (d.kind === 'rotateToken') {
const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
const delta = shortestAngleDelta(d.startPointerAngle, ang);
updateToken(d.tokenId, { rotationDeg: d.startRotation + delta });
}
}}
onPointerUp={() => {
dragRef.current = null;
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
{isImage ? (
) : (
)}
{localDarken && contentRect ? (
) : null}
{localDarken && darknessTool !== 'none' && contentRect ? (
<>
{
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}}
onPointerLeave={() => {
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.preventDefault();
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
setDarknessDraft({
points: [pt],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerMove={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (p) {
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}
const brush = darknessBrushRef.current;
if (!brush || (e.buttons & 1) === 0) return;
if (!p) return;
const last = brush.points[brush.points.length - 1];
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brush.points.push(pt);
setDarknessDraft({
points: [...brush.points],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerUp={() => {
const brush = darknessBrushRef.current;
darknessBrushRef.current = null;
setDarknessDraft(null);
if (!brush || brush.points.length === 0) return;
const stroke: SceneDarknessRevealStroke = {
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
seed: brush.seed,
createdAtMs: brush.createdAtMs,
points: brush.points,
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
};
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
}}
onPointerCancel={() => {
darknessBrushRef.current = null;
setDarknessDraft(null);
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
/>
>
) : null}
{contentRect
? localTokens.map((tok) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
setSelected({ kind: 'token', id: tok.id })}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setSelected({ kind: 'token', id: tok.id });
setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveToken',
tokenId: tok.id,
startNx: tok.nx,
startNy: tok.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
onResizePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
onRotatePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const host = hostRef.current;
if (!host || !contentRect) return;
const r = host.getBoundingClientRect();
const cx = r.left + contentRect.x + tok.nx * contentRect.w;
const cy = r.top + contentRect.y + tok.ny * contentRect.h;
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'rotateToken',
tokenId: tok.id,
startRotation: tok.rotationDeg,
startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY),
centerClientX: cx,
centerClientY: cy,
};
}}
/>
);
})
: null}
{USERS_BRANCH_FEATURES_ENABLED && contentRect
? localNpcTokens.map((tok) => {
const npc = project?.npcs.find((item) => item.id === tok.npcId);
if (!npc) return null;
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
setSelected({ kind: 'npcToken', id: tok.id })}
onContextMenu={(e) => {
setSelected({ kind: 'npcToken', id: tok.id });
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveNpcToken',
tokenId: tok.id,
startNx: tok.nx,
startNy: tok.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
onResizePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeNpcToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
/>
);
})
: null}
{contentRect
? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, trap.sizeN * minDim);
const left = contentRect.x + trap.nx * contentRect.w;
const top = contentRect.y + trap.ny * contentRect.h;
const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
return (
{
if (e.button !== 0 || spaceDownRef.current) return;
e.stopPropagation();
setSelected({ kind: 'trap', id: trap.id });
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveTrap',
trapId: trap.id,
startNx: trap.nx,
startNy: trap.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
>
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
{trap.label}
) : null}
{isSelected ? (
{
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeTrap',
trapId: trap.id,
startSize: trap.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
};
}}
/>
) : null}
);
})
: null}
)}
t.name)}
onClose={() => setTokenModal(null)}
onSaved={() => setTokenModal(null)}
/>
{pendingDeleteToken
? createPortal(
<>