Skip to main content

mdcs_wasm/
lib.rs

1//! # MDCS WebAssembly Bindings
2//!
3//! This crate provides WebAssembly bindings for the MDCS (Merkle-Delta CRDT Store),
4//! enabling real-time collaborative editing in web browsers.
5//!
6//! ## Features
7//!
8//! - **CollaborativeDocument**: Rich text document with CRDT-based conflict resolution
9//! - **UserPresence**: Cursor and selection tracking for collaborative UIs
10//! - **Offline-first**: All operations work locally, sync when connected
11//!
12//! ## Usage
13//!
14//! ```javascript
15//! import init, { CollaborativeDocument, UserPresence } from 'mdcs-wasm';
16//!
17//! await init();
18//!
19//! const doc = new CollaborativeDocument('doc-123', 'user-abc');
20//! doc.insert(0, 'Hello, World!');
21//! doc.apply_bold(0, 5);
22//!
23//! console.log(doc.get_text());  // "Hello, World!"
24//! console.log(doc.get_html());  // "<b>Hello</b>, World!"
25//! ```
26
27use mdcs_core::lattice::Lattice;
28use mdcs_db::{MarkType, RichText};
29use serde::{Deserialize, Serialize};
30use wasm_bindgen::prelude::*;
31
32// Initialize panic hook for better error messages in browser console
33#[wasm_bindgen(start)]
34pub fn init_panic_hook() {
35    #[cfg(feature = "console_error_panic_hook")]
36    console_error_panic_hook::set_once();
37}
38
39// ============================================================================
40// CollaborativeDocument
41// ============================================================================
42
43/// A collaborative rich text document backed by CRDTs.
44///
45/// This is the main entry point for document editing. All operations are
46/// conflict-free and can be merged with remote changes.
47#[wasm_bindgen]
48pub struct CollaborativeDocument {
49    id: String,
50    replica_id: String,
51    text: RichText,
52    version: u64,
53}
54
55#[wasm_bindgen]
56impl CollaborativeDocument {
57    /// Create a new collaborative document.
58    ///
59    /// # Arguments
60    /// * `doc_id` - Unique identifier for this document
61    /// * `replica_id` - Unique identifier for this replica/user
62    #[wasm_bindgen(constructor)]
63    pub fn new(doc_id: &str, replica_id: &str) -> Self {
64        Self {
65            id: doc_id.to_string(),
66            replica_id: replica_id.to_string(),
67            text: RichText::new(replica_id),
68            version: 0,
69        }
70    }
71
72    /// Insert text at a position.
73    ///
74    /// # Arguments
75    /// * `position` - Character index to insert at (0-based)
76    /// * `text` - Text to insert
77    #[wasm_bindgen]
78    pub fn insert(&mut self, position: usize, text: &str) {
79        let pos = position.min(self.text.len());
80        self.text.insert(pos, text);
81        self.version += 1;
82    }
83
84    /// Delete text at a position.
85    ///
86    /// # Arguments
87    /// * `position` - Starting character index (0-based)
88    /// * `length` - Number of characters to delete
89    #[wasm_bindgen]
90    pub fn delete(&mut self, position: usize, length: usize) {
91        let pos = position.min(self.text.len());
92        let len = length.min(self.text.len().saturating_sub(pos));
93        if len > 0 {
94            self.text.delete(pos, len);
95            self.version += 1;
96        }
97    }
98
99    /// Apply bold formatting to a range.
100    ///
101    /// # Arguments
102    /// * `start` - Starting character index (inclusive)
103    /// * `end` - Ending character index (exclusive)
104    #[wasm_bindgen]
105    pub fn apply_bold(&mut self, start: usize, end: usize) {
106        self.apply_mark(start, end, MarkType::Bold);
107    }
108
109    /// Apply italic formatting to a range.
110    #[wasm_bindgen]
111    pub fn apply_italic(&mut self, start: usize, end: usize) {
112        self.apply_mark(start, end, MarkType::Italic);
113    }
114
115    /// Apply underline formatting to a range.
116    #[wasm_bindgen]
117    pub fn apply_underline(&mut self, start: usize, end: usize) {
118        self.apply_mark(start, end, MarkType::Underline);
119    }
120
121    /// Apply strikethrough formatting to a range.
122    #[wasm_bindgen]
123    pub fn apply_strikethrough(&mut self, start: usize, end: usize) {
124        self.apply_mark(start, end, MarkType::Strikethrough);
125    }
126
127    /// Apply a link to a range.
128    ///
129    /// # Arguments
130    /// * `start` - Starting character index (inclusive)
131    /// * `end` - Ending character index (exclusive)
132    /// * `url` - The URL to link to
133    #[wasm_bindgen]
134    pub fn apply_link(&mut self, start: usize, end: usize, url: &str) {
135        let s = start.min(self.text.len());
136        let e = end.min(self.text.len());
137        if s < e {
138            self.text.add_mark(
139                s,
140                e,
141                MarkType::Link {
142                    url: url.to_string(),
143                },
144            );
145            self.version += 1;
146        }
147    }
148
149    /// Get the plain text content (without formatting).
150    #[wasm_bindgen]
151    pub fn get_text(&self) -> String {
152        self.text.to_string()
153    }
154
155    /// Get the content as HTML with formatting applied.
156    #[wasm_bindgen]
157    pub fn get_html(&self) -> String {
158        self.text.to_html()
159    }
160
161    /// Get the document length in characters.
162    #[wasm_bindgen]
163    pub fn len(&self) -> usize {
164        self.text.len()
165    }
166
167    /// Check if the document is empty.
168    #[wasm_bindgen]
169    pub fn is_empty(&self) -> bool {
170        self.text.len() == 0
171    }
172
173    /// Get the current version number.
174    ///
175    /// This increments with each local operation and can be used
176    /// to track changes for sync purposes.
177    #[wasm_bindgen]
178    pub fn version(&self) -> u64 {
179        self.version
180    }
181
182    /// Get the document ID.
183    #[wasm_bindgen]
184    pub fn doc_id(&self) -> String {
185        self.id.clone()
186    }
187
188    /// Get the replica ID.
189    #[wasm_bindgen]
190    pub fn replica_id(&self) -> String {
191        self.replica_id.clone()
192    }
193
194    /// Serialize the document state for sync.
195    ///
196    /// Returns a base64-encoded binary string that can be sent to other replicas.
197    /// Binary format is more efficient and handles complex key types.
198    #[wasm_bindgen]
199    pub fn serialize(&self) -> Result<String, JsValue> {
200        // Use serde_wasm_bindgen which handles HashMap with non-string keys
201        let js_value = serde_wasm_bindgen::to_value(&self.text)
202            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))?;
203
204        // Convert JsValue to JSON string using js_sys
205        js_sys::JSON::stringify(&js_value)
206            .map(|s| s.into())
207            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))
208    }
209
210    /// Merge remote state into this document.
211    ///
212    /// This is the core CRDT operation - merging is commutative,
213    /// associative, and idempotent, so the order of merges doesn't matter.
214    ///
215    /// # Arguments
216    /// * `remote_state` - JSON string from another replica's `serialize()`
217    #[wasm_bindgen]
218    pub fn merge(&mut self, remote_state: &str) -> Result<(), JsValue> {
219        // Parse the JSON string back to JsValue
220        let js_value = js_sys::JSON::parse(remote_state)
221            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
222
223        // Deserialize using serde_wasm_bindgen
224        let remote: RichText = serde_wasm_bindgen::from_value(js_value)
225            .map_err(|e| JsValue::from_str(&format!("Deserialization error: {}", e)))?;
226
227        self.text = self.text.join(&remote);
228        self.version += 1;
229        Ok(())
230    }
231
232    /// Create a snapshot of the current state.
233    ///
234    /// This returns a JSON object with full document state.
235    #[wasm_bindgen]
236    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
237        let state_js = serde_wasm_bindgen::to_value(&self.text)
238            .map_err(|e| JsValue::from_str(&e.to_string()))?;
239        let state_str: String = js_sys::JSON::stringify(&state_js)
240            .map(|s| s.into())
241            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))?;
242
243        let snapshot = DocumentSnapshot {
244            doc_id: self.id.clone(),
245            replica_id: self.replica_id.clone(),
246            version: self.version,
247            state: state_str,
248        };
249        serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
250    }
251
252    /// Restore from a snapshot.
253    #[wasm_bindgen]
254    pub fn restore(snapshot_js: JsValue) -> Result<CollaborativeDocument, JsValue> {
255        let snapshot: DocumentSnapshot = serde_wasm_bindgen::from_value(snapshot_js)
256            .map_err(|e| JsValue::from_str(&e.to_string()))?;
257
258        // Parse the state JSON string
259        let state_js = js_sys::JSON::parse(&snapshot.state)
260            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
261
262        let text: RichText = serde_wasm_bindgen::from_value(state_js)
263            .map_err(|e| JsValue::from_str(&e.to_string()))?;
264
265        Ok(Self {
266            id: snapshot.doc_id,
267            replica_id: snapshot.replica_id,
268            text,
269            version: snapshot.version,
270        })
271    }
272
273    // Internal helper
274    fn apply_mark(&mut self, start: usize, end: usize, mark: MarkType) {
275        let s = start.min(self.text.len());
276        let e = end.min(self.text.len());
277        if s < e {
278            self.text.add_mark(s, e, mark);
279            self.version += 1;
280        }
281    }
282}
283
284/// Document snapshot for persistence/sync
285#[derive(Debug, Clone, Serialize, Deserialize)]
286struct DocumentSnapshot {
287    doc_id: String,
288    replica_id: String,
289    version: u64,
290    state: String,
291}
292
293// ============================================================================
294// UserPresence
295// ============================================================================
296
297/// User presence information for collaborative UI.
298///
299/// Tracks cursor position, selection, and user metadata for
300/// rendering remote user cursors.
301#[wasm_bindgen]
302pub struct UserPresence {
303    user_id: String,
304    user_name: String,
305    color: String,
306    cursor_position: Option<usize>,
307    selection_start: Option<usize>,
308    selection_end: Option<usize>,
309}
310
311#[wasm_bindgen]
312impl UserPresence {
313    /// Create a new user presence.
314    ///
315    /// # Arguments
316    /// * `user_id` - Unique user identifier
317    /// * `user_name` - Display name
318    /// * `color` - Hex color for cursor (e.g., "#FF6B6B")
319    #[wasm_bindgen(constructor)]
320    pub fn new(user_id: &str, user_name: &str, color: &str) -> Self {
321        Self {
322            user_id: user_id.to_string(),
323            user_name: user_name.to_string(),
324            color: color.to_string(),
325            cursor_position: None,
326            selection_start: None,
327            selection_end: None,
328        }
329    }
330
331    /// Set cursor position (clears selection).
332    #[wasm_bindgen]
333    pub fn set_cursor(&mut self, position: usize) {
334        self.cursor_position = Some(position);
335        self.selection_start = None;
336        self.selection_end = None;
337    }
338
339    /// Set selection range.
340    #[wasm_bindgen]
341    pub fn set_selection(&mut self, start: usize, end: usize) {
342        self.cursor_position = Some(end);
343        self.selection_start = Some(start.min(end));
344        self.selection_end = Some(start.max(end));
345    }
346
347    /// Clear cursor and selection.
348    #[wasm_bindgen]
349    pub fn clear(&mut self) {
350        self.cursor_position = None;
351        self.selection_start = None;
352        self.selection_end = None;
353    }
354
355    /// Get user ID.
356    #[wasm_bindgen(getter)]
357    pub fn user_id(&self) -> String {
358        self.user_id.clone()
359    }
360
361    /// Get user name.
362    #[wasm_bindgen(getter)]
363    pub fn user_name(&self) -> String {
364        self.user_name.clone()
365    }
366
367    /// Get user color.
368    #[wasm_bindgen(getter)]
369    pub fn color(&self) -> String {
370        self.color.clone()
371    }
372
373    /// Get cursor position.
374    #[wasm_bindgen(getter)]
375    pub fn cursor(&self) -> Option<usize> {
376        self.cursor_position
377    }
378
379    /// Get selection start.
380    #[wasm_bindgen(getter)]
381    pub fn selection_start(&self) -> Option<usize> {
382        self.selection_start
383    }
384
385    /// Get selection end.
386    #[wasm_bindgen(getter)]
387    pub fn selection_end(&self) -> Option<usize> {
388        self.selection_end
389    }
390
391    /// Check if user has a selection (not just cursor).
392    #[wasm_bindgen]
393    pub fn has_selection(&self) -> bool {
394        self.selection_start.is_some() && self.selection_end.is_some()
395    }
396
397    /// Serialize to JSON for network transmission.
398    #[wasm_bindgen]
399    pub fn to_json(&self) -> Result<JsValue, JsValue> {
400        let data = PresenceData {
401            user_id: self.user_id.clone(),
402            user_name: self.user_name.clone(),
403            color: self.color.clone(),
404            cursor: self.cursor_position,
405            selection_start: self.selection_start,
406            selection_end: self.selection_end,
407        };
408        serde_wasm_bindgen::to_value(&data).map_err(|e| JsValue::from_str(&e.to_string()))
409    }
410
411    /// Deserialize from JSON.
412    #[wasm_bindgen]
413    pub fn from_json(js: JsValue) -> Result<UserPresence, JsValue> {
414        let data: PresenceData =
415            serde_wasm_bindgen::from_value(js).map_err(|e| JsValue::from_str(&e.to_string()))?;
416
417        Ok(Self {
418            user_id: data.user_id,
419            user_name: data.user_name,
420            color: data.color,
421            cursor_position: data.cursor,
422            selection_start: data.selection_start,
423            selection_end: data.selection_end,
424        })
425    }
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
429struct PresenceData {
430    user_id: String,
431    user_name: String,
432    color: String,
433    cursor: Option<usize>,
434    selection_start: Option<usize>,
435    selection_end: Option<usize>,
436}
437
438// ============================================================================
439// Utility Functions
440// ============================================================================
441
442/// Generate a unique replica ID.
443///
444/// Uses timestamp + random string for uniqueness.
445#[wasm_bindgen]
446pub fn generate_replica_id() -> String {
447    let timestamp = js_sys::Date::now() as u64;
448    let random: u32 = js_sys::Math::random().to_bits() as u32;
449    format!("{}-{:x}", timestamp, random)
450}
451
452/// Generate a random user color from a preset palette.
453#[wasm_bindgen]
454pub fn generate_user_color() -> String {
455    let colors = [
456        "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
457        "#E74C3C", "#3498DB", "#2ECC71", "#9B59B6", "#1ABC9C", "#F39C12", "#E91E63", "#00BCD4",
458    ];
459    let idx = (js_sys::Math::random() * colors.len() as f64) as usize;
460    colors[idx % colors.len()].to_string()
461}
462
463/// Log a message to the browser console.
464#[wasm_bindgen]
465pub fn console_log(message: &str) {
466    web_sys::console::log_1(&JsValue::from_str(message));
467}
468
469// ============================================================================
470// Tests
471// ============================================================================
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_document_creation() {
479        let doc = CollaborativeDocument::new("doc-1", "replica-1");
480        assert_eq!(doc.doc_id(), "doc-1");
481        assert_eq!(doc.replica_id(), "replica-1");
482        assert_eq!(doc.len(), 0);
483        assert!(doc.is_empty());
484    }
485
486    #[test]
487    fn test_insert_and_delete() {
488        let mut doc = CollaborativeDocument::new("doc-1", "replica-1");
489
490        doc.insert(0, "Hello, World!");
491        assert_eq!(doc.get_text(), "Hello, World!");
492        assert_eq!(doc.len(), 13);
493
494        doc.delete(5, 2); // Delete ", "
495        assert_eq!(doc.get_text(), "HelloWorld!");
496    }
497
498    #[test]
499    fn test_formatting() {
500        let mut doc = CollaborativeDocument::new("doc-1", "replica-1");
501
502        doc.insert(0, "Hello World");
503        doc.apply_bold(0, 5);
504        doc.apply_italic(6, 11);
505
506        let html = doc.get_html();
507        assert!(html.contains("<b>") || html.contains("<strong>"));
508        assert!(html.contains("<i>") || html.contains("<em>"));
509    }
510
511    // Note: serialize/merge tests require WASM environment
512    // Use wasm-bindgen-test for full integration testing
513    // The RichText serialization uses HashMap<MarkId, Mark> which needs special handling
514
515    #[test]
516    fn test_crdt_merge_convergence() {
517        // Test the underlying CRDT merge via Lattice trait
518        let mut doc1 = CollaborativeDocument::new("doc-1", "replica-1");
519        let mut doc2 = CollaborativeDocument::new("doc-1", "replica-2");
520
521        doc1.insert(0, "Hello");
522        doc2.insert(0, "World");
523
524        // Use the Lattice join directly (no JSON serialization needed)
525        let text1_clone = doc1.text.clone();
526        let text2_clone = doc2.text.clone();
527
528        doc1.text = doc1.text.join(&text2_clone);
529        doc2.text = doc2.text.join(&text1_clone);
530
531        // Both should converge to the same state
532        assert_eq!(doc1.get_text(), doc2.get_text());
533        // Content should include both insertions
534        let final_text = doc1.get_text();
535        assert!(final_text.contains("Hello") || final_text.contains("World"));
536    }
537
538    #[test]
539    fn test_user_presence() {
540        let mut presence = UserPresence::new("user-1", "Alice", "#FF6B6B");
541
542        assert_eq!(presence.user_id(), "user-1");
543        assert_eq!(presence.user_name(), "Alice");
544        assert!(!presence.has_selection());
545
546        presence.set_cursor(10);
547        assert_eq!(presence.cursor(), Some(10));
548        assert!(!presence.has_selection());
549
550        presence.set_selection(5, 15);
551        assert!(presence.has_selection());
552        assert_eq!(presence.selection_start(), Some(5));
553        assert_eq!(presence.selection_end(), Some(15));
554    }
555}