markdown_preview: Add Mermaid Diagram Support (#49064)

Closes https://github.com/zed-industries/zed/issues/10696

Adds support for rendering Mermaid diagrams in the markdown preview
using
[mermaid-rs-renderer](https://github.com/1jehuang/mermaid-rs-renderer)
(with [a
patch](https://github.com/1jehuang/mermaid-rs-renderer/pull/35)).

- Renders mermaid diagrams on background task
- Shows the previously cached image while re-computing
- Supports a scale parameter i.e. default 100
- Falls back to raw mermaid source code when render fails

<img width="1512" height="897" alt="image"
src="https://github.com/user-attachments/assets/9157625d-bb62-402f-8a8b-517f28d43f95"
/>

Release Notes:

- Added mermaid diagram rendering support to the markdown preview panel.

---------

Co-authored-by: oscarvarto <contact@oscarvarto.mx>
Co-authored-by: oscarvarto <oscarvarto@users.noreply.github.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
This commit is contained in:
Smit Barmase 2026-02-13 15:25:40 +05:30 committed by GitHub
parent bd2333d573
commit 856ba20261
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 610 additions and 74 deletions

58
Cargo.lock generated
View file

@ -3887,7 +3887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b"
dependencies = [
"bitflags 2.10.0",
"fontdb",
"fontdb 0.23.0",
"harfrust",
"linebender_resource_handle",
"log",
@ -6371,6 +6371,20 @@ dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.23.0"
@ -6382,7 +6396,7 @@ dependencies = [
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
"ttf-parser 0.25.1",
]
[[package]]
@ -8344,7 +8358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"hashbrown 0.15.5",
"serde",
"serde_core",
]
@ -8710,6 +8724,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "json_dotpath"
version = "1.1.0"
@ -9842,6 +9867,7 @@ dependencies = [
"linkify",
"log",
"markup5ever_rcdom",
"mermaid-rs-renderer",
"pretty_assertions",
"pulldown-cmark 0.13.0",
"settings",
@ -10055,6 +10081,22 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "mermaid-rs-renderer"
version = "0.2.0"
source = "git+https://github.com/zed-industries/mermaid-rs-renderer?branch=fix-font-family-xml-escaping#d91961aa90bc7b0c09c87a13c91d48e2f05c468d"
dependencies = [
"anyhow",
"fontdb 0.16.2",
"json5",
"once_cell",
"regex",
"serde",
"serde_json",
"thiserror 2.0.17",
"ttf-parser 0.20.0",
]
[[package]]
name = "metal"
version = "0.29.0"
@ -14500,7 +14542,7 @@ dependencies = [
"core_maths",
"log",
"smallvec",
"ttf-parser",
"ttf-parser 0.25.1",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
@ -17960,6 +18002,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
[[package]]
name = "ttf-parser"
version = "0.25.1"
@ -18310,7 +18358,7 @@ dependencies = [
"base64 0.22.1",
"data-url",
"flate2",
"fontdb",
"fontdb 0.23.0",
"imagesize",
"kurbo",
"log",

View file

@ -354,6 +354,7 @@ markdown_preview = { path = "crates/markdown_preview" }
svg_preview = { path = "crates/svg_preview" }
media = { path = "crates/media" }
menu = { path = "crates/menu" }
mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", branch = "fix-font-family-xml-escaping", default-features = false }
migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" }

View file

@ -35,6 +35,7 @@ urlencoding.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
mermaid-rs-renderer.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }

View file

@ -14,6 +14,7 @@ pub enum ParsedMarkdownElement {
Table(ParsedMarkdownTable),
BlockQuote(ParsedMarkdownBlockQuote),
CodeBlock(ParsedMarkdownCodeBlock),
MermaidDiagram(ParsedMarkdownMermaidDiagram),
/// A paragraph of text and other inline elements.
Paragraph(MarkdownParagraph),
HorizontalRule(Range<usize>),
@ -28,6 +29,7 @@ impl ParsedMarkdownElement {
Self::Table(table) => table.source_range.clone(),
Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
Self::CodeBlock(code_block) => code_block.source_range.clone(),
Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(),
Self::Paragraph(text) => match text.get(0)? {
MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
@ -86,6 +88,19 @@ pub struct ParsedMarkdownCodeBlock {
pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ParsedMarkdownMermaidDiagram {
pub source_range: Range<usize>,
pub contents: ParsedMarkdownMermaidDiagramContents,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ParsedMarkdownMermaidDiagramContents {
pub contents: SharedString,
pub scale: u32,
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ParsedMarkdownHeading {

View file

@ -196,21 +196,29 @@ impl<'a> MarkdownParser<'a> {
Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)])
}
Tag::CodeBlock(kind) => {
let language = match kind {
pulldown_cmark::CodeBlockKind::Indented => None,
let (language, scale) = match kind {
pulldown_cmark::CodeBlockKind::Indented => (None, None),
pulldown_cmark::CodeBlockKind::Fenced(language) => {
if language.is_empty() {
None
(None, None)
} else {
Some(language.to_string())
let parts: Vec<&str> = language.split_whitespace().collect();
let lang = parts.first().map(|s| s.to_string());
let scale = parts.get(1).and_then(|s| s.parse::<u32>().ok());
(lang, scale)
}
}
};
self.cursor += 1;
let code_block = self.parse_code_block(language).await?;
Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
if language.as_deref() == Some("mermaid") {
let mermaid_diagram = self.parse_mermaid_diagram(scale).await?;
Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)])
} else {
let code_block = self.parse_code_block(language).await?;
Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
}
}
Tag::HtmlBlock => {
self.cursor += 1;
@ -806,6 +814,50 @@ impl<'a> MarkdownParser<'a> {
})
}
async fn parse_mermaid_diagram(
&mut self,
scale: Option<u32>,
) -> Option<ParsedMarkdownMermaidDiagram> {
let Some((_event, source_range)) = self.previous() else {
return None;
};
let source_range = source_range.clone();
let mut code = String::new();
while !self.eof() {
let Some((current, _source_range)) = self.current() else {
break;
};
match current {
Event::Text(text) => {
code.push_str(text);
self.cursor += 1;
}
Event::End(TagEnd::CodeBlock) => {
self.cursor += 1;
break;
}
_ => {
break;
}
}
}
code = code.strip_suffix('\n').unwrap_or(&code).to_string();
let scale = scale.unwrap_or(100).clamp(10, 500);
Some(ParsedMarkdownMermaidDiagram {
source_range,
contents: ParsedMarkdownMermaidDiagramContents {
contents: code.into(),
scale,
},
})
}
async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
let mut elements = Vec::new();
let Some((_event, _source_range)) = self.previous() else {

View file

@ -19,7 +19,7 @@ use workspace::item::{Item, ItemHandle};
use workspace::{Pane, Workspace};
use crate::markdown_elements::ParsedMarkdownElement;
use crate::markdown_renderer::CheckboxClickedEvent;
use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
use crate::{
OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
markdown_elements::ParsedMarkdown,
@ -39,6 +39,7 @@ pub struct MarkdownPreviewView {
selected_block: usize,
list_state: ListState,
language_registry: Arc<LanguageRegistry>,
mermaid_state: MermaidState,
parsing_markdown_task: Option<Task<Result<()>>>,
mode: MarkdownPreviewMode,
}
@ -214,6 +215,7 @@ impl MarkdownPreviewView {
contents: None,
list_state,
language_registry,
mermaid_state: Default::default(),
parsing_markdown_task: None,
image_cache: RetainAllImageCache::new(cx),
mode,
@ -345,7 +347,9 @@ impl MarkdownPreviewView {
parse_markdown(&contents, file_location, Some(language_registry)).await
});
let contents = parsing_task.await;
view.update(cx, move |view, cx| {
view.mermaid_state.update(&contents, cx);
let markdown_blocks_count = contents.children.len();
view.contents = Some(contents);
let scroll_top = view.list_state.logical_scroll_top();
@ -571,39 +575,35 @@ impl Render for MarkdownPreviewView {
return div().into_any();
};
let mut render_cx =
RenderContext::new(Some(this.workspace.clone()), window, cx)
.with_checkbox_clicked_callback(cx.listener(
move |this, e: &CheckboxClickedEvent, window, cx| {
if let Some(editor) = this
.active_editor
.as_ref()
.map(|s| s.editor.clone())
{
editor.update(cx, |editor, cx| {
let task_marker =
if e.checked() { "[x]" } else { "[ ]" };
let mut render_cx = RenderContext::new(
Some(this.workspace.clone()),
&this.mermaid_state,
window,
cx,
)
.with_checkbox_clicked_callback(cx.listener(
move |this, e: &CheckboxClickedEvent, window, cx| {
if let Some(editor) =
this.active_editor.as_ref().map(|s| s.editor.clone())
{
editor.update(cx, |editor, cx| {
let task_marker =
if e.checked() { "[x]" } else { "[ ]" };
editor.edit(
[(
MultiBufferOffset(
e.source_range().start,
)
..MultiBufferOffset(
e.source_range().end,
),
task_marker,
)],
cx,
);
});
this.parse_markdown_from_active_editor(
false, window, cx,
);
cx.notify();
}
},
));
editor.edit(
[(
MultiBufferOffset(e.source_range().start)
..MultiBufferOffset(e.source_range().end),
task_marker,
)],
cx,
);
});
this.parse_markdown_from_active_editor(false, window, cx);
cx.notify();
}
},
));
let block = contents.children.get(ix).unwrap();
let rendered_block = render_markdown_block(block, &mut render_cx);
@ -613,6 +613,8 @@ impl Render for MarkdownPreviewView {
contents.children.get(ix + 1),
);
let selected_block = this.selected_block;
let scaled_rems = render_cx.scaled_rems(1.0);
div()
.id(ix)
.when(should_apply_padding, |this| {
@ -643,11 +645,11 @@ impl Render for MarkdownPreviewView {
let indicator = div()
.h_full()
.w(px(4.0))
.when(ix == this.selected_block, |this| {
.when(ix == selected_block, |this| {
this.bg(cx.theme().colors().border)
})
.group_hover("markdown-block", |s| {
if ix == this.selected_block {
if ix == selected_block {
s
} else {
s.bg(cx.theme().colors().border_variant)
@ -658,11 +660,7 @@ impl Render for MarkdownPreviewView {
container.child(
div()
.relative()
.child(
div()
.pl(render_cx.scaled_rems(1.0))
.child(rendered_block),
)
.child(div().pl(scaled_rems).child(rendered_block))
.child(indicator.absolute().left_0().top_0()),
)
})

View file

@ -1,20 +1,26 @@
use crate::markdown_elements::{
HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
use crate::{
markdown_elements::{
HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType,
ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable,
ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
},
markdown_preview_view::MarkdownPreviewView,
};
use collections::HashMap;
use fs::normalize_path;
use gpui::{
AbsoluteLength, AnyElement, App, AppContext as _, Context, Div, Element, ElementId, Entity,
HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers,
ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, WeakEntity,
Window, div, img, rems,
AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
};
use settings::Settings;
use std::{
ops::{Mul, Range},
sync::Arc,
sync::{Arc, OnceLock},
time::Duration,
vec,
};
use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
@ -38,8 +44,134 @@ impl CheckboxClickedEvent {
type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, CachedMermaidDiagram>;
#[derive(Default)]
pub(crate) struct MermaidState {
cache: MermaidDiagramCache,
order: Vec<ParsedMarkdownMermaidDiagramContents>,
}
impl MermaidState {
fn get_fallback_image(
idx: usize,
old_order: &[ParsedMarkdownMermaidDiagramContents],
new_order_len: usize,
cache: &MermaidDiagramCache,
) -> Option<Arc<RenderImage>> {
// When the diagram count changes e.g. addition or removal, positional matching
// is unreliable since a new diagram at index i likely doesn't correspond to the
// old diagram at index i. We only allow fallbacks when counts match, which covers
// the common case of editing a diagram in-place.
//
// Swapping two diagrams would briefly show the stale fallback, but that's an edge
// case we don't handle.
if old_order.len() != new_order_len {
return None;
}
old_order.get(idx).and_then(|old_content| {
cache.get(old_content).and_then(|old_cached| {
old_cached
.render_image
.get()
.and_then(|result| result.as_ref().ok().cloned())
// Chain fallbacks for rapid edits.
.or_else(|| old_cached.fallback_image.clone())
})
})
}
pub(crate) fn update(
&mut self,
parsed: &ParsedMarkdown,
cx: &mut Context<MarkdownPreviewView>,
) {
use crate::markdown_elements::ParsedMarkdownElement;
use std::collections::HashSet;
let mut new_order = Vec::new();
for element in parsed.children.iter() {
if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element {
new_order.push(mermaid_diagram.contents.clone());
}
}
for (idx, new_content) in new_order.iter().enumerate() {
if !self.cache.contains_key(new_content) {
let fallback =
Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
self.cache.insert(
new_content.clone(),
CachedMermaidDiagram::new(new_content.clone(), fallback, cx),
);
}
}
let new_order_set: HashSet<_> = new_order.iter().cloned().collect();
self.cache
.retain(|content, _| new_order_set.contains(content));
self.order = new_order;
}
}
pub(crate) struct CachedMermaidDiagram {
pub(crate) render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
pub(crate) fallback_image: Option<Arc<RenderImage>>,
_task: Task<()>,
}
impl CachedMermaidDiagram {
pub(crate) fn new(
contents: ParsedMarkdownMermaidDiagramContents,
fallback_image: Option<Arc<RenderImage>>,
cx: &mut Context<MarkdownPreviewView>,
) -> Self {
let result = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
let result_clone = result.clone();
let svg_renderer = cx.svg_renderer();
let _task = cx.spawn(async move |this, cx| {
let value = cx
.background_spawn(async move {
let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
let scale = contents.scale as f32 / 100.0;
svg_renderer
.render_single_frame(svg_string.as_bytes(), scale, true)
.map_err(|e| anyhow::anyhow!("{}", e))
})
.await;
let _ = result_clone.set(value);
this.update(cx, |_, cx| {
cx.notify();
})
.ok();
});
Self {
render_image: result,
fallback_image,
_task,
}
}
#[cfg(test)]
fn new_for_test(
render_image: Option<Arc<RenderImage>>,
fallback_image: Option<Arc<RenderImage>>,
) -> Self {
let result = Arc::new(OnceLock::new());
if let Some(img) = render_image {
let _ = result.set(Ok(img));
}
Self {
render_image: result,
fallback_image,
_task: Task::ready(()),
}
}
}
#[derive(Clone)]
pub struct RenderContext {
pub struct RenderContext<'a> {
workspace: Option<WeakEntity<Workspace>>,
next_id: usize,
buffer_font_family: SharedString,
@ -58,14 +190,16 @@ pub struct RenderContext {
indent: usize,
checkbox_clicked_callback: Option<CheckboxClickedCallback>,
is_last_child: bool,
mermaid_state: &'a MermaidState,
}
impl RenderContext {
pub fn new(
impl<'a> RenderContext<'a> {
pub(crate) fn new(
workspace: Option<WeakEntity<Workspace>>,
mermaid_state: &'a MermaidState,
window: &mut Window,
cx: &mut App,
) -> RenderContext {
) -> Self {
let theme = cx.theme().clone();
let settings = ThemeSettings::get_global(cx);
@ -95,6 +229,7 @@ impl RenderContext {
code_span_background_color: theme.colors().editor_document_highlight_read_background,
checkbox_clicked_callback: None,
is_last_child: false,
mermaid_state,
}
}
@ -163,7 +298,8 @@ pub fn render_parsed_markdown(
window: &mut Window,
cx: &mut App,
) -> Div {
let mut cx = RenderContext::new(workspace, window, cx);
let cache = Default::default();
let mut cx = RenderContext::new(workspace, &cache, window, cx);
v_flex().gap_3().children(
parsed
@ -181,6 +317,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte
Table(table) => render_markdown_table(table, cx),
BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx),
HorizontalRule(_) => render_markdown_rule(cx),
Image(image) => render_markdown_image(image, cx),
}
@ -320,7 +457,7 @@ struct MarkdownCheckbox {
style: ui::ToggleStyle,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
label: Option<SharedString>,
render_cx: RenderContext,
base_rem: Rems,
}
impl MarkdownCheckbox {
@ -336,7 +473,7 @@ impl MarkdownCheckbox {
tooltip: None,
label: None,
placeholder: false,
render_cx,
base_rem: render_cx.scaled_rems(1.0),
}
}
@ -379,7 +516,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
} else {
Color::Selected
};
let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small
let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small
let icon = match self.toggle_state {
ToggleState::Selected => {
if self.placeholder {
@ -404,7 +541,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
let border_color = self.border_color(cx);
let hover_border_color = border_color.alpha(0.7);
let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px)
let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px)
let checkbox = h_flex()
.id(self.id.clone())
@ -418,9 +555,9 @@ impl gpui::RenderOnce for MarkdownCheckbox {
.flex_none()
.justify_center()
.items_center()
.m(self.render_cx.scaled_rems(0.25)) // was .m_1
.size(self.render_cx.scaled_rems(1.0)) // was .size_4
.rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs
.m(self.base_rem.mul(0.25)) // was .m_1
.size(self.base_rem.mul(1.0)) // was .size_4
.rounded(self.base_rem.mul(0.125)) // was .rounded_xs
.border_1()
.bg(bg_color)
.border_color(border_color)
@ -437,7 +574,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
.flex_none()
.rounded_full()
.bg(color.color(cx).alpha(0.5))
.size(self.render_cx.scaled_rems(0.25)), // was .size_1
.size(self.base_rem.mul(0.25)), // was .size_1
)
})
.children(icon),
@ -651,6 +788,89 @@ fn render_markdown_code_block(
.into_any()
}
fn render_mermaid_diagram(
parsed: &ParsedMarkdownMermaidDiagram,
cx: &mut RenderContext,
) -> AnyElement {
let cached = cx.mermaid_state.cache.get(&parsed.contents);
if let Some(result) = cached.and_then(|c| c.render_image.get()) {
match result {
Ok(render_image) => cx
.with_common_p(div())
.px_3()
.py_3()
.bg(cx.code_block_background_color)
.rounded_sm()
.child(
div().w_full().child(
img(ImageSource::Render(render_image.clone()))
.max_w_full()
.with_fallback(|| {
div()
.child(Label::new("Failed to load mermaid diagram"))
.into_any_element()
}),
),
)
.into_any(),
Err(_) => cx
.with_common_p(div())
.px_3()
.py_3()
.bg(cx.code_block_background_color)
.rounded_sm()
.child(StyledText::new(parsed.contents.contents.clone()))
.into_any(),
}
} else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
cx.with_common_p(div())
.px_3()
.py_3()
.bg(cx.code_block_background_color)
.rounded_sm()
.child(
div()
.w_full()
.child(
img(ImageSource::Render(fallback.clone()))
.max_w_full()
.with_fallback(|| {
div()
.child(Label::new("Failed to load mermaid diagram"))
.into_any_element()
}),
)
.with_animation(
"mermaid-fallback-pulse",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.6, 1.0)),
|el, delta| el.opacity(delta),
),
)
.into_any()
} else {
cx.with_common_p(div())
.px_3()
.py_3()
.bg(cx.code_block_background_color)
.rounded_sm()
.child(
Label::new("Rendering mermaid diagram...")
.color(Color::Muted)
.with_animation(
"mermaid-loading-pulse",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
),
)
.into_any()
}
}
fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
cx.with_common_p(div())
.children(render_markdown_text(parsed, cx))
@ -917,6 +1137,7 @@ fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
use crate::markdown_elements::ParsedMarkdownTableColumn;
use crate::markdown_elements::ParsedMarkdownText;
@ -1074,4 +1295,204 @@ mod tests {
assert_eq!(list_item_prefix(1, false, 3), "");
assert_eq!(list_item_prefix(1, false, 4), " ");
}
fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
ParsedMarkdownMermaidDiagramContents {
contents: SharedString::from(s.to_string()),
scale: 1,
}
}
fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
diagrams
.iter()
.map(|diagram| mermaid_contents(diagram))
.collect()
}
fn mermaid_fallback(
new_diagram: &str,
new_full_order: &[ParsedMarkdownMermaidDiagramContents],
old_full_order: &[ParsedMarkdownMermaidDiagramContents],
cache: &MermaidDiagramCache,
) -> Option<Arc<RenderImage>> {
let new_content = mermaid_contents(new_diagram);
let idx = new_full_order
.iter()
.position(|content| content == &new_content)?;
MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
}
fn mock_render_image() -> Arc<RenderImage> {
Arc::new(RenderImage::new(Vec::new()))
}
#[test]
fn test_mermaid_fallback_on_edit() {
let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
let svg_b = mock_render_image();
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
cache.insert(
mermaid_contents("graph B"),
CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
);
cache.insert(
mermaid_contents("graph C"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback =
mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
assert!(
fallback.is_some(),
"Should use old diagram as fallback when editing"
);
assert!(
Arc::ptr_eq(&fallback.unwrap(), &svg_b),
"Fallback should be the old diagram's SVG"
);
}
#[test]
fn test_mermaid_no_fallback_on_add_in_middle() {
let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
cache.insert(
mermaid_contents("graph C"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
assert!(
fallback.is_none(),
"Should NOT use fallback when adding new diagram"
);
}
#[test]
fn test_mermaid_fallback_chains_on_rapid_edits() {
let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
let original_svg = mock_render_image();
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
cache.insert(
mermaid_contents("graph B modified"),
// Still rendering, but has fallback from original "graph B"
CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
);
cache.insert(
mermaid_contents("graph C"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback = mermaid_fallback(
"graph B modified again",
&new_full_order,
&old_full_order,
&cache,
);
assert!(
fallback.is_some(),
"Should chain fallback when previous render not complete"
);
assert!(
Arc::ptr_eq(&fallback.unwrap(), &original_svg),
"Fallback should chain through to the original SVG"
);
}
#[test]
fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
let old_full_order = mermaid_sequence(&["graph A"]);
let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
assert!(
fallback.is_none(),
"Should NOT have fallback when adding diagram at end"
);
}
#[test]
fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
let svg_a = mock_render_image();
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
);
cache.insert(
mermaid_contents("graph B"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
assert!(
fallback.is_some(),
"Should use old diagram as fallback when editing one of duplicate blocks"
);
assert!(
Arc::ptr_eq(&fallback.unwrap(), &svg_a),
"Fallback should be the old duplicate diagram's image"
);
}
#[test]
fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
let svg_a = mock_render_image();
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
);
cache.insert(
mermaid_contents("graph B"),
CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
);
let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
assert!(
fallback.is_some(),
"Should use old diagram as fallback when editing the second duplicate block"
);
assert!(
Arc::ptr_eq(&fallback.unwrap(), &svg_a),
"Fallback should be the old duplicate diagram's image"
);
}
}