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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user