Skip to content

Commit 40c77b2

Browse files
committed
added excalidraw base proj
1 parent 4bb8de2 commit 40c77b2

File tree

779 files changed

+190109
-6
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

779 files changed

+190109
-6
lines changed

QUICKSTART.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ npm run tauri dev
116116
│ │ │ ├── ConnectionDialog.css
117117
│ │ │ └── ExcalidrawWrapper.tsx
118118
│ │ ├── lib/
119-
│ │ │ ├── api.ts
119+
│ │ │ ├── api.ts![1760632999038](image/QUICKSTART/1760632999038.png)
120120
│ │ │ ├── storage.ts
121121
│ │ │ └── websocket.ts
122122
│ │ ├── App.tsx

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A self-hosted, privacy-focused Excalidraw setup with a Tauri desktop application
77
- 🖥️ **Desktop App**: Native Tauri application for Windows, macOS, and Linux
88
- 🔒 **Privacy First**: All data stored locally by default
99
- 🤝 **Optional Collaboration**: Connect to your own server for real-time collaboration
10+
- 📸 **Room Snapshots**: Save and restore drawing states with thumbnails (manual + auto-save)
1011
- 💾 **Multiple Storage Options**: SQLite, filesystem, or in-memory storage
1112
- 🚀 **No Cloud Dependencies**: No Firebase, no external services
1213
-**Fast & Lightweight**: Minimal server with WebSocket support
@@ -139,6 +140,20 @@ cd excalidraw-server
139140
go build -o excalidraw-server .
140141
```
141142

143+
## Room Snapshots
144+
145+
The app includes a powerful snapshot feature for saving and restoring drawing states:
146+
147+
- **📸 Manual Snapshots**: Save snapshots on demand via the menu
148+
- **⏰ Auto-Save**: Automatic snapshots at configurable intervals (default: 5 min)
149+
- **🖼️ Thumbnail Previews**: Visual preview of each snapshot
150+
- **🔧 Configurable**: Per-room settings for max snapshots and auto-save interval
151+
- **🌐 Smart Storage**: Server-side when connected, local when offline
152+
153+
**Access snapshots**: Menu → 📸 Snapshots
154+
155+
For detailed documentation, see [SNAPSHOTS_FEATURE.md](SNAPSHOTS_FEATURE.md).
156+
142157
## Architecture
143158

144159
### Desktop App (Tauri)
@@ -150,16 +165,18 @@ go build -o excalidraw-server .
150165
- Server connection dialog
151166
- WebSocket client for collaboration
152167
- Auto-save functionality
168+
- Room snapshots with thumbnails
153169

154170
### Collaboration Server (Go)
155171

156172
- **WebSocket**: Socket.IO for real-time collaboration
157-
- **REST API**: Simple save/load endpoints
173+
- **REST API**: Save/load endpoints + snapshot management
158174
- **Storage**: Pluggable backends (memory/filesystem/SQLite)
159175
- **Features**:
160176
- Room-based collaboration
161177
- User presence tracking
162178
- Document persistence
179+
- Snapshot storage and retrieval
163180

164181
## License
165182

excalidraw-app/src-tauri/src/commands.rs

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::db::{Drawing, DB};
1+
use crate::db::{Drawing, Snapshot, RoomSettings, DB};
22
use std::time::{SystemTime, UNIX_EPOCH};
33

44
#[tauri::command]
@@ -96,3 +96,175 @@ pub fn delete_drawing(id: String) -> Result<(), String> {
9696

9797
Ok(())
9898
}
99+
100+
// Snapshot-related commands
101+
102+
#[tauri::command]
103+
pub fn save_snapshot(
104+
room_id: String,
105+
name: Option<String>,
106+
description: Option<String>,
107+
thumbnail: Option<String>,
108+
created_by: Option<String>,
109+
data: String,
110+
) -> Result<String, String> {
111+
let timestamp = SystemTime::now()
112+
.duration_since(UNIX_EPOCH)
113+
.unwrap()
114+
.as_secs() as i64;
115+
116+
let id = uuid::Uuid::new_v4().to_string();
117+
118+
let conn = DB.lock().map_err(|e| e.to_string())?;
119+
120+
// Get room settings to check max snapshots
121+
let settings = get_room_settings_internal(&conn, &room_id)?;
122+
123+
// Count existing snapshots
124+
let count: i32 = conn
125+
.query_row(
126+
"SELECT COUNT(*) FROM snapshots WHERE room_id = ?1",
127+
rusqlite::params![&room_id],
128+
|row| row.get(0),
129+
)
130+
.map_err(|e| e.to_string())?;
131+
132+
// If at limit, delete oldest snapshot
133+
if count >= settings.max_snapshots {
134+
conn.execute(
135+
"DELETE FROM snapshots WHERE id = (SELECT id FROM snapshots WHERE room_id = ?1 ORDER BY created_at ASC LIMIT 1)",
136+
rusqlite::params![&room_id],
137+
)
138+
.map_err(|e| e.to_string())?;
139+
}
140+
141+
// Insert new snapshot
142+
conn.execute(
143+
"INSERT INTO snapshots (id, room_id, name, description, thumbnail, created_by, created_at, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
144+
rusqlite::params![&id, &room_id, &name, &description, &thumbnail, &created_by, timestamp, &data],
145+
)
146+
.map_err(|e| e.to_string())?;
147+
148+
Ok(id)
149+
}
150+
151+
#[tauri::command]
152+
pub fn list_snapshots(room_id: String) -> Result<Vec<Snapshot>, String> {
153+
let conn = DB.lock().map_err(|e| e.to_string())?;
154+
155+
let mut stmt = conn
156+
.prepare("SELECT id, room_id, name, description, thumbnail, created_by, created_at, '' as data FROM snapshots WHERE room_id = ?1 ORDER BY created_at DESC")
157+
.map_err(|e| e.to_string())?;
158+
159+
let snapshots = stmt
160+
.query_map(rusqlite::params![&room_id], |row| {
161+
Ok(Snapshot {
162+
id: row.get(0)?,
163+
room_id: row.get(1)?,
164+
name: row.get(2)?,
165+
description: row.get(3)?,
166+
thumbnail: row.get(4)?,
167+
created_by: row.get(5)?,
168+
created_at: row.get(6)?,
169+
data: row.get(7)?,
170+
})
171+
})
172+
.map_err(|e| e.to_string())?
173+
.collect::<Result<Vec<_>, _>>()
174+
.map_err(|e| e.to_string())?;
175+
176+
Ok(snapshots)
177+
}
178+
179+
#[tauri::command]
180+
pub fn load_snapshot(id: String) -> Result<Snapshot, String> {
181+
let conn = DB.lock().map_err(|e| e.to_string())?;
182+
183+
let snapshot = conn
184+
.query_row(
185+
"SELECT id, room_id, name, description, thumbnail, created_by, created_at, data FROM snapshots WHERE id = ?1",
186+
rusqlite::params![&id],
187+
|row| {
188+
Ok(Snapshot {
189+
id: row.get(0)?,
190+
room_id: row.get(1)?,
191+
name: row.get(2)?,
192+
description: row.get(3)?,
193+
thumbnail: row.get(4)?,
194+
created_by: row.get(5)?,
195+
created_at: row.get(6)?,
196+
data: row.get(7)?,
197+
})
198+
},
199+
)
200+
.map_err(|e| e.to_string())?;
201+
202+
Ok(snapshot)
203+
}
204+
205+
#[tauri::command]
206+
pub fn delete_snapshot(id: String) -> Result<(), String> {
207+
let conn = DB.lock().map_err(|e| e.to_string())?;
208+
209+
conn.execute("DELETE FROM snapshots WHERE id = ?1", rusqlite::params![&id])
210+
.map_err(|e| e.to_string())?;
211+
212+
Ok(())
213+
}
214+
215+
#[tauri::command]
216+
pub fn update_snapshot_metadata(id: String, name: String, description: String) -> Result<(), String> {
217+
let conn = DB.lock().map_err(|e| e.to_string())?;
218+
219+
conn.execute(
220+
"UPDATE snapshots SET name = ?1, description = ?2 WHERE id = ?3",
221+
rusqlite::params![&name, &description, &id],
222+
)
223+
.map_err(|e| e.to_string())?;
224+
225+
Ok(())
226+
}
227+
228+
// Room settings commands
229+
230+
fn get_room_settings_internal(conn: &rusqlite::Connection, room_id: &str) -> Result<RoomSettings, String> {
231+
conn.query_row(
232+
"SELECT room_id, max_snapshots, auto_save_interval FROM room_settings WHERE room_id = ?1",
233+
rusqlite::params![room_id],
234+
|row| {
235+
Ok(RoomSettings {
236+
room_id: row.get(0)?,
237+
max_snapshots: row.get(1)?,
238+
auto_save_interval: row.get(2)?,
239+
})
240+
},
241+
)
242+
.or_else(|_| {
243+
// Return default settings if not found
244+
Ok(RoomSettings {
245+
room_id: room_id.to_string(),
246+
max_snapshots: 10,
247+
auto_save_interval: 300,
248+
})
249+
})
250+
}
251+
252+
#[tauri::command]
253+
pub fn get_room_settings(room_id: String) -> Result<RoomSettings, String> {
254+
let conn = DB.lock().map_err(|e| e.to_string())?;
255+
get_room_settings_internal(&conn, &room_id)
256+
}
257+
258+
#[tauri::command]
259+
pub fn update_room_settings(room_id: String, max_snapshots: i32, auto_save_interval: i32) -> Result<(), String> {
260+
let conn = DB.lock().map_err(|e| e.to_string())?;
261+
262+
conn.execute(
263+
"INSERT INTO room_settings (room_id, max_snapshots, auto_save_interval) VALUES (?1, ?2, ?3)
264+
ON CONFLICT(room_id) DO UPDATE SET max_snapshots = ?2, auto_save_interval = ?3",
265+
rusqlite::params![&room_id, max_snapshots, auto_save_interval],
266+
)
267+
.map_err(|e| e.to_string())?;
268+
269+
Ok(())
270+
}

excalidraw-app/src-tauri/src/db.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ pub struct Drawing {
1313
pub updated_at: i64,
1414
}
1515

16+
#[derive(Debug, Serialize, Deserialize)]
17+
pub struct Snapshot {
18+
pub id: String,
19+
pub room_id: String,
20+
pub name: Option<String>,
21+
pub description: Option<String>,
22+
pub thumbnail: Option<String>,
23+
pub created_by: Option<String>,
24+
pub created_at: i64,
25+
pub data: String,
26+
}
27+
28+
#[derive(Debug, Serialize, Deserialize)]
29+
pub struct RoomSettings {
30+
pub room_id: String,
31+
pub max_snapshots: i32,
32+
pub auto_save_interval: i32,
33+
}
34+
1635
pub static DB: Lazy<Mutex<Connection>> = Lazy::new(|| {
1736
let conn = Connection::open(get_db_path()).expect("Failed to open database");
1837
init_db(&conn).expect("Failed to initialize database");
@@ -42,5 +61,29 @@ fn init_db(conn: &Connection) -> Result<()> {
4261
)",
4362
[],
4463
)?;
64+
65+
conn.execute(
66+
"CREATE TABLE IF NOT EXISTS snapshots (
67+
id TEXT PRIMARY KEY,
68+
room_id TEXT NOT NULL,
69+
name TEXT,
70+
description TEXT,
71+
thumbnail TEXT,
72+
created_by TEXT,
73+
created_at INTEGER NOT NULL,
74+
data TEXT NOT NULL
75+
)",
76+
[],
77+
)?;
78+
79+
conn.execute(
80+
"CREATE TABLE IF NOT EXISTS room_settings (
81+
room_id TEXT PRIMARY KEY,
82+
max_snapshots INTEGER DEFAULT 10,
83+
auto_save_interval INTEGER DEFAULT 300
84+
)",
85+
[],
86+
)?;
87+
4588
Ok(())
4689
}

excalidraw-app/src-tauri/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ pub fn run() {
1414
commands::load_drawing,
1515
commands::list_drawings,
1616
commands::delete_drawing,
17+
commands::save_snapshot,
18+
commands::list_snapshots,
19+
commands::load_snapshot,
20+
commands::delete_snapshot,
21+
commands::update_snapshot_metadata,
22+
commands::get_room_settings,
23+
commands::update_room_settings,
1724
])
1825
.run(tauri::generate_context!())
1926
.expect("error while running tauri application");

excalidraw-app/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ function App() {
2020
setShowDialog(false);
2121
}
2222

23-
// Add keyboard shortcut to open connection dialog (Cmd/Ctrl + K)
23+
// Add keyboard shortcuts
2424
const handleKeyDown = (e: KeyboardEvent) => {
25+
// Cmd/Ctrl + K to open connection dialog
2526
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
2627
e.preventDefault();
2728
setShowDialog(true);

0 commit comments

Comments
 (0)