Markdown Editor
Rust UI component that displays a markdown editor, with a preview.
🌟 Welcome to Random Markdown
“Markdown is not a replacement for HTML, but it is a great tool for writers!” — Someone Wise
🚀 Features
- Bold and Italic text
Inline code
and syntax highlighting- Links
- Images: Random Cat
📋 To-Do List
- Learn Markdown
- Master GitHub README files
- Build a static site with Markdown content
💻 Code Block
def hello_markdown():
print("Hello, Markdown World!")
use leptos::either::Either; use leptos::prelude::*; use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; const MARKDOWN_CONTENT: &str = include_str!("../../../MARKDOWN.md"); #[component] pub fn DemoMarkdownEditor() -> impl IntoView { let markdown_content = RwSignal::new(MARKDOWN_CONTENT.to_string()); let show_preview = RwSignal::new(true); let rendered_content = move || { let content = markdown_content.get(); render_markdown(content) }; view! { <style> {r#" .editorTextArea { min-height: 31vh; resize: both; } "#} </style> <div class="w-full max-w-6xl xl:max-w-4xl"> <div class="rounded-lg border shadow-sm"> <div class="flex justify-between items-center px-4 py-3 border-b"> <button class="flex gap-2 items-center px-3 py-1 text-sm rounded hover:bg-accent" on:click=move |_| { show_preview.set(!show_preview.get()); } > {move || { if show_preview.get() { Either::Left(view! { <p>"Preview"</p> }) } else { Either::Right(view! { <p>"Edit"</p> }) } }} </button> </div> <div class="p-0"> <div class="flex flex-col lg:flex-row"> <div class=move || { if show_preview.get() { "w-full lg:w-1/2 lg:border-r" } else { "w-full" } }> <div class="p-4"> <textarea class="px-3 py-2 w-full rounded border border-gray-300 editorTextArea focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" prop:value=markdown_content on:input=move |ev| { markdown_content.set(event_target_value(&ev)); } spellcheck="false" /> </div> </div> {move || { if show_preview.get() { Either::Left( view! { <div class="w-full lg:w-1/2"> <div class="p-4"> <div class="overflow-auto p-3 h-3/4 rounded border border-gray-300"> {rendered_content} </div> </div> </div> }, ) } else { Either::Right(view! { <div class="hidden"></div> }) } }} </div> </div> </div> </div> } } fn render_markdown(markdown: String) -> impl IntoView { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_FOOTNOTES); options.insert(Options::ENABLE_TASKLISTS); options.insert(Options::ENABLE_SMART_PUNCTUATION); let parser = Parser::new_ext(&markdown, options); let mut rendered_elements = Vec::<AnyView>::new(); let mut current_element: Option<MarkdownElement> = None; let mut list_stack = Vec::<ListInfo>::new(); let mut formatting_stack = Vec::<FormattingState>::new(); for event in parser { match event { Event::Start(tag) => match tag { Tag::Heading { level, .. } => { if let Some(element) = current_element.take() { rendered_elements.push(render_element(element)); } current_element = Some(MarkdownElement::Heading { level: heading_level_to_u32(level), content: String::new(), }); } Tag::Paragraph => { if let Some(element) = current_element.take() { rendered_elements.push(render_element(element)); } current_element = Some(MarkdownElement::Paragraph { content: Vec::new(), }); } Tag::CodeBlock(info) => { if let Some(element) = current_element.take() { rendered_elements.push(render_element(element)); } current_element = Some(MarkdownElement::CodeBlock { _language: code_block_kind_to_string(info), content: String::new(), }); } Tag::List(start_number) => { if let Some(element) = current_element.take() { rendered_elements.push(render_element(element)); } list_stack.push(ListInfo { is_ordered: start_number.is_some(), items: Vec::new(), }); } Tag::Item => { current_element = Some(MarkdownElement::ListItem { content: Vec::new(), }); } Tag::BlockQuote(_) => { if let Some(element) = current_element.take() { rendered_elements.push(render_element(element)); } current_element = Some(MarkdownElement::BlockQuote { content: Vec::new(), }); } Tag::Strong => { formatting_stack.push(FormattingState::Bold); } Tag::Emphasis => { formatting_stack.push(FormattingState::Italic); } _ => {} }, Event::End(tag) => match tag { TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock | TagEnd::BlockQuote(_) => { if let Some(element) = current_element.take() && list_stack.is_empty() { rendered_elements.push(render_element(element)); } } TagEnd::Item => { if let Some(element) = current_element.take() && let Some(list_info) = list_stack.last_mut() { list_info.items.push(element); } } TagEnd::List(_) => { if let Some(list_info) = list_stack.pop() { rendered_elements.push(render_list(list_info)); } } TagEnd::Strong => { formatting_stack.retain(|f| !matches!(f, FormattingState::Bold)); } TagEnd::Emphasis => { formatting_stack.retain(|f| !matches!(f, FormattingState::Italic)); } _ => {} }, Event::Text(text) => { let is_bold = formatting_stack.contains(&FormattingState::Bold); let is_italic = formatting_stack.contains(&FormattingState::Italic); add_text_to_current_element(&mut current_element, text, is_bold, is_italic, false); } Event::Code(code) => { add_text_to_current_element(&mut current_element, code, false, false, true); } Event::Html(html) => { add_text_to_current_element(&mut current_element, html, false, false, false); } Event::InlineHtml(html) => { add_text_to_current_element(&mut current_element, html, false, false, false); } Event::SoftBreak => { add_text_to_current_element( &mut current_element, CowStr::from(" "), false, false, false, ); } Event::HardBreak => { add_text_to_current_element( &mut current_element, CowStr::from("\n"), false, false, false, ); } _ => {} } } if let Some(element) = current_element { rendered_elements.push(render_element(element)); } rendered_elements } #[derive(Debug, Clone, PartialEq)] enum FormattingState { Bold, Italic, } #[derive(Debug, Clone)] enum MarkdownElement { Heading { level: u32, content: String }, Paragraph { content: Vec<TextSpan> }, CodeBlock { _language: String, content: String }, ListItem { content: Vec<TextSpan> }, BlockQuote { content: Vec<TextSpan> }, } #[derive(Debug, Clone)] struct TextSpan { text: String, is_bold: bool, is_italic: bool, is_code: bool, } #[derive(Debug, Clone)] struct ListInfo { is_ordered: bool, items: Vec<MarkdownElement>, } fn heading_level_to_u32(level: HeadingLevel) -> u32 { match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, } } fn code_block_kind_to_string(kind: CodeBlockKind) -> String { match kind { CodeBlockKind::Indented => String::new(), CodeBlockKind::Fenced(info) => info.to_string(), } } fn add_text_to_current_element( current_element: &mut Option<MarkdownElement>, text: CowStr, is_bold: bool, is_italic: bool, is_code: bool, ) { match current_element { Some(MarkdownElement::Heading { content, .. }) => { content.push_str(&text); } Some(MarkdownElement::Paragraph { content }) => { content.push(TextSpan { text: text.to_string(), is_bold, is_italic, is_code, }); } Some(MarkdownElement::CodeBlock { content, .. }) => { content.push_str(&text); } Some(MarkdownElement::ListItem { content }) => { content.push(TextSpan { text: text.to_string(), is_bold, is_italic, is_code, }); } Some(MarkdownElement::BlockQuote { content }) => { content.push(TextSpan { text: text.to_string(), is_bold, is_italic, is_code, }); } None => {} } } fn render_element(element: MarkdownElement) -> AnyView { match element { MarkdownElement::Heading { level, content } => { let class = match level { 1 => "text-3xl font-bold mb-3 mt-0", 2 => "text-2xl font-bold mb-3 mt-4", 3 => "text-xl font-semibold mb-2 mt-3", 4 => "text-lg font-semibold mb-2 mt-3", 5 => "text-base font-semibold mb-2 mt-2", _ => "text-sm font-semibold mb-2 mt-2", }; match level { 1 => view! { <h1 class=class>{content}</h1> }.into_any(), 2 => view! { <h2 class=class>{content}</h2> }.into_any(), 3 => view! { <h3 class=class>{content}</h3> }.into_any(), 4 => view! { <h4 class=class>{content}</h4> }.into_any(), 5 => view! { <h5 class=class>{content}</h5> }.into_any(), _ => view! { <h6 class=class>{content}</h6> }.into_any(), } } MarkdownElement::Paragraph { content } => { let text_spans = render_text_spans(content); view! { <p class="mb-3">{text_spans}</p> }.into_any() } MarkdownElement::CodeBlock { _language: _, content, } => view! { <div class="mb-3"> <pre class="p-3 mb-0 rounded border border-input bg-card"> <code class="font-mono text-sm">{content}</code> </pre> </div> } .into_any(), MarkdownElement::ListItem { content } => { let text_spans = render_text_spans(content); view! { <li class="mb-1">{text_spans}</li> }.into_any() } MarkdownElement::BlockQuote { content } => { let text_spans = render_text_spans(content); view! { <blockquote class="py-2 pl-3 mb-3 rounded border-l-4 border-input bg-card/50"> <p class="mb-0 italic text-muted-foreground">{text_spans}</p> </blockquote> } .into_any() } } } fn render_text_spans(spans: Vec<TextSpan>) -> Vec<AnyView> { spans .into_iter() .map(|span| { if span.is_code { view! { <code class="px-1 font-mono text-sm text-red-600 rounded bg-card">{span.text}</code> } .into_any() } else { match (span.is_bold, span.is_italic) { (true, true) => view! { <strong> <em>{span.text}</em> </strong> } .into_any(), (true, false) => view! { <strong>{span.text}</strong> }.into_any(), (false, true) => view! { <em>{span.text}</em> }.into_any(), (false, false) => view! { <span>{span.text}</span> }.into_any(), } } }) .collect() } fn render_list(list_info: ListInfo) -> AnyView { let items: Vec<AnyView> = list_info.items.into_iter().map(render_element).collect(); if list_info.is_ordered { view! { <ol class="pl-4 mb-3 list-decimal">{items}</ol> }.into_any() } else { view! { <ul class="pl-4 mb-3 list-disc">{items}</ul> }.into_any() } }
Installation
# Coming soon :)
Usage
// Coming soon 🦀