mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
bd2333d573
commit
856ba20261
7 changed files with 610 additions and 74 deletions
58
Cargo.lock
generated
58
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue