From c5af26eeb2da6fcb135e24aba425406d7036df4d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Aug 2026 19:20:17 +0200 Subject: [PATCH 1/8] Update release notes --- docs/releases.md | 54 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index dc61fb9..fbad892 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,15 +1,40 @@ # Release Notes -## Unreleased +## Version 0.5.0 -### Bug fixes +This release focuses on driving the graph from Python: the +presentational properties of `Node` and `Edge` now sync to the browser +in place, batches of changes render as a single update, and a new error +boundary keeps a malformed graph from blanking the canvas. -- **Progressive re-render when deleting multiple elements** — deleting a - multi-node selection removed the nodes one at a time, syncing an - intermediate graph to the browser per node so the nodes visibly - disappeared one by one. Updates triggered by a frontend message are now - held and combined into a single patch, and node/edge deletion assigns - `nodes` and `edges` once. +### Highlights + +- **Error recovery** — the canvas is now wrapped in an error boundary, + controlled by the new `error_recovery` parameter (`"auto"` by default, + or `"manual"` / `"off"`). In `"auto"` mode a React render error + remounts the canvas, then remounts again in a view-only safe mode that + repairs or hides elements it cannot render (invalid positions, unknown + node/edge types, dangling edges, duplicate or missing ids) without + mutating the server-side graph. If retries are exhausted a recovery + panel offers *Try again*, *Reload page* and *Copy details*. Every + error is logged to the `panel.reactflow` logger and emitted as a + `client_error` event, so browser-side failures are no longer invisible + to the server + ([#70](https://github.com/panel-extensions/panel-reactflow/pull/70)). + +- **Base property sync** — the top-level React Flow fields on `Node` and + `Edge` (`label`, `type`, `style`, `className`, `draggable`, + `connectable`, `deletable`, ...) are now synced to the frontend, so + assigning `node.label = "Start (running)"` patches the browser in place + instead of requiring `flow.nodes` to be replaced. Parameters declared + on a subclass continue to sync into `data`. New `patch_node_props()` + and `patch_edge_props()` methods do the same for dict-based nodes and + edges, where passing `None` clears a field back to the CSS/theme + default, and new `on_props_change` hooks fire on `Node`/`Edge` + subclasses when the frontend changes a property. `position` and + `selected` remain browser-owned during drag and selection and are only + pushed via `patch_node_props()` + ([#71](https://github.com/panel-extensions/panel-reactflow/pull/71)). ### Enhancements @@ -18,7 +43,18 @@ (`flow.remove_node("n1", "n2")`) or as a sequence (`flow.remove_node(["n1", "n2"])`), and remove them in a single update. For any other batch of changes made from Python, wrap them in - `pn.io.hold()` to render them at once. + `pn.io.hold()` to render them at once + ([#72](https://github.com/panel-extensions/panel-reactflow/pull/72)). + +### Bug fixes + +- **Progressive re-render when deleting multiple elements** — deleting a + multi-node selection removed the nodes one at a time, syncing an + intermediate graph to the browser per node so the nodes visibly + disappeared one by one. Updates triggered by a frontend message are now + held and combined into a single patch, and node/edge deletion assigns + `nodes` and `edges` once + ([#72](https://github.com/panel-extensions/panel-reactflow/pull/72)). ## Version 0.4.1 From b2b60c457cde209692bbaf605ae063b748398b97 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 22 Sep 2026 20:51:38 +0200 Subject: [PATCH 2/8] feat: inspect port and edge values --- docs/how-to/declare-types.md | 45 ++++++- src/panel_reactflow/base.py | 93 +++++++++++++- src/panel_reactflow/dist/css/reactflow.css | 11 ++ src/panel_reactflow/models/reactflow.jsx | 139 ++++++++++++++++----- tests/test_api.py | 68 ++++++++++ tests/test_core.py | 2 + tests/ui/test_value_popup.py | 117 +++++++++++++++++ 7 files changed, 433 insertions(+), 42 deletions(-) create mode 100644 tests/ui/test_value_popup.py diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index ac4a45e..a3b821b 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -244,7 +244,8 @@ validation or auto-generated forms. ## Handle tooltips By default, handles are plain connection points. You can add a tooltip (shown -on hover) by passing a dict with `"id"` and `"label"` instead of a plain string: +on hover) by passing a dict with `"id"` and `"label"` and/or `"type"` instead +of a plain string: ```python from panel_reactflow import NodeType @@ -253,23 +254,55 @@ node_types = { "transform": NodeType( type="transform", label="Transform", - inputs=[{"id": "in", "label": "Data Input"}], + inputs=[{"id": "in", "label": "Data Input", "type": "DataFrame"}], outputs=[ - {"id": "success", "label": "Successful results"}, - {"id": "error", "label": "Failed records"}, + {"id": "success", "label": "Successful results", "type": "DataFrame"}, + {"id": "error", "label": "Failed records", "type": "list"}, ], ), } ``` -Plain strings and dicts can be mixed freely in the same list: +The tooltip combines both when present (`"Data Input (DataFrame)"`), or falls +back to whichever one is given. Plain strings and dicts can be mixed freely in +the same list: ```python -inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this"}] +inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this", "type": "int"}] ``` --- +## Show a port's current value on click + +Clicking a handle emits a `"handle_clicked"` event with the node id, handle +id, direction (`"input"`/`"output"`) and a screen position. Clicking an edge +emits `"edge_clicked"` with the edge id and position. Use +`ReactFlow.show_popup(content, position)` in the handler to display whatever +you consider the "current value" for that port or connection: + +```python +def on_handle_clicked(payload, flow): + node_id, handle_id = payload["node_id"], payload["handle_id"] + value = live_values.get(node_id, {}).get(handle_id) + flow.show_popup(pn.pane.Markdown(f"**{handle_id}**: {value!r}"), payload["position"]) + +def on_edge_clicked(payload, flow): + source_id, source_port = edge_sources[payload["edge_id"]] + value = live_values.get(source_id, {}).get(source_port) + flow.show_popup(pn.pane.Markdown(f"**Value:** {value!r}"), payload["position"]) + +flow.on("handle_clicked", on_handle_clicked) +flow.on("edge_clicked", on_edge_clicked) +``` + +The popup closes itself when the user clicks elsewhere, or programmatically +via `flow.close_popup()`. `panel-reactflow` only provides the click events and +overlay; looking up "the current value" for a port is application-specific, +since it depends on how you execute or simulate the graph. + +--- + ## Control handle connectivity By default, all handles (inputs and outputs) are fully connectable — users can diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 163ba33..8e98989 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -269,11 +269,13 @@ class NodeType: inputs : list of str or dict, optional List of input port definitions. Each entry can be a plain string (the handle ID) or a dict with ``"id"`` and optional ``"label"`` - keys. When a label is provided it renders as a tooltip on hover. + and ``"type"`` keys. When a label and/or type is provided it renders + as a tooltip on hover, e.g. ``"Raw data input (DataFrame)"``. outputs : list of str or dict, optional List of output port definitions. Each entry can be a plain string (the handle ID) or a dict with ``"id"`` and optional ``"label"`` - keys. When a label is provided it renders as a tooltip on hover. + and ``"type"`` keys. When a label and/or type is provided it renders + as a tooltip on hover, e.g. ``"Raw data input (DataFrame)"``. input_connectable : bool, default True Whether input handles are connectable. When False, users cannot create connections to or from input handles. @@ -1591,6 +1593,8 @@ class ReactFlow(ReactComponent): _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.") + _value_popup = Child(doc="Popup component rendered on handle/edge click, e.g. via show_popup().") + _value_popup_position = param.Dict(default=None, allow_None=True, doc="Screen position for the value popup 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.") @@ -1999,7 +2003,14 @@ def _invoke_node_callback(callback: Callable, payload: dict[str, Any], flow: "Re cb = partial(callback, payload, flow) else: cb = partial(callback, payload) - pn.state.execute(cb) + # schedule=False: the outer _process_event already forced this whole + # message-handling pass onto the doc's own callback (schedule=False), + # so 'auto' scheduling here would see the doc as still locked and + # defer to the next tick. That breaks Child-param updates (e.g. via + # show_popup) made from inside a hook, since the model reconciliation + # that turns a Viewable into its Bokeh model expects to run in the + # same pass as the triggering event. + pn.state.execute(cb, schedule=False) def _invoke_node_hook(self, node: Node, hook_name: str, payload: dict[str, Any]) -> None: hook = getattr(node, hook_name, None) @@ -2198,7 +2209,7 @@ def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str, children["_node_editor_views"] = editor_models old_models += editor_old - for name in ("top_panel", "bottom_panel", "left_panel", "right_panel", "_context_menu", "_selected_editor"): + for name in ("top_panel", "bottom_panel", "left_panel", "right_panel", "_context_menu", "_value_popup", "_selected_editor"): panels = getattr(self, name, None) if panels is None: children[name] = None @@ -2470,6 +2481,16 @@ def _process_msg(self, msg: dict[str, Any]) -> None: if node_id is None: return self._emit("node_clicked", msg) + case "handle_clicked": + node_id = msg.get("node_id") + if node_id is None: + return + self._emit("handle_clicked", msg) + case "edge_clicked": + edge_id = msg.get("edge_id") + if edge_id is None: + return + self._emit("edge_clicked", msg) case "node_context_menu": node_id = msg.get("node_id") position = msg.get("position") @@ -2487,6 +2508,9 @@ def _process_msg(self, msg: dict[str, Any]) -> None: case "close_context_menu": self._context_menu = None self._context_menu_position = None + case "close_value_popup": + self._value_popup = None + self._value_popup_position = None case "client_error": self._handle_client_error(msg) case _: @@ -3313,6 +3337,42 @@ def from_networkx( edges.append(edge) return cls(nodes=nodes, edges=edges) + def show_popup(self, content: Any, position: dict[str, float]) -> None: + """Show a Panel viewable in a floating overlay at a screen position. + + Intended for use from ``"handle_clicked"`` and ``"edge_clicked"`` + callbacks to display, e.g., the current value flowing through a port + or connection, but can be called for any reason. + + Parameters + ---------- + content + A Panel viewable (or anything ``pn.panel()`` accepts) to render + inside the popup. + position + Screen position with ``x``/``y`` keys, typically taken straight + from the ``"position"`` field of the triggering event payload. + + Examples + -------- + >>> def on_handle_clicked(payload, flow): + ... value = compute_value(payload["node_id"], payload["handle_id"]) + ... flow.show_popup(pn.pane.Markdown(f"Value: {value}"), payload["position"]) + >>> + >>> flow.on("handle_clicked", on_handle_clicked) + + See Also + -------- + close_popup : Dismiss the popup programmatically. + """ + self._value_popup = content + self._value_popup_position = position + + def close_popup(self) -> None: + """Dismiss the popup shown by :meth:`show_popup`, if any.""" + self._value_popup = None + self._value_popup_position = None + def on(self, event_type: str, callback) -> None: """Register a callback for graph events. @@ -3330,6 +3390,14 @@ def on(self, event_type: str, callback) -> None: - ``"node_deleted"``: Node was removed from the graph - ``"node_moved"``: Node was dragged to a new position - ``"node_clicked"``: Node was clicked + - ``"handle_clicked"``: A node's input/output handle was clicked. + Payload has ``node_id``, ``handle_id`` (``None`` for a node's + unnamed default handle), ``direction`` (``"input"`` or + ``"output"``) and a screen ``position`` suitable for + :meth:`show_popup`. + - ``"edge_clicked"``: An edge was clicked. Payload has + ``edge_id`` and a screen ``position`` suitable for + :meth:`show_popup`. - ``"node_data_changed"``: Node data was modified - ``"node_props_changed"``: Top-level node properties (``label``, ``style``, ``type``, ...) were modified @@ -3408,6 +3476,15 @@ def on(self, event_type: str, callback) -> None: >>> >>> flow.on("node_data_changed", on_data_change) + Show the current value of a port when its handle is clicked: + + >>> import panel as pn + >>> def on_handle_clicked(payload, flow): + ... value = my_values[payload["node_id"]][payload["handle_id"]] + ... flow.show_popup(pn.pane.Markdown(f"**Value:** {value}"), payload["position"]) + >>> + >>> flow.on("handle_clicked", on_handle_clicked) + Notes ----- Multiple callbacks can be registered for the same event type. @@ -3422,18 +3499,22 @@ def on(self, event_type: str, callback) -> None: self._event_handlers.setdefault(event_type, []).append(callback) def _emit(self, event_type: str, payload: dict[str, Any]) -> None: + # schedule=False: see the matching comment on _invoke_node_callback. + # This runs the message handling that triggered the event, so + # deferring to the next tick would break Child-param updates (e.g. + # via show_popup) made from inside a registered callback. for callback in self._event_handlers.get(event_type, []): if len(inspect.signature(callback).parameters) == 2: cb = partial(callback, payload, self) else: cb = partial(callback, payload) - pn.state.execute(cb) + pn.state.execute(cb, schedule=False) for callback in self._event_handlers.get("*", []): if len(inspect.signature(callback).parameters) == 2: cb = partial(callback, payload, self) else: cb = partial(callback, payload) - pn.state.execute(cb) + pn.state.execute(cb, schedule=False) self._dispatch_node_hooks(event_type, payload) self._dispatch_edge_hooks(event_type, payload) diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css index 9073b1c..704730b 100644 --- a/src/panel_reactflow/dist/css/reactflow.css +++ b/src/panel_reactflow/dist/css/reactflow.css @@ -116,6 +116,17 @@ min-width: 120px; } +.rf-value-popup { + 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: 8px; + max-width: 360px; + max-height: 320px; + overflow: auto; +} + /* Error recovery overlay and safe mode banner */ .rf-recovery { position: absolute; diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 0a2d9ba..76f9fd4 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -33,6 +33,18 @@ const figureStylesheet = ` function renderHandles(direction, handles, opts = {}) { const handleType = direction === "input" ? "target" : "source"; const position = direction === "input" ? Position.Left : Position.Right; + const tooltipPos = direction === "input" ? "left" : "right"; + const onHandleClick = opts.onHandleClick; + + const makeClickHandler = (id) => + onHandleClick + ? (event) => { + // A handle click also bubbles to the node underneath, which would + // otherwise select/drag it instead of opening the value popup. + event.stopPropagation(); + onHandleClick(id, direction, event); + } + : undefined; // Build handle props from opts, only including defined values const handleProps = {}; @@ -52,12 +64,21 @@ function renderHandles(direction, handles, opts = {}) { } // null/undefined → default handle if (!handles?.length) { - return ; + return ( + + ); } const spacing = 100 / (handles.length + 1); return handles.map((handle, index) => { const id = typeof handle === "string" ? handle : handle.id; const label = typeof handle === "object" ? handle.label : undefined; + const dtype = typeof handle === "object" ? handle.type : undefined; + const tooltip = label && dtype ? `${label} (${dtype})` : label || dtype; return ( ); }); } -function makeNodeComponent(typeName, typeSpec, editorMode) { +function makeNodeComponent(typeName, typeSpec, editorMode, model) { return function NodeComponent({ id, data }) { const [toolbarOpen, toggleToolbar] = React.useState(false); + const onHandleClick = useCallback( + (handleId, direction, event) => { + model.send_msg({ + type: "handle_clicked", + node_id: id, + handle_id: handleId, + direction, + position: { x: event.clientX, y: event.clientY }, + }); + }, + [id], + ); const zoom = useStore((s) => s.transform?.[2] ?? 1); const spec = typeSpec || {}; const hasEditor = data?._hasEditor; @@ -198,6 +232,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { connectable: spec.inputConnectable, connectableStart: spec.inputConnectableStart, connectableEnd: spec.inputConnectableEnd, + onHandleClick, })}
{displayLabel} @@ -212,6 +247,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { connectable: spec.outputConnectable, connectableStart: spec.outputConnectableStart, connectableEnd: spec.outputConnectableEnd, + onHandleClick, })}
); @@ -254,6 +290,38 @@ function signature(value) { } } +/** + * Close a positioned overlay (context menu, value popup) when the user + * clicks outside of it, by sending `closeMsgType` back to Python. + */ +function useCloseOnOutsideClick(position, ref, closeMsgType, model) { + useEffect(() => { + if (!position) return undefined; + const handleClick = (event) => { + const el = ref.current; + if (el) { + const rect = el.getBoundingClientRect(); + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + return; + } + } + model.send_msg({ type: closeMsgType }); + }; + const id = requestAnimationFrame(() => { + document.addEventListener("mousedown", handleClick, true); + }); + return () => { + cancelAnimationFrame(id); + document.removeEventListener("mousedown", handleClick, true); + }; + }, [position, ref, closeMsgType, model]); +} + /** * Combine the class React Flow needs for its own node chrome with the class * declared on the Python side. @@ -758,6 +826,17 @@ function FlowInner({ [sendPatch], ); + const onEdgeClick = useCallback( + (event, edge) => { + sendPatch({ + type: "edge_clicked", + edge_id: edge.id, + position: { x: event.clientX, y: event.clientY }, + }); + }, + [sendPatch], + ); + const onMoveEnd = useCallback( (_event, nextViewport) => { if (!areEqual(nextViewport, viewport)) { @@ -792,11 +871,13 @@ function FlowInner({ onMoveEnd: wrap("onMoveEnd", onMoveEnd), onNodeDoubleClick: wrap("onNodeDoubleClick", onNodeDoubleClick), onNodeContextMenu: wrap("onNodeContextMenu", onNodeContextMenu), + onEdgeClick: wrap("onEdgeClick", onEdgeClick), onPaneClick: wrap("onPaneClick", onPaneClick), }; }, [ handleNodesChange, onConnect, + onEdgeClick, onEdgesChange, onEdgesDelete, onMoveEnd, @@ -857,6 +938,8 @@ export function render({ model, view }) { const [viewport, setViewport] = model.useState("viewport"); const [contextMenuPosition] = model.useState("_context_menu_position"); const contextMenu = model.get_child("_context_menu"); + const [valuePopupPosition] = model.useState("_value_popup_position"); + const valuePopup = model.get_child("_value_popup"); const selectedEditor = model.get_child("_selected_editor"); const views = model.get_child("_views"); const nodeEditors = model.get_child("_node_editor_views"); @@ -1064,39 +1147,21 @@ export function render({ model, view }) { const hydratedNodeTypes = useMemo(() => { const mapping = {}; Object.entries({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }).forEach(([typeName, spec]) => { - mapping[typeName] = makeNodeComponent(typeName, spec, editorMode); + mapping[typeName] = makeNodeComponent(typeName, spec, editorMode, model); }); return mapping; - }, [editorMode, pyNodeTypes]); + }, [editorMode, pyNodeTypes, model]); const contextMenuRef = useRef(null); + const valuePopupRef = useRef(null); const containerRef = useRef(null); - useEffect(() => { - if (!contextMenuPosition) return; - const handleClick = (event) => { - const el = contextMenuRef.current; - if (el) { - const rect = el.getBoundingClientRect(); - if ( - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom - ) { - return; - } - } - model.send_msg({ type: "close_context_menu" }); - }; - const id = requestAnimationFrame(() => { - document.addEventListener("mousedown", handleClick, true); - }); - return () => { - cancelAnimationFrame(id); - document.removeEventListener("mousedown", handleClick, true); - }; - }, [contextMenuPosition, model]); + // Shared outside-click dismissal for the context menu and value popup + // overlays: both are absolutely-positioned children the frontend renders + // on demand, so Python has no click event to react to when the user just + // wants to dismiss one without interacting with the canvas underneath. + useCloseOnOutsideClick(contextMenuPosition, contextMenuRef, "close_context_menu", model); + useCloseOnOutsideClick(valuePopupPosition, valuePopupRef, "close_value_popup", model); const hydratedEdgeTypes = useMemo(() => ({ bezier: BezierEdge, @@ -1221,6 +1286,20 @@ export function render({ model, view }) { {contextMenu} ) : null} + {valuePopup && valuePopupPosition ? ( +
+ {valuePopup} +
+ ) : null} ); } diff --git a/tests/test_api.py b/tests/test_api.py index 452be73..9a1b08f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -840,6 +840,74 @@ def test_handle_msg_node_clicked_emits_once() -> None: assert events[0]["node_id"] == "n1" +def test_handle_msg_handle_clicked_emits_once() -> None: + flow = ReactFlow(nodes=[{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}]) + events: list[dict] = [] + flow.on("handle_clicked", events.append) + flow._handle_msg( + { + "type": "handle_clicked", + "node_id": "n1", + "handle_id": "out", + "direction": "output", + "position": {"x": 12, "y": 34}, + }, + ) + assert len(events) == 1 + assert events[0]["node_id"] == "n1" + assert events[0]["handle_id"] == "out" + assert events[0]["direction"] == "output" + assert events[0]["position"] == {"x": 12, "y": 34} + + +def test_handle_msg_handle_clicked_requires_node_id() -> None: + flow = ReactFlow() + events: list[dict] = [] + flow.on("handle_clicked", events.append) + flow._handle_msg({"type": "handle_clicked", "handle_id": "out"}) + assert events == [] + + +def test_handle_msg_edge_clicked_emits_once() -> None: + flow = ReactFlow( + nodes=[ + {"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}, + {"id": "n2", "position": {"x": 1, "y": 1}, "data": {}}, + ], + edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}], + ) + events: list[dict] = [] + flow.on("edge_clicked", events.append) + flow._handle_msg({"type": "edge_clicked", "edge_id": "e1", "position": {"x": 5, "y": 6}}) + assert len(events) == 1 + assert events[0]["edge_id"] == "e1" + assert events[0]["position"] == {"x": 5, "y": 6} + + +def test_show_popup_sets_content_and_position() -> None: + flow = ReactFlow() + content = pn.pane.Markdown("Value: 42") + flow.show_popup(content, {"x": 1, "y": 2}) + assert flow._value_popup is content + assert flow._value_popup_position == {"x": 1, "y": 2} + + +def test_close_popup_clears_content_and_position() -> None: + flow = ReactFlow() + flow.show_popup(pn.pane.Markdown("Value"), {"x": 1, "y": 2}) + flow.close_popup() + assert flow._value_popup is None + assert flow._value_popup_position is None + + +def test_handle_msg_close_value_popup_clears_popup() -> None: + flow = ReactFlow() + flow.show_popup(pn.pane.Markdown("Value"), {"x": 1, "y": 2}) + flow._handle_msg({"type": "close_value_popup"}) + assert flow._value_popup is None + assert flow._value_popup_position is None + + def test_handle_msg_sync_emits_once() -> None: flow = ReactFlow() events: list[dict] = [] diff --git a/tests/test_core.py b/tests/test_core.py index 7c53c67..6ac9d37 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -73,6 +73,7 @@ def test_reactflow_add_node_dynamically_creates_views(document, comm): "left_panel", "right_panel", "_context_menu", + "_value_popup", "_selected_editor", ] @@ -111,6 +112,7 @@ def editor(self, data, schema, *, id, type, on_patch): "left_panel", "right_panel", "_context_menu", + "_value_popup", "_selected_editor", ] assert len(model.data._views) == 1 diff --git a/tests/ui/test_value_popup.py b/tests/ui/test_value_popup.py new file mode 100644 index 0000000..d1cdf81 --- /dev/null +++ b/tests/ui/test_value_popup.py @@ -0,0 +1,117 @@ +"""UI tests for handle/edge click-to-inspect (value popup) feature.""" + +import panel as pn +import pytest +from panel.tests.util import serve_component, wait_until + +from panel_reactflow import EdgeSpec, NodeSpec, NodeType, ReactFlow + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + +NODE_TYPES = { + "source": NodeType( + type="source", + label="Source", + outputs=[{"id": "out", "label": "Output records", "type": "DataFrame"}], + ), + "sink": NodeType( + type="sink", + label="Sink", + inputs=[{"id": "in", "label": "Input records", "type": "DataFrame"}], + ), +} + + +def _flow(): + nodes = [ + NodeSpec(id="src", type="source", position={"x": 0, "y": 100}, data={}).to_dict(), + NodeSpec(id="snk", type="sink", position={"x": 300, "y": 100}, data={}).to_dict(), + ] + edges = [EdgeSpec(id="e1", source="src", target="snk", sourceHandle="out", targetHandle="in").to_dict()] + return ReactFlow( + nodes=nodes, + edges=edges, + node_types=NODE_TYPES, + width=900, + height=600, + ) + + +def test_handle_tooltip_shows_label_and_type(page): + flow = _flow() + serve_component(page, flow) + + # "source"/"sink" node types only declare one side of handles, so each + # node also renders its unlabeled default handle on the other side; + # target the one carrying the tooltip explicitly rather than `.first`. + handle = page.locator(".react-flow__handle[data-tooltip]").first + expect(handle).to_have_attribute("data-tooltip", "Output records (DataFrame)") + + +def test_handle_click_emits_event_with_direction(page): + events = [] + flow = _flow() + flow.on("handle_clicked", lambda payload: events.append(payload)) + serve_component(page, flow) + + output_handle = page.locator(".react-flow__handle-right").first + output_handle.click() + + wait_until(lambda: len(events) == 1, timeout=8000) + assert events[0]["node_id"] == "src" + assert events[0]["handle_id"] == "out" + assert events[0]["direction"] == "output" + assert "position" in events[0] + + +def test_show_popup_renders_content_at_click(page): + flow = _flow() + + def on_handle_clicked(payload, flow): + flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"]) + + flow.on("handle_clicked", on_handle_clicked) + serve_component(page, flow) + + output_handle = page.locator(".react-flow__handle-right").first + output_handle.click() + + popup = page.locator(".rf-value-popup") + expect(popup).to_be_visible() + expect(popup.locator("text=Value: 42")).to_be_visible() + wait_until(lambda: flow._value_popup is not None, timeout=8000) + + +def test_popup_closes_on_pane_click(page): + flow = _flow() + flow.on("handle_clicked", lambda payload, flow: flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"])) + serve_component(page, flow) + + page.locator(".react-flow__handle-right").first.click() + popup = page.locator(".rf-value-popup") + expect(popup).to_be_visible() + + pane = page.locator(".react-flow__pane") + box = pane.bounding_box() + page.mouse.click(box["x"] + box["width"] - 50, box["y"] + box["height"] - 50) + + expect(popup).not_to_be_visible() + wait_until(lambda: flow._value_popup is None, timeout=8000) + + +def test_edge_click_emits_event(page): + events = [] + flow = _flow() + flow.on("edge_clicked", lambda payload: events.append(payload)) + serve_component(page, flow) + + edge = page.locator(".react-flow__edge-path").first + edge.click(force=True) + + wait_until(lambda: len(events) == 1, timeout=8000) + assert events[0]["edge_id"] == "e1" + assert "position" in events[0] From f29f6641155ee0bd86c6fc4bfe2f8f3b59cad582 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 22 Sep 2026 20:59:00 +0200 Subject: [PATCH 3/8] docs: add port value inspection example --- docs/how-to/declare-types.md | 7 +++ examples/port_value_inspection.py | 98 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 examples/port_value_inspection.py diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index a3b821b..6fd0d86 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -301,6 +301,13 @@ via `flow.close_popup()`. `panel-reactflow` only provides the click events and overlay; looking up "the current value" for a port is application-specific, since it depends on how you execute or simulate the graph. +For a complete runnable graph with typed port hover tooltips and popups for +both handles and edges, run: + +```bash +panel serve examples/port_value_inspection.py --show +``` + --- ## Control handle connectivity diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py new file mode 100644 index 0000000..3263990 --- /dev/null +++ b/examples/port_value_inspection.py @@ -0,0 +1,98 @@ +"""Inspect typed port values by clicking handles and edges. + +Run with: + + panel serve examples/port_value_inspection.py --show +""" + +import panel as pn + +from panel_reactflow import EdgeSpec, NodeSpec, NodeType, ReactFlow + +pn.extension("jsoneditor") + +node_types = { + "source": NodeType( + type="source", + label="Data Source", + outputs=[{"id": "records", "label": "Output records", "type": "list[dict]"}], + ), + "transform": NodeType( + type="transform", + label="Transform", + inputs=[{"id": "records", "label": "Input records", "type": "list[dict]"}], + outputs=[{"id": "summary", "label": "Summary", "type": "dict[str, int]"}], + ), + "sink": NodeType( + type="sink", + label="Sink", + inputs=[{"id": "summary", "label": "Input summary", "type": "dict[str, int]"}], + ), +} + +nodes = [ + NodeSpec(id="source", type="source", position={"x": 0, "y": 100}, data={}).to_dict(), + NodeSpec(id="transform", type="transform", position={"x": 300, "y": 100}, data={}).to_dict(), + NodeSpec(id="sink", type="sink", position={"x": 600, "y": 100}, data={}).to_dict(), +] + +edges = [ + EdgeSpec(id="records", source="source", target="transform", sourceHandle="records", targetHandle="records").to_dict(), + EdgeSpec(id="summary", source="transform", target="sink", sourceHandle="summary", targetHandle="summary").to_dict(), +] + +# In an application this mapping would be updated by the code that executes +# the graph. It is intentionally separate from ReactFlow's graph metadata. +live_values = { + "source": {"records": [{"city": "Berlin", "sales": 12}, {"city": "Oslo", "sales": 8}]}, + "transform": {"summary": {"record_count": 2, "total_sales": 20}}, + "sink": {"summary": {"record_count": 2, "total_sales": 20}}, +} + +edge_sources = { + "records": ("source", "records"), + "summary": ("transform", "summary"), +} + +flow = ReactFlow( + nodes=nodes, + edges=edges, + node_types=node_types, + sizing_mode="stretch_both", + min_height=450, +) + + +def show_value(title, value, position): + flow.show_popup( + pn.Column( + pn.pane.Markdown(f"**{title}**", margin=(0, 0, 6, 0)), + pn.pane.JSON(value, depth=3, sizing_mode="stretch_width"), + sizing_mode="stretch_width", + ), + position, + ) + + +def on_handle_clicked(payload, flow): + node_id = payload["node_id"] + handle_id = payload["handle_id"] + value = live_values.get(node_id, {}).get(handle_id) + show_value(f"{node_id}.{handle_id}", value, payload["position"]) + + +def on_edge_clicked(payload, flow): + node_id, handle_id = edge_sources[payload["edge_id"]] + value = live_values[node_id][handle_id] + show_value(f"{node_id}.{handle_id}", value, payload["position"]) + + +flow.on("handle_clicked", on_handle_clicked) +flow.on("edge_clicked", on_edge_clicked) + +pn.Column( + "# Port value inspection", + "Hover a port to see its type. Click a port or edge to inspect its current value.", + flow, + sizing_mode="stretch_both", +).servable() From b489a04803d5a4bd531b5db9dd668d5bafee8422 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 22 Sep 2026 21:17:18 +0200 Subject: [PATCH 4/8] feat: configure value popup hover behavior --- docs/how-to/declare-types.md | 10 +- examples/port_value_inspection.py | 3 + src/panel_reactflow/base.py | 46 +++++++- src/panel_reactflow/models/reactflow.jsx | 130 ++++++++++++++++++++++- tests/test_api.py | 19 ++++ tests/ui/test_value_popup.py | 27 ++++- 6 files changed, 226 insertions(+), 9 deletions(-) diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index 6fd0d86..31b2b70 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -297,9 +297,13 @@ flow.on("edge_clicked", on_edge_clicked) ``` The popup closes itself when the user clicks elsewhere, or programmatically -via `flow.close_popup()`. `panel-reactflow` only provides the click events and -overlay; looking up "the current value" for a port is application-specific, -since it depends on how you execute or simulate the graph. +via `flow.close_popup()`. Set `value_popup_trigger="hover"` to require the +pointer to dwell over a port or edge before opening the popup. The default +`value_popup_hover_delay` is 500 ms; after opening, the popup remains visible +until the pointer moves `value_popup_hover_distance` pixels from the hover +anchor. Use `value_popup_trigger="none"` to disable the built-in inspection +events. `panel-reactflow` only provides the interaction events and overlay; +looking up "the current value" for a port is application-specific. For a complete runnable graph with typed port hover tooltips and popups for both handles and edges, run: diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py index 3263990..74b8e20 100644 --- a/examples/port_value_inspection.py +++ b/examples/port_value_inspection.py @@ -58,6 +58,9 @@ nodes=nodes, edges=edges, node_types=node_types, + value_popup_trigger="click", # Change to "hover" to inspect after a dwell. + value_popup_hover_delay=500, + value_popup_hover_distance=24, sizing_mode="stretch_both", min_height=450, ) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 8e98989..ac1e88a 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1581,6 +1581,24 @@ class ReactFlow(ReactComponent): viewport = param.Dict(default=None, allow_None=True, doc="Optional persisted viewport state.") + value_popup_trigger = param.ObjectSelector( + default="click", + objects=["click", "hover", "none"], + doc="Whether port and edge inspection events are triggered on click, hover, or not automatically.", + ) + + value_popup_hover_delay = param.Integer( + default=500, + bounds=(0, None), + doc="Delay in milliseconds before a hover inspection event is emitted.", + ) + + value_popup_hover_distance = param.Number( + default=24, + bounds=(0, None), + doc="Pointer distance in pixels from the hover anchor before closing the popup.", + ) + top_panel = Children(default=[], doc="Children rendered in a top-center panel.") bottom_panel = Children(default=[], doc="Children rendered in a bottom-center panel.") left_panel = Children(default=[], doc="Children rendered in a center-left panel.") @@ -2485,12 +2503,26 @@ def _process_msg(self, msg: dict[str, Any]) -> None: node_id = msg.get("node_id") if node_id is None: return - self._emit("handle_clicked", msg) + if self.value_popup_trigger == "click": + self._emit("handle_clicked", msg) case "edge_clicked": edge_id = msg.get("edge_id") if edge_id is None: return - self._emit("edge_clicked", msg) + if self.value_popup_trigger == "click": + self._emit("edge_clicked", msg) + case "handle_hovered": + if self.value_popup_trigger == "hover" and msg.get("node_id") is not None: + self._emit("handle_hovered", msg) + case "handle_unhovered": + if self.value_popup_trigger == "hover" and msg.get("node_id") is not None: + self._emit("handle_unhovered", msg) + case "edge_hovered": + if self.value_popup_trigger == "hover" and msg.get("edge_id") is not None: + self._emit("edge_hovered", msg) + case "edge_unhovered": + if self.value_popup_trigger == "hover" and msg.get("edge_id") is not None: + self._emit("edge_unhovered", msg) case "node_context_menu": node_id = msg.get("node_id") position = msg.get("position") @@ -3364,6 +3396,12 @@ def show_popup(self, content: Any, position: dict[str, float]) -> None: See Also -------- close_popup : Dismiss the popup programmatically. + + Notes + ----- + Set ``value_popup_trigger="hover"`` to use the configured dwell delay + and movement distance before and after opening a value popup. Set it to + ``"none"`` to disable automatic inspection events. """ self._value_popup = content self._value_popup_position = position @@ -3398,6 +3436,10 @@ def on(self, event_type: str, callback) -> None: - ``"edge_clicked"``: An edge was clicked. Payload has ``edge_id`` and a screen ``position`` suitable for :meth:`show_popup`. + - ``"handle_hovered"`` / ``"edge_hovered"``: A port or edge was + entered while ``value_popup_trigger="hover"``. + - ``"handle_unhovered"`` / ``"edge_unhovered"``: The pointer left + the inspected port or edge. - ``"node_data_changed"``: Node data was modified - ``"node_props_changed"``: Top-level node properties (``label``, ``style``, ``type``, ...) were modified diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 76f9fd4..5842f3d 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -35,6 +35,12 @@ function renderHandles(direction, handles, opts = {}) { const position = direction === "input" ? Position.Left : Position.Right; const tooltipPos = direction === "input" ? "left" : "right"; const onHandleClick = opts.onHandleClick; + const onHandleHover = opts.onHandleHover; + + const makeHoverHandler = (id, eventType) => + onHandleHover + ? (event) => onHandleHover(id, direction, eventType, event) + : undefined; const makeClickHandler = (id) => onHandleClick @@ -69,6 +75,8 @@ function renderHandles(direction, handles, opts = {}) { type={handleType} position={position} onClick={makeClickHandler(null)} + onMouseEnter={makeHoverHandler(null, "enter")} + onMouseLeave={makeHoverHandler(null, "leave")} {...handleProps} /> ); @@ -88,15 +96,66 @@ function renderHandles(direction, handles, opts = {}) { style={{ top: `${(index + 1) * spacing}%` }} {...(tooltip ? {"data-tooltip": tooltip, "data-tooltip-pos": tooltipPos} : {})} onClick={makeClickHandler(id)} + onMouseEnter={makeHoverHandler(id, "enter")} + onMouseLeave={makeHoverHandler(id, "leave")} {...handleProps} /> ); }); } -function makeNodeComponent(typeName, typeSpec, editorMode, model) { +function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrigger, hoverDelay, hoverDistance) { return function NodeComponent({ id, data }) { const [toolbarOpen, toggleToolbar] = React.useState(false); + const hoverTimerRef = useRef(null); + const hoverCleanupRef = useRef(null); + const onHandleHover = useCallback( + (handleId, direction, eventType, event) => { + if (valuePopupTrigger !== "hover") { + return; + } + if (eventType === "leave") { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + return; + } + const position = { x: event.clientX, y: event.clientY }; + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + if (hoverCleanupRef.current) { + hoverCleanupRef.current(); + } + const target = { node_id: id, handle_id: handleId, direction }; + const cleanup = () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + if (hoverCleanupRef.current === cleanup) { + hoverCleanupRef.current = null; + } + document.removeEventListener("pointermove", onPointerMove, true); + }; + const onPointerMove = (moveEvent) => { + const dx = moveEvent.clientX - position.x; + const dy = moveEvent.clientY - position.y; + if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { + model.send_msg({ type: "handle_unhovered", ...target }); + cleanup(); + } + }; + hoverCleanupRef.current = cleanup; + hoverTimerRef.current = setTimeout(() => { + hoverTimerRef.current = null; + model.send_msg({ type: "handle_hovered", ...target, position }); + document.addEventListener("pointermove", onPointerMove, true); + }, hoverDelay); + }, + [id, valuePopupTrigger, hoverDelay, hoverDistance], + ); const onHandleClick = useCallback( (handleId, direction, event) => { model.send_msg({ @@ -233,6 +292,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model) { connectableStart: spec.inputConnectableStart, connectableEnd: spec.inputConnectableEnd, onHandleClick, + onHandleHover, })}
{displayLabel} @@ -248,6 +308,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model) { connectableStart: spec.outputConnectableStart, connectableEnd: spec.outputConnectableEnd, onHandleClick, + onHandleHover, })}
); @@ -570,6 +631,9 @@ function FlowInner({ syncMode, debounceMs, viewport, + valuePopupTrigger, + hoverDelay, + hoverDistance, }) { const [nodes, setNodes, onNodesChange] = useNodesState(hydratedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges); @@ -826,6 +890,54 @@ function FlowInner({ [sendPatch], ); + const edgeHoverTimerRef = useRef(null); + const edgeHoverCleanupRef = useRef(null); + const onEdgeHover = useCallback( + (event, edge) => { + if (valuePopupTrigger !== "hover") { + return; + } + if (edgeHoverTimerRef.current) { + clearTimeout(edgeHoverTimerRef.current); + } + if (edgeHoverCleanupRef.current) { + edgeHoverCleanupRef.current(); + } + const position = { x: event.clientX, y: event.clientY }; + const target = { edge_id: edge.id }; + const cleanup = () => { + if (edgeHoverTimerRef.current) { + clearTimeout(edgeHoverTimerRef.current); + edgeHoverTimerRef.current = null; + } + if (edgeHoverCleanupRef.current === cleanup) { + edgeHoverCleanupRef.current = null; + } + document.removeEventListener("pointermove", onPointerMove, true); + }; + const onPointerMove = (moveEvent) => { + const dx = moveEvent.clientX - position.x; + const dy = moveEvent.clientY - position.y; + if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { + sendPatch({ type: "edge_unhovered", ...target }); + cleanup(); + } + }; + edgeHoverCleanupRef.current = cleanup; + edgeHoverTimerRef.current = setTimeout(() => { + edgeHoverTimerRef.current = null; + sendPatch({ type: "edge_hovered", ...target, position }); + document.addEventListener("pointermove", onPointerMove, true); + }, hoverDelay); + }, + [sendPatch, valuePopupTrigger, hoverDelay, hoverDistance], + ); + + const onEdgeHoverEnd = useCallback( + () => {}, + [], + ); + const onEdgeClick = useCallback( (event, edge) => { sendPatch({ @@ -872,12 +984,16 @@ function FlowInner({ onNodeDoubleClick: wrap("onNodeDoubleClick", onNodeDoubleClick), onNodeContextMenu: wrap("onNodeContextMenu", onNodeContextMenu), onEdgeClick: wrap("onEdgeClick", onEdgeClick), + onEdgeMouseEnter: wrap("onEdgeMouseEnter", onEdgeHover), + onEdgeMouseLeave: wrap("onEdgeMouseLeave", onEdgeHoverEnd), onPaneClick: wrap("onPaneClick", onPaneClick), }; }, [ handleNodesChange, onConnect, onEdgeClick, + onEdgeHover, + onEdgeHoverEnd, onEdgesChange, onEdgesDelete, onMoveEnd, @@ -928,6 +1044,9 @@ export function render({ model, view }) { const [debounceMs] = model.useState("debounce_ms"); const [editable] = model.useState("editable"); const [editorMode] = model.useState("editor_mode"); + const [valuePopupTrigger] = model.useState("value_popup_trigger"); + const [hoverDelay] = model.useState("value_popup_hover_delay"); + const [hoverDistance] = model.useState("value_popup_hover_distance"); const [errorRecovery] = model.useState("error_recovery"); const [enableConnect] = model.useState("enable_connect"); const [enableDelete] = model.useState("enable_delete"); @@ -1147,10 +1266,12 @@ export function render({ model, view }) { const hydratedNodeTypes = useMemo(() => { const mapping = {}; Object.entries({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }).forEach(([typeName, spec]) => { - mapping[typeName] = makeNodeComponent(typeName, spec, editorMode, model); + mapping[typeName] = makeNodeComponent( + typeName, spec, editorMode, model, valuePopupTrigger, hoverDelay, hoverDistance, + ); }); return mapping; - }, [editorMode, pyNodeTypes, model]); + }, [editorMode, pyNodeTypes, model, valuePopupTrigger, hoverDelay, hoverDistance]); const contextMenuRef = useRef(null); const valuePopupRef = useRef(null); @@ -1242,6 +1363,9 @@ export function render({ model, view }) { syncMode={syncMode} debounceMs={debounceMs} viewport={viewport} + valuePopupTrigger={valuePopupTrigger} + hoverDelay={hoverDelay} + hoverDistance={hoverDistance} /> ); diff --git a/tests/test_api.py b/tests/test_api.py index 9a1b08f..984a502 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -840,6 +840,25 @@ def test_handle_msg_node_clicked_emits_once() -> None: assert events[0]["node_id"] == "n1" +def test_value_popup_trigger_defaults_to_click() -> None: + flow = ReactFlow() + assert flow.value_popup_trigger == "click" + assert flow.value_popup_hover_delay == 500 + assert flow.value_popup_hover_distance == 24 + + +def test_hover_events_emit_only_in_hover_mode() -> None: + flow = ReactFlow(value_popup_trigger="hover") + events: list[dict] = [] + flow.on("handle_hovered", events.append) + flow._handle_msg({"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}) + assert events == [{"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}] + + flow.value_popup_trigger = "click" + flow._handle_msg({"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}) + assert len(events) == 1 + + def test_handle_msg_handle_clicked_emits_once() -> None: flow = ReactFlow(nodes=[{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}]) events: list[dict] = [] diff --git a/tests/ui/test_value_popup.py b/tests/ui/test_value_popup.py index d1cdf81..f0921cc 100644 --- a/tests/ui/test_value_popup.py +++ b/tests/ui/test_value_popup.py @@ -26,7 +26,7 @@ } -def _flow(): +def _flow(**params): nodes = [ NodeSpec(id="src", type="source", position={"x": 0, "y": 100}, data={}).to_dict(), NodeSpec(id="snk", type="sink", position={"x": 300, "y": 100}, data={}).to_dict(), @@ -38,6 +38,7 @@ def _flow(): node_types=NODE_TYPES, width=900, height=600, + **params, ) @@ -115,3 +116,27 @@ def test_edge_click_emits_event(page): wait_until(lambda: len(events) == 1, timeout=8000) assert events[0]["edge_id"] == "e1" assert "position" in events[0] + + +def test_hover_trigger_waits_for_delay_and_opens_popup(page): + flow = _flow(value_popup_trigger="hover", value_popup_hover_delay=100, value_popup_hover_distance=20) + flow.on("handle_hovered", lambda payload, flow: flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"])) + serve_component(page, flow) + + handle = page.locator(".react-flow__handle-right").first + handle.hover() + popup = page.locator(".rf-value-popup") + expect(popup).not_to_be_visible() + page.wait_for_timeout(150) + expect(popup).to_be_visible() + + +def test_hover_trigger_does_not_emit_click_event(page): + events = [] + flow = _flow(value_popup_trigger="hover") + flow.on("handle_clicked", lambda payload: events.append(payload)) + serve_component(page, flow) + + page.locator(".react-flow__handle-right").first.click() + page.wait_for_timeout(100) + assert events == [] From 8e2af848d1ffe5198cfe3641daa84ec3892e1f36 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 22 Sep 2026 21:20:10 +0200 Subject: [PATCH 5/8] refactor: shorten popup parameter names --- docs/how-to/declare-types.md | 8 ++++---- examples/port_value_inspection.py | 6 +++--- src/panel_reactflow/base.py | 22 +++++++++++----------- src/panel_reactflow/models/reactflow.jsx | 6 +++--- tests/test_api.py | 12 ++++++------ tests/ui/test_value_popup.py | 4 ++-- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index 31b2b70..7ceaa3e 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -297,11 +297,11 @@ flow.on("edge_clicked", on_edge_clicked) ``` The popup closes itself when the user clicks elsewhere, or programmatically -via `flow.close_popup()`. Set `value_popup_trigger="hover"` to require the +via `flow.close_popup()`. Set `popup_trigger="hover"` to require the pointer to dwell over a port or edge before opening the popup. The default -`value_popup_hover_delay` is 500 ms; after opening, the popup remains visible -until the pointer moves `value_popup_hover_distance` pixels from the hover -anchor. Use `value_popup_trigger="none"` to disable the built-in inspection +`popup_hover_delay` is 500 ms; after opening, the popup remains visible +until the pointer moves `popup_hover_distance` pixels from the hover +anchor. Use `popup_trigger="none"` to disable the built-in inspection events. `panel-reactflow` only provides the interaction events and overlay; looking up "the current value" for a port is application-specific. diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py index 74b8e20..f35d77d 100644 --- a/examples/port_value_inspection.py +++ b/examples/port_value_inspection.py @@ -58,9 +58,9 @@ nodes=nodes, edges=edges, node_types=node_types, - value_popup_trigger="click", # Change to "hover" to inspect after a dwell. - value_popup_hover_delay=500, - value_popup_hover_distance=24, + popup_trigger="click", # Change to "hover" to inspect after a dwell. + popup_hover_delay=500, + popup_hover_distance=24, sizing_mode="stretch_both", min_height=450, ) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index ac1e88a..40d185a 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1581,19 +1581,19 @@ class ReactFlow(ReactComponent): viewport = param.Dict(default=None, allow_None=True, doc="Optional persisted viewport state.") - value_popup_trigger = param.ObjectSelector( + popup_trigger = param.ObjectSelector( default="click", objects=["click", "hover", "none"], doc="Whether port and edge inspection events are triggered on click, hover, or not automatically.", ) - value_popup_hover_delay = param.Integer( + popup_hover_delay = param.Integer( default=500, bounds=(0, None), doc="Delay in milliseconds before a hover inspection event is emitted.", ) - value_popup_hover_distance = param.Number( + popup_hover_distance = param.Number( default=24, bounds=(0, None), doc="Pointer distance in pixels from the hover anchor before closing the popup.", @@ -2503,25 +2503,25 @@ def _process_msg(self, msg: dict[str, Any]) -> None: node_id = msg.get("node_id") if node_id is None: return - if self.value_popup_trigger == "click": + if self.popup_trigger == "click": self._emit("handle_clicked", msg) case "edge_clicked": edge_id = msg.get("edge_id") if edge_id is None: return - if self.value_popup_trigger == "click": + if self.popup_trigger == "click": self._emit("edge_clicked", msg) case "handle_hovered": - if self.value_popup_trigger == "hover" and msg.get("node_id") is not None: + if self.popup_trigger == "hover" and msg.get("node_id") is not None: self._emit("handle_hovered", msg) case "handle_unhovered": - if self.value_popup_trigger == "hover" and msg.get("node_id") is not None: + if self.popup_trigger == "hover" and msg.get("node_id") is not None: self._emit("handle_unhovered", msg) case "edge_hovered": - if self.value_popup_trigger == "hover" and msg.get("edge_id") is not None: + if self.popup_trigger == "hover" and msg.get("edge_id") is not None: self._emit("edge_hovered", msg) case "edge_unhovered": - if self.value_popup_trigger == "hover" and msg.get("edge_id") is not None: + if self.popup_trigger == "hover" and msg.get("edge_id") is not None: self._emit("edge_unhovered", msg) case "node_context_menu": node_id = msg.get("node_id") @@ -3399,7 +3399,7 @@ def show_popup(self, content: Any, position: dict[str, float]) -> None: Notes ----- - Set ``value_popup_trigger="hover"`` to use the configured dwell delay + Set ``popup_trigger="hover"`` to use the configured dwell delay and movement distance before and after opening a value popup. Set it to ``"none"`` to disable automatic inspection events. """ @@ -3437,7 +3437,7 @@ def on(self, event_type: str, callback) -> None: ``edge_id`` and a screen ``position`` suitable for :meth:`show_popup`. - ``"handle_hovered"`` / ``"edge_hovered"``: A port or edge was - entered while ``value_popup_trigger="hover"``. + entered while ``popup_trigger="hover"``. - ``"handle_unhovered"`` / ``"edge_unhovered"``: The pointer left the inspected port or edge. - ``"node_data_changed"``: Node data was modified diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 5842f3d..f4bd8a4 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -1044,9 +1044,9 @@ export function render({ model, view }) { const [debounceMs] = model.useState("debounce_ms"); const [editable] = model.useState("editable"); const [editorMode] = model.useState("editor_mode"); - const [valuePopupTrigger] = model.useState("value_popup_trigger"); - const [hoverDelay] = model.useState("value_popup_hover_delay"); - const [hoverDistance] = model.useState("value_popup_hover_distance"); + const [valuePopupTrigger] = model.useState("popup_trigger"); + const [hoverDelay] = model.useState("popup_hover_delay"); + const [hoverDistance] = model.useState("popup_hover_distance"); const [errorRecovery] = model.useState("error_recovery"); const [enableConnect] = model.useState("enable_connect"); const [enableDelete] = model.useState("enable_delete"); diff --git a/tests/test_api.py b/tests/test_api.py index 984a502..56a2d99 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -840,21 +840,21 @@ def test_handle_msg_node_clicked_emits_once() -> None: assert events[0]["node_id"] == "n1" -def test_value_popup_trigger_defaults_to_click() -> None: +def test_popup_trigger_defaults_to_click() -> None: flow = ReactFlow() - assert flow.value_popup_trigger == "click" - assert flow.value_popup_hover_delay == 500 - assert flow.value_popup_hover_distance == 24 + assert flow.popup_trigger == "click" + assert flow.popup_hover_delay == 500 + assert flow.popup_hover_distance == 24 def test_hover_events_emit_only_in_hover_mode() -> None: - flow = ReactFlow(value_popup_trigger="hover") + flow = ReactFlow(popup_trigger="hover") events: list[dict] = [] flow.on("handle_hovered", events.append) flow._handle_msg({"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}) assert events == [{"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}] - flow.value_popup_trigger = "click" + flow.popup_trigger = "click" flow._handle_msg({"type": "handle_hovered", "node_id": "n1", "handle_id": "out"}) assert len(events) == 1 diff --git a/tests/ui/test_value_popup.py b/tests/ui/test_value_popup.py index f0921cc..31b785e 100644 --- a/tests/ui/test_value_popup.py +++ b/tests/ui/test_value_popup.py @@ -119,7 +119,7 @@ def test_edge_click_emits_event(page): def test_hover_trigger_waits_for_delay_and_opens_popup(page): - flow = _flow(value_popup_trigger="hover", value_popup_hover_delay=100, value_popup_hover_distance=20) + flow = _flow(popup_trigger="hover", popup_hover_delay=100, popup_hover_distance=20) flow.on("handle_hovered", lambda payload, flow: flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"])) serve_component(page, flow) @@ -133,7 +133,7 @@ def test_hover_trigger_waits_for_delay_and_opens_popup(page): def test_hover_trigger_does_not_emit_click_event(page): events = [] - flow = _flow(value_popup_trigger="hover") + flow = _flow(popup_trigger="hover") flow.on("handle_clicked", lambda payload: events.append(payload)) serve_component(page, flow) From 213a68204b4ea8b1eb65ed08aa069dc0996d3375 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 22 Sep 2026 21:51:25 +0200 Subject: [PATCH 6/8] fix: keep hover popups open near content --- examples/port_value_inspection.py | 2 +- src/panel_reactflow/models/reactflow.jsx | 59 ++++++++++++++++++++---- tests/ui/test_value_popup.py | 15 ++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py index f35d77d..a8ff8fc 100644 --- a/examples/port_value_inspection.py +++ b/examples/port_value_inspection.py @@ -58,7 +58,7 @@ nodes=nodes, edges=edges, node_types=node_types, - popup_trigger="click", # Change to "hover" to inspect after a dwell. + popup_trigger="hover", # Change to "hover" to inspect after a dwell. popup_hover_delay=500, popup_hover_distance=24, sizing_mode="stretch_both", diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index f4bd8a4..ed7cdde 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -21,6 +21,28 @@ const MAX_RECOVERY_ATTEMPTS = 2; const RETRY_DELAY_MS = 100; // How long a remounted flow must survive before its retry budget is refilled. const HEALTHY_RESET_MS = 5000; +const HOVER_CLOSE_GRACE_MS = 150; +const popupHoverState = { inside: false, closeTimers: new Set() }; + +function schedulePopupClose(callback) { + const timer = setTimeout(() => { + popupHoverState.closeTimers.delete(timer); + if (!popupHoverState.inside) { + callback(); + } + }, HOVER_CLOSE_GRACE_MS); + popupHoverState.closeTimers.add(timer); +} + +function enterValuePopup() { + popupHoverState.inside = true; + popupHoverState.closeTimers.forEach((timer) => clearTimeout(timer)); + popupHoverState.closeTimers.clear(); +} + +function leaveValuePopup() { + popupHoverState.inside = false; +} const figureStylesheet = ` .bk-Canvas { @@ -30,6 +52,15 @@ const figureStylesheet = ` height: calc(var(--rf-zoom) * 100%); }`.trim(); +function isInsideValuePopup(x, y) { + const popup = document.querySelector(".rf-value-popup"); + if (!popup) { + return false; + } + const rect = popup.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + function renderHandles(direction, handles, opts = {}) { const handleType = direction === "input" ? "target" : "source"; const position = direction === "input" ? Position.Left : Position.Right; @@ -75,8 +106,8 @@ function renderHandles(direction, handles, opts = {}) { type={handleType} position={position} onClick={makeClickHandler(null)} - onMouseEnter={makeHoverHandler(null, "enter")} - onMouseLeave={makeHoverHandler(null, "leave")} + onPointerEnter={makeHoverHandler(null, "enter")} + onPointerLeave={makeHoverHandler(null, "leave")} {...handleProps} /> ); @@ -96,8 +127,8 @@ function renderHandles(direction, handles, opts = {}) { style={{ top: `${(index + 1) * spacing}%` }} {...(tooltip ? {"data-tooltip": tooltip, "data-tooltip-pos": tooltipPos} : {})} onClick={makeClickHandler(id)} - onMouseEnter={makeHoverHandler(id, "enter")} - onMouseLeave={makeHoverHandler(id, "leave")} + onPointerEnter={makeHoverHandler(id, "enter")} + onPointerLeave={makeHoverHandler(id, "leave")} {...handleProps} /> ); @@ -142,9 +173,14 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrig const onPointerMove = (moveEvent) => { const dx = moveEvent.clientX - position.x; const dy = moveEvent.clientY - position.y; + if (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { + return; + } if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { - model.send_msg({ type: "handle_unhovered", ...target }); - cleanup(); + schedulePopupClose(() => { + model.send_msg({ type: "handle_unhovered", ...target }); + cleanup(); + }); } }; hoverCleanupRef.current = cleanup; @@ -918,9 +954,14 @@ function FlowInner({ const onPointerMove = (moveEvent) => { const dx = moveEvent.clientX - position.x; const dy = moveEvent.clientY - position.y; + if (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { + return; + } if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { - sendPatch({ type: "edge_unhovered", ...target }); - cleanup(); + schedulePopupClose(() => { + sendPatch({ type: "edge_unhovered", ...target }); + cleanup(); + }); } }; edgeHoverCleanupRef.current = cleanup; @@ -1414,6 +1455,8 @@ export function render({ model, view }) {
Date: Tue, 22 Sep 2026 22:10:15 +0200 Subject: [PATCH 7/8] fix: wire edge hover inspection example --- docs/how-to/declare-types.md | 3 +++ examples/port_value_inspection.py | 10 ++++++---- tests/ui/test_value_popup.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index 7ceaa3e..608ee67 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -294,6 +294,9 @@ def on_edge_clicked(payload, flow): flow.on("handle_clicked", on_handle_clicked) flow.on("edge_clicked", on_edge_clicked) +# Register these instead when using popup_trigger="hover". +flow.on("handle_hovered", on_handle_clicked) +flow.on("edge_hovered", on_edge_clicked) ``` The popup closes itself when the user clicks elsewhere, or programmatically diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py index a8ff8fc..ba5687b 100644 --- a/examples/port_value_inspection.py +++ b/examples/port_value_inspection.py @@ -77,21 +77,23 @@ def show_value(title, value, position): ) -def on_handle_clicked(payload, flow): +def on_handle_inspected(payload, flow): node_id = payload["node_id"] handle_id = payload["handle_id"] value = live_values.get(node_id, {}).get(handle_id) show_value(f"{node_id}.{handle_id}", value, payload["position"]) -def on_edge_clicked(payload, flow): +def on_edge_inspected(payload, flow): node_id, handle_id = edge_sources[payload["edge_id"]] value = live_values[node_id][handle_id] show_value(f"{node_id}.{handle_id}", value, payload["position"]) -flow.on("handle_clicked", on_handle_clicked) -flow.on("edge_clicked", on_edge_clicked) +flow.on("handle_clicked", on_handle_inspected) +flow.on("edge_clicked", on_edge_inspected) +flow.on("handle_hovered", on_handle_inspected) +flow.on("edge_hovered", on_edge_inspected) pn.Column( "# Port value inspection", diff --git a/tests/ui/test_value_popup.py b/tests/ui/test_value_popup.py index ea2379f..4782214 100644 --- a/tests/ui/test_value_popup.py +++ b/tests/ui/test_value_popup.py @@ -155,3 +155,23 @@ def test_hover_popup_remains_open_when_pointer_enters_popup(page): popup.hover() page.wait_for_timeout(100) expect(popup).to_be_visible() + + +def test_edge_hover_emits_event_and_opens_popup(page): + flow = _flow(popup_trigger="hover", popup_hover_delay=100, popup_hover_distance=20) + events = [] + + def on_edge_hovered(payload, flow): + events.append(payload) + flow.show_popup(pn.pane.Markdown("Edge value"), payload["position"]) + + flow.on("edge_hovered", on_edge_hovered) + serve_component(page, flow) + + edge = page.locator(".react-flow__edge-path").first + edge.hover(force=True) + page.wait_for_timeout(150) + + wait_until(lambda: len(events) == 1, timeout=8000) + assert events[0]["edge_id"] == "e1" + expect(page.locator(".rf-value-popup")).to_contain_text("Edge value") From 12fc01c62cc2fc438807772dda56fbf06d09b9bf Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 23 Sep 2026 13:52:54 +0200 Subject: [PATCH 8/8] Improvements --- docs/how-to/declare-types.md | 25 +++++---- examples/port_value_inspection.py | 34 +++++++++--- src/panel_reactflow/base.py | 6 --- src/panel_reactflow/models/reactflow.jsx | 24 +++++---- tests/test_api.py | 7 --- tests/ui/test_value_popup.py | 68 ++++++++++++++++++++++-- 6 files changed, 120 insertions(+), 44 deletions(-) diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index 608ee67..853ae18 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -294,19 +294,22 @@ def on_edge_clicked(payload, flow): flow.on("handle_clicked", on_handle_clicked) flow.on("edge_clicked", on_edge_clicked) -# Register these instead when using popup_trigger="hover". -flow.on("handle_hovered", on_handle_clicked) -flow.on("edge_hovered", on_edge_clicked) ``` -The popup closes itself when the user clicks elsewhere, or programmatically -via `flow.close_popup()`. Set `popup_trigger="hover"` to require the -pointer to dwell over a port or edge before opening the popup. The default -`popup_hover_delay` is 500 ms; after opening, the popup remains visible -until the pointer moves `popup_hover_distance` pixels from the hover -anchor. Use `popup_trigger="none"` to disable the built-in inspection -events. `panel-reactflow` only provides the interaction events and overlay; -looking up "the current value" for a port is application-specific. +For `popup_trigger="hover"`, register `handle_hovered` and `edge_hovered` to +show the popup and their corresponding `handle_unhovered` and `edge_unhovered` +events to call `flow.close_popup()`. Match the unhovered target against the +currently displayed one so a delayed leave event does not dismiss a newer +popup. See the runnable example below for both pairs of callbacks. + +The default `popup_hover_delay` is 500 ms. After opening, an unhover event is +emitted when the pointer moves more than 10% of the shorter canvas dimension +from the anchor (at least 48 pixels) and is not over the popup. Click-opened +popups dismiss on outside click; either mode can dismiss programmatically via +`flow.close_popup()`. Use +`popup_trigger="none"` to disable the built-in inspection events. +`panel-reactflow` only provides the interaction events and overlay; looking up +"the current value" for a port is application-specific. For a complete runnable graph with typed port hover tooltips and popups for both handles and edges, run: diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py index ba5687b..9631e39 100644 --- a/examples/port_value_inspection.py +++ b/examples/port_value_inspection.py @@ -1,4 +1,4 @@ -"""Inspect typed port values by clicking handles and edges. +"""Inspect typed port values by hovering over handles and edges. Run with: @@ -58,15 +58,19 @@ nodes=nodes, edges=edges, node_types=node_types, - popup_trigger="hover", # Change to "hover" to inspect after a dwell. + popup_trigger="hover", # Change to "click" to inspect on click instead. popup_hover_delay=500, - popup_hover_distance=24, sizing_mode="stretch_both", min_height=450, ) -def show_value(title, value, position): +active_target = None + + +def show_value(title, value, position, target): + global active_target + active_target = target flow.show_popup( pn.Column( pn.pane.Markdown(f"**{title}**", margin=(0, 0, 6, 0)), @@ -81,23 +85,39 @@ def on_handle_inspected(payload, flow): node_id = payload["node_id"] handle_id = payload["handle_id"] value = live_values.get(node_id, {}).get(handle_id) - show_value(f"{node_id}.{handle_id}", value, payload["position"]) + show_value(f"{node_id}.{handle_id}", value, payload["position"], ("handle", node_id, handle_id, payload["direction"])) def on_edge_inspected(payload, flow): node_id, handle_id = edge_sources[payload["edge_id"]] value = live_values[node_id][handle_id] - show_value(f"{node_id}.{handle_id}", value, payload["position"]) + show_value(f"{node_id}.{handle_id}", value, payload["position"], ("edge", payload["edge_id"])) + + +def on_handle_unhovered(payload, flow): + global active_target + if active_target == ("handle", payload["node_id"], payload["handle_id"], payload["direction"]): + active_target = None + flow.close_popup() + + +def on_edge_unhovered(payload, flow): + global active_target + if active_target == ("edge", payload["edge_id"]): + active_target = None + flow.close_popup() flow.on("handle_clicked", on_handle_inspected) flow.on("edge_clicked", on_edge_inspected) flow.on("handle_hovered", on_handle_inspected) flow.on("edge_hovered", on_edge_inspected) +flow.on("handle_unhovered", on_handle_unhovered) +flow.on("edge_unhovered", on_edge_unhovered) pn.Column( "# Port value inspection", - "Hover a port to see its type. Click a port or edge to inspect its current value.", + "Hover a port to see its type and pause over a port or edge to inspect its current value.", flow, sizing_mode="stretch_both", ).servable() diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 40d185a..b4fa925 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1593,12 +1593,6 @@ class ReactFlow(ReactComponent): doc="Delay in milliseconds before a hover inspection event is emitted.", ) - popup_hover_distance = param.Number( - default=24, - bounds=(0, None), - doc="Pointer distance in pixels from the hover anchor before closing the popup.", - ) - top_panel = Children(default=[], doc="Children rendered in a top-center panel.") bottom_panel = Children(default=[], doc="Children rendered in a bottom-center panel.") left_panel = Children(default=[], doc="Children rendered in a center-left panel.") diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index ed7cdde..249d841 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -61,6 +61,11 @@ function isInsideValuePopup(x, y) { return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } +function popupCloseDistance(event) { + const frame = event.currentTarget.closest(".react-flow")?.getBoundingClientRect(); + return Math.max(48, Math.min(frame?.width ?? 0, frame?.height ?? 0) * 0.1); +} + function renderHandles(direction, handles, opts = {}) { const handleType = direction === "input" ? "target" : "source"; const position = direction === "input" ? Position.Left : Position.Right; @@ -135,7 +140,7 @@ function renderHandles(direction, handles, opts = {}) { }); } -function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrigger, hoverDelay, hoverDistance) { +function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrigger, hoverDelay) { return function NodeComponent({ id, data }) { const [toolbarOpen, toggleToolbar] = React.useState(false); const hoverTimerRef = useRef(null); @@ -153,6 +158,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrig return; } const position = { x: event.clientX, y: event.clientY }; + const closeDistance = popupCloseDistance(event); if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); } @@ -176,7 +182,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrig if (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { return; } - if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { + if (dx * dx + dy * dy >= closeDistance * closeDistance) { schedulePopupClose(() => { model.send_msg({ type: "handle_unhovered", ...target }); cleanup(); @@ -190,7 +196,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode, model, valuePopupTrig document.addEventListener("pointermove", onPointerMove, true); }, hoverDelay); }, - [id, valuePopupTrigger, hoverDelay, hoverDistance], + [id, valuePopupTrigger, hoverDelay], ); const onHandleClick = useCallback( (handleId, direction, event) => { @@ -669,7 +675,6 @@ function FlowInner({ viewport, valuePopupTrigger, hoverDelay, - hoverDistance, }) { const [nodes, setNodes, onNodesChange] = useNodesState(hydratedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges); @@ -940,6 +945,7 @@ function FlowInner({ edgeHoverCleanupRef.current(); } const position = { x: event.clientX, y: event.clientY }; + const closeDistance = popupCloseDistance(event); const target = { edge_id: edge.id }; const cleanup = () => { if (edgeHoverTimerRef.current) { @@ -957,7 +963,7 @@ function FlowInner({ if (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { return; } - if (dx * dx + dy * dy >= hoverDistance * hoverDistance) { + if (dx * dx + dy * dy >= closeDistance * closeDistance) { schedulePopupClose(() => { sendPatch({ type: "edge_unhovered", ...target }); cleanup(); @@ -971,7 +977,7 @@ function FlowInner({ document.addEventListener("pointermove", onPointerMove, true); }, hoverDelay); }, - [sendPatch, valuePopupTrigger, hoverDelay, hoverDistance], + [sendPatch, valuePopupTrigger, hoverDelay], ); const onEdgeHoverEnd = useCallback( @@ -1087,7 +1093,6 @@ export function render({ model, view }) { const [editorMode] = model.useState("editor_mode"); const [valuePopupTrigger] = model.useState("popup_trigger"); const [hoverDelay] = model.useState("popup_hover_delay"); - const [hoverDistance] = model.useState("popup_hover_distance"); const [errorRecovery] = model.useState("error_recovery"); const [enableConnect] = model.useState("enable_connect"); const [enableDelete] = model.useState("enable_delete"); @@ -1308,11 +1313,11 @@ export function render({ model, view }) { const mapping = {}; Object.entries({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }).forEach(([typeName, spec]) => { mapping[typeName] = makeNodeComponent( - typeName, spec, editorMode, model, valuePopupTrigger, hoverDelay, hoverDistance, + typeName, spec, editorMode, model, valuePopupTrigger, hoverDelay, ); }); return mapping; - }, [editorMode, pyNodeTypes, model, valuePopupTrigger, hoverDelay, hoverDistance]); + }, [editorMode, pyNodeTypes, model, valuePopupTrigger, hoverDelay]); const contextMenuRef = useRef(null); const valuePopupRef = useRef(null); @@ -1406,7 +1411,6 @@ export function render({ model, view }) { viewport={viewport} valuePopupTrigger={valuePopupTrigger} hoverDelay={hoverDelay} - hoverDistance={hoverDistance} /> ); diff --git a/tests/test_api.py b/tests/test_api.py index 56a2d99..2ddfe6c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -840,13 +840,6 @@ def test_handle_msg_node_clicked_emits_once() -> None: assert events[0]["node_id"] == "n1" -def test_popup_trigger_defaults_to_click() -> None: - flow = ReactFlow() - assert flow.popup_trigger == "click" - assert flow.popup_hover_delay == 500 - assert flow.popup_hover_distance == 24 - - def test_hover_events_emit_only_in_hover_mode() -> None: flow = ReactFlow(popup_trigger="hover") events: list[dict] = [] diff --git a/tests/ui/test_value_popup.py b/tests/ui/test_value_popup.py index 4782214..c93ca5a 100644 --- a/tests/ui/test_value_popup.py +++ b/tests/ui/test_value_popup.py @@ -1,5 +1,8 @@ """UI tests for handle/edge click-to-inspect (value popup) feature.""" +import runpy +from pathlib import Path + import panel as pn import pytest from panel.tests.util import serve_component, wait_until @@ -119,7 +122,7 @@ def test_edge_click_emits_event(page): def test_hover_trigger_waits_for_delay_and_opens_popup(page): - flow = _flow(popup_trigger="hover", popup_hover_delay=100, popup_hover_distance=20) + flow = _flow(popup_trigger="hover", popup_hover_delay=100) flow.on("handle_hovered", lambda payload, flow: flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"])) serve_component(page, flow) @@ -143,7 +146,7 @@ def test_hover_trigger_does_not_emit_click_event(page): def test_hover_popup_remains_open_when_pointer_enters_popup(page): - flow = _flow(popup_trigger="hover", popup_hover_delay=100, popup_hover_distance=20) + flow = _flow(popup_trigger="hover", popup_hover_delay=100) flow.on("handle_hovered", lambda payload, flow: flow.show_popup(pn.pane.Markdown("Value: 42"), payload["position"])) serve_component(page, flow) @@ -158,7 +161,7 @@ def test_hover_popup_remains_open_when_pointer_enters_popup(page): def test_edge_hover_emits_event_and_opens_popup(page): - flow = _flow(popup_trigger="hover", popup_hover_delay=100, popup_hover_distance=20) + flow = _flow(popup_trigger="hover", popup_hover_delay=100) events = [] def on_edge_hovered(payload, flow): @@ -175,3 +178,62 @@ def on_edge_hovered(payload, flow): wait_until(lambda: len(events) == 1, timeout=8000) assert events[0]["edge_id"] == "e1" expect(page.locator(".rf-value-popup")).to_contain_text("Edge value") + + +@pytest.mark.parametrize("width,height,near,far", [(900, 600, 50, 120), (400, 400, 40, 90)]) +def test_hover_close_radius_scales_with_canvas(page, width, height, near, far): + flow = _flow(popup_trigger="hover", popup_hover_delay=100) + flow.width = width + flow.height = height + positions = [] + + def on_edge_hovered(payload, flow): + positions.append(payload["position"]) + flow.show_popup(pn.pane.Markdown("Edge value"), payload["position"]) + + flow.on("edge_hovered", on_edge_hovered) + flow.on("edge_unhovered", lambda payload, flow: flow.close_popup()) + serve_component(page, flow) + + page.locator(".react-flow__edge-path").first.hover(force=True) + popup = page.locator(".rf-value-popup") + expect(popup).to_be_visible() + x, y = positions[0]["x"], positions[0]["y"] + + page.mouse.move(x - near, y) + page.wait_for_timeout(250) + expect(popup).to_be_visible() + + page.mouse.move(x - far, y) + expect(popup).not_to_be_visible() + + +@pytest.mark.parametrize("target", [".react-flow__edge-path", ".react-flow__handle-right"]) +def test_example_hover_popup_closes_after_moving_away(page, target): + example = Path(__file__).resolve().parents[2] / "examples" / "port_value_inspection.py" + flow = runpy.run_path(str(example))["flow"] + serve_component(page, flow) + + page.locator(target).first.hover(force=True) + popup = page.locator(".rf-value-popup") + expect(popup).to_be_visible() + popup.hover() + expect(popup).to_be_visible() + + pane = page.locator(".react-flow__pane").bounding_box() + page.mouse.move(pane["x"] + pane["width"] - 40, pane["y"] + pane["height"] - 40) + expect(popup).not_to_be_visible() + wait_until(lambda: flow._value_popup is None, timeout=8000) + + +def test_example_stale_unhover_does_not_close_newer_popup(): + example = Path(__file__).resolve().parents[2] / "examples" / "port_value_inspection.py" + flow = runpy.run_path(str(example))["flow"] + + flow._handle_msg({"type": "edge_hovered", "edge_id": "records", "position": {"x": 10, "y": 10}}) + flow._handle_msg({"type": "edge_hovered", "edge_id": "summary", "position": {"x": 20, "y": 20}}) + flow._handle_msg({"type": "edge_unhovered", "edge_id": "records"}) + assert flow._value_popup is not None + + flow._handle_msg({"type": "edge_unhovered", "edge_id": "summary"}) + assert flow._value_popup is None