From cada6058cdd4299e54bcc8f0490f1a88eeb1662b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 29 Sep 2026 00:20:15 +0200 Subject: [PATCH 1/2] feat: Accept drops onto the canvas and keep nodes mounted when node types change --- docs/how-to/react-to-events.md | 32 ++++ docs/releases.md | 13 ++ examples/drop_from_menu.py | 75 +++++++++ src/panel_reactflow/base.py | 28 ++++ src/panel_reactflow/models/reactflow.jsx | 73 ++++++++- tests/test_api.py | 28 ++++ tests/ui/test_ui.py | 192 +++++++++++++++++++++++ 7 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 examples/drop_from_menu.py diff --git a/docs/how-to/react-to-events.md b/docs/how-to/react-to-events.md index b3741a9..1458d09 100644 --- a/docs/how-to/react-to-events.md +++ b/docs/how-to/react-to-events.md @@ -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` | --- @@ -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 diff --git a/docs/releases.md b/docs/releases.md index 9c68522..0b11af0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -63,8 +63,21 @@ 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). + ### 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. + - **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 diff --git a/examples/drop_from_menu.py b/examples/drop_from_menu.py new file mode 100644 index 0000000..8c099d0 --- /dev/null +++ b/examples/drop_from_menu.py @@ -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() diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index c5b7523..775c4f4 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -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"], @@ -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") @@ -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 diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index b7abf7a..b0ad287 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -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; @@ -751,6 +765,7 @@ function FlowInner({ viewport, valuePopupTrigger, hoverDelay, + dropTypes, }) { const [nodes, setNodes, onNodesChange] = useNodesState(hydratedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges); @@ -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); @@ -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; @@ -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, @@ -1212,6 +1261,8 @@ function FlowInner({ onNodeContextMenu, onNodeDoubleClick, onNodesDelete, + onDragOver, + onDrop, onPaneClick, onSelectionChange, reportError, @@ -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"); @@ -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]); @@ -1590,6 +1654,7 @@ export function render({ model, view }) { viewport={viewport} valuePopupTrigger={valuePopupTrigger} hoverDelay={hoverDelay} + dropTypes={dropTypes} /> ); diff --git a/tests/test_api.py b/tests/test_api.py index b9a54cc..9e952f5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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] diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 8df8f30..b12db41 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -1050,3 +1050,195 @@ def test_multi_select_delete_does_not_render_intermediate_graphs(page): expect(page.locator(".react-flow__node")).to_have_count(2) counts = _recorded_node_counts(page) assert set(counts) <= {6, 2}, f"intermediate graphs were rendered: {counts}" + + +def _drop(locator, data_by_type, offset=None): + """Dispatch dragover and drop on *locator*, returning whether the drop was accepted.""" + box = locator.bounding_box() + x, y = offset or (box["width"] / 2, box["height"] / 2) + return locator.evaluate( + """(el, [data, x, y]) => { + const rect = el.getBoundingClientRect() + const init = (dt) => ({ + dataTransfer: dt, bubbles: true, cancelable: true, composed: true, + clientX: rect.left + x, clientY: rect.top + y, + }) + const dt = new DataTransfer() + for (const [type, value] of Object.entries(data)) dt.setData(type, value) + const over = new DragEvent("dragover", init(dt)) + el.dispatchEvent(over) + el.dispatchEvent(new DragEvent("drop", init(dt))) + return over.defaultPrevented + }""", + [data_by_type, x, y], + ) + + +def _drop_flow(**params): + flow = ReactFlow( + nodes=[NodeSpec(id="n1", type="step", label="Step", position={"x": 0, "y": 0}).to_dict()], + node_types={"step": NodeType(type="step", inputs=["in"], outputs=["out"])}, + drop_types=["application/x-test"], + width=600, + height=400, + **params, + ) + drops = [] + flow.on("drop", drops.append) + return flow, drops + + +def test_drop_on_pane_emits_payload_and_flow_position(page): + flow, drops = _drop_flow() + serve_component(page, flow) + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + assert _drop(pane, {"application/x-test": '{"path": [0, 2]}'}, offset=(20, 30)) + wait_until(lambda: len(drops) == 1, timeout=8000) + drop = drops[0] + assert drop["drop_type"] == "application/x-test" + assert drop["data"] == {"path": [0, 2]} + assert drop["target"] is None + + viewport = flow.viewport or {"x": 0, "y": 0, "zoom": 1} + zoom = viewport.get("zoom", 1) + expected_x = (20 - viewport.get("x", 0)) / zoom + expected_y = (30 - viewport.get("y", 0)) / zoom + assert drop["position"]["x"] == pytest.approx(expected_x, abs=2) + assert drop["position"]["y"] == pytest.approx(expected_y, abs=2) + + +def test_drop_of_unaccepted_type_is_ignored(page): + flow, drops = _drop_flow() + serve_component(page, flow) + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + assert not _drop(pane, {"text/plain": "hello"}) + page.wait_for_timeout(300) + assert drops == [] + + +def test_drop_on_handle_reports_target(page): + flow, drops = _drop_flow() + serve_component(page, flow) + handle = page.locator(".react-flow__handle-left") + expect(handle).to_be_visible() + + assert _drop(handle, {"application/x-test": "plain"}) + wait_until(lambda: len(drops) == 1, timeout=8000) + assert drops[0]["data"] == "plain" + assert drops[0]["target"] == {"node_id": "n1", "handle_id": "in", "direction": "input"} + + +def test_drop_on_node_reports_node_target(page): + flow, drops = _drop_flow() + serve_component(page, flow) + node = page.locator(".react-flow__node") + expect(node).to_be_visible() + + assert _drop(node, {"application/x-test": "{}"}) + wait_until(lambda: len(drops) == 1, timeout=8000) + assert drops[0]["target"] == {"node_id": "n1", "handle_id": None, "direction": None} + + +def test_drop_ignored_when_not_editable(page): + flow, drops = _drop_flow(editable=False) + serve_component(page, flow) + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + assert not _drop(pane, {"application/x-test": "{}"}) + page.wait_for_timeout(300) + assert drops == [] + + +def test_drop_from_menu_demo(page): + pmui = pytest.importorskip("panel_material_ui") + if "draggable" not in pmui.MenuList.param: + pytest.skip("panel-material-ui MenuList does not support draggable items") + namespace = runpy.run_path(str(Path(__file__).resolve().parents[2] / "examples" / "drop_from_menu.py")) + flow = namespace["flow"] + serve_component(page, namespace["demo"]) + + items = page.locator(".MuiListItemButton-root") + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + items.filter(has_text="CSV file").drag_to(pane, target_position={"x": 80, "y": 80}) + wait_until(lambda: [n["label"] for n in flow.nodes] == ["Sink", "CSV file"], timeout=8000) + + items.filter(has_text="Database").drag_to(page.locator(".react-flow__node[data-id='sink'] .react-flow__handle-left")) + wait_until(lambda: len(flow.edges) == 1, timeout=8000) + database = next(n for n in flow.nodes if n["label"] == "Database") + assert flow.edges[0]["source"] == database["id"] + assert flow.edges[0]["target"] == "sink" + assert database["position"]["x"] < 400 + + items.filter(has_text="Sources").first.drag_to(pane, target_position={"x": 200, "y": 300}) + page.wait_for_timeout(500) + assert len(flow.nodes) == 3 + + +# Canvases inside an element, including its descendants' shadow roots, where Bokeh renders. +_DEEP_CANVASES = """el => { + const count = (node) => { + let n = node.tagName === "CANVAS" ? 1 : 0 + if (node.shadowRoot) n += count(node.shadowRoot) + for (const child of node.children || []) n += count(child) + return n + } + return count(el) +}""" + + +def test_adding_node_type_keeps_other_nodes_mounted(page): + from bokeh.plotting import figure + + fig = figure(width=200, height=120) + fig.line([0, 1, 2], [0, 1, 0]) + flow = ReactFlow( + nodes=[ + { + "id": "n1", + "type": "step", + "label": "Step", + "position": {"x": 0, "y": 0}, + "data": {}, + "view": pn.pane.Bokeh(fig), + } + ], + node_types={"step": NodeType(type="step", inputs=["in"], outputs=["out"])}, + width=700, + height=400, + ) + + # Mutate from a server callback, the way lazily loaded component types arrive. + def add_type(_): + flow.node_types = {**flow.node_types, "other": NodeType(type="other", inputs=["in"])} + flow.add_node( + { + "id": "n2", + "type": "other", + "label": "Other", + "position": {"x": 350, "y": 0}, + "data": {}, + "view": pn.pane.Markdown("OTHER-VIEW"), + } + ) + + button = pn.widgets.Button(label="Add type") + button.on_click(add_type) + serve_component(page, pn.Column(button, flow)) + + wrapper = page.locator(".react-flow__node[data-id='n1'] .rf-node-view-wrapper") + wait_until(lambda: wrapper.evaluate(_DEEP_CANVASES) > 0, timeout=8000) + wrapper.evaluate("el => { el.__marker = true }") + + page.get_by_role("button", name="Add type").click() + expect(page.locator(".react-flow__node[data-id='n2']", has_text="OTHER-VIEW")).to_have_count(1) + + # The same element proves the node was not remounted, so its view was never detached. + assert wrapper.evaluate("el => el.__marker === true") + assert wrapper.evaluate(_DEEP_CANVASES) > 0 From bf94e8c414270f665ddd2fd18fefac801b8e058c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 29 Sep 2026 00:20:56 +0200 Subject: [PATCH 2/2] docs: Link release notes to #77 --- docs/releases.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 0b11af0..a71e687 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -68,7 +68,8 @@ an error boundary keeps a malformed graph from blanking the canvas. 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). + [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 @@ -76,7 +77,8 @@ an error boundary keeps a malformed graph from blanking the canvas. `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. + 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