mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
markdown: Add support for HTML lists (#39553)
This PR adds support for **HTML** both ordered and unordered lists. <img width="1441" height="805" alt="Screenshot 2025-10-07 at 21 40 17" src="https://github.com/user-attachments/assets/8a54aec1-75aa-48fb-bf9f-c153cca48682" /> See code example used inside the screenshot: ```html <ol> <li>First item</li> <li>Second item</li> <li>Third item <ol> <li>Indented item</li> <li>Indented item</li> </ol> </li> <li>Fourth item</li> </ol> ``` TODO: - [x] Add examples - [x] update description (screenshots, add small description) - [x] fix displaying of nested lists cc @bennetbo Release Notes: - markdown preview: Added support for HTML lists --------- Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
parent
06e1db54a7
commit
45983e11e9
3 changed files with 288 additions and 23 deletions
|
|
@ -64,6 +64,8 @@ pub struct ParsedMarkdownListItem {
|
|||
pub depth: u16,
|
||||
pub item_type: ParsedMarkdownListItemType,
|
||||
pub content: Vec<ParsedMarkdownElement>,
|
||||
/// Whether we can expect nested list items inside of this items `content`.
|
||||
pub nested: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
|
|||
|
|
@ -61,6 +61,17 @@ struct MarkdownParser<'a> {
|
|||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParseHtmlNodeContext {
|
||||
list_item_depth: u16,
|
||||
}
|
||||
|
||||
impl Default for ParseHtmlNodeContext {
|
||||
fn default() -> Self {
|
||||
Self { list_item_depth: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownListItem {
|
||||
content: Vec<ParsedMarkdownElement>,
|
||||
item_type: ParsedMarkdownListItemType,
|
||||
|
|
@ -646,6 +657,7 @@ impl<'a> MarkdownParser<'a> {
|
|||
content: list_item.content,
|
||||
depth,
|
||||
item_type: list_item.item_type,
|
||||
nested: false,
|
||||
});
|
||||
|
||||
if let Some(index) = insertion_indices.get(&depth) {
|
||||
|
|
@ -828,7 +840,12 @@ impl<'a> MarkdownParser<'a> {
|
|||
.read_from(&mut cursor)
|
||||
&& let Some((start, end)) = html_source_range_start.zip(html_source_range_end)
|
||||
{
|
||||
self.parse_html_node(start..end, &dom.document, &mut elements);
|
||||
self.parse_html_node(
|
||||
start..end,
|
||||
&dom.document,
|
||||
&mut elements,
|
||||
&ParseHtmlNodeContext::default(),
|
||||
);
|
||||
}
|
||||
|
||||
elements
|
||||
|
|
@ -839,10 +856,11 @@ impl<'a> MarkdownParser<'a> {
|
|||
source_range: Range<usize>,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
elements: &mut Vec<ParsedMarkdownElement>,
|
||||
context: &ParseHtmlNodeContext,
|
||||
) {
|
||||
match &node.data {
|
||||
markup5ever_rcdom::NodeData::Document => {
|
||||
self.consume_children(source_range, node, elements);
|
||||
self.consume_children(source_range, node, elements, context);
|
||||
}
|
||||
markup5ever_rcdom::NodeData::Text { contents } => {
|
||||
elements.push(ParsedMarkdownElement::Paragraph(vec![
|
||||
|
|
@ -895,6 +913,15 @@ impl<'a> MarkdownParser<'a> {
|
|||
contents: paragraph,
|
||||
}));
|
||||
}
|
||||
} else if local_name!("ul") == name.local || local_name!("ol") == name.local {
|
||||
if let Some(list_items) = self.extract_html_list(
|
||||
node,
|
||||
local_name!("ol") == name.local,
|
||||
context.list_item_depth,
|
||||
source_range,
|
||||
) {
|
||||
elements.extend(list_items);
|
||||
}
|
||||
} else if local_name!("blockquote") == name.local {
|
||||
if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
|
||||
elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
|
||||
|
|
@ -904,7 +931,7 @@ impl<'a> MarkdownParser<'a> {
|
|||
elements.push(ParsedMarkdownElement::Table(table));
|
||||
}
|
||||
} else {
|
||||
self.consume_children(source_range, node, elements);
|
||||
self.consume_children(source_range, node, elements, context);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -1036,9 +1063,10 @@ impl<'a> MarkdownParser<'a> {
|
|||
source_range: Range<usize>,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
elements: &mut Vec<ParsedMarkdownElement>,
|
||||
context: &ParseHtmlNodeContext,
|
||||
) {
|
||||
for node in node.children.borrow().iter() {
|
||||
self.parse_html_node(source_range.clone(), node, elements);
|
||||
self.parse_html_node(source_range.clone(), node, elements, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1107,6 +1135,57 @@ impl<'a> MarkdownParser<'a> {
|
|||
Some(image)
|
||||
}
|
||||
|
||||
fn extract_html_list(
|
||||
&self,
|
||||
node: &Rc<markup5ever_rcdom::Node>,
|
||||
ordered: bool,
|
||||
depth: u16,
|
||||
source_range: Range<usize>,
|
||||
) -> Option<Vec<ParsedMarkdownElement>> {
|
||||
let mut list_items = Vec::with_capacity(node.children.borrow().len());
|
||||
|
||||
for (index, node) in node.children.borrow().iter().enumerate() {
|
||||
match &node.data {
|
||||
markup5ever_rcdom::NodeData::Element { name, .. } => {
|
||||
if local_name!("li") != name.local {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut content = Vec::new();
|
||||
self.consume_children(
|
||||
source_range.clone(),
|
||||
node,
|
||||
&mut content,
|
||||
&ParseHtmlNodeContext {
|
||||
list_item_depth: depth + 1,
|
||||
},
|
||||
);
|
||||
|
||||
if !content.is_empty() {
|
||||
list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
|
||||
depth,
|
||||
source_range: source_range.clone(),
|
||||
item_type: if ordered {
|
||||
ParsedMarkdownListItemType::Ordered(index as u64 + 1)
|
||||
} else {
|
||||
ParsedMarkdownListItemType::Unordered
|
||||
},
|
||||
content,
|
||||
nested: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if list_items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(list_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
|
||||
if value.ends_with("%") {
|
||||
value
|
||||
|
|
@ -1129,7 +1208,12 @@ impl<'a> MarkdownParser<'a> {
|
|||
source_range: Range<usize>,
|
||||
) -> Option<ParsedMarkdownBlockQuote> {
|
||||
let mut children = Vec::new();
|
||||
self.consume_children(source_range.clone(), node, &mut children);
|
||||
self.consume_children(
|
||||
source_range.clone(),
|
||||
node,
|
||||
&mut children,
|
||||
&ParseHtmlNodeContext::default(),
|
||||
);
|
||||
|
||||
if children.is_empty() {
|
||||
None
|
||||
|
|
@ -1552,6 +1636,168 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_unordered_list() {
|
||||
let parsed = parse(
|
||||
"<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![
|
||||
nested_list_item(
|
||||
0..82,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..82,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
|
||||
),
|
||||
]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_ordered_list() {
|
||||
let parsed = parse(
|
||||
"<ol>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ol>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![
|
||||
nested_list_item(
|
||||
0..82,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Ordered(1),
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..82,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Ordered(2),
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
|
||||
),
|
||||
]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_nested_ordered_list() {
|
||||
let parsed = parse(
|
||||
"<ol>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2
|
||||
<ol>
|
||||
<li>Sub-Item 1</li>
|
||||
<li>Sub-Item 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![
|
||||
nested_list_item(
|
||||
0..216,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Ordered(1),
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Ordered(2),
|
||||
vec![
|
||||
ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
2,
|
||||
ParsedMarkdownListItemType::Ordered(1),
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
2,
|
||||
ParsedMarkdownListItemType::Ordered(2),
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_html_nested_unordered_list() {
|
||||
let parsed = parse(
|
||||
"<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2
|
||||
<ul>
|
||||
<li>Sub-Item 1</li>
|
||||
<li>Sub-Item 2</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
ParsedMarkdown {
|
||||
children: vec![
|
||||
nested_list_item(
|
||||
0..216,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
1,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![
|
||||
ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
2,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
|
||||
),
|
||||
nested_list_item(
|
||||
0..216,
|
||||
2,
|
||||
ParsedMarkdownListItemType::Unordered,
|
||||
vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
},
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_inline_html_image_tag() {
|
||||
let parsed =
|
||||
|
|
@ -1594,7 +1840,7 @@ mod tests {
|
|||
async fn test_html_block_quote() {
|
||||
let parsed = parse(
|
||||
"<blockquote>
|
||||
<p>some description</p>
|
||||
<p>some description</p>
|
||||
</blockquote>",
|
||||
)
|
||||
.await;
|
||||
|
|
@ -1604,9 +1850,9 @@ mod tests {
|
|||
children: vec![block_quote(
|
||||
vec![ParsedMarkdownElement::Paragraph(text(
|
||||
"some description",
|
||||
0..76
|
||||
0..78
|
||||
))],
|
||||
0..76,
|
||||
0..78,
|
||||
)]
|
||||
},
|
||||
parsed
|
||||
|
|
@ -1617,10 +1863,10 @@ mod tests {
|
|||
async fn test_html_nested_block_quote() {
|
||||
let parsed = parse(
|
||||
"<blockquote>
|
||||
<p>some description</p>
|
||||
<blockquote>
|
||||
<p>some description</p>
|
||||
<blockquote>
|
||||
<p>second description</p>
|
||||
</blockquote>
|
||||
</blockquote>
|
||||
</blockquote>",
|
||||
)
|
||||
.await;
|
||||
|
|
@ -1629,16 +1875,16 @@ mod tests {
|
|||
ParsedMarkdown {
|
||||
children: vec![block_quote(
|
||||
vec![
|
||||
ParsedMarkdownElement::Paragraph(text("some description", 0..173)),
|
||||
ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
|
||||
block_quote(
|
||||
vec![ParsedMarkdownElement::Paragraph(text(
|
||||
"second description",
|
||||
0..173
|
||||
0..179
|
||||
))],
|
||||
0..173,
|
||||
0..179,
|
||||
)
|
||||
],
|
||||
0..173,
|
||||
0..179,
|
||||
)]
|
||||
},
|
||||
parsed
|
||||
|
|
@ -2542,6 +2788,22 @@ fn main() {
|
|||
item_type,
|
||||
depth,
|
||||
content,
|
||||
nested: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn nested_list_item(
|
||||
source_range: Range<usize>,
|
||||
depth: u16,
|
||||
item_type: ParsedMarkdownListItemType,
|
||||
content: Vec<ParsedMarkdownElement>,
|
||||
) -> ParsedMarkdownElement {
|
||||
ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
|
||||
source_range,
|
||||
item_type,
|
||||
depth,
|
||||
content,
|
||||
nested: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use crate::markdown_elements::{
|
|||
};
|
||||
use fs::normalize_path;
|
||||
use gpui::{
|
||||
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
|
||||
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
|
||||
Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText,
|
||||
TextStyle, WeakEntity, Window, div, img, rems,
|
||||
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
|
||||
ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
|
||||
Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
|
||||
WeakEntity, Window, div, img, rems,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
|
|
@ -234,8 +234,6 @@ fn render_markdown_list_item(
|
|||
) -> AnyElement {
|
||||
use ParsedMarkdownListItemType::*;
|
||||
|
||||
let padding = cx.scaled_rems((parsed.depth - 1) as f32);
|
||||
|
||||
let bullet = match &parsed.item_type {
|
||||
Ordered(order) => format!("{}.", order).into_any_element(),
|
||||
Unordered => "•".into_any_element(),
|
||||
|
|
@ -294,13 +292,16 @@ fn render_markdown_list_item(
|
|||
.collect();
|
||||
|
||||
let item = h_flex()
|
||||
.pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
|
||||
.when(!parsed.nested, |this| {
|
||||
this.pl(cx.scaled_rems(parsed.depth.saturating_sub(1) as f32))
|
||||
})
|
||||
.when(parsed.nested && parsed.depth > 1, |this| this.ml_neg_1p5())
|
||||
.items_start()
|
||||
.children(vec![
|
||||
bullet,
|
||||
v_flex()
|
||||
.children(contents)
|
||||
.gap(cx.scaled_rems(1.0))
|
||||
.when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0)))
|
||||
.pr(cx.scaled_rems(1.0))
|
||||
.w_full(),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue