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:
Ivan Fontosh
2026-07-09 14:47:28 +08:00
parent de9190959c
commit c8ab9dd567
26 changed files with 3507 additions and 266 deletions
+221 -122
View File
@@ -25,6 +25,19 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
import {
buildSceneResolutionsForImport,
computeImportConflicts,
ExportProjectModal,
ImportReportModal,
ImportSourceModal,
ImportStorylinesModal,
SceneConflictModal,
useStorylineLabels,
type ImportPeekResult,
type ImportSourceSelection,
} from './StorylineTransferModals';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
@@ -92,6 +105,14 @@ export function EditorApp() {
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
@@ -119,6 +140,7 @@ export function EditorApp() {
const graphUi = useMemo<SceneGraphUiStrings>(
() => ({
badgeStart: t('graph.badgeStart'),
badgeSideStory: t('graph.badgeSideStory'),
untitled: t('graph.untitled'),
videoBadge: t('graph.videoBadge'),
audioBadge: t('graph.audioBadge'),
@@ -133,6 +155,8 @@ export function EditorApp() {
closeMenu: t('common.closeMenu'),
startScene: t('graph.startScene'),
unsetStartScene: t('graph.unsetStartScene'),
sideStoryStartScene: t('graph.sideStoryStartScene'),
unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'),
runFromScene: t('graph.runFromScene'),
delete: t('common.delete'),
}),
@@ -146,6 +170,19 @@ export function EditorApp() {
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
useEffect(() => {
setSelectedGraphNodeId(null);
}, [state.project?.id]);
useEffect(() => {
if (!state.project || !selectedGraphNodeId) return;
if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
setSelectedGraphNodeId(null);
}
}, [selectedGraphNodeId, state.project?.sceneGraphNodes]);
const scenes = useMemo<SceneCard[]>(() => {
const p = state.project;
if (!p) return [];
@@ -367,6 +404,79 @@ export function EditorApp() {
}, []);
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const storylineLabels = useStorylineLabels();
const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false;
const loadProjectStorylines = useCallback(
(projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels),
[actions, storylineLabels],
);
const runStorylineMerge = useCallback(
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
setImportReport(report);
setImportReportOpen(true);
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setPendingImportSelections([]);
setImportConflicts([]);
},
[actions, importPeek],
);
const handleImportSourceContinue = useCallback(
async (selection: ImportSourceSelection) => {
if (!state.project) {
if (selection.kind === 'file') {
await actions.importProjectFromPath(selection.filePath);
setImportSourceOpen(false);
}
return;
}
const peek =
selection.kind === 'project'
? await actions.peekImportFromProject(
selection.sourceProjectId,
storylineLabels,
targetHasMainStart,
)
: await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart);
setImportPeek({
kind: selection.kind,
...(selection.kind === 'file'
? { filePath: selection.filePath }
: { sourceProjectId: selection.sourceProjectId }),
projectName: peek.projectName,
storylines: peek.storylines,
sourceProject: peek.sourceProject,
});
setImportSourceOpen(false);
setImportStorylinesOpen(true);
},
[actions, state.project, storylineLabels, targetHasMainStart],
);
const handleImportProject = useCallback(() => {
setProjectMenuOpen(false);
setImportSourceOpen(true);
}, []);
const goHome = useCallback(() => {
setProjectMenuOpen(false);
setExportModalOpen(false);
setImportSourceOpen(false);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportReportOpen(false);
setImportPeek(null);
void actions.closeProject();
}, [actions]);
const bodyOverlay =
licenseSnap === null ? (
@@ -424,7 +534,7 @@ export function EditorApp() {
type="button"
className={styles.brandButton}
onClick={() => {
void actions.closeProject();
goHome();
}}
title={t('top.backToProjects')}
>
@@ -562,7 +672,10 @@ export function EditorApp() {
<SceneListCard
key={s.id}
scene={s}
onSelect={() => void actions.selectScene(s.id)}
onSelect={() => {
setSelectedGraphNodeId(null);
void actions.selectScene(s.id);
}}
onDeleteScene={(id) => void actions.deleteScene(id)}
/>
))}
@@ -604,8 +717,11 @@ export function EditorApp() {
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneCardById={sceneCardById}
currentSceneId={state.selectedSceneId}
onCurrentSceneChange={(id) => void actions.selectScene(id)}
selectedGraphNodeId={selectedGraphNodeId}
onGraphNodeSelect={(graphNodeId, sceneId) => {
setSelectedGraphNodeId(graphNodeId);
void actions.selectScene(sceneId);
}}
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
onNodePositionCommit={(nodeId, x, y) =>
@@ -616,6 +732,9 @@ export function EditorApp() {
}}
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
onSetGraphNodeSideStoryStart={(graphNodeId) =>
void actions.setSceneGraphNodeSideStoryStart(graphNodeId)
}
onRunFromGraphNode={launchFromGraphNode}
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
/>
@@ -666,10 +785,14 @@ export function EditorApp() {
: previewImport !== null
? t('scene.previewOptimizing')
: t('scene.previewBusy');
const sideStoryStartNodes = proj.sceneGraphNodes.filter(
(n) => n.sceneId === sid && n.isSideStoryStart,
);
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
sideStoryStartNodes={sideStoryStartNodes}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
@@ -711,6 +834,9 @@ export function EditorApp() {
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
onSideStoryLineTitleChange={(graphNodeId, title) =>
void actions.updateSideStoryLineTitle(graphNodeId, title)
}
/>
);
})()
@@ -910,8 +1036,7 @@ export function EditorApp() {
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setProjectMenuOpen(false);
void actions.closeProject();
goHome();
}}
>
{t('projectMenu.home')}
@@ -921,8 +1046,7 @@ export function EditorApp() {
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setProjectMenuOpen(false);
void actions.importProject();
void handleImportProject();
}}
>
{t('projectMenu.import')}
@@ -990,9 +1114,77 @@ export function EditorApp() {
open={exportModalOpen}
projects={state.projects}
initialProjectId={exportModalInitialProjectId}
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId) => {
await actions.exportProject(projectId);
onExport={async (projectId, selections) => {
await actions.exportProject(projectId, selections, storylineLabels);
}}
/>
<ImportSourceModal
open={importSourceOpen}
canImportFromProject={state.project !== null}
projects={state.projects}
currentProjectId={state.project?.id ?? null}
pickFile={actions.pickImportZipFile}
onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue}
/>
<ImportStorylinesModal
open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''}
storylines={importPeek?.storylines ?? []}
onClose={() => {
setImportStorylinesOpen(false);
setImportPeek(null);
}}
onContinue={(selections) => {
if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections);
if (conflicts.length > 0) {
setImportConflicts(conflicts);
setImportConflictsOpen(true);
setImportStorylinesOpen(false);
return;
}
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
void runStorylineMerge(selections, resolutions);
}}
/>
<SceneConflictModal
open={importConflictsOpen}
conflicts={importConflicts}
onClose={() => {
setImportConflictsOpen(false);
setImportPeek(null);
setPendingImportSelections([]);
setImportConflicts([]);
}}
onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importConflicts,
userResolutions,
);
void runStorylineMerge(pendingImportSelections, resolutions);
}}
/>
<ImportReportModal
open={importReportOpen}
report={importReport}
onClose={() => {
setImportReportOpen(false);
setImportReport(null);
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
@@ -1006,118 +1198,6 @@ export function EditorApp() {
);
}
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
onClose: () => void;
onExport: (projectId: ProjectId) => Promise<void>;
};
function ExportProjectModal({
open,
projects,
initialProjectId,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
}, [initialProjectId, 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 canExport = projectId !== null && projects.some((p) => p.id === projectId);
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.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);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('export.saveAs')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type CheckUpdatesModalProps = {
open: boolean;
onClose: () => void;
@@ -1787,6 +1867,7 @@ function ProjectPicker({
type SceneInspectorProps = {
title: string;
description: string;
sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[];
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
@@ -1805,6 +1886,7 @@ type SceneInspectorProps = {
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void;
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
};
type CampaignInspectorProps = {
@@ -1897,6 +1979,7 @@ function CampaignInspector({
function SceneInspector({
title,
description,
sideStoryStartNodes,
previewAssetId,
previewAssetType,
previewVideoAutostart,
@@ -1915,6 +1998,7 @@ function SceneInspector({
onClearPreview,
onRotatePreview,
onUploadMedia,
onSideStoryLineTitleChange,
}: SceneInspectorProps) {
const { t } = useEditorI18n();
const previewUrl = useAssetUrl(previewAssetId);
@@ -1930,6 +2014,21 @@ function SceneInspector({
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
/>
{sideStoryStartNodes.length > 0 ? (
<>
<div className={styles.spacer8} />
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<div className={styles.spacer8} />
</div>
))}
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.preview')}</div>
<div className={styles.hint}>{t('scene.previewHint')}</div>