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:
@@ -8,6 +8,12 @@
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleSide {
|
||||
background: var(--side-story-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -26,6 +32,13 @@
|
||||
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
||||
}
|
||||
|
||||
.cardActiveSide {
|
||||
border-color: rgba(0, 120, 212, 0.95);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 120, 212, 0.35),
|
||||
0 25px 50px -12px rgba(0, 120, 212, 0.12);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -65,6 +78,21 @@
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.badgeSideStory {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--side-story-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-side-story-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,3 +16,11 @@ void test('SceneGraph: контекстное меню узла — «Запус
|
||||
assert.ok(src.includes('onRunFromGraphNode'));
|
||||
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
|
||||
});
|
||||
|
||||
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('sideStoryStartScene'));
|
||||
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
|
||||
assert.ok(src.includes('menuNodeIsSideBranch'));
|
||||
assert.ok(src.includes('badgeSideStory'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user