Files
Ivan Fontosh cbb6edc378 chore(release): gate users-branch features for hotfix
Add USERS_BRANCH_FEATURES_ENABLED (off by default) to hide Players library, session tokens, and related UI while keeping the preview import fix. Restore primary Run button when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:15:28 +08:00

1159 lines
42 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type {
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import type { GraphGroupFilter } from './NpcGraph';
import { NpcGroupModal } from './NpcGroupModal';
import {
flattenGroupOptions,
moveNpcToGroupEnd,
reorderNpcIds,
reorderSiblingGroups,
} from './npcListHelpers';
import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css';
const NpcGraph = lazy(async () => {
const mod = await import('./NpcGraph');
return { default: mod.NpcGraph };
});
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
type GroupModalState =
| { mode: 'create'; parentId: NpcGroupId | null }
| { mode: 'edit'; group: ProjectNpcGroup };
function NpcTile({
npc,
selected,
dragging,
dropPlace,
accentColor,
onSelect,
onMenu,
onDragStart,
onDragEnd,
onDragOver,
onDropReorder,
}: {
npc: ProjectNpc;
selected: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
accentColor?: string | null;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (place: 'before' | 'after') => void;
onDropReorder: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId);
return (
<div
className={[
styles.tile,
selected ? styles.tileSelected : '',
dragging ? styles.tileDragging : '',
dropPlace === 'before' ? styles.tileDropBefore : '',
dropPlace === 'after' ? styles.tileDropAfter : '',
]
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
e.dataTransfer.effectAllowed = 'move';
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(e) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onDragOver(e.clientY < mid ? 'before' : 'after');
}}
onDrop={(e) => {
e.preventDefault();
onDropReorder();
}}
>
<button type="button" className={styles.tileBody} onClick={onSelect}>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
<button
type="button"
className={styles.tileMenuBtn}
data-npc-menu-root="1"
aria-label={t('npcs.tileMenu')}
onClick={onMenu}
>
</button>
</div>
);
}
function GroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
dragId,
dropPlace,
dragGroupId,
groupDropPlace,
dropTargetGroup,
groupColorById,
onSelectNpc,
onNpcMenu,
onGroupMenu,
onNpcDragStart,
onNpcDragEnd,
onNpcDragOver,
onNpcDropReorder,
onGroupDragStart,
onGroupDragEnd,
onGroupDragOver,
onGroupDropReorder,
onGroupBodyDragOver,
onGroupBodyDrop,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
dragId: NpcId | null;
dropPlace: { id: NpcId; place: 'before' | 'after' } | null;
dragGroupId: NpcGroupId | null;
groupDropPlace: { id: NpcGroupId; place: 'before' | 'after' } | null;
dropTargetGroup: NpcGroupId | null;
groupColorById: Map<NpcGroupId, string>;
onSelectNpc: (id: NpcId) => void;
onNpcMenu: (id: NpcId, e: React.MouseEvent<HTMLButtonElement>) => void;
onGroupMenu: (id: NpcGroupId, e: React.MouseEvent<HTMLButtonElement>) => void;
onNpcDragStart: (id: NpcId) => void;
onNpcDragEnd: () => void;
onNpcDragOver: (id: NpcId, place: 'before' | 'after') => void;
onNpcDropReorder: (targetId: NpcId) => void;
onGroupDragStart: (id: NpcGroupId) => void;
onGroupDragEnd: () => void;
onGroupDragOver: (id: NpcGroupId, place: 'before' | 'after') => void;
onGroupDropReorder: (targetId: NpcGroupId) => void;
onGroupBodyDragOver: (groupId: NpcGroupId) => void;
onGroupBodyDrop: (groupId: NpcGroupId) => void;
}) {
const { t } = useEditorI18n();
const g = node.group;
const expanded = isExpanded(g.id);
const isGroupDragging = dragGroupId === g.id;
const groupDropBefore = groupDropPlace?.id === g.id && groupDropPlace.place === 'before';
const groupDropAfter = groupDropPlace?.id === g.id && groupDropPlace.place === 'after';
const bodyHighlight = dropTargetGroup === g.id && dragId !== null;
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div
className={[
styles.groupHeader,
isGroupDragging ? styles.groupHeaderDragging : '',
groupDropBefore ? styles.groupDropBefore : '',
groupDropAfter ? styles.groupDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_GROUP_ID_MIME, g.id);
e.dataTransfer.effectAllowed = 'move';
onGroupDragStart(g.id);
}}
onDragEnd={onGroupDragEnd}
onDragOver={(e) => {
if (dragGroupId && dragGroupId !== g.id) {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onGroupDragOver(g.id, e.clientY < mid ? 'before' : 'after');
return;
}
if (dragId) {
e.preventDefault();
onGroupBodyDragOver(g.id);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (dragGroupId && dragGroupId !== g.id && groupDropPlace?.id === g.id) {
onGroupDropReorder(g.id);
return;
}
if (dragId) onGroupBodyDrop(g.id);
}}
>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
<button
type="button"
className={styles.groupMenuBtn}
data-npc-menu-root="1"
aria-label={t('npcs.tileMenu')}
onClick={(e) => onGroupMenu(g.id, e)}
>
</button>
</div>
{expanded ? (
<div
className={[styles.groupBody, bodyHighlight ? styles.groupBodyDrop : ''].filter(Boolean).join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDragOver(g.id);
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDrop(g.id);
}}
>
{node.npcs.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
accentColor={groupColorById.get(g.id) ?? g.color}
onSelect={() => onSelectNpc(n.id)}
onMenu={(e) => onNpcMenu(n.id, e)}
onDragStart={() => onNpcDragStart(n.id)}
onDragEnd={onNpcDragEnd}
onDragOver={(place) => onNpcDragOver(n.id, place)}
onDropReorder={() => onNpcDropReorder(n.id)}
/>
))}
{node.children.map((child) => (
<GroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={onSelectNpc}
onNpcMenu={onNpcMenu}
onGroupMenu={onGroupMenu}
onNpcDragStart={onNpcDragStart}
onNpcDragEnd={onNpcDragEnd}
onNpcDragOver={onNpcDragOver}
onNpcDropReorder={onNpcDropReorder}
onGroupDragStart={onGroupDragStart}
onGroupDragEnd={onGroupDragEnd}
onGroupDragOver={onGroupDragOver}
onGroupDropReorder={onGroupDropReorder}
onGroupBodyDragOver={onGroupBodyDragOver}
onGroupBodyDrop={onGroupBodyDrop}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsEditorApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
const [editOpen, setEditOpen] = useState(false);
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
const [groupMenuFor, setGroupMenuFor] = useState<NpcGroupId | null>(null);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [dragId, setDragId] = useState<NpcId | null>(null);
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
const [dragGroupId, setDragGroupId] = useState<NpcGroupId | null>(null);
const [groupDropPlace, setGroupDropPlace] = useState<{
id: NpcGroupId;
place: 'before' | 'after';
} | null>(null);
const [dropTargetGroup, setDropTargetGroup] = useState<NpcGroupId | 'ungrouped' | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [groupModal, setGroupModal] = useState<GroupModalState | null>(null);
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState('');
const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
| { mode: 'edit'; relationId: NpcRelationId; label: string }
| null
>(null);
const [pendingDeleteRelation, setPendingDeleteRelation] = useState<ProjectNpcRelation | null>(null);
const [avatarBusy, setAvatarBusy] = useState(false);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
const groups = project?.npcGroups ?? [];
setExpandedGroups(new Set(groups.map((g) => g.id)));
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const project = session?.project ?? null;
const npcs = useMemo(() => project?.npcs ?? [], [project?.npcs]);
const npcGroups = useMemo(() => project?.npcGroups ?? [], [project?.npcGroups]);
const relations = useMemo(() => project?.npcRelations ?? [], [project?.npcRelations]);
const selected = npcs.find((n) => n.id === selectedId) ?? null;
useEffect(() => {
setNameDraft(selected?.name ?? '');
}, [selected?.id, selected?.name]);
useEffect(() => {
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
setSelectedId(npcs[0]?.id ?? null);
}, [npcs, selectedId]);
useEffect(() => {
if (!menuFor && !groupMenuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
setMenuFor(null);
setGroupMenuFor(null);
setMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [groupMenuFor, menuFor]);
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const { roots, ungrouped } = useMemo(
() => buildNpcGroupForest(npcGroups, filteredNpcs),
[filteredNpcs, npcGroups],
);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const groupOptions = useMemo(() => flattenGroupOptions(roots), [roots]);
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
.filter((r) => r.sourceNpcId === selected.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { relation: r, otherName: other?.name ?? '—' };
});
}, [npcs, relations, selected]);
const pickAvatar = useCallback(async () => {
const res = await api.invoke(ipcChannels.project.pickNpcAvatar, {});
if (res.canceled) return null;
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]);
const commitNpcGroupChange = useCallback(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId);
if (npc && npc.groupId !== groupId) {
await api.invoke(ipcChannels.project.updateNpcFields, { npcId, groupId });
}
if (orderIds) {
await api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: orderIds });
}
},
[api, npcs],
);
const handleNpcDropOnTile = useCallback(
(targetId: NpcId) => {
if (!dragId || dropPlace?.id !== targetId || dragId === targetId) return;
const targetNpc = npcs.find((n) => n.id === targetId);
if (!targetNpc) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const newGroupId = targetNpc.groupId;
const orderIds = reorderNpcIds(
dragNpc.groupId === newGroupId
? npcs
: npcs.map((n) => (n.id === dragId ? { ...n, groupId: newGroupId } : n)),
dragId,
targetId,
dropPlace.place,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, newGroupId, orderIds);
},
[commitNpcGroupChange, dragId, dropPlace, npcs],
);
const handleNpcDropOnGroup = useCallback(
(groupId: NpcGroupId | null) => {
if (!dragId) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const orderIds = moveNpcToGroupEnd(
dragNpc.groupId === groupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n)),
dragId,
groupId,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, groupId, orderIds);
},
[commitNpcGroupChange, dragId, npcs],
);
const handleGroupDropReorder = useCallback(
(targetId: NpcGroupId) => {
if (!dragGroupId || !groupDropPlace || dragGroupId === groupDropPlace.id) return;
const order = reorderSiblingGroups(npcGroups, dragGroupId, targetId, groupDropPlace.place);
setDragGroupId(null);
setGroupDropPlace(null);
void api.invoke(ipcChannels.project.setNpcGroupsOrder, { groupIds: order });
},
[api, dragGroupId, groupDropPlace, npcGroups],
);
const openMenuAt = (e: React.MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 120;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const top = r.bottom + 8 + menuH > window.innerHeight - 8 ? Math.max(8, r.top - menuH - 8) : r.bottom + 8;
setMenuPos({ left, top });
};
const graphUi = useMemo(
() => ({
zoomBar: t('npcs.graphZoomBar'),
zoomIn: t('npcs.graphZoomIn'),
zoomOut: t('npcs.graphZoomOut'),
fitAll: t('npcs.graphFitAll'),
editRelation: t('npcs.relationEdit'),
deleteRelation: t('npcs.relationDelete'),
untitled: t('npcs.untitled'),
graphFilter: t('npcs.graphFilter'),
graphFilterAll: t('npcs.graphFilterAll'),
graphFilterUngrouped: t('npcs.graphFilterUngrouped'),
}),
[t],
);
const isExpanded = (id: NpcGroupId) => expandedGroups.has(id);
const toggleExpanded = (id: NpcGroupId) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const siblingNamesForGroup = (parentId: NpcGroupId | null, exceptId?: NpcGroupId) =>
npcGroups.filter((g) => g.parentId === parentId && g.id !== exceptId).map((g) => g.name);
const hasListContent = npcGroups.length > 0 || ungrouped.length > 0;
return (
<div className={styles.page}>
<div className={styles.topBar}>
<div className={styles.topTitle}>{t('npcs.editorTitle')}</div>
<Button
onClick={() => {
void api.invoke(ipcChannels.windows.closeNpcsEditor, {});
}}
>
{t('common.close')}
</Button>
</div>
<div className={styles.body}>
<div className={[styles.col, styles.side].join(' ')}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.sideActions}>
<Button
variant="primary"
onClick={() => {
setEditInitial(null);
setEditOpen(true);
}}
>
{t('npcs.add')}
</Button>
<Button
onClick={() => {
setGroupModal({ mode: 'create', parentId: null });
}}
>
{t('npcs.addGroup')}
</Button>
</div>
<div className={styles.list}>
{roots.map((node) => (
<GroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup === 'ungrouped' ? null : dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={setSelectedId}
onNpcMenu={(id, e) => {
openMenuAt(e);
setGroupMenuFor(null);
setMenuFor((cur) => (cur === id ? null : id));
}}
onGroupMenu={(id, e) => {
openMenuAt(e);
setMenuFor(null);
setGroupMenuFor((cur) => (cur === id ? null : id));
}}
onNpcDragStart={setDragId}
onNpcDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onNpcDragOver={(id, place) => {
if (!dragId || dragId === id) {
setDropPlace(null);
return;
}
setDropPlace({ id, place });
}}
onNpcDropReorder={handleNpcDropOnTile}
onGroupDragStart={setDragGroupId}
onGroupDragEnd={() => {
setDragGroupId(null);
setGroupDropPlace(null);
}}
onGroupDragOver={(id, place) => {
if (!dragGroupId || dragGroupId === id) {
setGroupDropPlace(null);
return;
}
setGroupDropPlace({ id, place });
}}
onGroupDropReorder={handleGroupDropReorder}
onGroupBodyDragOver={setDropTargetGroup}
onGroupBodyDrop={(groupId) => handleNpcDropOnGroup(groupId)}
/>
))}
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div
className={[
styles.groupBody,
dropTargetGroup === 'ungrouped' && dragId ? styles.groupBodyDrop : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
setDropTargetGroup('ungrouped');
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
handleNpcDropOnGroup(null);
}}
>
{ungrouped.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
onSelect={() => setSelectedId(n.id)}
onMenu={(e) => {
openMenuAt(e);
setGroupMenuFor(null);
setMenuFor((cur) => (cur === n.id ? null : n.id));
}}
onDragStart={() => setDragId(n.id)}
onDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === n.id) {
setDropPlace(null);
return;
}
setDropPlace({ id: n.id, place });
}}
onDropReorder={() => handleNpcDropOnTile(n.id)}
/>
))}
</div>
</div>
{!hasListContent && npcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.empty')}</div>
) : null}
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
</div>
<div className={styles.col}>
<Suspense fallback={<div className={styles.graphLoading}>{t('npcs.graphLoading')}</div>}>
<NpcGraph
npcs={npcs}
relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
}}
onNodePositionCommit={(npcId, x, y) => {
void (async () => {
setSession((prev) => {
if (!prev?.project) return prev;
return {
...prev,
project: {
...prev.project,
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
},
};
});
try {
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
npcId,
x,
y,
});
setSession({
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
});
} catch {
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
}
})();
}}
onEditRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setRelationModal({ mode: 'edit', relationId, label: rel.label });
}}
onDeleteRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setPendingDeleteRelation(rel);
}}
/>
</Suspense>
</div>
<div className={[styles.col, styles.inspector].join(' ')}>
<div className={styles.inspectorScroll}>
{selected && project ? (
<>
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}>
{USERS_BRANCH_FEATURES_ENABLED ? (
<PlayerTokenView
name={selected.name}
imageUrl={selectedUrl}
ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
imageOffset={selected.imageOffset}
imageScale={selected.imageScale}
sizePx={140}
panEnabled
onImageOffsetChange={(imageOffset) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageOffset,
});
}}
onImageScaleChange={(imageScale) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageScale,
});
}}
/>
) : (
<div className={styles.avatarPreview}>
{selectedUrl ? (
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
) : null}
</div>
)}
<Button
disabled={avatarBusy}
onClick={() => {
void (async () => {
setAvatarBusy(true);
try {
const picked = await pickAvatar();
if (!picked) return;
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath: picked.filePath,
});
} finally {
setAvatarBusy(false);
}
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
</div>
{USERS_BRANCH_FEATURES_ENABLED ? (
<label>
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
<Select
value={selected.disposition ?? 'neutral'}
ariaLabel={t('npcs.disposition')}
onChange={(next) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
disposition: next as 'hostile' | 'neutral' | 'friendly',
});
}}
options={[
{ value: 'hostile', label: t('npcs.disposition.hostile') },
{ value: 'neutral', label: t('npcs.disposition.neutral') },
{ value: 'friendly', label: t('npcs.disposition.friendly') },
]}
/>
</label>
) : null}
<div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
<Input
value={nameDraft}
onChange={setNameDraft}
onBlur={() => {
const next = nameDraft.trim();
if (!next || next === selected.name) {
setNameDraft(selected.name);
return;
}
void api
.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
name: next,
})
.catch(() => setNameDraft(selected.name));
}}
placeholder={t('npcs.namePlaceholder')}
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
<Select
value={selected.groupId ?? ''}
ariaLabel={t('npcs.group')}
onChange={(next) => {
const groupId = (next || null) as NpcGroupId | null;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
groupId,
});
}}
options={[
{ value: '', label: t('npcs.ungrouped') },
...groupOptions.map((g) => ({ value: g.id, label: g.label })),
]}
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField
key={selected.id}
html={selected.description}
onCommit={(html) => {
if (html === selected.description) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
description: html,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relationsForSelected.map(({ relation, otherName }) => (
<div key={relation.id} className={styles.relationItem}>
{relation.label} {otherName}
</div>
))}
</div>
</div>
) : null}
</>
) : (
<div className={styles.emptyInspector}>{t('npcs.selectPrompt')}</div>
)}
</div>
</div>
</div>
<NpcEditModal
open={editOpen}
initial={editInitial}
existingNames={npcs.map((n) => n.name)}
npcGroups={npcGroups}
onClose={() => setEditOpen(false)}
onPickImage={pickAvatar}
onSave={async (input) => {
const res = await api.invoke(ipcChannels.project.upsertNpc, {
...(editInitial ? { npcId: editInitial.id } : {}),
name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
});
const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id);
}}
/>
<NpcGroupModal
open={Boolean(groupModal)}
initial={groupModal?.mode === 'edit' ? groupModal.group : null}
siblingNames={
groupModal?.mode === 'edit'
? siblingNamesForGroup(groupModal.group.parentId, groupModal.group.id)
: groupModal?.mode === 'create'
? siblingNamesForGroup(groupModal.parentId)
: []
}
onClose={() => setGroupModal(null)}
onSave={async ({ name, color }) => {
if (!groupModal) return;
if (groupModal.mode === 'edit') {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
groupId: groupModal.group.id,
name,
color,
});
} else {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
name,
color,
parentId: groupModal.parentId,
});
}
}}
/>
<NpcRelationModal
open={Boolean(relationModal)}
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
onClose={() => setRelationModal(null)}
onSave={async (label) => {
if (!relationModal) return;
if (relationModal.mode === 'create') {
await api.invoke(ipcChannels.project.upsertNpcRelation, {
sourceNpcId: relationModal.sourceNpcId,
targetNpcId: relationModal.targetNpcId,
label,
});
} else {
const rel = relations.find((r) => r.id === relationModal.relationId);
if (!rel) return;
await api.invoke(ipcChannels.project.upsertNpcRelation, {
relationId: rel.id,
sourceNpcId: rel.sourceNpcId,
targetNpcId: rel.targetNpcId,
label,
});
}
}}
/>
{menuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const npc = npcs.find((n) => n.id === menuFor);
if (npc) setPendingDelete(npc);
setMenuFor(null);
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
{groupMenuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setGroupModal({ mode: 'edit', group: g });
setGroupMenuFor(null);
}}
>
{t('npcs.editGroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
setGroupModal({ mode: 'create', parentId: groupMenuFor });
setGroupMenuFor(null);
}}
>
{t('npcs.addSubgroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setPendingDeleteGroup(g);
setGroupMenuFor(null);
}}
>
{t('npcs.deleteGroup')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDelete(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.deleteTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDelete(null)}
>
×
</button>
</div>
<div>{t('npcs.deleteConfirm', { name: pendingDelete.name })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDelete.id;
setPendingDelete(null);
void api.invoke(ipcChannels.project.deleteNpc, { npcId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteGroup
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteGroup(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.deleteGroupTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteGroup(null)}
>
×
</button>
</div>
<div>{t('npcs.deleteGroupConfirm', { name: pendingDeleteGroup.name })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteGroup(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteGroup.id;
setPendingDeleteGroup(null);
void api.invoke(ipcChannels.project.deleteNpcGroup, { groupId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteRelation
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteRelation(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.relationDeleteTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteRelation(null)}
>
×
</button>
</div>
<div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteRelation.id;
setPendingDeleteRelation(null);
void api.invoke(ipcChannels.project.deleteNpcRelation, { relationId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}