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,