1use std::{
2 borrow::Cow,
3 mem,
4};
5
6#[cfg(feature = "remote-asset")]
7use freya_components::Uri;
8#[cfg(feature = "remote-asset")]
9use freya_components::image_viewer::{
10 ImageSource,
11 ImageViewer,
12};
13#[cfg(feature = "router")]
14use freya_components::link::{
15 Link,
16 LinkTooltip,
17};
18use freya_components::{
19 define_theme,
20 get_theme_or_default,
21 table::{
22 Table,
23 TableBody,
24 TableCell,
25 TableHead,
26 TableRow,
27 },
28 theming::macros::Preference,
29};
30use freya_core::prelude::*;
31use pulldown_cmark::{
32 Event,
33 HeadingLevel,
34 Options,
35 Parser,
36 Tag,
37 TagEnd,
38};
39use torin::prelude::*;
40
41#[cfg(feature = "code-editor")]
42mod code_editor;
43#[cfg(feature = "code-editor")]
44use code_editor::CodeBlockEditor;
45
46define_theme! {
47 %[component]
48 pub MarkdownViewer {
49 %[fields]
50 color: Color,
51 color_link: Color,
52 background_code: Color,
53 color_code: Color,
54 background_blockquote: Color,
55 border_blockquote: Color,
56 background_divider: Color,
57 heading_h1: f32,
58 heading_h2: f32,
59 heading_h3: f32,
60 heading_h4: f32,
61 heading_h5: f32,
62 heading_h6: f32,
63 paragraph_size: f32,
64 code_font_size: f32,
65 table_font_size: f32,
66 }
67}
68
69fn markdown_theme_preference() -> MarkdownViewerThemePreference {
70 MarkdownViewerThemePreference {
71 color: Preference::Reference("text_primary"),
72 color_link: Preference::Reference("text_highlight"),
73 background_code: Preference::Reference("surface_tertiary"),
74 color_code: Preference::Reference("text_primary"),
75 background_blockquote: Preference::Reference("surface_tertiary"),
76 border_blockquote: Preference::Reference("surface_primary"),
77 background_divider: Preference::Reference("border"),
78 heading_h1: Preference::Specific(32.0),
79 heading_h2: Preference::Specific(28.0),
80 heading_h3: Preference::Specific(24.0),
81 heading_h4: Preference::Specific(20.0),
82 heading_h5: Preference::Specific(18.0),
83 heading_h6: Preference::Specific(16.0),
84 paragraph_size: Preference::Specific(16.0),
85 code_font_size: Preference::Specific(14.0),
86 table_font_size: Preference::Specific(14.0),
87 }
88}
89
90#[derive(PartialEq)]
118pub struct MarkdownViewer {
119 content: Cow<'static, str>,
120 layout: LayoutData,
121 key: DiffKey,
122 pub(crate) theme: Option<MarkdownViewerThemePartial>,
123 inline_element: Option<Callback<String, Option<Element>>>,
124 code_editor_font_family: Cow<'static, str>,
125 #[cfg(feature = "code-editor")]
126 language_resolver: Option<code_editor::LanguageResolver>,
127}
128
129impl MarkdownViewer {
130 pub fn new(content: impl Into<Cow<'static, str>>) -> Self {
131 Self {
132 content: content.into(),
133 layout: LayoutData::default(),
134 key: DiffKey::None,
135 theme: None,
136 inline_element: None,
137 code_editor_font_family: Cow::Borrowed("Jetbrains Mono"),
138 #[cfg(feature = "code-editor")]
139 language_resolver: None,
140 }
141 }
142
143 pub fn inline_element<ReturnedElement: IntoElement + 'static>(
156 mut self,
157 handler: impl Into<Callback<String, Option<ReturnedElement>>>,
158 ) -> Self {
159 let handler = handler.into();
160 self.inline_element = Some(Callback::new(move |html| {
161 handler.call(html).map(IntoElement::into_element)
162 }));
163 self
164 }
165
166 pub fn code_editor_font_family(mut self, font_family: impl Into<Cow<'static, str>>) -> Self {
168 self.code_editor_font_family = font_family.into();
169 self
170 }
171
172 #[cfg(feature = "code-editor")]
174 pub fn code_editor_language(
175 mut self,
176 resolver: impl Into<code_editor::LanguageResolver>,
177 ) -> Self {
178 self.language_resolver = Some(resolver.into());
179 self
180 }
181}
182
183impl KeyExt for MarkdownViewer {
184 fn write_key(&mut self) -> &mut DiffKey {
185 &mut self.key
186 }
187}
188
189impl LayoutExt for MarkdownViewer {
190 fn get_layout(&mut self) -> &mut LayoutData {
191 &mut self.layout
192 }
193}
194
195impl ContainerExt for MarkdownViewer {}
196
197#[allow(dead_code)]
198#[derive(Clone)]
199enum MarkdownElement {
200 Heading {
201 level: HeadingLevel,
202 spans: Vec<TextSpan>,
203 },
204 Paragraph {
205 content: Vec<Inline>,
206 },
207 CodeBlock {
208 code: String,
209 language: Option<String>,
210 },
211 UnorderedList {
212 items: Vec<Vec<TextSpan>>,
213 },
214 OrderedList {
215 start: u64,
216 items: Vec<Vec<TextSpan>>,
217 },
218 Image {
219 #[cfg_attr(not(feature = "remote-asset"), allow(dead_code))]
220 url: String,
221 alt: String,
222 },
223 Link {
224 url: String,
225 title: Option<String>,
226 text: Vec<TextSpan>,
227 },
228 Blockquote {
229 spans: Vec<TextSpan>,
230 },
231 Table {
232 headers: Vec<Vec<TextSpan>>,
233 rows: Vec<Vec<Vec<TextSpan>>>,
234 },
235 HorizontalRule,
236}
237
238#[derive(Clone)]
240enum Inline {
241 Span(TextSpan),
242 #[cfg_attr(not(feature = "router"), allow(dead_code))]
243 Link {
244 url: String,
245 title: Option<String>,
246 text: Vec<TextSpan>,
247 },
248 Html(String),
250}
251
252#[derive(Clone, Debug)]
254struct TextSpan {
255 text: String,
256 bold: bool,
257 italic: bool,
258 #[allow(dead_code)]
259 strikethrough: bool,
260 code: bool,
261}
262
263impl TextSpan {
264 fn new(text: impl Into<String>) -> Self {
265 Self {
266 text: text.into(),
267 bold: false,
268 italic: false,
269 strikethrough: false,
270 code: false,
271 }
272 }
273}
274
275fn parse_markdown(content: &str) -> Vec<MarkdownElement> {
276 let mut options = Options::empty();
277 options.insert(Options::ENABLE_STRIKETHROUGH);
278 options.insert(Options::ENABLE_TABLES);
279
280 let parser = Parser::new_ext(content, options);
281 let mut elements = Vec::new();
282 let mut current_spans: Vec<TextSpan> = Vec::new();
283 let mut current_content: Vec<Inline> = Vec::new();
284 let mut list_items: Vec<Vec<TextSpan>> = Vec::new();
285 let mut current_list_item: Vec<TextSpan> = Vec::new();
286
287 let mut in_heading: Option<HeadingLevel> = None;
288 let mut in_paragraph = false;
289 let mut in_code_block = false;
290 let mut code_block_content = String::new();
291 let mut code_block_language: Option<String> = None;
292 let mut ordered_list_start: Option<u64> = None;
293 let mut in_list_item = false;
294 let mut in_blockquote = false;
295 let mut blockquote_spans: Vec<TextSpan> = Vec::new();
296
297 let mut in_table_cell = false;
298 let mut table_headers: Vec<Vec<TextSpan>> = Vec::new();
299 let mut table_rows: Vec<Vec<Vec<TextSpan>>> = Vec::new();
300 let mut current_table_row: Vec<Vec<TextSpan>> = Vec::new();
301 let mut current_cell_spans: Vec<TextSpan> = Vec::new();
302
303 let mut in_link = false;
304 let mut link_url: Option<String> = None;
305 let mut link_title: Option<String> = None;
306 let mut link_spans: Vec<TextSpan> = Vec::new();
307
308 let mut bold = false;
309 let mut italic = false;
310 let mut strikethrough = false;
311
312 for event in parser {
313 match event {
314 Event::Start(tag) => match tag {
315 Tag::Heading { level, .. } => {
316 in_heading = Some(level);
317 current_spans.clear();
318 }
319 Tag::Paragraph => {
320 if in_blockquote {
321 } else if in_list_item {
323 } else {
325 in_paragraph = true;
326 current_spans.clear();
327 current_content.clear();
328 }
329 }
330 Tag::CodeBlock(kind) => {
331 in_code_block = true;
332 code_block_content.clear();
333 code_block_language = match kind {
334 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
335 let lang_str = lang.to_string();
336 if lang_str.is_empty() {
337 None
338 } else {
339 Some(lang_str)
340 }
341 }
342 pulldown_cmark::CodeBlockKind::Indented => None,
343 };
344 }
345 Tag::List(start) => {
346 ordered_list_start = start;
347 list_items.clear();
348 }
349 Tag::Item => {
350 in_list_item = true;
351 current_list_item.clear();
352 }
353 Tag::Strong => bold = true,
354 Tag::Emphasis => italic = true,
355 Tag::Strikethrough => strikethrough = true,
356 Tag::BlockQuote(_) => {
357 in_blockquote = true;
358 blockquote_spans.clear();
359 }
360 Tag::Image {
361 dest_url, title, ..
362 } => {
363 elements.push(MarkdownElement::Image {
364 url: dest_url.to_string(),
365 alt: title.to_string(),
366 });
367 }
368 Tag::Link {
369 dest_url, title, ..
370 } => {
371 in_link = true;
372 link_url = Some(dest_url.to_string());
373 link_title = Some(title.to_string());
374 link_spans.clear();
375 }
376 Tag::Table(_) => {
377 table_headers.clear();
378 table_rows.clear();
379 current_table_row.clear();
380 }
381 Tag::TableHead => {}
382 Tag::TableRow => {
383 current_table_row.clear();
384 }
385 Tag::TableCell => {
386 in_table_cell = true;
387 current_cell_spans.clear();
388 }
389 _ => {}
390 },
391 Event::End(tag_end) => match tag_end {
392 TagEnd::Heading(_) => {
393 if let Some(level) = in_heading.take() {
394 elements.push(MarkdownElement::Heading {
395 level,
396 spans: mem::take(&mut current_spans),
397 });
398 }
399 }
400 TagEnd::Paragraph => {
401 if in_blockquote {
402 blockquote_spans.append(&mut current_spans)
403 } else if in_list_item {
404 current_list_item.append(&mut current_spans)
405 } else if in_paragraph {
406 in_paragraph = false;
407 current_content.extend(current_spans.drain(..).map(Inline::Span));
408 elements.push(MarkdownElement::Paragraph {
409 content: mem::take(&mut current_content),
410 });
411 }
412 }
413 TagEnd::CodeBlock => {
414 in_code_block = false;
415 elements.push(MarkdownElement::CodeBlock {
416 code: mem::take(&mut code_block_content),
417 language: code_block_language.take(),
418 });
419 }
420 TagEnd::List(_) => {
421 let items = mem::take(&mut list_items);
422 if let Some(start) = ordered_list_start.take() {
423 elements.push(MarkdownElement::OrderedList { start, items });
424 } else {
425 elements.push(MarkdownElement::UnorderedList { items });
426 }
427 }
428 TagEnd::Item => {
429 in_list_item = false;
430 list_items.push(mem::take(&mut current_list_item));
431 }
432 TagEnd::Strong => bold = false,
433 TagEnd::Emphasis => italic = false,
434 TagEnd::Strikethrough => strikethrough = false,
435 TagEnd::BlockQuote(_) => {
436 in_blockquote = false;
437 elements.push(MarkdownElement::Blockquote {
438 spans: mem::take(&mut blockquote_spans),
439 });
440 }
441 TagEnd::Table => {
442 elements.push(MarkdownElement::Table {
443 headers: mem::take(&mut table_headers),
444 rows: mem::take(&mut table_rows),
445 });
446 }
447 TagEnd::TableHead => {
448 table_headers = mem::take(&mut current_table_row);
450 }
451 TagEnd::TableRow => {
452 table_rows.push(mem::take(&mut current_table_row));
454 }
455 TagEnd::TableCell => {
456 in_table_cell = false;
457 current_table_row.push(mem::take(&mut current_cell_spans));
458 }
459 TagEnd::Link => {
460 in_link = false;
461 if let Some(url) = link_url.take() {
462 let title = link_title.take();
463 let text = mem::take(&mut link_spans);
464 if in_paragraph {
465 current_content.extend(current_spans.drain(..).map(Inline::Span));
466 current_content.push(Inline::Link { url, title, text });
467 } else {
468 elements.push(MarkdownElement::Link { url, title, text });
469 }
470 }
471 }
472 _ => {}
473 },
474 Event::Text(text) => {
475 if in_code_block {
476 code_block_content.push_str(text.trim());
477 } else if in_table_cell {
478 let span = TextSpan {
479 text: text.to_string(),
480 bold,
481 italic,
482 strikethrough,
483 code: false,
484 };
485 current_cell_spans.push(span);
486 } else {
487 let span = TextSpan {
488 text: text.to_string(),
489 bold,
490 italic,
491 strikethrough,
492 code: false,
493 };
494 if in_blockquote && !in_paragraph {
495 blockquote_spans.push(span);
496 } else if in_list_item && !in_paragraph {
497 current_list_item.push(span);
498 } else if in_link {
499 link_spans.push(span);
500 } else {
501 current_spans.push(span);
502 }
503 }
504 }
505 Event::Code(code) => {
506 let span = TextSpan {
507 text: code.to_string(),
508 bold,
509 italic,
510 strikethrough,
511 code: true,
512 };
513 if in_table_cell {
514 current_cell_spans.push(span);
515 } else if in_blockquote {
516 blockquote_spans.push(span);
517 } else if in_list_item {
518 current_list_item.push(span);
519 } else if in_link {
520 link_spans.push(span);
521 } else {
522 current_spans.push(span);
523 }
524 }
525 Event::SoftBreak | Event::HardBreak => {
526 let span = TextSpan::new(" ");
527 if in_blockquote {
528 blockquote_spans.push(span);
529 } else if in_list_item {
530 current_list_item.push(span);
531 } else if in_link {
532 link_spans.push(span);
533 } else {
534 current_spans.push(span);
535 }
536 }
537 Event::InlineHtml(html) => {
538 if in_paragraph && !in_link {
539 current_content.extend(current_spans.drain(..).map(Inline::Span));
540 current_content.push(Inline::Html(html.to_string()));
541 }
542 }
543 Event::Rule => {
544 elements.push(MarkdownElement::HorizontalRule);
545 }
546 _ => {}
547 }
548 }
549
550 elements
551}
552
553fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> {
555 let mut styled = Span::new(span.text.clone());
556 if span.bold {
557 styled = styled.font_weight(FontWeight::BOLD);
558 }
559 if span.italic {
560 styled = styled.font_slant(FontSlant::Italic);
561 }
562 if span.code {
563 styled.font_family("monospace").color(code_color)
564 } else {
565 styled.color(text_color)
566 }
567}
568
569fn render_spans(
571 spans: &[TextSpan],
572 base_font_size: f32,
573 text_color: Color,
574 code_color: Color,
575) -> Paragraph {
576 paragraph().font_size(base_font_size).spans_iter(
577 spans
578 .iter()
579 .map(|span| styled_span(span, text_color, code_color)),
580 )
581}
582
583fn render_content(
585 content: &[Inline],
586 base_font_size: f32,
587 text_color: Color,
588 link_color: Color,
589 code_color: Color,
590 inline_element: Option<&Callback<String, Option<Element>>>,
591) -> Paragraph {
592 let mut result = paragraph().font_size(base_font_size);
593 for item in content {
594 result = match item {
595 Inline::Span(span) => result.span(styled_span(span, text_color, code_color)),
596 Inline::Html(raw) => {
597 match inline_element.and_then(|handler| handler.call(raw.clone())) {
598 Some(element) => result.child(element),
599 None => result.span(Span::new(raw.clone()).color(text_color)),
600 }
601 }
602 #[cfg(feature = "router")]
603 Inline::Link { url, title, text } => {
604 let mut tooltip = LinkTooltip::Default;
605 if let Some(title) = title
606 && !title.is_empty()
607 {
608 tooltip = LinkTooltip::Custom(title.clone());
609 }
610 result.child(Link::new(url.clone()).tooltip(tooltip).child(render_spans(
611 text,
612 base_font_size,
613 link_color,
614 code_color,
615 )))
616 }
617 #[cfg(not(feature = "router"))]
618 Inline::Link { text, .. } => text.iter().fold(result, |paragraph, span| {
619 paragraph.span(styled_span(span, link_color, code_color))
620 }),
621 };
622 }
623 result
624}
625
626impl Component for MarkdownViewer {
627 fn render(&self) -> impl IntoElement {
628 let elements = parse_markdown(&self.content);
629
630 let MarkdownViewerTheme {
631 color,
632 color_link,
633 #[cfg(not(feature = "code-editor"))]
634 background_code,
635 #[cfg(feature = "code-editor")]
636 background_code: _,
637 color_code,
638 background_blockquote,
639 border_blockquote,
640 background_divider,
641 heading_h1,
642 heading_h2,
643 heading_h3,
644 heading_h4,
645 heading_h5,
646 heading_h6,
647 paragraph_size,
648 code_font_size,
649 table_font_size,
650 } = get_theme_or_default!(
651 &self.theme,
652 MarkdownViewerThemePreference,
653 "markdown_viewer",
654 markdown_theme_preference
655 );
656
657 let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
658
659 for (idx, element) in elements.into_iter().enumerate() {
660 let child: Element = match element {
661 MarkdownElement::Heading { level, spans } => {
662 let font_size = match level {
663 HeadingLevel::H1 => heading_h1,
664 HeadingLevel::H2 => heading_h2,
665 HeadingLevel::H3 => heading_h3,
666 HeadingLevel::H4 => heading_h4,
667 HeadingLevel::H5 => heading_h5,
668 HeadingLevel::H6 => heading_h6,
669 };
670 render_spans(&spans, font_size, color, color_code)
671 .font_weight(FontWeight::BOLD)
672 .key(idx)
673 .into()
674 }
675 MarkdownElement::Paragraph { content } => render_content(
676 &content,
677 paragraph_size,
678 color,
679 color_link,
680 color_code,
681 self.inline_element.as_ref(),
682 )
683 .key(idx)
684 .into(),
685 MarkdownElement::CodeBlock {
686 code,
687 #[cfg(feature = "code-editor")]
688 language,
689 #[cfg(not(feature = "code-editor"))]
690 language: _,
691 } => {
692 #[cfg(feature = "code-editor")]
693 let element = CodeBlockEditor::new(
694 move || Cow::Owned(code.clone()),
695 language,
696 self.language_resolver.clone(),
697 code_font_size,
698 self.code_editor_font_family.clone(),
699 )
700 .key(idx)
701 .into();
702
703 #[cfg(not(feature = "code-editor"))]
704 let element = rect()
705 .key(idx)
706 .width(Size::fill())
707 .background(background_code)
708 .corner_radius(6.)
709 .padding(Gaps::new_all(12.))
710 .child(
711 label()
712 .text(code)
713 .font_family(self.code_editor_font_family.clone())
714 .font_size(code_font_size)
715 .color(color_code),
716 )
717 .into();
718
719 element
720 }
721 MarkdownElement::UnorderedList { items } => {
722 let mut list = rect()
723 .key(idx)
724 .vertical()
725 .spacing(4.)
726 .padding(Gaps::new(0., 0., 0., 20.));
727
728 for (item_idx, item_spans) in items.into_iter().enumerate() {
729 let item_content = rect()
730 .key(item_idx)
731 .horizontal()
732 .cross_align(Alignment::Start)
733 .spacing(8.)
734 .child(label().text("•").font_size(paragraph_size).color(color))
735 .child(render_spans(&item_spans, paragraph_size, color, color_code));
736
737 list = list.child(item_content);
738 }
739
740 list.into()
741 }
742 MarkdownElement::OrderedList { start, items } => {
743 let mut list = rect()
744 .key(idx)
745 .vertical()
746 .spacing(4.)
747 .padding(Gaps::new(0., 0., 0., 20.));
748
749 for (item_idx, item_spans) in items.into_iter().enumerate() {
750 let number = start + item_idx as u64;
751 let item_content = rect()
752 .key(item_idx)
753 .horizontal()
754 .cross_align(Alignment::Start)
755 .spacing(8.)
756 .child(
757 label()
758 .text(format!("{}.", number))
759 .font_size(paragraph_size)
760 .color(color),
761 )
762 .child(render_spans(&item_spans, paragraph_size, color, color_code));
763
764 list = list.child(item_content);
765 }
766
767 list.into()
768 }
769 #[cfg(feature = "remote-asset")]
770 MarkdownElement::Image { url, alt } => match url.parse::<Uri>() {
771 Ok(uri) => {
772 let source: ImageSource = uri.into();
773 ImageViewer::new(source)
774 .a11y_alt(alt)
775 .key(idx)
776 .width(Size::fill())
777 .height(Size::px(300.))
778 .into()
779 }
780 Err(_) => label()
781 .key(idx)
782 .text(format!("[Invalid image URL: {}]", url))
783 .color(color)
784 .into(),
785 },
786 #[cfg(not(feature = "remote-asset"))]
787 MarkdownElement::Image { alt, .. } => label()
788 .key(idx)
789 .text(format!("[Image: {}]", alt))
790 .color(color)
791 .into(),
792 #[cfg(feature = "router")]
793 MarkdownElement::Link { url, title, text } => {
794 let mut tooltip = LinkTooltip::Default;
795 if let Some(title) = title
796 && !title.is_empty()
797 {
798 tooltip = LinkTooltip::Custom(title);
799 }
800
801 Link::new(url)
802 .tooltip(tooltip)
803 .child(render_spans(&text, paragraph_size, color_link, color_code))
804 .key(idx)
805 .into()
806 }
807 #[cfg(not(feature = "router"))]
808 MarkdownElement::Link { text, .. } => {
809 render_spans(&text, paragraph_size, color, color_code)
810 .key(idx)
811 .into()
812 }
813 MarkdownElement::Blockquote { spans } => rect()
814 .key(idx)
815 .width(Size::fill())
816 .padding(Gaps::new(12., 12., 12., 16.))
817 .border(
818 Border::new()
819 .width(4.)
820 .fill(border_blockquote)
821 .alignment(BorderAlignment::Inner),
822 )
823 .background(background_blockquote)
824 .child(
825 render_spans(&spans, paragraph_size, color, color_code)
826 .font_slant(FontSlant::Italic),
827 )
828 .into(),
829 MarkdownElement::HorizontalRule => rect()
830 .key(idx)
831 .width(Size::fill())
832 .height(Size::px(1.))
833 .background(background_divider)
834 .into(),
835 MarkdownElement::Table { headers, rows } => {
836 let mut head = TableHead::new();
837 let mut header_row = TableRow::new();
838 for (col_idx, header_spans) in headers.into_iter().enumerate() {
839 header_row = header_row.child(
840 TableCell::new().key(col_idx).child(
841 render_spans(&header_spans, table_font_size, color, color_code)
842 .font_weight(FontWeight::BOLD),
843 ),
844 );
845 }
846 head = head.child(header_row);
847
848 let mut body = TableBody::new();
849 for (row_idx, row) in rows.into_iter().enumerate() {
850 let mut table_row = TableRow::new().key(row_idx);
851 for (col_idx, cell_spans) in row.into_iter().enumerate() {
852 table_row = table_row.child(TableCell::new().key(col_idx).child(
853 render_spans(&cell_spans, table_font_size, color, color_code),
854 ));
855 }
856 body = body.child(table_row);
857 }
858
859 Table::new().key(idx).child(head).child(body).into()
860 }
861 };
862
863 container = container.child(child);
864 }
865
866 container
867 }
868
869 fn render_key(&self) -> DiffKey {
870 self.key.clone().or(self.default_key())
871 }
872}