Skip to content

Repository files navigation

streamlit-excalidraw

A Streamlit custom component that embeds the Excalidraw whiteboard editor.

Works in both directions: draw a scene from Python and read back what is on the canvas.

Pushing a scene into the canvas and exporting it back out

Installation

pip install git+https://github.com/gioelemo/streamlit-excalidraw.git

Usage

Reading the canvas

import streamlit as st
from streamlit_excalidraw import excalidraw_whiteboard

trigger = st.button("Export canvas")
result = excalidraw_whiteboard(height=600, key="my_canvas", trigger_export=trigger)

if result and result["png"]:
    st.image(result["png"], caption="Exported canvas")
    st.json(result["elements"])

Drawing into the canvas

import json

elements = json.dumps(
    [
        {
            "type": "rectangle",
            "x": 100,
            "y": 100,
            "width": 200,
            "height": 90,
            "backgroundColor": "#a5d8ff",
            "fillStyle": "solid",
            "label": {"text": "Beam", "fontSize": 20},
        },
    ]
)

# Bump the version whenever you want the canvas overwritten; keep it stable
# and reruns will not disturb what the user is drawing.
if st.button("Draw"):
    st.session_state.seq = st.session_state.get("seq", 0) + 1

excalidraw_whiteboard(
    key="my_canvas",
    elements=elements,
    scene_version=f"demo:{st.session_state.get('seq', 0)}",
)

API

excalidraw_whiteboard(height=650, key=None, trigger_export=False, elements=None, scene_version=None)

Parameter Type Default Description
height int 650 Height of the whiteboard in pixels
key str | None None Unique key for the component instance
trigger_export bool False When True, exports the canvas
elements str | None None JSON array string of Excalidraw elements to draw. Loose skeletons are fine — the component fills in the rest. None leaves the canvas untouched
scene_version str | None None Opaque token deciding when elements is written — see below

Returns: dict | NoneNone until an export is triggered, then:

Key Type Description
png str | None Base64-encoded PNG data URL, or None if the canvas was empty
elements str JSON array string of the current scene ("[]" when empty)
exported_at str ISO-8601 timestamp, unique per export — a reliable de-duplication key

How scene_version works

The scene is written only when scene_version changes. Streamlit re-sends every argument on every rerun, so without this the component would overwrite the canvas continuously and erase whatever the user was mid-way through drawing.

Prefer a counter-like token (f"{chat_id}:{seq}") over a hash of the content: a content-derived value re-triggers the write if the user erases back to a scene that was pushed earlier. When omitted, the elements payload itself is used.

Pushed scenes go into Excalidraw's undo history, so a user can Ctrl-Z a drawing they did not ask for.

Migrating from 0.1.x

The return value changed from a bare base64 PNG string to a dict.

# 0.1.x
data = excalidraw_whiteboard(trigger_export=trigger)
if data:
    st.image(data)

# 0.2.0
result = excalidraw_whiteboard(trigger_export=trigger)
if result and result["png"]:
    st.image(result["png"])

Note the truthiness check. An empty canvas used to return None; it now returns a dict with png=None and elements="[]", so that "exported an empty canvas" is distinguishable from "no export has happened". Test result["png"], not result.

Development

The compiled bundle is committed on purpose

streamlit_excalidraw/frontend/build/ holds the Vite output and is tracked in git. That is deliberate: consumers install with pip install git+https://..., which runs no Node, so the compiled asset has to be in the repo or the component renders nothing.

The cost is that staleness is invisible — edit src/index.tsx, commit without rebuilding, and every consumer keeps the old component with no error anywhere. A check-bundle-rebuilt pre-commit hook guards against this; run pre-commit install once so it is armed.

Rebuilding the bundle

cd streamlit_excalidraw/frontend
npm ci                 # `ci`, not `install` -- package-lock.json is committed
npm run build          # rimraf build -> tsc --noEmit -> vite build (~10 min)
cd ../..
git add -A streamlit_excalidraw/frontend/build

Use git add -A: Vite content-hashes the bundle into its filename (index-<hash>.js), so a rebuild adds a new asset and the old one must be removed in the same commit.

Two things that make the bundle hard to inspect by hand:

  • It is minified with minifyIdentifiers, so imported bindings are renamed. Grepping it for a symbol like convertToExcalidrawElements returns 0 whether or not the import is present — that is not evidence of tree-shaking. Only property names (updateScene, scrollToContent) survive verbatim.
  • npm run build runs tsc --noEmit first, so a bad import fails the build rather than shipping quietly. That typecheck is the real guard.

Iterating on the frontend

npm run dev is vite build --watch — it rebuilds into build/, it is not a dev server. Hard-refresh the Streamlit page to pick up each rebuild, and run Streamlit against this checkout (pip install -e .) rather than an installed copy.

For a true HMR loop, set _RELEASE = False in streamlit_excalidraw/__init__.py and start Vite manually — there is no npm script for it and no server block in vite.config.ts, so the port must be given explicitly to match the URL that _RELEASE = False points at:

cd streamlit_excalidraw/frontend && npx vite --port 3001 --strictPort

Remember to set _RELEASE = True again before committing; nothing checks it.

License

MIT

About

Streamlit component for embedding an Excalidraw whiteboard canvas in Streamlit apps.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages