logicaffeine_web/ui/components/
katex.rs1use dioxus::prelude::*;
30use wasm_bindgen::prelude::*;
31
32#[wasm_bindgen]
33extern "C" {
34 #[wasm_bindgen(js_namespace = window, js_name = renderKaTeX)]
35 fn render_katex(element_id: &str, latex: &str, display_mode: bool);
36}
37
38static KATEX_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
39
40fn next_katex_id() -> String {
41 let id = KATEX_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
42 format!("katex-{}", id)
43}
44
45#[component]
46pub fn KatexSpan(latex: String, #[props(default = false)] display: bool) -> Element {
47 let element_id = use_signal(|| next_katex_id());
48
49 use_effect(move || {
50 let id = element_id.read().clone();
51 let latex = latex.clone();
52 render_katex(&id, &latex, display);
53 });
54
55 rsx! {
56 span {
57 id: "{element_id}",
58 class: if display { "katex-display" } else { "katex-inline" }
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
64pub enum TextPart {
65 Plain(String),
66 Latex { content: String, display: bool },
67}
68
69pub fn parse_latex_in_text(text: &str) -> Vec<TextPart> {
70 let mut parts = Vec::new();
71 let mut remaining = text;
72
73 while !remaining.is_empty() {
74 let display_pos = remaining.find("$$");
75 let inline_pos = remaining.find('$');
76
77 match (display_pos, inline_pos) {
78 (Some(d), Some(i)) if d <= i => {
79 if d > 0 {
80 parts.push(TextPart::Plain(remaining[..d].to_string()));
81 }
82 remaining = &remaining[d + 2..];
83 if let Some(end) = remaining.find("$$") {
84 parts.push(TextPart::Latex {
85 content: remaining[..end].to_string(),
86 display: true,
87 });
88 remaining = &remaining[end + 2..];
89 } else {
90 parts.push(TextPart::Plain(format!("$${}", remaining)));
91 break;
92 }
93 }
94 (_, Some(i)) => {
95 if i > 0 {
96 parts.push(TextPart::Plain(remaining[..i].to_string()));
97 }
98 remaining = &remaining[i + 1..];
99 if let Some(end) = remaining.find('$') {
100 parts.push(TextPart::Latex {
101 content: remaining[..end].to_string(),
102 display: false,
103 });
104 remaining = &remaining[end + 1..];
105 } else {
106 parts.push(TextPart::Plain(format!("${}", remaining)));
107 break;
108 }
109 }
110 (Some(d), None) => {
111 if d > 0 {
112 parts.push(TextPart::Plain(remaining[..d].to_string()));
113 }
114 remaining = &remaining[d + 2..];
115 if let Some(end) = remaining.find("$$") {
116 parts.push(TextPart::Latex {
117 content: remaining[..end].to_string(),
118 display: true,
119 });
120 remaining = &remaining[end + 2..];
121 } else {
122 parts.push(TextPart::Plain(format!("$${}", remaining)));
123 break;
124 }
125 }
126 (None, None) => {
127 parts.push(TextPart::Plain(remaining.to_string()));
128 break;
129 }
130 }
131 }
132
133 parts
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_parse_plain_text() {
142 let parts = parse_latex_in_text("Hello world");
143 assert_eq!(parts.len(), 1);
144 assert_eq!(parts[0], TextPart::Plain("Hello world".to_string()));
145 }
146
147 #[test]
148 fn test_parse_inline_latex() {
149 let parts = parse_latex_in_text("The formula $x + y$ is simple");
150 assert_eq!(parts.len(), 3);
151 assert_eq!(parts[0], TextPart::Plain("The formula ".to_string()));
152 assert_eq!(parts[1], TextPart::Latex { content: "x + y".to_string(), display: false });
153 assert_eq!(parts[2], TextPart::Plain(" is simple".to_string()));
154 }
155
156 #[test]
157 fn test_parse_display_latex() {
158 let parts = parse_latex_in_text("Consider: $$\\forall x$$ as shown");
159 assert_eq!(parts.len(), 3);
160 assert_eq!(parts[0], TextPart::Plain("Consider: ".to_string()));
161 assert_eq!(parts[1], TextPart::Latex { content: "\\forall x".to_string(), display: true });
162 assert_eq!(parts[2], TextPart::Plain(" as shown".to_string()));
163 }
164
165 #[test]
166 fn test_parse_mixed() {
167 let parts = parse_latex_in_text("$A$ and $B$ implies $$A \\land B$$");
168 assert_eq!(parts.len(), 5);
169 assert_eq!(parts[0], TextPart::Latex { content: "A".to_string(), display: false });
170 assert_eq!(parts[1], TextPart::Plain(" and ".to_string()));
171 assert_eq!(parts[2], TextPart::Latex { content: "B".to_string(), display: false });
172 assert_eq!(parts[3], TextPart::Plain(" implies ".to_string()));
173 assert_eq!(parts[4], TextPart::Latex { content: "A \\land B".to_string(), display: true });
174 }
175
176 #[test]
177 fn test_unclosed_inline() {
178 let parts = parse_latex_in_text("Start $unclosed");
179 assert_eq!(parts.len(), 2);
180 assert_eq!(parts[0], TextPart::Plain("Start ".to_string()));
181 assert_eq!(parts[1], TextPart::Plain("$unclosed".to_string()));
182 }
183}