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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/how-to/react-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ the `ReactFlow` instance as a second argument. You can also listen for
| `edge_data_changed` | Edge data is patched (via API, editor patch, or parameter-driven sync). | `edge_id`, `patch` |
| `selection_changed` | The active selection changes. | `nodes`, `edges` |
| `sync` | A batch sync from the frontend. | *(varies)* |
| `drop` | Data of one of the `drop_types` is dropped onto the canvas. See [Handle drops](#handle-drops-onto-the-canvas). | `drop_type`, `data`, `position`, `target` |
| `client_error` | The graph view hit a rendering error in the browser. See [Recover from Rendering Errors](recover-from-errors.md). | `source`, `message`, `stack`, `component_stack`, `attempt`, `mode` |

---
Expand Down Expand Up @@ -151,6 +152,37 @@ flow.on("selection_changed", on_selection)

---

## Handle drops onto the canvas

Set `drop_types` to the MIME types the canvas accepts, and each drop of one
of them emits `drop`. Anything that sets data under that type when a drag
starts can be the source; a `panel_material_ui.MenuList` with
`draggable=True` sends `{"path": [...], "label": ...}` under its
`drag_type`. The payload's `position` is in flow coordinates, and `target`
names the node, and handle when there is one, under the pointer, so a
handler can add the dropped item there or wire it to the handle it landed
on. Drops are ignored while `editable` is False.

```python
palette = pmui.MenuList(items=[{"label": "CSV file"}, {"label": "Database"}], draggable=True)
flow = ReactFlow(drop_types=[palette.drag_type])

def on_drop(payload, flow):
item = palette.items[payload["data"]["path"][0]]
node_id = f"node-{len(flow.nodes)}"
flow.add_node({"id": node_id, "label": item["label"], "position": payload["position"], "data": {}})
target = payload["target"]
if target and target["direction"] == "input":
flow.add_edge({"source": node_id, "target": target["node_id"], "targetHandle": target["handle_id"]})

flow.on("drop", on_drop)
```

`examples/drop_from_menu.py` extends this with a grouped palette and places
a node dropped on an input handle beside the node it feeds.

---

## Register multiple handlers

You can register as many handlers as you like for the same event — they
Expand Down
15 changes: 15 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,23 @@ an error boundary keeps a malformed graph from blanking the canvas.
`pn.io.hold()` to render them at once
([#72](https://github.com/panel-extensions/panel-reactflow/pull/72)).

- **Drop data onto the canvas** — `drop_types` lists MIME types the canvas
accepts, and each drop of one of them emits a `drop` event with the
dropped data, its flow position, and the node or handle under the
pointer, so a handler can add and wire what was dropped. Draggable
`panel_material_ui.MenuList` items work as a source; see
[React to Events](how-to/react-to-events.md#handle-drops-onto-the-canvas)
([#77](https://github.com/panel-extensions/panel-reactflow/pull/77)).

### Bug fixes

- **Nodes remounted when node types change** — any change to
`node_types` rebuilt the component for every node type, so React
remounted every node and detached its embedded view, which could leave
Bokeh plots blank. Each type's component is now reused until its own
spec or the popup settings change
([#77](https://github.com/panel-extensions/panel-reactflow/pull/77)).

- **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
Expand Down
75 changes: 75 additions & 0 deletions examples/drop_from_menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Drag items from a panel-material-ui MenuList onto the canvas to add nodes.

Dropping an item on empty canvas adds a node there; dropping it on a node's
input handle also connects the new node to that input.

Run from the repository root with:

PYTHONPATH=src pixi run panel serve examples/drop_from_menu.py --show
"""

import panel as pn
import panel_material_ui as pmui

from panel_reactflow import NodeSpec, NodeType, ReactFlow

pn.extension()

PALETTE_ITEMS = [
{"label": "Sources", "draggable": False, "items": [
{"label": "CSV file", "icon": "description", "kind": "source"},
{"label": "Database", "icon": "storage", "kind": "source"},
]},
{"label": "Transforms", "draggable": False, "items": [
{"label": "Filter", "icon": "filter_alt", "kind": "transform"},
]},
]


def lookup(items, path):
item = {"items": items}
for index in path:
item = item["items"][index]
return item


palette = pmui.MenuList(
items=PALETTE_ITEMS, draggable=True, expanded=[(0,), (1,)], width=220, label="Drag onto the canvas"
)

flow = ReactFlow(
nodes=[NodeSpec(id="sink", type="transform", label="Sink", position={"x": 400, "y": 150}).to_dict()],
node_types={
"source": NodeType(type="source", inputs=[], outputs=["out"]),
"transform": NodeType(type="transform", inputs=["in"], outputs=["out"]),
},
drop_types=[palette.drag_type],
# Keeps fitView from zooming in on the lone starting node, so nodes placed
# beside it stay on screen.
max_zoom=1,
sizing_mode="stretch_both",
min_height=500,
)


def on_drop(payload, flow):
item = lookup(palette.items, payload["data"]["path"])
node_id = f"{item['kind']}-{len(flow.nodes)}"
target = payload["target"]
wire = target is not None and target["direction"] == "input"
position = payload["position"]
if wire:
# Place the new node left of the input it feeds rather than on top of it.
target_node = next(n for n in flow.nodes if n["id"] == target["node_id"])
position = {"x": target_node["position"]["x"] - 320, "y": target_node["position"]["y"]}
flow.add_node(NodeSpec(id=node_id, type=item["kind"], label=item["label"], position=position).to_dict())
if wire:
flow.add_edge(
{"source": node_id, "sourceHandle": "out", "target": target["node_id"], "targetHandle": target["handle_id"]}
)


flow.on("drop", on_drop)

demo = pmui.Row(palette, flow, sizing_mode="stretch_both")
demo.servable()
28 changes: 28 additions & 0 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,16 @@ class ReactFlow(ReactComponent):

viewport = param.Dict(default=None, allow_None=True, doc="Optional persisted viewport state.")

drop_types = param.List(
default=[],
item_type=str,
doc="""
MIME types accepted when data is dropped onto the canvas, e.g. the
``drag_type`` of a draggable ``panel_material_ui.MenuList``. Each
accepted drop emits a ``"drop"`` event; drops of other types are
ignored, as are all drops while ``editable`` is False.""",
)

popup_trigger = param.ObjectSelector(
default="click",
objects=["click", "hover", "none"],
Expand Down Expand Up @@ -2458,6 +2468,17 @@ def _process_msg(self, msg: dict[str, Any]) -> None:
match msg.get("type"):
case "connection_validation_requested":
self._validate_connection_request(msg)
case "drop":
self._emit(
"drop",
{
"type": "drop",
"drop_type": msg.get("drop_type"),
"data": msg.get("data"),
"position": msg.get("position"),
"target": msg.get("target"),
},
)
case "sync":
nodes = msg.get("nodes")
edges = msg.get("edges")
Expand Down Expand Up @@ -3508,6 +3529,13 @@ def on(self, event_type: str, callback) -> None:
``style``, ``type``, ...) were modified
- ``"selection_changed"``: Selection changed
- ``"sync"``: Full graph sync from frontend
- ``"drop"``: Data of one of the ``drop_types`` was dropped onto
the canvas. Payload has the matched ``drop_type``, the dropped
``data`` (parsed from JSON when possible, otherwise the raw
string), the drop ``position`` in flow coordinates, and a
``target`` of ``{"node_id", "handle_id", "direction"}`` when
dropped on a node or handle (``handle_id`` and ``direction``
are ``None`` for a node), else ``None``.
- ``"client_error"``: The graph view hit a rendering error in the
browser. The payload carries ``source``, ``message``, ``stack``,
``component_stack``, ``attempt`` and ``mode``, or for
Expand Down
73 changes: 69 additions & 4 deletions src/panel_reactflow/models/reactflow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,20 @@ function popupCloseDistance(event) {
return Math.max(48, Math.min(frame?.width ?? 0, frame?.height ?? 0) * 0.1);
}

// Resolve the node or handle under a drop so a handler can wire what was dropped.
function dropTarget(element) {
const handle = element?.closest?.(".react-flow__handle");
if (handle) {
return {
node_id: handle.dataset.nodeid,
handle_id: handle.dataset.handleid || null,
direction: handle.classList.contains("target") ? "input" : "output",
};
}
const node = element?.closest?.(".react-flow__node");
return node ? { node_id: node.dataset.id, handle_id: null, direction: null } : null;
}

function renderHandles(direction, handles, opts = {}) {
const handleType = direction === "input" ? "target" : "source";
const position = direction === "input" ? Position.Left : Position.Right;
Expand Down Expand Up @@ -751,6 +765,7 @@ function FlowInner({
viewport,
valuePopupTrigger,
hoverDelay,
dropTypes,
}) {
const [nodes, setNodes, onNodesChange] = useNodesState(hydratedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges);
Expand All @@ -760,7 +775,7 @@ function FlowInner({
const edgeHydrationFrameRef = useRef(null);
const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null });
const lastViewportSig = useRef(null);
const { setViewport: setRfViewport } = useReactFlow();
const { setViewport: setRfViewport, screenToFlowPosition } = useReactFlow();
const [drag, setDrag] = useState(null);
const [validationResults, setValidationResults] = useState(null);
const dragRef = useRef(null);
Expand Down Expand Up @@ -815,6 +830,38 @@ function FlowInner({
}
}, [connectionValidation, hasConnectionValidators, model]);

const acceptedDropType = useCallback((event) => {
if (!editable || !dropTypes?.length) return null;
const types = Array.from(event.dataTransfer?.types || []);
return dropTypes.find((type) => types.includes(type)) ?? null;
}, [dropTypes, editable]);

const onDragOver = useCallback((event) => {
if (!acceptedDropType(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, [acceptedDropType]);

const onDrop = useCallback((event) => {
const dropType = acceptedDropType(event);
if (!dropType) return;
event.preventDefault();
const raw = event.dataTransfer.getData(dropType);
let data = raw;
try {
data = JSON.parse(raw);
} catch (_error) {
// Not JSON, so hand the handler the raw string.
}
model.send_msg({
type: "drop",
drop_type: dropType,
data,
position: screenToFlowPosition({ x: event.clientX, y: event.clientY }),
target: dropTarget(event.target),
});
}, [acceptedDropType, model, screenToFlowPosition]);

const onConnectEnd = useCallback(() => {
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
validationTimerRef.current = null;
Expand Down Expand Up @@ -1197,6 +1244,8 @@ function FlowInner({
onEdgeMouseEnter: wrap("onEdgeMouseEnter", onEdgeHover),
onEdgeMouseLeave: wrap("onEdgeMouseLeave", onEdgeHoverEnd),
onPaneClick: wrap("onPaneClick", onPaneClick),
onDragOver: wrap("onDragOver", onDragOver),
onDrop: wrap("onDrop", onDrop),
};
}, [
handleNodesChange,
Expand All @@ -1212,6 +1261,8 @@ function FlowInner({
onNodeContextMenu,
onNodeDoubleClick,
onNodesDelete,
onDragOver,
onDrop,
onPaneClick,
onSelectionChange,
reportError,
Expand Down Expand Up @@ -1266,6 +1317,7 @@ export function render({ model, view }) {
const [editable] = model.useState("editable");
const [editorMode] = model.useState("editor_mode");
const [valuePopupTrigger] = model.useState("popup_trigger");
const [dropTypes] = model.useState("drop_types");
const [hoverDelay] = model.useState("popup_hover_delay");
const [errorRecovery] = model.useState("error_recovery");
const [enableConnect] = model.useState("enable_connect");
Expand Down Expand Up @@ -1485,13 +1537,25 @@ export function render({ model, view }) {
});
}, [pyEdges]);

// A new component function makes React remount every node of that type,
// and re-attached Bokeh views stay blank. Reuse each type's component until
// its own spec or the popup settings change, so adding a type leaves the
// nodes of other types, and their embedded views, mounted.
const nodeComponentCache = useRef(new Map());
const hydratedNodeTypes = useMemo(() => {
const mapping = {};
const cache = nodeComponentCache.current;
const next = new Map();
Object.entries({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }).forEach(([typeName, spec]) => {
mapping[typeName] = makeNodeComponent(
typeName, spec, editorMode, model, valuePopupTrigger, hoverDelay,
);
const key = JSON.stringify([spec, editorMode, valuePopupTrigger, hoverDelay]);
const cached = cache.get(typeName);
const component = cached?.key === key
? cached.component
: makeNodeComponent(typeName, spec, editorMode, model, valuePopupTrigger, hoverDelay);
next.set(typeName, { key, component });
mapping[typeName] = component;
});
nodeComponentCache.current = next;
return mapping;
}, [editorMode, pyNodeTypes, model, valuePopupTrigger, hoverDelay]);

Expand Down Expand Up @@ -1590,6 +1654,7 @@ export function render({ model, view }) {
viewport={viewport}
valuePopupTrigger={valuePopupTrigger}
hoverDelay={hoverDelay}
dropTypes={dropTypes}
/>
);

Expand Down
28 changes: 28 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1854,3 +1854,31 @@ def test_remove_node_without_arguments_is_a_noop() -> None:
assert len(flow.nodes) == 5
assert len(flow.edges) == 4
assert messages == []


def test_drop_message_emits_drop_event() -> None:
flow = ReactFlow(drop_types=["application/x-test"])
received = []
flow.on("drop", lambda payload, flow: received.append((payload, flow)))
wildcard = []
flow.on("*", wildcard.append)

flow._handle_msg(
{
"type": "drop",
"drop_type": "application/x-test",
"data": {"path": [1]},
"position": {"x": 10.5, "y": 20},
"target": {"node_id": "n1", "handle_id": "in", "direction": "input"},
}
)

payload = {
"type": "drop",
"drop_type": "application/x-test",
"data": {"path": [1]},
"position": {"x": 10.5, "y": 20},
"target": {"node_id": "n1", "handle_id": "in", "direction": "input"},
}
assert received == [(payload, flow)]
assert wildcard == [payload]
Loading
Loading