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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user