fix(editor): delete graph edges via right-click context menu

Remove accidental edge deletion on click and document the new graph link workflow in help and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-01 14:26:14 +08:00
parent 3acb89c80d
commit 374e041321
3 changed files with 72 additions and 12 deletions
+61 -10
View File
@@ -364,15 +364,19 @@ function SceneGraphCanvas({
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI; const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
const [edgeMenu, setEdgeMenu] = useState<{ x: number; y: number; edgeId: string } | null>(null);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu && !edgeMenu) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null); if (e.key === 'Escape') {
setMenu(null);
setEdgeMenu(null);
}
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [menu]); }, [edgeMenu, menu]);
const menuNodeIsStart = useMemo(() => { const menuNodeIsStart = useMemo(() => {
if (!menu) return false; if (!menu) return false;
@@ -439,6 +443,7 @@ function SceneGraphCanvas({
target: e.targetGraphNodeId, target: e.targetGraphNodeId,
type: 'smoothstep', type: 'smoothstep',
animated: false, animated: false,
selectable: false,
})); }));
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]); }, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
@@ -491,6 +496,16 @@ function SceneGraphCanvas({
return { x, y }; return { x, y };
}, [menu]); }, [menu]);
const edgeMenuPosition = useMemo(() => {
if (!edgeMenu) return null;
const pad = 8;
const mw = 200;
const mh = 48;
const x = Math.max(pad, Math.min(edgeMenu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(edgeMenu.y, window.innerHeight - mh - pad));
return { x, y };
}, [edgeMenu]);
return ( return (
<GraphUiContext.Provider value={ui}> <GraphUiContext.Provider value={ui}>
<div className={styles.canvasWrap}> <div className={styles.canvasWrap}>
@@ -505,32 +520,33 @@ function SceneGraphCanvas({
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
onConnect={onConnectInternal} onConnect={onConnectInternal}
onEdgesDelete={(eds) => { onEdgeContextMenu={(e, edge) => {
for (const ed of eds) { e.preventDefault();
onDisconnect(ed.id); setMenu(null);
} setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
}}
onEdgeClick={(_, edge) => {
onDisconnect(edge.id);
}} }}
onNodesDelete={(nds) => { onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId)); onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}} }}
onNodeClick={(_, node) => { onNodeClick={(_, node) => {
setMenu(null); setMenu(null);
setEdgeMenu(null);
const d = node.data as SceneCardData; const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId); onCurrentSceneChange(d.sceneId);
}} }}
onNodeContextMenu={(e, node) => { onNodeContextMenu={(e, node) => {
e.preventDefault(); e.preventDefault();
setEdgeMenu(null);
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId }); setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}} }}
onPaneClick={() => { onPaneClick={() => {
setMenu(null); setMenu(null);
setEdgeMenu(null);
}} }}
onPaneContextMenu={(e) => { onPaneContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setMenu(null); setMenu(null);
setEdgeMenu(null);
}} }}
onInit={(instance) => { onInit={(instance) => {
instance.fitView({ padding: 0.25 }); instance.fitView({ padding: 0.25 });
@@ -595,6 +611,41 @@ function SceneGraphCanvas({
document.body, document.body,
) )
: null} : null}
{edgeMenu && edgeMenuPosition
? createPortal(
<>
<button
type="button"
aria-label={ui.closeMenu}
className={styles.menuBackdrop}
onClick={() => setEdgeMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: edgeMenuPosition.x, top: edgeMenuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setEdgeMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onDisconnect(edgeMenu.edgeId);
setEdgeMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
</div> </div>
</GraphUiContext.Provider> </GraphUiContext.Provider>
); );
+2 -2
View File
@@ -159,7 +159,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Граф сцен', 'help.section.graph.title': 'Граф сцен',
'help.section.graph.body': 'help.section.graph.body':
'Граф — визуальная карта кампании. Каждый узел на графе — экземпляр сцены; одна и та же сцена может встречаться на графе несколько раз (например, возврат в локацию).\n\nПеретащите сцену из левого списка на свободное место графа — появится карточка-узел. Перетаскивайте узлы, чтобы расставить схему. Соедините два узла: потяните от нижней точки (handle) одного узла к верхней точке другого — появится стрелка перехода. Одна карточка может иметь несколько исходящих связей (ветвление).\n\nНельзя провести вторую связь к тому же целевому узлу с той же карточки. Чтобы удалить связь, выделите стрелку и нажмите Delete или используйте контекстное меню React Flow.\n\nПравый клик по узлу: «Начальная сцена» — с какого узла стартует партия при «Запустить»; «Удалить» — убрать узел с графа (сама сцена в списке останется). На начальной сцене отображается метка «НАЧАЛО».\n\nПанель масштаба внизу графа: увеличение, уменьшение, «Показать всё». Колёсико мыши над графом тоже меняет масштаб.', 'Граф — визуальная карта кампании. Каждый узел на графе — экземпляр сцены; одна и та же сцена может встречаться на графе несколько раз (например, возврат в локацию).\n\nПеретащите сцену из левого списка на свободное место графа — появится карточка-узел. Перетаскивайте узлы, чтобы расставить схему. Соедините два узла: потяните от нижней точки (handle) одного узла к верхней точке другого — появится стрелка перехода. Одна карточка может иметь несколько исходящих связей (ветвление).\n\nНельзя провести вторую связь к тому же целевому узлу с той же карточки. Обычный клик и двойной клик по линии ничего не делают. Чтобы удалить связь, нажмите по линии правой кнопкой мыши и выберите «Удалить» в контекстном меню.\n\nПравый клик по узлу: «Начальная сцена» — с какого узла стартует партия при «Запустить»; «Удалить» — убрать узел с графа (сама сцена в списке останется). На начальной сцене отображается метка «НАЧАЛО».\n\nПанель масштаба внизу графа: увеличение, уменьшение, «Показать всё». Колёсико мыши над графом тоже меняет масштаб.',
'help.section.sceneProps.title': 'Свойства сцены', 'help.section.sceneProps.title': 'Свойства сцены',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
@@ -476,7 +476,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Scene graph', 'help.section.graph.title': 'Scene graph',
'help.section.graph.body': 'help.section.graph.body':
'The graph is a visual map of your campaign. Each node is a scene instance; the same scene can appear on the graph more than once (e.g. returning to a location).\n\nDrag a scene from the left list onto empty graph space to create a node. Drag nodes to arrange the layout. Connect two nodes: pull from the bottom handle of one node to the top handle of another — a transition arrow appears. One card can have several outgoing links (branching).\n\nYou cannot add a second link to the same target from the same source. To remove a link, select the arrow and press Delete or use the flow context menu.\n\nRight-click a node: Start scene — where the party begins when you Run; Delete — remove the node from the graph (the scene stays in the list). The start node shows a START badge.\n\nThe zoom bar at the bottom: zoom in, zoom out, fit view. The mouse wheel over the graph also zooms.', 'The graph is a visual map of your campaign. Each node is a scene instance; the same scene can appear on the graph more than once (e.g. returning to a location).\n\nDrag a scene from the left list onto empty graph space to create a node. Drag nodes to arrange the layout. Connect two nodes: pull from the bottom handle of one node to the top handle of another — a transition arrow appears. One card can have several outgoing links (branching).\n\nYou cannot add a second link to the same target from the same source. Regular clicks and double-clicks on a line do nothing. To remove a link, right-click the line and choose Delete in the context menu.\n\nRight-click a node: Start scene — where the party begins when you Run; Delete — remove the node from the graph (the scene stays in the list). The start node shows a START badge.\n\nThe zoom bar at the bottom: zoom in, zoom out, fit view. The mouse wheel over the graph also zooms.',
'help.section.sceneProps.title': 'Scene properties', 'help.section.sceneProps.title': 'Scene properties',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
+9
View File
@@ -0,0 +1,9 @@
# Graph Editing
## Scene Links
- Create a link by dragging from the source node handle to the target node handle.
- A regular click or double-click on a link must not change the graph.
- Delete a link only through its context menu: right-click the line and choose **Delete**.
- Delete a node through the node context menu: right-click the node and choose **Delete**. Removing a node does not delete the scene from the scene list.