diff --git a/crates/story/examples/fixtures/test.md b/crates/story/examples/fixtures/test.md index d55d7ffb..e216a476 100644 --- a/crates/story/examples/fixtures/test.md +++ b/crates/story/examples/fixtures/test.md @@ -1,5 +1,7 @@ # Hello, **World**! +Build Status [![Build Status](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml/badge.svg)](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml) of [GPUI Component](https://github.com/longbridge/gpui-component). + This is first paragraph, there have **BOLD**, _italic_, and ~strikethrough~, `code` text. This is an additional demonstration paragraph in English demonstrating more content for [Markdown GFM](https://github.github.com/gfm/). It includes various stylistic elements and plain text. diff --git a/crates/ui/src/text/element.rs b/crates/ui/src/text/element.rs index b45f89ec..25d23596 100644 --- a/crates/ui/src/text/element.rs +++ b/crates/ui/src/text/element.rs @@ -4,11 +4,14 @@ use gpui::{ div, img, prelude::FluentBuilder as _, px, relative, rems, AnyElement, App, DefiniteLength, Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, InteractiveText, IntoElement, Length, ObjectFit, ParentElement, Rems, RenderOnce, SharedString, - SharedUri, Styled, StyledImage as _, StyledText, Window, + SharedUri, StatefulInteractiveElement, Styled, StyledImage as _, StyledText, Window, }; use markdown::mdast; -use crate::{h_flex, highlighter::SyntaxHighlighter, v_flex, ActiveTheme as _, Icon, IconName}; +use crate::{ + h_flex, highlighter::SyntaxHighlighter, tooltip::Tooltip, v_flex, ActiveTheme as _, Icon, + IconName, +}; use super::{utils::list_item_prefix, TextViewStyle}; @@ -44,12 +47,22 @@ impl From for ElementId { #[derive(Debug, Default, Clone)] pub struct ImageNode { pub url: SharedUri, + pub link: Option, pub title: Option, pub alt: Option, pub width: Option, pub height: Option, } +impl ImageNode { + pub fn title(&self) -> String { + self.title + .clone() + .unwrap_or_else(|| self.alt.clone().unwrap_or_default()) + .to_string() + } +} + impl PartialEq for ImageNode { fn eq(&self, other: &Self) -> bool { self.url == other.url && self.title == other.title && self.alt == other.alt @@ -60,37 +73,24 @@ impl PartialEq for ImageNode { pub struct TextNode { /// The text content. pub text: String, + pub image: Option, /// The text styles, each tuple contains the range of the text and the style. pub marks: Vec<(Range, InlineTextStyle)>, } -#[derive(Debug, Clone, PartialEq, IntoElement)] -pub enum Paragraph { - Texts { - span: Option, - children: Vec, - }, - Image { - span: Option, - image: ImageNode, - }, -} - -impl Default for Paragraph { - fn default() -> Self { - Self::Texts { - span: None, - children: vec![], - } - } +#[derive(Debug, Default, Clone, PartialEq, IntoElement)] +pub struct Paragraph { + pub(super) span: Option, + pub(super) children: Vec, } impl From for Paragraph { fn from(value: String) -> Self { - Self::Texts { + Self { span: None, children: vec![TextNode { text: value.clone(), + image: None, marks: vec![], }], } @@ -141,80 +141,60 @@ pub struct TableCell { impl Paragraph { pub fn clear(&mut self) { - match self { - Self::Texts { children, .. } => children.clear(), - Self::Image { .. } => *self = Self::default(), - } + self.span = None; + self.children.clear(); } pub fn is_image(&self) -> bool { - matches!(self, Self::Image { .. }) + false } pub fn set_span(&mut self, span: Span) { - match self { - Self::Texts { span: s, .. } => *s = Some(span), - Self::Image { span: s, .. } => *s = Some(span), - } + self.span = Some(span); } pub fn push_str(&mut self, text: &str) { - if let Self::Texts { children, .. } = self { - children.push(TextNode { - text: text.to_string(), - marks: vec![(0..text.len(), InlineTextStyle::default())], - }); - } + self.children.push(TextNode { + text: text.to_string(), + image: None, + marks: vec![(0..text.len(), InlineTextStyle::default())], + }); } pub fn push(&mut self, text: TextNode) { - if let Self::Texts { children, .. } = self { - children.push(text); - } + self.children.push(text); } - pub fn set_image(&mut self, image: ImageNode) { - *self = Self::Image { span: None, image }; + pub fn push_image(&mut self, image: ImageNode) { + self.children.push(TextNode { + text: String::new(), + image: Some(image), + marks: vec![], + }); } pub fn is_empty(&self) -> bool { - match self { - Self::Texts { .. } => self.text_len() == 0, - Self::Image { .. } => false, - } + self.children.is_empty() + || self + .children + .iter() + .all(|node| node.text.is_empty() && node.image.is_none()) } /// Return length of children text. pub fn text_len(&self) -> usize { - match self { - Self::Texts { children, .. } => { - let mut len = 0; - for text_node in children.iter() { - len = text_node.text.len().max(len); - } - len - } - Self::Image { .. } => 1, - } + self.children + .iter() + .map(|node| node.text.len()) + .sum::() } /// Try to merge two paragraphs, if they are both text elements. /// /// - Returns `true` if other have merge into self. /// - Returns `false` if not able to merge. - pub fn try_merge(&mut self, other: &Self) -> bool { - if let Self::Texts { children, .. } = self { - if let Self::Texts { - children: other_children, - .. - } = other - { - children.extend(other_children.clone()); - return true; - } - } - - false + pub fn merge(&mut self, other: &Self) { + self.children.extend(other.children.clone()); } } @@ -307,92 +287,132 @@ impl Node { impl RenderOnce for Paragraph { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - match self { - Self::Texts { span, children } => { - let mut text = String::new(); - let mut highlights: Vec<(Range, HighlightStyle)> = vec![]; - let mut links: Vec<(Range, LinkMark)> = vec![]; - let mut offset = 0; + let span = self.span; + let children = self.children; - for text_node in children.into_iter() { - let text_len = text_node.text.len(); - let part = if text.len() == 0 { - // trim start for first text - text_node.text.trim_start() - } else { - text_node.text.as_str() - }; - text.push_str(&part); + let mut child_nodes: Vec = vec![]; - let mut node_highlights = vec![]; - for (range, style) in text_node.marks { - let inner_range = (offset + range.start)..(offset + range.end); + let mut text = String::new(); + let mut highlights: Vec<(Range, HighlightStyle)> = vec![]; + let mut links: Vec<(Range, LinkMark)> = vec![]; + let mut offset = 0; - let mut highlight = HighlightStyle::default(); - if style.bold { - highlight.font_weight = Some(FontWeight::BOLD); - } - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - if style.strikethrough { - highlight.strikethrough = Some(gpui::StrikethroughStyle { - thickness: gpui::px(1.), - ..Default::default() - }); - } - if style.code { - highlight.background_color = Some(cx.theme().accent); + fn inline_text( + ix: usize, + text: String, + links: Vec<(Range, LinkMark)>, + highlights: Vec<(Range, HighlightStyle)>, + window: &Window, + ) -> AnyElement { + let text_style = window.text_style(); + let styled_text = + StyledText::new(text).with_default_highlights(&text_style, highlights); + let link_ranges = links + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); + + InteractiveText::new(ix, styled_text) + .on_click(link_ranges, { + let links = links.clone(); + move |ix, _, cx| { + if let Some((_, link)) = &links.get(ix) { + // Stop propagation to prevent the parent element from handling the event. + // + // For example the text in a checkbox label, click link need avoid toggle check state. + cx.stop_propagation(); + cx.open_url(&link.url); } + } + }) + .into_any_element() + } + let mut ix = 0; + for text_node in children.into_iter() { + let text_len = text_node.text.len(); + text.push_str(&text_node.text); - if let Some(link_mark) = style.link { - highlight.color = Some(cx.theme().link); - highlight.underline = Some(gpui::UnderlineStyle { - thickness: gpui::px(1.), - ..Default::default() - }); + if let Some(image) = &text_node.image { + if text.len() > 0 { + child_nodes.push(inline_text( + ix, + text.clone(), + links.clone(), + highlights.clone(), + window, + )); + } + child_nodes.push( + img(image.url.clone()) + .id(ix) + .object_fit(ObjectFit::Contain) + .max_w(relative(1.)) + .when_some(image.width, |this, width| this.w(width)) + .when_some(image.link.clone(), |this, link| { + let title = image.title(); + this.cursor_pointer() + .tooltip(move |window, cx| { + Tooltip::new(title.clone()).build(window, cx) + }) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.open_url(&link.url); + }) + }) + .into_any_element(), + ); - links.push((inner_range.clone(), link_mark)); - } + text.clear(); + links.clear(); + highlights.clear(); + offset = 0; + } else { + let mut node_highlights = vec![]; + for (range, style) in text_node.marks { + let inner_range = (offset + range.start)..(offset + range.end); - node_highlights.push((inner_range, highlight)); + let mut highlight = HighlightStyle::default(); + if style.bold { + highlight.font_weight = Some(FontWeight::BOLD); + } + if style.italic { + highlight.font_style = Some(FontStyle::Italic); + } + if style.strikethrough { + highlight.strikethrough = Some(gpui::StrikethroughStyle { + thickness: gpui::px(1.), + ..Default::default() + }); + } + if style.code { + highlight.background_color = Some(cx.theme().accent); } - highlights = gpui::combine_highlights(highlights, node_highlights).collect(); + if let Some(link_mark) = style.link { + highlight.color = Some(cx.theme().link); + highlight.underline = Some(gpui::UnderlineStyle { + thickness: gpui::px(1.), + ..Default::default() + }); - offset += text_len; + links.push((inner_range.clone(), link_mark)); + } + + node_highlights.push((inner_range, highlight)); } - let text_style = window.text_style(); - let element_id: ElementId = span.unwrap_or_default().into(); - let styled_text = - StyledText::new(text).with_default_highlights(&text_style, highlights); - let link_ranges = links - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); - - InteractiveText::new(element_id, styled_text) - .on_click(link_ranges, { - let links = links.clone(); - move |ix, _, cx| { - if let Some((_, link)) = &links.get(ix) { - // Stop propagation to prevent the parent element from handling the event. - // - // For example the text in a checkbox label, click link need avoid toggle check state. - cx.stop_propagation(); - cx.open_url(&link.url); - } - } - }) - .into_any_element() + highlights = gpui::combine_highlights(highlights, node_highlights).collect(); + offset += text_len; } - Self::Image { image, .. } => img(image.url) - .object_fit(ObjectFit::Contain) - .max_w(relative(1.)) - .when_some(image.width, |this, width| this.w(width)) - .into_any_element(), + ix += 1; } + + if text.len() > 0 { + // Add the last text node + child_nodes.push(inline_text(ix, text, links, highlights, window)); + } + + div().id(span.unwrap_or_default()).children(child_nodes) } } @@ -733,41 +753,42 @@ impl Node { impl Paragraph { fn to_markdown(&self) -> String { - let mut text = match self { - Paragraph::Texts { children, .. } => children - .iter() - .map(|text_node| { - let mut text = text_node.text.clone(); - for (range, style) in &text_node.marks { - if style.bold { - text = format!("**{}**", &text_node.text[range.clone()]); - } - if style.italic { - text = format!("*{}*", &text_node.text[range.clone()]); - } - if style.strikethrough { - text = format!("~~{}~~", &text_node.text[range.clone()]); - } - if style.code { - text = format!("`{}`", &text_node.text[range.clone()]); - } - if let Some(link) = &style.link { - text = format!("[{}]({})", &text_node.text[range.clone()], link.url); - } + let mut text = self + .children + .iter() + .map(|text_node| { + let mut text = text_node.text.clone(); + for (range, style) in &text_node.marks { + if style.bold { + text = format!("**{}**", &text_node.text[range.clone()]); } - text - }) - .collect::>() - .join(""), - Paragraph::Image { image, .. } => { - let alt = image.alt.clone().unwrap_or_default(); - let title = image - .title - .clone() - .map_or(String::new(), |t| format!(" \"{}\"", t)); - format!("![{}]({}{})", alt, image.url, title) - } - }; + if style.italic { + text = format!("*{}*", &text_node.text[range.clone()]); + } + if style.strikethrough { + text = format!("~~{}~~", &text_node.text[range.clone()]); + } + if style.code { + text = format!("`{}`", &text_node.text[range.clone()]); + } + if let Some(link) = &style.link { + text = format!("[{}]({})", &text_node.text[range.clone()], link.url); + } + } + + if let Some(image) = &text_node.image { + let alt = image.alt.clone().unwrap_or_default(); + let title = image + .title + .clone() + .map_or(String::new(), |t| format!(" \"{}\"", t)); + text.push_str(&format!("![{}]({}{})", alt, image.url, title)) + } + + text + }) + .collect::>() + .join(""); text.push_str("\n\n"); text diff --git a/crates/ui/src/text/html.rs b/crates/ui/src/text/html.rs index 5f0de714..8f581afb 100644 --- a/crates/ui/src/text/html.rs +++ b/crates/ui/src/text/html.rs @@ -416,6 +416,7 @@ fn parse_paragraph( )); paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -434,6 +435,7 @@ fn parse_paragraph( )); paragraph.push(TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -452,6 +454,7 @@ fn parse_paragraph( )); paragraph.push(TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -470,6 +473,7 @@ fn parse_paragraph( )); paragraph.push(TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -494,6 +498,7 @@ fn parse_paragraph( )); paragraph.push(TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -509,8 +514,9 @@ fn parse_paragraph( let title = attr_value(attrs, local_name!("title")); let (width, height) = attr_width_height(attrs); - paragraph.set_image(ImageNode { + paragraph.push_image(ImageNode { url: src.into(), + link: None, alt: alt.map(Into::into), width, height, @@ -526,6 +532,7 @@ fn parse_paragraph( } paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -538,6 +545,7 @@ fn parse_paragraph( } paragraph.push(TextNode { text: text.clone(), + image: None, marks: marks.clone(), }); } @@ -611,22 +619,21 @@ fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> Option 0 { - children.push(element::Node::Paragraph(image)); + children.push(element::Node::Paragraph(paragraph)); Some(element::Node::Root { children }) } else { - Some(element::Node::Paragraph(image)) + Some(element::Node::Paragraph(paragraph)) } } local_name!("ul") | local_name!("ol") => { @@ -647,9 +654,8 @@ fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> Option, paragraph: &mut Paragrap mod tests { use gpui::{px, relative}; - use crate::text::element::{Node, Paragraph}; + use crate::text::element::{Node, Paragraph, TextNode}; use super::trim_text; @@ -860,15 +866,20 @@ mod tests { let node = super::parse_html(html).unwrap(); assert_eq!( node, - Node::Paragraph(Paragraph::Image { + Node::Paragraph(Paragraph { span: None, - image: super::ImageNode { - url: "https://example.com/image.png".to_string().into(), - alt: Some("Example".to_string().into()), - width: Some(px(100.).into()), - height: Some(px(200.).into()), - title: Some("Example Image".to_string().into()) - } + children: vec![TextNode { + text: String::new(), + marks: vec![], + image: Some(super::ImageNode { + url: "https://example.com/image.png".to_string().into(), + alt: Some("Example".to_string().into()), + width: Some(px(100.).into()), + height: Some(px(200.).into()), + title: Some("Example Image".to_string().into()), + ..Default::default() + }), + }], }) ); @@ -876,15 +887,20 @@ mod tests { let node = super::parse_html(html).unwrap(); assert_eq!( node, - Node::Paragraph(Paragraph::Image { + Node::Paragraph(Paragraph { span: None, - image: super::ImageNode { - url: "https://example.com/image.png".to_string().into(), - alt: Some("Example".to_string().into()), - width: Some(relative(0.8)), - height: None, - title: Some("Example Image".to_string().into()) - } + children: vec![TextNode { + text: String::new(), + marks: vec![], + image: Some(super::ImageNode { + url: "https://example.com/image.png".to_string().into(), + alt: Some("Example".to_string().into()), + width: Some(relative(0.8)), + height: None, + title: Some("Example Image".to_string().into()), + ..Default::default() + }), + }], }) ); } diff --git a/crates/ui/src/text/markdown.rs b/crates/ui/src/text/markdown.rs index d5511696..27bf86fa 100644 --- a/crates/ui/src/text/markdown.rs +++ b/crates/ui/src/text/markdown.rs @@ -230,6 +230,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { } paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![( 0..text.len(), InlineTextStyle { @@ -246,6 +247,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { } paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![( 0..text.len(), InlineTextStyle { @@ -262,6 +264,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { } paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![( 0..text.len(), InlineTextStyle { @@ -275,6 +278,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { text = val.value.clone(); paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![( 0..text.len(), InlineTextStyle { @@ -285,26 +289,36 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { }); } Node::Link(val) => { + let link_mark = Some(LinkMark { + url: val.url.clone().into(), + title: val.title.clone().map(|s| s.into()), + }); + let mut child_paragraph = Paragraph::default(); for child in val.children.iter() { text.push_str(&parse_paragraph(&mut child_paragraph, &child)); } - paragraph.push(element::TextNode { - text: text.clone(), - marks: vec![( - 0..text.len(), + + // FIXME: GPUI InteractiveText does not support inline images yet. + // So here we push images to the paragraph directly. + for child in child_paragraph.children.iter_mut() { + if let Some(image) = child.image.as_mut() { + image.link = link_mark.clone(); + } + + child.marks.push(( + 0..child.text.len(), InlineTextStyle { - link: Some(LinkMark { - url: val.url.clone().into(), - title: val.title.clone().map(|s| s.into()), - }), + link: link_mark.clone(), ..Default::default() }, - )], - }); + )); + } + + paragraph.merge(&child_paragraph); } Node::Image(raw) => { - paragraph.set_image(ImageNode { + paragraph.push_image(ImageNode { url: raw.url.clone().into(), title: raw.title.clone().map(|t| t.into()), alt: Some(raw.alt.clone().into()), @@ -315,6 +329,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { text = raw.value.clone(); paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![( 0..text.len(), InlineTextStyle { @@ -328,6 +343,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { text = raw.value.clone(); paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![(0..text.len(), InlineTextStyle::default())], }); } @@ -337,6 +353,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { text = "\n".to_owned(); paragraph.push(element::TextNode { text: text.clone(), + image: None, marks: vec![(0..text.len(), InlineTextStyle::default())], }); } else {