Files
DndGamePlayer/app/renderer/sceneEditor/SceneEditorApp.tsx
T
Ivan Fontosh 877fbaa8e7 feat: save games and edit scene darkness
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:41:42 +08:00

1930 lines
75 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,
Scene,
SceneDarknessBrushShape,
SceneDarknessDraft,
SceneDarknessRevealStroke,
SceneDarknessState,
SceneGrid,
SceneGridTemplate,
SceneId,
SceneNpcToken,
SceneToken,
SceneTrap,
SceneTrapType,
TokenId,
} from '../../shared/types';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
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 sameDarknessStrokes(
a: readonly SceneDarknessRevealStroke[],
b: readonly SceneDarknessRevealStroke[],
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.id !== right.id || left.points.length !== right.points.length) return false;
if (left.radiusN !== right.radiusN || left.mode !== right.mode || left.shape !== right.shape) {
return false;
}
}
return true;
}
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 [darknessOpen, setDarknessOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = useState(false);
const [localDarken, setLocalDarken] = useState(false);
const [localDarknessStrokes, setLocalDarknessStrokes] = useState<SceneDarknessRevealStroke[]>([]);
const [darknessDraft, setDarknessDraft] = useState<SceneDarknessDraft | null>(null);
const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none');
const [darknessRadiusN, setDarknessRadiusN] = useState(0.08);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushRef = useRef<{
points: { x: number; y: number; tMs: number }[];
createdAtMs: number;
seed: number;
} | null>(null);
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const darknessCursorPosRef = useRef<{ x: number; y: number } | null>(null);
const darknessToolRef = useRef(darknessTool);
const darknessRadiusNRef = useRef(darknessRadiusN);
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessToolRef.current = darknessTool;
darknessRadiusNRef.current = darknessRadiusN;
darknessBrushShapeRef.current = darknessBrushShape;
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 contentRectRef = useRef(contentRect);
contentRectRef.current = contentRect;
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 saveDarknessTimerRef = useRef(0);
const pendingDarknessStrokesRef = useRef<SceneDarknessRevealStroke[] | null>(null);
const darknessDirtyRef = useRef(false);
const spaceDownRef = useRef(false);
const sceneIdRef = useRef<SceneId | null>(null);
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
sceneIdRef.current = sceneId;
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[]>([]);
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
useEffect(() => {
trapsRef.current = localTraps;
tokensRef.current = localTokens;
npcTokensRef.current = localNpcTokens;
}, [localNpcTokens, localTokens, localTraps]);
useEffect(() => {
localDarknessStrokesRef.current = localDarknessStrokes;
}, [localDarknessStrokes]);
useEffect(() => {
darknessDirtyRef.current = false;
pendingDarknessStrokesRef.current = null;
if (saveDarknessTimerRef.current) {
window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = 0;
}
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 });
setLocalDarken(Boolean(scene?.darkenScene));
const strokes = scene?.darknessStrokes ?? [];
localDarknessStrokesRef.current = strokes;
setLocalDarknessStrokes(strokes);
setDarknessDraft(null);
setDarknessTool('none');
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(() => {
// IPC session clones arrays — do not clobber unsaved local darkness edits.
if (darknessBrushRef.current || darknessDirtyRef.current || saveDarknessTimerRef.current) return;
setLocalDarken(Boolean(scene?.darkenScene));
const incoming = scene?.darknessStrokes ?? [];
if (sameDarknessStrokes(incoming, localDarknessStrokesRef.current)) return;
localDarknessStrokesRef.current = incoming;
setLocalDarknessStrokes(incoming);
}, [scene?.darkenScene, scene?.darknessStrokes]);
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 mergeSceneIntoSession = useCallback((updated: Scene) => {
setSession((prev) => {
if (!prev?.project?.scenes[updated.id]) return prev;
return {
...prev,
project: {
...prev.project,
scenes: { ...prev.project.scenes, [updated.id]: updated },
},
};
});
}, []);
const flushDarknessStrokes = useCallback(async () => {
const sid = sceneIdRef.current;
const payload = pendingDarknessStrokesRef.current;
if (saveDarknessTimerRef.current) {
window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = 0;
}
if (!sid || !payload) return;
pendingDarknessStrokesRef.current = null;
try {
const res = await api.invoke(ipcChannels.project.updateScene, {
sceneId: sid,
patch: { darknessStrokes: payload },
});
darknessDirtyRef.current = false;
mergeSceneIntoSession(res.scene);
} catch {
// Keep dirty so a later flush / session sync can retry from local ref.
pendingDarknessStrokesRef.current = payload;
darknessDirtyRef.current = true;
}
}, [api, mergeSceneIntoSession]);
const persistDarken = useCallback(
(next: boolean) => {
const sid = sceneIdRef.current;
if (!sid) return;
setLocalDarken(next);
if (!next) setDarknessTool('none');
darknessDirtyRef.current = true;
void (async () => {
await flushDarknessStrokes();
try {
const res = await api.invoke(ipcChannels.project.updateScene, {
sceneId: sid,
patch: { darkenScene: next },
});
darknessDirtyRef.current = false;
mergeSceneIntoSession(res.scene);
} catch {
darknessDirtyRef.current = true;
}
})();
},
[api, flushDarknessStrokes, mergeSceneIntoSession],
);
const persistDarknessStrokes = useCallback(
(next: SceneDarknessRevealStroke[]) => {
if (!sceneIdRef.current) return;
localDarknessStrokesRef.current = next;
setLocalDarknessStrokes(next);
pendingDarknessStrokesRef.current = next;
darknessDirtyRef.current = true;
if (saveDarknessTimerRef.current) window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = window.setTimeout(() => {
void flushDarknessStrokes();
}, 120);
},
[flushDarknessStrokes],
);
const flushDarknessStrokesRef = useRef(flushDarknessStrokes);
flushDarknessStrokesRef.current = flushDarknessStrokes;
useEffect(() => {
const flush = () => {
void flushDarknessStrokesRef.current();
};
const onVis = () => {
if (document.hidden) flush();
};
window.addEventListener('blur', flush);
document.addEventListener('visibilitychange', onVis);
return () => {
window.removeEventListener('blur', flush);
document.removeEventListener('visibilitychange', onVis);
flush();
};
}, []);
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 `${t(localGrid.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · ${String(sizeLabel)}`;
}, [localGrid.sizeN, localGrid.type, t]);
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 layoutDarknessBrushCursor = () => {
const el = brushCursorElRef.current;
const p = darknessCursorPosRef.current;
const cr = contentRectRef.current;
if (!el) return;
if (!p || !cr || darknessToolRef.current === 'none') {
el.style.visibility = 'hidden';
return;
}
el.style.visibility = 'visible';
el.className = [
styles.brushCursor,
darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
]
.filter(Boolean)
.join(' ');
const minDim = Math.min(cr.w, cr.h);
const size = Math.max(1, darknessRadiusNRef.current * minDim * 2);
el.style.left = `${String(cr.x + p.x * cr.w)}px`;
el.style.top = `${String(cr.y + p.y * cr.h)}px`;
el.style.width = `${String(size)}px`;
el.style.height = `${String(size)}px`;
};
useEffect(() => {
layoutDarknessBrushCursor();
}, [darknessBrushShape, darknessRadiusN, darknessTool, contentRect]);
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;
const darknessOverlayState = useMemo((): SceneDarknessState | null => {
if (!localDarken) return null;
return {
revision: 1,
enabled: true,
cacheKey: sceneId,
strokes: localDarknessStrokes,
draft: darknessDraft,
};
}, [darknessDraft, localDarken, localDarknessStrokes, sceneId]);
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}>
{t(tpl.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · {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>
<div className={styles.accordion} data-testid="scene-darkness-accordion">
<button
type="button"
className={styles.accordionHead}
onClick={() => setDarknessOpen((v) => !v)}
>
{t('sceneEditor.darkness')} {darknessOpen ? '▾' : '▸'}
</button>
{darknessOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localDarken}
disabled={!hasMapMedia}
onChange={(e) => persistDarken(e.target.checked)}
data-testid="scene-darken-toggle"
/>
<span>{t('scene.darkenScene')}</span>
</label>
{localDarken && hasMapMedia ? (
<>
<div className={styles.darknessTools}>
<Button
variant={darknessTool === 'explore' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explorerBrush')}
ariaLabel={t('control.explorerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'explore' ? 'none' : 'explore'))
}
>
<span aria-hidden>🔦</span>
</Button>
<Button
variant={darknessTool === 'close' ? 'primary' : 'ghost'}
iconOnly
title={t('control.closerBrush')}
ariaLabel={t('control.closerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'close' ? 'none' : 'close'))
}
>
<span aria-hidden>⬛</span>
</Button>
</div>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('control.brushRadius')}{' '}
<span className={styles.fieldValue}>
{Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
</span>
</span>
<input
type="range"
className={styles.range}
min={1}
max={36}
step={1}
value={Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
onChange={(e) => {
const ui = Number(e.currentTarget.value);
setDarknessRadiusN(Math.max(1, Math.min(36, Math.round(ui))) * 0.005);
}}
/>
</label>
<div className={styles.darknessTools}>
<Button
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setDarknessBrushShape('circle')}
>
<span aria-hidden>○</span>
</Button>
<Button
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setDarknessBrushShape('square')}
>
<span aria-hidden>□</span>
</Button>
</div>
</>
) : null}
</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} />
{localDarken && contentRect ? (
<SceneDarknessOverlay
state={darknessOverlayState}
overlayAlpha={0.55}
viewport={contentRect}
style={{ zIndex: 28 }}
/>
) : null}
{localDarken && darknessTool !== 'none' && contentRect ? (
<>
<div
ref={brushCursorElRef}
className={styles.brushCursor}
style={{ visibility: 'hidden' }}
aria-hidden
/>
<div
className={styles.darknessBrushLayer}
style={{
left: contentRect.x,
top: contentRect.y,
width: contentRect.w,
height: contentRect.h,
}}
onPointerEnter={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}}
onPointerLeave={() => {
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.preventDefault();
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
setDarknessDraft({
points: [pt],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerMove={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (p) {
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}
const brush = darknessBrushRef.current;
if (!brush || (e.buttons & 1) === 0) return;
if (!p) return;
const last = brush.points[brush.points.length - 1];
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brush.points.push(pt);
setDarknessDraft({
points: [...brush.points],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerUp={() => {
const brush = darknessBrushRef.current;
darknessBrushRef.current = null;
setDarknessDraft(null);
if (!brush || brush.points.length === 0) return;
const stroke: SceneDarknessRevealStroke = {
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
seed: brush.seed,
createdAtMs: brush.createdAtMs,
points: brush.points,
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
};
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
}}
onPointerCancel={() => {
darknessBrushRef.current = null;
setDarknessDraft(null);
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
/>
</>
) : null}
<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>
);
}