Files
DndGamePlayer/app/renderer/editor/StorylineTransferModals.tsx
T
Ivan Fontosh c8ab9dd567 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>
2026-07-09 14:47:28 +08:00

672 lines
23 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,
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],
);
}