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:
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user