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