a6cbcc273e
Made-with: Cursor
497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
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<SceneId, Scene>;
|
||
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 (
|
||
<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 url = useAssetUrl(data.previewAssetId);
|
||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||
const showCornerVideo = data.previewIsVideo;
|
||
const showCornerAudio = data.hasSceneAudio;
|
||
return (
|
||
<div className={styles.nodeWrap}>
|
||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||
<div className={cardClass}>
|
||
<div className={styles.previewShell}>
|
||
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
||
{url && data.previewAssetType === 'image' ? (
|
||
<div className={styles.previewFill}>
|
||
{data.previewRotationDeg === 0 ? (
|
||
<img src={url} alt="" className={styles.imageCover} draggable={false} />
|
||
) : (
|
||
<RotatedImage
|
||
url={url}
|
||
rotationDeg={data.previewRotationDeg}
|
||
mode="cover"
|
||
style={{ width: '100%', height: '100%' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : url && data.previewAssetType === 'video' ? (
|
||
<video
|
||
src={url}
|
||
muted
|
||
playsInline
|
||
preload="metadata"
|
||
className={styles.videoCover}
|
||
onLoadedData={(e) => {
|
||
const v = e.currentTarget;
|
||
try {
|
||
v.currentTime = 0;
|
||
v.pause();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className={styles.previewPlaceholder} aria-hidden />
|
||
)}
|
||
{showCornerVideo || showCornerAudio ? (
|
||
<div className={styles.cornerBadges}>
|
||
{showCornerVideo ? (
|
||
<span className={styles.mediaBadge} title="Видео">
|
||
<IconVideoBadge />
|
||
</span>
|
||
) : null}
|
||
{showCornerAudio ? (
|
||
<span className={styles.mediaBadge} title="Аудио">
|
||
<IconAudioBadge />
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.nodeBody}>
|
||
<div className={styles.title}>{data.title || 'Без названия'}</div>
|
||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||
<div className={styles.musicParams}>
|
||
{data.hasAnyAudioLoop ? (
|
||
<div className={styles.musicParam}>
|
||
<IconLoopParam />
|
||
<span>Цикл</span>
|
||
</div>
|
||
) : null}
|
||
{data.hasAnyAudioAutoplay ? (
|
||
<div className={styles.musicParam}>
|
||
<IconAutoplayParam />
|
||
<span>Автостарт</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{data.showPreviewVideoAutostart || data.showPreviewVideoLoop ? (
|
||
<div className={styles.musicParams}>
|
||
{data.showPreviewVideoAutostart ? (
|
||
<div className={styles.musicParam}>
|
||
<IconVideoPreviewAutostart />
|
||
<span>Авто превью</span>
|
||
</div>
|
||
) : null}
|
||
{data.showPreviewVideoLoop ? (
|
||
<div className={styles.musicParam}>
|
||
<IconLoopParam />
|
||
<span>Цикл видео</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Panel position="bottom-center" className={styles.zoomPanel}>
|
||
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
|
||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
|
||
+
|
||
</button>
|
||
<span className={styles.zoomPct}>{pct}%</span>
|
||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
|
||
−
|
||
</button>
|
||
<span className={styles.zoomDivider} aria-hidden />
|
||
<button
|
||
type="button"
|
||
className={styles.zoomBtn}
|
||
onClick={() => fitView({ padding: 0.25 })}
|
||
aria-label="Показать всё"
|
||
title="Показать всё"
|
||
>
|
||
<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,
|
||
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<Node<SceneCardData>[]>(() => {
|
||
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<Edge[]>(() => {
|
||
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<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 = 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 (
|
||
<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}
|
||
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 }}
|
||
>
|
||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||
<GraphZoomToolbar />
|
||
</ReactFlow>
|
||
{menu && menuPosition
|
||
? createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label="Закрыть меню"
|
||
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 ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
className={styles.ctxItemDanger}
|
||
onClick={() => {
|
||
onRemoveGraphNode(menu.graphNodeId);
|
||
setMenu(null);
|
||
}}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SceneGraph(props: SceneGraphProps) {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<SceneGraphCanvas {...props} />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|