import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import ReactFlow, { Background, 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 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; export type SceneGraphProps = { sceneGraphNodes: SceneGraphNode[]; sceneGraphEdges: SceneGraphEdge[]; sceneCardById: Record; currentSceneId: SceneId | null; onCurrentSceneChange: (id: 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; 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; 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 thumbUrl = useAssetUrl(data.previewThumbAssetId); const previewUrl = useAssetUrl(data.previewAssetId); const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' '); const showCornerVideo = data.previewIsVideo; const showCornerAudio = data.hasSceneAudio; return (
{data.isStartScene ?
НАЧАЛО
: null} {thumbUrl ? (
{data.previewRotationDeg === 0 ? ( ) : ( )}
) : previewUrl && data.previewAssetType === 'image' ? (
{data.previewRotationDeg === 0 ? ( ) : ( )}
) : previewUrl && data.previewAssetType === 'video' ? (
); } const nodeTypes = { sceneCard: SceneCardNode }; function GraphZoomToolbar() { 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, currentSceneId, onCurrentSceneChange, onConnect, onDisconnect, onNodePositionCommit, onRemoveGraphNodes, onRemoveGraphNode, onSetGraphNodeStart, onDropSceneFromList, }: SceneGraphProps) { const { screenToFlowPosition } = useReactFlow(); const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null); useEffect(() => { if (!menu) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [menu]); const menuNodeIsStart = useMemo(() => { if (!menu) return false; return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene); }, [menu, sceneGraphNodes]); const desiredNodes = useMemo[]>(() => { return sceneGraphNodes.map((gn) => { const c = sceneCardById[gn.sceneId]; const active = gn.sceneId === currentSceneId; 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, 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' }, }; }); }, [currentSceneId, sceneCardById, sceneGraphNodes]); const desiredEdges = useMemo(() => { const selectedGraphNodeIds = new Set(); 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, })); }, [currentSceneId, sceneGraphEdges, sceneGraphNodes]); 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 = 120; 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]); return (
{ onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y); }} onEdgesChange={onEdgesChange} isValidConnection={isValidConnection} onConnect={onConnectInternal} onEdgesDelete={(eds) => { for (const ed of eds) { onDisconnect(ed.id); } }} onEdgeClick={(_, edge) => { onDisconnect(edge.id); }} onNodesDelete={(nds) => { onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId)); }} onNodeClick={(_, node) => { setMenu(null); const d = node.data as SceneCardData; onCurrentSceneChange(d.sceneId); }} onNodeContextMenu={(e, node) => { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId }); }} onPaneClick={() => { setMenu(null); }} onPaneContextMenu={(e) => { e.preventDefault(); setMenu(null); }} onInit={(instance) => { instance.fitView({ padding: 0.25 }); }} onDragOver={onDragOver} onDrop={onDrop} panOnScroll selectionOnDrag={false} deleteKeyCode={['Backspace', 'Delete']} proOptions={{ hideAttribution: true }} > {menu && menuPosition ? createPortal( <>
, document.body, ) : null}
); } export function SceneGraph(props: SceneGraphProps) { return ( ); }