Skip to main content

mdcs_db/
rich_text.rs

1//! Rich Text CRDT - Collaborative rich text with formatting marks.
2//!
3//! Extends RGAText with support for:
4//! - Inline formatting (bold, italic, underline, strikethrough)
5//! - Links and references
6//! - Comments and annotations
7//! - Custom marks for extensibility
8//!
9//! Uses anchor-based marks that reference TextIds for stability.
10
11use crate::rga_text::{RGAText, RGATextDelta, TextId};
12use mdcs_core::lattice::Lattice;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use ulid::Ulid;
16
17/// Unique identifier for a mark (formatting span).
18#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct MarkId {
20    /// The replica that created this mark.
21    pub replica: String,
22    /// Unique identifier within that replica.
23    pub ulid: String,
24}
25
26impl MarkId {
27    pub fn new(replica: impl Into<String>) -> Self {
28        Self {
29            replica: replica.into(),
30            ulid: Ulid::new().to_string(),
31        }
32    }
33}
34
35/// The type/style of a formatting mark.
36#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum MarkType {
38    /// Bold text.
39    Bold,
40    /// Italic text.
41    Italic,
42    /// Underlined text.
43    Underline,
44    /// Strikethrough text.
45    Strikethrough,
46    /// Code/monospace text.
47    Code,
48    /// A hyperlink with URL.
49    Link { url: String },
50    /// A comment/annotation with author and content.
51    Comment { author: String, content: String },
52    /// Highlight with a color.
53    Highlight { color: String },
54    /// Custom mark type for extensibility.
55    Custom { name: String, value: String },
56}
57
58impl MarkType {
59    /// Check if this mark type conflicts with another.
60    /// Conflicting marks cannot overlap.
61    pub fn conflicts_with(&self, other: &MarkType) -> bool {
62        use MarkType::*;
63        matches!(
64            (self, other),
65            (Bold, Bold)
66                | (Italic, Italic)
67                | (Underline, Underline)
68                | (Strikethrough, Strikethrough)
69                | (Code, Code)
70                | (Link { .. }, Link { .. })
71        )
72    }
73}
74
75/// An anchor specifying a position in the text.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub enum Anchor {
78    /// Before all text.
79    Start,
80    /// After all text.
81    End,
82    /// After a specific character (by TextId).
83    After(TextId),
84    /// Before a specific character (by TextId).
85    Before(TextId),
86}
87
88impl Anchor {
89    /// Resolve this anchor to a position in the text.
90    pub fn resolve(&self, text: &RGAText) -> Option<usize> {
91        match self {
92            Anchor::Start => Some(0),
93            Anchor::End => Some(text.len()),
94            Anchor::After(id) => text.id_to_position(id).map(|p| p + 1),
95            Anchor::Before(id) => text.id_to_position(id),
96        }
97    }
98}
99
100/// A formatting mark that spans a range of text.
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Mark {
103    /// Unique identifier for this mark.
104    pub id: MarkId,
105    /// The type/style of the mark.
106    pub mark_type: MarkType,
107    /// Start anchor (inclusive).
108    pub start: Anchor,
109    /// End anchor (exclusive).
110    pub end: Anchor,
111    /// Whether this mark is deleted (tombstone).
112    pub deleted: bool,
113}
114
115impl Mark {
116    pub fn new(id: MarkId, mark_type: MarkType, start: Anchor, end: Anchor) -> Self {
117        Self {
118            id,
119            mark_type,
120            start,
121            end,
122            deleted: false,
123        }
124    }
125
126    /// Get the resolved range (start, end) in the text.
127    pub fn range(&self, text: &RGAText) -> Option<(usize, usize)> {
128        let start = self.start.resolve(text)?;
129        let end = self.end.resolve(text)?;
130        Some((start, end))
131    }
132
133    /// Check if this mark covers a position.
134    pub fn covers(&self, text: &RGAText, position: usize) -> bool {
135        if self.deleted {
136            return false;
137        }
138        if let Some((start, end)) = self.range(text) {
139            position >= start && position < end
140        } else {
141            false
142        }
143    }
144}
145
146/// Delta for rich text operations.
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148pub struct RichTextDelta {
149    /// Text changes.
150    pub text_delta: Option<RGATextDelta>,
151    /// Marks to add.
152    pub add_marks: Vec<Mark>,
153    /// Marks to remove (by ID).
154    pub remove_marks: Vec<MarkId>,
155}
156
157impl RichTextDelta {
158    pub fn new() -> Self {
159        Self {
160            text_delta: None,
161            add_marks: Vec::new(),
162            remove_marks: Vec::new(),
163        }
164    }
165
166    pub fn is_empty(&self) -> bool {
167        self.text_delta.is_none() && self.add_marks.is_empty() && self.remove_marks.is_empty()
168    }
169}
170
171impl Default for RichTextDelta {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177/// Collaborative rich text with formatting support.
178///
179/// Combines RGAText for the text content with a set of
180/// anchor-based marks for formatting.
181#[derive(Clone, Debug, Serialize, Deserialize)]
182pub struct RichText {
183    /// The underlying plain text.
184    text: RGAText,
185    /// All marks indexed by their ID.
186    marks: HashMap<MarkId, Mark>,
187    /// The replica ID for this instance.
188    replica_id: String,
189    /// Pending delta for replication.
190    #[serde(skip)]
191    pending_delta: Option<RichTextDelta>,
192}
193
194impl RichText {
195    /// Create a new empty rich text.
196    pub fn new(replica_id: impl Into<String>) -> Self {
197        let replica_id = replica_id.into();
198        Self {
199            text: RGAText::new(&replica_id),
200            marks: HashMap::new(),
201            replica_id,
202            pending_delta: None,
203        }
204    }
205
206    /// Get the replica ID.
207    pub fn replica_id(&self) -> &str {
208        &self.replica_id
209    }
210
211    /// Get the underlying text as a String.
212    pub fn text_content(&self) -> String {
213        self.text.to_string()
214    }
215
216    /// Get the text length.
217    pub fn len(&self) -> usize {
218        self.text.len()
219    }
220
221    /// Check if empty.
222    pub fn is_empty(&self) -> bool {
223        self.text.is_empty()
224    }
225
226    /// Get access to the underlying RGAText.
227    pub fn text(&self) -> &RGAText {
228        &self.text
229    }
230
231    // === Text Operations ===
232
233    /// Insert plain text at a position.
234    pub fn insert(&mut self, position: usize, text: &str) {
235        self.text.insert(position, text);
236
237        // Capture text delta
238        if let Some(text_delta) = self.text.take_delta() {
239            let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
240            delta.text_delta = Some(text_delta);
241        }
242    }
243
244    /// Delete text range.
245    pub fn delete(&mut self, start: usize, length: usize) {
246        self.text.delete(start, length);
247
248        // Capture text delta
249        if let Some(text_delta) = self.text.take_delta() {
250            let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
251            delta.text_delta = Some(text_delta);
252        }
253    }
254
255    /// Replace text range.
256    pub fn replace(&mut self, start: usize, end: usize, text: &str) {
257        self.text.replace(start, end, text);
258
259        // Capture text delta
260        if let Some(text_delta) = self.text.take_delta() {
261            let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
262            delta.text_delta = Some(text_delta);
263        }
264    }
265
266    // === Mark Operations ===
267
268    /// Add a formatting mark to a range.
269    pub fn add_mark(&mut self, start: usize, end: usize, mark_type: MarkType) -> MarkId {
270        let id = MarkId::new(&self.replica_id);
271
272        // Convert positions to anchors
273        let start_anchor = if start == 0 {
274            Anchor::Start
275        } else {
276            self.text
277                .position_to_id(start.saturating_sub(1))
278                .map(Anchor::After)
279                .unwrap_or(Anchor::Start)
280        };
281
282        let end_anchor = if end >= self.text.len() {
283            Anchor::End
284        } else {
285            self.text
286                .position_to_id(end)
287                .map(Anchor::Before)
288                .unwrap_or(Anchor::End)
289        };
290
291        let mark = Mark::new(id.clone(), mark_type, start_anchor, end_anchor);
292
293        self.marks.insert(id.clone(), mark.clone());
294
295        // Record delta
296        let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
297        delta.add_marks.push(mark);
298
299        id
300    }
301
302    /// Add bold formatting.
303    pub fn bold(&mut self, start: usize, end: usize) -> MarkId {
304        self.add_mark(start, end, MarkType::Bold)
305    }
306
307    /// Add italic formatting.
308    pub fn italic(&mut self, start: usize, end: usize) -> MarkId {
309        self.add_mark(start, end, MarkType::Italic)
310    }
311
312    /// Add underline formatting.
313    pub fn underline(&mut self, start: usize, end: usize) -> MarkId {
314        self.add_mark(start, end, MarkType::Underline)
315    }
316
317    /// Add a link.
318    pub fn link(&mut self, start: usize, end: usize, url: impl Into<String>) -> MarkId {
319        self.add_mark(start, end, MarkType::Link { url: url.into() })
320    }
321
322    /// Add a comment/annotation.
323    pub fn comment(
324        &mut self,
325        start: usize,
326        end: usize,
327        author: impl Into<String>,
328        content: impl Into<String>,
329    ) -> MarkId {
330        self.add_mark(
331            start,
332            end,
333            MarkType::Comment {
334                author: author.into(),
335                content: content.into(),
336            },
337        )
338    }
339
340    /// Add a highlight.
341    pub fn highlight(&mut self, start: usize, end: usize, color: impl Into<String>) -> MarkId {
342        self.add_mark(
343            start,
344            end,
345            MarkType::Highlight {
346                color: color.into(),
347            },
348        )
349    }
350
351    /// Remove a mark by ID.
352    pub fn remove_mark(&mut self, id: &MarkId) -> bool {
353        if let Some(mark) = self.marks.get_mut(id) {
354            mark.deleted = true;
355
356            // Record delta
357            let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
358            delta.remove_marks.push(id.clone());
359
360            true
361        } else {
362            false
363        }
364    }
365
366    /// Remove all marks of a type from a range.
367    pub fn remove_marks_in_range(&mut self, start: usize, end: usize, mark_type: &MarkType) {
368        let to_remove: Vec<_> = self
369            .marks
370            .iter()
371            .filter(|(_, mark)| {
372                if mark.deleted || &mark.mark_type != mark_type {
373                    return false;
374                }
375                if let Some((ms, me)) = mark.range(&self.text) {
376                    // Overlaps with range
377                    ms < end && me > start
378                } else {
379                    false
380                }
381            })
382            .map(|(id, _)| id.clone())
383            .collect();
384
385        for id in to_remove {
386            self.remove_mark(&id);
387        }
388    }
389
390    /// Get all marks at a position.
391    pub fn marks_at(&self, position: usize) -> Vec<&Mark> {
392        self.marks
393            .values()
394            .filter(|m| m.covers(&self.text, position))
395            .collect()
396    }
397
398    /// Get all marks in a range.
399    pub fn marks_in_range(&self, start: usize, end: usize) -> Vec<&Mark> {
400        self.marks
401            .values()
402            .filter(|mark| {
403                if mark.deleted {
404                    return false;
405                }
406                if let Some((ms, me)) = mark.range(&self.text) {
407                    ms < end && me > start
408                } else {
409                    false
410                }
411            })
412            .collect()
413    }
414
415    /// Check if a position has a specific mark type.
416    pub fn has_mark(&self, position: usize, mark_type: &MarkType) -> bool {
417        self.marks_at(position)
418            .iter()
419            .any(|m| &m.mark_type == mark_type)
420    }
421
422    /// Get all marks (including deleted for debugging).
423    pub fn all_marks(&self) -> impl Iterator<Item = &Mark> + '_ {
424        self.marks.values()
425    }
426
427    /// Get only active marks.
428    pub fn active_marks(&self) -> impl Iterator<Item = &Mark> + '_ {
429        self.marks.values().filter(|m| !m.deleted)
430    }
431
432    // === Delta Operations ===
433
434    /// Take the pending delta.
435    pub fn take_delta(&mut self) -> Option<RichTextDelta> {
436        self.pending_delta.take()
437    }
438
439    /// Apply a delta from another replica.
440    pub fn apply_delta(&mut self, delta: &RichTextDelta) {
441        // Apply text changes
442        if let Some(text_delta) = &delta.text_delta {
443            self.text.apply_delta(text_delta);
444        }
445
446        // Apply mark additions
447        for mark in &delta.add_marks {
448            self.marks
449                .entry(mark.id.clone())
450                .and_modify(|m| {
451                    if mark.deleted {
452                        m.deleted = true;
453                    }
454                })
455                .or_insert_with(|| mark.clone());
456        }
457
458        // Apply mark removals
459        for id in &delta.remove_marks {
460            if let Some(mark) = self.marks.get_mut(id) {
461                mark.deleted = true;
462            }
463        }
464    }
465
466    // === Rendering ===
467
468    /// Render as HTML (basic implementation).
469    pub fn to_html(&self) -> String {
470        let text = self.to_string();
471        if text.is_empty() {
472            return String::new();
473        }
474
475        // Collect marks and their ranges
476        let mut events: Vec<(usize, i8, &Mark)> = Vec::new();
477        for mark in self.active_marks() {
478            if let Some((start, end)) = mark.range(&self.text) {
479                events.push((start, 1, mark)); // 1 = open
480                events.push((end, -1, mark)); // -1 = close
481            }
482        }
483
484        // Sort: by position, then closes before opens at same position
485        events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
486
487        let mut result = String::new();
488        let chars: Vec<char> = text.chars().collect();
489        let mut pos = 0;
490
491        let mut open_tags: Vec<&Mark> = Vec::new();
492
493        for (event_pos, event_type, mark) in events {
494            // Output text before this event
495            while pos < event_pos && pos < chars.len() {
496                result.push(chars[pos]);
497                pos += 1;
498            }
499
500            if event_type > 0 {
501                // Open tag
502                result.push_str(&mark_open_tag(&mark.mark_type));
503                open_tags.push(mark);
504            } else {
505                // Close tag
506                result.push_str(&mark_close_tag(&mark.mark_type));
507                open_tags.retain(|m| m.id != mark.id);
508            }
509        }
510
511        // Output remaining text
512        while pos < chars.len() {
513            result.push(chars[pos]);
514            pos += 1;
515        }
516
517        result
518    }
519}
520
521fn mark_open_tag(mark_type: &MarkType) -> String {
522    match mark_type {
523        MarkType::Bold => "<strong>".to_string(),
524        MarkType::Italic => "<em>".to_string(),
525        MarkType::Underline => "<u>".to_string(),
526        MarkType::Strikethrough => "<s>".to_string(),
527        MarkType::Code => "<code>".to_string(),
528        MarkType::Link { url } => format!("<a href=\"{}\">", url),
529        MarkType::Comment { author, content } => format!(
530            "<span data-comment-author=\"{}\" data-comment=\"{}\">",
531            author, content
532        ),
533        MarkType::Highlight { color } => format!("<mark style=\"background-color:{}\">", color),
534        MarkType::Custom { name, value } => format!("<span data-{}=\"{}\">", name, value),
535    }
536}
537
538fn mark_close_tag(mark_type: &MarkType) -> String {
539    match mark_type {
540        MarkType::Bold => "</strong>".to_string(),
541        MarkType::Italic => "</em>".to_string(),
542        MarkType::Underline => "</u>".to_string(),
543        MarkType::Strikethrough => "</s>".to_string(),
544        MarkType::Code => "</code>".to_string(),
545        MarkType::Link { .. } => "</a>".to_string(),
546        MarkType::Comment { .. } => "</span>".to_string(),
547        MarkType::Highlight { .. } => "</mark>".to_string(),
548        MarkType::Custom { .. } => "</span>".to_string(),
549    }
550}
551
552impl std::fmt::Display for RichText {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        write!(f, "{}", self.text)
555    }
556}
557
558impl PartialEq for RichText {
559    fn eq(&self, other: &Self) -> bool {
560        self.to_string() == other.to_string() && self.marks.len() == other.marks.len()
561    }
562}
563
564impl Eq for RichText {}
565
566impl Lattice for RichText {
567    fn bottom() -> Self {
568        Self::new("")
569    }
570
571    fn join(&self, other: &Self) -> Self {
572        let mut result = self.clone();
573
574        // Merge text
575        result.text = self.text.join(&other.text);
576
577        // Merge marks
578        for (id, mark) in &other.marks {
579            result
580                .marks
581                .entry(id.clone())
582                .and_modify(|m| {
583                    if mark.deleted {
584                        m.deleted = true;
585                    }
586                })
587                .or_insert_with(|| mark.clone());
588        }
589
590        result
591    }
592}
593
594impl Default for RichText {
595    fn default() -> Self {
596        Self::new("")
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn test_basic_formatting() {
606        let mut doc = RichText::new("r1");
607        doc.insert(0, "Hello World");
608        doc.bold(0, 5);
609
610        assert_eq!(doc.to_string(), "Hello World");
611        assert!(doc.has_mark(2, &MarkType::Bold));
612        assert!(!doc.has_mark(6, &MarkType::Bold));
613    }
614
615    #[test]
616    fn test_multiple_marks() {
617        let mut doc = RichText::new("r1");
618        doc.insert(0, "Hello World");
619        doc.bold(0, 5);
620        doc.italic(6, 11);
621
622        let marks_at_2 = doc.marks_at(2);
623        assert_eq!(marks_at_2.len(), 1);
624        assert_eq!(marks_at_2[0].mark_type, MarkType::Bold);
625
626        let marks_at_8 = doc.marks_at(8);
627        assert_eq!(marks_at_8.len(), 1);
628        assert_eq!(marks_at_8[0].mark_type, MarkType::Italic);
629    }
630
631    #[test]
632    fn test_overlapping_marks() {
633        let mut doc = RichText::new("r1");
634        doc.insert(0, "Hello World");
635        doc.bold(0, 8);
636        doc.italic(4, 11);
637
638        // Position 5 should have both
639        let marks = doc.marks_at(5);
640        assert_eq!(marks.len(), 2);
641    }
642
643    #[test]
644    fn test_link_and_comment() {
645        let mut doc = RichText::new("r1");
646        doc.insert(0, "Check this out");
647        doc.link(6, 10, "https://example.com");
648        doc.comment(0, 14, "Alice", "Needs review");
649
650        assert!(doc.has_mark(
651            7,
652            &MarkType::Link {
653                url: "https://example.com".to_string()
654            }
655        ));
656        assert!(doc
657            .marks_at(0)
658            .iter()
659            .any(|m| matches!(&m.mark_type, MarkType::Comment { .. })));
660    }
661
662    #[test]
663    fn test_remove_mark() {
664        let mut doc = RichText::new("r1");
665        doc.insert(0, "Hello World");
666        let mark_id = doc.bold(0, 5);
667
668        assert!(doc.has_mark(2, &MarkType::Bold));
669
670        doc.remove_mark(&mark_id);
671
672        assert!(!doc.has_mark(2, &MarkType::Bold));
673    }
674
675    #[test]
676    fn test_concurrent_formatting() {
677        let mut doc1 = RichText::new("r1");
678        let mut doc2 = RichText::new("r2");
679
680        // Setup
681        doc1.insert(0, "Hello World");
682        doc2.apply_delta(&doc1.take_delta().unwrap());
683
684        // Concurrent formatting
685        doc1.bold(0, 5);
686        doc2.italic(6, 11);
687
688        // Exchange deltas
689        let delta1 = doc1.take_delta().unwrap();
690        let delta2 = doc2.take_delta().unwrap();
691
692        doc1.apply_delta(&delta2);
693        doc2.apply_delta(&delta1);
694
695        // Both should have both marks
696        assert!(doc1.has_mark(2, &MarkType::Bold));
697        assert!(doc1.has_mark(8, &MarkType::Italic));
698        assert!(doc2.has_mark(2, &MarkType::Bold));
699        assert!(doc2.has_mark(8, &MarkType::Italic));
700    }
701
702    #[test]
703    fn test_html_rendering() {
704        let mut doc = RichText::new("r1");
705        doc.insert(0, "Hello World");
706        doc.bold(0, 5);
707
708        let html = doc.to_html();
709        assert!(html.contains("<strong>Hello</strong>"));
710        assert!(html.contains("World"));
711    }
712
713    #[test]
714    fn test_insert_expands_mark() {
715        let mut doc = RichText::new("r1");
716        doc.insert(0, "AB");
717        doc.bold(0, 2); // Bold "AB"
718
719        // Insert "X" in the middle
720        doc.insert(1, "X");
721
722        // Text should be "AXB"
723        assert_eq!(doc.to_string(), "AXB");
724
725        // The mark anchor system means the mark covers A and B,
726        // but X was inserted after A so may or may not be covered
727        // depending on anchor resolution
728    }
729
730    #[test]
731    fn test_lattice_join() {
732        let mut doc1 = RichText::new("r1");
733        let mut doc2 = RichText::new("r2");
734
735        doc1.insert(0, "Hello");
736        doc1.bold(0, 5);
737
738        doc2.insert(0, "World");
739        doc2.italic(0, 5);
740
741        let merged = doc1.join(&doc2);
742
743        // Should have marks from both
744        assert!(merged.active_marks().count() >= 2);
745    }
746
747    #[test]
748    fn test_marks_in_range() {
749        let mut doc = RichText::new("r1");
750        doc.insert(0, "Hello World Test");
751        doc.bold(0, 5);
752        doc.italic(6, 11);
753        doc.underline(12, 16);
754
755        let marks = doc.marks_in_range(4, 13);
756        // Should include Bold (ends at 5), Italic (6-11), and Underline (starts at 12)
757        assert!(marks.len() >= 2);
758    }
759}