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:
+221
-122
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user