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
@@ -278,6 +278,62 @@
font-weight: 900;
}
.branchCardReturn {
border-color: rgba(0, 120, 212, 0.45);
}
.sideStoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.sideStoryTile {
display: grid;
gap: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.sideStoryTile:hover .sideStoryPreview {
border-color: rgba(0, 120, 212, 0.55);
}
.sideStoryPreview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--scene-tile-radius);
overflow: hidden;
border: 2px solid var(--stroke);
background: #0c0c0e;
position: relative;
}
.sideStoryVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sideStoryPlaceholder {
width: 100%;
height: 100%;
background: #0c0c0e;
}
.sideStoryTitle {
font-size: var(--text-xs);
font-weight: 800;
line-height: 1.25;
color: var(--text1);
}
.musicHeader {
display: flex;
align-items: center;
+137 -3
View File
@@ -3,15 +3,18 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import { isNodeInMainStoryline, isNodeInSideStoryline, listSideStoryStarts } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './ControlApp.module.css';
import { ControlScenePreview } from './ControlScenePreview';
@@ -45,6 +48,41 @@ function playLightningEffectSound(): void {
}
}
function SideStoryTile({
scene,
title,
onClick,
}: {
scene: Scene;
title: string;
onClick: () => void;
}) {
const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
return (
<button type="button" className={styles.sideStoryTile} onClick={onClick}>
<div className={styles.sideStoryPreview}>
{imageUrl ? (
<RotatedImage
url={imageUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
) : previewUrl && scene.previewAssetType === 'video' ? (
<video src={previewUrl} muted playsInline preload="metadata" className={styles.sideStoryVideo} />
) : (
<div className={styles.sideStoryPlaceholder} aria-hidden />
)}
</div>
<div className={styles.sideStoryTitle}>{title}</div>
</button>
);
}
export function ControlApp() {
const api = getDndApi();
const { t } = useEditorI18n();
@@ -55,6 +93,9 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
@@ -116,7 +157,11 @@ export function ControlApp() {
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
const cur = state.project?.currentGraphNodeId ?? null;
if (!cur) return;
if (!cur) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
return;
}
const arr = historyRef.current;
if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur];
@@ -125,6 +170,15 @@ export function ControlApp() {
});
}, [api]);
useEffect(() => {
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
}
});
}, [api]);
useEffect(() => {
audioUnmountRef.current = false;
return () => {
@@ -590,6 +644,54 @@ export function ControlApp() {
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
}, [currentGraphNodeId, project]);
const isInSideStoryline = useMemo(() => {
if (!project || !currentGraphNodeId) return false;
return isNodeInSideStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId);
}, [currentGraphNodeId, project]);
const sideStoryLines = useMemo(() => {
if (!project) return [];
return listSideStoryStarts(project.sceneGraphNodes).map((gn) => {
const scene = project.scenes[gn.sceneId];
return {
graphNodeId: gn.id,
title: gn.sideStoryLineTitle.trim() || scene?.title || t('control.unnamed'),
scene,
};
});
}, [project, t]);
const returnSceneTitle = useMemo(() => {
if (!project || !mainStoryReturnGraphNodeId) return '';
const gn = project.sceneGraphNodes.find((n) => n.id === mainStoryReturnGraphNodeId);
if (!gn) return '';
return project.scenes[gn.sceneId]?.title || t('control.unnamed');
}, [mainStoryReturnGraphNodeId, project, t]);
const enterSideStoryline = (startGraphNodeId: GraphNodeId) => {
if (!project) return;
if (
currentGraphNodeId &&
mainStoryReturnRef.current === null &&
isNodeInMainStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId)
) {
mainStoryReturnRef.current = currentGraphNodeId;
setMainStoryReturnGraphNodeId(currentGraphNodeId);
}
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: startGraphNodeId });
};
const returnToMainStoryline = () => {
const ret = mainStoryReturnRef.current;
if (!ret) return;
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: ret });
};
const showReturnToMain = isInSideStoryline && mainStoryReturnGraphNodeId !== null;
const branchOptionOffset = showReturnToMain ? 1 : 0;
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool);
toolRef.current = tool;
@@ -1407,10 +1509,23 @@ export function ControlApp() {
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}>
{showReturnToMain ? (
<div className={[styles.branchCard, styles.branchCardReturn].join(' ')}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
</div>
<div className={styles.branchName}>{returnSceneTitle}</div>
<Button variant="primary" onClick={returnToMainStoryline}>
{t('control.returnToMainStory')}
</Button>
</div>
) : null}
{nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div>
<div className={styles.branchOption}>
{t('control.option', { n: String(i + 1 + branchOptionOffset) })}
</div>
</div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<Button
@@ -1423,7 +1538,7 @@ export function ControlApp() {
</Button>
</div>
))}
{nextScenes.length === 0 ? (
{nextScenes.length === 0 && !showReturnToMain ? (
<div className={styles.branchEmpty}>
<div>{t('control.noBranches')}</div>
<Button
@@ -1666,6 +1781,25 @@ export function ControlApp() {
</div>
)}
</Surface>
{sideStoryLines.length > 0 ? (
<Surface className={styles.surfacePad}>
<div className={styles.previewTitle}>{t('control.sideStoryLines')}</div>
<div className={styles.spacer10} />
<div className={styles.sideStoryGrid}>
{sideStoryLines.map((line) =>
line.scene ? (
<SideStoryTile
key={line.graphNodeId}
scene={line.scene}
title={line.title}
onClick={() => enterSideStoryline(line.graphNodeId)}
/>
) : null,
)}
</div>
</Surface>
) : null}
</div>
</div>
);