feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован partial export/import сюжетных линий: - экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json; - импорт в открытый проект с выбором линий, разрешением конфликтов названий сцен (одна модалка на операцию) и отчётом о результате; - новое окно источника импорта: «Из проекта» (dropdown, без текущего) или «Из файла»; на главном экране — только полный импорт из файла. Исправлены гонки при открытии/закрытии проекта (сериализация open/close в main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и старт Electron в dev после готовности Vite. Добавлены unit-тесты для lineage, export/import и контрактов zipStore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
findSceneTitleConflicts,
|
||||
storylineSelectionKey,
|
||||
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 } from '../shared/ui/controls';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import styles from './EditorApp.module.css';
|
||||
|
||||
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
|
||||
className={styles.selectInput}
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<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.fieldLabel}>{t('importSource.type')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={importKind}
|
||||
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="project">{t('importSource.fromProject')}</option>
|
||||
<option value="file">{t('importSource.fromFile')}</option>
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
|
||||
)}
|
||||
|
||||
{importKind === 'project' && canImportFromProject ? (
|
||||
<>
|
||||
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={sourceProjectId ?? ''}
|
||||
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)}
|
||||
disabled={availableProjects.length === 0 || submitting}
|
||||
>
|
||||
{availableProjects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{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
|
||||
className={styles.selectInput}
|
||||
value={choices[c.sourceSceneId] ?? 'create'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="create">{t('importStoryline.createNewScene')}</option>
|
||||
{c.matches.map((m) => (
|
||||
<option key={m.sceneId} value={m.sceneId}>
|
||||
{t('importStoryline.useExistingScene', { title: m.title })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 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.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 useStorylineLabels(): StorylineLabels {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
() => ({
|
||||
main: t('storyline.main'),
|
||||
untitled: t('graph.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user