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
+125 -54
View File
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
ConnectionMode,
Handle,
MarkerType,
Panel,
@@ -19,6 +20,11 @@ import ReactFlow, {
import 'reactflow/dist/style.css';
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
import {
canSetSideStoryStart,
isNodeInSideStoryline,
isSideStoryEdge,
} from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage';
import { useAssetUrl } from '../../shared/useAssetImageUrl';
@@ -53,6 +59,7 @@ const SCENE_CARD_H = 248;
/** UI strings for the scene graph (passed from editor i18n). */
export type SceneGraphUiStrings = {
badgeStart: string;
badgeSideStory: string;
untitled: string;
videoBadge: string;
audioBadge: string;
@@ -67,12 +74,15 @@ export type SceneGraphUiStrings = {
closeMenu: string;
startScene: string;
unsetStartScene: string;
sideStoryStartScene: string;
unsetSideStoryStartScene: string;
runFromScene: string;
delete: string;
};
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
badgeStart: 'НАЧАЛО',
badgeSideStory: 'ПОБОЧНАЯ',
untitled: 'Без названия',
videoBadge: 'Видео',
audioBadge: 'Аудио',
@@ -87,6 +97,8 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»',
sideStoryStartScene: 'Начальная сцена побочной линии',
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
runFromScene: 'Запустить с этой сцены',
delete: 'Удалить',
};
@@ -97,15 +109,17 @@ export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null;
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
selectedGraphNodeId: GraphNodeId | null;
graphUi?: SceneGraphUiStrings;
onCurrentSceneChange: (id: SceneId) => void;
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
onDisconnect: (edgeId: string) => void;
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
};
@@ -120,6 +134,8 @@ type SceneCardData = {
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
isStartScene: boolean;
isSideStoryStart: boolean;
isSideStoryNode: boolean;
hasSceneAudio: boolean;
previewIsVideo: boolean;
hasAnyAudioLoop: boolean;
@@ -179,15 +195,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const ui = useContext(GraphUiContext);
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
const cardClass = [
styles.card,
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
]
.filter(Boolean)
.join(' ');
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio;
return (
<div className={styles.nodeWrap}>
<Handle type="target" position={Position.Top} className={styles.handle} />
<Handle type="target" position={Position.Top} className={handleClass} />
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
{data.isSideStoryStart ? (
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
) : null}
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
@@ -303,7 +328,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null}
</div>
</div>
<Handle type="source" position={Position.Bottom} className={styles.handle} />
<Handle type="source" position={Position.Bottom} className={handleClass} />
</div>
);
}
@@ -353,15 +378,16 @@ function SceneGraphCanvas({
sceneGraphNodes,
sceneGraphEdges,
sceneCardById,
currentSceneId,
selectedGraphNodeId,
graphUi,
onCurrentSceneChange,
onGraphNodeSelect,
onConnect,
onDisconnect,
onNodePositionCommit,
onRemoveGraphNodes,
onRemoveGraphNode,
onSetGraphNodeStart,
onSetGraphNodeSideStoryStart,
onRunFromGraphNode,
onDropSceneFromList,
}: SceneGraphProps) {
@@ -387,10 +413,31 @@ function SceneGraphCanvas({
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
}, [menu, sceneGraphNodes]);
const menuNodeIsSideStoryStart = useMemo(() => {
if (!menu) return false;
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
}, [menu, sceneGraphNodes]);
const menuCanSetSideStoryStart = useMemo(() => {
if (!menu) return false;
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const menuNodeIsSideBranch = useMemo(() => {
if (!menu) return false;
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
return sceneGraphNodes.map((gn) => {
const c = sceneCardById[gn.sceneId];
const active = gn.sceneId === currentSceneId;
const active = selectedGraphNodeId === gn.id;
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
const audios = c?.audios ?? [];
return {
id: gn.id,
@@ -406,6 +453,8 @@ function SceneGraphCanvas({
previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0,
isStartScene: gn.isStartScene,
isSideStoryStart: gn.isSideStoryStart,
isSideStoryNode,
hasSceneAudio: audios.length >= 1,
previewIsVideo: c?.previewAssetType === 'video',
hasAnyAudioLoop: audios.some((a) => a.loop),
@@ -416,40 +465,46 @@ function SceneGraphCanvas({
style: { padding: 0, background: 'transparent', border: 'none' },
};
});
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const desiredEdges = useMemo<Edge[]>(() => {
const selectedGraphNodeIds = new Set<GraphNodeId>();
if (currentSceneId) {
for (const gn of sceneGraphNodes) {
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
}
}
const hasSelection = selectedGraphNodeIds.size > 0;
return sceneGraphEdges.map((e) => ({
...(hasSelection
? {
style:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
markerEnd:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
}
: {
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}),
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
selectable: false,
}));
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
const hasSelection = selectedGraphNodeId != null;
return sceneGraphEdges.map((e) => {
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
const strokeDim = 'rgba(255,255,255,0.10)';
const markerDim = 'rgba(255,255,255,0.18)';
const touchesSelection =
selectedGraphNodeId != null &&
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
return {
...(hasSelection
? {
style: touchesSelection
? { stroke: strokeActive, strokeWidth: 3 }
: { stroke: strokeDim, strokeWidth: 2 },
markerEnd: touchesSelection
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
: { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
}
: {
style: { stroke: strokeIdle, strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
strokeWidth: 2,
},
}),
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
selectable: false,
};
});
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -494,7 +549,7 @@ function SceneGraphCanvas({
if (!menu) return null;
const pad = 8;
const mw = 220;
const mh = 168;
const mh = 210;
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
return { x, y };
@@ -523,6 +578,7 @@ function SceneGraphCanvas({
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
connectionMode={ConnectionMode.Loose}
onConnect={onConnectInternal}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
@@ -536,7 +592,7 @@ function SceneGraphCanvas({
setMenu(null);
setEdgeMenu(null);
const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId);
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
@@ -599,18 +655,33 @@ function SceneGraphCanvas({
>
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</button>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
disabled={!onRunFromGraphNode}
onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId);
setMenu(null);
}}
>
{ui.runFromScene}
</button>
{menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
onSetGraphNodeSideStoryStart(menu.graphNodeId);
setMenu(null);
}}
>
{menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
</button>
) : null}
{!menuNodeIsSideBranch ? (
<button
type="button"
role="menuitem"
className={styles.ctxItem}
disabled={!onRunFromGraphNode}
onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId);
setMenu(null);
}}
>
{ui.runFromScene}
</button>
) : null}
<button
type="button"
role="menuitem"