feat(npcs): add groups, storyline bindings, and Foundry import

Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+8 -2
View File
@@ -153,7 +153,8 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
z-index: 30000;
}
.editorLockOverlay {
@@ -380,9 +381,14 @@
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
padding-right: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
background-color: var(--bg0);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text0);
font: inherit;
}
+117 -18
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import type {
NpcImportResolution,
SceneImportResolution,
StorylineImportMergeReport,
StorylineSelection,
@@ -42,6 +43,7 @@ import {
sceneTitleFromMediaPath,
useFileDropZone,
} from './fileDrop';
import { FoundryImportModal } from './FoundryImportModal';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
DND_SCENE_ID_MIME,
@@ -58,12 +60,15 @@ import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';
import {
buildNpcResolutionsForImport,
buildSceneResolutionsForImport,
computeImportConflicts,
computeNpcImportConflicts,
ExportProjectModal,
ImportReportModal,
ImportSourceModal,
ImportStorylinesModal,
NpcConflictModal,
SceneConflictModal,
useStorylineLabels,
type ImportPeekResult,
@@ -123,11 +128,17 @@ export function EditorApp() {
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
const [foundryImportOpen, setFoundryImportOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<
ReturnType<typeof computeNpcImportConflicts>
>([]);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
@@ -499,22 +510,71 @@ export function EditorApp() {
[actions, storylineLabels],
);
const clearImportFlow = useCallback(() => {
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportNpcConflictsOpen(false);
setPendingImportSelections([]);
setPendingSceneResolutions([]);
setImportConflicts([]);
setImportNpcConflicts([]);
}, []);
const runStorylineMerge = useCallback(
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
async (
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions: NpcImportResolution[],
) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
? await actions.mergeImportFromProject(
importPeek.sourceProjectId,
selections,
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
setImportReport(report);
setImportReportOpen(true);
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setPendingImportSelections([]);
setImportConflicts([]);
clearImportFlow();
},
[actions, importPeek],
[actions, clearImportFlow, importPeek],
);
const continueImportAfterScenes = useCallback(
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
if (!importPeek || !state.project) return;
const npcConflicts = computeNpcImportConflicts(
state.project,
importPeek.sourceProject,
selections,
);
setPendingImportSelections(selections);
setPendingSceneResolutions(sceneResolutions);
setImportConflictsOpen(false);
if (npcConflicts.length > 0) {
setImportNpcConflicts(npcConflicts);
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
);
const handleImportSourceContinue = useCallback(
@@ -554,10 +614,16 @@ export function EditorApp() {
setImportSourceOpen(true);
}, []);
const handleImportFoundry = useCallback(() => {
setProjectMenuOpen(false);
setFoundryImportOpen(true);
}, []);
const goHome = useCallback(() => {
setProjectMenuOpen(false);
setExportModalOpen(false);
setImportSourceOpen(false);
setFoundryImportOpen(false);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportReportOpen(false);
@@ -1294,6 +1360,18 @@ export function EditorApp() {
>
{t('projectMenu.import')}
</button>
{!state.project ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
handleImportFoundry();
}}
>
{t('projectMenu.importFoundry')}
</button>
) : null}
<button
type="button"
role="menuitem"
@@ -1373,6 +1451,16 @@ export function EditorApp() {
onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue}
/>
<FoundryImportModal
open={foundryImportOpen}
pickSource={actions.pickFoundrySource}
onClose={() => setFoundryImportOpen(false)}
onImport={async (selection) => {
// Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
setFoundryImportOpen(false);
await actions.importFoundryProject(selection.sourcePath);
}}
/>
<ImportStorylinesModal
open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''}
@@ -1385,10 +1473,10 @@ export function EditorApp() {
if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections);
setImportStorylinesOpen(false);
if (conflicts.length > 0) {
setImportConflicts(conflicts);
setImportConflictsOpen(true);
setImportStorylinesOpen(false);
return;
}
const resolutions = buildSceneResolutionsForImport(
@@ -1398,18 +1486,13 @@ export function EditorApp() {
[],
[],
);
void runStorylineMerge(selections, resolutions);
continueImportAfterScenes(selections, resolutions);
}}
/>
<SceneConflictModal
open={importConflictsOpen}
conflicts={importConflicts}
onClose={() => {
setImportConflictsOpen(false);
setImportPeek(null);
setPendingImportSelections([]);
setImportConflicts([]);
}}
onClose={clearImportFlow}
onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport(
@@ -1419,7 +1502,23 @@ export function EditorApp() {
importConflicts,
userResolutions,
);
void runStorylineMerge(pendingImportSelections, resolutions);
continueImportAfterScenes(pendingImportSelections, resolutions);
}}
/>
<NpcConflictModal
open={importNpcConflictsOpen}
conflicts={importNpcConflicts}
onClose={clearImportFlow}
onConfirm={(userNpcResolutions) => {
if (!importPeek || !state.project) return;
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importNpcConflicts,
userNpcResolutions,
);
void runStorylineMerge(pendingImportSelections, pendingSceneResolutions, npcResolutions);
}}
/>
<ImportReportModal
+145
View File
@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type FoundryImportSourceSelection =
| { kind: 'folder'; sourcePath: string }
| { kind: 'archive'; sourcePath: string };
type FoundryImportModalProps = {
open: boolean;
pickSource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
onClose: () => void;
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
};
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
const { t } = useEditorI18n();
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setMode('folder');
setPicked(null);
setSubmitting(false);
setError(null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !submitting) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, submitting]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<select
className={styles.selectInput}
value={mode}
disabled={submitting}
onChange={(e) => {
setMode(e.target.value as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
>
<option value="folder">{t('foundryImport.folder')}</option>
<option value="archive">{t('foundryImport.archive')}</option>
</select>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickSource(mode);
if (res.canceled) return;
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
setPicked({ path: res.sourcePath, name });
})();
}}
>
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
</Button>
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!picked || submitting}
onClick={() => {
if (!picked) return;
void (async () => {
setSubmitting(true);
setError(null);
try {
await onImport({
kind: mode,
sourcePath: picked.path,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('foundryImport.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+229 -24
View File
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts,
findSceneTitleConflicts,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
type SceneImportResolution,
type SceneTitleConflict,
type StorylineImportMergeReport,
@@ -14,8 +18,9 @@ import {
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button } from '../shared/ui/controls';
import { useEditorI18n } from './i18n/EditorI18nContext';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
type ExportProjectModalProps = {
open: boolean;
@@ -109,11 +114,21 @@ export function ExportProjectModal({
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
@@ -145,7 +160,10 @@ export function ExportProjectModal({
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={checked}
@@ -266,17 +284,25 @@ export function ImportSourceModal({
const canContinue =
!submitting &&
(importKind === 'project'
? canImportFromProject && sourceProjectId !== null
: pickedFile !== null);
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importSource.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
@@ -432,11 +458,21 @@ export function ImportStorylinesModal({
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
@@ -454,7 +490,10 @@ export function ImportStorylinesModal({
const key = storylineSelectionKey(item.selection);
const disabled = item.disabled === true;
return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={selectedKeys.has(key)}
@@ -474,11 +513,7 @@ export function ImportStorylinesModal({
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
disabled={!canContinue}
onClick={() => onContinue(selectedSelections)}
>
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
{t('importStoryline.continue')}
</Button>
</div>
@@ -512,11 +547,21 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
@@ -571,6 +616,121 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
);
}
type NpcConflictModalProps = {
open: boolean;
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
};
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
const init: Record<string, 'create' | string> = {};
for (const c of conflicts) {
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
}
return init;
}
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
if (!open) return null;
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
return (
<NpcConflictModalBody
key={remountKey}
conflicts={conflicts}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
function NpcConflictModalBody({
conflicts,
onClose,
onConfirm,
}: {
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
}) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div
role="dialog"
aria-modal="true"
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceNpcId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceName}</div>
<select
className={styles.selectInput}
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(e) => {
const v = e.target.value;
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
>
<option value="create">{t('importStoryline.createNewNpc')}</option>
{c.matches.map((m) => (
<option key={m.npcId} value={m.npcId}>
{t('importStoryline.useExistingNpc', { name: m.name })}
</option>
))}
</select>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceNpcId] ?? 'create';
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportReportModalProps = {
open: boolean;
report: StorylineImportMergeReport | null;
@@ -593,11 +753,21 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
@@ -606,14 +776,14 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
{report.renamedSideTitles.length > 0 ? (
<li>
{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
</li>
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
) : null}
</ul>
@@ -659,6 +829,41 @@ export function computeImportConflicts(
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
}
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
const out: NpcImportResolution[] = [];
for (const n of exported) {
if (conflictIds.has(n.id)) {
const r = bySource.get(n.id);
if (r) out.push(r);
continue;
}
out.push({ sourceNpcId: n.id, mode: 'create' });
}
return out;
}
export function computeNpcImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): NpcNameConflict[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
return findNpcNameConflicts(
targetProject,
sourceProject,
exported.map((n) => n.id),
);
}
export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n();
return useMemo(
@@ -235,9 +235,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'projectMenu.home': 'Начальный экран',
'projectMenu.import': 'Импорт',
'projectMenu.importFoundry': 'Импорт из Foundry',
'projectMenu.export': 'Экспорт',
'projectMenu.noProjects': 'Нет сохранённых проектов',
'foundryImport.title': 'Импорт из Foundry',
'foundryImport.hint':
'Выберите папку или архив мира (.world) либо модуля Foundry VTT (версии 11+). Будет создан новый проект.',
'foundryImport.sourceType': 'ТИП ИСТОЧНИКА',
'foundryImport.folder': 'Папка',
'foundryImport.archive': 'Архив (.zip / .fvtt)',
'foundryImport.source': 'ИСТОЧНИК',
'foundryImport.chooseFolder': 'Выбрать папку',
'foundryImport.chooseArchive': 'Выбрать архив',
'foundryImport.noSourceSelected': 'Не выбрано',
'foundryImport.import': 'Импортировать',
'fileMenu.rename': 'Переименовать проект',
'scenes.search': 'Поиск сцен…',
@@ -301,11 +314,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'importStoryline.reportLines': 'Импортировано линий: {count}',
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
'importStoryline.reportNpcsCreated': 'Создано новых НПС: {count}',
'importStoryline.reportNpcsReused': 'Использовано существующих НПС: {count}',
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
'importStoryline.reportEdges': 'Добавлено связей: {count}',
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
'importStoryline.npcConflictsTitle': 'Совпадение имён НПС',
'importStoryline.npcConflictsHint':
'В импортируемых линиях есть НПС с такими же именами, как в текущем проекте. Выберите действие для каждого.',
'importStoryline.createNewNpc': 'Создать нового НПС',
'importStoryline.useExistingNpc': 'Использовать «{name}»',
'confirmDelete.title': 'Удаление проекта',
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
@@ -411,6 +431,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
'npcs.ungrouped': 'Без группы',
'npcs.addGroup': 'Новая группа',
'npcs.editGroup': 'Изменить группу',
'npcs.deleteGroup': 'Удалить группу',
'npcs.groupName': 'Название группы',
'npcs.groupColor': 'Цвет',
'npcs.groupNameRequired': 'Укажите название группы.',
'npcs.groupNameDup': 'Группа с таким названием уже есть.',
'npcs.deleteGroupTitle': 'Удаление группы',
'npcs.deleteGroupConfirm':
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
'npcs.addSubgroup': 'Добавить подгруппу',
'npcs.group': 'ГРУППА',
'npcs.bindingEnable': 'Привязать…',
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
'npcs.bindingStoryline': 'Сюжетная линия',
'npcs.bindingScene': 'Сцена',
'npcs.bindingMain': 'Основная линия',
'npcs.bindingSelect': 'ОБЪЕКТ',
'npcs.graphFilterAll': 'Все',
'npcs.graphFilterUngrouped': 'Без группы',
'npcs.graphFilter': 'Фильтр графа',
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
'scene.description': 'ОПИСАНИЕ',
@@ -727,9 +769,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'projectMenu.home': 'Home',
'projectMenu.import': 'Import',
'projectMenu.importFoundry': 'Import from Foundry',
'projectMenu.export': 'Export',
'projectMenu.noProjects': 'No saved projects',
'foundryImport.title': 'Import from Foundry',
'foundryImport.hint':
'Choose a Foundry VTT world or module folder or archive (version 11+). A new project will be created.',
'foundryImport.sourceType': 'SOURCE TYPE',
'foundryImport.folder': 'Folder',
'foundryImport.archive': 'Archive (.zip / .fvtt)',
'foundryImport.source': 'SOURCE',
'foundryImport.chooseFolder': 'Choose folder',
'foundryImport.chooseArchive': 'Choose archive',
'foundryImport.noSourceSelected': 'Nothing selected',
'foundryImport.import': 'Import',
'fileMenu.rename': 'Rename project',
'scenes.search': 'Search scenes…',
@@ -793,11 +848,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'importStoryline.reportLines': 'Storylines imported: {count}',
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
'importStoryline.reportNpcsCreated': 'New NPCs created: {count}',
'importStoryline.reportNpcsReused': 'Existing NPCs reused: {count}',
'importStoryline.reportNodes': 'Graph cards added: {count}',
'importStoryline.reportEdges': 'Connections added: {count}',
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
'importStoryline.npcConflictsTitle': 'Duplicate NPC names',
'importStoryline.npcConflictsHint':
'Imported storylines contain NPCs with the same names as in the current project. Choose what to do for each.',
'importStoryline.createNewNpc': 'Create new NPC',
'importStoryline.useExistingNpc': 'Use existing «{name}»',
'confirmDelete.title': 'Delete project',
'confirmDelete.body':
@@ -904,6 +966,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
'npcs.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
'npcs.ungrouped': 'Ungrouped',
'npcs.addGroup': 'New group',
'npcs.editGroup': 'Edit group',
'npcs.deleteGroup': 'Delete group',
'npcs.groupName': 'Group name',
'npcs.groupColor': 'Color',
'npcs.groupNameRequired': 'Group name is required.',
'npcs.groupNameDup': 'A group with this name already exists.',
'npcs.deleteGroupTitle': 'Delete group',
'npcs.deleteGroupConfirm':
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
'npcs.addSubgroup': 'Add subgroup',
'npcs.group': 'GROUP',
'npcs.bindingEnable': 'Bind…',
'npcs.bindingKind': 'BINDING TYPE',
'npcs.bindingStoryline': 'Storyline',
'npcs.bindingScene': 'Scene',
'npcs.bindingMain': 'Main storyline',
'npcs.bindingSelect': 'TARGET',
'npcs.graphFilterAll': 'All',
'npcs.graphFilterUngrouped': 'Ungrouped',
'npcs.graphFilter': 'Graph filter',
'scene.title': 'SCENE TITLE',
'scene.description': 'DESCRIPTION',
+31
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type {
NpcImportResolution,
SceneImportResolution,
StorylineImportMergeReport,
StorylineLabels,
@@ -94,6 +95,10 @@ type Actions = {
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>;
pickFoundrySource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
| { canceled: true }
| {
@@ -129,11 +134,13 @@ type Actions = {
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
mergeImportFromProject: (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
@@ -749,6 +756,24 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}
};
const pickFoundrySource = async (mode: 'folder' | 'archive') => {
return api.invoke(ipcChannels.project.pickFoundrySource, { mode });
};
const importFoundryProject = async (sourcePath: string) => {
try {
const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
};
@@ -781,11 +806,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
filePath,
storylineSelections,
sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
});
setState((s) => ({
...s,
@@ -799,11 +826,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
sourceProjectId,
storylineSelections,
sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
});
setState((s) => ({
...s,
@@ -884,6 +913,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
renameProject,
importProject,
importProjectFromPath,
pickFoundrySource,
importFoundryProject,
peekImportZip,
pickImportZipFile,
peekImportZipPath,
+140
View File
@@ -0,0 +1,140 @@
import React, { useMemo } from 'react';
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import controlStyles from '../shared/ui/Controls.module.css';
type NpcBindingFieldsProps = {
project: Project;
binding: NpcBinding;
onChange: (binding: NpcBinding) => void;
};
function defaultBinding(project: Project): NpcBinding {
const opts = listStorylineOptionsForBinding(project);
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
if (opts.sides[0]) {
return {
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
};
}
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
if (firstScene) return { kind: 'scene', sceneId: firstScene };
return noneBinding();
}
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
const { t } = useEditorI18n();
const enabled = !isNpcBindingNone(binding);
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
const sceneOptions = useMemo(
() =>
Object.entries(project.scenes)
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
[project.scenes],
);
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
const bindingTargetValue = useMemo(() => {
if (binding.kind === 'scene') return binding.sceneId;
if (binding.kind === 'storyline') {
if (binding.storyline.kind === 'main') return 'main';
return `side:${binding.storyline.startGraphNodeId}`;
}
return '';
}, [binding]);
return (
<div className={editorStyles.fieldGrid}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
}}
/>
<span>{t('npcs.bindingEnable')}</span>
</label>
{enabled ? (
<>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<select
className={controlStyles.input}
value={kind}
onChange={(e) => {
const nextKind = e.target.value;
if (nextKind === 'scene') {
const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
return;
}
if (storylineOpts.main) {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
} else if (storylineOpts.sides[0]) {
onChange({
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
});
} else {
onChange(noneBinding());
}
}}
>
<option value="storyline">{t('npcs.bindingStoryline')}</option>
<option value="scene">{t('npcs.bindingScene')}</option>
</select>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<select
className={controlStyles.input}
value={bindingTargetValue}
onChange={(e) => {
const v = e.target.value;
if (kind === 'scene') {
onChange({ kind: 'scene', sceneId: v as SceneId });
return;
}
if (v === 'main') {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
return;
}
if (v.startsWith('side:')) {
onChange({
kind: 'storyline',
storyline: {
kind: 'side',
startGraphNodeId: v.slice(5) as GraphNodeId,
},
});
}
}}
>
{kind === 'storyline' ? (
<>
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
{storylineOpts.sides.map((s) => (
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
{s.label}
</option>
))}
</>
) : (
sceneOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))
)}
</select>
</>
) : null}
</div>
);
}
+75 -17
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ProjectNpc } from '../../shared/types';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import {
filterMaterialImagePaths,
@@ -13,24 +13,51 @@ import {
} from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css';
import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
function flattenGroupOptions(
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
depth = 0,
): { id: NpcGroupId; label: string }[] {
const out: { id: NpcGroupId; label: string }[] = [];
for (const node of nodes) {
const prefix = depth > 0 ? ' '.repeat(depth) : '';
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
out.push(...flattenGroupOptions(node.children, depth + 1));
}
return out;
}
type NpcEditModalProps = {
open: boolean;
initial: ProjectNpc | null;
existingNames: string[];
project: Project | null;
npcGroups: ProjectNpcGroup[];
onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
onSave: (input: {
name: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
}) => Promise<void>;
};
export function NpcEditModal({
open,
initial,
existingNames,
project,
npcGroups,
onClose,
onPickImage,
onSave,
@@ -39,15 +66,24 @@ export function NpcEditModal({
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
const groupOptions = useMemo(() => {
const { roots } = buildNpcGroupForest(npcGroups, []);
return flattenGroupOptions(roots);
}, [npcGroups]);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setFilePath(null);
setLocalPreviewUrl(null);
setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false);
setError(null);
}, [initial, open]);
@@ -95,7 +131,7 @@ export function NpcEditModal({
);
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl;
const previewSrc = localPreviewUrl ?? existingUrl;
if (!open) return null;
@@ -109,9 +145,7 @@ export function NpcEditModal({
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
</div>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
@@ -129,6 +163,24 @@ export function NpcEditModal({
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
</div>
{!initial && groupOptions.length > 0 ? (
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={groupId}
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
) : null}
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
<div
@@ -139,11 +191,11 @@ export function NpcEditModal({
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
const files = e.dataTransfer.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
const entry = entries[i];
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
const file = files[i];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
@@ -153,9 +205,7 @@ export function NpcEditModal({
}
}}
>
{drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
{previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : (
@@ -176,6 +226,10 @@ export function NpcEditModal({
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
@@ -191,7 +245,11 @@ export function NpcEditModal({
setSaving(true);
setError(null);
try {
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
await onSave({
name: trimmed,
...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}),
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
+26 -1
View File
@@ -40,11 +40,20 @@
border: 1px solid var(--stroke);
background: #18181b;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
border-left-width: 3px;
border-left-color: var(--npc-group-color, var(--stroke));
}
.nodeActive {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35);
border-left-color: var(--npc-group-color, #60a5fa);
box-shadow:
0 0 0 1px var(--npc-group-color, #60a5fa),
0 8px 24px rgba(0, 0, 0, 0.35);
}
.nodeDimmed {
opacity: 0.28;
}
.avatar {
@@ -152,3 +161,19 @@
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
.filterSelect {
min-width: 160px;
padding: 6px 10px;
padding-right: 28px;
border-radius: 8px;
border: 1px solid var(--stroke);
background-color: rgba(24, 24, 27, 0.92);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text1);
font-size: 12px;
font-weight: 600;
}
+206 -128
View File
@@ -22,7 +22,15 @@ import ReactFlow, {
} from 'reactflow';
import 'reactflow/dist/style.css';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
import type {
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcGraph.module.css';
@@ -32,6 +40,8 @@ type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
export type NpcGraphUiStrings = {
zoomBar: string;
zoomIn: string;
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
editRelation: string;
deleteRelation: string;
untitled: string;
graphFilter: string;
graphFilterAll: string;
graphFilterUngrouped: string;
};
type NpcNodeData = {
name: string;
avatarAssetId: ProjectNpc['avatarAssetId'];
active: boolean;
groupColor: string | null;
dimmed: boolean;
};
const NPC_ACCENT = '#60a5fa';
@@ -102,30 +117,35 @@ function pickEndpointSides(
? { sourceSide: 'right', targetSide: 'left' }
: { sourceSide: 'left', targetSide: 'right' };
}
return dy >= 0
? { sourceSide: 'bottom', targetSide: 'top' }
: { sourceSide: 'top', targetSide: 'bottom' };
return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
}
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
const url = useAssetUrl(data.avatarAssetId);
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
const accent = data.groupColor ?? NPC_ACCENT;
return (
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}>
<div
className={[
styles.node,
data.active || selected ? styles.nodeActive : '',
data.dimmed ? styles.nodeDimmed : '',
]
.filter(Boolean)
.join(' ')}
style={
data.groupColor
? ({
'--npc-group-color': accent,
borderColor: data.active || selected ? accent : undefined,
} as React.CSSProperties)
: undefined
}
>
{sides.map((side) => (
<React.Fragment key={side}>
<Handle
type="source"
position={sideToPosition(side)}
id={`s-${side}`}
className={styles.handle}
/>
<Handle
type="target"
position={sideToPosition(side)}
id={`t-${side}`}
className={styles.handle}
/>
<Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
<Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
</React.Fragment>
))}
<div className={styles.avatar}>
@@ -151,9 +171,7 @@ function parallelCubicPath(
): { path: string; labelX: number; labelY: number } {
// Канонический вектор между концами (не зависит от направления стрелки).
const [ax, ay, bx, by] =
sourceNpcId < targetNpcId
? [sourceX, sourceY, targetX, targetY]
: [targetX, targetY, sourceX, sourceY];
sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
const cdx = bx - ax;
const cdy = by - ay;
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
const targetNpcId = data?.targetNpcId;
const { path, labelX, labelY } =
sourceNpcId && targetNpcId
? parallelCubicPath(
sourceNpcId,
targetNpcId,
sourceX,
sourceY,
targetX,
targetY,
worldOffset,
)
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
: {
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
labelX: (sourceX + targetX) / 2,
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
{label && relationId ? (
<EdgeLabelRenderer>
<div
className={[
styles.edgeLabel,
highlighted ? styles.edgeLabelActive : '',
'nodrag',
'nopan',
]
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
.filter(Boolean)
.join(' ')}
style={{
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button>
<button type="button" className={styles.zoomBtn} onClick={() => fitView({ padding: 0.2 })} aria-label={ui.fitAll}>
<button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.2 })}
aria-label={ui.fitAll}
>
</button>
</div>
@@ -277,10 +287,44 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
);
}
function FilterToolbar({
ui,
groups,
value,
onChange,
}: {
ui: NpcGraphUiStrings;
groups: ProjectNpcGroup[];
value: GraphGroupFilter;
onChange: (v: GraphGroupFilter) => void;
}) {
return (
<Panel position="top-left">
<select
className={styles.filterSelect}
aria-label={ui.graphFilter}
value={value}
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
>
<option value="all">{ui.graphFilterAll}</option>
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</Panel>
);
}
export type NpcGraphProps = {
npcs: ProjectNpc[];
relations: ProjectNpcRelation[];
npcGroups: ProjectNpcGroup[];
selectedNpcId: NpcId | null;
graphFilter: GraphGroupFilter;
onGraphFilterChange: (filter: GraphGroupFilter) => void;
graphUi: NpcGraphUiStrings;
onSelect: (npcId: NpcId) => void;
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
function NpcGraphInner({
npcs,
relations,
npcGroups,
selectedNpcId,
graphFilter,
onGraphFilterChange,
graphUi,
onSelect,
onConnectRequest,
@@ -300,9 +347,7 @@ function NpcGraphInner({
onEditRelation,
onDeleteRelation,
}: NpcGraphProps) {
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
null,
);
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
const connectFromRef = useRef<NpcId | null>(null);
@@ -320,6 +365,23 @@ function NpcGraphInner({
};
}, [menu]);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const filterGroupIds = useMemo(() => {
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
return collectDescendantGroupIds(npcGroups, graphFilter);
}, [graphFilter, npcGroups]);
const isNpcDimmed = useCallback(
(npc: ProjectNpc) => {
if (graphFilter === 'all') return false;
if (graphFilter === 'ungrouped') return npc.groupId !== null;
if (!filterGroupIds) return true;
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
},
[filterGroupIds, graphFilter],
);
const initialNodes: Node<NpcNodeData>[] = useMemo(
() =>
npcs.map((n) => ({
@@ -330,9 +392,11 @@ function NpcGraphInner({
name: n.name,
avatarAssetId: n.avatarAssetId,
active: n.id === selectedNpcId,
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
dimmed: isNpcDimmed(n),
},
})),
[npcs, selectedNpcId],
[groupColorById, isNpcDimmed, npcs, selectedNpcId],
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -364,6 +428,11 @@ function NpcGraphInner({
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
const edgeDimmed =
graphFilter !== 'all' &&
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
out.push({
id: r.id,
@@ -381,7 +450,11 @@ function NpcGraphInner({
targetNpcId: r.targetNpcId,
highlighted,
},
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
style: {
stroke: color,
strokeWidth: highlighted ? 2.5 : 2,
opacity: edgeDimmed ? 0.2 : 1,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
@@ -392,7 +465,7 @@ function NpcGraphInner({
});
}
return out;
}, [nodes, npcs, relations, selectedNpcId]);
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
useEffect(() => {
setNodes(initialNodes);
@@ -435,97 +508,102 @@ function NpcGraphInner({
return (
<OpenEdgeMenuContext.Provider value={openEdgeMenu}>
<SelectSourceNpcContext.Provider value={selectSourceNpc}>
<div className={styles.wrap}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={() => {
connectFromRef.current = null;
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
fitView
proOptions={{ hideAttribution: true }}
onNodeClick={(_e, node) => {
setMenu(null);
onSelect(node.id as NpcId);
}}
onNodeDragStop={(_e, node) => {
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
}}
onEdgeClick={(e, edge) => {
e.stopPropagation();
setMenu(null);
const sourceId =
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
onSelect(sourceId);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
e.stopPropagation();
const relationId =
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
openEdgeMenu(relationId, e.clientX, e.clientY);
}}
onPaneClick={() => setMenu(null)}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
>
<Background gap={18} size={1} color="#27272a" />
<ZoomToolbar ui={graphUi} />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
aria-label="close"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
/>
<div
className={styles.menu}
style={{ left: menuPosition.left, top: menuPosition.top }}
data-npc-edge-menu="1"
onMouseDown={(e) => e.stopPropagation()}
>
<div className={styles.wrap}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={() => {
connectFromRef.current = null;
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
fitView
proOptions={{ hideAttribution: true }}
onNodeClick={(_e, node) => {
setMenu(null);
onSelect(node.id as NpcId);
}}
onNodeDragStop={(_e, node) => {
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
}}
onEdgeClick={(e, edge) => {
e.stopPropagation();
setMenu(null);
const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
onSelect(sourceId);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
e.stopPropagation();
const relationId =
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
openEdgeMenu(relationId, e.clientX, e.clientY);
}}
onPaneClick={() => setMenu(null)}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
>
<Background gap={18} size={1} color="#27272a" />
<FilterToolbar
ui={graphUi}
groups={npcGroups}
value={graphFilter}
onChange={onGraphFilterChange}
/>
<ZoomToolbar ui={graphUi} />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
className={styles.menuItem}
onClick={() => {
onEditRelation(menu.relationId);
aria-label="close"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
/>
<div
className={styles.menu}
style={{ left: menuPosition.left, top: menuPosition.top }}
data-npc-edge-menu="1"
onMouseDown={(e) => e.stopPropagation()}
>
{graphUi.editRelation}
</button>
<button
type="button"
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
onClick={() => {
onDeleteRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.deleteRelation}
</button>
</div>
</>,
document.body,
)
: null}
</div>
<button
type="button"
className={styles.menuItem}
onClick={() => {
onEditRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.editRelation}
</button>
<button
type="button"
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
onClick={() => {
onDeleteRelation(menu.relationId);
setMenu(null);
}}
>
{graphUi.deleteRelation}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</SelectSourceNpcContext.Provider>
</OpenEdgeMenuContext.Provider>
);
@@ -0,0 +1,32 @@
.nameColorRow {
display: flex;
gap: 10px;
align-items: center;
}
.colorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.colorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.colorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
import type { ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { Button, Input } from '../shared/ui/controls';
import styles from './NpcGroupModal.module.css';
type NpcGroupModalProps = {
open: boolean;
initial: ProjectNpcGroup | null;
siblingNames: string[];
onClose: () => void;
onSave: (input: { name: string; color: string }) => Promise<void>;
};
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
setSaving(false);
setError(null);
}, [initial, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup = useMemo(() => {
if (!nameOk) return false;
const key = normalizeName(trimmed);
const except = normalizeName(initial?.name ?? '');
return siblingNames.some((n) => {
const nk = normalizeName(n);
return nk === key && nk !== except;
});
}, [initial?.name, nameOk, siblingNames, trimmed]);
const canSave = nameOk && !nameDup && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.groupName')}</div>
<div className={styles.nameColorRow}>
<input
type="color"
className={styles.colorInput}
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t('npcs.groupColor')}
/>
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
</div>
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</div> : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+1 -5
View File
@@ -64,11 +64,7 @@ export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: N
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
<Input
value={label}
onChange={setLabel}
placeholder={t('npcs.relationLabelPlaceholder')}
/>
<Input value={label} onChange={setLabel} placeholder={t('npcs.relationLabelPlaceholder')} />
{trimmed.length < 1 ? (
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
) : null}
+63
View File
@@ -158,3 +158,66 @@
color: var(--text2);
font-size: 13px;
}
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
user-select: none;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 0;
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+167 -25
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, ProjectNpc } from '../../shared/types';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button, Input } from '../shared/ui/controls';
@@ -37,28 +38,38 @@ function ZoomOutIcon() {
);
}
/** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = [];
for (const node of nodes) {
const children = pruneEmptyGroupNodes(node.children);
if (node.npcs.length === 0 && children.length === 0) continue;
out.push({ ...node, children });
}
return out;
}
function RuntimeNpcTile({
npc,
selected,
active,
accentColor,
onActivate,
}: {
npc: ProjectNpc;
selected: boolean;
active: boolean;
accentColor?: string | null;
onActivate: () => void;
}) {
const url = useAssetUrl(npc.avatarAssetId);
return (
<button
type="button"
className={[
styles.tile,
selected ? styles.tileSelected : '',
active ? styles.tileActive : '',
]
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate}
>
<div className={styles.tileAvatar}>
@@ -69,6 +80,70 @@ function RuntimeNpcTile({
);
}
function RuntimeGroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
activeId,
onActivate,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
activeId: NpcId | null;
onActivate: (id: NpcId) => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div className={styles.groupHeader}>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
</div>
{expanded ? (
<div className={styles.groupBody}>
{node.npcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
accentColor={g.color}
onActivate={() => onActivate(n.id)}
/>
))}
{node.children.map((child) => (
<RuntimeGroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
activeId={activeId}
onActivate={onActivate}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
@@ -76,6 +151,7 @@ export function NpcsApp() {
const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
@@ -106,18 +182,53 @@ export function NpcsApp() {
return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
const npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const activeId = overlay?.activeNpcId ?? null;
const zoomTool = overlay?.zoomTool ?? null;
const selected = npcs.find((n) => n.id === selectedId) ?? null;
const effectiveSelectedId =
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
const filtered = useMemo(() => {
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const searching = query.trim().length > 0;
const { roots, ungrouped } = useMemo(() => {
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
if (!searching) return forest;
return {
roots: pruneEmptyGroupNodes(forest.roots),
ungrouped: forest.ungrouped,
};
}, [filteredNpcs, npcGroups, searching]);
const isExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return true;
return !collapsedGroups.has(id);
},
[collapsedGroups, searching],
);
const toggleExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return;
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[searching],
);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
@@ -202,10 +313,7 @@ export function NpcsApp() {
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div
className={styles.detailDesc}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
@@ -231,17 +339,51 @@ export function NpcsApp() {
<div className={styles.listCol}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.list}>
{filtered.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))}
{npcGroups.length === 0 ? (
filteredNpcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))
) : (
<>
{roots.map((node) => (
<RuntimeGroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={effectiveSelectedId}
activeId={activeId}
onActivate={onSelectTile}
/>
))}
{ungrouped.length > 0 || !searching ? (
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div className={styles.groupBody}>
{ungrouped.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))}
</div>
</div>
) : null}
</>
)}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
+118
View File
@@ -45,6 +45,27 @@
background: #0f0f12;
}
.sideActions {
display: flex;
gap: 8px;
flex-wrap: nowrap;
align-items: stretch;
}
.sideActions > * {
flex: 1 1 0;
min-width: 0;
}
.sideActions button {
width: 100%;
padding-left: 8px;
padding-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list {
display: grid;
gap: 8px;
@@ -272,3 +293,100 @@
.menuItemDanger:hover {
background: rgba(239, 68, 68, 0.18);
}
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
cursor: grab;
user-select: none;
}
.groupHeaderDragging {
opacity: 0.55;
}
.groupDropBefore {
box-shadow: inset 0 2px 0 #60a5fa;
}
.groupDropAfter {
box-shadow: inset 0 -2px 0 #60a5fa;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupMenuBtn {
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text2);
cursor: pointer;
}
.groupMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 8px;
}
.groupBodyDrop {
background: rgba(96, 165, 250, 0.08);
border-radius: 8px;
outline: 1px dashed rgba(96, 165, 250, 0.45);
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+623 -60
View File
@@ -2,7 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type {
NpcBinding,
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
@@ -10,19 +19,33 @@ import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import { NpcGraph } from './NpcGraph';
import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
import { NpcGroupModal } from './NpcGroupModal';
import {
flattenGroupOptions,
moveNpcToGroupEnd,
reorderNpcIds,
reorderSiblingGroups,
} from './npcListHelpers';
import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css';
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
type GroupModalState =
| { mode: 'create'; parentId: NpcGroupId | null }
| { mode: 'edit'; group: ProjectNpcGroup };
function NpcTile({
npc,
selected,
dragging,
dropPlace,
accentColor,
onSelect,
onMenu,
onDragStart,
@@ -34,6 +57,7 @@ function NpcTile({
selected: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
accentColor?: string | null;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
@@ -54,6 +78,7 @@ function NpcTile({
]
.filter(Boolean)
.join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
@@ -91,6 +116,193 @@ function NpcTile({
);
}
function GroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
dragId,
dropPlace,
dragGroupId,
groupDropPlace,
dropTargetGroup,
groupColorById,
onSelectNpc,
onNpcMenu,
onGroupMenu,
onNpcDragStart,
onNpcDragEnd,
onNpcDragOver,
onNpcDropReorder,
onGroupDragStart,
onGroupDragEnd,
onGroupDragOver,
onGroupDropReorder,
onGroupBodyDragOver,
onGroupBodyDrop,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
dragId: NpcId | null;
dropPlace: { id: NpcId; place: 'before' | 'after' } | null;
dragGroupId: NpcGroupId | null;
groupDropPlace: { id: NpcGroupId; place: 'before' | 'after' } | null;
dropTargetGroup: NpcGroupId | null;
groupColorById: Map<NpcGroupId, string>;
onSelectNpc: (id: NpcId) => void;
onNpcMenu: (id: NpcId, e: React.MouseEvent<HTMLButtonElement>) => void;
onGroupMenu: (id: NpcGroupId, e: React.MouseEvent<HTMLButtonElement>) => void;
onNpcDragStart: (id: NpcId) => void;
onNpcDragEnd: () => void;
onNpcDragOver: (id: NpcId, place: 'before' | 'after') => void;
onNpcDropReorder: (targetId: NpcId) => void;
onGroupDragStart: (id: NpcGroupId) => void;
onGroupDragEnd: () => void;
onGroupDragOver: (id: NpcGroupId, place: 'before' | 'after') => void;
onGroupDropReorder: (targetId: NpcGroupId) => void;
onGroupBodyDragOver: (groupId: NpcGroupId) => void;
onGroupBodyDrop: (groupId: NpcGroupId) => void;
}) {
const { t } = useEditorI18n();
const g = node.group;
const expanded = isExpanded(g.id);
const isGroupDragging = dragGroupId === g.id;
const groupDropBefore = groupDropPlace?.id === g.id && groupDropPlace.place === 'before';
const groupDropAfter = groupDropPlace?.id === g.id && groupDropPlace.place === 'after';
const bodyHighlight = dropTargetGroup === g.id && dragId !== null;
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div
className={[
styles.groupHeader,
isGroupDragging ? styles.groupHeaderDragging : '',
groupDropBefore ? styles.groupDropBefore : '',
groupDropAfter ? styles.groupDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_GROUP_ID_MIME, g.id);
e.dataTransfer.effectAllowed = 'move';
onGroupDragStart(g.id);
}}
onDragEnd={onGroupDragEnd}
onDragOver={(e) => {
if (dragGroupId && dragGroupId !== g.id) {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onGroupDragOver(g.id, e.clientY < mid ? 'before' : 'after');
return;
}
if (dragId) {
e.preventDefault();
onGroupBodyDragOver(g.id);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (dragGroupId && dragGroupId !== g.id && groupDropPlace?.id === g.id) {
onGroupDropReorder(g.id);
return;
}
if (dragId) onGroupBodyDrop(g.id);
}}
>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
<button
type="button"
className={styles.groupMenuBtn}
data-npc-menu-root="1"
aria-label={t('npcs.tileMenu')}
onClick={(e) => onGroupMenu(g.id, e)}
>
</button>
</div>
{expanded ? (
<div
className={[styles.groupBody, bodyHighlight ? styles.groupBodyDrop : ''].filter(Boolean).join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDragOver(g.id);
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDrop(g.id);
}}
>
{node.npcs.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
accentColor={groupColorById.get(g.id) ?? g.color}
onSelect={() => onSelectNpc(n.id)}
onMenu={(e) => onNpcMenu(n.id, e)}
onDragStart={() => onNpcDragStart(n.id)}
onDragEnd={onNpcDragEnd}
onDragOver={(place) => onNpcDragOver(n.id, place)}
onDropReorder={() => onNpcDropReorder(n.id)}
/>
))}
{node.children.map((child) => (
<GroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={onSelectNpc}
onNpcMenu={onNpcMenu}
onGroupMenu={onGroupMenu}
onNpcDragStart={onNpcDragStart}
onNpcDragEnd={onNpcDragEnd}
onNpcDragOver={onNpcDragOver}
onNpcDropReorder={onNpcDropReorder}
onGroupDragStart={onGroupDragStart}
onGroupDragEnd={onGroupDragEnd}
onGroupDragOver={onGroupDragOver}
onGroupDropReorder={onGroupDropReorder}
onGroupBodyDragOver={onGroupBodyDragOver}
onGroupBodyDrop={onGroupBodyDrop}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsEditorApp() {
const { t } = useEditorI18n();
const api = getDndApi();
@@ -101,9 +313,20 @@ export function NpcsEditorApp() {
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
const [groupMenuFor, setGroupMenuFor] = useState<NpcGroupId | null>(null);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [dragId, setDragId] = useState<NpcId | null>(null);
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
const [dragGroupId, setDragGroupId] = useState<NpcGroupId | null>(null);
const [groupDropPlace, setGroupDropPlace] = useState<{
id: NpcGroupId;
place: 'before' | 'after';
} | null>(null);
const [dropTargetGroup, setDropTargetGroup] = useState<NpcGroupId | 'ungrouped' | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [groupModal, setGroupModal] = useState<GroupModalState | null>(null);
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState('');
const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
@@ -118,14 +341,18 @@ export function NpcsEditorApp() {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
const groups = project?.npcGroups ?? [];
setExpandedGroups(new Set(groups.map((g) => g.id)));
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const npcs = session?.project?.npcs ?? [];
const relations = session?.project?.npcRelations ?? [];
const project = session?.project ?? null;
const npcs = useMemo(() => project?.npcs ?? [], [project?.npcs]);
const npcGroups = useMemo(() => project?.npcGroups ?? [], [project?.npcGroups]);
const relations = useMemo(() => project?.npcRelations ?? [], [project?.npcRelations]);
const selected = npcs.find((n) => n.id === selectedId) ?? null;
useEffect(() => {
@@ -138,23 +365,33 @@ export function NpcsEditorApp() {
}, [npcs, selectedId]);
useEffect(() => {
if (!menuFor) return;
if (!menuFor && !groupMenuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
setMenuFor(null);
setGroupMenuFor(null);
setMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]);
}, [groupMenuFor, menuFor]);
const filtered = useMemo(() => {
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const { roots, ungrouped } = useMemo(
() => buildNpcGroupForest(npcGroups, filteredNpcs),
[filteredNpcs, npcGroups],
);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const groupOptions = useMemo(() => flattenGroupOptions(roots), [roots]);
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
const relationsForSelected = useMemo(() => {
@@ -173,6 +410,83 @@ export function NpcsEditorApp() {
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]);
const commitNpcGroupChange = useCallback(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId);
if (npc && npc.groupId !== groupId) {
await api.invoke(ipcChannels.project.updateNpcFields, { npcId, groupId });
}
if (orderIds) {
await api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: orderIds });
}
},
[api, npcs],
);
const handleNpcDropOnTile = useCallback(
(targetId: NpcId) => {
if (!dragId || dropPlace?.id !== targetId || dragId === targetId) return;
const targetNpc = npcs.find((n) => n.id === targetId);
if (!targetNpc) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const newGroupId = targetNpc.groupId;
const orderIds = reorderNpcIds(
dragNpc.groupId === newGroupId
? npcs
: npcs.map((n) => (n.id === dragId ? { ...n, groupId: newGroupId } : n)),
dragId,
targetId,
dropPlace.place,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, newGroupId, orderIds);
},
[commitNpcGroupChange, dragId, dropPlace, npcs],
);
const handleNpcDropOnGroup = useCallback(
(groupId: NpcGroupId | null) => {
if (!dragId) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const orderIds = moveNpcToGroupEnd(
dragNpc.groupId === groupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n)),
dragId,
groupId,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, groupId, orderIds);
},
[commitNpcGroupChange, dragId, npcs],
);
const handleGroupDropReorder = useCallback(
(targetId: NpcGroupId) => {
if (!dragGroupId || !groupDropPlace || dragGroupId === groupDropPlace.id) return;
const order = reorderSiblingGroups(npcGroups, dragGroupId, targetId, groupDropPlace.place);
setDragGroupId(null);
setGroupDropPlace(null);
void api.invoke(ipcChannels.project.setNpcGroupsOrder, { groupIds: order });
},
[api, dragGroupId, groupDropPlace, npcGroups],
);
const openMenuAt = (e: React.MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 120;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const top = r.bottom + 8 + menuH > window.innerHeight - 8 ? Math.max(8, r.top - menuH - 8) : r.bottom + 8;
setMenuPos({ left, top });
};
const graphUi = useMemo(
() => ({
zoomBar: t('npcs.graphZoomBar'),
@@ -182,10 +496,28 @@ export function NpcsEditorApp() {
editRelation: t('npcs.relationEdit'),
deleteRelation: t('npcs.relationDelete'),
untitled: t('npcs.untitled'),
graphFilter: t('npcs.graphFilter'),
graphFilterAll: t('npcs.graphFilterAll'),
graphFilterUngrouped: t('npcs.graphFilterUngrouped'),
}),
[t],
);
const isExpanded = (id: NpcGroupId) => expandedGroups.has(id);
const toggleExpanded = (id: NpcGroupId) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const siblingNamesForGroup = (parentId: NpcGroupId | null, exceptId?: NpcGroupId) =>
npcGroups.filter((g) => g.parentId === parentId && g.id !== exceptId).map((g) => g.name);
const hasListContent = npcGroups.length > 0 || ungrouped.length > 0;
return (
<div className={styles.page}>
<div className={styles.topBar}>
@@ -202,66 +534,138 @@ export function NpcsEditorApp() {
<div className={styles.body}>
<div className={[styles.col, styles.side].join(' ')}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<Button
variant="primary"
onClick={() => {
setEditInitial(null);
setEditOpen(true);
}}
>
{t('npcs.add')}
</Button>
<div className={styles.sideActions}>
<Button
variant="primary"
onClick={() => {
setEditInitial(null);
setEditOpen(true);
}}
>
{t('npcs.add')}
</Button>
<Button
onClick={() => {
setGroupModal({ mode: 'create', parentId: null });
}}
>
{t('npcs.addGroup')}
</Button>
</div>
<div className={styles.list}>
{filtered.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
onSelect={() => setSelectedId(n.id)}
onMenu={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 88;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const top =
r.bottom + 8 + menuH > window.innerHeight - 8
? Math.max(8, r.top - menuH - 8)
: r.bottom + 8;
setMenuPos({ left, top });
setMenuFor((cur) => (cur === n.id ? null : n.id));
{roots.map((node) => (
<GroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup === 'ungrouped' ? null : dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={setSelectedId}
onNpcMenu={(id, e) => {
openMenuAt(e);
setGroupMenuFor(null);
setMenuFor((cur) => (cur === id ? null : id));
}}
onDragStart={() => setDragId(n.id)}
onDragEnd={() => {
onGroupMenu={(id, e) => {
openMenuAt(e);
setMenuFor(null);
setGroupMenuFor((cur) => (cur === id ? null : id));
}}
onNpcDragStart={setDragId}
onNpcDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === n.id) {
onNpcDragOver={(id, place) => {
if (!dragId || dragId === id) {
setDropPlace(null);
return;
}
setDropPlace({ id: n.id, place });
setDropPlace({ id, place });
}}
onDropReorder={() => {
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
const ids = npcs.map((x) => x.id);
const from = ids.indexOf(dragId);
if (from < 0) return;
ids.splice(from, 1);
let to = ids.indexOf(dropPlace.id);
if (to < 0) return;
if (dropPlace.place === 'after') to += 1;
ids.splice(to, 0, dragId);
setDragId(null);
setDropPlace(null);
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
onNpcDropReorder={handleNpcDropOnTile}
onGroupDragStart={setDragGroupId}
onGroupDragEnd={() => {
setDragGroupId(null);
setGroupDropPlace(null);
}}
onGroupDragOver={(id, place) => {
if (!dragGroupId || dragGroupId === id) {
setGroupDropPlace(null);
return;
}
setGroupDropPlace({ id, place });
}}
onGroupDropReorder={handleGroupDropReorder}
onGroupBodyDragOver={setDropTargetGroup}
onGroupBodyDrop={(groupId) => handleNpcDropOnGroup(groupId)}
/>
))}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? (
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div
className={[
styles.groupBody,
dropTargetGroup === 'ungrouped' && dragId ? styles.groupBodyDrop : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
setDropTargetGroup('ungrouped');
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
handleNpcDropOnGroup(null);
}}
>
{ungrouped.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
onSelect={() => setSelectedId(n.id)}
onMenu={(e) => {
openMenuAt(e);
setGroupMenuFor(null);
setMenuFor((cur) => (cur === n.id ? null : n.id));
}}
onDragStart={() => setDragId(n.id)}
onDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === n.id) {
setDropPlace(null);
return;
}
setDropPlace({ id: n.id, place });
}}
onDropReorder={() => handleNpcDropOnTile(n.id)}
/>
))}
</div>
</div>
{!hasListContent && npcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.empty')}</div>
) : null}
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
@@ -271,7 +675,10 @@ export function NpcsEditorApp() {
<NpcGraph
npcs={npcs}
relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
@@ -295,7 +702,7 @@ export function NpcsEditorApp() {
<div className={[styles.col, styles.inspector].join(' ')}>
<div className={styles.inspectorScroll}>
{selected ? (
{selected && project ? (
<>
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
@@ -352,6 +759,28 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={selected.groupId ?? ''}
onChange={(e) => {
const groupId = (e.target.value || null) as NpcGroupId | null;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
groupId,
});
}}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField
@@ -366,6 +795,20 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
<NpcBindingFields
project={project}
binding={selected.binding}
onChange={(binding: NpcBinding) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
binding,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -390,6 +833,8 @@ export function NpcsEditorApp() {
open={editOpen}
initial={editInitial}
existingNames={npcs.map((n) => n.name)}
project={project}
npcGroups={npcGroups}
onClose={() => setEditOpen(false)}
onPickImage={pickAvatar}
onSave={async (input) => {
@@ -397,12 +842,43 @@ export function NpcsEditorApp() {
...(editInitial ? { npcId: editInitial.id } : {}),
name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.binding !== undefined ? { binding: input.binding } : {}),
});
const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id);
}}
/>
<NpcGroupModal
open={Boolean(groupModal)}
initial={groupModal?.mode === 'edit' ? groupModal.group : null}
siblingNames={
groupModal?.mode === 'edit'
? siblingNamesForGroup(groupModal.group.parentId, groupModal.group.id)
: groupModal?.mode === 'create'
? siblingNamesForGroup(groupModal.parentId)
: []
}
onClose={() => setGroupModal(null)}
onSave={async ({ name, color }) => {
if (!groupModal) return;
if (groupModal.mode === 'edit') {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
groupId: groupModal.group.id,
name,
color,
});
} else {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
name,
color,
parentId: groupModal.parentId,
});
}
}}
/>
<NpcRelationModal
open={Boolean(relationModal)}
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
@@ -431,13 +907,14 @@ export function NpcsEditorApp() {
{menuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
onMouseDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const npc = npcs.find((n) => n.id === menuFor);
@@ -452,6 +929,54 @@ export function NpcsEditorApp() {
)
: null}
{groupMenuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setGroupModal({ mode: 'edit', group: g });
setGroupMenuFor(null);
}}
>
{t('npcs.editGroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
setGroupModal({ mode: 'create', parentId: groupMenuFor });
setGroupMenuFor(null);
}}
>
{t('npcs.addSubgroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setPendingDeleteGroup(g);
setGroupMenuFor(null);
}}
>
{t('npcs.deleteGroup')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
@@ -492,6 +1017,46 @@ export function NpcsEditorApp() {
)
: null}
{pendingDeleteGroup
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteGroup(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.deleteGroupTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteGroup(null)}
>
×
</button>
</div>
<div>{t('npcs.deleteGroupConfirm', { name: pendingDeleteGroup.name })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteGroup(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteGroup.id;
setPendingDeleteGroup(null);
void api.invoke(ipcChannels.project.deleteNpcGroup, { groupId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteRelation
? createPortal(
<>
@@ -512,9 +1077,7 @@ export function NpcsEditorApp() {
×
</button>
</div>
<div>
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
</div>
<div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
<Button
+95
View File
@@ -0,0 +1,95 @@
import type { NpcGroupId, NpcId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
export function reorderNpcIds(
npcs: ProjectNpc[],
dragId: NpcId,
targetId: NpcId,
place: 'before' | 'after',
): NpcId[] {
const ids = npcs.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let to = ids.indexOf(targetId);
if (to < 0) return npcs.map((n) => n.id);
if (place === 'after') to += 1;
ids.splice(to, 0, dragId);
return ids;
}
/** Move NPC to end of its group block in global order. */
export function moveNpcToGroupEnd(npcs: ProjectNpc[], dragId: NpcId, groupId: NpcGroupId | null): NpcId[] {
const updated = npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n));
const ids = updated.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let insertAt = ids.length;
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === groupId) {
insertAt = i + 1;
break;
}
}
if (groupId === null) {
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === null) {
insertAt = i + 1;
break;
}
}
}
ids.splice(insertAt, 0, dragId);
return ids;
}
export function reorderSiblingGroups(
groups: ProjectNpcGroup[],
dragId: NpcGroupId,
targetId: NpcGroupId,
place: 'before' | 'after',
): NpcGroupId[] {
const drag = groups.find((g) => g.id === dragId);
const target = groups.find((g) => g.id === targetId);
if (!drag || drag.parentId !== target?.parentId) return groups.map((g) => g.id);
const parentId = drag.parentId;
const siblingIds = groups.filter((g) => g.parentId === parentId).map((g) => g.id);
const from = siblingIds.indexOf(dragId);
if (from < 0) return groups.map((g) => g.id);
siblingIds.splice(from, 1);
let to = siblingIds.indexOf(targetId);
if (to < 0) return groups.map((g) => g.id);
if (place === 'after') to += 1;
siblingIds.splice(to, 0, dragId);
const result: NpcGroupId[] = [];
let inserted = false;
for (const g of groups) {
if (g.parentId === parentId) {
if (!inserted) {
result.push(...siblingIds);
inserted = true;
}
continue;
}
result.push(g.id);
}
return result;
}
export function flattenGroupOptions(
nodes: { group: ProjectNpcGroup; children: unknown[] }[],
depth = 0,
): { id: NpcGroupId; label: string }[] {
const out: { id: NpcGroupId; label: string }[] = [];
for (const node of nodes) {
const prefix = depth > 0 ? ' '.repeat(depth) : '';
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
out.push(...flattenGroupOptions(node.children as typeof nodes, depth + 1));
}
return out;
}
+10 -1
View File
@@ -85,6 +85,15 @@
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
background-color: var(--color-overlay-dark-3);
outline: none;
color: inherit;
}
select.input {
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
}
+13 -1
View File
@@ -44,11 +44,23 @@ a {
button,
input,
textarea {
textarea,
select {
font: inherit;
color: inherit;
}
/* Кастомная стрелка select: 8px от правого края (нативные часто вплотную). */
select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
padding-right: 28px;
}
::selection {
background: var(--selection-bg);
}