Files
DndGamePlayer/app/renderer/editor/graph/SceneGraph.tsx
T
Ivan Fontosh 7362a36fe5 feat(scene): rotate video previews like images
Reuse previewRotationDeg for video scenes across editor, control,
presentation and overlays via ContainedVideo layout parity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:17:57 +08:00

775 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
ConnectionMode,
Handle,
MarkerType,
Panel,
Position,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
useStore,
type Connection,
type Edge,
type Node,
type NodeProps,
} from '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 { ContainedVideo } from '../../shared/ContainedVideo';
import { RotatedImage } from '../../shared/RotatedImage';
import { EllipsisText } from '../../shared/ui/EllipsisText';
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
import { useAssetUrl } from '../../shared/useAssetImageUrl';
import styles from './SceneGraph.module.css';
/** Поля сцены, нужные только для карточки узла графа (без описания и прочего). */
export type SceneGraphSceneAudioSummary = {
assetId: AssetId;
loop: boolean;
autoplay: boolean;
};
export type SceneGraphSceneCard = {
title: string;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
loopVideo: boolean;
audios: readonly SceneGraphSceneAudioSummary[];
};
/** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
/** Примерные размеры карточки узла — чтобы точка сброса совпадала с центром карточки. */
const SCENE_CARD_W = 220;
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;
loop: string;
autoplay: string;
previewAutostart: string;
videoLoop: string;
zoomBar: string;
zoomIn: string;
zoomOut: string;
fitAll: string;
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: 'Аудио',
loop: 'Цикл',
autoplay: 'Автостарт',
previewAutostart: 'Авто превью',
videoLoop: 'Цикл видео',
zoomBar: 'Масштаб графа',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
fitAll: 'Показать всё',
closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»',
sideStoryStartScene: 'Начальная сцена побочной линии',
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
runFromScene: 'Запустить с этой сцены',
delete: 'Удалить',
};
const GraphUiContext = createContext<SceneGraphUiStrings>(DEFAULT_SCENE_GRAPH_UI);
export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
selectedGraphNodeId: GraphNodeId | null;
graphUi?: SceneGraphUiStrings;
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;
};
type SceneCardData = {
sceneId: SceneId;
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
isStartScene: boolean;
isSideStoryStart: boolean;
isSideStoryNode: boolean;
hasSceneAudio: boolean;
previewIsVideo: boolean;
hasAnyAudioLoop: boolean;
hasAnyAudioAutoplay: boolean;
showPreviewVideoAutostart: boolean;
showPreviewVideoLoop: boolean;
};
function IconAudioBadge() {
return (
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zM6 15a2 2 0 1 0 4 0 2 2 0 0 0-4 0z" />
</svg>
);
}
function IconVideoBadge() {
return (
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path
fill="currentColor"
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h7A2.5 2.5 0 0 1 16 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 4 17.5v-11zM19 8.2l-3 2.2v3.2l3 2.2V8.2z"
/>
</svg>
);
}
function IconLoopParam() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path
fill="currentColor"
d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"
/>
</svg>
);
}
function IconAutoplayParam() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path fill="currentColor" d="M13 2 3 14h7v8l11-14h-8l2-8z" />
</svg>
);
}
/** Иконка для «Авто превью» (видео-превью). */
function IconVideoPreviewAutostart() {
return (
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
<path fill="currentColor" d="M8 5v14l11-7-11-7z" />
</svg>
);
}
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 ? (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={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 ? (
<img
src={thumbUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={thumbUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={previewUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<video
src={previewUrl}
muted
playsInline
preload="metadata"
className={styles.videoCover}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
) : (
<ContainedVideo
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
muted
playsInline
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
)}
</div>
) : (
<div className={styles.previewPlaceholder} aria-hidden />
)}
{showCornerVideo || showCornerAudio ? (
<div className={styles.cornerBadges}>
{showCornerVideo ? (
<span className={styles.mediaBadge} title={ui.videoBadge}>
<IconVideoBadge />
</span>
) : null}
{showCornerAudio ? (
<span className={styles.mediaBadge} title={ui.audioBadge}>
<IconAudioBadge />
</span>
) : null}
</div>
) : null}
</div>
<div className={styles.nodeBody}>
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}>
{data.hasAnyAudioLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>{ui.loop}</span>
</div>
) : null}
{data.hasAnyAudioAutoplay ? (
<div className={styles.musicParam}>
<IconAutoplayParam />
<span>{ui.autoplay}</span>
</div>
) : null}
</div>
) : null}
{data.showPreviewVideoAutostart || data.showPreviewVideoLoop ? (
<div className={styles.musicParams}>
{data.showPreviewVideoAutostart ? (
<div className={styles.musicParam}>
<IconVideoPreviewAutostart />
<span>{ui.previewAutostart}</span>
</div>
) : null}
{data.showPreviewVideoLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>{ui.videoLoop}</span>
</div>
) : null}
</div>
) : null}
</div>
</div>
<Handle type="source" position={Position.Bottom} className={handleClass} />
</div>
);
}
const nodeTypes = { sceneCard: SceneCardNode };
function GraphZoomToolbar() {
const ui = useContext(GraphUiContext);
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((s) => s.transform[2]);
const pct = Math.max(1, Math.round(zoom * 100));
return (
<Panel position="bottom-center" className={styles.zoomPanel}>
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
+
</button>
<span className={styles.zoomPct}>{pct}%</span>
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button>
<span className={styles.zoomDivider} aria-hidden />
<button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.25 })}
aria-label={ui.fitAll}
title={ui.fitAll}
>
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
<path
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
/>
</svg>
</button>
</div>
</Panel>
);
}
function SceneGraphCanvas({
sceneGraphNodes,
sceneGraphEdges,
sceneCardById,
selectedGraphNodeId,
graphUi,
onGraphNodeSelect,
onConnect,
onDisconnect,
onNodePositionCommit,
onRemoveGraphNodes,
onRemoveGraphNode,
onSetGraphNodeStart,
onSetGraphNodeSideStoryStart,
onRunFromGraphNode,
onDropSceneFromList,
}: SceneGraphProps) {
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
const { screenToFlowPosition } = useReactFlow();
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
const [edgeMenu, setEdgeMenu] = useState<{ x: number; y: number; edgeId: string } | null>(null);
useEffect(() => {
if (!menu && !edgeMenu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMenu(null);
setEdgeMenu(null);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [edgeMenu, menu]);
const menuNodeIsStart = useMemo(() => {
if (!menu) return false;
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 = selectedGraphNodeId === gn.id;
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
const audios = c?.audios ?? [];
return {
id: gn.id,
type: 'sceneCard',
position: { x: gn.x, y: gn.y },
data: {
sceneId: gn.sceneId,
title: c?.title ?? '',
active,
previewAssetId: c?.previewAssetId ?? null,
previewThumbAssetId: c?.previewThumbAssetId ?? null,
previewAssetType: c?.previewAssetType ?? null,
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),
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
showPreviewVideoAutostart: c?.previewAssetType === 'video' ? c.previewVideoAutostart : false,
showPreviewVideoLoop: c?.previewAssetType === 'video' ? c.loopVideo : false,
},
style: { padding: 0, background: 'transparent', border: 'none' },
};
});
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const desiredEdges = useMemo<Edge[]>(() => {
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>([]);
useEffect(() => {
setNodes(desiredNodes as unknown as Parameters<typeof setNodes>[0]);
setEdges(desiredEdges);
}, [desiredEdges, desiredNodes, setEdges, setNodes]);
const isValidConnection = useCallback(
(conn: Connection) => {
const source = conn.source as GraphNodeId | null;
const target = conn.target as GraphNodeId | null;
if (!source || !target) return false;
return !isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, source, target);
},
[sceneGraphEdges, sceneGraphNodes],
);
const onConnectInternal = (conn: Connection) => {
const source = conn.source as GraphNodeId | null;
const target = conn.target as GraphNodeId | null;
if (!source || !target) return;
if (!isValidConnection(conn)) return;
onConnect(source, target);
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
const id = e.dataTransfer.getData(DND_SCENE_ID_MIME);
if (!id) return;
const p = screenToFlowPosition({ x: e.clientX, y: e.clientY });
onDropSceneFromList(id as SceneId, p.x - SCENE_CARD_W / 2, p.y - SCENE_CARD_H / 2);
};
const menuPosition = useMemo(() => {
if (!menu) return null;
const pad = 8;
const mw = 220;
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 };
}, [menu]);
const edgeMenuPosition = useMemo(() => {
if (!edgeMenu) return null;
const pad = 8;
const mw = 200;
const mh = 48;
const x = Math.max(pad, Math.min(edgeMenu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(edgeMenu.y, window.innerHeight - mh - pad));
return { x, y };
}, [edgeMenu]);
return (
<GraphUiContext.Provider value={ui}>
<div className={styles.canvasWrap}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={(_, node) => {
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
connectionMode={ConnectionMode.Loose}
onConnect={onConnectInternal}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
setMenu(null);
setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
}}
onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}}
onNodeClick={(_, node) => {
setMenu(null);
setEdgeMenu(null);
const d = node.data as SceneCardData;
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
setEdgeMenu(null);
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}}
onPaneClick={() => {
setMenu(null);
setEdgeMenu(null);
}}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
setEdgeMenu(null);
}}
onInit={(instance) => {
instance.fitView({ padding: 0.25 });
}}
onDragOver={onDragOver}
onDrop={onDrop}
panOnScroll
selectionOnDrag={false}
minZoom={0.1}
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
<GraphZoomToolbar />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
aria-label={ui.closeMenu}
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: menuPosition.x, top: menuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
if (menuNodeIsStart) {
onSetGraphNodeStart(null);
} else {
onSetGraphNodeStart(menu.graphNodeId);
}
setMenu(null);
}}
>
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</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"
className={styles.ctxItemDanger}
onClick={() => {
onRemoveGraphNode(menu.graphNodeId);
setMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
{edgeMenu && edgeMenuPosition
? createPortal(
<>
<button
type="button"
aria-label={ui.closeMenu}
className={styles.menuBackdrop}
onClick={() => setEdgeMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: edgeMenuPosition.x, top: edgeMenuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setEdgeMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onDisconnect(edgeMenu.edgeId);
setEdgeMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</GraphUiContext.Provider>
);
}
export function SceneGraph(props: SceneGraphProps) {
return (
<ReactFlowProvider>
<SceneGraphCanvas {...props} />
</ReactFlowProvider>
);
}