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
+140
View File
@@ -0,0 +1,140 @@
import React, { useMemo } from 'react';
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import controlStyles from '../shared/ui/Controls.module.css';
type NpcBindingFieldsProps = {
project: Project;
binding: NpcBinding;
onChange: (binding: NpcBinding) => void;
};
function defaultBinding(project: Project): NpcBinding {
const opts = listStorylineOptionsForBinding(project);
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
if (opts.sides[0]) {
return {
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
};
}
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
if (firstScene) return { kind: 'scene', sceneId: firstScene };
return noneBinding();
}
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
const { t } = useEditorI18n();
const enabled = !isNpcBindingNone(binding);
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
const sceneOptions = useMemo(
() =>
Object.entries(project.scenes)
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
[project.scenes],
);
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
const bindingTargetValue = useMemo(() => {
if (binding.kind === 'scene') return binding.sceneId;
if (binding.kind === 'storyline') {
if (binding.storyline.kind === 'main') return 'main';
return `side:${binding.storyline.startGraphNodeId}`;
}
return '';
}, [binding]);
return (
<div className={editorStyles.fieldGrid}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
}}
/>
<span>{t('npcs.bindingEnable')}</span>
</label>
{enabled ? (
<>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<select
className={controlStyles.input}
value={kind}
onChange={(e) => {
const nextKind = e.target.value;
if (nextKind === 'scene') {
const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
return;
}
if (storylineOpts.main) {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
} else if (storylineOpts.sides[0]) {
onChange({
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
});
} else {
onChange(noneBinding());
}
}}
>
<option value="storyline">{t('npcs.bindingStoryline')}</option>
<option value="scene">{t('npcs.bindingScene')}</option>
</select>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<select
className={controlStyles.input}
value={bindingTargetValue}
onChange={(e) => {
const v = e.target.value;
if (kind === 'scene') {
onChange({ kind: 'scene', sceneId: v as SceneId });
return;
}
if (v === 'main') {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
return;
}
if (v.startsWith('side:')) {
onChange({
kind: 'storyline',
storyline: {
kind: 'side',
startGraphNodeId: v.slice(5) as GraphNodeId,
},
});
}
}}
>
{kind === 'storyline' ? (
<>
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
{storylineOpts.sides.map((s) => (
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
{s.label}
</option>
))}
</>
) : (
sceneOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))
)}
</select>
</>
) : null}
</div>
);
}
+75 -17
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ProjectNpc } from '../../shared/types';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import {
filterMaterialImagePaths,
@@ -13,24 +13,51 @@ import {
} from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css';
import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
function flattenGroupOptions(
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
depth = 0,
): { id: NpcGroupId; label: string }[] {
const out: { id: NpcGroupId; label: string }[] = [];
for (const node of nodes) {
const prefix = depth > 0 ? ' '.repeat(depth) : '';
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
out.push(...flattenGroupOptions(node.children, depth + 1));
}
return out;
}
type NpcEditModalProps = {
open: boolean;
initial: ProjectNpc | null;
existingNames: string[];
project: Project | null;
npcGroups: ProjectNpcGroup[];
onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
onSave: (input: {
name: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
}) => Promise<void>;
};
export function NpcEditModal({
open,
initial,
existingNames,
project,
npcGroups,
onClose,
onPickImage,
onSave,
@@ -39,15 +66,24 @@ export function NpcEditModal({
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
const groupOptions = useMemo(() => {
const { roots } = buildNpcGroupForest(npcGroups, []);
return flattenGroupOptions(roots);
}, [npcGroups]);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setFilePath(null);
setLocalPreviewUrl(null);
setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false);
setError(null);
}, [initial, open]);
@@ -95,7 +131,7 @@ export function NpcEditModal({
);
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl;
const previewSrc = localPreviewUrl ?? existingUrl;
if (!open) return null;
@@ -109,9 +145,7 @@ export function NpcEditModal({
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
</div>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
@@ -129,6 +163,24 @@ export function NpcEditModal({
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
</div>
{!initial && groupOptions.length > 0 ? (
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={groupId}
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
) : null}
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
<div
@@ -139,11 +191,11 @@ export function NpcEditModal({
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
const files = e.dataTransfer.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
const entry = entries[i];
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
const file = files[i];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
@@ -153,9 +205,7 @@ export function NpcEditModal({
}
}}
>
{drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
{previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : (
@@ -176,6 +226,10 @@ export function NpcEditModal({
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
@@ -191,7 +245,11 @@ export function NpcEditModal({
setSaving(true);
setError(null);
try {
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
await onSave({
name: trimmed,
...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}),
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
+26 -1
View File
@@ -40,11 +40,20 @@
border: 1px solid var(--stroke);
background: #18181b;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
border-left-width: 3px;
border-left-color: var(--npc-group-color, var(--stroke));
}
.nodeActive {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35);
border-left-color: var(--npc-group-color, #60a5fa);
box-shadow:
0 0 0 1px var(--npc-group-color, #60a5fa),
0 8px 24px rgba(0, 0, 0, 0.35);
}
.nodeDimmed {
opacity: 0.28;
}
.avatar {
@@ -152,3 +161,19 @@
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
.filterSelect {
min-width: 160px;
padding: 6px 10px;
padding-right: 28px;
border-radius: 8px;
border: 1px solid var(--stroke);
background-color: rgba(24, 24, 27, 0.92);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text1);
font-size: 12px;
font-weight: 600;
}
+206 -128
View File
@@ -22,7 +22,15 @@ import ReactFlow, {
} from 'reactflow';
import 'reactflow/dist/style.css';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
import type {
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcGraph.module.css';
@@ -32,6 +40,8 @@ type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
export type NpcGraphUiStrings = {
zoomBar: string;
zoomIn: string;
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
editRelation: string;
deleteRelation: string;
untitled: string;
graphFilter: string;
graphFilterAll: string;
graphFilterUngrouped: string;
};
type NpcNodeData = {
name: string;
avatarAssetId: ProjectNpc['avatarAssetId'];
active: boolean;
groupColor: string | null;
dimmed: boolean;
};
const NPC_ACCENT = '#60a5fa';
@@ -102,30 +117,35 @@ function pickEndpointSides(
? { sourceSide: 'right', targetSide: 'left' }
: { sourceSide: 'left', targetSide: 'right' };
}
return dy >= 0
? { sourceSide: 'bottom', targetSide: 'top' }
: { sourceSide: 'top', targetSide: 'bottom' };
return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
}
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
const url = useAssetUrl(data.avatarAssetId);
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
const accent = data.groupColor ?? NPC_ACCENT;
return (
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}>
<div
className={[
styles.node,
data.active || selected ? styles.nodeActive : '',
data.dimmed ? styles.nodeDimmed : '',
]
.filter(Boolean)
.join(' ')}
style={
data.groupColor
? ({
'--npc-group-color': accent,
borderColor: data.active || selected ? accent : undefined,
} as React.CSSProperties)
: undefined
}
>
{sides.map((side) => (
<React.Fragment key={side}>
<Handle
type="source"
position={sideToPosition(side)}
id={`s-${side}`}
className={styles.handle}
/>
<Handle
type="target"
position={sideToPosition(side)}
id={`t-${side}`}
className={styles.handle}
/>
<Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
<Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
</React.Fragment>
))}
<div className={styles.avatar}>
@@ -151,9 +171,7 @@ function parallelCubicPath(
): { path: string; labelX: number; labelY: number } {
// Канонический вектор между концами (не зависит от направления стрелки).
const [ax, ay, bx, by] =
sourceNpcId < targetNpcId
? [sourceX, sourceY, targetX, targetY]
: [targetX, targetY, sourceX, sourceY];
sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
const cdx = bx - ax;
const cdy = by - ay;
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
const targetNpcId = data?.targetNpcId;
const { path, labelX, labelY } =
sourceNpcId && targetNpcId
? parallelCubicPath(
sourceNpcId,
targetNpcId,
sourceX,
sourceY,
targetX,
targetY,
worldOffset,
)
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
: {
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
labelX: (sourceX + targetX) / 2,
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
{label && relationId ? (
<EdgeLabelRenderer>
<div
className={[
styles.edgeLabel,
highlighted ? styles.edgeLabelActive : '',
'nodrag',
'nopan',
]
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
.filter(Boolean)
.join(' ')}
style={{
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button>
<button type="button" className={styles.zoomBtn} onClick={() => fitView({ padding: 0.2 })} aria-label={ui.fitAll}>
<button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.2 })}
aria-label={ui.fitAll}
>
</button>
</div>
@@ -277,10 +287,44 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
);
}
function FilterToolbar({
ui,
groups,
value,
onChange,
}: {
ui: NpcGraphUiStrings;
groups: ProjectNpcGroup[];
value: GraphGroupFilter;
onChange: (v: GraphGroupFilter) => void;
}) {
return (
<Panel position="top-left">
<select
className={styles.filterSelect}
aria-label={ui.graphFilter}
value={value}
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
>
<option value="all">{ui.graphFilterAll}</option>
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</Panel>
);
}
export type NpcGraphProps = {
npcs: ProjectNpc[];
relations: ProjectNpcRelation[];
npcGroups: ProjectNpcGroup[];
selectedNpcId: NpcId | null;
graphFilter: GraphGroupFilter;
onGraphFilterChange: (filter: GraphGroupFilter) => void;
graphUi: NpcGraphUiStrings;
onSelect: (npcId: NpcId) => void;
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
function NpcGraphInner({
npcs,
relations,
npcGroups,
selectedNpcId,
graphFilter,
onGraphFilterChange,
graphUi,
onSelect,
onConnectRequest,
@@ -300,9 +347,7 @@ function NpcGraphInner({
onEditRelation,
onDeleteRelation,
}: NpcGraphProps) {
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
null,
);
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
const connectFromRef = useRef<NpcId | null>(null);
@@ -320,6 +365,23 @@ function NpcGraphInner({
};
}, [menu]);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const filterGroupIds = useMemo(() => {
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
return collectDescendantGroupIds(npcGroups, graphFilter);
}, [graphFilter, npcGroups]);
const isNpcDimmed = useCallback(
(npc: ProjectNpc) => {
if (graphFilter === 'all') return false;
if (graphFilter === 'ungrouped') return npc.groupId !== null;
if (!filterGroupIds) return true;
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
},
[filterGroupIds, graphFilter],
);
const initialNodes: Node<NpcNodeData>[] = useMemo(
() =>
npcs.map((n) => ({
@@ -330,9 +392,11 @@ function NpcGraphInner({
name: n.name,
avatarAssetId: n.avatarAssetId,
active: n.id === selectedNpcId,
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
dimmed: isNpcDimmed(n),
},
})),
[npcs, selectedNpcId],
[groupColorById, isNpcDimmed, npcs, selectedNpcId],
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -364,6 +428,11 @@ function NpcGraphInner({
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
const edgeDimmed =
graphFilter !== 'all' &&
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
out.push({
id: r.id,
@@ -381,7 +450,11 @@ function NpcGraphInner({
targetNpcId: r.targetNpcId,
highlighted,
},
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
style: {
stroke: color,
strokeWidth: highlighted ? 2.5 : 2,
opacity: edgeDimmed ? 0.2 : 1,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
@@ -392,7 +465,7 @@ function NpcGraphInner({
});
}
return out;
}, [nodes, npcs, relations, selectedNpcId]);
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
useEffect(() => {
setNodes(initialNodes);
@@ -435,97 +508,102 @@ function NpcGraphInner({
return (
<OpenEdgeMenuContext.Provider value={openEdgeMenu}>
<SelectSourceNpcContext.Provider value={selectSourceNpc}>
<div className={styles.wrap}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={() => {
connectFromRef.current = null;
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
fitView
proOptions={{ hideAttribution: true }}
onNodeClick={(_e, node) => {
setMenu(null);
onSelect(node.id as NpcId);
}}
onNodeDragStop={(_e, node) => {
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
}}
onEdgeClick={(e, edge) => {
e.stopPropagation();
setMenu(null);
const sourceId =
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
onSelect(sourceId);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
e.stopPropagation();
const relationId =
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
openEdgeMenu(relationId, e.clientX, e.clientY);
}}
onPaneClick={() => setMenu(null)}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
>
<Background gap={18} size={1} color="#27272a" />
<ZoomToolbar ui={graphUi} />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
aria-label="close"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
/>
<div
className={styles.menu}
style={{ left: menuPosition.left, top: menuPosition.top }}
data-npc-edge-menu="1"
onMouseDown={(e) => e.stopPropagation()}
>
<div className={styles.wrap}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={() => {
connectFromRef.current = null;
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
fitView
proOptions={{ hideAttribution: true }}
onNodeClick={(_e, node) => {
setMenu(null);
onSelect(node.id as NpcId);
}}
onNodeDragStop={(_e, node) => {
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
}}
onEdgeClick={(e, edge) => {
e.stopPropagation();
setMenu(null);
const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
onSelect(sourceId);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
e.stopPropagation();
const relationId =
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
openEdgeMenu(relationId, e.clientX, e.clientY);
}}
onPaneClick={() => setMenu(null)}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
>
<Background gap={18} size={1} color="#27272a" />
<FilterToolbar
ui={graphUi}
groups={npcGroups}
value={graphFilter}
onChange={onGraphFilterChange}
/>
<ZoomToolbar ui={graphUi} />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
className={styles.menuItem}
onClick={() => {
onEditRelation(menu.relationId);
aria-label="close"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
/>
<div
className={styles.menu}
style={{ left: menuPosition.left, top: menuPosition.top }}
data-npc-edge-menu="1"
onMouseDown={(e) => e.stopPropagation()}
>
{graphUi.editRelation}
</button>
<button
type="button"
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
onClick={() => {
onDeleteRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.deleteRelation}
</button>
</div>
</>,
document.body,
)
: null}
</div>
<button
type="button"
className={styles.menuItem}
onClick={() => {
onEditRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.editRelation}
</button>
<button
type="button"
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
onClick={() => {
onDeleteRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.deleteRelation}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</SelectSourceNpcContext.Provider>
</OpenEdgeMenuContext.Provider>
);
@@ -0,0 +1,32 @@
.nameColorRow {
display: flex;
gap: 10px;
align-items: center;
}
.colorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.colorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.colorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
import type { ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { Button, Input } from '../shared/ui/controls';
import styles from './NpcGroupModal.module.css';
type NpcGroupModalProps = {
open: boolean;
initial: ProjectNpcGroup | null;
siblingNames: string[];
onClose: () => void;
onSave: (input: { name: string; color: string }) => Promise<void>;
};
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
setSaving(false);
setError(null);
}, [initial, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup = useMemo(() => {
if (!nameOk) return false;
const key = normalizeName(trimmed);
const except = normalizeName(initial?.name ?? '');
return siblingNames.some((n) => {
const nk = normalizeName(n);
return nk === key && nk !== except;
});
}, [initial?.name, nameOk, siblingNames, trimmed]);
const canSave = nameOk && !nameDup && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.groupName')}</div>
<div className={styles.nameColorRow}>
<input
type="color"
className={styles.colorInput}
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t('npcs.groupColor')}
/>
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
</div>
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</div> : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+1 -5
View File
@@ -64,11 +64,7 @@ export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: N
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
<Input
value={label}
onChange={setLabel}
placeholder={t('npcs.relationLabelPlaceholder')}
/>
<Input value={label} onChange={setLabel} placeholder={t('npcs.relationLabelPlaceholder')} />
{trimmed.length < 1 ? (
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
) : null}
+63
View File
@@ -158,3 +158,66 @@
color: var(--text2);
font-size: 13px;
}
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
user-select: none;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 0;
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+167 -25
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, ProjectNpc } from '../../shared/types';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button, Input } from '../shared/ui/controls';
@@ -37,28 +38,38 @@ function ZoomOutIcon() {
);
}
/** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = [];
for (const node of nodes) {
const children = pruneEmptyGroupNodes(node.children);
if (node.npcs.length === 0 && children.length === 0) continue;
out.push({ ...node, children });
}
return out;
}
function RuntimeNpcTile({
npc,
selected,
active,
accentColor,
onActivate,
}: {
npc: ProjectNpc;
selected: boolean;
active: boolean;
accentColor?: string | null;
onActivate: () => void;
}) {
const url = useAssetUrl(npc.avatarAssetId);
return (
<button
type="button"
className={[
styles.tile,
selected ? styles.tileSelected : '',
active ? styles.tileActive : '',
]
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate}
>
<div className={styles.tileAvatar}>
@@ -69,6 +80,70 @@ function RuntimeNpcTile({
);
}
function RuntimeGroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
activeId,
onActivate,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
activeId: NpcId | null;
onActivate: (id: NpcId) => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div className={styles.groupHeader}>
<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>
</div>
{expanded ? (
<div className={styles.groupBody}>
{node.npcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
accentColor={g.color}
onActivate={() => onActivate(n.id)}
/>
))}
{node.children.map((child) => (
<RuntimeGroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
activeId={activeId}
onActivate={onActivate}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
@@ -76,6 +151,7 @@ export function NpcsApp() {
const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
@@ -106,18 +182,53 @@ export function NpcsApp() {
return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
const npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const activeId = overlay?.activeNpcId ?? null;
const zoomTool = overlay?.zoomTool ?? null;
const selected = npcs.find((n) => n.id === selectedId) ?? null;
const effectiveSelectedId =
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
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 searching = query.trim().length > 0;
const { roots, ungrouped } = useMemo(() => {
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
if (!searching) return forest;
return {
roots: pruneEmptyGroupNodes(forest.roots),
ungrouped: forest.ungrouped,
};
}, [filteredNpcs, npcGroups, searching]);
const isExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return true;
return !collapsedGroups.has(id);
},
[collapsedGroups, searching],
);
const toggleExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return;
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[searching],
);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
@@ -202,10 +313,7 @@ export function NpcsApp() {
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div
className={styles.detailDesc}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
@@ -231,17 +339,51 @@ export function NpcsApp() {
<div className={styles.listCol}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.list}>
{filtered.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))}
{npcGroups.length === 0 ? (
filteredNpcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))
) : (
<>
{roots.map((node) => (
<RuntimeGroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={effectiveSelectedId}
activeId={activeId}
onActivate={onSelectTile}
/>
))}
{ungrouped.length > 0 || !searching ? (
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div className={styles.groupBody}>
{ungrouped.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))}
</div>
</div>
) : null}
</>
)}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
+118
View File
@@ -45,6 +45,27 @@
background: #0f0f12;
}
.sideActions {
display: flex;
gap: 8px;
flex-wrap: nowrap;
align-items: stretch;
}
.sideActions > * {
flex: 1 1 0;
min-width: 0;
}
.sideActions button {
width: 100%;
padding-left: 8px;
padding-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list {
display: grid;
gap: 8px;
@@ -272,3 +293,100 @@
.menuItemDanger:hover {
background: rgba(239, 68, 68, 0.18);
}
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
cursor: grab;
user-select: none;
}
.groupHeaderDragging {
opacity: 0.55;
}
.groupDropBefore {
box-shadow: inset 0 2px 0 #60a5fa;
}
.groupDropAfter {
box-shadow: inset 0 -2px 0 #60a5fa;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupMenuBtn {
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text2);
cursor: pointer;
}
.groupMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 8px;
}
.groupBodyDrop {
background: rgba(96, 165, 250, 0.08);
border-radius: 8px;
outline: 1px dashed rgba(96, 165, 250, 0.45);
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+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
+95
View File
@@ -0,0 +1,95 @@
import type { NpcGroupId, NpcId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
export function reorderNpcIds(
npcs: ProjectNpc[],
dragId: NpcId,
targetId: NpcId,
place: 'before' | 'after',
): NpcId[] {
const ids = npcs.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let to = ids.indexOf(targetId);
if (to < 0) return npcs.map((n) => n.id);
if (place === 'after') to += 1;
ids.splice(to, 0, dragId);
return ids;
}
/** Move NPC to end of its group block in global order. */
export function moveNpcToGroupEnd(npcs: ProjectNpc[], dragId: NpcId, groupId: NpcGroupId | null): NpcId[] {
const updated = npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n));
const ids = updated.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let insertAt = ids.length;
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === groupId) {
insertAt = i + 1;
break;
}
}
if (groupId === null) {
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === null) {
insertAt = i + 1;
break;
}
}
}
ids.splice(insertAt, 0, dragId);
return ids;
}
export function reorderSiblingGroups(
groups: ProjectNpcGroup[],
dragId: NpcGroupId,
targetId: NpcGroupId,
place: 'before' | 'after',
): NpcGroupId[] {
const drag = groups.find((g) => g.id === dragId);
const target = groups.find((g) => g.id === targetId);
if (!drag || drag.parentId !== target?.parentId) return groups.map((g) => g.id);
const parentId = drag.parentId;
const siblingIds = groups.filter((g) => g.parentId === parentId).map((g) => g.id);
const from = siblingIds.indexOf(dragId);
if (from < 0) return groups.map((g) => g.id);
siblingIds.splice(from, 1);
let to = siblingIds.indexOf(targetId);
if (to < 0) return groups.map((g) => g.id);
if (place === 'after') to += 1;
siblingIds.splice(to, 0, dragId);
const result: NpcGroupId[] = [];
let inserted = false;
for (const g of groups) {
if (g.parentId === parentId) {
if (!inserted) {
result.push(...siblingIds);
inserted = true;
}
continue;
}
result.push(g.id);
}
return result;
}
export function flattenGroupOptions(
nodes: { group: ProjectNpcGroup; children: unknown[] }[],
depth = 0,
): { id: NpcGroupId; label: string }[] {
const out: { id: NpcGroupId; label: string }[] = [];
for (const node of nodes) {
const prefix = depth > 0 ? ' '.repeat(depth) : '';
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
out.push(...flattenGroupOptions(node.children as typeof nodes, depth + 1));
}
return out;
}