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>
);
}