Files

497 lines
17 KiB
TypeScript
Raw Permalink 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, { 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>
);
}