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 { RotatedImage } from '../../shared/RotatedImage'; 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(DEFAULT_SCENE_GRAPH_UI); export type SceneGraphProps = { sceneGraphNodes: SceneGraphNode[]; sceneGraphEdges: SceneGraphEdge[]; sceneCardById: Record; /** Выделенная карточка на графе (одна нода, не все копии сцены). */ 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 ( ); } function IconVideoBadge() { return ( ); } function IconLoopParam() { return ( ); } function IconAutoplayParam() { return ( ); } /** Иконка для «Авто превью» (видео-превью). */ function IconVideoPreviewAutostart() { return ( ); } function SceneCardNode({ data }: NodeProps) { 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 (
{data.isStartScene ?
{ui.badgeStart}
: null} {data.isSideStoryStart ? (
{ui.badgeSideStory}
) : null} {thumbUrl ? (
{data.previewRotationDeg === 0 ? ( ) : ( )}
) : previewUrl && data.previewAssetType === 'image' ? (
{data.previewRotationDeg === 0 ? ( ) : ( )}
) : previewUrl && data.previewAssetType === 'video' ? (
); } 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 (
{pct}%
); } 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[]>(() => { 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(() => { 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>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); useEffect(() => { setNodes(desiredNodes as unknown as Parameters[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 (
{ 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 }} > {menu && menuPosition ? createPortal( <> {menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? ( ) : null} {!menuNodeIsSideBranch ? ( ) : null}
, document.body, ) : null} {edgeMenu && edgeMenuPosition ? createPortal( <>
, document.body, ) : null} ); } export function SceneGraph(props: SceneGraphProps) { return ( ); }