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
14 changes: 14 additions & 0 deletions docs/how-to/react-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.""",
)

Expand Down Expand Up @@ -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``.
Expand Down
50 changes: 35 additions & 15 deletions src/panel_reactflow/models/reactflow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 37 additions & 5 deletions tests/ui/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1052,33 +1052,37 @@ 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,
clientX: rect.left + x, clientY: rect.top + y,
})
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,
Expand Down Expand Up @@ -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)
Expand Down
Loading