DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
.nodeWrap {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--stroke-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 8px;
|
||||
background: #18181b;
|
||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.cardActive {
|
||||
border-color: var(--graph-node-active-border);
|
||||
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 135px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.previewFill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.previewPlaceholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.badgeStart {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mediaBadge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.badgeGlyph {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageCover,
|
||||
.videoCover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageCover {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodeBody {
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParam {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: calc(14px * 0.7);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musicParamIcon {
|
||||
flex-shrink: 0;
|
||||
color: #a78bfa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvasWrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow) {
|
||||
background-color: var(--bg0);
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow__renderer) {
|
||||
background-color: var(--bg0);
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow__attribution) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoomPanel {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.zoomBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(24, 24, 27, 0.96);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.zoomPct {
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zoomDivider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomFitIcon {
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-menu-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: var(--z-file-menu);
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-menu);
|
||||
box-shadow: var(--shadow-menu);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user