Save, Restore & Export
Persist and restore flow state, export to PNG, and copy/paste.
Overview
Serialize the whole graph with exportFlowState / flowState and rebuild it with restoreFlowState. Export the canvas to an image via downloadImage, and duplicate elements with copyAction / pasteAction.
Live demo
Drag the nodes, pan, and zoom — the canvas below is a real, running DashFlows component (no callbacks, just the rendered graph):
# File: docs/persistence/demo.py
"""Live, callback-free demo for the docs page. Rendered via `.. exec::docs.persistence.demo`."""
import dash_flows
nodes = [{'id': 'load', 'type': 'input', 'data': {'label': 'Load'}, 'position': {'x': 120, 'y': 40}},
{'id': 'edit', 'type': 'default', 'data': {'label': 'Edit'}, 'position': {'x': 120, 'y': 180}},
{'id': 'save', 'type': 'output', 'data': {'label': 'Save'}, 'position': {'x': 120, 'y': 320}}]
edges = [{'id': 'le', 'source': 'load', 'target': 'edit', 'animated': True},
{'id': 'es', 'source': 'edit', 'target': 'save'}]
component = dash_flows.DashFlows(
id="persistence-demo",
nodes=nodes,
edges=edges,
style={'border': '1px solid var(--mantine-color-default-border)',
'borderRadius': '8px',
'height': '440px'},
showControls=True,
showMiniMap=True,
fitView=True,
colorMode='system',
)
:defaultExpanded: false :withExpandedButton: true
Examples
Each example below is a complete, runnable Dash app from the examples/ folder. Run any of them with python examples/<file>.
Save & Restore
Export the current flow to JSON and restore it later, plus drive the viewport (fit, zoom, focus) from callbacks. Use this pattern for a "save my diagram" button or a session-restore flow.
# File: examples/15_save_restore.py
"""
Example 15: Save and Restore Flow State
=======================================
This example demonstrates how to:
- Export the current flow state to JSON
- Save the state to localStorage or download as file
- Restore a previously saved flow state
- Use viewport actions for programmatic control
"""
import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows
import json
app = dash.Dash(__name__)
# Initial nodes
initial_nodes = [
{
"id": "node-1",
"type": "input",
"data": {"label": "Data Source"},
"position": {"x": 100, "y": 100},
},
{
"id": "node-2",
"type": "default",
"data": {"label": "Transform"},
"position": {"x": 100, "y": 250},
},
{
"id": "node-3",
"type": "default",
"data": {"label": "Process"},
"position": {"x": 300, "y": 250},
},
{
"id": "node-4",
"type": "output",
"data": {"label": "Output"},
"position": {"x": 200, "y": 400},
},
]
# Initial edges
initial_edges = [
{"id": "e1-2", "source": "node-1", "target": "node-2"},
{"id": "e1-3", "source": "node-1", "target": "node-3"},
{"id": "e2-4", "source": "node-2", "target": "node-4"},
{"id": "e3-4", "source": "node-3", "target": "node-4"},
]
app.layout = html.Div([
html.H1("Save and Restore Flow State"),
html.P("Drag nodes around, add connections, then save or restore the flow state."),
# Control buttons
html.Div([
html.Button("Export State", id="export-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "10px 20px"}),
html.Button("Download JSON", id="download-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "10px 20px"}),
html.Button("Restore from Saved", id="restore-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "10px 20px"}),
html.Button("Reset to Initial", id="reset-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "10px 20px"}),
], style={"marginBottom": "10px"}),
# Viewport action buttons
html.Div([
html.Button("Fit View", id="fit-view-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "8px 16px"}),
html.Button("Zoom In", id="zoom-in-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "8px 16px"}),
html.Button("Zoom Out", id="zoom-out-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "8px 16px"}),
html.Button("Focus Node 1", id="focus-node-btn", n_clicks=0,
style={"marginRight": "10px", "padding": "8px 16px"}),
], style={"marginBottom": "20px"}),
# Download component
dcc.Download(id="download-json"),
# Hidden store for saved state
dcc.Store(id="saved-state-store"),
# Status display
html.Div(id="status-display", style={
"padding": "10px",
"marginBottom": "10px",
"background": "#f0f0f0",
"borderRadius": "5px",
}),
# Flow component
dash_flows.DashFlows(
id="flow",
nodes=initial_nodes,
edges=initial_edges,
style={"height": "500px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
showMiniMap=True,
showBackground=True,
),
# JSON preview
html.H3("Exported State Preview:"),
html.Pre(id="state-preview", style={
"padding": "15px",
"background": "#2d2d2d",
"color": "#a6e22e",
"borderRadius": "5px",
"maxHeight": "300px",
"overflow": "auto",
"fontSize": "12px",
}),
])
# Export state callback
@callback(
Output("flow", "exportFlowState"),
Input("export-btn", "n_clicks"),
prevent_initial_call=True,
)
def trigger_export(n_clicks):
return True
# Handle exported state
@callback(
[Output("saved-state-store", "data"),
Output("state-preview", "children"),
Output("status-display", "children")],
Input("flow", "flowState"),
prevent_initial_call=True,
)
def handle_export(flow_state):
if flow_state:
json_str = json.dumps(flow_state, indent=2)
return (
flow_state,
json_str,
f"State exported! {len(flow_state.get('nodes', []))} nodes, {len(flow_state.get('edges', []))} edges"
)
return dash.no_update, dash.no_update, dash.no_update
# Download JSON callback
@callback(
Output("download-json", "data"),
Input("download-btn", "n_clicks"),
State("saved-state-store", "data"),
prevent_initial_call=True,
)
def download_json(n_clicks, saved_state):
if saved_state:
return dict(
content=json.dumps(saved_state, indent=2),
filename="flow_state.json",
)
return None
# Restore from saved state
@callback(
Output("flow", "restoreFlowState"),
Input("restore-btn", "n_clicks"),
State("saved-state-store", "data"),
prevent_initial_call=True,
)
def restore_state(n_clicks, saved_state):
if saved_state:
return saved_state
return None
# Reset to initial state
@callback(
Output("flow", "restoreFlowState", allow_duplicate=True),
Input("reset-btn", "n_clicks"),
prevent_initial_call=True,
)
def reset_state(n_clicks):
return {
"nodes": initial_nodes,
"edges": initial_edges,
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
# Viewport action callbacks
@callback(
Output("flow", "viewportAction"),
[Input("fit-view-btn", "n_clicks"),
Input("zoom-in-btn", "n_clicks"),
Input("zoom-out-btn", "n_clicks"),
Input("focus-node-btn", "n_clicks")],
prevent_initial_call=True,
)
def handle_viewport_action(fit_clicks, zoom_in_clicks, zoom_out_clicks, focus_clicks):
ctx = dash.callback_context
if not ctx.triggered:
return None
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "fit-view-btn":
return {"action": "fitView", "options": {"padding": 0.2, "duration": 500}}
elif button_id == "zoom-in-btn":
return {"action": "zoomIn", "options": {"duration": 300}}
elif button_id == "zoom-out-btn":
return {"action": "zoomOut", "options": {"duration": 300}}
elif button_id == "focus-node-btn":
return {"action": "focusNode", "nodeId": "node-1", "zoom": 2, "duration": 800}
return None
if __name__ == "__main__":
app.run(debug=True, port=8085)
:defaultExpanded: false :withExpandedButton: true
How it works
exportFlowState=True— asks the flow to serialize its current nodes, edges, and viewport; the result comes back onflowState.Input("ex15-flow", "flowState")— fires once export completes, so you can stash it in adcc.Storeand preview it as JSON.restoreFlowState={"nodes": ..., "edges": ..., "viewport": ...}— rebuilds the flow from a previously saved (or hand-built) state object.Output(..., allow_duplicate=True)— required whenever a second callback also targetsrestoreFlowState, here used by the "Reset to Initial" button.viewportAction={"action": "fitView" | "zoomIn" | "zoomOut" | "focusNode", "options": {...}}— triggers programmatic viewport moves without touching nodes or edges.
Export Image
Download the flow as a PNG, SVG, or JPEG using html-to-image, with options for background color, quality, and resolution.
# File: examples/18_export_image.py
"""
Example 18: Export Flow as Image
================================
This example demonstrates how to:
- Export the flow as PNG, SVG, or JPEG
- Configure export options (quality, background, resolution)
- Track when downloads complete
"""
import dash
from dash import html, callback, Input, Output, State
import dash_flows
app = dash.Dash(__name__)
# Sample nodes
nodes = [
{
"id": "node-1",
"type": "input",
"data": {"label": "Data Input"},
"position": {"x": 100, "y": 50},
},
{
"id": "node-2",
"type": "default",
"data": {"label": "Process"},
"position": {"x": 100, "y": 200},
},
{
"id": "node-3",
"type": "default",
"data": {"label": "Transform"},
"position": {"x": 300, "y": 200},
},
{
"id": "node-4",
"type": "output",
"data": {"label": "Output"},
"position": {"x": 200, "y": 350},
},
]
edges = [
{"id": "e1-2", "source": "node-1", "target": "node-2", "animated": True},
{"id": "e1-3", "source": "node-1", "target": "node-3"},
{"id": "e2-4", "source": "node-2", "target": "node-4", "label": "Result"},
{"id": "e3-4", "source": "node-3", "target": "node-4"},
]
button_style = {
"padding": "10px 20px",
"marginRight": "10px",
"borderRadius": "6px",
"border": "none",
"cursor": "pointer",
"fontWeight": "500",
}
app.layout = html.Div([
html.H1("Export Flow as Image"),
html.P("Click a button below to export the flow as an image file."),
# Export buttons
html.Div([
html.Button(
"Download PNG",
id="btn-png",
n_clicks=0,
style={**button_style, "background": "#4CAF50", "color": "white"}
),
html.Button(
"Download SVG",
id="btn-svg",
n_clicks=0,
style={**button_style, "background": "#2196F3", "color": "white"}
),
html.Button(
"Download JPEG",
id="btn-jpeg",
n_clicks=0,
style={**button_style, "background": "#FF9800", "color": "white"}
),
html.Button(
"High-Res PNG (4x)",
id="btn-hires",
n_clicks=0,
style={**button_style, "background": "#9C27B0", "color": "white"}
),
html.Button(
"Transparent PNG",
id="btn-transparent",
n_clicks=0,
style={**button_style, "background": "#607D8B", "color": "white"}
),
], style={"marginBottom": "20px"}),
# Status display
html.Div(id="export-status", style={
"padding": "10px",
"marginBottom": "10px",
"background": "#f0f0f0",
"borderRadius": "5px",
}, children="Click a button to export the flow"),
# Flow component
dash_flows.DashFlows(
id="flow",
nodes=nodes,
edges=edges,
style={"height": "500px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
showMiniMap=True,
showBackground=True,
backgroundVariant="dots",
),
])
# Handle export button clicks
@callback(
Output("flow", "downloadImage"),
[Input("btn-png", "n_clicks"),
Input("btn-svg", "n_clicks"),
Input("btn-jpeg", "n_clicks"),
Input("btn-hires", "n_clicks"),
Input("btn-transparent", "n_clicks")],
prevent_initial_call=True,
)
def handle_export(png_clicks, svg_clicks, jpeg_clicks, hires_clicks, transparent_clicks):
ctx = dash.callback_context
if not ctx.triggered:
return None
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "btn-png":
return {
"format": "png",
"filename": "flow_diagram",
"backgroundColor": "#ffffff",
}
elif button_id == "btn-svg":
return {
"format": "svg",
"filename": "flow_vector",
"backgroundColor": "#ffffff",
}
elif button_id == "btn-jpeg":
return {
"format": "jpeg",
"filename": "flow_compressed",
"quality": 0.8,
"backgroundColor": "#ffffff",
}
elif button_id == "btn-hires":
return {
"format": "png",
"filename": "flow_highres",
"pixelRatio": 4,
"backgroundColor": "#ffffff",
}
elif button_id == "btn-transparent":
return {
"format": "png",
"filename": "flow_transparent",
"backgroundColor": "transparent",
}
return None
# Show status when download completes
@callback(
Output("export-status", "children"),
[Input("flow", "imageDownloaded"),
Input("flow", "lastError")],
prevent_initial_call=True,
)
def update_status(downloaded, error):
ctx = dash.callback_context
if not ctx.triggered:
return dash.no_update
trigger = ctx.triggered[0]["prop_id"]
if "imageDownloaded" in trigger and downloaded:
return f"Downloaded: {downloaded.get('filename', 'unknown')} ({downloaded.get('format', '').upper()})"
elif "lastError" in trigger and error:
if error.get("type") == "image-export":
return f"Export failed: {error.get('message', 'Unknown error')}"
return dash.no_update
if __name__ == "__main__":
app.run(debug=True, port=8088)
:defaultExpanded: false :withExpandedButton: true
How it works
downloadImage={"format": "png" | "svg" | "jpeg", "filename": ..., "backgroundColor": ...}— triggers a browser download of the rendered canvas.pixelRatio— renders at a higher resolution (e.g.4for a 4x PNG) without changing the on-screen layout.backgroundColor="transparent"— exports a PNG with no background fill, useful for overlaying on other content.Input("ex18-flow", "imageDownloaded")— fires with the filename and format once the export succeeds.Input("ex18-flow", "lastError")— reports export failures (checked here fortype == "image-export").
Copy & Paste
Copy selected nodes and edges to an internal clipboard, then paste them with a position offset — wired to both buttons and Ctrl/Cmd+C / Ctrl/Cmd+V keyboard shortcuts.
# File: examples/19_copy_paste.py
"""
Example 19: Copy and Paste
==========================
This example demonstrates how to:
- Copy selected nodes and edges
- Paste with position offset
- Use keyboard shortcuts for copy/paste
"""
import dash
from dash import html, callback, Input, Output, State
import dash_flows
app = dash.Dash(__name__)
# Initial nodes
initial_nodes = [
{
"id": "node-1",
"type": "input",
"data": {"label": "Source A"},
"position": {"x": 100, "y": 100},
},
{
"id": "node-2",
"type": "default",
"data": {"label": "Process"},
"position": {"x": 100, "y": 250},
},
{
"id": "node-3",
"type": "output",
"data": {"label": "Output"},
"position": {"x": 100, "y": 400},
},
]
initial_edges = [
{"id": "e1-2", "source": "node-1", "target": "node-2"},
{"id": "e2-3", "source": "node-2", "target": "node-3"},
]
button_style = {
"padding": "10px 20px",
"marginRight": "10px",
"borderRadius": "6px",
"border": "none",
"cursor": "pointer",
"fontWeight": "500",
"fontSize": "14px",
}
app.layout = html.Div([
html.H1("Copy and Paste Example"),
html.P([
"Select nodes (click or shift+drag), then use the buttons or keyboard shortcuts to copy/paste.",
html.Br(),
"Tip: Hold Shift and drag to select multiple nodes."
]),
# Control buttons
html.Div([
html.Button(
"Copy Selected (Ctrl+C)",
id="btn-copy",
n_clicks=0,
style={**button_style, "background": "#4CAF50", "color": "white"}
),
html.Button(
"Paste (Ctrl+V)",
id="btn-paste",
n_clicks=0,
style={**button_style, "background": "#2196F3", "color": "white"}
),
html.Button(
"Copy All",
id="btn-copy-all",
n_clicks=0,
style={**button_style, "background": "#FF9800", "color": "white"}
),
], style={"marginBottom": "15px"}),
# Status display
html.Div(id="status", style={
"padding": "10px",
"marginBottom": "10px",
"background": "#f0f0f0",
"borderRadius": "5px",
}, children="Select nodes and use Copy/Paste"),
# Flow component
dash_flows.DashFlows(
id="flow",
nodes=initial_nodes,
edges=initial_edges,
style={"height": "500px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
showMiniMap=True,
showBackground=True,
selectionOnDrag=True, # Enable drag selection
),
])
# Copy button handler
@callback(
Output("flow", "copyAction"),
Input("btn-copy", "n_clicks"),
prevent_initial_call=True,
)
def handle_copy(n_clicks):
return True
# Copy all button handler
@callback(
Output("flow", "copyAction", allow_duplicate=True),
Input("btn-copy-all", "n_clicks"),
State("flow", "nodes"),
prevent_initial_call=True,
)
def handle_copy_all(n_clicks, nodes):
# Deselect all first (to trigger copy all behavior)
return True
# Paste button handler
@callback(
Output("flow", "pasteAction"),
Input("btn-paste", "n_clicks"),
prevent_initial_call=True,
)
def handle_paste(n_clicks):
return {"offset": {"x": 100, "y": 50}}
# Update status based on clipboard and paste events
@callback(
Output("status", "children"),
[Input("flow", "clipboard"),
Input("flow", "pastedElements"),
Input("flow", "selectedNodes")],
)
def update_status(clipboard, pasted, selected):
status_parts = []
if selected:
status_parts.append(f"Selected: {len(selected)} node(s)")
else:
status_parts.append("No nodes selected")
if clipboard and clipboard.get("nodes"):
status_parts.append(f" | Clipboard: {len(clipboard['nodes'])} node(s)")
if pasted and pasted.get("nodeIds"):
status_parts.append(f" | Just pasted: {len(pasted['nodeIds'])} node(s)")
return "".join(status_parts) if status_parts else "Select nodes and use Copy/Paste"
# Client-side callback for keyboard shortcuts
app.clientside_callback(
"""
function(id) {
document.addEventListener('keydown', function(e) {
// Ctrl+C or Cmd+C
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) {
// Don't interfere with normal copy in text inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
document.getElementById('btn-copy').click();
}
// Ctrl+V or Cmd+V
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
document.getElementById('btn-paste').click();
}
});
return window.dash_clientside.no_update;
}
""",
Output("status", "data-keyboard"),
Input("status", "id"),
)
if __name__ == "__main__":
app.run(debug=True, port=8089)
:defaultExpanded: false :withExpandedButton: true
How it works
copyAction=True— copies the currently selected nodes/edges (or everything, if nothing is selected) into the flow's internalclipboard.pasteAction={"offset": {"x": 100, "y": 50}}— pastes the clipboard contents, shifting the new copies so they don't sit exactly on top of the originals.selectionOnDrag=True— lets a shift+drag rectangle select multiple nodes at once, so Copy Selected has something to act on.Input("ex19-flow", "clipboard")/Input("ex19-flow", "pastedElements")— report clipboard contents and the ids of the most recently pasted elements.clientside_callback(...)— listens for Ctrl/Cmd+C and Ctrl/Cmd+V at the document level and clicks the copy/paste buttons, so keyboard shortcuts work without a server round trip.
Source: /persistence
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /persistence/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt