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