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
@@ -17,8 +17,9 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
);
assert.match(
src,
/const openProject = async[\s\S]+?openInFlightRef\.current[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/,
/const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?const epoch = projectDataEpochRef\.current[\s\S]+?openInFlightRef\.current = null[\s\S]+?if \(projectDataEpochRef\.current !== epoch\)/,
);
assert.match(src, /openInFlightRef\.current = null[\s\S]+?openingProjectId: null/);
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
});
+214 -18
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type {
SceneImportResolution,
StorylineImportMergeReport,
StorylineLabels,
StorylineListItem,
StorylineSelection,
} from '../../../shared/graph/storylineExportImport';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
@@ -54,10 +61,59 @@ type Actions = {
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>;
exportProject: (projectId: ProjectId) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
| { canceled: true }
| {
canceled: false;
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}
>;
pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
peekImportZipPath: (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
peekImportFromProject: (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
mergeImportZip: (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
mergeImportFromProject: (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
exportProject: (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
};
@@ -110,8 +166,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
}
});
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
@@ -125,8 +182,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
}
});
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
@@ -180,12 +238,18 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const openProject = async (id: ProjectId) => {
if (openInFlightRef.current) return openInFlightRef.current;
projectDataEpochRef.current += 1;
const epoch = projectDataEpochRef.current;
openInFlightRef.current = null;
const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id }));
try {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
if (projectDataEpochRef.current !== epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
return;
}
setState((s) => ({
...s,
project: res.project,
@@ -193,7 +257,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
openingProjectId: null,
}));
} catch {
setState((s) => ({ ...s, openingProjectId: null }));
if (projectDataEpochRef.current === epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
}
}
})();
openInFlightRef.current = job.finally(() => {
@@ -203,9 +269,20 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const closeProject = async () => {
await api.invoke(ipcChannels.project.close, {});
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
projectDataEpochRef.current += 1;
openInFlightRef.current = null;
try {
await api.invoke(ipcChannels.project.close, {});
} finally {
setState((s) => ({
...s,
project: null,
selectedSceneId: null,
openingProjectId: null,
zipProgress: null,
}));
await refreshProjects();
}
};
const createScene = async () => {
@@ -411,6 +488,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setState((s) => ({ ...s, project: res.project }));
};
const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
setState((s) => ({ ...s, project: res.project }));
};
const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
setState((s) => ({ ...s, project: res.project }));
};
const deleteScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
setState((s) => ({
@@ -428,19 +515,118 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const importProject = async () => {
const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
try {
const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const importProjectFromPath = async (filePath: string) => {
try {
const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
};
const pickImportZipFile = async () => {
return api.invoke(ipcChannels.project.pickImportZipFile, {});
};
const peekImportZipPath = async (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
};
const peekImportFromProject = async (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportFromProject, {
sourceProjectId,
labels,
targetHasMainStart,
});
};
const mergeImportZip = async (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
filePath,
storylineSelections,
sceneResolutions,
});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
}));
await refreshProjects();
return res;
};
const exportProject = async (projectId: ProjectId) => {
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
if (res.canceled) return;
const mergeImportFromProject = async (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
sourceProjectId,
storylineSelections,
sceneResolutions,
});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
}));
return res;
};
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
return res.storylines;
};
const exportProject = async (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
projectId,
storylineSelections,
labels,
});
if (res.canceled) return;
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const deleteProject = async (projectId: ProjectId) => {
@@ -476,9 +662,19 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
addSceneGraphEdge,
removeSceneGraphEdge,
setSceneGraphNodeStart,
setSceneGraphNodeSideStoryStart,
updateSideStoryLineTitle,
deleteScene,
renameProject,
importProject,
importProjectFromPath,
peekImportZip,
pickImportZipFile,
peekImportZipPath,
peekImportFromProject,
mergeImportZip,
mergeImportFromProject,
getProjectStorylines,
exportProject,
deleteProject,
};