diff --git a/docs/how-to/react-to-events.md b/docs/how-to/react-to-events.md index 1458d09..9ad485a 100644 --- a/docs/how-to/react-to-events.md +++ b/docs/how-to/react-to-events.md @@ -181,6 +181,20 @@ 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. +To accept files dragged in from the operating system, add `"Files"` to `drop_types`. The payload's `data` is then a list with one `{"name", "type", "size", "content"}` entry per file, each read as text: + +```python +flow = ReactFlow(drop_types=["Files"]) + +def on_file_drop(payload, flow): + for file in payload["data"]: + if file["name"].endswith(".json"): + graph = json.loads(file["content"]) + flow.param.update(nodes=graph["nodes"], edges=graph["edges"]) + +flow.on("drop", on_file_drop) +``` + --- ## Register multiple handlers diff --git a/docs/releases.md b/docs/releases.md index c65b0b7..cd3de08 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,13 @@ # Release Notes +## Version 0.5.1 + +A small enhancement release that lets the canvas accept files dragged in from the operating system. + +### Enhancements + +- **Drop files onto the canvas**: add `"Files"` to `drop_types` to accept files dragged in from the operating system. The `drop` event's `data` lists each file's `name`, `type`, `size` and text `content`, so a handler can, for example, load a graph saved as JSON. See [React to Events](how-to/react-to-events.md#handle-drops-onto-the-canvas) ([#79](https://github.com/panel-extensions/panel-reactflow/pull/79)). + ## Version 0.5.0 This release adds port and edge value inspection, connection checks diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 775c4f4..38ab6a3 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1589,8 +1589,9 @@ class ReactFlow(ReactComponent): 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 + ``drag_type`` of a draggable ``panel_material_ui.MenuList``. Include + ``"Files"`` to accept files dragged in from the operating system. + Each accepted drop emits a ``"drop"`` event; drops of other types are ignored, as are all drops while ``editable`` is False.""", ) @@ -3532,7 +3533,9 @@ def on(self, event_type: str, callback) -> None: - ``"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 + string; for ``"Files"`` a list of ``{"name", "type", "size", + "content"}`` with each file read as text), 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``. diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index b0ad287..2c2617a 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -124,6 +124,19 @@ function popupCloseDistance(event) { return Math.max(48, Math.min(frame?.width ?? 0, frame?.height ?? 0) * 0.1); } +// The DataTransfer type browsers report for files dragged in from the OS. +const FILES_DROP_TYPE = "Files"; + +// Files carry no string payload, so their contents have to be read explicitly. +function readDroppedFiles(files) { + return Promise.all(Array.from(files, async (file) => ({ + name: file.name, + type: file.type, + size: file.size, + content: await file.text(), + }))); +} + // 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"); @@ -842,25 +855,32 @@ function FlowInner({ event.dataTransfer.dropEffect = "copy"; }, [acceptedDropType]); - const onDrop = useCallback((event) => { + const onDrop = useCallback(async (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. + // The event is recycled once the handler yields, so read it before awaiting files. + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + const target = dropTarget(event.target); + let data; + if (dropType === FILES_DROP_TYPE) { + try { + data = await readDroppedFiles(event.dataTransfer.files); + } catch (error) { + reportError(error, null, { source: "handler", handler: "onDrop" }); + return; + } + } else { + const raw = event.dataTransfer.getData(dropType); + 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]); + model.send_msg({ type: "drop", drop_type: dropType, data, position, target }); + }, [acceptedDropType, model, reportError, screenToFlowPosition]); const onConnectEnd = useCallback(() => { if (validationTimerRef.current) clearTimeout(validationTimerRef.current); diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index b12db41..51b3373 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -1052,12 +1052,15 @@ def test_multi_select_delete_does_not_render_intermediate_graphs(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.""" +def _drop(locator, data_by_type, offset=None, files=()): + """Dispatch dragover and drop on *locator*, returning whether the drop was accepted. + + *files* are ``(name, mime_type, content)`` tuples added as OS files. + """ box = locator.bounding_box() x, y = offset or (box["width"] / 2, box["height"] / 2) return locator.evaluate( - """(el, [data, x, y]) => { + """(el, [data, files, x, y]) => { const rect = el.getBoundingClientRect() const init = (dt) => ({ dataTransfer: dt, bubbles: true, cancelable: true, composed: true, @@ -1065,20 +1068,21 @@ def _drop(locator, data_by_type, offset=None): }) const dt = new DataTransfer() for (const [type, value] of Object.entries(data)) dt.setData(type, value) + for (const [name, type, content] of files) dt.items.add(new File([content], name, { type })) 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], + [data_by_type, [list(f) for f in files], x, y], ) def _drop_flow(**params): + params.setdefault("drop_types", ["application/x-test"]) 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, @@ -1143,6 +1147,34 @@ def test_drop_on_node_reports_node_target(page): assert drops[0]["target"] == {"node_id": "n1", "handle_id": None, "direction": None} +def test_drop_of_files_sends_their_contents(page): + flow, drops = _drop_flow(drop_types=["Files"]) + serve_component(page, flow) + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + files = [("graph.json", "application/json", '{"nodes": []}'), ("notes.txt", "text/plain", "hi")] + assert _drop(pane, {}, files=files) + wait_until(lambda: len(drops) == 1, timeout=8000) + assert drops[0]["drop_type"] == "Files" + assert drops[0]["data"] == [ + {"name": "graph.json", "type": "application/json", "size": 13, "content": '{"nodes": []}'}, + {"name": "notes.txt", "type": "text/plain", "size": 2, "content": "hi"}, + ] + assert drops[0]["target"] is None + + +def test_file_drop_ignored_unless_files_accepted(page): + flow, drops = _drop_flow() + serve_component(page, flow) + pane = page.locator(".react-flow__pane") + expect(pane).to_be_visible() + + assert not _drop(pane, {}, files=[("graph.json", "application/json", "{}")]) + page.wait_for_timeout(300) + assert drops == [] + + def test_drop_ignored_when_not_editable(page): flow, drops = _drop_flow(editable=False) serve_component(page, flow)