Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
@@ -287,6 +287,34 @@
cursor: grabbing;
}
.npcTile {
display: grid;
justify-content: center;
overflow: hidden;
padding: 7px 4px;
border: 1px solid var(--stroke, #2a2f3a);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
cursor: grab;
}
.sceneNpcToken {
position: absolute;
z-index: 2;
overflow: visible;
transform: translate(-50%, -50%);
cursor: move;
touch-action: none;
}
.sceneNpcToken > * {
pointer-events: none;
}
.sceneNpcToken .handle {
pointer-events: auto;
}
.tokenThumb {
aspect-ratio: 1;
border-radius: 6px;
@@ -452,3 +480,49 @@
.toolbar button {
font-size: 12px;
}
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem,
.ctxItemDanger {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItemDanger {
color: var(--color-danger, #e57373);
}
.ctxItem:hover,
.ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06);
}
+377 -19
View File
@@ -2,7 +2,27 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types';
import type {
NpcDisposition,
NpcId,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneToken,
SceneTrap,
SceneTrapType,
TokenId,
} from '../../shared/types';
import {
asSceneNpcTokenId,
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
} from '../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from '../../shared/types/npcDisposition';
import {
asSceneTokenId,
asTokenId,
@@ -14,6 +34,7 @@ import {
DEFAULT_SCENE_GRID,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
@@ -24,21 +45,28 @@ import {
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { SceneTokenMarker } from './SceneTokenMarker';
import styles from './SceneEditorApp.module.css';
import { SceneTokenMarker } from './SceneTokenMarker';
import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
@@ -47,14 +75,37 @@ function isTypingTarget(el: EventTarget | null): boolean {
}
type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token'; id: string } | null;
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: '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: '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;
@@ -80,27 +131,116 @@ function shortestAngleDelta(fromDeg: number, toDeg: number): number {
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 [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>(
null,
);
const [npcSearch, setNpcSearch] = useState('');
const [tokenModal, setTokenModal] = useState<
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
>(null);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [npcCtxMenu, setNpcCtxMenu] = useState<{
x: number;
y: number;
placementId: string;
} | null>(null);
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const [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);
@@ -111,15 +251,24 @@ export function SceneEditorApp() {
const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
trapsRef.current = localTraps;
tokensRef.current = localTokens;
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 });
@@ -136,6 +285,12 @@ export function SceneEditorApp() {
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 });
@@ -176,6 +331,19 @@ export function SceneEditorApp() {
[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;
@@ -196,8 +364,10 @@ export function SceneEditorApp() {
e.preventDefault();
if (selected.kind === 'trap') {
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
} else {
} 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);
}
@@ -212,7 +382,7 @@ export function SceneEditorApp() {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [persistTraps, persistTokens, sceneId, selected]);
}, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current;
@@ -286,6 +456,24 @@ export function SceneEditorApp() {
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);
@@ -295,6 +483,11 @@ export function SceneEditorApp() {
addTokenAt(asTokenId(tokenId), p.x, p.y);
return;
}
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
if (npcId) {
addNpcTokenAt(npcId as NpcId, p.x, p.y);
return;
}
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return;
addTrapAt(type, p.x, p.y);
@@ -308,13 +501,24 @@ export function SceneEditorApp() {
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 editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null;
const filteredNpcs = useMemo(() => {
const q = npcSearch.trim().toLowerCase();
const npcs = project?.npcs ?? [];
return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
}, [npcSearch, project?.npcs]);
const editingToken =
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return (
@@ -396,6 +600,28 @@ export function SceneEditorApp() {
) : null}
</div>
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'}
</button>
{npcsOpen ? (
<div className={styles.tokensPanel}>
<Input
value={npcSearch}
onChange={setNpcSearch}
placeholder="Поиск НПС…"
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={npc} />
))}
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'}
@@ -409,7 +635,6 @@ export function SceneEditorApp() {
value={tokenSearch}
onChange={setTokenSearch}
placeholder="Поиск…"
autoFocus={tokensOpen}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
@@ -457,10 +682,14 @@ export function SceneEditorApp() {
<div className={styles.clearSceneBtn}>
<Button
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
data-testid="scene-clear-btn"
disabled={
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
}
onClick={() => {
persistTraps([]);
persistTokens([]);
persistNpcTokens([]);
setSelected(null);
}}
>
@@ -543,6 +772,26 @@ export function SceneEditorApp() {
});
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);
@@ -635,6 +884,60 @@ export function SceneEditorApp() {
);
})
: null}
{contentRect
? localNpcTokens.map((tok) => {
const npc = project?.npcs.find((item) => item.id === tok.npcId);
if (!npc) return null;
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
<SceneNpcMarker
key={tok.id}
placement={tok}
npc={npc}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
onContextMenu={(e) => {
setSelected({ kind: 'npcToken', id: tok.id });
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveNpcToken',
tokenId: tok.id,
startNx: tok.nx,
startNy: tok.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
onResizePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeNpcToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
/>
);
})
: null}
{contentRect
? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h);
@@ -645,7 +948,9 @@ export function SceneEditorApp() {
return (
<div
key={trap.id}
className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
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;
@@ -744,6 +1049,59 @@ export function SceneEditorApp() {
document.body,
)
: null}
{npcCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
setSelected((current) => (current?.id === id ? null : current));
}}
>
{t('common.delete')}
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
if (!tok || !npc) return null;
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
return otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateNpcToken(tok.id, { disposition: d });
setNpcCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
));
})()}
</div>
</>,
document.body,
)
: null}
</div>
);
}