1use crate::rga_text::{RGAText, RGATextDelta, TextId};
12use mdcs_core::lattice::Lattice;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use ulid::Ulid;
16
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct MarkId {
20 pub replica: String,
22 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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum MarkType {
38 Bold,
40 Italic,
42 Underline,
44 Strikethrough,
46 Code,
48 Link { url: String },
50 Comment { author: String, content: String },
52 Highlight { color: String },
54 Custom { name: String, value: String },
56}
57
58impl MarkType {
59 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub enum Anchor {
78 Start,
80 End,
82 After(TextId),
84 Before(TextId),
86}
87
88impl Anchor {
89 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Mark {
103 pub id: MarkId,
105 pub mark_type: MarkType,
107 pub start: Anchor,
109 pub end: Anchor,
111 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148pub struct RichTextDelta {
149 pub text_delta: Option<RGATextDelta>,
151 pub add_marks: Vec<Mark>,
153 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#[derive(Clone, Debug, Serialize, Deserialize)]
182pub struct RichText {
183 text: RGAText,
185 marks: HashMap<MarkId, Mark>,
187 replica_id: String,
189 #[serde(skip)]
191 pending_delta: Option<RichTextDelta>,
192}
193
194impl RichText {
195 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 pub fn replica_id(&self) -> &str {
208 &self.replica_id
209 }
210
211 pub fn text_content(&self) -> String {
213 self.text.to_string()
214 }
215
216 pub fn len(&self) -> usize {
218 self.text.len()
219 }
220
221 pub fn is_empty(&self) -> bool {
223 self.text.is_empty()
224 }
225
226 pub fn text(&self) -> &RGAText {
228 &self.text
229 }
230
231 pub fn insert(&mut self, position: usize, text: &str) {
235 self.text.insert(position, text);
236
237 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 pub fn delete(&mut self, start: usize, length: usize) {
246 self.text.delete(start, length);
247
248 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 pub fn replace(&mut self, start: usize, end: usize, text: &str) {
257 self.text.replace(start, end, text);
258
259 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 pub fn add_mark(&mut self, start: usize, end: usize, mark_type: MarkType) -> MarkId {
270 let id = MarkId::new(&self.replica_id);
271
272 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 let delta = self.pending_delta.get_or_insert_with(RichTextDelta::new);
297 delta.add_marks.push(mark);
298
299 id
300 }
301
302 pub fn bold(&mut self, start: usize, end: usize) -> MarkId {
304 self.add_mark(start, end, MarkType::Bold)
305 }
306
307 pub fn italic(&mut self, start: usize, end: usize) -> MarkId {
309 self.add_mark(start, end, MarkType::Italic)
310 }
311
312 pub fn underline(&mut self, start: usize, end: usize) -> MarkId {
314 self.add_mark(start, end, MarkType::Underline)
315 }
316
317 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 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 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 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 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 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 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 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 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 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 pub fn all_marks(&self) -> impl Iterator<Item = &Mark> + '_ {
424 self.marks.values()
425 }
426
427 pub fn active_marks(&self) -> impl Iterator<Item = &Mark> + '_ {
429 self.marks.values().filter(|m| !m.deleted)
430 }
431
432 pub fn take_delta(&mut self) -> Option<RichTextDelta> {
436 self.pending_delta.take()
437 }
438
439 pub fn apply_delta(&mut self, delta: &RichTextDelta) {
441 if let Some(text_delta) = &delta.text_delta {
443 self.text.apply_delta(text_delta);
444 }
445
446 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 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 pub fn to_html(&self) -> String {
470 let text = self.to_string();
471 if text.is_empty() {
472 return String::new();
473 }
474
475 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)); events.push((end, -1, mark)); }
482 }
483
484 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 while pos < event_pos && pos < chars.len() {
496 result.push(chars[pos]);
497 pos += 1;
498 }
499
500 if event_type > 0 {
501 result.push_str(&mark_open_tag(&mark.mark_type));
503 open_tags.push(mark);
504 } else {
505 result.push_str(&mark_close_tag(&mark.mark_type));
507 open_tags.retain(|m| m.id != mark.id);
508 }
509 }
510
511 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 result.text = self.text.join(&other.text);
576
577 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 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 doc1.insert(0, "Hello World");
682 doc2.apply_delta(&doc1.take_delta().unwrap());
683
684 doc1.bold(0, 5);
686 doc2.italic(6, 11);
687
688 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 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); doc.insert(1, "X");
721
722 assert_eq!(doc.to_string(), "AXB");
724
725 }
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 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 assert!(marks.len() >= 2);
758 }
759}