101f595bac
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
617 lines
20 KiB
TypeScript
617 lines
20 KiB
TypeScript
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import ReactFlow, {
|
||
Background,
|
||
BaseEdge,
|
||
ConnectionMode,
|
||
EdgeLabelRenderer,
|
||
Handle,
|
||
MarkerType,
|
||
Panel,
|
||
Position,
|
||
ReactFlowProvider,
|
||
useEdgesState,
|
||
useNodesState,
|
||
useReactFlow,
|
||
type Connection,
|
||
type Edge,
|
||
type EdgeProps,
|
||
type Node,
|
||
type NodeProps,
|
||
type OnConnectStartParams,
|
||
} from 'reactflow';
|
||
import 'reactflow/dist/style.css';
|
||
|
||
import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
|
||
import type {
|
||
NpcGroupId,
|
||
NpcId,
|
||
NpcRelationId,
|
||
ProjectNpc,
|
||
ProjectNpcGroup,
|
||
ProjectNpcRelation,
|
||
} from '../../shared/types';
|
||
import { Select } from '../shared/ui/controls';
|
||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||
|
||
import styles from './NpcGraph.module.css';
|
||
|
||
type OpenEdgeMenuFn = (relationId: NpcRelationId, x: number, y: number) => void;
|
||
type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
|
||
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
|
||
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
|
||
|
||
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
|
||
|
||
export type NpcGraphUiStrings = {
|
||
zoomBar: string;
|
||
zoomIn: string;
|
||
zoomOut: string;
|
||
fitAll: string;
|
||
editRelation: string;
|
||
deleteRelation: string;
|
||
untitled: string;
|
||
graphFilter: string;
|
||
graphFilterAll: string;
|
||
graphFilterUngrouped: string;
|
||
};
|
||
|
||
type NpcNodeData = {
|
||
name: string;
|
||
avatarAssetId: ProjectNpc['avatarAssetId'];
|
||
active: boolean;
|
||
groupColor: string | null;
|
||
dimmed: boolean;
|
||
};
|
||
|
||
const NPC_ACCENT = '#60a5fa';
|
||
const NPC_EDGE_IDLE = '#a1a1aa';
|
||
/** Согласовано с `.node` в CSS (ширина + типичная высота карточки). */
|
||
const NPC_NODE_W = 120;
|
||
const NPC_NODE_H = 150;
|
||
/** Расстояние между параллельными связями одной пары НПС. */
|
||
const PARALLEL_EDGE_GAP = 21;
|
||
|
||
type Side = 'left' | 'right' | 'top' | 'bottom';
|
||
|
||
type NpcEdgeData = {
|
||
label: string;
|
||
/** Смещение в мировых координатах (общее для пары, без учёта направления). */
|
||
offset: number;
|
||
relationId: NpcRelationId;
|
||
sourceNpcId: NpcId;
|
||
targetNpcId: NpcId;
|
||
highlighted: boolean;
|
||
};
|
||
|
||
/** Любые связи между одной парой НПС (A→B и B→A) — в одной группе разведения. */
|
||
function undirectedPairKey(a: NpcId, b: NpcId): string {
|
||
return a < b ? `${a}__${b}` : `${b}__${a}`;
|
||
}
|
||
|
||
function sideToPosition(side: Side): Position {
|
||
switch (side) {
|
||
case 'left':
|
||
return Position.Left;
|
||
case 'right':
|
||
return Position.Right;
|
||
case 'top':
|
||
return Position.Top;
|
||
case 'bottom':
|
||
return Position.Bottom;
|
||
}
|
||
}
|
||
|
||
/** Выбираем стороны карточек так, чтобы линия выходила наружу и не шла сквозь блок. */
|
||
function pickEndpointSides(
|
||
sourcePos: { x: number; y: number },
|
||
targetPos: { x: number; y: number },
|
||
): { sourceSide: Side; targetSide: Side } {
|
||
const sx = sourcePos.x + NPC_NODE_W / 2;
|
||
const sy = sourcePos.y + NPC_NODE_H / 2;
|
||
const tx = targetPos.x + NPC_NODE_W / 2;
|
||
const ty = targetPos.y + NPC_NODE_H / 2;
|
||
const dx = tx - sx;
|
||
const dy = ty - sy;
|
||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||
return dx >= 0
|
||
? { sourceSide: 'right', targetSide: 'left' }
|
||
: { sourceSide: 'left', targetSide: 'right' };
|
||
}
|
||
return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
|
||
}
|
||
|
||
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
|
||
const url = useAssetUrl(data.avatarAssetId);
|
||
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
|
||
const accent = data.groupColor ?? NPC_ACCENT;
|
||
return (
|
||
<div
|
||
className={[
|
||
styles.node,
|
||
data.active || selected ? styles.nodeActive : '',
|
||
data.dimmed ? styles.nodeDimmed : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
style={
|
||
data.groupColor
|
||
? ({
|
||
'--npc-group-color': accent,
|
||
borderColor: data.active || selected ? accent : undefined,
|
||
} as React.CSSProperties)
|
||
: undefined
|
||
}
|
||
>
|
||
{sides.map((side) => (
|
||
<React.Fragment key={side}>
|
||
<Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
|
||
<Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
|
||
</React.Fragment>
|
||
))}
|
||
<div className={styles.avatar}>
|
||
{url ? <img className={styles.avatarImg} src={url} alt="" draggable={false} /> : null}
|
||
</div>
|
||
<div className={styles.name}>{data.name || '—'}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Кривая с перпендикуляром в мировых координатах.
|
||
* Базис берём от «меньшего» id к «большему», чтобы A→B и B→A с разными offset не совпадали.
|
||
*/
|
||
function parallelCubicPath(
|
||
sourceNpcId: NpcId,
|
||
targetNpcId: NpcId,
|
||
sourceX: number,
|
||
sourceY: number,
|
||
targetX: number,
|
||
targetY: number,
|
||
worldOffset: number,
|
||
): { path: string; labelX: number; labelY: number } {
|
||
// Канонический вектор между концами (не зависит от направления стрелки).
|
||
const [ax, ay, bx, by] =
|
||
sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
|
||
const cdx = bx - ax;
|
||
const cdy = by - ay;
|
||
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
|
||
const px = (-cdy / clen) * worldOffset;
|
||
const py = (cdx / clen) * worldOffset;
|
||
|
||
// Сдвигаем всю кривую (включая концы у ручек), чтобы линии не сливались.
|
||
const sx = sourceX + px;
|
||
const sy = sourceY + py;
|
||
const tx = targetX + px;
|
||
const ty = targetY + py;
|
||
const dx = tx - sx;
|
||
const dy = ty - sy;
|
||
const c1x = sx + dx * 0.35;
|
||
const c1y = sy + dy * 0.35;
|
||
const c2x = sx + dx * 0.65;
|
||
const c2y = sy + dy * 0.65;
|
||
return {
|
||
path: `M ${sx},${sy} C ${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`,
|
||
labelX: (sx + tx) / 2,
|
||
labelY: (sy + ty) / 2,
|
||
};
|
||
}
|
||
|
||
function LabeledNpcEdge({
|
||
id,
|
||
sourceX,
|
||
sourceY,
|
||
targetX,
|
||
targetY,
|
||
style,
|
||
markerEnd,
|
||
data,
|
||
}: EdgeProps<NpcEdgeData>) {
|
||
const openEdgeMenu = useContext(OpenEdgeMenuContext);
|
||
const selectSourceNpc = useContext(SelectSourceNpcContext);
|
||
const worldOffset = data?.offset ?? 0;
|
||
const sourceNpcId = data?.sourceNpcId;
|
||
const targetNpcId = data?.targetNpcId;
|
||
const { path, labelX, labelY } =
|
||
sourceNpcId && targetNpcId
|
||
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
|
||
: {
|
||
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
||
labelX: (sourceX + targetX) / 2,
|
||
labelY: (sourceY + targetY) / 2,
|
||
};
|
||
const label = data?.label ?? '';
|
||
const relationId = data?.relationId;
|
||
const highlighted = Boolean(data?.highlighted);
|
||
|
||
return (
|
||
<>
|
||
<BaseEdge
|
||
id={id}
|
||
path={path}
|
||
interactionWidth={24}
|
||
{...(style ? { style } : {})}
|
||
{...(markerEnd ? { markerEnd } : {})}
|
||
/>
|
||
{label && relationId ? (
|
||
<EdgeLabelRenderer>
|
||
<div
|
||
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
style={{
|
||
position: 'absolute',
|
||
left: labelX,
|
||
top: labelY,
|
||
zIndex: highlighted ? 20 : 5,
|
||
}}
|
||
title={label}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (sourceNpcId) selectSourceNpc?.(sourceNpcId);
|
||
}}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
openEdgeMenu?.(relationId, e.clientX, e.clientY);
|
||
}}
|
||
>
|
||
{label}
|
||
</div>
|
||
</EdgeLabelRenderer>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||
return (
|
||
<Panel position="bottom-right">
|
||
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
|
||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
|
||
+
|
||
</button>
|
||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||
−
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.zoomBtn}
|
||
onClick={() => fitView({ padding: 0.2 })}
|
||
aria-label={ui.fitAll}
|
||
>
|
||
⤢
|
||
</button>
|
||
</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
function FilterToolbar({
|
||
ui,
|
||
groups,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
ui: NpcGraphUiStrings;
|
||
groups: ProjectNpcGroup[];
|
||
value: GraphGroupFilter;
|
||
onChange: (v: GraphGroupFilter) => void;
|
||
}) {
|
||
return (
|
||
<Panel position="top-left">
|
||
<Select
|
||
{...(styles.filterSelect ? { className: styles.filterSelect } : {})}
|
||
ariaLabel={ui.graphFilter}
|
||
value={value}
|
||
onChange={(next) => onChange(next as GraphGroupFilter)}
|
||
options={[
|
||
{ value: 'all', label: ui.graphFilterAll },
|
||
{ value: 'ungrouped', label: ui.graphFilterUngrouped },
|
||
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||
]}
|
||
/>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
export type NpcGraphProps = {
|
||
npcs: ProjectNpc[];
|
||
relations: ProjectNpcRelation[];
|
||
npcGroups: ProjectNpcGroup[];
|
||
selectedNpcId: NpcId | null;
|
||
graphFilter: GraphGroupFilter;
|
||
onGraphFilterChange: (filter: GraphGroupFilter) => void;
|
||
graphUi: NpcGraphUiStrings;
|
||
onSelect: (npcId: NpcId) => void;
|
||
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
|
||
onNodePositionCommit: (npcId: NpcId, x: number, y: number) => void;
|
||
onEditRelation: (relationId: NpcRelationId) => void;
|
||
onDeleteRelation: (relationId: NpcRelationId) => void;
|
||
};
|
||
|
||
function NpcGraphInner({
|
||
npcs,
|
||
relations,
|
||
npcGroups,
|
||
selectedNpcId,
|
||
graphFilter,
|
||
onGraphFilterChange,
|
||
graphUi,
|
||
onSelect,
|
||
onConnectRequest,
|
||
onNodePositionCommit,
|
||
onEditRelation,
|
||
onDeleteRelation,
|
||
}: NpcGraphProps) {
|
||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
|
||
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
|
||
const connectFromRef = useRef<NpcId | null>(null);
|
||
|
||
const edgeTypes = useMemo(() => ({ npcRelation: LabeledNpcEdge }), []);
|
||
const nodeTypes = useMemo(() => ({ npc: NpcNode }), []);
|
||
|
||
const menuPosition = useMemo(() => {
|
||
if (!menu) return null;
|
||
const pad = 8;
|
||
const mw = 180;
|
||
const mh = 88;
|
||
return {
|
||
left: Math.max(pad, Math.min(menu.left, window.innerWidth - mw - pad)),
|
||
top: Math.max(pad, Math.min(menu.top, window.innerHeight - mh - pad)),
|
||
};
|
||
}, [menu]);
|
||
|
||
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
|
||
|
||
const filterGroupIds = useMemo(() => {
|
||
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
|
||
return collectDescendantGroupIds(npcGroups, graphFilter);
|
||
}, [graphFilter, npcGroups]);
|
||
|
||
const isNpcDimmed = useCallback(
|
||
(npc: ProjectNpc) => {
|
||
if (graphFilter === 'all') return false;
|
||
if (graphFilter === 'ungrouped') return npc.groupId !== null;
|
||
if (!filterGroupIds) return true;
|
||
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
|
||
},
|
||
[filterGroupIds, graphFilter],
|
||
);
|
||
|
||
const initialNodes: Node<NpcNodeData>[] = useMemo(
|
||
() =>
|
||
npcs.map((n) => ({
|
||
id: n.id,
|
||
type: 'npc',
|
||
position: { x: n.x, y: n.y },
|
||
data: {
|
||
name: n.name,
|
||
avatarAssetId: n.avatarAssetId,
|
||
active: n.id === selectedNpcId,
|
||
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
|
||
dimmed: isNpcDimmed(n),
|
||
},
|
||
})),
|
||
[groupColorById, isNpcDimmed, npcs, selectedNpcId],
|
||
);
|
||
|
||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||
|
||
const builtEdges: Edge<NpcEdgeData>[] = useMemo(() => {
|
||
const posById = new Map<string, { x: number; y: number }>();
|
||
for (const n of nodes) posById.set(n.id, n.position);
|
||
// Пока nodes ещё пуст/не синхронизирован — берём координаты из проекта.
|
||
for (const n of npcs) {
|
||
if (!posById.has(n.id)) posById.set(n.id, { x: n.x, y: n.y });
|
||
}
|
||
|
||
// Группируем ВСЕ связи между парой НПС (оба направления), чтобы не накладывались.
|
||
const groups = new Map<string, ProjectNpcRelation[]>();
|
||
for (const r of relations) {
|
||
const key = undirectedPairKey(r.sourceNpcId, r.targetNpcId);
|
||
const list = groups.get(key) ?? [];
|
||
list.push(r);
|
||
groups.set(key, list);
|
||
}
|
||
const out: Edge<NpcEdgeData>[] = [];
|
||
for (const group of groups.values()) {
|
||
const sorted = [...group].sort((a, b) => a.id.localeCompare(b.id));
|
||
const total = sorted.length;
|
||
sorted.forEach((r, index) => {
|
||
const sourcePos = posById.get(r.sourceNpcId) ?? { x: 0, y: 0 };
|
||
const targetPos = posById.get(r.targetNpcId) ?? { x: 0, y: 0 };
|
||
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
|
||
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
|
||
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
|
||
const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
|
||
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
|
||
const edgeDimmed =
|
||
graphFilter !== 'all' &&
|
||
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
|
||
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
|
||
out.push({
|
||
id: r.id,
|
||
source: r.sourceNpcId,
|
||
target: r.targetNpcId,
|
||
sourceHandle: `s-${sourceSide}`,
|
||
targetHandle: `t-${targetSide}`,
|
||
type: 'npcRelation',
|
||
zIndex: highlighted ? 10 : 0,
|
||
data: {
|
||
label: r.label,
|
||
offset,
|
||
relationId: r.id,
|
||
sourceNpcId: r.sourceNpcId,
|
||
targetNpcId: r.targetNpcId,
|
||
highlighted,
|
||
},
|
||
style: {
|
||
stroke: color,
|
||
strokeWidth: highlighted ? 2.5 : 2,
|
||
opacity: edgeDimmed ? 0.2 : 1,
|
||
},
|
||
markerEnd: {
|
||
type: MarkerType.ArrowClosed,
|
||
width: 16,
|
||
height: 16,
|
||
color,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
return out;
|
||
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
|
||
|
||
useEffect(() => {
|
||
setNodes(initialNodes);
|
||
}, [initialNodes, setNodes]);
|
||
|
||
useEffect(() => {
|
||
setEdges(builtEdges);
|
||
}, [builtEdges, setEdges]);
|
||
|
||
const onConnectStart = useCallback((_event: unknown, params: OnConnectStartParams) => {
|
||
connectFromRef.current = params.nodeId ? (params.nodeId as NpcId) : null;
|
||
}, []);
|
||
|
||
const onConnect = useCallback(
|
||
(conn: Connection) => {
|
||
if (!conn.source || !conn.target || conn.source === conn.target) return;
|
||
const from = connectFromRef.current;
|
||
if (from && (from === conn.source || from === conn.target)) {
|
||
const to = (from === conn.source ? conn.target : conn.source) as NpcId;
|
||
onConnectRequest(from, to);
|
||
return;
|
||
}
|
||
onConnectRequest(conn.source as NpcId, conn.target as NpcId);
|
||
},
|
||
[onConnectRequest],
|
||
);
|
||
|
||
const openEdgeMenu = useCallback((relationId: NpcRelationId, x: number, y: number) => {
|
||
setMenu({ relationId, left: x, top: y });
|
||
}, []);
|
||
|
||
const selectSourceNpc = useCallback(
|
||
(sourceNpcId: NpcId) => {
|
||
setMenu(null);
|
||
onSelect(sourceNpcId);
|
||
},
|
||
[onSelect],
|
||
);
|
||
|
||
return (
|
||
<OpenEdgeMenuContext.Provider value={openEdgeMenu}>
|
||
<SelectSourceNpcContext.Provider value={selectSourceNpc}>
|
||
<div className={styles.wrap}>
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onConnectStart={onConnectStart}
|
||
onConnect={onConnect}
|
||
onConnectEnd={() => {
|
||
connectFromRef.current = null;
|
||
}}
|
||
nodeTypes={nodeTypes}
|
||
edgeTypes={edgeTypes}
|
||
connectionMode={ConnectionMode.Loose}
|
||
fitView
|
||
proOptions={{ hideAttribution: true }}
|
||
onNodeClick={(_e, node) => {
|
||
setMenu(null);
|
||
onSelect(node.id as NpcId);
|
||
}}
|
||
onNodeDragStop={(_e, node) => {
|
||
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
|
||
}}
|
||
onEdgeClick={(e, edge) => {
|
||
e.stopPropagation();
|
||
setMenu(null);
|
||
const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||
onSelect(sourceId);
|
||
}}
|
||
onEdgeContextMenu={(e, edge) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const relationId =
|
||
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
|
||
openEdgeMenu(relationId, e.clientX, e.clientY);
|
||
}}
|
||
onPaneClick={() => setMenu(null)}
|
||
onPaneContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setMenu(null);
|
||
}}
|
||
>
|
||
<Background gap={18} size={1} color="#27272a" />
|
||
<FilterToolbar
|
||
ui={graphUi}
|
||
groups={npcGroups}
|
||
value={graphFilter}
|
||
onChange={onGraphFilterChange}
|
||
/>
|
||
<ZoomToolbar ui={graphUi} />
|
||
</ReactFlow>
|
||
{menu && menuPosition
|
||
? createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label="close"
|
||
className={styles.menuBackdrop}
|
||
onClick={() => setMenu(null)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setMenu(null);
|
||
}}
|
||
/>
|
||
<div
|
||
className={styles.menu}
|
||
style={{ left: menuPosition.left, top: menuPosition.top }}
|
||
data-npc-edge-menu="1"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={styles.menuItem}
|
||
onClick={() => {
|
||
onEditRelation(menu.relationId);
|
||
setMenu(null);
|
||
}}
|
||
>
|
||
{graphUi.editRelation}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
|
||
onClick={() => {
|
||
onDeleteRelation(menu.relationId);
|
||
setMenu(null);
|
||
}}
|
||
>
|
||
{graphUi.deleteRelation}
|
||
</button>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
</SelectSourceNpcContext.Provider>
|
||
</OpenEdgeMenuContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function NpcGraph(props: NpcGraphProps) {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<NpcGraphInner {...props} />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|