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::{JsonCrdt, JsonPath, JsonValue, MarkType, RGAText, 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 inline code formatting to a range.
128    #[wasm_bindgen]
129    pub fn apply_code(&mut self, start: usize, end: usize) {
130        self.apply_mark(start, end, MarkType::Code);
131    }
132
133    /// Apply a link to a range.
134    ///
135    /// # Arguments
136    /// * `start` - Starting character index (inclusive)
137    /// * `end` - Ending character index (exclusive)
138    /// * `url` - The URL to link to
139    #[wasm_bindgen]
140    pub fn apply_link(&mut self, start: usize, end: usize, url: &str) {
141        let s = start.min(self.text.len());
142        let e = end.min(self.text.len());
143        if s < e {
144            self.text.add_mark(
145                s,
146                e,
147                MarkType::Link {
148                    url: url.to_string(),
149                },
150            );
151            self.version += 1;
152        }
153    }
154
155    /// Apply a highlight color to a range.
156    ///
157    /// # Arguments
158    /// * `start` - Starting character index (inclusive)
159    /// * `end` - Ending character index (exclusive)
160    /// * `color` - CSS color string (e.g., "#FFEAA7")
161    #[wasm_bindgen]
162    pub fn apply_highlight(&mut self, start: usize, end: usize, color: &str) {
163        let s = start.min(self.text.len());
164        let e = end.min(self.text.len());
165        if s < e {
166            self.text.add_mark(
167                s,
168                e,
169                MarkType::Highlight {
170                    color: color.to_string(),
171                },
172            );
173            self.version += 1;
174        }
175    }
176
177    /// Apply a comment annotation to a range.
178    ///
179    /// # Arguments
180    /// * `start` - Starting character index (inclusive)
181    /// * `end` - Ending character index (exclusive)
182    /// * `author` - Comment author name/id
183    /// * `content` - Comment body
184    #[wasm_bindgen]
185    pub fn apply_comment(&mut self, start: usize, end: usize, author: &str, content: &str) {
186        let s = start.min(self.text.len());
187        let e = end.min(self.text.len());
188        if s < e {
189            self.text.add_mark(
190                s,
191                e,
192                MarkType::Comment {
193                    author: author.to_string(),
194                    content: content.to_string(),
195                },
196            );
197            self.version += 1;
198        }
199    }
200
201    /// Apply a custom formatting mark to a range.
202    ///
203    /// # Arguments
204    /// * `start` - Starting character index (inclusive)
205    /// * `end` - Ending character index (exclusive)
206    /// * `name` - Custom mark name
207    /// * `value` - Custom mark value
208    #[wasm_bindgen]
209    pub fn apply_custom_mark(&mut self, start: usize, end: usize, name: &str, value: &str) {
210        let s = start.min(self.text.len());
211        let e = end.min(self.text.len());
212        if s < e {
213            self.text.add_mark(
214                s,
215                e,
216                MarkType::Custom {
217                    name: name.to_string(),
218                    value: value.to_string(),
219                },
220            );
221            self.version += 1;
222        }
223    }
224
225    /// Get the plain text content (without formatting).
226    #[wasm_bindgen]
227    pub fn get_text(&self) -> String {
228        self.text.to_string()
229    }
230
231    /// Get the content as HTML with formatting applied.
232    #[wasm_bindgen]
233    pub fn get_html(&self) -> String {
234        self.text.to_html()
235    }
236
237    /// Get the document length in characters.
238    #[wasm_bindgen]
239    pub fn len(&self) -> usize {
240        self.text.len()
241    }
242
243    /// Check if the document is empty.
244    #[wasm_bindgen]
245    pub fn is_empty(&self) -> bool {
246        self.text.len() == 0
247    }
248
249    /// Get the current version number.
250    ///
251    /// This increments with each local operation and can be used
252    /// to track changes for sync purposes.
253    #[wasm_bindgen]
254    pub fn version(&self) -> u64 {
255        self.version
256    }
257
258    /// Get the document ID.
259    #[wasm_bindgen]
260    pub fn doc_id(&self) -> String {
261        self.id.clone()
262    }
263
264    /// Get the replica ID.
265    #[wasm_bindgen]
266    pub fn replica_id(&self) -> String {
267        self.replica_id.clone()
268    }
269
270    /// Serialize the document state for sync.
271    ///
272    /// Returns a base64-encoded binary string that can be sent to other replicas.
273    /// Binary format is more efficient and handles complex key types.
274    #[wasm_bindgen]
275    pub fn serialize(&self) -> Result<String, JsValue> {
276        // Use serde_wasm_bindgen which handles HashMap with non-string keys
277        let js_value = serde_wasm_bindgen::to_value(&self.text)
278            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))?;
279
280        // Convert JsValue to JSON string using js_sys
281        js_sys::JSON::stringify(&js_value)
282            .map(|s| s.into())
283            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))
284    }
285
286    /// Merge remote state into this document.
287    ///
288    /// This is the core CRDT operation - merging is commutative,
289    /// associative, and idempotent, so the order of merges doesn't matter.
290    ///
291    /// # Arguments
292    /// * `remote_state` - JSON string from another replica's `serialize()`
293    #[wasm_bindgen]
294    pub fn merge(&mut self, remote_state: &str) -> Result<(), JsValue> {
295        // Parse the JSON string back to JsValue
296        let js_value = js_sys::JSON::parse(remote_state)
297            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
298
299        // Deserialize using serde_wasm_bindgen
300        let remote: RichText = serde_wasm_bindgen::from_value(js_value)
301            .map_err(|e| JsValue::from_str(&format!("Deserialization error: {}", e)))?;
302
303        self.text = self.text.join(&remote);
304        self.version += 1;
305        Ok(())
306    }
307
308    /// Create a snapshot of the current state.
309    ///
310    /// This returns a JSON object with full document state.
311    #[wasm_bindgen]
312    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
313        let state_js = serde_wasm_bindgen::to_value(&self.text)
314            .map_err(|e| JsValue::from_str(&e.to_string()))?;
315        let state_str: String = js_sys::JSON::stringify(&state_js)
316            .map(|s| s.into())
317            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))?;
318
319        let snapshot = DocumentSnapshot {
320            doc_id: self.id.clone(),
321            replica_id: self.replica_id.clone(),
322            version: self.version,
323            state: state_str,
324        };
325        serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
326    }
327
328    /// Restore from a snapshot.
329    #[wasm_bindgen]
330    pub fn restore(snapshot_js: JsValue) -> Result<CollaborativeDocument, JsValue> {
331        let snapshot: DocumentSnapshot = serde_wasm_bindgen::from_value(snapshot_js)
332            .map_err(|e| JsValue::from_str(&e.to_string()))?;
333
334        // Parse the state JSON string
335        let state_js = js_sys::JSON::parse(&snapshot.state)
336            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
337
338        let text: RichText = serde_wasm_bindgen::from_value(state_js)
339            .map_err(|e| JsValue::from_str(&e.to_string()))?;
340
341        Ok(Self {
342            id: snapshot.doc_id,
343            replica_id: snapshot.replica_id,
344            text,
345            version: snapshot.version,
346        })
347    }
348
349    // Internal helper
350    fn apply_mark(&mut self, start: usize, end: usize, mark: MarkType) {
351        let s = start.min(self.text.len());
352        let e = end.min(self.text.len());
353        if s < e {
354            self.text.add_mark(s, e, mark);
355            self.version += 1;
356        }
357    }
358}
359
360/// Document snapshot for persistence/sync
361#[derive(Debug, Clone, Serialize, Deserialize)]
362struct DocumentSnapshot {
363    doc_id: String,
364    replica_id: String,
365    version: u64,
366    state: String,
367}
368
369// ============================================================================
370// TextDocument (Plain Text / RGA)
371// ============================================================================
372
373/// A collaborative plain text document backed by RGAText.
374#[wasm_bindgen]
375pub struct TextDocument {
376    id: String,
377    replica_id: String,
378    text: RGAText,
379    version: u64,
380}
381
382#[wasm_bindgen]
383impl TextDocument {
384    #[wasm_bindgen(constructor)]
385    pub fn new(doc_id: &str, replica_id: &str) -> Self {
386        Self {
387            id: doc_id.to_string(),
388            replica_id: replica_id.to_string(),
389            text: RGAText::new(replica_id),
390            version: 0,
391        }
392    }
393
394    #[wasm_bindgen]
395    pub fn insert(&mut self, position: usize, text: &str) {
396        let pos = position.min(self.text.len());
397        self.text.insert(pos, text);
398        self.version += 1;
399    }
400
401    #[wasm_bindgen]
402    pub fn delete(&mut self, position: usize, length: usize) {
403        let pos = position.min(self.text.len());
404        let len = length.min(self.text.len().saturating_sub(pos));
405        if len > 0 {
406            self.text.delete(pos, len);
407            self.version += 1;
408        }
409    }
410
411    #[wasm_bindgen]
412    pub fn replace(&mut self, start: usize, end: usize, text: &str) {
413        let s = start.min(self.text.len());
414        let e = end.min(self.text.len());
415        if s <= e {
416            self.text.replace(s, e, text);
417            self.version += 1;
418        }
419    }
420
421    #[wasm_bindgen]
422    pub fn splice(&mut self, position: usize, delete_count: usize, insert: &str) {
423        let pos = position.min(self.text.len());
424        self.text.splice(pos, delete_count, insert);
425        self.version += 1;
426    }
427
428    #[wasm_bindgen]
429    pub fn get_text(&self) -> String {
430        self.text.to_string()
431    }
432
433    #[wasm_bindgen]
434    pub fn len(&self) -> usize {
435        self.text.len()
436    }
437
438    #[wasm_bindgen]
439    pub fn is_empty(&self) -> bool {
440        self.text.is_empty()
441    }
442
443    #[wasm_bindgen]
444    pub fn version(&self) -> u64 {
445        self.version
446    }
447
448    #[wasm_bindgen]
449    pub fn doc_id(&self) -> String {
450        self.id.clone()
451    }
452
453    #[wasm_bindgen]
454    pub fn replica_id(&self) -> String {
455        self.replica_id.clone()
456    }
457
458    #[wasm_bindgen]
459    pub fn serialize(&self) -> Result<String, JsValue> {
460        let js_value = serde_wasm_bindgen::to_value(&self.text)
461            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))?;
462
463        js_sys::JSON::stringify(&js_value)
464            .map(|s| s.into())
465            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))
466    }
467
468    #[wasm_bindgen]
469    pub fn merge(&mut self, remote_state: &str) -> Result<(), JsValue> {
470        let js_value = js_sys::JSON::parse(remote_state)
471            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
472
473        let remote: RGAText = serde_wasm_bindgen::from_value(js_value)
474            .map_err(|e| JsValue::from_str(&format!("Deserialization error: {}", e)))?;
475
476        self.text = self.text.join(&remote);
477        self.version += 1;
478        Ok(())
479    }
480
481    #[wasm_bindgen]
482    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
483        let state_js = serde_wasm_bindgen::to_value(&self.text)
484            .map_err(|e| JsValue::from_str(&e.to_string()))?;
485        let state_str: String = js_sys::JSON::stringify(&state_js)
486            .map(|s| s.into())
487            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))?;
488
489        let snapshot = DocumentSnapshot {
490            doc_id: self.id.clone(),
491            replica_id: self.replica_id.clone(),
492            version: self.version,
493            state: state_str,
494        };
495        serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
496    }
497
498    #[wasm_bindgen]
499    pub fn restore(snapshot_js: JsValue) -> Result<TextDocument, JsValue> {
500        let snapshot: DocumentSnapshot = serde_wasm_bindgen::from_value(snapshot_js)
501            .map_err(|e| JsValue::from_str(&e.to_string()))?;
502
503        let state_js = js_sys::JSON::parse(&snapshot.state)
504            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
505
506        let text: RGAText =
507            serde_wasm_bindgen::from_value(state_js).map_err(|e| JsValue::from_str(&e.to_string()))?;
508
509        Ok(Self {
510            id: snapshot.doc_id,
511            replica_id: snapshot.replica_id,
512            text,
513            version: snapshot.version,
514        })
515    }
516}
517
518// ============================================================================
519// RichTextDocument (explicit rich-text wrapper)
520// ============================================================================
521
522/// Explicit rich text wrapper for SDK-style API naming.
523///
524/// This wraps `CollaborativeDocument` and exposes the same rich-text CRDT behavior.
525#[wasm_bindgen]
526pub struct RichTextDocument {
527    inner: CollaborativeDocument,
528}
529
530#[wasm_bindgen]
531impl RichTextDocument {
532    #[wasm_bindgen(constructor)]
533    pub fn new(doc_id: &str, replica_id: &str) -> Self {
534        Self {
535            inner: CollaborativeDocument::new(doc_id, replica_id),
536        }
537    }
538
539    #[wasm_bindgen]
540    pub fn insert(&mut self, position: usize, text: &str) {
541        self.inner.insert(position, text);
542    }
543
544    #[wasm_bindgen]
545    pub fn delete(&mut self, position: usize, length: usize) {
546        self.inner.delete(position, length);
547    }
548
549    #[wasm_bindgen]
550    pub fn apply_bold(&mut self, start: usize, end: usize) {
551        self.inner.apply_bold(start, end);
552    }
553
554    #[wasm_bindgen]
555    pub fn apply_italic(&mut self, start: usize, end: usize) {
556        self.inner.apply_italic(start, end);
557    }
558
559    #[wasm_bindgen]
560    pub fn apply_underline(&mut self, start: usize, end: usize) {
561        self.inner.apply_underline(start, end);
562    }
563
564    #[wasm_bindgen]
565    pub fn apply_strikethrough(&mut self, start: usize, end: usize) {
566        self.inner.apply_strikethrough(start, end);
567    }
568
569    #[wasm_bindgen]
570    pub fn apply_code(&mut self, start: usize, end: usize) {
571        self.inner.apply_code(start, end);
572    }
573
574    #[wasm_bindgen]
575    pub fn apply_link(&mut self, start: usize, end: usize, url: &str) {
576        self.inner.apply_link(start, end, url);
577    }
578
579    #[wasm_bindgen]
580    pub fn apply_highlight(&mut self, start: usize, end: usize, color: &str) {
581        self.inner.apply_highlight(start, end, color);
582    }
583
584    #[wasm_bindgen]
585    pub fn apply_comment(&mut self, start: usize, end: usize, author: &str, content: &str) {
586        self.inner.apply_comment(start, end, author, content);
587    }
588
589    #[wasm_bindgen]
590    pub fn apply_custom_mark(&mut self, start: usize, end: usize, name: &str, value: &str) {
591        self.inner.apply_custom_mark(start, end, name, value);
592    }
593
594    #[wasm_bindgen]
595    pub fn get_text(&self) -> String {
596        self.inner.get_text()
597    }
598
599    #[wasm_bindgen]
600    pub fn get_html(&self) -> String {
601        self.inner.get_html()
602    }
603
604    #[wasm_bindgen]
605    pub fn len(&self) -> usize {
606        self.inner.len()
607    }
608
609    #[wasm_bindgen]
610    pub fn is_empty(&self) -> bool {
611        self.inner.is_empty()
612    }
613
614    #[wasm_bindgen]
615    pub fn version(&self) -> u64 {
616        self.inner.version()
617    }
618
619    #[wasm_bindgen]
620    pub fn doc_id(&self) -> String {
621        self.inner.doc_id()
622    }
623
624    #[wasm_bindgen]
625    pub fn replica_id(&self) -> String {
626        self.inner.replica_id()
627    }
628
629    #[wasm_bindgen]
630    pub fn serialize(&self) -> Result<String, JsValue> {
631        self.inner.serialize()
632    }
633
634    #[wasm_bindgen]
635    pub fn merge(&mut self, remote_state: &str) -> Result<(), JsValue> {
636        self.inner.merge(remote_state)
637    }
638
639    #[wasm_bindgen]
640    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
641        self.inner.snapshot()
642    }
643
644    #[wasm_bindgen]
645    pub fn restore(snapshot_js: JsValue) -> Result<RichTextDocument, JsValue> {
646        Ok(Self {
647            inner: CollaborativeDocument::restore(snapshot_js)?,
648        })
649    }
650}
651
652// ============================================================================
653// JsonDocument (JSON CRDT)
654// ============================================================================
655
656/// A collaborative JSON document backed by JsonCrdt.
657#[wasm_bindgen]
658pub struct JsonDocument {
659    id: String,
660    replica_id: String,
661    doc: JsonCrdt,
662    version: u64,
663}
664
665#[wasm_bindgen]
666impl JsonDocument {
667    #[wasm_bindgen(constructor)]
668    pub fn new(doc_id: &str, replica_id: &str) -> Self {
669        Self {
670            id: doc_id.to_string(),
671            replica_id: replica_id.to_string(),
672            doc: JsonCrdt::new(replica_id),
673            version: 0,
674        }
675    }
676
677    #[wasm_bindgen]
678    pub fn set_string(&mut self, path: &str, value: &str) -> Result<(), JsValue> {
679        self.doc
680            .set(&JsonPath::parse(path), JsonValue::String(value.to_string()))
681            .map_err(|e| JsValue::from_str(&e.to_string()))?;
682        self.version += 1;
683        Ok(())
684    }
685
686    #[wasm_bindgen]
687    pub fn set_int(&mut self, path: &str, value: i64) -> Result<(), JsValue> {
688        self.doc
689            .set(&JsonPath::parse(path), JsonValue::Int(value))
690            .map_err(|e| JsValue::from_str(&e.to_string()))?;
691        self.version += 1;
692        Ok(())
693    }
694
695    #[wasm_bindgen]
696    pub fn set_float(&mut self, path: &str, value: f64) -> Result<(), JsValue> {
697        self.doc
698            .set(&JsonPath::parse(path), JsonValue::Float(value))
699            .map_err(|e| JsValue::from_str(&e.to_string()))?;
700        self.version += 1;
701        Ok(())
702    }
703
704    #[wasm_bindgen]
705    pub fn set_bool(&mut self, path: &str, value: bool) -> Result<(), JsValue> {
706        self.doc
707            .set(&JsonPath::parse(path), JsonValue::Bool(value))
708            .map_err(|e| JsValue::from_str(&e.to_string()))?;
709        self.version += 1;
710        Ok(())
711    }
712
713    #[wasm_bindgen]
714    pub fn set_null(&mut self, path: &str) -> Result<(), JsValue> {
715        self.doc
716            .set(&JsonPath::parse(path), JsonValue::Null)
717            .map_err(|e| JsValue::from_str(&e.to_string()))?;
718        self.version += 1;
719        Ok(())
720    }
721
722    #[wasm_bindgen]
723    pub fn set_object(&mut self, path: &str) -> Result<(), JsValue> {
724        self.doc
725            .set_object(&JsonPath::parse(path))
726            .map_err(|e| JsValue::from_str(&e.to_string()))?;
727        self.version += 1;
728        Ok(())
729    }
730
731    #[wasm_bindgen]
732    pub fn set_array(&mut self, path: &str) -> Result<(), JsValue> {
733        self.doc
734            .set_array(&JsonPath::parse(path))
735            .map_err(|e| JsValue::from_str(&e.to_string()))?;
736        self.version += 1;
737        Ok(())
738    }
739
740    #[wasm_bindgen]
741    pub fn array_push_string(&mut self, path: &str, value: &str) -> Result<(), JsValue> {
742        let arr_id = self.get_array_id(path)?;
743        self.doc
744            .array_push(&arr_id, JsonValue::String(value.to_string()))
745            .map_err(|e| JsValue::from_str(&e.to_string()))?;
746        self.version += 1;
747        Ok(())
748    }
749
750    #[wasm_bindgen]
751    pub fn array_push_int(&mut self, path: &str, value: i64) -> Result<(), JsValue> {
752        let arr_id = self.get_array_id(path)?;
753        self.doc
754            .array_push(&arr_id, JsonValue::Int(value))
755            .map_err(|e| JsValue::from_str(&e.to_string()))?;
756        self.version += 1;
757        Ok(())
758    }
759
760    #[wasm_bindgen]
761    pub fn array_push_float(&mut self, path: &str, value: f64) -> Result<(), JsValue> {
762        let arr_id = self.get_array_id(path)?;
763        self.doc
764            .array_push(&arr_id, JsonValue::Float(value))
765            .map_err(|e| JsValue::from_str(&e.to_string()))?;
766        self.version += 1;
767        Ok(())
768    }
769
770    #[wasm_bindgen]
771    pub fn array_push_bool(&mut self, path: &str, value: bool) -> Result<(), JsValue> {
772        let arr_id = self.get_array_id(path)?;
773        self.doc
774            .array_push(&arr_id, JsonValue::Bool(value))
775            .map_err(|e| JsValue::from_str(&e.to_string()))?;
776        self.version += 1;
777        Ok(())
778    }
779
780    #[wasm_bindgen]
781    pub fn array_push_null(&mut self, path: &str) -> Result<(), JsValue> {
782        let arr_id = self.get_array_id(path)?;
783        self.doc
784            .array_push(&arr_id, JsonValue::Null)
785            .map_err(|e| JsValue::from_str(&e.to_string()))?;
786        self.version += 1;
787        Ok(())
788    }
789
790    #[wasm_bindgen]
791    pub fn array_remove(&mut self, path: &str, index: usize) -> Result<JsValue, JsValue> {
792        let arr_id = self.get_array_id(path)?;
793        let removed = self
794            .doc
795            .array_remove(&arr_id, index)
796            .map_err(|e| JsValue::from_str(&e.to_string()))?;
797        self.version += 1;
798
799        let removed_json = match removed {
800            JsonValue::Null => serde_json::Value::Null,
801            JsonValue::Bool(b) => serde_json::Value::Bool(b),
802            JsonValue::Int(i) => serde_json::Value::Number(i.into()),
803            JsonValue::Float(f) => serde_json::Number::from_f64(f)
804                .map(serde_json::Value::Number)
805                .unwrap_or(serde_json::Value::Null),
806            JsonValue::String(s) => serde_json::Value::String(s),
807            JsonValue::Array(_) | JsonValue::Object(_) => serde_json::Value::String(
808                "[complex_json_reference]".to_string(),
809            ),
810        };
811
812        serde_wasm_bindgen::to_value(&removed_json).map_err(|e| JsValue::from_str(&e.to_string()))
813    }
814
815    #[wasm_bindgen]
816    pub fn delete(&mut self, path: &str) -> Result<(), JsValue> {
817        self.doc
818            .delete(&JsonPath::parse(path))
819            .map_err(|e| JsValue::from_str(&e.to_string()))?;
820        self.version += 1;
821        Ok(())
822    }
823
824    #[wasm_bindgen]
825    pub fn get(&self, path: &str) -> Result<JsValue, JsValue> {
826        let root = self.doc.to_json();
827        let maybe_value = get_json_at_dot_path(&root, path);
828        match maybe_value {
829            Some(value) => {
830                serde_wasm_bindgen::to_value(&value).map_err(|e| JsValue::from_str(&e.to_string()))
831            }
832            None => Ok(JsValue::UNDEFINED),
833        }
834    }
835
836    #[wasm_bindgen]
837    pub fn to_json(&self) -> Result<JsValue, JsValue> {
838        serde_wasm_bindgen::to_value(&self.doc.to_json())
839            .map_err(|e| JsValue::from_str(&e.to_string()))
840    }
841
842    #[wasm_bindgen]
843    pub fn keys(&self) -> Result<JsValue, JsValue> {
844        let keys = self.doc.keys();
845        serde_wasm_bindgen::to_value(&keys).map_err(|e| JsValue::from_str(&e.to_string()))
846    }
847
848    #[wasm_bindgen]
849    pub fn contains_key(&self, key: &str) -> bool {
850        self.doc.contains_key(key)
851    }
852
853    #[wasm_bindgen]
854    pub fn version(&self) -> u64 {
855        self.version
856    }
857
858    #[wasm_bindgen]
859    pub fn doc_id(&self) -> String {
860        self.id.clone()
861    }
862
863    #[wasm_bindgen]
864    pub fn replica_id(&self) -> String {
865        self.replica_id.clone()
866    }
867
868    #[wasm_bindgen]
869    pub fn serialize(&self) -> Result<String, JsValue> {
870        let js_value = serde_wasm_bindgen::to_value(&self.doc)
871            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))?;
872
873        js_sys::JSON::stringify(&js_value)
874            .map(|s| s.into())
875            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))
876    }
877
878    #[wasm_bindgen]
879    pub fn merge(&mut self, remote_state: &str) -> Result<(), JsValue> {
880        let js_value = js_sys::JSON::parse(remote_state)
881            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
882
883        let remote: JsonCrdt = serde_wasm_bindgen::from_value(js_value)
884            .map_err(|e| JsValue::from_str(&format!("Deserialization error: {}", e)))?;
885
886        self.doc = self.doc.join(&remote);
887        self.version += 1;
888        Ok(())
889    }
890
891    #[wasm_bindgen]
892    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
893        let state_js = serde_wasm_bindgen::to_value(&self.doc)
894            .map_err(|e| JsValue::from_str(&e.to_string()))?;
895        let state_str: String = js_sys::JSON::stringify(&state_js)
896            .map(|s| s.into())
897            .map_err(|e| JsValue::from_str(&format!("JSON stringify error: {:?}", e)))?;
898
899        let snapshot = DocumentSnapshot {
900            doc_id: self.id.clone(),
901            replica_id: self.replica_id.clone(),
902            version: self.version,
903            state: state_str,
904        };
905        serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
906    }
907
908    #[wasm_bindgen]
909    pub fn restore(snapshot_js: JsValue) -> Result<JsonDocument, JsValue> {
910        let snapshot: DocumentSnapshot = serde_wasm_bindgen::from_value(snapshot_js)
911            .map_err(|e| JsValue::from_str(&e.to_string()))?;
912
913        let state_js = js_sys::JSON::parse(&snapshot.state)
914            .map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?;
915
916        let doc: JsonCrdt =
917            serde_wasm_bindgen::from_value(state_js).map_err(|e| JsValue::from_str(&e.to_string()))?;
918
919        Ok(Self {
920            id: snapshot.doc_id,
921            replica_id: snapshot.replica_id,
922            doc,
923            version: snapshot.version,
924        })
925    }
926}
927
928impl JsonDocument {
929    fn get_array_id(&self, path: &str) -> Result<mdcs_db::json_crdt::ArrayId, JsValue> {
930        let json_path = JsonPath::parse(path);
931        let value = self
932            .doc
933            .get(&json_path)
934            .ok_or_else(|| JsValue::from_str(&format!("Path not found: {}", path)))?;
935
936        match value {
937            JsonValue::Array(id) => Ok(id.clone()),
938            _ => Err(JsValue::from_str(&format!(
939                "Path is not an array: {}",
940                path
941            ))),
942        }
943    }
944}
945
946fn get_json_at_dot_path(root: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
947    if path.is_empty() {
948        return Some(root.clone());
949    }
950
951    let mut current = root;
952    for seg in path.split('.') {
953        if let Ok(idx) = seg.parse::<usize>() {
954            current = current.get(idx)?;
955        } else {
956            current = current.get(seg)?;
957        }
958    }
959
960    Some(current.clone())
961}
962
963// ============================================================================
964// UserPresence
965// ============================================================================
966
967/// User presence information for collaborative UI.
968///
969/// Tracks cursor position, selection, and user metadata for
970/// rendering remote user cursors.
971#[wasm_bindgen]
972pub struct UserPresence {
973    user_id: String,
974    user_name: String,
975    color: String,
976    cursor_position: Option<usize>,
977    selection_start: Option<usize>,
978    selection_end: Option<usize>,
979}
980
981#[wasm_bindgen]
982impl UserPresence {
983    /// Create a new user presence.
984    ///
985    /// # Arguments
986    /// * `user_id` - Unique user identifier
987    /// * `user_name` - Display name
988    /// * `color` - Hex color for cursor (e.g., "#FF6B6B")
989    #[wasm_bindgen(constructor)]
990    pub fn new(user_id: &str, user_name: &str, color: &str) -> Self {
991        Self {
992            user_id: user_id.to_string(),
993            user_name: user_name.to_string(),
994            color: color.to_string(),
995            cursor_position: None,
996            selection_start: None,
997            selection_end: None,
998        }
999    }
1000
1001    /// Set cursor position (clears selection).
1002    #[wasm_bindgen]
1003    pub fn set_cursor(&mut self, position: usize) {
1004        self.cursor_position = Some(position);
1005        self.selection_start = None;
1006        self.selection_end = None;
1007    }
1008
1009    /// Set selection range.
1010    #[wasm_bindgen]
1011    pub fn set_selection(&mut self, start: usize, end: usize) {
1012        self.cursor_position = Some(end);
1013        self.selection_start = Some(start.min(end));
1014        self.selection_end = Some(start.max(end));
1015    }
1016
1017    /// Clear cursor and selection.
1018    #[wasm_bindgen]
1019    pub fn clear(&mut self) {
1020        self.cursor_position = None;
1021        self.selection_start = None;
1022        self.selection_end = None;
1023    }
1024
1025    /// Get user ID.
1026    #[wasm_bindgen(getter)]
1027    pub fn user_id(&self) -> String {
1028        self.user_id.clone()
1029    }
1030
1031    /// Get user name.
1032    #[wasm_bindgen(getter)]
1033    pub fn user_name(&self) -> String {
1034        self.user_name.clone()
1035    }
1036
1037    /// Get user color.
1038    #[wasm_bindgen(getter)]
1039    pub fn color(&self) -> String {
1040        self.color.clone()
1041    }
1042
1043    /// Get cursor position.
1044    #[wasm_bindgen(getter)]
1045    pub fn cursor(&self) -> Option<usize> {
1046        self.cursor_position
1047    }
1048
1049    /// Get selection start.
1050    #[wasm_bindgen(getter)]
1051    pub fn selection_start(&self) -> Option<usize> {
1052        self.selection_start
1053    }
1054
1055    /// Get selection end.
1056    #[wasm_bindgen(getter)]
1057    pub fn selection_end(&self) -> Option<usize> {
1058        self.selection_end
1059    }
1060
1061    /// Check if user has a selection (not just cursor).
1062    #[wasm_bindgen]
1063    pub fn has_selection(&self) -> bool {
1064        self.selection_start.is_some() && self.selection_end.is_some()
1065    }
1066
1067    /// Serialize to JSON for network transmission.
1068    #[wasm_bindgen]
1069    pub fn to_json(&self) -> Result<JsValue, JsValue> {
1070        let data = PresenceData {
1071            user_id: self.user_id.clone(),
1072            user_name: self.user_name.clone(),
1073            color: self.color.clone(),
1074            cursor: self.cursor_position,
1075            selection_start: self.selection_start,
1076            selection_end: self.selection_end,
1077        };
1078        serde_wasm_bindgen::to_value(&data).map_err(|e| JsValue::from_str(&e.to_string()))
1079    }
1080
1081    /// Deserialize from JSON.
1082    #[wasm_bindgen]
1083    pub fn from_json(js: JsValue) -> Result<UserPresence, JsValue> {
1084        let data: PresenceData =
1085            serde_wasm_bindgen::from_value(js).map_err(|e| JsValue::from_str(&e.to_string()))?;
1086
1087        Ok(Self {
1088            user_id: data.user_id,
1089            user_name: data.user_name,
1090            color: data.color,
1091            cursor_position: data.cursor,
1092            selection_start: data.selection_start,
1093            selection_end: data.selection_end,
1094        })
1095    }
1096}
1097
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1099struct PresenceData {
1100    user_id: String,
1101    user_name: String,
1102    color: String,
1103    cursor: Option<usize>,
1104    selection_start: Option<usize>,
1105    selection_end: Option<usize>,
1106}
1107
1108// ============================================================================
1109// Utility Functions
1110// ============================================================================
1111
1112/// Generate a unique replica ID.
1113///
1114/// Uses timestamp + random string for uniqueness.
1115#[wasm_bindgen]
1116pub fn generate_replica_id() -> String {
1117    let timestamp = js_sys::Date::now() as u64;
1118    let random: u32 = js_sys::Math::random().to_bits() as u32;
1119    format!("{}-{:x}", timestamp, random)
1120}
1121
1122/// Generate a random user color from a preset palette.
1123#[wasm_bindgen]
1124pub fn generate_user_color() -> String {
1125    let colors = [
1126        "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
1127        "#E74C3C", "#3498DB", "#2ECC71", "#9B59B6", "#1ABC9C", "#F39C12", "#E91E63", "#00BCD4",
1128    ];
1129    let idx = (js_sys::Math::random() * colors.len() as f64) as usize;
1130    colors[idx % colors.len()].to_string()
1131}
1132
1133/// Log a message to the browser console.
1134#[wasm_bindgen]
1135pub fn console_log(message: &str) {
1136    web_sys::console::log_1(&JsValue::from_str(message));
1137}
1138
1139// ============================================================================
1140// Tests
1141// ============================================================================
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    #[test]
1148    fn test_document_creation() {
1149        let doc = CollaborativeDocument::new("doc-1", "replica-1");
1150        assert_eq!(doc.doc_id(), "doc-1");
1151        assert_eq!(doc.replica_id(), "replica-1");
1152        assert_eq!(doc.len(), 0);
1153        assert!(doc.is_empty());
1154    }
1155
1156    #[test]
1157    fn test_insert_and_delete() {
1158        let mut doc = CollaborativeDocument::new("doc-1", "replica-1");
1159
1160        doc.insert(0, "Hello, World!");
1161        assert_eq!(doc.get_text(), "Hello, World!");
1162        assert_eq!(doc.len(), 13);
1163
1164        doc.delete(5, 2); // Delete ", "
1165        assert_eq!(doc.get_text(), "HelloWorld!");
1166    }
1167
1168    #[test]
1169    fn test_formatting() {
1170        let mut doc = CollaborativeDocument::new("doc-1", "replica-1");
1171
1172        doc.insert(0, "Hello World");
1173        doc.apply_bold(0, 5);
1174        doc.apply_italic(6, 11);
1175
1176        let html = doc.get_html();
1177        assert!(html.contains("<b>") || html.contains("<strong>"));
1178        assert!(html.contains("<i>") || html.contains("<em>"));
1179    }
1180
1181    // Note: serialize/merge tests require WASM environment
1182    // Use wasm-bindgen-test for full integration testing
1183    // The RichText serialization uses HashMap<MarkId, Mark> which needs special handling
1184
1185    #[test]
1186    fn test_crdt_merge_convergence() {
1187        // Test the underlying CRDT merge via Lattice trait
1188        let mut doc1 = CollaborativeDocument::new("doc-1", "replica-1");
1189        let mut doc2 = CollaborativeDocument::new("doc-1", "replica-2");
1190
1191        doc1.insert(0, "Hello");
1192        doc2.insert(0, "World");
1193
1194        // Use the Lattice join directly (no JSON serialization needed)
1195        let text1_clone = doc1.text.clone();
1196        let text2_clone = doc2.text.clone();
1197
1198        doc1.text = doc1.text.join(&text2_clone);
1199        doc2.text = doc2.text.join(&text1_clone);
1200
1201        // Both should converge to the same state
1202        assert_eq!(doc1.get_text(), doc2.get_text());
1203        // Content should include both insertions
1204        let final_text = doc1.get_text();
1205        assert!(final_text.contains("Hello") || final_text.contains("World"));
1206    }
1207
1208    #[test]
1209    fn test_user_presence() {
1210        let mut presence = UserPresence::new("user-1", "Alice", "#FF6B6B");
1211
1212        assert_eq!(presence.user_id(), "user-1");
1213        assert_eq!(presence.user_name(), "Alice");
1214        assert!(!presence.has_selection());
1215
1216        presence.set_cursor(10);
1217        assert_eq!(presence.cursor(), Some(10));
1218        assert!(!presence.has_selection());
1219
1220        presence.set_selection(5, 15);
1221        assert!(presence.has_selection());
1222        assert_eq!(presence.selection_start(), Some(5));
1223        assert_eq!(presence.selection_end(), Some(15));
1224    }
1225
1226    #[test]
1227    fn test_extended_mark_types() {
1228        let mut doc = CollaborativeDocument::new("doc-1", "replica-1");
1229        doc.insert(0, "hello world");
1230
1231        let initial_version = doc.version();
1232
1233        doc.apply_code(0, 5);
1234        doc.apply_highlight(6, 11, "#FFEAA7");
1235        doc.apply_comment(0, 11, "alice", "review this");
1236        doc.apply_custom_mark(0, 5, "tag", "important");
1237
1238        assert!(doc.version() >= initial_version + 4);
1239        assert_eq!(doc.get_text(), "hello world");
1240        assert_eq!(doc.len(), 11);
1241    }
1242
1243    #[test]
1244    fn test_text_document_api() {
1245        let mut doc = TextDocument::new("text-doc", "replica-1");
1246        doc.insert(0, "Hello");
1247        doc.insert(5, " World");
1248        assert_eq!(doc.get_text(), "Hello World");
1249
1250        doc.replace(6, 11, "Rust");
1251        assert_eq!(doc.get_text(), "Hello Rust");
1252
1253        doc.splice(5, 1, ",");
1254        assert_eq!(doc.get_text(), "Hello,Rust");
1255        assert!(doc.version() > 0);
1256    }
1257
1258    #[test]
1259    fn test_rich_text_document_wrapper() {
1260        let mut doc = RichTextDocument::new("rich-doc", "replica-1");
1261        doc.insert(0, "hello world");
1262        doc.apply_bold(0, 5);
1263        doc.apply_code(6, 11);
1264
1265        assert_eq!(doc.get_text(), "hello world");
1266        assert_eq!(doc.len(), 11);
1267        assert!(doc.version() > 0);
1268    }
1269
1270    #[test]
1271    fn test_json_document_api() {
1272        let mut doc = JsonDocument::new("json-doc", "replica-1");
1273        doc.set_string("name", "Alice").unwrap();
1274        doc.set_int("age", 30).unwrap();
1275        doc.set_bool("active", true).unwrap();
1276        doc.set_object("profile").unwrap();
1277        doc.set_string("profile.city", "Chennai").unwrap();
1278        doc.set_array("tags").unwrap();
1279        doc.array_push_string("tags", "crdt").unwrap();
1280        doc.array_push_string("tags", "wasm").unwrap();
1281
1282        let root_v = doc.doc.to_json();
1283        assert_eq!(root_v["name"], "Alice");
1284        assert_eq!(root_v["profile"]["city"], "Chennai");
1285        assert_eq!(root_v["tags"][0], "crdt");
1286        assert!(doc.version() > 0);
1287    }
1288}