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
+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