File uploads
Keep canvas JSON small: push pasted images to external storage and swap the base64 for URLs.
Where the bytes go, and how to point that at a CDN
A dropped file arrives as base64 — that is how the browser hands it over, and it is the wrong place for it to stay. Inline in the scene, a 120 KB image is ~160 KB of base64 inside every serializedData you persist, every callback payload that carries the scene, and every undo step. The job of this page is getting it out.
The path is four steps, and only the third is deployment-specific:
- The canvas emits the bytes.
lastFileAddedfor an image Excalidraw
placed; lastExternalDrop for anything else — including GIFs, for the reason below. Both carry a dataURL.
- Python decodes and stores it.
decode_data_url→ the storage backend's
put(key, mime, data), which returns a URL.
- The bytes live wherever the backend puts them. In-process by default.
- The canvas is pointed at the URL.
replaceFilesfor images; for other
files the placeholder gets a link, and a GIF's placeholder is replaced by an embeddable whose iframe loads the stored file. Either way externalizedSerializedData comes back with every data: URI stripped — that is the payload you persist.
The storage backend is a seam
lib/file_backends.py defines it, and it is three methods:
class FileBackend:
def put(self, key, mime_type, data) -> str: ... # store; return a URL
def get(self, key) -> tuple[str, bytes] | None: ... # None if this app
# does not serve them
def url_for(self, key) -> str: ...
EXCALIDRAW_FILE_BACKEND chooses one: memory (default) or disk (EXCALIDRAW_FILE_DIR). An unknown name raises at boot rather than falling back — a store that is silently not the one you configured is worse than a failure to start.
For Cloudflare R2, S3 or a Postgres blob table, implement those three and register it:
from lib.file_backends import FileBackend, register_backend
class R2Backend(FileBackend):
name = "r2"
def put(self, key, mime_type, data):
bucket.put_object(Key=key, Body=data, ContentType=mime_type)
return self.url_for(key)
def get(self, key):
return None # the CDN serves them; this app never does
def url_for(self, key):
return f"https://cdn.example.com/{key}"
register_backend("r2", R2Backend)
Nothing above storage changes. The canvas only ever needed a URL back.
Two things change once the URL is not this app's origin, and both are the deployment's to solve rather than the component's. CORS: Excalidraw fetches image bytes, so the bucket must allow your site's origin — a CDN that serves <img> happily can still refuse a cross-origin fetch. And lifetime: the default backend's caps exist because it holds bytes in a web process on a 512 MB container; a bucket has none of those constraints and should not inherit limits sized for somebody else's.
Why GIFs take a different route
Excalidraw rasterises a dropped GIF to a single still frame before anything downstream sees it. Measured here: a 123,069-byte GIF89a of 12 frames arrives as a 2,820-byte PNG with none. So uploading what the canvas hands you stores a still, and framing that in an iframe frames a still — the animation was lost at the drop, not at the embed.
The rasterisation is also what freezes the tab on a large one. Dropping a 5.17 MB 600×600×20 GIF left the renderer unable to answer a debugger evaluation at all — two 45-second timeouts — while the server sat idle at 32 callbacks and logged no error. It is decode work on the main thread, not I/O, and no storage change touches it.
(Timer-based "scheduling lag" is not a usable measure here: a backgrounded tab is throttled to ~1 s, so an idle page with no GIF reports the same figure as a wedged one. An earlier draft of this note quoted those numbers before that was checked against a control.)
So the component intercepts a GIF drop and sends the original bytes down the lastExternalDrop path, exactly like a .pdf or a .txt. Excalidraw never decodes it. This page then stores those bytes and puts an embeddable where the placeholder was, so the browser animates the real file.
The trade is that a GIF is an embed rather than an image element: it is not croppable or styleable on the canvas, and it needs the storage URL to be reachable from the browser. That is the price of it moving at all.
An embed always draws above the canvas, whatever the z-order says
Bring to front will not lift a shape above a GIF, and that is not a bug in this page — it is how Excalidraw renders. Measured in the DOM:
| layer | what is in it | z-index |
|---|---|---|
.excalidraw__canvas.static | every rectangle, image, arrow and text — painted as pixels into one bitmap | 1 |
.excalidraw__embeddable-container | every embed, as real <iframe> elements | 2 |
Scene order decides the painting order within that single canvas. It cannot lift part of a bitmap above a DOM sibling that sits in a higher layer, so an embed is in front of every ordinary element no matter where it sits in the scene. The same is true of a YouTube embed on excalidraw.com; nothing about GIFs or this page causes it.
There is no setting that fixes it. Raising the canvas above the embed layer would only invert the problem — every embed would hide behind everything and stop being clickable — and true interleaving would mean one canvas per z-band, which is an upstream renderer change.
What works in practice: don't overlap them. If layering matters more than motion for a particular image, keep it as an ordinary image element and accept a still frame.
Overview
Keep canvas JSON small: uploads to external storage, base64 swapped for URLs. Drop multiple images, or any non-image file, or a GIF — each takes the right path automatically.
Live demo
Source
# File: docs/file-uploads/file_uploads.py
"""File uploads: externalize inline base64, place non-image placeholders,
auto-embed GIFs as animated iframes, view via dmc.Drawer.
Demonstrates the production pattern for keeping Excalidraw's serialized
JSON small and scalable, AND shows how the wrapper's extended drop
handler feeds multi-file and non-image cases through the same rails.
Flow per drop type:
Image (non-GIF)
wrapper adds as `image` element → lastFileAdded → Python uploads →
replaceFiles swaps dataURL → JSON is clean.
GIF
wrapper adds as `image` element (static initially) → lastFileAdded
→ Python uploads → replaceFiles swaps dataURL → follow-up callback
(triggered by `files`) spots the mime=image/gif with an external URL
and replaces the image element with an `embeddable` so the GIF
animates inside an iframe.
Non-image (pdf, doc, csv, json, …)
wrapper places a rectangle+text placeholder at the drop point →
lastExternalDrop fires with {placeholderIds, files[]} → Python
uploads → dispatches updateScene to set the placeholder's `link` to
the served URL. Cmd/Ctrl+click opens the drawer.
"""
from __future__ import annotations
import json
import mimetypes
import uuid
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, ctx, dcc, html, no_update
from dash_excalidraw import DashExcalidraw, decode_data_url
from lib import file_backends as _file_backends
from lib import file_store as _file_store
from docs._shared import (
canvas_frame,
code_block,
sync_canvas_theme,
two_column,
)
sync_canvas_theme("fu-canvas")
try:
from dash import clientside_callback, ClientsideFunction # noqa: F401
except ImportError: # pragma: no cover
clientside_callback = None
ClientsideFunction = None
# ---------------------------------------------------------------------------
# Showcase code snippet (trimmed for display)
# ---------------------------------------------------------------------------
FLOW_CODE = """
@callback(
Output('canvas', 'command'),
Output('uploads', 'data'),
Input('canvas', 'lastFileAdded'),
State('uploads', 'data'),
prevent_initial_call=True,
)
def upload_and_swap(event, uploads):
mappings = {}
uploads = dict(uploads or {})
for f in event.get('files') or [event]:
mime, raw = decode_data_url(f['dataURL'])
url = upload(raw, mime) # ← your storage layer here
uploads[f['fileId']] = {'url': url, 'mimeType': mime, ...}
mappings[f['fileId']] = {'dataURL': url, 'mimeType': mime}
return ({'id': str(uuid.uuid4()),
'type': 'replaceFiles',
'payload': mappings},
uploads)
"""
# ---------------------------------------------------------------------------
# UI helpers
# ---------------------------------------------------------------------------
def _human_bytes(n):
if n is None:
return "—"
step = 1024.0
n = float(n)
for unit in ("B", "KiB", "MiB", "GiB"):
if n < step:
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} {unit}"
n /= step
return f"{n:.1f} TiB"
def _uploads_table(uploads):
if not uploads:
return dmc.Text(
"Drop an image or any other file on the canvas to start.",
size="sm",
c="dimmed",
)
rows = []
for fid, info in uploads.items():
status = info.get("status", "uploaded")
kind = info.get("kind", "image")
rows.append(
dmc.TableTr(
[
dmc.TableTd(
dmc.Badge(
kind,
size="sm",
color={"gif": "grape", "file": "blue"}.get(kind, "teal"),
variant="light",
),
),
dmc.TableTd(
dmc.Code(
(info.get("name") or fid)[:22]
+ ("…" if len(info.get("name") or fid) > 22 else "")
)
),
dmc.TableTd(_human_bytes(info.get("size", 0))),
dmc.TableTd(
dmc.Anchor(
info.get("url", ""),
href=info.get("url", ""),
target="_blank",
size="xs",
)
),
dmc.TableTd(
dmc.Tooltip(
label=info.get("detail") or status,
disabled=not info.get("detail"),
multiline=True,
w=320,
children=dmc.Badge(
status,
# Red for a refusal, not yellow: nothing was
# stored and the URL column is empty, so this
# is a failure rather than a slow step.
color={
"uploaded": "green",
"too large": "red",
}.get(status, "yellow"),
variant="light",
size="sm",
),
)
),
]
)
)
# SCROLLS SIDEWAYS. The url column holds a full storage path, and a
# backend whose URLs are a CDN's makes it longer still — the table was
# running past the card and clipping the status column, which is the one
# that says whether the upload actually worked.
return dmc.TableScrollContainer(
minWidth=560,
children=dmc.Table(
highlightOnHover=True,
withTableBorder=True,
children=[
dmc.TableThead(
dmc.TableTr(
[
dmc.TableTh("kind"),
dmc.TableTh("name"),
dmc.TableTh("size"),
dmc.TableTh("url"),
dmc.TableTh("status"),
]
)
),
dmc.TableTbody(rows),
],
),
)
def _size_panel(serialized, externalized):
raw = len(serialized or "")
ext = len(externalized or "")
ratio = f"{raw / ext:.1f}×" if ext else "—"
return dmc.Stack(
gap="xs",
children=[
dmc.Group(
justify="space-between",
children=[
dmc.Text("serializedData", size="sm", fw=500),
dmc.Badge(_human_bytes(raw), color="red", variant="light"),
],
),
dmc.Group(
justify="space-between",
children=[
dmc.Text("externalizedSerializedData", size="sm", fw=500),
dmc.Badge(_human_bytes(ext), color="green", variant="light"),
],
),
dmc.Group(
justify="space-between",
children=[
dmc.Text("reduction", size="sm", c="dimmed"),
dmc.Text(ratio, size="sm", fw=600),
],
),
],
)
def _emoji_for_mime(mime: str, name: str) -> str:
"""Python mirror of the TSX icon picker for the drawer header."""
m = (mime or "").lower()
n = (name or "").lower()
if "pdf" in m or n.endswith(".pdf"):
return "📕"
if "csv" in m or n.endswith(".csv"):
return "📊"
if "spreadsheet" in m or "excel" in m or n.endswith((".xls", ".xlsx")):
return "📊"
if "presentation" in m or "powerpoint" in m or n.endswith((".ppt", ".pptx")):
return "🎞️"
if "msword" in m or "officedocument" in m or n.endswith((".doc", ".docx")):
return "📘"
if "json" in m or "yaml" in m or n.endswith((".json", ".yaml", ".yml", ".toml")):
return "🗂️"
if m.startswith("audio/"):
return "🎵"
if m.startswith("video/"):
return "🎬"
if m.startswith("image/"):
return "🖼️"
if "zip" in m or "tar" in m or "gzip" in m or "compressed" in m:
return "🗜️"
if m.startswith("text/"):
return "📝"
return "📎"
def _preview_for(file_id: str, mime: str, url: str):
"""Return a type-appropriate preview widget for the drawer body."""
if mime.startswith("image/"):
return html.Img(
src=url,
style={
"maxWidth": "100%",
"maxHeight": "55vh",
"display": "block",
"margin": "0 auto",
"borderRadius": 8,
"border": "1px solid var(--mantine-color-gray-3)",
},
)
if mime == "application/pdf":
return html.Iframe(
src=url,
style={
"width": "100%",
"height": "55vh",
"border": "1px solid var(--mantine-color-gray-3)",
"borderRadius": 8,
},
)
if mime.startswith("text/") or mime in (
"application/json",
"application/xml",
"application/javascript",
"application/yaml",
"application/x-yaml",
"application/toml",
):
entry = _file_backends.backend().get(file_id)
text = ""
try:
text = entry[1].decode("utf-8", errors="replace") if entry else ""
except Exception:
text = "(cannot decode as UTF-8)"
language = {
"application/json": "json",
"application/xml": "xml",
"application/javascript": "javascript",
"application/yaml": "yaml",
"application/x-yaml": "yaml",
"application/toml": "toml",
"text/markdown": "markdown",
"text/x-python": "python",
}.get(mime, "text")
try:
return dmc.CodeHighlight(
code=text[:20000],
language=language,
withCopyButton=True,
)
except Exception:
return dmc.Code(text[:20000], block=True)
return dmc.Alert(
color="gray",
variant="light",
children=[
dmc.Text(
f"No inline preview available for {mime or 'this file type'}.",
size="sm",
),
dmc.Text(
"Use the Download / Open buttons above to inspect the file.",
size="xs",
c="dimmed",
),
],
)
def _drawer_body(file_id: str, name: str, mime: str, url: str):
"""Polished drawer layout: big icon header, metadata, actions, preview."""
if not file_id:
return dmc.Text("(no file selected)", c="dimmed")
entry = _file_backends.backend().get(file_id)
size_bytes = len(entry[1]) if entry else 0
icon = _emoji_for_mime(mime, name)
short_mime = (mime or "application/octet-stream").split("/")[-1].upper()
header = dmc.Paper(
withBorder=True,
p="md",
radius="md",
children=dmc.Group(
gap="md",
wrap="nowrap",
children=[
dmc.Text(icon, style={"fontSize": 56, "lineHeight": 1}),
dmc.Stack(
gap=4,
style={"flex": 1, "minWidth": 0},
children=[
dmc.Text(
name,
fw=700,
size="md",
style={
"overflow": "hidden",
"textOverflow": "ellipsis",
"whiteSpace": "nowrap",
},
),
dmc.Group(
gap="xs",
children=[
dmc.Badge(
short_mime,
color="indigo",
variant="light",
size="sm",
),
dmc.Text(
_human_bytes(size_bytes),
size="xs",
c="dimmed",
),
dmc.Text(
f"id: {file_id[:14]}…"
if len(file_id) > 14
else f"id: {file_id}",
size="xs",
c="dimmed",
),
],
),
],
),
],
),
)
served_row = dmc.Paper(
withBorder=True,
p="sm",
radius="md",
children=dmc.Stack(
gap=4,
children=[
dmc.Text("Served URL", size="xs", c="dimmed", fw=500),
dmc.Group(
gap="xs",
wrap="nowrap",
children=[
dmc.Code(
url,
style={
"flex": 1,
"whiteSpace": "nowrap",
"overflow": "hidden",
"textOverflow": "ellipsis",
},
),
],
),
],
),
)
actions = dmc.Group(
gap="xs",
children=[
html.A(
dmc.Button(
"Download",
leftSection="⬇",
variant="filled",
color="indigo",
),
href=url,
download=name,
style={"textDecoration": "none"},
),
html.A(
dmc.Button(
"Open in new tab",
leftSection="↗",
variant="light",
),
href=url,
target="_blank",
rel="noreferrer",
style={"textDecoration": "none"},
),
dmc.Button(
"Copy URL",
id="fu-drawer-copy-btn",
leftSection="⧉",
variant="subtle",
color="gray",
),
dmc.Text(
id="fu-drawer-copy-status",
size="xs",
c="green",
style={"alignSelf": "center"},
),
],
)
preview_block = dmc.Paper(
withBorder=True,
p="md",
radius="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("Preview", fw=600, size="sm"),
_preview_for(file_id, mime, url),
],
),
)
return dmc.Stack(
gap="md",
children=[header, actions, served_row, preview_block],
)
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
component = dmc.Stack(
gap="md",
children=[
dmc.Alert(
color="indigo",
variant="light",
title="Drop anything",
children=dmc.List(
size="sm",
children=[
dmc.ListItem(
"Single image → Excalidraw's native placement."
),
dmc.ListItem(
"Multiple images → wrapper places them side-by-side and "
"`lastFileAdded` carries the full batch in `event['files']`."
),
dmc.ListItem(
"GIF → uploaded, then the static image element is "
"upgraded to an `embeddable` iframe so the animation plays."
),
dmc.ListItem(
"Any other file → rectangle placeholder on the canvas; "
"Cmd/Ctrl-click opens a drawer with its contents."
),
],
),
),
code_block(FLOW_CODE),
dmc.Box(id="fu-backend-card"),
dcc.Store(id="fu-uploads-store", data={}),
dcc.Store(id="fu-drawer-store", data=None),
two_column(
canvas_frame(
DashExcalidraw(
id="fu-canvas",
height="560px",
interceptLinkOpens=True,
validateEmbeddable=True,
UIOptions={
"welcomeScreen": False,
"tools": {"image": True},
"canvasActions": {"export": False, "saveAsImage": True},
},
),
min_height=560,
),
dmc.Stack(
gap="md",
children=[
dmc.Paper(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("Upload activity", fw=600),
dmc.Box(id="fu-table"),
],
),
),
dmc.Paper(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("Payload size", fw=600),
dmc.Box(id="fu-size"),
],
),
),
dmc.Paper(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Group(
justify="space-between",
children=[
dmc.Text(
"externalizedSerializedData",
fw=600,
),
dmc.Button(
"Clear storage",
id="fu-clear-btn",
variant="subtle",
color="red",
size="compact-xs",
),
],
),
dmc.ScrollArea(
style={"height": 220},
children=dmc.Code(
id="fu-json",
block=True,
style={
"whiteSpace": "pre-wrap",
"wordBreak": "break-word",
"fontSize": 11,
},
),
),
],
),
),
],
),
),
dmc.Drawer(
id="fu-drawer",
opened=False,
position="right",
size="lg",
padding="md",
title=dmc.Group(
[
dmc.Text(id="fu-drawer-title", fw=600),
dmc.Badge(id="fu-drawer-badge", size="sm", variant="light"),
]
),
children=dmc.Box(id="fu-drawer-body"),
),
dmc.Divider(label="Edge cases", labelPosition="center", my="lg"),
dmc.SimpleGrid(
cols={"base": 1, "md": 3},
spacing="md",
children=[
dmc.Card(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("Multi-file drag-drop", fw=600),
dmc.Text(
"Drop several files (any mix of types). The "
"wrapper places them all side-by-side and "
"`lastFileAdded` carries the full batch in "
"`event['files']`.",
size="sm",
c="dimmed",
),
],
),
),
dmc.Card(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("GIF auto-embed", fw=600),
dmc.Text(
"Excalidraw rasterizes a dropped GIF to one "
"still frame — measured, a 12-frame 123 KB "
"GIF89a arrives as a 2.8 KB PNG. So the "
"component intercepts GIF drops, keeps the "
"original bytes, and this page puts an "
"embeddable over them. The iframe animates the "
"real file; Excalidraw never decodes it.",
size="sm",
c="dimmed",
),
],
),
),
dmc.Card(
withBorder=True,
p="md",
children=dmc.Stack(
gap="xs",
children=[
dmc.Text("Non-image files", fw=600),
dmc.Text(
"PDF, JSON, CSV, anything — placed on the "
"canvas as a card. Cmd/Ctrl-click opens the "
"drawer with a type-appropriate view (iframe, "
"syntax highlight, or download).",
size="sm",
c="dimmed",
),
],
),
),
],
),
],
)
# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------
@callback(
Output("fu-canvas", "command"),
Output("fu-uploads-store", "data"),
Input("fu-canvas", "lastFileAdded"),
State("fu-uploads-store", "data"),
prevent_initial_call=True,
)
def _upload_and_swap(event, uploads):
"""Upload every new file in the batch; dispatch replaceFiles once."""
if not event:
return no_update, no_update
files_in_event = event.get("files") or [
{k: event.get(k) for k in ("fileId", "mimeType", "dataURL", "size")}
]
uploads = dict(uploads or {})
mappings = {}
for f in files_in_event:
try:
mime, raw = decode_data_url(f["dataURL"])
except (ValueError, KeyError):
continue
try:
url = _file_backends.backend().put(f["fileId"], mime, raw)
except _file_store.FileTooLarge as exc:
# The store refuses a blob over its per-file cap, and that refusal
# used to leave the callback: HTTP 500, a placeholder that never
# resolved, and nothing on the page saying why. MEASURED with a
# 5.17 MB GIF against the 5 MB default. A cap is a policy, so it
# gets an answer, not a traceback.
uploads[f["fileId"]] = {
"url": "",
"size": f.get("size", len(raw)),
"mimeType": mime,
"name": f.get("fileId"),
"kind": "gif" if mime == "image/gif" else "image",
"status": "too large",
"detail": str(exc),
}
continue
uploads[f["fileId"]] = {
"url": url,
"size": f.get("size", len(raw)),
"mimeType": mime,
"name": f.get("fileId"),
"kind": "gif" if mime == "image/gif" else "image",
"status": "uploaded",
}
mappings[f["fileId"]] = {"dataURL": url, "mimeType": mime}
if not mappings:
return no_update, no_update
return (
{
"id": f"replace-{uuid.uuid4()}",
"type": "replaceFiles",
"payload": mappings,
},
uploads,
)
@callback(
Output("fu-canvas", "command", allow_duplicate=True),
Input("fu-canvas", "files"),
State("fu-canvas", "elements"),
prevent_initial_call=True,
)
def _upgrade_gifs_to_embeddable(files, elements):
"""Once a GIF has an external URL, swap its image element for an iframe."""
if not files or not elements:
return no_update
gif_targets = []
for el in elements:
if el.get("type") != "image":
continue
fid = el.get("fileId")
if not fid:
continue
entry = files.get(fid) if isinstance(files, dict) else None
if not isinstance(entry, dict):
continue
if entry.get("mimeType") != "image/gif":
continue
url = entry.get("dataURL")
if not isinstance(url, str) or url.startswith("data:"):
continue
gif_targets.append(
{
"id": el["id"],
"x": el.get("x", 0),
"y": el.get("y", 0),
"width": el.get("width", 320),
"height": el.get("height", 240),
"url": url,
}
)
if not gif_targets:
return no_update
target_ids = {g["id"] for g in gif_targets}
new_elements = [el for el in elements if el["id"] not in target_ids]
now_ms = int(uuid.uuid4().int % (10**12))
for g in gif_targets:
# Use the /viewer HTML wrapper as the embeddable src. Pointing the
# iframe directly at the raw GIF URL trips Chromium's same-URL frame
# safety check ("Unsafe attempt to load URL ... from frame with URL ...");
# loading HTML that contains an <img> sidesteps that and lets the GIF
# animate natively inside the iframe.
viewer_href = g["url"].rstrip("/") + "/viewer"
new_elements.append(
{
"id": f"gif-embed-{g['id']}",
"type": "embeddable",
"x": g["x"],
"y": g["y"],
"width": g["width"],
"height": g["height"],
"angle": 0,
"strokeColor": "transparent",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"seed": now_ms % 1_000_000,
"version": 1,
"versionNonce": now_ms % 1_000_000,
"isDeleted": False,
"groupIds": [],
"frameId": None,
"boundElements": [],
"updated": now_ms,
"link": viewer_href,
"locked": False,
"roundness": None,
}
)
return {
"id": f"gif-upgrade-{uuid.uuid4()}",
"type": "updateScene",
"payload": {"elements": new_elements},
}
@callback(
Output("fu-canvas", "command", allow_duplicate=True),
Output("fu-uploads-store", "data", allow_duplicate=True),
Input("fu-canvas", "lastExternalDrop"),
State("fu-canvas", "elements"),
State("fu-uploads-store", "data"),
prevent_initial_call=True,
)
def _handle_external_drop(event, elements, uploads):
"""Non-image drops: upload each file and set the placeholder's link.
GIFs arrive here too, and that is the point. The component routes them
down this path instead of letting Excalidraw place them, because
Excalidraw rasterises a GIF to one still frame on drop — measured, a
123,069-byte 12-frame GIF89a became a 2,820-byte PNG. What reaches this
callback is the ORIGINAL file, so the bytes in storage are the animation.
A GIF's placeholder is then replaced by an `embeddable` rather than given
a link: the iframe loads the stored GIF and the browser animates it, which
a canvas image element can never do.
"""
if not event or not event.get("files"):
return no_update, no_update
placeholder_ids = event.get("placeholderIds") or []
uploads = dict(uploads or {})
updates = {} # elementId -> {link, mimeType, fileId, name}
for idx, f in enumerate(event["files"]):
try:
mime, raw = decode_data_url(f["dataURL"])
except (ValueError, KeyError):
continue
# Use a single store_key for everything: file_store, uploads dict,
# and customData.external.fileId. Keeps the drawer lookup honest.
guessed_ext = mimetypes.guess_extension(mime) or ""
store_key = f"ext-{uuid.uuid4().hex[:12]}{guessed_ext}"
try:
url = _file_backends.backend().put(store_key, mime, raw)
except _file_store.FileTooLarge as exc:
uploads[store_key] = {
"url": "",
"size": f.get("size", len(raw)),
"mimeType": mime,
"name": f.get("name") or store_key,
"kind": "file",
"status": "too large",
"detail": str(exc),
}
continue
uploads[store_key] = {
"url": url,
"size": f.get("size", len(raw)),
"mimeType": mime,
"name": f.get("name") or store_key,
"kind": "file",
"status": "uploaded",
}
if idx < len(placeholder_ids):
updates[placeholder_ids[idx]] = {
"link": url,
"fileId": store_key,
"mimeType": mime,
"name": f.get("name") or store_key,
# Carried from the drop so the embed matches the GIF rather
# than the placeholder rectangle it replaces.
"naturalSize": f.get("naturalSize"),
}
if not updates:
return no_update, uploads
# A placeholder is a GROUP — rectangle, icon, name, size, badge — so a GIF
# replacing one has to remove all five, not just repaint the rectangle, or
# the label floats on top of the animation.
gif_groups = set()
for el in elements or []:
meta = updates.get(el.get("id"))
if meta and meta["mimeType"] == "image/gif":
gif_groups.update(el.get("groupIds") or [])
new_elements = []
for el in elements or []:
meta = updates.get(el.get("id"))
if meta and meta["mimeType"] == "image/gif" and el.get("type") == "rectangle":
# A GIF becomes an iframe over the stored file. `/viewer` rather
# than the raw URL: framing an image directly trips Chromium's
# same-URL frame check, and a one-line HTML document holding an
# <img> does not.
size = meta.get("naturalSize") or {}
new_elements.append({
**el,
"type": "embeddable",
"width": size.get("width") or el.get("width", 320),
"height": size.get("height") or el.get("height", 240),
"link": meta["link"].rstrip("/") + "/viewer",
"strokeColor": "transparent",
"backgroundColor": "transparent",
"groupIds": [],
"customData": {
**(el.get("customData") or {}),
"external": meta,
},
})
elif gif_groups.intersection(el.get("groupIds") or []):
# The rest of that placeholder group: dropped.
continue
elif meta and el.get("type") == "rectangle":
patched = dict(el)
patched["link"] = meta["link"]
# Stash metadata in customData so the drawer callback can look up
# mime/name/fileId by element id without re-parsing the link.
patched["customData"] = {
**(el.get("customData") or {}),
"external": meta,
}
new_elements.append(patched)
else:
new_elements.append(el)
return (
{
"id": f"extdrop-{uuid.uuid4()}",
"type": "updateScene",
"payload": {"elements": new_elements},
},
uploads,
)
@callback(
Output("fu-drawer", "opened"),
Output("fu-drawer-title", "children"),
Output("fu-drawer-badge", "children"),
Output("fu-drawer-body", "children"),
Output("fu-drawer-store", "data"),
Input("fu-canvas", "lastLinkOpen"),
State("fu-canvas", "elements"),
prevent_initial_call=True,
)
def _open_drawer(link_event, elements):
"""Open the drawer when a placeholder link is clicked."""
if not link_event or not link_event.get("elementId"):
return no_update, no_update, no_update, no_update, no_update
eid = link_event["elementId"]
url = link_event.get("url") or ""
# Only handle links served from our Flask endpoint
if not url.startswith(_file_store.FILE_URL_PREFIX):
return no_update, no_update, no_update, no_update, no_update
target = next((el for el in (elements or []) if el.get("id") == eid), None)
meta = (target or {}).get("customData", {}).get("external", {})
name = meta.get("name") or url.rsplit("/", 1)[-1]
mime = meta.get("mimeType") or "application/octet-stream"
file_id = meta.get("fileId") or url.rsplit("/", 1)[-1]
body = _drawer_body(file_id, name, mime, url)
return True, name, mime, body, {"url": url, "name": name, "mime": mime}
@callback(
Output("fu-table", "children"),
Input("fu-uploads-store", "data"),
)
def _render_table(uploads):
return _uploads_table(uploads)
@callback(
Output("fu-size", "children"),
Output("fu-json", "children"),
Input("fu-canvas", "serializedData"),
Input("fu-canvas", "externalizedSerializedData"),
)
def _render_size(serialized, externalized):
size_panel = _size_panel(serialized, externalized)
if not externalized:
json_blob = "(draw or drop something to see the envelope)"
else:
try:
json_blob = json.dumps(json.loads(externalized), indent=2)
except (TypeError, ValueError):
json_blob = externalized
return size_panel, json_blob
@callback(
Output("fu-uploads-store", "data", allow_duplicate=True),
Input("fu-clear-btn", "n_clicks"),
prevent_initial_call=True,
)
def _clear_store(_clicks):
_file_store.clear()
return {}
if clientside_callback is not None:
clientside_callback(
"""
function(n, data) {
if (!n || !data || !data.url) { return window.dash_clientside.no_update; }
try {
const href = new URL(data.url, window.location.origin).href;
navigator.clipboard.writeText(href);
return "copied ✓";
} catch (err) {
return "copy failed";
}
}
""",
Output("fu-drawer-copy-status", "children"),
Input("fu-drawer-copy-btn", "n_clicks"),
State("fu-drawer-store", "data"),
prevent_initial_call=True,
)
@callback(
Output("fu-backend-card", "children"),
Input("fu-canvas", "id"),
)
def _describe_backend(_id):
"""Name the storage backend that is actually serving, at page load.
Prose in the markdown can go stale the moment somebody sets an env var;
this reads the live object, so what the page claims about where bytes go
is what is happening. It also makes the seam visible — the whole point is
that this line can say "r2" without anything else on the page changing.
"""
info = _file_backends.backend().describe()
rows = [
dmc.Group(
gap="xs",
children=[
dmc.Text("Storage backend", fw=600, size="sm"),
dmc.Badge(info.get("name", "?"), variant="light"),
dmc.Badge(
"serves bytes here"
if info.get("serves_bytes")
else "bytes served elsewhere",
color="teal" if info.get("serves_bytes") else "grape",
variant="light",
size="sm",
),
],
)
]
facts = []
if info.get("max_entry_bytes"):
facts.append(f"{info['max_entry_bytes'] // (1024 * 1024)} MB per file")
if info.get("max_total_bytes"):
facts.append(f"{info['max_total_bytes'] // (1024 * 1024)} MB total")
if info.get("ttl_seconds"):
facts.append(f"{info['ttl_seconds'] // 60} min TTL")
if info.get("root"):
facts.append(info["root"])
# State the two properties that decide whether this is deployable, rather
# than leaving someone to infer them from the backend's name.
facts.append(
"survives a restart"
if info.get("survives_restart")
else "lost on restart"
)
facts.append(
"shared between workers"
if info.get("shared_between_workers")
else "per-worker"
)
rows.append(dmc.Text(" · ".join(facts), size="xs", c="dimmed"))
rows.append(
dmc.Text(
"EXCALIDRAW_FILE_BACKEND selects it (memory | disk). Implement "
"three methods to point it at R2, S3 or Postgres — see the section "
"above and lib/file_backends.py.",
size="xs",
c="dimmed",
)
)
return dmc.Alert(
dmc.Stack(rows, gap=4),
color="blue",
variant="light",
)
:defaultExpanded: false :withExpandedButton: true
Source: /file-uploads
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:
- /file-uploads/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt