f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
1122 lines
40 KiB
TypeScript
1122 lines
40 KiB
TypeScript
import React, { 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 {
|
||
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';
|
||
import { Button, Input, Select } from '../shared/ui/controls';
|
||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||
|
||
import { NpcBindingFields } from './NpcBindingFields';
|
||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||
import { NpcEditModal } from './NpcEditModal';
|
||
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,
|
||
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}>
|
||
<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);
|
||
}}
|
||
/>
|
||
</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}>
|
||
<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>
|
||
|
||
<div>
|
||
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
||
<input
|
||
className={controlStyles.input}
|
||
value={nameDraft}
|
||
onChange={(e) => setNameDraft(e.target.value)}
|
||
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
|
||
html={selected.description}
|
||
onCommit={(html) => {
|
||
if (html === selected.description) return;
|
||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||
npcId: selected.id,
|
||
description: html,
|
||
});
|
||
}}
|
||
/>
|
||
</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>
|
||
<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)}
|
||
project={project}
|
||
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 } : {}),
|
||
...(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 : ''}
|
||
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>
|
||
);
|
||
}
|