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, Scene, SceneGraphEdge, SceneGraphNode, SceneId, } from '../../../shared/types'; import { RotatedImage } from '../../shared/RotatedImage'; import { useAssetUrl } from '../../shared/useAssetImageUrl'; import styles from './SceneGraph.module.css'; /** 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[]; sceneById: 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; 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 url = 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} {url && data.previewAssetType === 'image' ? (
{data.previewRotationDeg === 0 ? ( ) : ( )}
) : url && 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, sceneById, 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 s = sceneById[gn.sceneId]; const active = gn.sceneId === currentSceneId; const audios = s?.media.audios ?? []; return { id: gn.id, type: 'sceneCard', position: { x: gn.x, y: gn.y }, data: { sceneId: gn.sceneId, title: s?.title ?? '', active, previewAssetId: s?.previewAssetId ?? null, previewAssetType: s?.previewAssetType ?? null, previewVideoAutostart: s?.previewVideoAutostart ?? false, previewRotationDeg: s?.previewRotationDeg ?? 0, isStartScene: gn.isStartScene, hasSceneAudio: audios.length >= 1, previewIsVideo: s?.previewAssetType === 'video', hasAnyAudioLoop: audios.some((a) => a.loop), hasAnyAudioAutoplay: audios.some((a) => a.autoplay), showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false, showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false, }, style: { padding: 0, background: 'transparent', border: 'none' }, }; }); }, [currentSceneId, sceneById, sceneGraphNodes]); const desiredEdges = useMemo(() => { return sceneGraphEdges.map((e) => ({ id: e.id, source: e.sourceGraphNodeId, target: e.targetGraphNodeId, type: 'smoothstep', animated: false, style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 }, })); }, [sceneGraphEdges]); 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 ( ); }