excalidraw: native export menu + drawing rename

Users couldn't see Excalidraw's built-in Save as / Export image options:
the app's custom toolbar was drawn exactly on top of the native hamburger
menu button, hiding it. Removed the overlay and integrated Back to
Library / Save now / Rename into the native menu, so the native export
formats (.excalidraw file, PNG, SVG, clipboard) are now reachable.
Viktor asked for exports to work via the native Excalidraw feature and
for drawings to be renameable by clicking their name.

Rename: new PATCH /api/drawings/{id} endpoint (server-side name
sanitization, 409 on conflict) + click-to-rename title pill in the
editor (updates URL in place) + Rename button/modal in the dashboard.
Existing GET/PUT/DELETE semantics unchanged for API compatibility
(emo's upload pipeline). Added main_test.go (httptest) covering rename
+ existing handler behavior; dashboard rows now DOM-built (XSS-safe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-07-02 14:29:10 +00:00
parent 6f03ccd1aa
commit 1cbc1e962b
4 changed files with 577 additions and 133 deletions

View file

@ -4,18 +4,28 @@ A self-hosted Excalidraw library with per-user drawing storage and management.
## Features
- Dashboard to manage all your drawings
- Dashboard to manage all your drawings (create, open, rename, delete)
- Per-user storage (via Authentik SSO headers)
- Create, edit, and delete drawings
- Rename drawings from the dashboard or by clicking the drawing name in the editor
- Native Excalidraw export via the editor's hamburger menu: "Save to..."
(.excalidraw file) and "Export image..." (PNG / SVG / clipboard)
- Autosave (2s debounce) + manual save (Ctrl+S or menu "Save now")
- Persistent storage via NFS
## Docker Image
```
viktorbarzin/excalidraw-library:v4
ghcr.io/viktorbarzin/excalidraw-library:latest
```
Available on Docker Hub: https://hub.docker.com/r/viktorbarzin/excalidraw-library
Built by GitHub Actions (`.github/workflows/build-excalidraw.yml` in the infra
repo, ADR-0002) on every master push touching `stacks/excalidraw/project/**`;
tags `:latest` + `:<git-sha>`. The package is PRIVATE — cluster pulls use the
Kyverno-synced `ghcr-credentials` secret. Keel polls `:latest` and rolls the
deployment on digest change.
The legacy manually-built DockerHub image `viktorbarzin/excalidraw-library:v4`
is frozen as the rollback target; nothing pushes to it anymore.
## Configuration
@ -39,54 +49,13 @@ Mount a persistent volume to the `DATA_DIR` path. Drawings are stored as `.excal
└── my-diagram.excalidraw
```
The filename (without extension) is both the drawing ID and its display name;
renaming a drawing renames the file (`os.Rename`, mtime preserved).
## Deployment
### Docker
```bash
docker run -d \
--name excalidraw-rooms \
-p 8080:8080 \
-v /path/to/storage:/data \
viktorbarzin/excalidraw-library:v4
```
### Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: excalidraw
spec:
replicas: 1
selector:
matchLabels:
app: excalidraw
template:
metadata:
labels:
app: excalidraw
spec:
containers:
- name: excalidraw
image: viktorbarzin/excalidraw-library:v4
ports:
- containerPort: 8080
env:
- name: DATA_DIR
value: /data
- name: PORT
value: "8080"
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
nfs:
server: 192.168.1.127
path: /srv/nfs/excalidraw
```
Deployed by the `stacks/excalidraw` Terraform stack (namespace `excalidraw`,
service `draw`, ingress `draw.viktorbarzin.me` with `auth = "required"`).
### With Authentik SSO
@ -96,23 +65,7 @@ The application reads user identity from Authentik headers:
- `X-Authentik-Email` - Displayed in UI
- `X-Authentik-Name` - Displayed in UI
Configure your ingress to pass these headers:
```yaml
annotations:
nginx.ingress.kubernetes.io/auth-response-headers: "X-authentik-username,X-authentik-email,X-authentik-name"
```
## Building
```bash
# Build the Docker image
docker build -t excalidraw-library .
# Or build locally
go build -o excalidraw-library .
./excalidraw-library
```
Requests without `X-Authentik-Username` fall back to the `anonymous` user.
## API Endpoints
@ -122,10 +75,25 @@ go build -o excalidraw-library .
| GET | `/api/drawings` | List all drawings for current user |
| GET | `/api/drawings/:id` | Get drawing data |
| PUT | `/api/drawings/:id` | Save drawing |
| PATCH | `/api/drawings/:id` | Rename drawing — body `{"name": "<new-name>"}`; returns `{"status":"renamed","id":"<new-id>"}`; 409 if the target name exists |
| DELETE | `/api/drawings/:id` | Delete drawing |
| GET | `/api/user` | Get current user info |
| GET | `/draw/:id` | Open drawing in editor |
Rename names are sanitized server-side to `[a-zA-Z0-9-_]` (other characters
become `-`; a trailing `.excalidraw` is stripped). Existing IDs are accepted
as-is for backward compatibility with API clients.
## Development
```bash
# Run tests
go test ./...
# Run locally
DATA_DIR=/tmp/excalidraw-data go run .
```
## License
MIT

View file

@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@ -63,6 +64,21 @@ func getUsername(r *http.Request) string {
return username
}
var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9-_]`)
// sanitizeName normalizes a user-supplied drawing name into a safe file ID
// (same charset the dashboard applies on create). Returns "" if nothing
// meaningful remains.
func sanitizeName(name string) string {
name = strings.TrimSpace(name)
name = strings.TrimSuffix(name, ".excalidraw")
name = invalidNameChars.ReplaceAllString(name, "-")
if strings.Trim(name, "-") == "" {
return ""
}
return name
}
// getUserDataDir returns the data directory for a specific user and ensures it exists
func getUserDataDir(username string) string {
userDir := filepath.Join(dataDir, username)
@ -168,6 +184,41 @@ func handleDrawing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "saved", "id": id})
case http.MethodPatch:
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}
newID := sanitizeName(req.Name)
if newID == "" {
http.Error(w, "Invalid name", http.StatusBadRequest)
return
}
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "Drawing not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if newID != id {
newPath := filepath.Join(userDataDir, newID+".excalidraw")
if _, err := os.Stat(newPath); err == nil {
http.Error(w, "A drawing with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(filePath, newPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "renamed", "id": newID})
case http.MethodDelete:
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
@ -264,6 +315,8 @@ const dashboardHTML = `<!DOCTYPE html>
.btn:hover { background: #5b4cdb; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.btn-secondary { background: #3d3d5c; }
.btn-secondary:hover { background: #4a4a70; }
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.drawings { display: grid; gap: 1rem; }
.drawing {
@ -342,11 +395,11 @@ const dashboardHTML = `<!DOCTYPE html>
<div id="modal" class="modal">
<div class="modal-content">
<h2>New Drawing</h2>
<h2 id="modal-title">New Drawing</h2>
<input type="text" id="drawingName" placeholder="Drawing name..." autofocus>
<div class="modal-actions">
<button class="btn" style="background:#444" onclick="hideModal()">Cancel</button>
<button class="btn" onclick="createDrawing()">Create</button>
<button class="btn" id="modal-confirm" onclick="confirmModal()">Create</button>
</div>
</div>
</div>
@ -369,31 +422,63 @@ const dashboardHTML = `<!DOCTYPE html>
}
}
function drawingRow(d) {
var row = document.createElement('div');
row.className = 'drawing';
var info = document.createElement('div');
info.className = 'drawing-info';
var nameLink = document.createElement('a');
nameLink.className = 'drawing-name';
nameLink.href = '/draw/' + encodeURIComponent(d.id);
nameLink.textContent = d.name;
var meta = document.createElement('div');
meta.className = 'drawing-meta';
meta.textContent = 'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' +
new Date(d.modified).toLocaleTimeString() + ' - ' + formatSize(d.size);
info.appendChild(nameLink);
info.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'drawing-actions';
var open = document.createElement('a');
open.className = 'btn btn-small';
open.href = '/draw/' + encodeURIComponent(d.id);
open.textContent = 'Open';
var rename = document.createElement('button');
rename.className = 'btn btn-small btn-secondary';
rename.textContent = 'Rename';
rename.onclick = function() { showRenameModal(d.id); };
var del = document.createElement('button');
del.className = 'btn btn-small btn-danger';
del.textContent = 'Delete';
del.onclick = function() { deleteDrawing(d.id); };
actions.appendChild(open);
actions.appendChild(rename);
actions.appendChild(del);
row.appendChild(info);
row.appendChild(actions);
return row;
}
async function loadDrawings() {
const resp = await fetch('/api/drawings');
const drawings = await resp.json();
const container = document.getElementById('drawings');
container.replaceChildren();
if (!drawings || drawings.length === 0) {
container.innerHTML = '<div class="empty">No drawings yet. Create your first one!</div>';
var empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No drawings yet. Create your first one!';
container.appendChild(empty);
return;
}
container.innerHTML = drawings.map(function(d) {
return '<div class="drawing">' +
'<div class="drawing-info">' +
'<a href="/draw/' + d.id + '" class="drawing-name">' + d.name + '</a>' +
'<div class="drawing-meta">' +
'Modified: ' + new Date(d.modified).toLocaleDateString() + ' ' + new Date(d.modified).toLocaleTimeString() +
' - ' + formatSize(d.size) +
'</div>' +
'</div>' +
'<div class="drawing-actions">' +
'<a href="/draw/' + d.id + '" class="btn btn-small">Open</a>' +
'<button class="btn btn-small btn-danger" onclick="deleteDrawing(\'' + d.id + '\')">Delete</button>' +
'</div>' +
'</div>';
}).join('');
drawings.forEach(function(d) {
container.appendChild(drawingRow(d));
});
}
function formatSize(bytes) {
@ -402,18 +487,64 @@ const dashboardHTML = `<!DOCTYPE html>
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function showNewModal() {
var modalAction = null; // invoked with the input value on confirm
function showModal(title, confirmLabel, initialValue, action) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-confirm').textContent = confirmLabel;
var input = document.getElementById('drawingName');
input.value = initialValue || '';
modalAction = action;
document.getElementById('modal').classList.add('active');
document.getElementById('drawingName').focus();
input.focus();
input.select();
}
function showNewModal() {
showModal('New Drawing', 'Create', '', createDrawing);
}
function showRenameModal(id) {
showModal('Rename Drawing', 'Rename', id, function(value) {
renameDrawing(id, value);
});
}
function hideModal() {
document.getElementById('modal').classList.remove('active');
document.getElementById('drawingName').value = '';
modalAction = null;
}
async function createDrawing() {
var name = document.getElementById('drawingName').value.trim();
function confirmModal() {
if (modalAction) modalAction(document.getElementById('drawingName').value);
}
async function renameDrawing(id, newName) {
newName = (newName || '').trim();
if (!newName || newName === id) {
hideModal();
return;
}
var resp = await fetch('/api/drawings/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (resp.status === 409) {
alert('A drawing with that name already exists.');
return; // keep the modal open so the user can pick another name
}
if (!resp.ok) {
alert('Rename failed: ' + await resp.text());
return;
}
hideModal();
loadDrawings();
}
async function createDrawing(name) {
name = (name || '').trim();
if (!name) {
name = 'drawing-' + Date.now();
}
@ -446,7 +577,7 @@ const dashboardHTML = `<!DOCTYPE html>
}
document.getElementById('drawingName').addEventListener('keypress', function(e) {
if (e.key === 'Enter') createDrawing();
if (e.key === 'Enter') confirmModal();
});
document.getElementById('modal').addEventListener('click', function(e) {

View file

@ -0,0 +1,249 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const testDrawing = `{"type":"excalidraw","version":2,"source":"excalidraw-library","elements":[{"id":"e1"}],"appState":{"viewBackgroundColor":"#ffffff"}}`
func setupDataDir(t *testing.T) {
t.Helper()
dataDir = t.TempDir()
}
// doDrawing sends a request to handleDrawing for the given user and returns the recorder.
func doDrawing(t *testing.T, method, id, body, user string) *httptest.ResponseRecorder {
t.Helper()
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, "/api/drawings/"+id, reader)
if user != "" {
req.Header.Set("X-Authentik-Username", user)
}
w := httptest.NewRecorder()
handleDrawing(w, req)
return w
}
func listDrawings(t *testing.T, user string) []Drawing {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/drawings", nil)
if user != "" {
req.Header.Set("X-Authentik-Username", user)
}
w := httptest.NewRecorder()
handleListDrawings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
var drawings []Drawing
if err := json.Unmarshal(w.Body.Bytes(), &drawings); err != nil {
t.Fatalf("list: bad JSON: %v", err)
}
return drawings
}
func TestPutGetRoundtrip(t *testing.T) {
setupDataDir(t)
if w := doDrawing(t, http.MethodPut, "foo", testDrawing, "alice"); w.Code != http.StatusOK {
t.Fatalf("PUT: expected 200, got %d: %s", w.Code, w.Body.String())
}
w := doDrawing(t, http.MethodGet, "foo", "", "alice")
if w.Code != http.StatusOK {
t.Fatalf("GET: expected 200, got %d", w.Code)
}
if w.Body.String() != testDrawing {
t.Errorf("GET: content mismatch: %s", w.Body.String())
}
}
func TestGetMissing(t *testing.T) {
setupDataDir(t)
if w := doDrawing(t, http.MethodGet, "nope", "", "alice"); w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestListDrawings(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "one", testDrawing, "alice")
doDrawing(t, http.MethodPut, "two", testDrawing, "alice")
drawings := listDrawings(t, "alice")
if len(drawings) != 2 {
t.Fatalf("expected 2 drawings, got %d", len(drawings))
}
ids := map[string]bool{drawings[0].ID: true, drawings[1].ID: true}
if !ids["one"] || !ids["two"] {
t.Errorf("unexpected ids: %v", ids)
}
for _, d := range drawings {
if d.Name != d.ID {
t.Errorf("name should equal id: %+v", d)
}
}
}
func TestDelete(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusOK {
t.Fatalf("DELETE: expected 200, got %d", w.Code)
}
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound {
t.Fatalf("GET after delete: expected 404, got %d", w.Code)
}
if w := doDrawing(t, http.MethodDelete, "foo", "", "alice"); w.Code != http.StatusNotFound {
t.Fatalf("second DELETE: expected 404, got %d", w.Code)
}
}
func TestPerUserIsolation(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "secret", testDrawing, "alice")
if w := doDrawing(t, http.MethodGet, "secret", "", "bob"); w.Code != http.StatusNotFound {
t.Fatalf("bob should not see alice's drawing, got %d", w.Code)
}
if drawings := listDrawings(t, "bob"); len(drawings) != 0 {
t.Fatalf("bob's list should be empty, got %d", len(drawings))
}
}
// --- rename (PATCH) ---
func renameReq(t *testing.T, id, newName, user string) *httptest.ResponseRecorder {
t.Helper()
return doDrawing(t, http.MethodPatch, id, `{"name":`+strconv(newName)+`}`, user)
}
// strconv JSON-quotes a string without importing encoding/json for a one-liner.
func strconv(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func TestRenameSuccess(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
w := renameReq(t, "foo", "bar", "alice")
if w.Code != http.StatusOK {
t.Fatalf("PATCH: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("PATCH: bad JSON: %v", err)
}
if resp["id"] != "bar" || resp["status"] != "renamed" {
t.Errorf("unexpected response: %v", resp)
}
if w := doDrawing(t, http.MethodGet, "bar", "", "alice"); w.Code != http.StatusOK || w.Body.String() != testDrawing {
t.Errorf("GET new id: code=%d content=%q", w.Code, w.Body.String())
}
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusNotFound {
t.Errorf("GET old id: expected 404, got %d", w.Code)
}
}
func TestRenameConflict(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "a", testDrawing, "alice")
doDrawing(t, http.MethodPut, "b", testDrawing, "alice")
if w := renameReq(t, "a", "b", "alice"); w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
// both drawings intact
for _, id := range []string{"a", "b"} {
if w := doDrawing(t, http.MethodGet, id, "", "alice"); w.Code != http.StatusOK {
t.Errorf("drawing %q should be intact, got %d", id, w.Code)
}
}
}
func TestRenameMissing(t *testing.T) {
setupDataDir(t)
if w := renameReq(t, "nope", "new", "alice"); w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestRenameSameName(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
w := renameReq(t, "foo", "foo", "alice")
if w.Code != http.StatusOK {
t.Fatalf("same-name rename: expected 200, got %d: %s", w.Code, w.Body.String())
}
if w := doDrawing(t, http.MethodGet, "foo", "", "alice"); w.Code != http.StatusOK {
t.Errorf("drawing should be intact, got %d", w.Code)
}
}
func TestRenameInvalidNames(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
for _, name := range []string{"", " ", "../..", "---"} {
if w := renameReq(t, "foo", name, "alice"); w.Code != http.StatusBadRequest {
t.Errorf("rename to %q: expected 400, got %d", name, w.Code)
}
}
// malformed body
if w := doDrawing(t, http.MethodPatch, "foo", `{not json`, "alice"); w.Code != http.StatusBadRequest {
t.Errorf("malformed body: expected 400, got %d", w.Code)
}
}
func TestRenameSanitization(t *testing.T) {
setupDataDir(t)
cases := []struct{ in, want string }{
{"My Drawing!", "My-Drawing-"},
{"net diag.excalidraw", "net-diag"}, // .excalidraw suffix stripped, not mangled
{"a/b\\c", "a-b-c"},
}
for _, c := range cases {
doDrawing(t, http.MethodPut, "src", testDrawing, "alice")
w := renameReq(t, "src", c.in, "alice")
if w.Code != http.StatusOK {
t.Errorf("rename to %q: expected 200, got %d: %s", c.in, w.Code, w.Body.String())
continue
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["id"] != c.want {
t.Errorf("rename to %q: expected id %q, got %q", c.in, c.want, resp["id"])
}
// file must be inside the user dir under the sanitized name
if _, err := os.Stat(filepath.Join(dataDir, "alice", c.want+".excalidraw")); err != nil {
t.Errorf("rename to %q: expected file %q on disk: %v", c.in, c.want, err)
}
doDrawing(t, http.MethodDelete, resp["id"], "", "alice")
}
}
func TestRenameTraversalStaysInUserDir(t *testing.T) {
setupDataDir(t)
doDrawing(t, http.MethodPut, "foo", testDrawing, "alice")
w := renameReq(t, "foo", "../../../etc/passwd", "alice")
if w.Code == http.StatusOK {
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if strings.Contains(resp["id"], "/") || strings.Contains(resp["id"], "..") {
t.Fatalf("traversal characters survived: %q", resp["id"])
}
if _, err := os.Stat(filepath.Join(dataDir, "alice", resp["id"]+".excalidraw")); err != nil {
t.Fatalf("renamed file escaped user dir: %v", err)
}
}
// nothing outside the data dir
if _, err := os.Stat(filepath.Join(dataDir, "..", "etc")); err == nil {
t.Fatal("file escaped the data dir")
}
}

View file

@ -8,41 +8,41 @@
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#root { width: 100%; height: 100%; }
.toolbar {
position: fixed;
top: 10px;
left: 10px;
z-index: 1000;
.top-right-ui {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.95);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.top-right-ui a, .top-right-ui button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.toolbar button, .toolbar a {
padding: 6px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
background: #6c5ce7;
color: white;
font-size: 13px;
text-decoration: none;
display: inline-block;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
max-width: 40vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar button:hover, .toolbar a:hover { background: #5b4cdb; }
.toolbar .secondary { background: #ddd; color: #333; }
.toolbar .secondary:hover { background: #ccc; }
.toolbar .title {
font-weight: 600;
padding: 6px 0;
color: #333;
.top-right-ui.theme-light a, .top-right-ui.theme-light button {
background: #ffffff;
color: #1b1b1f;
}
.top-right-ui.theme-dark a, .top-right-ui.theme-dark button {
background: #232329;
color: #e9ecef;
}
.top-right-ui button:hover, .top-right-ui a:hover { border-color: #a29bfe; }
.status {
position: fixed;
bottom: 10px;
right: 10px;
right: 60px;
padding: 6px 12px;
background: rgba(0,0,0,0.7);
color: white;
@ -51,6 +51,7 @@
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.status.show { opacity: 1; }
.loading {
@ -67,11 +68,6 @@
</style>
</head>
<body>
<div class="toolbar">
<a href="/" class="secondary">Back to Library</a>
<span class="title" id="title">Loading...</span>
<button onclick="saveDrawing()">Save</button>
</div>
<div id="root">
<div class="loading">
<div>Loading Excalidraw...</div>
@ -81,16 +77,33 @@
<div id="status" class="status">Saved</div>
<script>
// Replaces #root with an error panel (safe DOM methods, no innerHTML).
function showFatal(title, detail) {
var root = document.getElementById('root');
root.replaceChildren();
var panel = document.createElement('div');
panel.className = 'loading error';
var titleEl = document.createElement('div');
titleEl.textContent = title;
panel.appendChild(titleEl);
if (detail) {
var detailEl = document.createElement('div');
detailEl.style.fontSize = '0.9rem';
detailEl.textContent = detail;
panel.appendChild(detailEl);
}
root.appendChild(panel);
}
// Get drawing ID from URL path: /draw/{id}
var pathParts = window.location.pathname.split('/');
var drawingId = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
if (!drawingId) {
document.getElementById('root').innerHTML = '<div class="loading error">No drawing ID specified</div>';
showFatal('No drawing ID specified');
throw new Error('No drawing ID');
}
document.getElementById('title').textContent = drawingId;
document.title = drawingId + ' - Excalidraw';
var excalidrawAPI = null;
@ -159,6 +172,46 @@
autoSaveTimeout = setTimeout(saveDrawing, 2000);
}
// Renames the current drawing via the API. Returns the new ID, or null
// if the rename was cancelled or failed.
async function renameCurrentDrawing() {
var newName = window.prompt('Rename drawing', drawingId);
if (newName === null) return null;
newName = newName.trim();
if (!newName || newName === drawingId) return null;
// A pending autosave would resurrect the old file after the rename.
clearTimeout(autoSaveTimeout);
var resp;
try {
resp = await fetch('/api/drawings/' + drawingId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
} catch (e) {
showStatus('Rename failed!');
return null;
}
if (resp.status === 409) {
window.alert('A drawing with that name already exists.');
return null;
}
if (!resp.ok) {
window.alert('Rename failed: ' + (await resp.text()));
return null;
}
var result = await resp.json();
drawingId = result.id;
document.title = drawingId + ' - Excalidraw';
window.history.replaceState(null, '', '/draw/' + encodeURIComponent(drawingId));
showStatus('Renamed');
// Flush any unsaved changes to the new file.
saveDrawing();
return drawingId;
}
// Load scripts dynamically
function loadScript(src) {
return new Promise(function(resolve, reject) {
@ -197,33 +250,76 @@
updateLoadStatus('Rendering Excalidraw...');
// Create Excalidraw component
var e = React.createElement;
var MainMenu = ExcalidrawLib.MainMenu;
// Native default menu items, existence-guarded so a library
// update that drops one degrades gracefully.
function defaultItem(name) {
var C = MainMenu && MainMenu.DefaultItems && MainMenu.DefaultItems[name];
return C ? e(C, { key: name }) : null;
}
function App() {
return React.createElement(ExcalidrawLib.Excalidraw, {
var nameState = React.useState(drawingId);
var name = nameState[0], setName = nameState[1];
function onRename() {
renameCurrentDrawing().then(function(newId) {
if (newId) setName(newId);
});
}
// The menu is where the native export features live:
// Export = "Save to..." (.excalidraw), SaveAsImage =
// "Export image..." (PNG / SVG / clipboard).
var menu = MainMenu ? e(MainMenu, { key: 'menu' },
e(MainMenu.Item, { key: 'back', onSelect: function() { window.location.href = '/'; } }, 'Back to Library'),
e(MainMenu.Item, { key: 'save', onSelect: saveDrawing }, 'Save now'),
e(MainMenu.Item, { key: 'rename', onSelect: onRename }, 'Rename drawing…'),
MainMenu.Separator ? e(MainMenu.Separator, { key: 'sep1' }) : null,
defaultItem('LoadScene'),
defaultItem('Export'),
defaultItem('SaveAsImage'),
MainMenu.Separator ? e(MainMenu.Separator, { key: 'sep2' }) : null,
defaultItem('ClearCanvas'),
defaultItem('ToggleTheme'),
defaultItem('ChangeCanvasBackground'),
defaultItem('Help')
) : null;
return e(ExcalidrawLib.Excalidraw, {
initialData: initialData ? {
elements: initialData.elements || [],
appState: initialData.appState || {}
} : undefined,
UIOptions: { canvasActions: { toggleTheme: true } },
excalidrawAPI: function(api) {
excalidrawAPI = api;
console.log('Excalidraw API ready');
},
onChange: onChange
});
onChange: onChange,
renderTopRightUI: function(isMobile, appState) {
return e('div', { className: 'top-right-ui theme-' + (appState.theme || 'light') },
e('a', { key: 'home', href: '/', title: 'Back to Library' }, '← Library'),
e('button', {
key: 'name',
title: 'Click to rename',
onClick: onRename
}, name + ' ✎')
);
}
}, menu);
}
var root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
root.render(e(App));
console.log('Excalidraw rendered successfully');
} catch (e) {
console.error('Init error:', e);
document.getElementById('root').innerHTML =
'<div class="loading error">' +
'<div>Failed to load Excalidraw</div>' +
'<div style="font-size:0.9rem">' + e.message + '</div>' +
'</div>';
} catch (err) {
console.error('Init error:', err);
showFatal('Failed to load Excalidraw', err.message);
}
}