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