Files
DndGamePlayer/app/renderer/sceneEditor/SceneEditorApp.tsx
T
Ivan Fontosh cc50e64e21 feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:11:01 +08:00

1099 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type {
NpcDisposition,
NpcId,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneToken,
SceneTrap,
SceneTrapType,
TokenId,
} from '../../shared/types';
import {
asSceneNpcTokenId,
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
} from '../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from '../../shared/types/npcDisposition';
import {
asSceneTokenId,
asTokenId,
clampSceneTokenSizeN,
DEFAULT_SCENE_TOKEN_SIZE_N,
} from '../../shared/types/appTokens';
import {
clampSceneGridSizeN,
DEFAULT_SCENE_GRID,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES,
trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './SceneEditorApp.module.css';
import { SceneTokenMarker } from './SceneTokenMarker';
import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
}
type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token' | 'npcToken'; id: string } | null;
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| {
kind: 'moveTrap';
trapId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
| {
kind: 'moveToken';
tokenId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
| {
kind: 'moveNpcToken';
tokenId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number }
| {
kind: 'rotateToken';
tokenId: string;
startRotation: number;
startPointerAngle: number;
centerClientX: number;
centerClientY: number;
}
| null;
function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number {
return (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id';
function SceneNpcMarker({
placement,
npc,
left,
top,
sizePx,
selected,
onSelect,
onContextMenu,
onMovePointerDown,
onResizePointerDown,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
left: number;
top: number;
sizePx: number;
selected: boolean;
onSelect: () => void;
onContextMenu: (e: React.MouseEvent) => void;
onMovePointerDown: (e: React.PointerEvent) => void;
onResizePointerDown: (e: React.PointerEvent) => void;
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
return (
<div
data-testid={`scene-npc-token-${placement.id}`}
className={[styles.sceneNpcToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e);
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
onSelect();
onMovePointerDown(e);
}}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
/>
{selected ? <div className={styles.handle} onPointerDown={onResizePointerDown} /> : null}
</div>
);
}
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition);
return (
<div
className={styles.npcTile}
data-testid={`scene-npc-tile-${npc.id}`}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id);
e.dataTransfer.effectAllowed = 'copy';
}}
title={npc.name}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={70}
/>
</div>
);
}
export function SceneEditorApp() {
const api = getDndApi();
const { t } = useEditorI18n();
const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = useState(false);
const [tokenSearch, setTokenSearch] = useState('');
const [npcSearch, setNpcSearch] = useState('');
const [tokenModal, setTokenModal] = useState<
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
>(null);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [npcCtxMenu, setNpcCtxMenu] = useState<{
x: number;
y: number;
placementId: string;
} | null>(null);
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0);
const saveNpcTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0);
const spaceDownRef = useRef(false);
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]);
useEffect(() => {
trapsRef.current = localTraps;
tokensRef.current = localTokens;
npcTokensRef.current = localNpcTokens;
}, [localNpcTokens, localTokens, localTraps]);
useEffect(() => {
setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalNpcTokens(
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
);
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]);
useEffect(() => {
if (dragRef.current) return;
setLocalTraps(scene?.traps ?? []);
}, [scene?.traps]);
useEffect(() => {
if (dragRef.current) return;
const known = new Set(appTokens.map((t) => t.id));
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
}, [scene?.tokens, appTokens]);
useEffect(() => {
if (dragRef.current) return;
const known = new Set((project?.npcs ?? []).map((npc) => npc.id));
setLocalNpcTokens((scene?.npcTokens ?? []).filter((t) => known.has(t.npcId)));
}, [scene?.npcTokens, project?.npcs]);
useEffect(() => {
if (dragRef.current) return;
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [scene?.grid]);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const persistTraps = useCallback(
(next: SceneTrap[]) => {
if (!sceneId) return;
setLocalTraps(next);
trapsRef.current = next;
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
saveTrapsTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
}, 120);
},
[api, sceneId],
);
const persistTokens = useCallback(
(next: SceneToken[]) => {
if (!sceneId) return;
setLocalTokens(next);
tokensRef.current = next;
if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
saveTokensTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } });
}, 120);
},
[api, sceneId],
);
const persistNpcTokens = useCallback(
(next: SceneNpcToken[]) => {
if (!sceneId) return;
setLocalNpcTokens(next);
npcTokensRef.current = next;
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
saveNpcTokensTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens: next } });
}, 120);
},
[api, sceneId],
);
const persistGrid = useCallback(
(next: SceneGrid) => {
if (!sceneId) return;
setLocalGrid(next);
if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
saveGridTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { grid: next } });
}, 120);
},
[api, sceneId],
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = true;
if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) {
e.preventDefault();
if (selected.kind === 'trap') {
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
} else if (selected.kind === 'token') {
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
} else {
persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id));
}
setSelected(null);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current;
const cr = contentRect;
if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
const r = host.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
};
};
const viewCamera = useMemo(() => view, [view]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const nativeWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const cr = contentRect;
if (!cr) {
setView((v) => {
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
return { ...v, scale: nextScale };
});
return;
}
const r = host.getBoundingClientRect();
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewZoomAt(v, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
});
};
host.addEventListener('wheel', nativeWheel, { passive: false });
return () => host.removeEventListener('wheel', nativeWheel);
}, [contentRect]);
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
const trap: SceneTrap = {
id: asSceneTrapId(randomId('trap')),
type,
nx,
ny,
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
};
setSelected({ kind: 'trap', id: trap.id });
persistTraps([...trapsRef.current, trap]);
};
const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => {
const placement: SceneToken = {
id: asSceneTokenId(randomId('stoken')),
tokenId,
nx,
ny,
sizeN: DEFAULT_SCENE_TOKEN_SIZE_N,
rotationDeg: 0,
};
setSelected({ kind: 'token', id: placement.id });
persistTokens([...tokensRef.current, placement]);
};
const addNpcTokenAt = (npcId: NpcId, nx: number, ny: number) => {
const gridCellSize =
localGrid.enabled && Number.isFinite(localGrid.sizeN)
? clampSceneNpcTokenSizeN(localGrid.sizeN)
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
const npc = project?.npcs.find((item) => item.id === npcId);
const placement: SceneNpcToken = {
id: asSceneNpcTokenId(randomId('snpc')),
npcId,
nx,
ny,
sizeN: gridCellSize,
disposition: normalizeNpcDisposition(npc?.disposition),
};
setSelected({ kind: 'npcToken', id: placement.id });
persistNpcTokens([...npcTokensRef.current, placement]);
};
const onStageDrop = (e: React.DragEvent) => {
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME);
if (tokenId) {
addTokenAt(asTokenId(tokenId), p.x, p.y);
return;
}
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
if (npcId) {
addNpcTokenAt(npcId as NpcId, p.x, p.y);
return;
}
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return;
addTrapAt(type, p.x, p.y);
};
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const updateToken = (id: string, patch: Partial<SceneToken>) => {
persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const updateNpcToken = (id: string, patch: Partial<SceneNpcToken>) => {
persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const filteredTokens = useMemo(() => {
const q = tokenSearch.trim().toLowerCase();
if (!q) return appTokens;
return appTokens.filter((t) => t.name.toLowerCase().includes(q));
}, [appTokens, tokenSearch]);
const filteredNpcs = useMemo(() => {
const q = npcSearch.trim().toLowerCase();
const npcs = project?.npcs ?? [];
return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
}, [npcSearch, project?.npcs]);
const editingToken =
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
<div className={styles.hint}>Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное.</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'}
</button>
{gridOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localGrid.enabled}
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
/>
<span>Наложить сетку</span>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span>
<Select
disabled={!localGrid.enabled}
value={localGrid.type}
ariaLabel="Тип сетки"
options={[
{ value: 'square', label: sceneGridTypeLabelRu('square') },
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
]}
onChange={(next) =>
persistGrid({
...localGrid,
type: next === 'hex' ? 'hex' : 'square',
})
}
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span>
<input
type="color"
className={styles.colorInput}
disabled={!localGrid.enabled}
value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label="Цвет сетки"
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>
Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
</span>
<input
type="range"
className={styles.range}
disabled={!localGrid.enabled}
min={SCENE_GRID_SIZE_MIN}
max={SCENE_GRID_SIZE_MAX}
step={0.005}
value={localGrid.sizeN}
onChange={(e) =>
persistGrid({
...localGrid,
sizeN: clampSceneGridSizeN(Number(e.currentTarget.value)),
})
}
/>
</label>
</div>
) : null}
</div>
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'}
</button>
{npcsOpen ? (
<div className={styles.tokensPanel}>
<Input
value={npcSearch}
onChange={setNpcSearch}
placeholder="Поиск НПС…"
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={npc} />
))}
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'}
</button>
{tokensOpen ? (
<div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
</div>
<Input
value={tokenSearch}
onChange={setTokenSearch}
placeholder="Поиск…"
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? (
<div className={styles.hint}>Нет токенов</div>
) : (
filteredTokens.map((token) => (
<TokenTile
key={token.id}
token={token}
onEdit={() => setTokenModal({ mode: 'edit', tokenId: token.id })}
onDelete={() => setPendingDeleteToken({ id: token.id, name: token.name })}
/>
))
)}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'}
</button>
{trapsOpen ? (
<div className={styles.palette}>
{SCENE_TRAP_TYPES.map((type) => (
<div
key={type}
className={styles.paletteItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
>
<TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
</div>
))}
</div>
) : null}
</div>
<div className={styles.clearSceneBtn}>
<Button
data-testid="scene-clear-btn"
disabled={
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
}
onClick={() => {
persistTraps([]);
persistTokens([]);
persistNpcTokens([]);
setSelected(null);
}}
>
Очистить сцену
</Button>
</div>
</aside>
<div className={styles.stage}>
{!isImage ? (
<div className={styles.empty}>Нужно изображение сцены</div>
) : (
<div
ref={hostRef}
className={styles.viewport}
onDragOver={(e) => 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;
}}
>
<RotatedImage
url={url!}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
{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 (
<SceneTokenMarker
key={tok.id}
token={tok}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'token' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'token', id: tok.id })}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
}}
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}
{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 (
<SceneNpcMarker
key={tok.id}
placement={tok}
npc={npc}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
onSelect={() => 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 (
<div
key={trap.id}
className={[styles.trap, isSelected ? styles.trapSelected : '']
.filter(Boolean)
.join(' ')}
style={{ left, top, width: sizePx, height: sizePx }}
onPointerDown={(e) => {
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,
};
}}
>
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
<div className={styles.trapLabel}>{trap.label}</div>
) : null}
{isSelected ? (
<div
className={styles.handle}
onPointerDown={(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: 'resizeTrap',
trapId: trap.id,
startSize: trap.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
};
}}
/>
) : null}
</div>
);
})
: null}
</div>
)}
</div>
<TokenEditModal
open={tokenModal !== null}
initial={editingToken}
existingNames={appTokens.map((t) => t.name)}
onClose={() => setTokenModal(null)}
onSaved={() => setTokenModal(null)}
/>
{pendingDeleteToken
? createPortal(
<>
<button
type="button"
aria-label="Закрыть"
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>Удалить токен</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteToken(null)}
>
×
</button>
</div>
<div>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteToken.id;
setPendingDeleteToken(null);
void api.invoke(ipcChannels.tokens.delete, { id });
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
}}
>
Удалить
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{npcCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
setSelected((current) => (current?.id === id ? null : current));
}}
>
{t('common.delete')}
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
if (!tok || !npc) return null;
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
return otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateNpcToken(tok.id, { disposition: d });
setNpcCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
));
})()}
</div>
</>,
document.body,
)
: null}
</div>
);
}