diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index e873a458cdaf981635f14c4e3ab18456e700f048..a0f91cb43698be042207e2f51a5fc8cab16e67a7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -249,6 +249,7 @@ pub struct Markdown { source: SharedString, selection: Selection, pressed_link: Option, + pressed_footnote_ref: Option, autoscroll_request: Option, active_root_block: Option, parsed_markdown: ParsedMarkdown, @@ -419,6 +420,7 @@ impl Markdown { source, selection: Selection::default(), pressed_link: None, + pressed_footnote_ref: None, autoscroll_request: None, active_root_block: None, should_reparse: false, @@ -532,6 +534,13 @@ impl Markdown { cx.refresh_windows(); } + fn footnote_definition_content_start(&self, label: &SharedString) -> Option { + self.parsed_markdown + .footnote_definitions + .get(label) + .copied() + } + pub fn set_active_root_for_source_index( &mut self, source_index: Option, @@ -696,6 +705,7 @@ impl Markdown { html_blocks: BTreeMap::default(), mermaid_diagrams: BTreeMap::default(), heading_slugs: HashMap::default(), + footnote_definitions: HashMap::default(), }, Default::default(), ); @@ -709,6 +719,7 @@ impl Markdown { let root_block_starts = parsed.root_block_starts; let html_blocks = parsed.html_blocks; let heading_slugs = parsed.heading_slugs; + let footnote_definitions = parsed.footnote_definitions; let mermaid_diagrams = if should_render_mermaid_diagrams { extract_mermaid_diagrams(&source, &events) } else { @@ -776,6 +787,7 @@ impl Markdown { html_blocks, mermaid_diagrams, heading_slugs, + footnote_definitions, }, images_by_source_offset, ) @@ -900,6 +912,7 @@ pub struct ParsedMarkdown { pub(crate) html_blocks: BTreeMap, pub(crate) mermaid_diagrams: BTreeMap, pub heading_slugs: HashMap, + pub footnote_definitions: HashMap, } impl ParsedMarkdown { @@ -1300,18 +1313,22 @@ impl MarkdownElement { return; } - let is_hovering_link = hitbox.is_hovered(window) + let is_hovering_clickable = hitbox.is_hovered(window) && !self.markdown.read(cx).selection.pending && rendered_text - .link_for_position(window.mouse_position()) - .is_some(); - - if !self.style.prevent_mouse_interaction { - if is_hovering_link { - window.set_cursor_style(CursorStyle::PointingHand, hitbox); - } else { - window.set_cursor_style(CursorStyle::IBeam, hitbox); - } + .source_index_for_position(window.mouse_position()) + .ok() + .is_some_and(|source_index| { + rendered_text.link_for_source_index(source_index).is_some() + || rendered_text + .footnote_ref_for_source_index(source_index) + .is_some() + }); + + if is_hovering_clickable { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } else { + window.set_cursor_style(CursorStyle::IBeam, hitbox); } let on_open_url = self.on_url_click.take(); @@ -1336,13 +1353,27 @@ impl MarkdownElement { move |markdown, event: &MouseDownEvent, phase, window, cx| { if hitbox.is_hovered(window) { if phase.bubble() { - if let Some(link) = rendered_text.link_for_position(event.position) { - markdown.pressed_link = Some(link.clone()); - } else { - let source_index = - match rendered_text.source_index_for_position(event.position) { - Ok(ix) | Err(ix) => ix, - }; + let position_result = + rendered_text.source_index_for_position(event.position); + + if let Ok(source_index) = position_result { + if let Some(footnote_ref) = + rendered_text.footnote_ref_for_source_index(source_index) + { + markdown.pressed_footnote_ref = Some(footnote_ref.clone()); + } else if let Some(link) = + rendered_text.link_for_source_index(source_index) + { + markdown.pressed_link = Some(link.clone()); + } + } + + if markdown.pressed_footnote_ref.is_none() + && markdown.pressed_link.is_none() + { + let source_index = match position_result { + Ok(ix) | Err(ix) => ix, + }; if let Some(handler) = on_source_click.as_ref() { let blocked = handler(source_index, event.click_count, window, cx); if blocked { @@ -1398,7 +1429,7 @@ impl MarkdownElement { self.on_mouse_event(window, cx, { let rendered_text = rendered_text.clone(); let hitbox = hitbox.clone(); - let was_hovering_link = is_hovering_link; + let was_hovering_clickable = is_hovering_clickable; move |markdown, event: &MouseMoveEvent, phase, window, cx| { if phase.capture() { return; @@ -1414,9 +1445,17 @@ impl MarkdownElement { markdown.autoscroll_request = Some(source_index); cx.notify(); } else { - let is_hovering_link = hitbox.is_hovered(window) - && rendered_text.link_for_position(event.position).is_some(); - if is_hovering_link != was_hovering_link { + let is_hovering_clickable = hitbox.is_hovered(window) + && rendered_text + .source_index_for_position(event.position) + .ok() + .is_some_and(|source_index| { + rendered_text.link_for_source_index(source_index).is_some() + || rendered_text + .footnote_ref_for_source_index(source_index) + .is_some() + }); + if is_hovering_clickable != was_hovering_clickable { cx.notify(); } } @@ -1426,8 +1465,21 @@ impl MarkdownElement { let rendered_text = rendered_text.clone(); move |markdown, event: &MouseUpEvent, phase, window, cx| { if phase.bubble() { - if let Some(pressed_link) = markdown.pressed_link.take() - && Some(&pressed_link) == rendered_text.link_for_position(event.position) + let source_index = rendered_text.source_index_for_position(event.position).ok(); + if let Some(pressed_footnote_ref) = markdown.pressed_footnote_ref.take() + && source_index + .and_then(|ix| rendered_text.footnote_ref_for_source_index(ix)) + == Some(&pressed_footnote_ref) + { + if let Some(source_index) = + markdown.footnote_definition_content_start(&pressed_footnote_ref.label) + { + markdown.autoscroll_request = Some(source_index); + cx.notify(); + } + } else if let Some(pressed_link) = markdown.pressed_link.take() + && source_index.and_then(|ix| rendered_text.link_for_source_index(ix)) + == Some(&pressed_link) { if let Some(open_url) = on_open_url.as_ref() { open_url(pressed_link.destination_url, window, cx); @@ -1818,6 +1870,36 @@ impl Element for MarkdownElement { builder.push_text_style(style) } } + MarkdownTag::FootnoteDefinition(label) => { + if !builder.rendered_footnote_separator { + builder.rendered_footnote_separator = true; + builder.push_div( + div() + .border_t_1() + .mt_2() + .border_color(self.style.rule_color), + range, + markdown_end, + ); + builder.pop_div(); + } + builder.push_div( + div() + .pt_1() + .mb_1() + .line_height(rems(1.3)) + .text_size(rems(0.85)) + .h_flex() + .items_start() + .gap_2() + .child( + div().text_size(rems(0.85)).child(format!("{}.", label)), + ), + range, + markdown_end, + ); + builder.push_div(div().flex_1().w_0(), range, markdown_end); + } MarkdownTag::MetadataBlock(_) => {} MarkdownTag::Table(alignments) => { builder.table.start(alignments.clone()); @@ -1973,6 +2055,10 @@ impl Element for MarkdownElement { builder.pop_div(); builder.table.end_cell(); } + MarkdownTagEnd::FootnoteDefinition => { + builder.pop_div(); + builder.pop_div(); + } _ => log::debug!("unsupported markdown tag end: {:?}", tag), }, MarkdownEvent::Text => { @@ -2028,7 +2114,12 @@ impl Element for MarkdownElement { MarkdownEvent::TaskListMarker(_) => { // handled inside the `MarkdownTag::Item` case } - _ => log::debug!("unsupported markdown event {:?}", event), + MarkdownEvent::FootnoteReference(label) => { + builder.push_footnote_ref(label.clone(), range.clone()); + builder.push_text_style(self.style.link.clone()); + builder.push_text(&format!("[{label}]"), range.clone()); + builder.pop_text_style(); + } } } if self.style.code_block_overflow_x_scroll { @@ -2270,8 +2361,10 @@ struct MarkdownElementBuilder { rendered_lines: Vec, pending_line: PendingLine, rendered_links: Vec, + rendered_footnote_refs: Vec, current_source_index: usize, html_comment: bool, + rendered_footnote_separator: bool, base_text_style: TextStyle, text_style_stack: Vec, code_block_stack: Vec>>, @@ -2306,8 +2399,10 @@ impl MarkdownElementBuilder { rendered_lines: Vec::new(), pending_line: PendingLine::default(), rendered_links: Vec::new(), + rendered_footnote_refs: Vec::new(), current_source_index: 0, html_comment: false, + rendered_footnote_separator: false, base_text_style, text_style_stack: Vec::new(), code_block_stack: Vec::new(), @@ -2459,6 +2554,13 @@ impl MarkdownElementBuilder { }); } + fn push_footnote_ref(&mut self, label: SharedString, source_range: Range) { + self.rendered_footnote_refs.push(RenderedFootnoteRef { + source_range, + label, + }); + } + fn push_text(&mut self, text: &str, source_range: Range) { self.pending_line.source_mappings.push(SourceMapping { rendered_index: self.pending_line.text.len(), @@ -2576,6 +2678,7 @@ impl MarkdownElementBuilder { text: RenderedText { lines: self.rendered_lines.into(), links: self.rendered_links.into(), + footnote_refs: self.rendered_footnote_refs.into(), }, } } @@ -2690,6 +2793,7 @@ pub struct RenderedMarkdown { struct RenderedText { lines: Rc<[RenderedLine]>, links: Rc<[RenderedLink]>, + footnote_refs: Rc<[RenderedFootnoteRef]>, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -2698,6 +2802,12 @@ struct RenderedLink { destination_url: SharedString, } +#[derive(Debug, Clone, Eq, PartialEq)] +struct RenderedFootnoteRef { + source_range: Range, + label: SharedString, +} + impl RenderedText { fn source_index_for_position(&self, position: Point) -> Result { let mut lines = self.lines.iter().peekable(); @@ -2844,12 +2954,17 @@ impl RenderedText { accumulator } - fn link_for_position(&self, position: Point) -> Option<&RenderedLink> { - let source_index = self.source_index_for_position(position).ok()?; + fn link_for_source_index(&self, source_index: usize) -> Option<&RenderedLink> { self.links .iter() .find(|link| link.source_range.contains(&source_index)) } + + fn footnote_ref_for_source_index(&self, source_index: usize) -> Option<&RenderedFootnoteRef> { + self.footnote_refs + .iter() + .find(|fref| fref.source_range.contains(&source_index)) + } } #[cfg(test)] diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index c6c988083fddeac357b92d0b6604e0bbd564308f..641b43a1399773d2d4df2ec13e2873c816a6d49a 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -38,6 +38,7 @@ pub(crate) struct ParsedMarkdownData { pub root_block_starts: Vec, pub html_blocks: BTreeMap, pub heading_slugs: HashMap, + pub footnote_definitions: HashMap, } impl ParseState { @@ -499,9 +500,10 @@ pub(crate) fn parse_markdown_with_options( pulldown_cmark::Event::InlineHtml(_) => { state.push_event(range, MarkdownEvent::InlineHtml) } - pulldown_cmark::Event::FootnoteReference(_) => { - state.push_event(range, MarkdownEvent::FootnoteReference) - } + pulldown_cmark::Event::FootnoteReference(label) => state.push_event( + range, + MarkdownEvent::FootnoteReference(SharedString::from(label.to_string())), + ), pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak), pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak), pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule), @@ -517,6 +519,7 @@ pub(crate) fn parse_markdown_with_options( } else { HashMap::default() }; + let footnote_definitions = build_footnote_definitions(&state.events); ParsedMarkdownData { events: state.events, @@ -525,7 +528,34 @@ pub(crate) fn parse_markdown_with_options( root_block_starts: state.root_block_starts, html_blocks, heading_slugs, + footnote_definitions, + } +} + +fn build_footnote_definitions( + events: &[(Range, MarkdownEvent)], +) -> HashMap { + let mut definitions = HashMap::default(); + let mut current_label: Option = None; + + for (range, event) in events { + match event { + MarkdownEvent::Start(MarkdownTag::FootnoteDefinition(label)) => { + current_label = Some(label.clone()); + } + MarkdownEvent::End(MarkdownTagEnd::FootnoteDefinition) => { + current_label = None; + } + MarkdownEvent::Text if current_label.is_some() => { + if let Some(label) = current_label.take() { + definitions.entry(label).or_insert(range.start); + } + } + _ => {} + } } + + definitions } pub fn parse_links_only(text: &str) -> Vec<(Range, MarkdownEvent)> { @@ -589,7 +619,7 @@ pub enum MarkdownEvent { /// A reference to a footnote with given label, which may or may not be defined /// by an event with a `Tag::FootnoteDefinition` tag. Definitions and references to them may /// occur in any order. - FootnoteReference, + FootnoteReference(SharedString), /// A soft line break. SoftBreak, /// A hard line break. @@ -1111,6 +1141,48 @@ mod tests { assert_eq!(extract_code_block_content_range(input), 3..3); } + #[test] + fn test_footnotes() { + let parsed = parse_markdown_with_options( + "Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.", + false, + false, + ); + assert_eq!( + parsed.events, + vec![ + (0..45, RootStart), + (0..45, Start(Paragraph)), + (0..20, Text), + (20..24, FootnoteReference("1".into())), + (24..44, Text), + (0..45, End(MarkdownTagEnd::Paragraph)), + (0..45, RootEnd(0)), + (46..81, RootStart), + (46..81, Start(FootnoteDefinition("1".into()))), + (52..81, Start(Paragraph)), + (52..81, Text), + (52..81, End(MarkdownTagEnd::Paragraph)), + (46..81, End(MarkdownTagEnd::FootnoteDefinition)), + (46..81, RootEnd(1)), + ] + ); + assert_eq!(parsed.footnote_definitions.len(), 1); + assert_eq!(parsed.footnote_definitions.get("1").copied(), Some(52)); + } + + #[test] + fn test_footnote_definitions_multiple() { + let parsed = parse_markdown_with_options( + "Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.", + false, + false, + ); + assert_eq!(parsed.footnote_definitions.len(), 2); + assert!(parsed.footnote_definitions.contains_key("a")); + assert!(parsed.footnote_definitions.contains_key("b")); + } + #[test] fn test_links_split_across_fragments() { // This test verifies that links split across multiple text fragments due to escaping or other issues