Files
Ivan Fontosh 1ab6ffd593 feat(tokens): animated paths for scene and NPC tokens
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 08:48:41 +08:00

1255 lines
48 KiB
TypeScript
Raw Permalink 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 { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { ContainedVideo } from '../shared/ContainedVideo';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage';
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
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 { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';
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 [tokenCtxMenu, setTokenCtxMenu] = 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 (USERS_BRANCH_FEATURES_ENABLED && 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);
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
const hasMapMedia = isImage || isVideo;
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<EllipsisText
text={scene?.title ?? 'Сцена'}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/>
<div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное.
</div>
<div className={styles.accordionScroll}>
<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>
{USERS_BRANCH_FEATURES_ENABLED ? (
<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>
) : null}
<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>
</div>
</aside>
<div className={styles.stage}>
{!hasMapMedia ? (
<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;
}}
>
{isImage ? (
<RotatedImage
url={url!}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
) : (
<ContainedVideo
url={url!}
rotationDeg={rot}
muted
playsInline
loop
preload="metadata"
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
)}
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
<TokenPathsOverlay
tokens={localTokens}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
viewport={contentRect}
mode="always"
/>
{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();
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 (
<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}
{tokenCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setTokenCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: tokenCtxMenu.x, top: tokenCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const id = tokenCtxMenu.placementId;
setTokenCtxMenu(null);
void api
.invoke(ipcChannels.windows.openTokenPathEditor, {
kind: 'token',
placementId: id,
})
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
</button>
{(() => {
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
if (!tok?.path || tok.path.points.length < 2) return null;
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = sampleTokenPathAtDistance(tok.path!, 0);
if (sample) {
updateToken(tok.id, {
nx: sample.nx,
ny: sample.ny,
...(tok.path?.facingMode === 'fixed'
? { rotationDeg: tok.path.fixedRotationDeg }
: { rotationDeg: sample.rotationDeg }),
});
}
setTokenCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
);
})()}
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = tokenCtxMenu.placementId;
setTokenCtxMenu(null);
persistTokens(tokensRef.current.filter((item) => item.id !== id));
setSelected((current) =>
current?.kind === 'token' && current.id === id ? null : current,
);
}}
>
{t('common.delete')}
</button>
</div>
</>,
document.body,
)
: null}
{USERS_BRANCH_FEATURES_ENABLED && 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.ctxItem}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
void api
.invoke(ipcChannels.windows.openTokenPathEditor, {
kind: 'npcToken',
placementId: id,
})
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
if (!tok?.path || tok.path.points.length < 2) return null;
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = sampleTokenPathAtDistance(tok.path!, 0);
if (sample) {
updateNpcToken(tok.id, { nx: sample.nx, ny: sample.ny });
}
setNpcCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
);
})()}
<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>
);
}