Files
DndGamePlayer/app/renderer/editor/StorylineTransferModals.tsx
T
Ivan Fontosh e08f5ef550 feat(npcs): drop binding; select NPCs on storyline export
Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:05:09 +08:00

966 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
findNpcNameConflicts,
findSceneTitleConflicts,
listExportedNpcsFromBundle,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
type SceneImportResolution,
type SceneTitleConflict,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type ExportNpcOption = { id: string; name: string };
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (
projectId: ProjectId,
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
onClose: () => void;
onExport: (
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
) => Promise<void>;
};
export function ExportProjectModal({
open,
projects,
initialProjectId,
storylineLabels: _storylineLabels,
loadStorylines,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]);
useEffect(() => {
if (!open || !projectId) {
setStorylines([]);
setNpcs([]);
return;
}
let cancelled = false;
setLoadingStorylines(true);
void (async () => {
try {
const res = await loadStorylines(projectId);
if (cancelled) return;
const list = Array.isArray(res?.storylines) ? res.storylines : [];
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
setStorylines(list);
setNpcs(npcList);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
setStep('storylines');
} catch (e) {
if (!cancelled) {
setStorylines([]);
setNpcs([]);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) setLoadingStorylines(false);
}
})();
return () => {
cancelled = true;
};
}, [loadStorylines, open, projectId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (step === 'npcs') setStep('storylines');
else onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, step]);
if (!open) return null;
const canContinueStorylines =
projectId !== null &&
projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 &&
!loadingStorylines;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleNpc = (id: string) => {
setSelectedNpcIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const runExport = (npcIds: string[]) => {
if (!projectId || !canContinueStorylines) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections, npcIds);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
};
const goNextFromStorylines = () => {
if (!canContinueStorylines) return;
if (npcs.length === 0) {
runExport([]);
return;
}
setStep('npcs');
};
return createPortal(
<>
<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}>
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
{step === 'storylines' ? (
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<Select
value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0 || saving}
ariaLabel={t('export.project')}
options={projects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? (
<div className={styles.muted}>{t('storyline.loading')}</div>
) : storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={checked}
disabled={disabled || saving}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
<div className={styles.muted}>{t('export.hint')}</div>
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</div>
)}
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
{step === 'npcs' ? (
<Button onClick={() => setStep('storylines')} disabled={saving}>
{t('export.back')}
</Button>
) : (
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
)}
{step === 'storylines' ? (
<Button
variant="primary"
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
variant="primary"
disabled={saving}
onClick={() => runExport([...selectedNpcIds])}
>
{t('export.saveAs')}
</Button>
)}
</div>
</div>
</>,
document.body,
);
}
export type ImportPeekResult = {
kind: 'file' | 'project';
filePath?: string;
sourceProjectId?: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
export type ImportSourceSelection =
| { kind: 'file'; filePath: string; fileName: string }
| { kind: 'project'; sourceProjectId: ProjectId };
type ImportSourceModalProps = {
open: boolean;
/** false — только импорт из файла (полный импорт проекта). */
canImportFromProject: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
currentProjectId: ProjectId | null;
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
onClose: () => void;
onContinue: (selection: ImportSourceSelection) => Promise<void>;
};
export function ImportSourceModal({
open,
canImportFromProject,
projects,
currentProjectId,
pickFile,
onClose,
onContinue,
}: ImportSourceModalProps) {
const { t } = useEditorI18n();
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const availableProjects = useMemo(
() => projects.filter((p) => p.id !== currentProjectId),
[currentProjectId, projects],
);
useEffect(() => {
if (!open) return;
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
setSourceProjectId(availableProjects[0]?.id ?? null);
setPickedFile(null);
setSubmitting(false);
setError(null);
}, [availableProjects, canImportFromProject, 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]);
if (!open) return null;
const canContinue =
!submitting &&
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
return createPortal(
<>
<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>
</div>
<div className={styles.fieldGrid}>
{canImportFromProject ? (
<div className={styles.fieldGroup}>
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
<Select
value={importKind}
onChange={(next) => setImportKind(next as 'project' | 'file')}
disabled={submitting}
ariaLabel={t('importSource.type')}
options={[
{ value: 'project', label: t('importSource.fromProject') },
{ value: 'file', label: t('importSource.fromFile') },
]}
/>
</div>
) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)}
{importKind === 'project' && canImportFromProject ? (
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
<Select
value={sourceProjectId ?? ''}
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting}
ariaLabel={t('importSource.project')}
options={availableProjects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
{availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null}
</div>
) : (
<div
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
.filter(Boolean)
.join(' ')}
>
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickFile();
if (res.canceled) return;
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
setPickedFile({ path: res.filePath, name });
})();
}}
>
{t('importSource.chooseFile')}
</Button>
<span className={styles.muted}>
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span>
</div>
</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={!canContinue}
onClick={() => {
void (async () => {
setSubmitting(true);
setError(null);
try {
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
await onContinue({ kind: 'project', sourceProjectId });
} else if (pickedFile) {
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportStorylinesModalProps = {
open: boolean;
sourceName: string;
storylines: StorylineListItem[];
onClose: () => void;
onContinue: (selections: StorylineSelection[]) => void;
};
export function ImportStorylinesModal({
open,
sourceName,
storylines,
onClose,
onContinue,
}: ImportStorylinesModalProps) {
const { t } = useEditorI18n();
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setSelectedKeys(new Set());
}, [open, sourceName]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const canContinue = selectedKeys.size > 0;
return createPortal(
<>
<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>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
<div>{sourceName}</div>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={selectedKeys.has(key)}
disabled={disabled}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type SceneConflictModalProps = {
open: boolean;
conflicts: SceneTitleConflict[];
onClose: () => void;
onConfirm: (resolutions: SceneImportResolution[]) => void;
};
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
useEffect(() => {
if (!open) return;
const init: Record<string, 'create' | SceneId> = {};
for (const c of conflicts) {
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
}
setChoices(init);
}, [conflicts, open]);
if (!open) return null;
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}`}>
<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>
</div>
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
<Select
value={choices[c.sourceSceneId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
}));
}}
ariaLabel={c.sourceTitle}
options={[
{ value: 'create', label: t('importStoryline.createNewScene') },
...c.matches.map((m) => ({
value: m.sceneId,
label: t('importStoryline.useExistingScene', { title: m.title }),
})),
]}
/>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceSceneId] ?? 'create';
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
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
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
ariaLabel={c.sourceName}
options={[
{ value: 'create', label: t('importStoryline.createNewNpc') },
...c.matches.map((m) => ({
value: m.npcId,
label: t('importStoryline.useExistingNpc', { name: m.name }),
})),
]}
/>
</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;
onClose: () => void;
};
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
const { t } = useEditorI18n();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open || !report) return null;
return createPortal(
<>
<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>
</div>
<ul className={styles.reportList}>
<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>
) : null}
</ul>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function buildSceneResolutionsForImport(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: SceneTitleConflict[],
userResolutions: SceneImportResolution[],
): SceneImportResolution[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
const out: SceneImportResolution[] = [];
for (const sid of sceneIds) {
if (conflictIds.has(sid)) {
const r = bySource.get(sid);
if (r) out.push(r);
continue;
}
out.push({ sourceSceneId: sid, mode: 'create' });
}
return out;
}
export function computeImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): SceneTitleConflict[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
}
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = listExportedNpcsFromBundle(sourceProject);
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,
): NpcNameConflict[] {
const exported = listExportedNpcsFromBundle(sourceProject);
return findNpcNameConflicts(
targetProject,
sourceProject,
exported.map((n) => n.id),
);
}
export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n();
return useMemo(
() => ({
main: t('storyline.main'),
untitled: t('graph.untitled'),
}),
[t],
);
}