Improve context menu experience in the markdown preview (#59993)
* removes useless "no items" pop-up and does not show any menu at all if no actions are applicable. This required adding `pub fn maybe_menu` into the * adds actions to copy rendered text and markdown text: in fact, `cmd/ctrl-c` work already but we did not expose it Before: https://github.com/user-attachments/assets/906f2bfd-bf19-48de-bbb5-85bb33431f60 After: https://github.com/user-attachments/assets/2c039c4d-2d42-4689-9ac1-12e3b10119cf Release Notes: - Improved context menu experience in the markdown preview
This commit is contained in:
1 parent
8eb85ac9e2
commit
18c28c3e63
3 files changed
+114
-22
No files matched your search
@@ -399,7 +399,8 @@ pub struct Markdown {
|
||||
wrapped_code_blocks: HashSet<usize>,
|
||||
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
|
||||
context_menu_link: Option<SharedString>,
|
||||
context_menu_selected_text: Option<String>,
|
||||
context_menu_selected_text: Option<SharedString>,
|
||||
context_menu_selected_markdown: Option<SharedString>,
|
||||
search_highlights: Vec<Range<usize>>,
|
||||
active_search_highlight: Option<usize>,
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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<SharedString>) {
|
||||
self.context_menu_selected_text = self.selected_text();
|
||||
fn capture_for_context_menu(
|
||||
&mut self,
|
||||
link: Option<SharedString>,
|
||||
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<Self>) {
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,13 +10,21 @@ use gpui::{
|
||||
pub struct RightClickMenu<M: ManagedView> {
|
||||
id: ElementId,
|
||||
child_builder: Option<Box<dyn FnOnce(bool, &mut Window, &mut App) -> AnyElement + 'static>>,
|
||||
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
|
||||
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
|
||||
anchor: Option<Anchor>,
|
||||
attach: Option<Anchor>,
|
||||
}
|
||||
|
||||
impl<M: ManagedView> RightClickMenu<M> {
|
||||
pub fn menu(mut self, f: impl Fn(&mut Window, &mut App) -> Entity<M> + '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<Entity<M>> + 'static,
|
||||
) -> Self {
|
||||
self.menu_builder = Some(Rc::new(f));
|
||||
self
|
||||
}
|
||||
@@ -243,7 +251,9 @@ impl<M: ManagedView> Element for RightClickMenu<M> {
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user