diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index ac4a45e..853ae18 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,19 +254,68 @@ 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) +``` + +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: + +```bash +panel serve examples/port_value_inspection.py --show ``` --- 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 diff --git a/examples/port_value_inspection.py b/examples/port_value_inspection.py new file mode 100644 index 0000000..9631e39 --- /dev/null +++ b/examples/port_value_inspection.py @@ -0,0 +1,123 @@ +"""Inspect typed port values by hovering over 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, + popup_trigger="hover", # Change to "click" to inspect on click instead. + popup_hover_delay=500, + sizing_mode="stretch_both", + min_height=450, +) + + +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)), + pn.pane.JSON(value, depth=3, sizing_mode="stretch_width"), + sizing_mode="stretch_width", + ), + position, + ) + + +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"], ("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"], ("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 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 163ba33..b4fa925 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. @@ -1579,6 +1581,18 @@ class ReactFlow(ReactComponent): viewport = param.Dict(default=None, allow_None=True, doc="Optional persisted viewport state.") + 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.", + ) + + popup_hover_delay = param.Integer( + default=500, + bounds=(0, None), + doc="Delay in milliseconds before a hover inspection event is emitted.", + ) + 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.") @@ -1591,6 +1605,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 +2015,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 +2221,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 +2493,30 @@ 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 + 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.popup_trigger == "click": + self._emit("edge_clicked", msg) + case "handle_hovered": + if self.popup_trigger == "hover" and msg.get("node_id") is not None: + self._emit("handle_hovered", msg) + case "handle_unhovered": + if self.popup_trigger == "hover" and msg.get("node_id") is not None: + self._emit("handle_unhovered", msg) + case "edge_hovered": + if self.popup_trigger == "hover" and msg.get("edge_id") is not None: + self._emit("edge_hovered", msg) + case "edge_unhovered": + 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") position = msg.get("position") @@ -2487,6 +2534,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 +3363,48 @@ 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. + + Notes + ----- + 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. + """ + 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 +3422,18 @@ 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`. + - ``"handle_hovered"`` / ``"edge_hovered"``: A port or edge was + entered while ``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 @@ -3408,6 +3512,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 +3535,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..249d841 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,9 +52,41 @@ 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 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; + 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 + ? (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 +106,23 @@ 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, valuePopupTrigger, hoverDelay) { 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 }; + const closeDistance = popupCloseDistance(event); + 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 (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { + return; + } + if (dx * dx + dy * dy >= closeDistance * closeDistance) { + schedulePopupClose(() => { + 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], + ); + 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 +333,8 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { connectable: spec.inputConnectable, connectableStart: spec.inputConnectableStart, connectableEnd: spec.inputConnectableEnd, + onHandleClick, + onHandleHover, })}
{displayLabel} @@ -212,6 +349,8 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { connectable: spec.outputConnectable, connectableStart: spec.outputConnectableStart, connectableEnd: spec.outputConnectableEnd, + onHandleClick, + onHandleHover, })}
); @@ -254,6 +393,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. @@ -502,6 +673,8 @@ function FlowInner({ syncMode, debounceMs, viewport, + valuePopupTrigger, + hoverDelay, }) { const [nodes, setNodes, onNodesChange] = useNodesState(hydratedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges); @@ -758,6 +931,71 @@ 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 closeDistance = popupCloseDistance(event); + 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 (isInsideValuePopup(moveEvent.clientX, moveEvent.clientY)) { + return; + } + if (dx * dx + dy * dy >= closeDistance * closeDistance) { + schedulePopupClose(() => { + 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], + ); + + const onEdgeHoverEnd = useCallback( + () => {}, + [], + ); + + 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 +1030,17 @@ function FlowInner({ onMoveEnd: wrap("onMoveEnd", onMoveEnd), 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, @@ -847,6 +1091,8 @@ 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("popup_trigger"); + const [hoverDelay] = model.useState("popup_hover_delay"); const [errorRecovery] = model.useState("error_recovery"); const [enableConnect] = model.useState("enable_connect"); const [enableDelete] = model.useState("enable_delete"); @@ -857,6 +1103,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 +1312,23 @@ 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, valuePopupTrigger, hoverDelay, + ); }); return mapping; - }, [editorMode, pyNodeTypes]); + }, [editorMode, pyNodeTypes, model, valuePopupTrigger, hoverDelay]); 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, @@ -1177,6 +1409,8 @@ export function render({ model, view }) { syncMode={syncMode} debounceMs={debounceMs} viewport={viewport} + valuePopupTrigger={valuePopupTrigger} + hoverDelay={hoverDelay} /> ); @@ -1221,6 +1455,22 @@ 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..2ddfe6c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -840,6 +840,86 @@ def test_handle_msg_node_clicked_emits_once() -> None: assert events[0]["node_id"] == "n1" +def test_hover_events_emit_only_in_hover_mode() -> None: + 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.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] = [] + 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..c93ca5a --- /dev/null +++ b/tests/ui/test_value_popup.py @@ -0,0 +1,239 @@ +"""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 + +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(**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(), + ] + 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, + **params, + ) + + +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] + + +def test_hover_trigger_waits_for_delay_and_opens_popup(page): + 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) + + 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(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 == [] + + +def test_hover_popup_remains_open_when_pointer_enters_popup(page): + 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) + + handle = page.locator(".react-flow__handle-right").first + handle.hover() + popup = page.locator(".rf-value-popup") + page.wait_for_timeout(150) + expect(popup).to_be_visible() + 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) + 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") + + +@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