Files
DndGamePlayer/app/renderer/editor/graph/SceneGraph.tsx
T
2026-05-11 22:20:14 +08:00

610 lines
21 KiB
TypeScript
Raw 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, { createContext, useCallback, useContext, 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;
/** UI strings for the scene graph (passed from editor i18n). */
export type SceneGraphUiStrings = {
badgeStart: 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;
delete: string;
};
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
badgeStart: 'НАЧАЛО',
untitled: 'Без названия',
videoBadge: 'Видео',
audioBadge: 'Аудио',
loop: 'Цикл',
autoplay: 'Автостарт',
previewAutostart: 'Авто превью',
videoLoop: 'Цикл видео',
zoomBar: 'Масштаб графа',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
fitAll: 'Показать всё',
closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»',
delete: 'Удалить',
};
const GraphUiContext = createContext<SceneGraphUiStrings>(DEFAULT_SCENE_GRAPH_UI);
export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null;
graphUi?: SceneGraphUiStrings;
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 (
<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 ui = useContext(GraphUiContext);
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 (
<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}>{ui.badgeStart}</div> : null}
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={thumbUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={thumbUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={previewUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<video
src={previewUrl}
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={ui.videoBadge}>
<IconVideoBadge />
</span>
) : null}
{showCornerAudio ? (
<span className={styles.mediaBadge} title={ui.audioBadge}>
<IconAudioBadge />
</span>
) : null}
</div>
) : null}
</div>
<div className={styles.nodeBody}>
<div className={styles.title}>{data.title || ui.untitled}</div>
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}>
{data.hasAnyAudioLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>{ui.loop}</span>
</div>
) : null}
{data.hasAnyAudioAutoplay ? (
<div className={styles.musicParam}>
<IconAutoplayParam />
<span>{ui.autoplay}</span>
</div>
) : null}
</div>
) : null}
{data.showPreviewVideoAutostart || data.showPreviewVideoLoop ? (
<div className={styles.musicParams}>
{data.showPreviewVideoAutostart ? (
<div className={styles.musicParam}>
<IconVideoPreviewAutostart />
<span>{ui.previewAutostart}</span>
</div>
) : null}
{data.showPreviewVideoLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>{ui.videoLoop}</span>
</div>
) : null}
</div>
) : null}
</div>
</div>
<Handle type="source" position={Position.Bottom} className={styles.handle} />
</div>
);
}
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 (
<Panel position="bottom-center" className={styles.zoomPanel}>
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
+
</button>
<span className={styles.zoomPct}>{pct}%</span>
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button>
<span className={styles.zoomDivider} aria-hidden />
<button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.25 })}
aria-label={ui.fitAll}
title={ui.fitAll}
>
<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,
sceneCardById,
currentSceneId,
graphUi,
onCurrentSceneChange,
onConnect,
onDisconnect,
onNodePositionCommit,
onRemoveGraphNodes,
onRemoveGraphNode,
onSetGraphNodeStart,
onDropSceneFromList,
}: SceneGraphProps) {
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
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 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<Edge[]>(() => {
const selectedGraphNodeIds = new Set<GraphNodeId>();
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<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 (
<GraphUiContext.Provider value={ui}>
<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={ui.closeMenu}
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 ? ui.unsetStartScene : ui.startScene}
</button>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onRemoveGraphNode(menu.graphNodeId);
setMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</GraphUiContext.Provider>
);
}
export function SceneGraph(props: SceneGraphProps) {
return (
<ReactFlowProvider>
<SceneGraphCanvas {...props} />
</ReactFlowProvider>
);
}