Command dispatch
Call Excalidraw's imperative API from Python through a JSON-safe command prop dispatched once per id.
Overview
Every imperative API the upstream Excalidraw exposes is callable from Python as a command prop: a dict with id, type, and payload. The component dispatches once per unique id, then clears the prop so React re-renders don't re-fire.
The twelve command types
type | Payload | Notes |
|---|---|---|
updateScene | {elements?, appState?, collaborators?, captureUpdate?} | See history, below |
addFiles | A list of {id, mimeType, dataURL, created} | Raw passthrough |
replaceFiles | A map {fileId: {dataURL, mimeType?}} | Stores the new bytes under a fresh id and repoints the elements |
resetScene | {} | |
scrollToContent | {target?, opts?} | |
setActiveTool | {type: "selection"}, {type: "rectangle"}, … | Defaults to selection |
setToast | {message, duration?}, or None to clear | |
toggleSidebar | {name, tab?, force?} | name is the sidebar — Excalidraw's built-in one is "default"; "library" and "search" are tabs inside it. Naming a tab as the sidebar does nothing at all. |
updateLibrary | {libraryItems, merge?} | |
exportToSvg | Export options | Replies on lastExport |
exportToBlob | Export options, {mimeType?} | Replies on lastExport |
exportToCanvas | Export options, {mimeType?} | Replies on lastExport |
addFiles and replaceFiles both reach Excalidraw's addFiles, but they are not interchangeable. Use replaceFiles to swap an existing file's bytes — it takes the id-keyed map, fills created and mimeType for you, and skips malformed entries; that is the shape File uploads uses to trade base64 for URLs.
It does not overwrite, because Excalidraw cannot. api.addFiles with an id the store already holds is a silent no-op — measured in a browser: add x inline, add x again with a URL, read getFiles(), and it is still the inline one. There is no removeFiles, and updateScene({files}) is ignored. So replaceFiles stores the new bytes under a new id and repoints every element that referenced the old one. The orphaned entry cannot be deleted, but nothing references it and externalizedSerializedData strips it anyway.
Until 2026-09-12 this command claimed the overwrite and silently did nothing, which is why /file-uploads' GIF auto-embed never fired: that page waits for a file whose dataURL has become a URL, and there never was one. Use addFiles only when you need the raw Excalidraw BinaryFileData list, including fields replaceFiles would drop. It is a straight passthrough, so the list you send is the list Excalidraw receives: you supply created and mimeType yourself, and an entry missing either is not an error — Excalidraw simply ignores it, so a malformed addFiles payload is a silent no-op rather than a visible failure.
# replaceFiles — id-keyed map; created/mimeType filled for you
{"id": cmd_id, "type": "replaceFiles",
"payload": {file_id: {"dataURL": "https://cdn.example.com/a.png"}}}
# addFiles — raw BinaryFileData list; every field is yours to supply
{"id": cmd_id, "type": "addFiles",
"payload": [{"id": file_id, "mimeType": "image/png",
"dataURL": "data:image/png;base64,…",
"created": 1757376000000}]}
Scene pushes and undo history
Excalidraw 0.18 replaced commitToHistory with captureUpdate, and changed what the default means. 0.17 left undo history untouched when no flag was given; 0.18 defaults to folding a programmatic push into the next captured action, so a user's first Ctrl+Z after your updateScene would also roll back their own previous edit. Nothing errors and nothing warns.
This wrapper therefore defaults captureUpdate to IMMEDIATELY: a dispatched scene push is a deliberate edit and undoes as one discrete step. Override it per command:
{
"id": str(uuid.uuid4()),
"type": "updateScene",
"payload": {
"elements": elements,
"captureUpdate": "NEVER", # or "IMMEDIATELY" (default), "EVENTUALLY"
},
}
captureUpdate | Effect on undo history |
|---|---|
IMMEDIATELY | (default) The push is its own undo step |
NEVER | The push is not recorded; Ctrl+Z skips past it |
EVENTUALLY | Folded into the next captured action — Excalidraw's own 0.18 default |
Two guardrails worth knowing. Passing the removed commitToHistory still works: it is translated (True → IMMEDIATELY, False → NEVER) and logs a one-time console warning telling you to switch. And an unrecognised captureUpdate value is not accepted silently — it warns and falls back to IMMEDIATELY, because a typo that looked like it took effect is the worse failure.
Live demo
Source
# File: docs/commands/commands.py
"""Command dispatch: drive Excalidraw imperatively from Python."""
from __future__ import annotations
import time
import uuid
import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, no_update
from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme
sync_canvas_theme("commands-canvas")
CODE = """
@callback(
Output('canvas', 'command'),
Input('btn-rect', 'n_clicks'),
prevent_initial_call=True,
)
def pick_rectangle(_):
return {
'id': str(uuid.uuid4()),
'type': 'setActiveTool',
'payload': {'type': 'rectangle'},
}
"""
def _btn(btn_id: str, label: str, color: str = "indigo") -> dmc.Button:
return dmc.Button(label, id=btn_id, color=color, variant="light", size="sm")
component = dmc.Stack(
gap="md",
children=[
code_block(CODE),
dmc.Paper(
withBorder=True,
p="md",
radius="md",
children=dmc.Stack(
gap="sm",
children=[
dmc.Text("Scene", size="sm", fw=600, c="dimmed"),
dmc.Group(
[
_btn("cmd-update", "updateScene (seed)"),
_btn("cmd-reset", "resetScene", color="red"),
_btn("cmd-scrollto", "scrollToContent"),
]
),
dmc.Text("Tools", size="sm", fw=600, c="dimmed"),
dmc.Group(
[
_btn("cmd-tool-rect", "setActiveTool: rectangle"),
_btn("cmd-tool-arrow", "setActiveTool: arrow"),
_btn("cmd-tool-select", "setActiveTool: selection"),
]
),
dmc.Text("UI", size="sm", fw=600, c="dimmed"),
dmc.Group(
[
_btn("cmd-toast", "setToast"),
_btn("cmd-sidebar", "toggleSidebar"),
]
),
],
),
),
canvas_frame(
DashExcalidraw(
id="commands-canvas",
height="600px",
)
),
],
)
def _cmd(type_: str, payload: dict | None = None) -> dict:
return {"id": f"{type_}-{uuid.uuid4()}", "type": type_, "payload": payload or {}}
@callback(
Output("commands-canvas", "command"),
Input("cmd-update", "n_clicks"),
Input("cmd-reset", "n_clicks"),
Input("cmd-scrollto", "n_clicks"),
Input("cmd-tool-rect", "n_clicks"),
Input("cmd-tool-arrow", "n_clicks"),
Input("cmd-tool-select", "n_clicks"),
Input("cmd-toast", "n_clicks"),
Input("cmd-sidebar", "n_clicks"),
prevent_initial_call=True,
)
def _dispatch(update_, reset_, scrollto_, rect_, arrow_, sel_, toast_, sidebar_):
trigger = dash.ctx.triggered_id
if trigger == "cmd-update":
return _cmd(
"updateScene",
{
"elements": [
{
"id": f"seeded-{int(time.time())}",
"type": "ellipse",
"x": 200,
"y": 200,
"width": 300,
"height": 180,
"angle": 0,
"strokeColor": "#e67700",
"backgroundColor": "#fff4e6",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"seed": 42,
"version": 1,
"versionNonce": 42,
"isDeleted": False,
"groupIds": [],
"frameId": None,
"boundElements": [],
"updated": 1,
"link": None,
"locked": False,
}
]
},
)
if trigger == "cmd-reset":
return _cmd("resetScene")
if trigger == "cmd-scrollto":
return _cmd("scrollToContent", {"opts": {"fitToViewport": True}})
if trigger == "cmd-tool-rect":
return _cmd("setActiveTool", {"type": "rectangle"})
if trigger == "cmd-tool-arrow":
return _cmd("setActiveTool", {"type": "arrow"})
if trigger == "cmd-tool-select":
return _cmd("setActiveTool", {"type": "selection"})
if trigger == "cmd-toast":
return _cmd(
"setToast",
{"message": "Toast dispatched from Python!", "duration": 2500},
)
if trigger == "cmd-sidebar":
# "default" is the sidebar; "library" is a tab within it. Naming
# the tab as the sidebar is a silent no-op — see docs/library.
return _cmd("toggleSidebar", {"name": "default", "tab": "library"})
return no_update
:defaultExpanded: false :withExpandedButton: true
Source: /commands
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:
- /commands/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt