Files
DndGamePlayer/app/renderer/sceneEditor/SceneEditorApp.tsx
T
Ivan Fontosh 060bbd471a feat(ui): confirm ending a show and surface help
End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 08:10:54 +08:00

1528 lines
59 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 {
GridTemplateId,
NpcDisposition,
NpcId,
ProjectNpc,
SceneGrid,
SceneGridTemplate,
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,
MAX_SCENE_GRID_TEMPLATES,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
} from '../../shared/types/sceneGrid';
import {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES,
} 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 GridTemplateApplyIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 12.5 10 17.5 19 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GridTemplateRenameIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M4 20h4.2L19.5 8.7a1.8 1.8 0 0 0 0-2.5L17.8 4.5a1.8 1.8 0 0 0-2.5 0L4 15.8V20Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M13.5 6.5 17.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function GridTemplateDeleteIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 7h14M10 7V5h4v2M8 7l.8 12h6.4L16 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
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 [templateNameDraft, setTemplateNameDraft] = useState('');
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [renamingTemplateId, setRenamingTemplateId] = useState<GridTemplateId | null>(null);
const [renameDraft, setRenameDraft] = useState('');
const [pendingDeleteTemplate, setPendingDeleteTemplate] = useState<SceneGridTemplate | null>(null);
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],
);
const gridTemplates = project?.gridTemplates ?? [];
const defaultTemplateName = useMemo(() => {
const sizeLabel = Math.round(localGrid.sizeN * 100);
return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`;
}, [localGrid.sizeN, localGrid.type]);
const saveGridTemplate = async () => {
if (!sceneId) return;
const name = (templateNameDraft.trim() || defaultTemplateName).trim();
if (!name) return;
if (gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES) return;
await api.invoke(ipcChannels.project.addGridTemplate, {
name,
type: localGrid.type,
sizeN: localGrid.sizeN,
color: localGrid.color,
});
setSaveTemplateOpen(false);
setTemplateNameDraft('');
};
const applyGridTemplate = async (templateId: GridTemplateId) => {
if (!sceneId) return;
if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
await api.invoke(ipcChannels.project.applyGridTemplate, { sceneId, templateId });
};
const commitRenameTemplate = async () => {
if (!renamingTemplateId) return;
const name = renameDraft.trim();
if (!name) {
setRenamingTemplateId(null);
return;
}
await api.invoke(ipcChannels.project.renameGridTemplate, {
templateId: renamingTemplateId,
name,
});
setRenamingTemplateId(null);
};
const confirmDeleteTemplate = async () => {
if (!pendingDeleteTemplate) return;
const id = pendingDeleteTemplate.id;
setPendingDeleteTemplate(null);
await api.invoke(ipcChannels.project.deleteGridTemplate, { templateId: id });
};
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 ?? t('sceneEditor.untitledScene')}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/>
<div className={styles.hint}>
{t('sceneEditor.mapHint')}
</div>
<div className={styles.accordionScroll}>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
{t('sceneEditor.grid')} {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>{t('sceneEditor.enableGrid')}</span>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>{t('sceneEditor.gridType')}</span>
<Select
disabled={!localGrid.enabled}
value={localGrid.type}
ariaLabel={t('sceneEditor.gridTypeAria')}
options={[
{ value: 'square', label: t('sceneEditor.gridSquare') },
{ value: 'hex', label: t('sceneEditor.gridHex') },
]}
onChange={(next) =>
persistGrid({
...localGrid,
type: next === 'hex' ? 'hex' : 'square',
})
}
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>{t('sceneEditor.gridColor')}</span>
<input
type="color"
className={styles.colorInput}
disabled={!localGrid.enabled}
value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label={t('sceneEditor.gridColorAria')}
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>
{t('sceneEditor.gridSize')} <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 className={styles.templatesBlock}>
<div className={styles.templatesTitle}>{t('sceneGrid.templates')}</div>
{saveTemplateOpen ? (
<div className={styles.templateSaveRow}>
<Input
value={templateNameDraft}
onChange={setTemplateNameDraft}
placeholder={defaultTemplateName}
data-testid="grid-template-name"
/>
<div className={styles.templateSaveActions}>
<Button
variant="primary"
disabled={gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => void saveGridTemplate()}
data-testid="grid-template-save-confirm"
>
{t('common.save')}
</Button>
<Button
variant="ghost"
onClick={() => {
setSaveTemplateOpen(false);
setTemplateNameDraft('');
}}
>
{t('common.cancel')}
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
disabled={!sceneId || gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => {
setTemplateNameDraft(defaultTemplateName);
setSaveTemplateOpen(true);
}}
data-testid="grid-template-save"
title={
gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES
? t('sceneGrid.templatesFull', { max: MAX_SCENE_GRID_TEMPLATES })
: undefined
}
>
{t('sceneGrid.saveTemplate')}
</Button>
)}
{gridTemplates.length === 0 ? (
<div className={styles.templatesListSection}>
<div className={styles.templatesEmpty}>{t('sceneGrid.emptyTemplates')}</div>
</div>
) : (
<div className={styles.templatesListSection}>
<div className={styles.templatesList}>
{gridTemplates.map((tpl) => (
<div key={tpl.id} className={styles.templateRow} data-testid={`grid-template-${tpl.id}`}>
<span
className={styles.templateSwatch}
style={{ background: tpl.color }}
aria-hidden
/>
<div className={styles.templateMeta}>
{renamingTemplateId === tpl.id ? (
<Input
value={renameDraft}
onChange={setRenameDraft}
onBlur={() => void commitRenameTemplate()}
data-testid="grid-template-rename-input"
/>
) : (
<div className={styles.templateName}>{tpl.name}</div>
)}
<div className={styles.templateHint}>
{sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
</div>
</div>
<div className={styles.templateActions}>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.applyTemplate')}
ariaLabel={t('sceneGrid.applyTemplate')}
onClick={() => void applyGridTemplate(tpl.id)}
data-testid={`grid-template-apply-${tpl.id}`}
>
<GridTemplateApplyIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.renameTemplate')}
ariaLabel={t('sceneGrid.renameTemplate')}
onClick={() => {
setRenamingTemplateId(tpl.id);
setRenameDraft(tpl.name);
}}
>
<GridTemplateRenameIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.deleteTemplate')}
ariaLabel={t('sceneGrid.deleteTemplate')}
onClick={() => setPendingDeleteTemplate(tpl)}
>
<GridTemplateDeleteIcon />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</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)}>
{t('sceneEditor.npcs')} {npcsOpen ? '▾' : '▸'}
</button>
{npcsOpen ? (
<div className={styles.tokensPanel}>
<Input
value={npcSearch}
onChange={setNpcSearch}
placeholder={t('sceneEditor.npcSearch')}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={npc} />
))}
{filteredNpcs.length === 0 ? <div className={styles.hint}>{t('sceneEditor.noNpcs')}</div> : null}
</div>
</div>
) : null}
</div>
) : null}
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
{t('sceneEditor.tokens')} {tokensOpen ? '▾' : '▸'}
</button>
{tokensOpen ? (
<div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>{t('sceneEditor.add')}</Button>
</div>
<Input
value={tokenSearch}
onChange={setTokenSearch}
placeholder={t('sceneEditor.tokenSearch')}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? (
<div className={styles.hint}>{t('sceneEditor.noTokens')}</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)}>
{t('sceneEditor.traps')} {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={t('sceneEditor.dragToMap')}
>
<TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{t(`sceneEditor.trap.${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={() => {
if (!sceneId) return;
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
saveTrapsTimerRef.current = 0;
saveTokensTimerRef.current = 0;
saveNpcTokensTimerRef.current = 0;
setLocalTraps([]);
setLocalTokens([]);
setLocalNpcTokens([]);
trapsRef.current = [];
tokensRef.current = [];
npcTokensRef.current = [];
setSelected(null);
// Single atomic patch — separate persist* calls raced and restored tokens/traps.
void api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: { traps: [], tokens: [], npcTokens: [] },
});
}}
>
{t('sceneEditor.clearScene')}
</Button>
</div>
</div>
</aside>
<div className={styles.stage}>
{!hasMapMedia ? (
<div className={styles.empty}>{t('sceneEditor.needMedia')}</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={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('sceneEditor.deleteTokenTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteToken(null)}
>
×
</button>
</div>
<div>
{t('sceneEditor.deleteTokenBody', { name: pendingDeleteToken.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>{t('common.cancel')}</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));
}}
>
{t('common.delete')}
</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));
}}
>
{t('sceneEditor.setPath')}
</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);
}}
>
{t('sceneEditor.resetPath')}
</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));
}}
>
{t('sceneEditor.setPath')}
</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);
}}
>
{t('sceneEditor.resetPath')}
</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}
{pendingDeleteTemplate
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteTemplate(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="grid-template-delete-confirm"
className={editorStyles.modalDialog}
>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('sceneGrid.deleteTemplateTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalClose}
onClick={() => setPendingDeleteTemplate(null)}
>
×
</button>
</div>
<div className={editorStyles.muted}>
{t('sceneGrid.deleteTemplateConfirm', { name: pendingDeleteTemplate.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteTemplate(null)}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={() => void confirmDeleteTemplate()}>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}