mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +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 depth: u16,
|
||||||
pub item_type: ParsedMarkdownListItemType,
|
pub item_type: ParsedMarkdownListItemType,
|
||||||
pub content: Vec<ParsedMarkdownElement>,
|
pub content: Vec<ParsedMarkdownElement>,
|
||||||
|
/// Whether we can expect nested list items inside of this items `content`.
|
||||||
|
pub nested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,17 @@ struct MarkdownParser<'a> {
|
||||||
language_registry: Option<Arc<LanguageRegistry>>,
|
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 {
|
struct MarkdownListItem {
|
||||||
content: Vec<ParsedMarkdownElement>,
|
content: Vec<ParsedMarkdownElement>,
|
||||||
item_type: ParsedMarkdownListItemType,
|
item_type: ParsedMarkdownListItemType,
|
||||||
|
|
@ -646,6 +657,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
content: list_item.content,
|
content: list_item.content,
|
||||||
depth,
|
depth,
|
||||||
item_type: list_item.item_type,
|
item_type: list_item.item_type,
|
||||||
|
nested: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(index) = insertion_indices.get(&depth) {
|
if let Some(index) = insertion_indices.get(&depth) {
|
||||||
|
|
@ -828,7 +840,12 @@ impl<'a> MarkdownParser<'a> {
|
||||||
.read_from(&mut cursor)
|
.read_from(&mut cursor)
|
||||||
&& let Some((start, end)) = html_source_range_start.zip(html_source_range_end)
|
&& 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
|
elements
|
||||||
|
|
@ -839,10 +856,11 @@ impl<'a> MarkdownParser<'a> {
|
||||||
source_range: Range<usize>,
|
source_range: Range<usize>,
|
||||||
node: &Rc<markup5ever_rcdom::Node>,
|
node: &Rc<markup5ever_rcdom::Node>,
|
||||||
elements: &mut Vec<ParsedMarkdownElement>,
|
elements: &mut Vec<ParsedMarkdownElement>,
|
||||||
|
context: &ParseHtmlNodeContext,
|
||||||
) {
|
) {
|
||||||
match &node.data {
|
match &node.data {
|
||||||
markup5ever_rcdom::NodeData::Document => {
|
markup5ever_rcdom::NodeData::Document => {
|
||||||
self.consume_children(source_range, node, elements);
|
self.consume_children(source_range, node, elements, context);
|
||||||
}
|
}
|
||||||
markup5ever_rcdom::NodeData::Text { contents } => {
|
markup5ever_rcdom::NodeData::Text { contents } => {
|
||||||
elements.push(ParsedMarkdownElement::Paragraph(vec![
|
elements.push(ParsedMarkdownElement::Paragraph(vec![
|
||||||
|
|
@ -895,6 +913,15 @@ impl<'a> MarkdownParser<'a> {
|
||||||
contents: paragraph,
|
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 {
|
} else if local_name!("blockquote") == name.local {
|
||||||
if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
|
if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
|
||||||
elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
|
elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
|
||||||
|
|
@ -904,7 +931,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
elements.push(ParsedMarkdownElement::Table(table));
|
elements.push(ParsedMarkdownElement::Table(table));
|
||||||
}
|
}
|
||||||
} else {
|
} 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>,
|
source_range: Range<usize>,
|
||||||
node: &Rc<markup5ever_rcdom::Node>,
|
node: &Rc<markup5ever_rcdom::Node>,
|
||||||
elements: &mut Vec<ParsedMarkdownElement>,
|
elements: &mut Vec<ParsedMarkdownElement>,
|
||||||
|
context: &ParseHtmlNodeContext,
|
||||||
) {
|
) {
|
||||||
for node in node.children.borrow().iter() {
|
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)
|
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> {
|
fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
|
||||||
if value.ends_with("%") {
|
if value.ends_with("%") {
|
||||||
value
|
value
|
||||||
|
|
@ -1129,7 +1208,12 @@ impl<'a> MarkdownParser<'a> {
|
||||||
source_range: Range<usize>,
|
source_range: Range<usize>,
|
||||||
) -> Option<ParsedMarkdownBlockQuote> {
|
) -> Option<ParsedMarkdownBlockQuote> {
|
||||||
let mut children = Vec::new();
|
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() {
|
if children.is_empty() {
|
||||||
None
|
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]
|
#[gpui::test]
|
||||||
async fn test_inline_html_image_tag() {
|
async fn test_inline_html_image_tag() {
|
||||||
let parsed =
|
let parsed =
|
||||||
|
|
@ -1594,7 +1840,7 @@ mod tests {
|
||||||
async fn test_html_block_quote() {
|
async fn test_html_block_quote() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"<blockquote>
|
"<blockquote>
|
||||||
<p>some description</p>
|
<p>some description</p>
|
||||||
</blockquote>",
|
</blockquote>",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -1604,9 +1850,9 @@ mod tests {
|
||||||
children: vec![block_quote(
|
children: vec![block_quote(
|
||||||
vec![ParsedMarkdownElement::Paragraph(text(
|
vec![ParsedMarkdownElement::Paragraph(text(
|
||||||
"some description",
|
"some description",
|
||||||
0..76
|
0..78
|
||||||
))],
|
))],
|
||||||
0..76,
|
0..78,
|
||||||
)]
|
)]
|
||||||
},
|
},
|
||||||
parsed
|
parsed
|
||||||
|
|
@ -1617,10 +1863,10 @@ mod tests {
|
||||||
async fn test_html_nested_block_quote() {
|
async fn test_html_nested_block_quote() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"<blockquote>
|
"<blockquote>
|
||||||
<p>some description</p>
|
<p>some description</p>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
<p>second description</p>
|
<p>second description</p>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</blockquote>",
|
</blockquote>",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -1629,16 +1875,16 @@ mod tests {
|
||||||
ParsedMarkdown {
|
ParsedMarkdown {
|
||||||
children: vec![block_quote(
|
children: vec![block_quote(
|
||||||
vec![
|
vec![
|
||||||
ParsedMarkdownElement::Paragraph(text("some description", 0..173)),
|
ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
|
||||||
block_quote(
|
block_quote(
|
||||||
vec![ParsedMarkdownElement::Paragraph(text(
|
vec![ParsedMarkdownElement::Paragraph(text(
|
||||||
"second description",
|
"second description",
|
||||||
0..173
|
0..179
|
||||||
))],
|
))],
|
||||||
0..173,
|
0..179,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
0..173,
|
0..179,
|
||||||
)]
|
)]
|
||||||
},
|
},
|
||||||
parsed
|
parsed
|
||||||
|
|
@ -2542,6 +2788,22 @@ fn main() {
|
||||||
item_type,
|
item_type,
|
||||||
depth,
|
depth,
|
||||||
content,
|
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 fs::normalize_path;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
|
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
|
||||||
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
|
ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
|
||||||
Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText,
|
Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
|
||||||
TextStyle, WeakEntity, Window, div, img, rems,
|
WeakEntity, Window, div, img, rems,
|
||||||
};
|
};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use std::{
|
use std::{
|
||||||
|
|
@ -234,8 +234,6 @@ fn render_markdown_list_item(
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
use ParsedMarkdownListItemType::*;
|
use ParsedMarkdownListItemType::*;
|
||||||
|
|
||||||
let padding = cx.scaled_rems((parsed.depth - 1) as f32);
|
|
||||||
|
|
||||||
let bullet = match &parsed.item_type {
|
let bullet = match &parsed.item_type {
|
||||||
Ordered(order) => format!("{}.", order).into_any_element(),
|
Ordered(order) => format!("{}.", order).into_any_element(),
|
||||||
Unordered => "•".into_any_element(),
|
Unordered => "•".into_any_element(),
|
||||||
|
|
@ -294,13 +292,16 @@ fn render_markdown_list_item(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let item = h_flex()
|
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()
|
.items_start()
|
||||||
.children(vec![
|
.children(vec![
|
||||||
bullet,
|
bullet,
|
||||||
v_flex()
|
v_flex()
|
||||||
.children(contents)
|
.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))
|
.pr(cx.scaled_rems(1.0))
|
||||||
.w_full(),
|
.w_full(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue