diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e2c28b..94c0d67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,7 +135,7 @@ jobs: environment: ["test-ui"] timeout-minutes: 60 env: - PYTHONIOENCODING: utf8 + PYTHONUTF8: 1 PANEL_LOG_LEVEL: info FAIL: "--screenshot only-on-failure --full-page-screenshot --output ui_screenshots --tracing retain-on-failure" steps: diff --git a/docs/how-to/style-nodes-edges.md b/docs/how-to/style-nodes-edges.md index 8587da6..fe74a92 100644 --- a/docs/how-to/style-nodes-edges.md +++ b/docs/how-to/style-nodes-edges.md @@ -13,6 +13,97 @@ Python logic and lets you iterate on visuals without restarting the server. --- +## Complete runnable example + +This script is a minimal, working example that produces the visualization +shown above. + +```python +import panel as pn + +from panel_reactflow import EdgeType, NodeType, ReactFlow + +pn.extension("jsoneditor") + +TASK_NODE_CSS = """ +.react-flow__node-task { + border-radius: 8px; + border: 1.5px solid #7c3aed; + background: linear-gradient(168deg, #faf5ff 0%, #ffffff 60%); + box-shadow: 0 1px 3px rgba(124, 58, 237, 0.10); + min-width: 160px; + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} +.react-flow__node-task.selectable:hover { + border-color: #6d28d9; + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.18); + transform: translateY(-1px); +} +.react-flow__node-task.selected { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.25), + 0 4px 14px rgba(124, 58, 237, 0.15); +} +""" + +TYPED_EDGE_CSS = """ +.react-flow__edge-pipe .react-flow__edge-path { + stroke: #2563eb; + stroke-width: 2.5px; +} +.react-flow__edge-signal .react-flow__edge-path { + stroke: #dc2626; + stroke-width: 2px; + stroke-dasharray: 6 3; +} +.react-flow__edge-text { + fill: #475569; + font-size: 12px; +} +""" + +nodes = [ + {"id": "n1", "type": "task", "label": "Ingest", "position": {"x": 0, "y": 0}, "data": {"status": "idle"}}, + {"id": "n2", "type": "task", "label": "Transform", "position": {"x": 280, "y": 0}, "data": {"status": "running"}}, + {"id": "n3", "type": "task", "label": "Export", "position": {"x": 560, "y": 0}, "data": {"status": "done"}}, +] + +edges = [ + {"id": "e1", "source": "n1", "target": "n2", "type": "pipe", "label": "pipe", "data": {}}, + {"id": "e2", "source": "n2", "target": "n3", "type": "signal", "label": "signal", "data": {}}, +] + +task_schema = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["idle", "running", "done"]}, + }, +} + +flow = ReactFlow( + nodes=nodes, + edges=edges, + node_types={"task": NodeType(type="task", label="Task", schema=task_schema)}, + edge_types={ + "pipe": EdgeType(type="pipe", label="Pipe"), + "signal": EdgeType(type="signal", label="Signal"), + }, + stylesheets=[TASK_NODE_CSS, TYPED_EDGE_CSS], + sizing_mode="stretch_both", +) + +pn.Column(flow, sizing_mode="stretch_both").servable() +``` + +## How this code maps to the visualization + +- `nodes` and `edges` define the same three-node, two-edge layout. +- `.react-flow__node-task` styles all task nodes (background, border, shadow). +- `.react-flow__edge-pipe` and `.react-flow__edge-signal` style each edge type differently. +- `stylesheets=[TASK_NODE_CSS, TYPED_EDGE_CSS]` applies the custom CSS. + +--- + ## How CSS classes are assigned | Element | Class pattern | Example | @@ -120,6 +211,25 @@ flow = ReactFlow( --- +## Color mode and theme integration + +`ReactFlow` exposes a `color_mode` parameter (`"light"` or `"dark"`). If you +do not set it explicitly, it defaults to the current `pn.config.theme` when +the component is created. + +```python +flow = ReactFlow( + nodes=nodes, + edges=edges, + color_mode="dark", # optional override +) +``` + +When using `panel-material-ui`, theme toggles are reflected automatically, so +the graph updates along with the active light/dark theme. + +--- + ## Style selected elements The `.selected` class is added automatically when a user clicks a node or @@ -145,5 +255,7 @@ SELECTION_CSS = """ - Use CSS `transition` for smooth hover and selection effects. - Scope styles to a node or edge type to keep visuals consistent across instances of the same type. +- If you define a custom node type and do not provide `stylesheets`, nodes fall + back to the default node styling (`.react-flow__node-default`). - For rapid prototyping, define styles as inline Python strings. For production apps, move them to `.css` files and reference by path. diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index bb0af02..faed470 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1163,6 +1163,12 @@ class ReactFlow(ReactComponent): default_node_editor = param.Parameter(default=None, doc="Default node editor factory.", precedence=-1) default_edge_editor = param.Parameter(default=None, doc="Default edge editor factory.", precedence=-1) + color_mode = param.ObjectSelector(default=None, objects=["light", "dark"], doc="Color mode for the graph.") + + allow_edge_loops = param.Boolean(default=False, doc="Allow to have edge loops in the graph (can lead to update infinite loops).") + + display_side_bar = param.Boolean(default=True, doc="Display the side bar to drag and drop new nodes.") + debounce_ms = param.Integer(default=150, bounds=(0, None), doc="Debounce delay in milliseconds when sync_mode='debounce'.") default_edge_options = param.Dict(default={}, doc="Default React Flow edge options.") @@ -1211,6 +1217,8 @@ class ReactFlow(ReactComponent): _render_policy = "manual" def __init__(self, **params: Any): + if "color_mode" not in params: + params["color_mode"] = "dark" if pn.config.theme == "dark" else "light" self._node_ids: list[str] = [] self._edge_ids: list[str] = [] # Normalize type specs before parent init so the frontend receives diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css index 8392bdf..b911015 100644 --- a/src/panel_reactflow/dist/css/reactflow.css +++ b/src/panel_reactflow/dist/css/reactflow.css @@ -3,13 +3,17 @@ .react-flow__node-minimal { padding: 0; border-radius: 6px; - border: 1px solid var(--xy-node-border, #d0d7de); - background-color: var(--xy-node-background-color, #ffffff); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08); - color: var(--xy-node-color, #101828); + border: 1px solid var(--xy-node-border, var(--panel-border-color)); + background-color: var(--xy-node-background-color, var(--panel-background-color)); + box-shadow: 0 1px 2px var(--panel-shadow-color); + color: var(--xy-node-color, var(--panel-on-background-color)); font-size: 13px; min-width: 140px; } +.react-flow__node-default { + text-align: left; + width: unset; +} .react-flow__node-panel.selectable:hover, .react-flow__node-default.selectable:hover, .react-flow__node-minimal.selectable:hover { @@ -18,7 +22,7 @@ .react-flow__node-panel.selected, .react-flow__node-default.selected, .react-flow__node-minimal.selected { - border-color: var(--xy-node-border-selected, #2684ff); + border-color: var(--xy-node-border-selected, var(--panel-primary-color)); box-shadow: 0 0 0 1px rgba(38, 132, 255, 0.35); } @@ -30,7 +34,9 @@ } .rf-node-toolbar-button { - float: right; + position: absolute; + right: 0.5em; + top: 0.5em; border: none; background: transparent; font-size: 17px; @@ -38,13 +44,12 @@ cursor: pointer; z-index: 2; padding: 0; - margin: 0 0 0 6px; transition: color 0.1s; } .rf-node-toolbar-button--open { color: #3477db; - filter: drop-shadow(0 0 1px #3477db) brightness(1.15); + filter: drop-shadow(0 0 1px var(--panel-primary-color)) brightness(1.15); } .rf-node-toolbar-button--closed { diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index d87838d..54da2e7 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -97,7 +97,7 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { const applyFigureStyles = async () => { - const views = [...Bokeh.index.find_by_id(data.view.key)] + const views = [...Bokeh.index.find_by_id(data.view?.key)] if (!views.length) { return; } @@ -228,6 +228,7 @@ function FlowInner({ nodeTypes, nodeEditors, edgeEditors, + colorMode, editable, enableConnect, enableDelete, @@ -471,6 +472,7 @@ function FlowInner({ edges={edges} nodeTypes={nodeTypes} defaultEdgeOptions={defaultEdgeOptions} + colorMode={colorMode} onNodesChange={handleNodesChange} onEdgesChange={onEdgesChange} onSelectionChange={onSelectionChange} @@ -503,6 +505,7 @@ export function render({ model, view }) { const [defaultEdgeOptions] = model.useState("default_edge_options"); const [selection, setSelection] = model.useState("selection"); const [syncMode] = model.useState("sync_mode"); + const [colorMode] = model.useState("color_mode"); const [debounceMs] = model.useState("debounce_ms"); const [editable] = model.useState("editable"); const [editorMode] = model.useState("editor_mode"); @@ -618,8 +621,10 @@ export function render({ model, view }) { const typeSpec = allNodeTypes[node.type] || {}; const realKeys = Object.keys(data).filter((k) => k !== "view_idx"); const hasEditor = realKeys.length > 0 || !!typeSpec.schema; + console.log(node) return { ...node, + className: (node.type === "panel" || model.stylesheets) ? "" : "react-flow__node-default", data: { ...data, view: baseView, @@ -664,6 +669,7 @@ export function render({ model, view }) { views={views} viewportSetter={setViewport} defaultEdgeOptions={defaultEdgeOptions} + colorMode={colorMode} nodeTypes={hydratedNodeTypes} nodeEditors={nodeEditors} edgeEditors={edgeEditors}