markdown: Add support for inline HTML img tags inside text (#37264)
Follow-up: #36700 This PR adds basic support for showing images inline inside a text. As you can see inside the before screenshot, the image was displayed right below the `Some inline text` text. This was because we didn't consider the image to be inline with the text (paragraph). Now we do :) All the test changes are making sure it is not more than 1 element parsed, instead of only checking for the first parsed element. This could work out bad when we return more than 1 result. **Before** <img width="1717" height="1344" alt="Screenshot 2025-08-31 at 13 49 45" src="https://github.com/user-attachments/assets/13c5f9dd-0e0a-4e08-b2a6-28e9a4e0cab8" /> **After** <img width="1719" height="1343" alt="Screenshot 2025-08-31 at 13 42 14" src="https://github.com/user-attachments/assets/bf7aa82f-3743-4fb3-87aa-4a97a550c4d1" /> **Code example**: ```markdown <p>some inline text <img src="https://picsum.photos/200/300" alt="Description of image" style="height: 100px" /> asdjkflsadjfl</p> # Html Tag <img src="https://picsum.photos/200/300" alt="Description of image" /> # Html Tag with width and height <img src="https://picsum.photos/200/300" alt="Description of image" width="100" height="200" /> # Html Tag with style attribute with width and height <img src="https://picsum.photos/200/300" alt="Description of image" style="width: 100px; height: 200px" /> # Normal Tag  ``` Release Notes: - Markdown: Added support for inline HTML `img` tags inside paragraphs
This commit is contained in:
@@ -155,7 +155,7 @@ pub struct ParsedMarkdownText {
|
||||
/// Where the text is located in the source Markdown document.
|
||||
pub source_range: Range<usize>,
|
||||
/// The text content stripped of any formatting symbols.
|
||||
pub contents: String,
|
||||
pub contents: SharedString,
|
||||
/// The list of highlights contained in the Markdown document.
|
||||
pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
|
||||
/// The regions of the various ranges in the Markdown document.
|
||||
|
||||
@@ -353,7 +353,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
if !text.is_empty() {
|
||||
let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range: source_range.clone(),
|
||||
contents: text.clone(),
|
||||
contents: text.into(),
|
||||
highlights: highlights.clone(),
|
||||
region_ranges: region_ranges.clone(),
|
||||
regions: regions.clone(),
|
||||
@@ -408,7 +408,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
if !text.is_empty() {
|
||||
markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range,
|
||||
contents: text,
|
||||
contents: text.into(),
|
||||
highlights,
|
||||
regions,
|
||||
region_ranges,
|
||||
@@ -808,15 +808,14 @@ impl<'a> MarkdownParser<'a> {
|
||||
markup5ever_rcdom::NodeData::Document => {
|
||||
self.consume_children(source_range, node, elements);
|
||||
}
|
||||
markup5ever_rcdom::NodeData::Doctype { .. } => {}
|
||||
markup5ever_rcdom::NodeData::Text { contents } => {
|
||||
elements.push(ParsedMarkdownElement::Paragraph(vec![
|
||||
MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range,
|
||||
contents: contents.borrow().to_string(),
|
||||
highlights: Vec::default(),
|
||||
region_ranges: Vec::default(),
|
||||
regions: Vec::default(),
|
||||
region_ranges: Vec::default(),
|
||||
highlights: Vec::default(),
|
||||
contents: contents.borrow().to_string().into(),
|
||||
}),
|
||||
]));
|
||||
}
|
||||
@@ -826,11 +825,64 @@ impl<'a> MarkdownParser<'a> {
|
||||
if let Some(image) = self.extract_image(source_range, attrs) {
|
||||
elements.push(ParsedMarkdownElement::Image(image));
|
||||
}
|
||||
} else if local_name!("p") == name.local {
|
||||
self.parse_paragraph(
|
||||
source_range,
|
||||
node,
|
||||
&mut MarkdownParagraph::new(),
|
||||
elements,
|
||||
);
|
||||
} else {
|
||||
self.consume_children(source_range, node, elements);
|
||||
}
|
||||
}
|
||||
markup5ever_rcdom::NodeData::ProcessingInstruction { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_paragraph(
|
||||
&self,
|
||||
source_range: Range<usize>,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
paragraph: &mut MarkdownParagraph,
|
||||
elements: &mut Vec<ParsedMarkdownElement>,
|
||||
) {
|
||||
match &node.data {
|
||||
markup5ever_rcdom::NodeData::Text { contents } => {
|
||||
paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range,
|
||||
regions: Vec::default(),
|
||||
region_ranges: Vec::default(),
|
||||
highlights: Vec::default(),
|
||||
contents: contents.borrow().to_string().into(),
|
||||
}));
|
||||
}
|
||||
markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
|
||||
if local_name!("img") == name.local {
|
||||
if let Some(image) = self.extract_image(source_range, attrs) {
|
||||
paragraph.push(MarkdownParagraphChunk::Image(image));
|
||||
}
|
||||
} else {
|
||||
self.consume_paragraph(source_range, node, paragraph, elements);
|
||||
|
||||
if !paragraph.is_empty() {
|
||||
elements.push(ParsedMarkdownElement::Paragraph(std::mem::take(paragraph)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_paragraph(
|
||||
&self,
|
||||
source_range: Range<usize>,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
paragraph: &mut MarkdownParagraph,
|
||||
elements: &mut Vec<ParsedMarkdownElement>,
|
||||
) {
|
||||
for node in node.children.borrow().iter() {
|
||||
self.parse_paragraph(source_range.clone(), node, paragraph, elements);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,14 +947,14 @@ impl<'a> MarkdownParser<'a> {
|
||||
|
||||
if let Some(width) = Self::attr_value(attrs, local_name!("width"))
|
||||
.or_else(|| styles.get("width").cloned())
|
||||
.and_then(|width| Self::parse_length(&width))
|
||||
.and_then(|width| Self::parse_html_element_dimension(&width))
|
||||
{
|
||||
image.set_width(width);
|
||||
}
|
||||
|
||||
if let Some(height) = Self::attr_value(attrs, local_name!("height"))
|
||||
.or_else(|| styles.get("height").cloned())
|
||||
.and_then(|height| Self::parse_length(&height))
|
||||
.and_then(|height| Self::parse_html_element_dimension(&height))
|
||||
{
|
||||
image.set_height(height);
|
||||
}
|
||||
@@ -910,8 +962,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Some(image)
|
||||
}
|
||||
|
||||
/// Parses the width/height attribute value of an html element (e.g. img element)
|
||||
fn parse_length(value: &str) -> Option<DefiniteLength> {
|
||||
fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
|
||||
if value.ends_with("%") {
|
||||
value
|
||||
.trim_end_matches("%")
|
||||
@@ -1010,7 +1061,7 @@ mod tests {
|
||||
ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text(
|
||||
ParsedMarkdownText {
|
||||
source_range: 0..35,
|
||||
contents: "Some bostrikethroughld text".to_string(),
|
||||
contents: "Some bostrikethroughld text".into(),
|
||||
highlights: Vec::new(),
|
||||
region_ranges: Vec::new(),
|
||||
regions: Vec::new(),
|
||||
@@ -1184,7 +1235,7 @@ mod tests {
|
||||
}),
|
||||
MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range: 0..81,
|
||||
contents: " Lorem Ipsum ".to_string(),
|
||||
contents: " Lorem Ipsum ".into(),
|
||||
highlights: Vec::new(),
|
||||
region_ranges: Vec::new(),
|
||||
regions: Vec::new(),
|
||||
@@ -1203,90 +1254,130 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_length() {
|
||||
fn test_parse_html_element_dimension() {
|
||||
// Test percentage values
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("50%"),
|
||||
MarkdownParser::parse_html_element_dimension("50%"),
|
||||
Some(DefiniteLength::Fraction(0.5))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("100%"),
|
||||
MarkdownParser::parse_html_element_dimension("100%"),
|
||||
Some(DefiniteLength::Fraction(1.0))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("25%"),
|
||||
MarkdownParser::parse_html_element_dimension("25%"),
|
||||
Some(DefiniteLength::Fraction(0.25))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("0%"),
|
||||
MarkdownParser::parse_html_element_dimension("0%"),
|
||||
Some(DefiniteLength::Fraction(0.0))
|
||||
);
|
||||
|
||||
// Test pixel values
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("100px"),
|
||||
MarkdownParser::parse_html_element_dimension("100px"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("50px"),
|
||||
MarkdownParser::parse_html_element_dimension("50px"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("0px"),
|
||||
MarkdownParser::parse_html_element_dimension("0px"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
|
||||
);
|
||||
|
||||
// Test values without units (should be treated as pixels)
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("100"),
|
||||
MarkdownParser::parse_html_element_dimension("100"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("42"),
|
||||
MarkdownParser::parse_html_element_dimension("42"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
|
||||
);
|
||||
|
||||
// Test invalid values
|
||||
assert_eq!(MarkdownParser::parse_length("invalid"), None);
|
||||
assert_eq!(MarkdownParser::parse_length("px"), None);
|
||||
assert_eq!(MarkdownParser::parse_length("%"), None);
|
||||
assert_eq!(MarkdownParser::parse_length(""), None);
|
||||
assert_eq!(MarkdownParser::parse_length("abc%"), None);
|
||||
assert_eq!(MarkdownParser::parse_length("abcpx"), None);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_html_element_dimension("invalid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None);
|
||||
assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None);
|
||||
assert_eq!(MarkdownParser::parse_html_element_dimension(""), None);
|
||||
assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None);
|
||||
assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None);
|
||||
|
||||
// Test decimal values
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("50.5%"),
|
||||
MarkdownParser::parse_html_element_dimension("50.5%"),
|
||||
Some(DefiniteLength::Fraction(0.505))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("100.25px"),
|
||||
MarkdownParser::parse_html_element_dimension("100.25px"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
|
||||
);
|
||||
assert_eq!(
|
||||
MarkdownParser::parse_length("42.0"),
|
||||
MarkdownParser::parse_html_element_dimension("42.0"),
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_inline_html_image_tag() {
|
||||
let parsed =
|
||||
parse("<p>Some text<img src=\"http://example.com/foo.png\" /> some more text</p>")
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Paragraph(vec![
|
||||
MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range: 0..71,
|
||||
contents: "Some text".into(),
|
||||
highlights: Default::default(),
|
||||
region_ranges: Default::default(),
|
||||
regions: Default::default()
|
||||
}),
|
||||
MarkdownParagraphChunk::Image(Image {
|
||||
source_range: 0..71,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: None,
|
||||
width: None,
|
||||
}),
|
||||
MarkdownParagraphChunk::Text(ParsedMarkdownText {
|
||||
source_range: 0..71,
|
||||
contents: " some more text".into(),
|
||||
highlights: Default::default(),
|
||||
region_ranges: Default::default(),
|
||||
regions: Default::default()
|
||||
}),
|
||||
])]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_image_tag() {
|
||||
let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
|
||||
|
||||
let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
|
||||
panic!("Expected a image element");
|
||||
};
|
||||
assert_eq!(
|
||||
image.clone(),
|
||||
Image {
|
||||
source_range: 0..40,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: None,
|
||||
width: None,
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Image(Image {
|
||||
source_range: 0..40,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: None,
|
||||
width: None,
|
||||
})]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1294,20 +1385,19 @@ mod tests {
|
||||
async fn test_html_image_tag_with_alt_text() {
|
||||
let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
|
||||
|
||||
let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
|
||||
panic!("Expected a image element");
|
||||
};
|
||||
assert_eq!(
|
||||
image.clone(),
|
||||
Image {
|
||||
source_range: 0..50,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: Some("Foo".into()),
|
||||
height: None,
|
||||
width: None,
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Image(Image {
|
||||
source_range: 0..50,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: Some("Foo".into()),
|
||||
height: None,
|
||||
width: None,
|
||||
})]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1316,20 +1406,19 @@ mod tests {
|
||||
let parsed =
|
||||
parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
|
||||
|
||||
let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
|
||||
panic!("Expected a image element");
|
||||
};
|
||||
assert_eq!(
|
||||
image.clone(),
|
||||
Image {
|
||||
source_range: 0..65,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
|
||||
width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Image(Image {
|
||||
source_range: 0..65,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
|
||||
width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
|
||||
})]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1340,20 +1429,19 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
|
||||
panic!("Expected a image element");
|
||||
};
|
||||
assert_eq!(
|
||||
image.clone(),
|
||||
Image {
|
||||
source_range: 0..75,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
|
||||
width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
|
||||
ParsedMarkdown {
|
||||
children: vec![ParsedMarkdownElement::Image(Image {
|
||||
source_range: 0..75,
|
||||
link: Link::Web {
|
||||
url: "http://example.com/foo.png".to_string(),
|
||||
},
|
||||
alt_text: None,
|
||||
height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
|
||||
width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
|
||||
})]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1804,7 +1892,7 @@ fn main() {
|
||||
region_ranges: Vec::new(),
|
||||
regions: Vec::new(),
|
||||
source_range,
|
||||
contents: contents.to_string(),
|
||||
contents: contents.to_string().into(),
|
||||
})]
|
||||
}
|
||||
|
||||
|
||||
@@ -624,15 +624,13 @@ fn render_markdown_code_block(
|
||||
}
|
||||
|
||||
fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
|
||||
cx.with_common_p(div())
|
||||
cx.with_common_p(h_flex().flex_wrap())
|
||||
.children(render_markdown_text(parsed, cx))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
|
||||
let mut any_element = vec![];
|
||||
let mut any_element = Vec::with_capacity(parsed_new.len());
|
||||
// these values are cloned in-order satisfy borrow checker
|
||||
let syntax_theme = cx.syntax_theme.clone();
|
||||
let workspace_clone = cx.workspace.clone();
|
||||
|
||||
Reference in New Issue
Block a user