diff --git a/Cargo.lock b/Cargo.lock index 76f8672d4d0d12..e210baa814b8f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9930,9 +9930,11 @@ dependencies = [ "editor", "fs", "gpui", + "html5ever 0.27.0", "language", "linkify", "log", + "markup5ever_rcdom", "pretty_assertions", "pulldown-cmark 0.12.2", "settings", diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index ebdd8a9eb6c0ff..55646cdcf43617 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -19,19 +19,21 @@ anyhow.workspace = true async-recursion.workspace = true collections.workspace = true editor.workspace = true +fs.workspace = true gpui.workspace = true +html5ever.workspace = true language.workspace = true linkify.workspace = true log.workspace = true +markup5ever_rcdom.workspace = true pretty_assertions.workspace = true pulldown-cmark.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true util.workspace = true -workspace.workspace = true workspace-hack.workspace = true -fs.workspace = true +workspace.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index a570e79f5344d0..560e468439efce 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -1,5 +1,6 @@ use gpui::{ - FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, UnderlineStyle, px, + DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, + UnderlineStyle, px, }; use language::HighlightId; use std::{fmt::Display, ops::Range, path::PathBuf}; @@ -15,6 +16,7 @@ pub enum ParsedMarkdownElement { /// A paragraph of text and other inline elements. Paragraph(MarkdownParagraph), HorizontalRule(Range), + Image(Image), } impl ParsedMarkdownElement { @@ -30,6 +32,7 @@ impl ParsedMarkdownElement { MarkdownParagraphChunk::Image(image) => image.source_range.clone(), }, Self::HorizontalRule(range) => range.clone(), + Self::Image(image) => image.source_range.clone(), }) } @@ -290,6 +293,8 @@ pub struct Image { pub link: Link, pub source_range: Range, pub alt_text: Option, + pub width: Option, + pub height: Option, } impl Image { @@ -303,10 +308,20 @@ impl Image { source_range, link, alt_text: None, + width: None, + height: None, }) } pub fn set_alt_text(&mut self, alt_text: SharedString) { self.alt_text = Some(alt_text); } + + pub fn set_width(&mut self, width: DefiniteLength) { + self.width = Some(width); + } + + pub fn set_height(&mut self, height: DefiniteLength) { + self.height = Some(height); + } } diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index b51b98a2ed64c7..1b116c50d9820d 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -1,10 +1,12 @@ use crate::markdown_elements::*; use async_recursion::async_recursion; use collections::FxHashMap; -use gpui::FontWeight; +use gpui::{DefiniteLength, FontWeight, px, relative}; +use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink}; use language::LanguageRegistry; +use markup5ever_rcdom::RcDom; use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; -use std::{ops::Range, path::PathBuf, sync::Arc, vec}; +use std::{cell::RefCell, collections::HashMap, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec}; pub async fn parse_markdown( markdown_input: &str, @@ -172,9 +174,14 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; - let code_block = self.parse_code_block(language).await; + let code_block = self.parse_code_block(language).await?; Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) } + Tag::HtmlBlock => { + self.cursor += 1; + + Some(self.parse_html_block().await) + } _ => None, }, Event::Rule => { @@ -378,7 +385,7 @@ impl<'a> MarkdownParser<'a> { TagEnd::Image => { if let Some(mut image) = image.take() { if !text.is_empty() { - image.alt_text = Some(std::mem::take(&mut text).into()); + image.set_alt_text(std::mem::take(&mut text).into()); } markdown_text_like.push(MarkdownParagraphChunk::Image(image)); } @@ -695,13 +702,22 @@ impl<'a> MarkdownParser<'a> { } } - async fn parse_code_block(&mut self, language: Option) -> ParsedMarkdownCodeBlock { - let (_event, source_range) = self.previous().unwrap(); + async fn parse_code_block( + &mut self, + language: Option, + ) -> Option { + 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 (current, _source_range) = self.current().unwrap(); + let Some((current, _source_range)) = self.current() else { + break; + }; + match current { Event::Text(text) => { code.push_str(text); @@ -734,23 +750,190 @@ impl<'a> MarkdownParser<'a> { None }; - ParsedMarkdownCodeBlock { + Some(ParsedMarkdownCodeBlock { source_range, contents: code.into(), language, highlights, + }) + } + + async fn parse_html_block(&mut self) -> Vec { + let mut elements = Vec::new(); + let Some((_event, _source_range)) = self.previous() else { + return elements; + }; + + while !self.eof() { + let Some((current, source_range)) = self.current() else { + break; + }; + let source_range = source_range.clone(); + match current { + Event::Html(html) => { + let mut cursor = std::io::Cursor::new(html.as_bytes()); + let Some(dom) = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut cursor) + .ok() + else { + self.cursor += 1; + continue; + }; + + self.cursor += 1; + + self.parse_html_node(source_range, &dom.document, &mut elements); + } + Event::End(TagEnd::CodeBlock) => { + self.cursor += 1; + break; + } + _ => { + break; + } + } + } + + elements + } + + fn parse_html_node( + &self, + source_range: Range, + node: &Rc, + elements: &mut Vec, + ) { + match &node.data { + 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(), + }), + ])); + } + markup5ever_rcdom::NodeData::Comment { .. } => {} + markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { + if local_name!("img") == name.local { + if let Some(image) = self.extract_image(source_range, attrs) { + elements.push(ParsedMarkdownElement::Image(image)); + } + } else { + self.consume_children(source_range, node, elements); + } + } + markup5ever_rcdom::NodeData::ProcessingInstruction { .. } => {} + } + } + + fn consume_children( + &self, + source_range: Range, + node: &Rc, + elements: &mut Vec, + ) { + for node in node.children.borrow().iter() { + self.parse_html_node(source_range.clone(), node, elements); + } + } + + fn attr_value( + attrs: &RefCell>, + name: html5ever::LocalName, + ) -> Option { + attrs.borrow().iter().find_map(|attr| { + if attr.name.local == name { + Some(attr.value.to_string()) + } else { + None + } + }) + } + + fn extract_styles_from_attributes( + attrs: &RefCell>, + ) -> HashMap { + let mut styles = HashMap::new(); + + if let Some(style) = Self::attr_value(attrs, local_name!("style")) { + for decl in style.split(';') { + let mut parts = decl.splitn(2, ':'); + if let Some((key, value)) = parts.next().zip(parts.next()) { + styles.insert( + key.trim().to_lowercase().to_string(), + value.trim().to_string(), + ); + } + } + } + + styles + } + + fn extract_image( + &self, + source_range: Range, + attrs: &RefCell>, + ) -> Option { + let src = Self::attr_value(attrs, local_name!("src"))?; + + let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?; + + if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) { + image.set_alt_text(alt.into()); + } + + let styles = Self::extract_styles_from_attributes(attrs); + + if let Some(width) = Self::attr_value(attrs, local_name!("width")) + .or_else(|| styles.get("width").cloned()) + .and_then(|width| Self::parse_length(&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)) + { + image.set_height(height); + } + + Some(image) + } + + /// Parses the width/height attribute value of an html element (e.g. img element) + fn parse_length(value: &str) -> Option { + if value.ends_with("%") { + value + .trim_end_matches("%") + .parse::() + .ok() + .map(|value| relative(value / 100.)) + } else { + value + .trim_end_matches("px") + .parse() + .ok() + .map(|value| px(value).into()) } } } #[cfg(test)] mod tests { - use core::panic; - use super::*; - use ParsedMarkdownListItemType::*; - use gpui::BackgroundExecutor; + use core::panic; + use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength}; use language::{ HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, tree_sitter_rust, }; @@ -925,6 +1108,8 @@ mod tests { url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), }, alt_text: Some("test".into()), + height: None, + width: None, },) ); } @@ -946,6 +1131,8 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: None, + height: None, + width: None, },) ); } @@ -965,6 +1152,8 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: Some("foo bar baz".into()), + height: None, + width: None, }),], ); } @@ -990,6 +1179,8 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: Some("foo".into()), + height: None, + width: None, }), MarkdownParagraphChunk::Text(ParsedMarkdownText { source_range: 0..81, @@ -1004,11 +1195,168 @@ mod tests { url: "http://example.com/bar.png".to_string(), }, alt_text: Some("bar".into()), + height: None, + width: None, }) ] ); } + #[test] + fn test_parse_length() { + // Test percentage values + assert_eq!( + MarkdownParser::parse_length("50%"), + Some(DefiniteLength::Fraction(0.5)) + ); + assert_eq!( + MarkdownParser::parse_length("100%"), + Some(DefiniteLength::Fraction(1.0)) + ); + assert_eq!( + MarkdownParser::parse_length("25%"), + Some(DefiniteLength::Fraction(0.25)) + ); + assert_eq!( + MarkdownParser::parse_length("0%"), + Some(DefiniteLength::Fraction(0.0)) + ); + + // Test pixel values + assert_eq!( + MarkdownParser::parse_length("100px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) + ); + assert_eq!( + MarkdownParser::parse_length("50px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0)))) + ); + assert_eq!( + MarkdownParser::parse_length("0px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0)))) + ); + + // Test values without units (should be treated as pixels) + assert_eq!( + MarkdownParser::parse_length("100"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) + ); + assert_eq!( + MarkdownParser::parse_length("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); + + // Test decimal values + assert_eq!( + MarkdownParser::parse_length("50.5%"), + Some(DefiniteLength::Fraction(0.505)) + ); + assert_eq!( + MarkdownParser::parse_length("100.25px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25)))) + ); + assert_eq!( + MarkdownParser::parse_length("42.0"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) + ); + } + + #[gpui::test] + async fn test_html_image_tag() { + let parsed = parse("").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, + }, + ); + } + + #[gpui::test] + async fn test_html_image_tag_with_alt_text() { + let parsed = parse("\"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, + }, + ); + } + + #[gpui::test] + async fn test_html_image_tag_with_height_and_width() { + let parsed = + parse("").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.)))), + }, + ); + } + + #[gpui::test] + async fn test_html_image_style_tag_with_height_and_width() { + let parsed = parse( + "", + ) + .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.)))), + }, + ); + } + #[gpui::test] async fn test_header_only_table() { let markdown = "\ diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index b0b10e927cb3bb..b07b4686a4eaeb 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,5 +1,5 @@ use crate::markdown_elements::{ - HeadingLevel, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, + HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, @@ -164,6 +164,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), CodeBlock(code_block) => render_markdown_code_block(code_block, cx), HorizontalRule(_) => render_markdown_rule(cx), + Image(image) => render_markdown_image(image, cx), } } @@ -722,65 +723,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) } MarkdownParagraphChunk::Image(image) => { - let image_resource = match image.link.clone() { - Link::Web { url } => Resource::Uri(url.into()), - Link::Path { path, .. } => Resource::Path(Arc::from(path)), - }; - - let element_id = cx.next_id(&image.source_range); - - let image_element = div() - .id(element_id) - .cursor_pointer() - .child( - img(ImageSource::Resource(image_resource)) - .max_w_full() - .with_fallback({ - let alt_text = image.alt_text.clone(); - move || div().children(alt_text.clone()).into_any_element() - }), - ) - .tooltip({ - let link = image.link.clone(); - move |_, cx| { - InteractiveMarkdownElementTooltip::new( - Some(link.to_string()), - "open image", - cx, - ) - .into() - } - }) - .on_click({ - let workspace = workspace_clone.clone(); - let link = image.link.clone(); - move |_, window, cx| { - if window.modifiers().secondary() { - match &link { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - } - } - } - }) - .into_any(); - any_element.push(image_element); + any_element.push(render_markdown_image(image, cx)); } } } @@ -793,18 +736,86 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { div().py(cx.scaled_rems(0.5)).child(rule).into_any() } +fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement { + let image_resource = match image.link.clone() { + Link::Web { url } => Resource::Uri(url.into()), + Link::Path { path, .. } => Resource::Path(Arc::from(path)), + }; + + let element_id = cx.next_id(&image.source_range); + let workspace = cx.workspace.clone(); + + div() + .id(element_id) + .cursor_pointer() + .child( + img(ImageSource::Resource(image_resource)) + .max_w_full() + .with_fallback({ + let alt_text = image.alt_text.clone(); + move || div().children(alt_text.clone()).into_any_element() + }) + .when_some(image.height, |this, height| this.h(height)) + .when_some(image.width, |this, width| this.w(width)), + ) + .tooltip({ + let link = image.link.clone(); + let alt_text = image.alt_text.clone(); + move |_, cx| { + InteractiveMarkdownElementTooltip::new( + Some(alt_text.clone().unwrap_or(link.to_string().into())), + "open image", + cx, + ) + .into() + } + }) + .on_click({ + let link = image.link.clone(); + move |_, window, cx| { + if window.modifiers().secondary() { + match &link { + Link::Web { url } => cx.open_url(url), + Link::Path { path, .. } => { + if let Some(workspace) = &workspace { + _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + path.clone(), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + .detach(); + }); + } + } + } + } + } + }) + .into_any() +} + struct InteractiveMarkdownElementTooltip { tooltip_text: Option, - action_text: String, + action_text: SharedString, } impl InteractiveMarkdownElementTooltip { - pub fn new(tooltip_text: Option, action_text: &str, cx: &mut App) -> Entity { + pub fn new( + tooltip_text: Option, + action_text: impl Into, + cx: &mut App, + ) -> Entity { let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); cx.new(|_cx| Self { tooltip_text, - action_text: action_text.to_string(), + action_text: action_text.into(), }) } }