f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
876 lines
28 KiB
TypeScript
876 lines
28 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import {
|
||
collectSceneIdsForSelections,
|
||
filterNpcsForStorylineExport,
|
||
findNpcNameConflicts,
|
||
findSceneTitleConflicts,
|
||
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';
|
||
|
||
type ExportProjectModalProps = {
|
||
open: boolean;
|
||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||
initialProjectId: ProjectId | null;
|
||
storylineLabels: StorylineLabels;
|
||
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
|
||
onClose: () => void;
|
||
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
|
||
};
|
||
|
||
export function ExportProjectModal({
|
||
open,
|
||
projects,
|
||
initialProjectId,
|
||
storylineLabels,
|
||
loadStorylines,
|
||
onClose,
|
||
onExport,
|
||
}: ExportProjectModalProps) {
|
||
const { t } = useEditorI18n();
|
||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||
const [selectedKeys, setSelectedKeys] = 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());
|
||
}, [initialProjectId, open]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !projectId) {
|
||
setStorylines([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setLoadingStorylines(true);
|
||
void (async () => {
|
||
try {
|
||
const list = await loadStorylines(projectId);
|
||
if (cancelled) return;
|
||
setStorylines(list);
|
||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||
} catch (e) {
|
||
if (!cancelled) 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') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose, open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const canExport =
|
||
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 selectedSelections = storylines
|
||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||
.map((item) => item.selection);
|
||
|
||
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('export.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('export.project')}</div>
|
||
<Select
|
||
value={projectId ?? ''}
|
||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||
disabled={projects.length === 0}
|
||
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}
|
||
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>
|
||
|
||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||
{t('common.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!canExport || saving}
|
||
onClick={() => {
|
||
if (!projectId || !canExport) return;
|
||
void (async () => {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await onExport(projectId, selectedSelections);
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{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,
|
||
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(
|
||
() => ({
|
||
main: t('storyline.main'),
|
||
untitled: t('graph.untitled'),
|
||
}),
|
||
[t],
|
||
);
|
||
}
|