Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions docs/how-to/context-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Node Context Menus

Panel-ReactFlow supports per-node context menus that appear on right-click.
Define the menu content by overriding the `context_menu()` method on a `Node`
subclass. The method returns any Panel component, which is rendered as a
floating overlay at the click position.

---

## Define a context menu

Override `context_menu()` on your `Node` subclass to return a Panel component.
The menu is dismissed automatically when the user clicks elsewhere on the
canvas.

```python
import panel as pn
import panel_material_ui as pmui
from panel_reactflow import Node, ReactFlow


class TaskNode(Node):
def context_menu(self):
return pn.Column(
pmui.Button(name="Run", variant="text", size="small"),
pmui.Button(name="Delete", variant="text", color="error", size="small"),
)


flow = ReactFlow(nodes=[
TaskNode(id="t1", position={"x": 0, "y": 0}, label="My Task", data={}),
])
```

---

## Access node state in the menu

The `context_menu()` method runs on the node instance, so you have access to
all its parameters and the parent flow via `self.flow`.

```python
class PipelineNode(Node):
status = param.Selector(
default="idle", objects=["idle", "running", "done"], precedence=0
)

def context_menu(self):
def set_status(status):
self.flow.patch_node_data(self.id, {"status": status})
# Close the menu after action
self.flow._handle_msg({"type": "close_context_menu"})

return pn.Column(
pn.pane.Markdown(f"**{self.label}** ({self.status})"),
pmui.Button(
name="Start", variant="text", size="small",
on_click=lambda e: set_status("running"),
),
pmui.Button(
name="Delete", variant="text", color="error", size="small",
on_click=lambda e: self.flow.remove_node(self.id),
),
)
```

---

## Close the menu programmatically

The context menu closes when the user clicks anywhere on the canvas pane.
To close it from a button callback (e.g. after performing an action), send
the close message:

```python
self.flow._handle_msg({"type": "close_context_menu"})
```

---

## Listen for context menu events

You can also react to the right-click event without rendering a menu by
subscribing to the `"node_context_menu"` event:

```python
def on_context(payload, flow):
print(f"Right-clicked node {payload['node_id']} at {payload['position']}")

flow.on("node_context_menu", on_context)
```

The payload includes `node_id` and `position` (with `x` and `y` screen
coordinates).

---

## Tips

- Return `None` from `context_menu()` to disable the menu for specific nodes
(this is the default behavior).
- Only `Node` subclass instances support context menus. Dict-based nodes do
not trigger a context menu on right-click.
- The menu overlay uses the `.rf-context-menu` CSS class for styling. Override
it in a custom stylesheet to change appearance.
1 change: 1 addition & 0 deletions docs/how-to/react-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the `ReactFlow` instance as a second argument. You can also listen for
| `node_deleted` | A node is removed. | `node_id` |
| `node_moved` | A node is dragged to a new position. | `node_id`, `position` |
| `node_clicked` | A node is clicked (single click). | `node_id` |
| `node_context_menu` | A node is right-clicked. | `node_id`, `position` |
| `node_data_changed` | Node data is patched (via API, editor patch, or parameter-driven sync). | `node_id`, `patch` |
| `edge_added` | An edge is created (UI connect or API). | `edge` |
| `edge_deleted` | An edge is removed. | `edge_id` |
Expand Down
82 changes: 82 additions & 0 deletions examples/context_menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Context menu example using Node subclasses.

Demonstrates:
- Per-node context menus via ``Node.context_menu()``
- Dynamic menu content based on node state
- Closing the menu on action (via ``flow.patch_node_data``)
"""

import panel as pn
import panel_material_ui as pmui
import param

from panel_reactflow import Node, ReactFlow

pn.extension()


class TaskNode(Node):
status = param.Selector(
default="idle", objects=["idle", "running", "done", "failed"], precedence=0
)

def __init__(self, **params):
params.setdefault("type", "panel")
super().__init__(**params)

def __panel__(self):
return pn.pane.Markdown(
f"**{self.label}**\n\nStatus: `{self.status}`",
sizing_mode="stretch_width",
)

def context_menu(self):
def set_status(status):
self.flow.patch_node_data(self.id, {"status": status})
self.flow._handle_msg({"type": "close_context_menu"})

run_btn = pmui.Button(
name="Run", variant="text", size="small",
on_click=lambda e: set_status("running"),
)
done_btn = pmui.Button(
name="Mark Done", variant="text", size="small",
on_click=lambda e: set_status("done"),
)
reset_btn = pmui.Button(
name="Reset", variant="text", size="small",
on_click=lambda e: set_status("idle"),
)
delete_btn = pmui.Button(
name="Delete", variant="text", color="error", size="small",
on_click=lambda e: self.flow.remove_node(self.id),
)

return pn.Column(
pn.pane.Markdown(f"**{self.label}**", margin=(4, 8)),
run_btn, done_btn, reset_btn, delete_btn,
sizing_mode="stretch_width",
margin=0,
)


nodes = [
TaskNode(id="extract", label="Extract", position={"x": 0, "y": 0}),
TaskNode(id="transform", label="Transform", position={"x": 300, "y": 80}),
TaskNode(id="load", label="Load", position={"x": 600, "y": 0}, status="done"),
]

flow = ReactFlow(
nodes=nodes,
edges=[
{"id": "e1", "source": "extract", "target": "transform"},
{"id": "e2", "source": "transform", "target": "load"},
],
sizing_mode="stretch_both",
)

pn.Column(
pn.pane.Markdown("## Context Menu Demo\nRight-click any node to see its context menu."),
flow,
sizing_mode="stretch_both",
).servable()
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
name = "panel-reactflow"
channels = ["conda-forge", "pyviz/label/dev"]
channels = ["pyviz/label/dev", "conda-forge"]
platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"]

[tasks]
Expand Down
95 changes: 76 additions & 19 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from bokeh.embed.bundle import extension_dirs
from bokeh.plotting import figure
from panel.config import config
from panel.custom import Children, ReactComponent
from panel.custom import Child, Children, ReactComponent
from panel.io.resources import EXTENSION_CDN
from panel.io.state import state
from panel.util import base_version, classproperty
Expand Down Expand Up @@ -684,6 +684,14 @@ def editor(self, data, schema, *, id, type, on_patch):
"""
return None

def context_menu(self) -> Any | None:
"""Return a Panel component to render as a context menu on right-click.

Override this method to provide a custom context menu for this node.
Return ``None`` to disable the context menu for this node.
"""
return None

def on_event(self, payload: dict[str, Any], flow: "ReactFlow") -> None:
"""Wildcard event hook for node-related events."""

Expand Down Expand Up @@ -1457,6 +1465,9 @@ class ReactFlow(ReactComponent):
_node_editor_views = Children(default=[], doc="Node editor views (one per node, same order).")
_edge_editors = param.Dict(default={}, doc="Per-edge editors.", precedence=-1)
_edge_editor_views = Children(default=[], doc="Edge editor views (one per edge, same order).")
_selected_editor = Child(doc="Active editor for the selected node/edge in side mode.")
_context_menu = Child(doc="Context menu component rendered on node right-click.")
_context_menu_position = param.Dict(default=None, allow_None=True, doc="Screen position for the context menu overlay.")
_views = Children(default=[], doc="Panel viewables rendered inside nodes via view_idx.")
_node_update_count = param.Integer(default=0, doc="Monotonic counter for normalized node updates.")

Expand Down Expand Up @@ -1502,16 +1513,21 @@ def __init__(self, **params: Any):
self.param.watch(self._normalize_specs, ["node_types", "edge_types"])
self.param.watch(
self._update_node_editors,
["nodes", "editor_mode", "selection", "node_editors", "default_node_editor"],
["nodes", "editor_mode", "node_editors", "default_node_editor"],
)
self.param.watch(
self._update_edge_editors,
["edges", "selection", "edge_editors", "default_edge_editor"],
["edges", "edge_editors", "default_edge_editor"],
)
self.param.watch(
self._update_selected_editor,
["selection", "editor_mode", "nodes", "edges"],
)
self.param.watch(self._update_views, ["nodes"])
self._sync_instance_flow_refs()
self._update_node_editors()
self._update_edge_editors()
self._update_selected_editor()
self._update_instance_data_param_watchers()

@classmethod
Expand Down Expand Up @@ -1978,6 +1994,20 @@ def _update_edge_editors(self, *events: tuple[param.parameterized.Event]) -> Non
self._edge_editors = editors
self.param.trigger("_edge_editor_views")

def _update_selected_editor(self, *events: tuple[param.parameterized.Event]) -> None:
selected_nodes = self.selection.get("nodes", [])
selected_edges = self.selection.get("edges", [])
editor = None
if selected_nodes and self.editor_mode == "side":
editor = self._node_editors.get(selected_nodes[0])
elif selected_edges and not selected_nodes:
editor = self._edge_editors.get(selected_edges[0])
if editor is not None:
view = self._resolve_editor_view(editor)
else:
view = None
self._selected_editor = view

@staticmethod
def _resolve_editor_view(editor: Any) -> Any:
"""Return a Panel viewable from an editor (class or plain object)."""
Expand All @@ -1989,34 +2019,43 @@ def _resolve_editor_view(editor: Any) -> Any:

def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str, list[UIElement] | UIElement | None], list[UIElement]]:
views = []
node_editors = []
for node in self.nodes:
view = self._node_view(node)
if view is not None:
views.append(self._resolve_editor_view(view))
node_editors.append(self._resolve_editor_view(self._node_editors.get(self._node_id(node))))
edge_editors = [self._resolve_editor_view(self._edge_editors.get(self._edge_id(edge))) for edge in self.edges]

children: dict[str, list[UIElement] | UIElement | None] = {}
old_models: list[UIElement] = []
views, view_models = self._get_child_model(views, doc, root, parent, comm)
self._patch_views(view_models)
children["_views"] = views
old_models += view_models
editor_models, editor_old = self._get_child_model(node_editors, doc, root, parent, comm)
children["_node_editor_views"] = editor_models
old_models += editor_old
edge_models, edge_old = self._get_child_model(edge_editors, doc, root, parent, comm)
children["_edge_editor_views"] = edge_models
old_models += edge_old
for name in ("top_panel", "bottom_panel", "left_panel", "right_panel"):
panels = list(getattr(self, name, []) or [])
if panels:
panel_models, panel_old = self._get_child_model(panels, doc, root, parent, comm)
children[name] = panel_models
old_models += panel_old

if self.editor_mode == "side":
children["_node_editor_views"] = []
children["_edge_editor_views"] = []
else:
node_editors = [self._resolve_editor_view(self._node_editors.get(self._node_id(node))) for node in self.nodes]
editor_models, editor_old = self._get_child_model(node_editors, doc, root, parent, comm)
children["_node_editor_views"] = editor_models
old_models += editor_old
children["_edge_editor_views"] = []

for name in ("top_panel", "bottom_panel", "left_panel", "right_panel", "_context_menu", "_selected_editor"):
panels = getattr(self, name, None)
if panels is None:
children[name] = None
elif isinstance(panels, list):
if panels:
panel_models, panel_old = self._get_child_model(panels, doc, root, parent, comm)
children[name] = panel_models
old_models += panel_old
else:
children[name] = []
else:
children[name] = []
panel_models, panel_old = self._get_child_model([panels], doc, root, parent, comm)
children[name] = panel_models[0] if panel_models else None
old_models += panel_old
return children, old_models

def _patch_views(self, view_models: list[UIElement]) -> None:
Expand Down Expand Up @@ -2239,6 +2278,7 @@ def _handle_msg(self, msg: dict[str, Any]) -> None:
for edge in self.edges:
self._edge_set_selected(edge, self._edge_id(edge) in edge_ids)
self.selection = {"nodes": list(node_ids), "edges": list(edge_ids)}
self._update_selected_editor()
self._emit("selection_changed", msg)
case "edge_added":
edge = msg.get("edge")
Expand All @@ -2262,6 +2302,23 @@ def _handle_msg(self, msg: dict[str, Any]) -> None:
if node_id is None:
return
self._emit("node_clicked", msg)
case "node_context_menu":
node_id = msg.get("node_id")
position = msg.get("position")
if node_id is None:
return
node = next((n for n in self.nodes if self._node_id(n) == node_id), None)
if node is None or not isinstance(node, Node):
return
menu = node.context_menu()
if menu is None:
return
self._context_menu = menu
self._context_menu_position = position
self._emit("node_context_menu", msg)
case "close_context_menu":
self._context_menu = None
self._context_menu_position = None
case _:
return

Expand Down
9 changes: 9 additions & 0 deletions src/panel_reactflow/dist/css/reactflow.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,12 @@
.rf-node-toolbar-icon--closed {
transform: none;
}

.rf-context-menu {
background: var(--xy-node-background-color, var(--panel-background-color));
border: 1px solid var(--panel-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px;
min-width: 120px;
}
Loading
Loading