diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 8792aaca7b..89266065a1 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -399,7 +399,8 @@ pub struct Markdown { wrapped_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_link: Option, - context_menu_selected_text: Option, + context_menu_selected_text: Option, + context_menu_selected_markdown: Option, search_highlights: Vec>, active_search_highlight: Option, } @@ -592,6 +593,7 @@ impl Markdown { code_block_scroll_handles: BTreeMap::default(), context_menu_link: None, context_menu_selected_text: None, + context_menu_selected_markdown: None, search_highlights: Vec::new(), active_search_highlight: None, }; @@ -864,8 +866,8 @@ impl Markdown { } fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context) { - if let Some(text) = self.context_menu_selected_text.take() { - cx.write_to_clipboard(ClipboardItem::new_string(text)); + if let Some(text) = self.context_menu_selected_markdown.take() { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); return; } if self.selection.end <= self.selection.start { @@ -875,8 +877,23 @@ impl Markdown { cx.write_to_clipboard(ClipboardItem::new_string(text)); } - fn capture_for_context_menu(&mut self, link: Option) { - self.context_menu_selected_text = self.selected_text(); + fn capture_for_context_menu( + &mut self, + link: Option, + rendered_text: Option<&RenderedText>, + ) { + let range = self.selection.start..self.selection.end; + if range.end > range.start { + self.context_menu_selected_markdown = + Some(SharedString::new(&self.source[range.clone()])); + self.context_menu_selected_text = rendered_text + .map(|text| text.text_for_range(range)) + .map(SharedString::new) + .or_else(|| self.context_menu_selected_markdown.clone()); + } else { + self.context_menu_selected_markdown = None; + self.context_menu_selected_text = None; + } self.context_menu_link = link; } @@ -887,6 +904,18 @@ impl Markdown { self.context_menu_link.as_ref() } + /// Returns the rendered (plain) text that was selected when the most recent + /// context menu invocation happened. + pub fn context_menu_selected_text(&self) -> Option<&SharedString> { + self.context_menu_selected_text.as_ref() + } + + /// Returns the raw markdown source that was selected when the most recent + /// context menu invocation happened. + pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> { + self.context_menu_selected_markdown.as_ref() + } + fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { self.should_reparse = false; @@ -1730,7 +1759,7 @@ impl MarkdownElement { .ok() .and_then(|ix| rendered_text.link_for_source_index(ix)) .map(|link| link.destination_url.clone()); - markdown.capture_for_context_menu(link); + markdown.capture_for_context_menu(link, Some(&rendered_text)); } } }); @@ -5194,30 +5223,47 @@ mod tests { fn test_capture_for_context_menu(cx: &mut TestAppContext) { ensure_theme_initialized(cx); let (_, cx) = cx.add_window_view(|_, _| TestWindow); - let markdown = cx.new(|cx| Markdown::new("text".into(), None, None, cx)); + let markdown = cx.new(|cx| Markdown::new("some text".into(), None, None, cx)); cx.run_until_parked(); - // Simulates right-clicking on a link + // Simulates right-clicking on a link, with "text" selected let url: SharedString = "https://example.com".into(); markdown.update(cx, |md, _cx| { - md.capture_for_context_menu(Some(url.clone())); + md.selection.start = 5; + md.selection.end = 9; + md.capture_for_context_menu(Some(url.clone()), None); }); cx.update(|_window, cx| { + let markdown = markdown.read(cx); + assert_eq!( + markdown.context_menu_link().map(SharedString::as_ref), + Some("https://example.com") + ); assert_eq!( markdown - .read(cx) - .context_menu_link() + .context_menu_selected_markdown() .map(SharedString::as_ref), - Some("https://example.com") + Some("text") + ); + assert_eq!( + markdown + .context_menu_selected_text() + .map(SharedString::as_ref), + Some("text") ); }); - // Simulates right-clicking on plain text — link is cleared + // Simulates right-clicking on plain text with no selection — everything is cleared markdown.update(cx, |md, _cx| { - md.capture_for_context_menu(None); + md.selection.start = 0; + md.selection.end = 0; + md.capture_for_context_menu(None, None); }); cx.update(|_window, cx| { - assert!(markdown.read(cx).context_menu_link().is_none()); + let markdown = markdown.read(cx); + assert!(markdown.context_menu_link().is_none()); + assert!(markdown.context_menu_selected_markdown().is_none()); + assert!(markdown.context_menu_selected_text().is_none()); }); } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 1d69d829e7..db6c1a8078 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1260,12 +1260,48 @@ impl Render for MarkdownPreviewView { let max_width = MarkdownPreviewSettings::get_global(cx).max_width; let content = right_click_menu("markdown-preview-context-menu") .trigger(move |_, _, _| markdown_element) - .menu(move |window, cx| { + .maybe_menu(move |window, cx| { let focus = window.focused(cx); - let context_menu_link = - markdown.read(cx).context_menu_link().cloned(); - ContextMenu::build(window, cx, move |menu, _, _cx| { + let markdown = markdown.read(cx); + let context_menu_link = markdown.context_menu_link().cloned(); + let selected_text = + markdown.context_menu_selected_text().cloned(); + let selected_markdown = + markdown.context_menu_selected_markdown().cloned(); + if context_menu_link.is_none() + && selected_text.is_none() + && selected_markdown.is_none() + { + return None; + } + Some(ContextMenu::build(window, cx, move |menu, _, _cx| { menu.when_some(focus, |menu, focus| menu.context(focus)) + .when_some(selected_text, |menu, text| { + menu.entry( + "Copy", + Some(Box::new(markdown::Copy)), + move |_, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string( + text.to_string(), + ), + ); + }, + ) + }) + .when_some(selected_markdown, |menu, text| { + menu.entry( + "Copy as Markdown", + Some(Box::new(markdown::CopyAsMarkdown)), + move |_, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string( + text.to_string(), + ), + ); + }, + ) + }) .when_some(context_menu_link, |menu, url| { menu.entry("Copy Link", None, move |_, cx| { cx.write_to_clipboard( @@ -1273,7 +1309,7 @@ impl Render for MarkdownPreviewView { ); }) }) - }) + })) }); div() .w_full() diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index a6b9515cac..9cc792326b 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -10,13 +10,21 @@ use gpui::{ pub struct RightClickMenu { id: ElementId, child_builder: Option AnyElement + 'static>>, - menu_builder: Option Entity + 'static>>, + menu_builder: Option Option> + 'static>>, anchor: Option, attach: Option, } impl RightClickMenu { pub fn menu(mut self, f: impl Fn(&mut Window, &mut App) -> Entity + 'static) -> Self { + self.menu_builder = Some(Rc::new(move |window, cx| Some(f(window, cx)))); + self + } + + pub fn maybe_menu( + mut self, + f: impl Fn(&mut Window, &mut App) -> Option> + 'static, + ) -> Self { self.menu_builder = Some(Rc::new(f)); self } @@ -243,7 +251,9 @@ impl Element for RightClickMenu { cx.stop_propagation(); window.prevent_default(); - let new_menu = (builder)(window, cx); + let Some(new_menu) = (builder)(window, cx) else { + return; + }; let menu2 = menu.clone(); let previous_focus_handle = window.focused(cx);