feat(npcs): add campaign NPCs with relation graph and session overlay

Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 13:13:58 +08:00
parent 61875be857
commit 37ba855faf
40 changed files with 3655 additions and 17 deletions
+157
View File
@@ -0,0 +1,157 @@
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React, { useEffect, useMemo } from 'react';
import { normalizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import modalStyles from '../editor/SceneDescriptionModal.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import styles from './NpcsEditorApp.module.css';
type NpcDescriptionFieldProps = {
html: string;
onCommit: (html: string) => void;
};
function ToolButton({
active = false,
title,
onClick,
children,
}: {
active?: boolean;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
aria-pressed={active}
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
onClick={onClick}
>
{children}
</button>
);
}
export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps) {
const { t } = useEditorI18n();
const extensions = useMemo(
() => [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
link: false,
}),
Placeholder.configure({
placeholder: t('npcs.descriptionPlaceholder'),
}),
],
[t],
);
const editor = useEditor({
extensions,
content: html || '',
immediatelyRender: true,
shouldRerenderOnTransaction: true,
editorProps: {
attributes: {
class: [modalStyles.prose, 'tiptap'].join(' '),
'aria-label': t('npcs.description'),
},
},
onBlur: ({ editor: ed }) => {
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
},
});
useEffect(() => {
if (!editor) return;
const current = normalizeSceneDescriptionHtml(editor.getHTML());
const next = normalizeSceneDescriptionHtml(html);
if (current !== next) {
editor.commands.setContent(html || '', { emitUpdate: false });
}
}, [editor, html]);
const toolbarState = useEditorState({
editor,
selector: ({ editor: ed }) => ({
bold: ed.isActive('bold'),
italic: ed.isActive('italic'),
bulletList: ed.isActive('bulletList'),
orderedList: ed.isActive('orderedList'),
h2: ed.isActive('heading', { level: 2 }),
h3: ed.isActive('heading', { level: 3 }),
}),
});
if (!editor) return null;
return (
<div className={styles.descShell}>
<div className={modalStyles.toolbar}>
<div className={modalStyles.toolbarGroup}>
<ToolButton
active={toolbarState.bold}
title={t('scene.descriptionBold')}
onClick={() => editor.chain().focus().toggleBold().run()}
>
B
</ToolButton>
<ToolButton
active={toolbarState.italic}
title={t('scene.descriptionItalic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
I
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
active={toolbarState.h2}
title={t('scene.descriptionHeading2')}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
active={toolbarState.h3}
title={t('scene.descriptionHeading3')}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
active={toolbarState.bulletList}
title={t('scene.descriptionBulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
</ToolButton>
<ToolButton
active={toolbarState.orderedList}
title={t('scene.descriptionOrderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1.
</ToolButton>
</div>
</div>
<div className={styles.descContent}>
<EditorContent editor={editor} />
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
import React, { useEffect, 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 editorStyles from '../editor/EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css';
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
type NpcEditModalProps = {
open: boolean;
initial: ProjectNpc | null;
existingNames: string[];
onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
};
export function NpcEditModal({
open,
initial,
existingNames,
onClose,
onPickImage,
onSave,
}: NpcEditModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setFilePath(null);
setLocalPreviewUrl(null);
setSaving(false);
setError(null);
}, [initial, open]);
useEffect(() => {
return () => {
if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl);
};
}, [localPreviewUrl]);
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 setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path);
setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl;
});
};
const drop = useFileDropZone({
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
setPreviewFromPathAndUrl(picked, '');
},
filterPaths: filterMaterialImagePaths,
});
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup =
nameOk &&
existingNames.some(
(n) =>
normalizeName(n) === normalizeName(trimmed) &&
normalizeName(n) !== normalizeName(initial?.name ?? ''),
);
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl;
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.editTitle') : t('npcs.addTitle')}
</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.name')}</div>
<Input value={name} onChange={setName} placeholder={t('npcs.namePlaceholder')} />
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.nameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
<div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
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];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
}
setPreviewFromPathAndUrl(entry.path, '');
return;
}
}}
>
{drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : (
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
)}
<Button
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</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(filePath ? { name: trimmed, filePath } : { name: trimmed });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+154
View File
@@ -0,0 +1,154 @@
.wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
background: #0c0c0f;
}
.zoomBar {
display: flex;
gap: 6px;
padding: 6px;
border-radius: 10px;
background: rgba(24, 24, 27, 0.92);
border: 1px solid var(--stroke);
}
.zoomBtn {
width: 32px;
height: 32px;
border: 1px solid var(--stroke);
border-radius: 8px;
background: #18181b;
color: var(--text1);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.zoomBtn:hover {
background: #27272a;
}
.node {
width: 120px;
display: grid;
gap: 6px;
padding: 8px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.nodeActive {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35);
}
.avatar {
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #09090b;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.name {
font-size: 12px;
font-weight: 700;
text-align: center;
color: var(--text1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.handle {
width: 10px !important;
height: 10px !important;
background: #71717a !important;
border: 2px solid #18181b !important;
}
.handle:hover {
background: #60a5fa !important;
}
.edgeLabel {
pointer-events: all;
padding: 2px 8px;
border-radius: 6px;
background: rgba(24, 24, 27, 0.92);
border: 1px solid var(--stroke);
color: var(--text1);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transform: translate(-50%, -50%);
position: absolute;
z-index: 5;
}
.edgeLabelActive {
border-color: #60a5fa;
color: #93c5fd;
box-shadow: 0 0 0 1px #60a5fa;
z-index: 20;
}
.menuBackdrop {
position: fixed;
inset: 0;
z-index: 79;
border: 0;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.menu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
background: #18181b;
border: 1px solid var(--stroke);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.menuItem {
border: 0;
background: transparent;
color: var(--text1);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.menuItem:hover {
background: #27272a;
}
.menuItemDanger:hover {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
+540
View File
@@ -0,0 +1,540 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
BaseEdge,
ConnectionMode,
EdgeLabelRenderer,
Handle,
MarkerType,
Panel,
Position,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Connection,
type Edge,
type EdgeProps,
type Node,
type NodeProps,
type OnConnectStartParams,
} from 'reactflow';
import 'reactflow/dist/style.css';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcGraph.module.css';
type OpenEdgeMenuFn = (relationId: NpcRelationId, x: number, y: number) => void;
type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
export type NpcGraphUiStrings = {
zoomBar: string;
zoomIn: string;
zoomOut: string;
fitAll: string;
editRelation: string;
deleteRelation: string;
untitled: string;
};
type NpcNodeData = {
name: string;
avatarAssetId: ProjectNpc['avatarAssetId'];
active: boolean;
};
const NPC_ACCENT = '#60a5fa';
const NPC_EDGE_IDLE = '#a1a1aa';
/** Согласовано с `.node` в CSS (ширина + типичная высота карточки). */
const NPC_NODE_W = 120;
const NPC_NODE_H = 150;
/** Расстояние между параллельными связями одной пары НПС. */
const PARALLEL_EDGE_GAP = 21;
type Side = 'left' | 'right' | 'top' | 'bottom';
type NpcEdgeData = {
label: string;
/** Смещение в мировых координатах (общее для пары, без учёта направления). */
offset: number;
relationId: NpcRelationId;
sourceNpcId: NpcId;
targetNpcId: NpcId;
highlighted: boolean;
};
/** Любые связи между одной парой НПС (A→B и B→A) — в одной группе разведения. */
function undirectedPairKey(a: NpcId, b: NpcId): string {
return a < b ? `${a}__${b}` : `${b}__${a}`;
}
function sideToPosition(side: Side): Position {
switch (side) {
case 'left':
return Position.Left;
case 'right':
return Position.Right;
case 'top':
return Position.Top;
case 'bottom':
return Position.Bottom;
}
}
/** Выбираем стороны карточек так, чтобы линия выходила наружу и не шла сквозь блок. */
function pickEndpointSides(
sourcePos: { x: number; y: number },
targetPos: { x: number; y: number },
): { sourceSide: Side; targetSide: Side } {
const sx = sourcePos.x + NPC_NODE_W / 2;
const sy = sourcePos.y + NPC_NODE_H / 2;
const tx = targetPos.x + NPC_NODE_W / 2;
const ty = targetPos.y + NPC_NODE_H / 2;
const dx = tx - sx;
const dy = ty - sy;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { sourceSide: 'right', targetSide: 'left' }
: { sourceSide: 'left', targetSide: 'right' };
}
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'];
return (
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}>
{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}
/>
</React.Fragment>
))}
<div className={styles.avatar}>
{url ? <img className={styles.avatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.name}>{data.name || '—'}</div>
</div>
);
}
/**
* Кривая с перпендикуляром в мировых координатах.
* Базис берём от «меньшего» id к «большему», чтобы A→B и B→A с разными offset не совпадали.
*/
function parallelCubicPath(
sourceNpcId: NpcId,
targetNpcId: NpcId,
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
worldOffset: number,
): { path: string; labelX: number; labelY: number } {
// Канонический вектор между концами (не зависит от направления стрелки).
const [ax, ay, bx, by] =
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;
const px = (-cdy / clen) * worldOffset;
const py = (cdx / clen) * worldOffset;
// Сдвигаем всю кривую (включая концы у ручек), чтобы линии не сливались.
const sx = sourceX + px;
const sy = sourceY + py;
const tx = targetX + px;
const ty = targetY + py;
const dx = tx - sx;
const dy = ty - sy;
const c1x = sx + dx * 0.35;
const c1y = sy + dy * 0.35;
const c2x = sx + dx * 0.65;
const c2y = sy + dy * 0.65;
return {
path: `M ${sx},${sy} C ${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: (sy + ty) / 2,
};
}
function LabeledNpcEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
style,
markerEnd,
data,
}: EdgeProps<NpcEdgeData>) {
const openEdgeMenu = useContext(OpenEdgeMenuContext);
const selectSourceNpc = useContext(SelectSourceNpcContext);
const worldOffset = data?.offset ?? 0;
const sourceNpcId = data?.sourceNpcId;
const targetNpcId = data?.targetNpcId;
const { path, labelX, labelY } =
sourceNpcId && targetNpcId
? parallelCubicPath(
sourceNpcId,
targetNpcId,
sourceX,
sourceY,
targetX,
targetY,
worldOffset,
)
: {
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
labelX: (sourceX + targetX) / 2,
labelY: (sourceY + targetY) / 2,
};
const label = data?.label ?? '';
const relationId = data?.relationId;
const highlighted = Boolean(data?.highlighted);
return (
<>
<BaseEdge
id={id}
path={path}
interactionWidth={24}
{...(style ? { style } : {})}
{...(markerEnd ? { markerEnd } : {})}
/>
{label && relationId ? (
<EdgeLabelRenderer>
<div
className={[
styles.edgeLabel,
highlighted ? styles.edgeLabelActive : '',
'nodrag',
'nopan',
]
.filter(Boolean)
.join(' ')}
style={{
position: 'absolute',
left: labelX,
top: labelY,
zIndex: highlighted ? 20 : 5,
}}
title={label}
onClick={(e) => {
e.stopPropagation();
if (sourceNpcId) selectSourceNpc?.(sourceNpcId);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
openEdgeMenu?.(relationId, e.clientX, e.clientY);
}}
>
{label}
</div>
</EdgeLabelRenderer>
) : null}
</>
);
}
function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
const { zoomIn, zoomOut, fitView } = useReactFlow();
return (
<Panel position="bottom-right">
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
+
</button>
<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>
</div>
</Panel>
);
}
export type NpcGraphProps = {
npcs: ProjectNpc[];
relations: ProjectNpcRelation[];
selectedNpcId: NpcId | null;
graphUi: NpcGraphUiStrings;
onSelect: (npcId: NpcId) => void;
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
onNodePositionCommit: (npcId: NpcId, x: number, y: number) => void;
onEditRelation: (relationId: NpcRelationId) => void;
onDeleteRelation: (relationId: NpcRelationId) => void;
};
function NpcGraphInner({
npcs,
relations,
selectedNpcId,
graphUi,
onSelect,
onConnectRequest,
onNodePositionCommit,
onEditRelation,
onDeleteRelation,
}: NpcGraphProps) {
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
null,
);
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
const connectFromRef = useRef<NpcId | null>(null);
const edgeTypes = useMemo(() => ({ npcRelation: LabeledNpcEdge }), []);
const nodeTypes = useMemo(() => ({ npc: NpcNode }), []);
const menuPosition = useMemo(() => {
if (!menu) return null;
const pad = 8;
const mw = 180;
const mh = 88;
return {
left: Math.max(pad, Math.min(menu.left, window.innerWidth - mw - pad)),
top: Math.max(pad, Math.min(menu.top, window.innerHeight - mh - pad)),
};
}, [menu]);
const initialNodes: Node<NpcNodeData>[] = useMemo(
() =>
npcs.map((n) => ({
id: n.id,
type: 'npc',
position: { x: n.x, y: n.y },
data: {
name: n.name,
avatarAssetId: n.avatarAssetId,
active: n.id === selectedNpcId,
},
})),
[npcs, selectedNpcId],
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const builtEdges: Edge<NpcEdgeData>[] = useMemo(() => {
const posById = new Map<string, { x: number; y: number }>();
for (const n of nodes) posById.set(n.id, n.position);
// Пока nodes ещё пуст/не синхронизирован — берём координаты из проекта.
for (const n of npcs) {
if (!posById.has(n.id)) posById.set(n.id, { x: n.x, y: n.y });
}
// Группируем ВСЕ связи между парой НПС (оба направления), чтобы не накладывались.
const groups = new Map<string, ProjectNpcRelation[]>();
for (const r of relations) {
const key = undirectedPairKey(r.sourceNpcId, r.targetNpcId);
const list = groups.get(key) ?? [];
list.push(r);
groups.set(key, list);
}
const out: Edge<NpcEdgeData>[] = [];
for (const group of groups.values()) {
const sorted = [...group].sort((a, b) => a.id.localeCompare(b.id));
const total = sorted.length;
sorted.forEach((r, index) => {
const sourcePos = posById.get(r.sourceNpcId) ?? { x: 0, y: 0 };
const targetPos = posById.get(r.targetNpcId) ?? { x: 0, y: 0 };
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
out.push({
id: r.id,
source: r.sourceNpcId,
target: r.targetNpcId,
sourceHandle: `s-${sourceSide}`,
targetHandle: `t-${targetSide}`,
type: 'npcRelation',
zIndex: highlighted ? 10 : 0,
data: {
label: r.label,
offset,
relationId: r.id,
sourceNpcId: r.sourceNpcId,
targetNpcId: r.targetNpcId,
highlighted,
},
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color,
},
});
});
}
return out;
}, [nodes, npcs, relations, selectedNpcId]);
useEffect(() => {
setNodes(initialNodes);
}, [initialNodes, setNodes]);
useEffect(() => {
setEdges(builtEdges);
}, [builtEdges, setEdges]);
const onConnectStart = useCallback((_event: unknown, params: OnConnectStartParams) => {
connectFromRef.current = params.nodeId ? (params.nodeId as NpcId) : null;
}, []);
const onConnect = useCallback(
(conn: Connection) => {
if (!conn.source || !conn.target || conn.source === conn.target) return;
const from = connectFromRef.current;
if (from && (from === conn.source || from === conn.target)) {
const to = (from === conn.source ? conn.target : conn.source) as NpcId;
onConnectRequest(from, to);
return;
}
onConnectRequest(conn.source as NpcId, conn.target as NpcId);
},
[onConnectRequest],
);
const openEdgeMenu = useCallback((relationId: NpcRelationId, x: number, y: number) => {
setMenu({ relationId, left: x, top: y });
}, []);
const selectSourceNpc = useCallback(
(sourceNpcId: NpcId) => {
setMenu(null);
onSelect(sourceNpcId);
},
[onSelect],
);
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()}
>
<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>
);
}
export function NpcGraph(props: NpcGraphProps) {
return (
<ReactFlowProvider>
<NpcGraphInner {...props} />
</ReactFlowProvider>
);
}
+109
View File
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Input } from '../shared/ui/controls';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
type NpcRelationModalProps = {
open: boolean;
initialLabel?: string;
onClose: () => void;
onSave: (label: string) => Promise<void>;
};
export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: NpcRelationModalProps) {
const { t } = useEditorI18n();
const [label, setLabel] = useState(initialLabel);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setLabel(initialLabel);
setSaving(false);
setError(null);
}, [initialLabel, 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 = label.trim();
const canSave = trimmed.length >= 1 && !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}>
{initialLabel ? t('npcs.relationEditTitle') : t('npcs.relationCreateTitle')}
</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.relationLabel')}</div>
<Input
value={label}
onChange={setLabel}
placeholder={t('npcs.relationLabelPlaceholder')}
/>
{trimmed.length < 1 ? (
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</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(trimmed);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+160
View File
@@ -0,0 +1,160 @@
.page {
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
background: #09090b;
color: var(--text1);
min-height: 0;
}
.toolbar {
display: grid;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--stroke);
background: #18181b;
}
.toolbarRow {
display: flex;
gap: 8px;
align-items: center;
}
.toolbarHint {
color: var(--text2);
font-size: 12px;
line-height: 1.35;
}
.body {
display: grid;
grid-template-columns: 1fr 280px;
min-height: 0;
}
.detail {
min-height: 0;
overflow: auto;
padding: 14px 16px;
border-right: 1px solid var(--stroke);
background: #0f0f12;
display: grid;
gap: 14px;
align-content: start;
}
.detailEmpty {
color: var(--text2);
font-size: 13px;
line-height: 1.45;
}
.detailName {
font-size: 18px;
font-weight: 900;
}
.detailSectionTitle {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
}
.detailDesc {
font-size: 13px;
line-height: 1.5;
color: var(--text1);
}
.detailDesc :is(h2, h3) {
margin: 0.6em 0 0.35em;
}
.detailDesc :is(p, ul, ol) {
margin: 0.35em 0;
}
.relationsList {
display: grid;
gap: 6px;
}
.relationItem {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--stroke);
background: #18181b;
font-size: 13px;
font-weight: 600;
}
.listCol {
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 10px;
padding: 12px;
background: #0f0f12;
}
.list {
display: grid;
gap: 8px;
align-content: start;
overflow: auto;
min-height: 0;
}
.tile {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 8px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
cursor: pointer;
text-align: left;
color: inherit;
width: 100%;
}
.tileSelected {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
}
.tileActive {
outline: 1px solid #fbbf24;
}
.tileAvatar {
width: 44px;
height: 44px;
border-radius: 10px;
overflow: hidden;
background: #09090b;
}
.tileAvatarImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tileName {
font-size: 13px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muted {
color: var(--text2);
font-size: 13px;
}
+252
View File
@@ -0,0 +1,252 @@
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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcsApp.module.css';
function ZoomInIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M10.5 7.8v5.4M7.8 10.5h5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomOutIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function RuntimeNpcTile({
npc,
selected,
active,
onActivate,
}: {
npc: ProjectNpc;
selected: boolean;
active: boolean;
onActivate: () => void;
}) {
const url = useAssetUrl(npc.avatarAssetId);
return (
<button
type="button"
className={[
styles.tile,
selected ? styles.tileSelected : '',
active ? styles.tileActive : '',
]
.filter(Boolean)
.join(' ')}
onClick={onActivate}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
);
}
export function NpcsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (overlay?.activeNpcId) {
void overlayApi.dispatch({ kind: 'hide' });
return;
}
if (overlay?.zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
window.close();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
const npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
const activeId = overlay?.activeNpcId ?? null;
const zoomTool = overlay?.zoomTool ?? null;
const selected = npcs.find((n) => n.id === selectedId) ?? null;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
.filter((r) => r.sourceNpcId === selected.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
});
}, [npcs, relations, selected]);
const onSelectTile = useCallback(
(id: NpcId) => {
setSelectedId(id);
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
},
[overlayApi],
);
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
return (
<div className={styles.page}>
<div className={styles.toolbar}>
<div className={styles.toolbarRow}>
<Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomIn')}
ariaLabel={t('npcs.zoomIn')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
});
}}
>
<ZoomInIcon />
</Button>
<Button
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomOut')}
ariaLabel={t('npcs.zoomOut')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
});
}}
>
<ZoomOutIcon />
</Button>
{activeId ? (
<Button
title={t('npcs.closeOverlay')}
ariaLabel={t('npcs.closeOverlay')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({ kind: 'hide' });
}}
>
{t('npcs.closeOverlay')}
</Button>
) : null}
</div>
<div className={styles.toolbarHint}>
{zoomTool === 'zoomIn'
? t('npcs.zoomInHint')
: zoomTool === 'zoomOut'
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
</div>
<div className={styles.body}>
<div className={styles.detail}>
{selected ? (
<>
<div className={styles.detailName}>{selected.name}</div>
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div
className={styles.detailDesc}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)}
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relationsForSelected.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
</div>
))}
</div>
</div>
) : null}
</>
) : (
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div>
)}
</div>
<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)}
/>
))}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
</div>
</div>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
.page {
height: 100vh;
display: grid;
grid-template-rows: 56px 1fr;
background: #09090b;
color: var(--text1);
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--stroke);
background: #18181b;
}
.topTitle {
font-weight: 900;
font-size: 15px;
letter-spacing: 0.2px;
}
.body {
display: grid;
grid-template-columns: 280px 1fr 480px;
min-height: 0;
}
.col {
min-height: 0;
border-right: 1px solid var(--stroke);
}
.col:last-child {
border-right: 0;
}
.side {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
padding: 12px;
min-height: 0;
background: #0f0f12;
}
.list {
display: grid;
gap: 8px;
align-content: start;
overflow: auto;
min-height: 0;
padding-right: 2px;
}
.tile {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px;
align-items: center;
padding: 4px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
width: 100%;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 4px;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
min-width: 0;
}
.tileSelected {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
}
.tileDragging {
opacity: 0.55;
}
.tileDropBefore {
box-shadow: inset 0 2px 0 #60a5fa;
}
.tileDropAfter {
box-shadow: inset 0 -2px 0 #60a5fa;
}
.tileAvatar {
width: 44px;
height: 44px;
border-radius: 10px;
overflow: hidden;
background: #09090b;
}
.tileAvatarImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tileName {
font-size: 13px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tileMenuBtn {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
}
.tileMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.inspector {
display: grid;
grid-template-rows: 1fr;
min-height: 0;
background: #0f0f12;
}
.inspectorScroll {
overflow: auto;
padding: 14px 16px 24px;
display: grid;
gap: 14px;
align-content: start;
}
.fieldLabel {
color: var(--text2);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
margin-bottom: 8px;
}
.avatarPick {
display: grid;
gap: 10px;
justify-items: start;
}
.avatarPreview {
width: 120px;
height: 120px;
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--stroke);
background: #09090b;
}
.avatarPreviewImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.descShell {
display: grid;
grid-template-rows: auto 1fr;
min-height: 220px;
border-radius: var(--radius-md, 10px);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, #0c0c0f);
overflow: hidden;
}
.descContent {
min-height: 160px;
max-height: 280px;
overflow: auto;
padding: 12px 14px;
}
.relationsTitle {
font-weight: 900;
font-size: 13px;
margin-bottom: 8px;
}
.relationsList {
display: grid;
gap: 6px;
}
.relationItem {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--stroke);
background: #18181b;
font-size: 13px;
font-weight: 600;
}
.emptyInspector {
color: var(--text2);
font-size: 13px;
line-height: 1.45;
}
.muted {
color: var(--text2);
font-size: 13px;
}
.menu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
background: #18181b;
border: 1px solid var(--stroke);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.menuItem {
border: 0;
background: transparent;
color: var(--text1);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
width: 100%;
}
.menuItem:hover {
background: #27272a;
}
.menuItemDanger {
border: 0;
background: transparent;
color: var(--color-danger);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
width: 100%;
}
.menuItemDanger:hover {
background: rgba(239, 68, 68, 0.18);
}
+538
View File
@@ -0,0 +1,538 @@
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 editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import { NpcGraph } from './NpcGraph';
import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css';
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
function NpcTile({
npc,
selected,
dragging,
dropPlace,
onSelect,
onMenu,
onDragStart,
onDragEnd,
onDragOver,
onDropReorder,
}: {
npc: ProjectNpc;
selected: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (place: 'before' | 'after') => void;
onDropReorder: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId);
return (
<div
className={[
styles.tile,
selected ? styles.tileSelected : '',
dragging ? styles.tileDragging : '',
dropPlace === 'before' ? styles.tileDropBefore : '',
dropPlace === 'after' ? styles.tileDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
e.dataTransfer.effectAllowed = 'move';
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(e) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onDragOver(e.clientY < mid ? 'before' : 'after');
}}
onDrop={(e) => {
e.preventDefault();
onDropReorder();
}}
>
<button type="button" className={styles.tileBody} onClick={onSelect}>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
<button
type="button"
className={styles.tileMenuBtn}
data-npc-menu-root="1"
aria-label={t('npcs.tileMenu')}
onClick={onMenu}
>
</button>
</div>
);
}
export function NpcsEditorApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
const [editOpen, setEditOpen] = useState(false);
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
const [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 [nameDraft, setNameDraft] = useState('');
const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
| { mode: 'edit'; relationId: NpcRelationId; label: string }
| null
>(null);
const [pendingDeleteRelation, setPendingDeleteRelation] = useState<ProjectNpcRelation | null>(null);
const [avatarBusy, setAvatarBusy] = useState(false);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
const selected = npcs.find((n) => n.id === selectedId) ?? null;
useEffect(() => {
setNameDraft(selected?.name ?? '');
}, [selected?.id, selected?.name]);
useEffect(() => {
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
setSelectedId(npcs[0]?.id ?? null);
}, [npcs, selectedId]);
useEffect(() => {
if (!menuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
setMenuFor(null);
setMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
.filter((r) => r.sourceNpcId === selected.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { relation: r, otherName: other?.name ?? '—' };
});
}, [npcs, relations, selected]);
const pickAvatar = useCallback(async () => {
const res = await api.invoke(ipcChannels.project.pickNpcAvatar, {});
if (res.canceled) return null;
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]);
const graphUi = useMemo(
() => ({
zoomBar: t('npcs.graphZoomBar'),
zoomIn: t('npcs.graphZoomIn'),
zoomOut: t('npcs.graphZoomOut'),
fitAll: t('npcs.graphFitAll'),
editRelation: t('npcs.relationEdit'),
deleteRelation: t('npcs.relationDelete'),
untitled: t('npcs.untitled'),
}),
[t],
);
return (
<div className={styles.page}>
<div className={styles.topBar}>
<div className={styles.topTitle}>{t('npcs.editorTitle')}</div>
<Button
onClick={() => {
void api.invoke(ipcChannels.windows.closeNpcsEditor, {});
}}
>
{t('common.close')}
</Button>
</div>
<div className={styles.body}>
<div className={[styles.col, styles.side].join(' ')}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<Button
variant="primary"
onClick={() => {
setEditInitial(null);
setEditOpen(true);
}}
>
{t('npcs.add')}
</Button>
<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));
}}
onDragStart={() => setDragId(n.id)}
onDragEnd={() => {
setDragId(null);
setDropPlace(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === n.id) {
setDropPlace(null);
return;
}
setDropPlace({ id: n.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 });
}}
/>
))}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
</div>
<div className={styles.col}>
<NpcGraph
npcs={npcs}
relations={relations}
selectedNpcId={selectedId}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
}}
onNodePositionCommit={(npcId, x, y) => {
void api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y });
}}
onEditRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setRelationModal({ mode: 'edit', relationId, label: rel.label });
}}
onDeleteRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setPendingDeleteRelation(rel);
}}
/>
</div>
<div className={[styles.col, styles.inspector].join(' ')}>
<div className={styles.inspectorScroll}>
{selected ? (
<>
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}>
<div className={styles.avatarPreview}>
{selectedUrl ? (
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
) : null}
</div>
<Button
disabled={avatarBusy}
onClick={() => {
void (async () => {
setAvatarBusy(true);
try {
const picked = await pickAvatar();
if (!picked) return;
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath: picked.filePath,
});
} finally {
setAvatarBusy(false);
}
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
<input
className={controlStyles.input}
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => {
const next = nameDraft.trim();
if (!next || next === selected.name) {
setNameDraft(selected.name);
return;
}
void api
.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
name: next,
})
.catch(() => setNameDraft(selected.name));
}}
placeholder={t('npcs.namePlaceholder')}
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField
html={selected.description}
onCommit={(html) => {
if (html === selected.description) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
description: html,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relationsForSelected.map(({ relation, otherName }) => (
<div key={relation.id} className={styles.relationItem}>
{relation.label} {otherName}
</div>
))}
</div>
</div>
) : null}
</>
) : (
<div className={styles.emptyInspector}>{t('npcs.selectPrompt')}</div>
)}
</div>
</div>
</div>
<NpcEditModal
open={editOpen}
initial={editInitial}
existingNames={npcs.map((n) => n.name)}
onClose={() => setEditOpen(false)}
onPickImage={pickAvatar}
onSave={async (input) => {
const res = await api.invoke(ipcChannels.project.upsertNpc, {
...(editInitial ? { npcId: editInitial.id } : {}),
name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}),
});
const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id);
}}
/>
<NpcRelationModal
open={Boolean(relationModal)}
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
onClose={() => setRelationModal(null)}
onSave={async (label) => {
if (!relationModal) return;
if (relationModal.mode === 'create') {
await api.invoke(ipcChannels.project.upsertNpcRelation, {
sourceNpcId: relationModal.sourceNpcId,
targetNpcId: relationModal.targetNpcId,
label,
});
} else {
const rel = relations.find((r) => r.id === relationModal.relationId);
if (!rel) return;
await api.invoke(ipcChannels.project.upsertNpcRelation, {
relationId: rel.id,
sourceNpcId: rel.sourceNpcId,
targetNpcId: rel.targetNpcId,
label,
});
}
}}
/>
{menuFor && menuPos
? createPortal(
<div
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
onMouseDown={(e) => e.stopPropagation()}
>
<button
type="button"
className={styles.menuItemDanger}
onClick={() => {
const npc = npcs.find((n) => n.id === menuFor);
if (npc) setPendingDelete(npc);
setMenuFor(null);
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDelete(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.deleteTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDelete(null)}
>
×
</button>
</div>
<div>{t('npcs.deleteConfirm', { name: pendingDelete.name })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDelete.id;
setPendingDelete(null);
void api.invoke(ipcChannels.project.deleteNpc, { npcId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteRelation
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteRelation(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.relationDeleteTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteRelation(null)}
>
×
</button>
</div>
<div>
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteRelation.id;
setPendingDeleteRelation(null);
void api.invoke(ipcChannels.project.deleteNpcRelation, { relationId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { NpcsEditorApp } from './NpcsEditorApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<NpcsEditorApp />
</EditorI18nProvider>
</React.StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { NpcsApp } from './NpcsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<NpcsApp />
</EditorI18nProvider>
</React.StrictMode>,
);