feat(npcs): add groups, storyline bindings, and Foundry import

Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+623 -60
View File
@@ -2,7 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type {
NpcBinding,
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';
@@ -10,19 +19,33 @@ import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import { NpcGraph } from './NpcGraph';
import { NpcGraph, 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 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,
@@ -34,6 +57,7 @@ function NpcTile({
selected: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
accentColor?: string | null;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
@@ -54,6 +78,7 @@ function NpcTile({
]
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
@@ -91,6 +116,193 @@ function NpcTile({
);
}
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();
@@ -101,9 +313,20 @@ export function NpcsEditorApp() {
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 }
@@ -118,14 +341,18 @@ export function NpcsEditorApp() {
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 npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
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(() => {
@@ -138,23 +365,33 @@ export function NpcsEditorApp() {
}, [npcs, selectedId]);
useEffect(() => {
if (!menuFor) return;
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);
}, [menuFor]);
}, [groupMenuFor, menuFor]);
const filtered = useMemo(() => {
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(() => {
@@ -173,6 +410,83 @@ export function NpcsEditorApp() {
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'),
@@ -182,10 +496,28 @@ export function NpcsEditorApp() {
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}>
@@ -202,66 +534,138 @@ export function NpcsEditorApp() {
<div className={styles.body}>
<div className={[styles.col, styles.side].join(' ')}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<Button
variant="primary"
onClick={() => {
setEditInitial(null);
setEditOpen(true);
}}
>
{t('npcs.add')}
</Button>
<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}>
{filtered.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) => {
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 88;
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 });
setMenuFor((cur) => (cur === n.id ? null : n.id));
{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));
}}
onDragStart={() => setDragId(n.id)}
onDragEnd={() => {
onGroupMenu={(id, e) => {
openMenuAt(e);
setMenuFor(null);
setGroupMenuFor((cur) => (cur === id ? null : id));
}}
onNpcDragStart={setDragId}
onNpcDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === n.id) {
onNpcDragOver={(id, place) => {
if (!dragId || dragId === id) {
setDropPlace(null);
return;
}
setDropPlace({ id: n.id, place });
setDropPlace({ id, place });
}}
onDropReorder={() => {
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
const ids = npcs.map((x) => x.id);
const from = ids.indexOf(dragId);
if (from < 0) return;
ids.splice(from, 1);
let to = ids.indexOf(dropPlace.id);
if (to < 0) return;
if (dropPlace.place === 'after') to += 1;
ids.splice(to, 0, dragId);
setDragId(null);
setDropPlace(null);
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
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)}
/>
))}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
<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>
@@ -271,7 +675,10 @@ export function NpcsEditorApp() {
<NpcGraph
npcs={npcs}
relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
@@ -295,7 +702,7 @@ export function NpcsEditorApp() {
<div className={[styles.col, styles.inspector].join(' ')}>
<div className={styles.inspectorScroll}>
{selected ? (
{selected && project ? (
<>
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
@@ -352,6 +759,28 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={selected.groupId ?? ''}
onChange={(e) => {
const groupId = (e.target.value || null) as NpcGroupId | null;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
groupId,
});
}}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField
@@ -366,6 +795,20 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
<NpcBindingFields
project={project}
binding={selected.binding}
onChange={(binding: NpcBinding) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
binding,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -390,6 +833,8 @@ export function NpcsEditorApp() {
open={editOpen}
initial={editInitial}
existingNames={npcs.map((n) => n.name)}
project={project}
npcGroups={npcGroups}
onClose={() => setEditOpen(false)}
onPickImage={pickAvatar}
onSave={async (input) => {
@@ -397,12 +842,43 @@ export function NpcsEditorApp() {
...(editInitial ? { npcId: editInitial.id } : {}),
name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.binding !== undefined ? { binding: input.binding } : {}),
});
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 : ''}
@@ -431,13 +907,14 @@ export function NpcsEditorApp() {
{menuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
onMouseDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const npc = npcs.find((n) => n.id === menuFor);
@@ -452,6 +929,54 @@ export function NpcsEditorApp() {
)
: 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(
<>
@@ -492,6 +1017,46 @@ export function NpcsEditorApp() {
)
: 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(
<>
@@ -512,9 +1077,7 @@ export function NpcsEditorApp() {
×
</button>
</div>
<div>
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
</div>
<div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
<Button