markdown: Make linked images clickable (#59525)

# Objective

Make images nested inside markdown links (e.g., README badges)
clickable, so they behave like normal markdown links.

Previously, linked images rendered correctly but were not interactive in
markdown previews: they did not show a pointer cursor, could not be
clicked to open their target URL, and did not expose link actions
through the context menu.

## Solution

Changed the `MarkdownElement` to detect when an `<img>` tag is inside a
`<a>` tag during rendering. When a linked image is rendered:

- The wrapper `div` gets a `cursor_pointer` style and an `on_click`
handler that opens the link URL (or delegates to the `on_url_click`
callback if set).
- The wrapper also captures right-click events to populate the context
menu with "Copy Link" via `capture_for_context_menu`.
- The `on_url_click` closure type was changed from `Box<dyn Fn>` to
`Rc<dyn Fn>` to allow cloning across multiple handlers.
- When a linked image fails to load, the fallback element's `on_click`
now calls `cx.stop_propagation()` to prevent the event from bubbling to
the parent link wrapper and opening the URL twice.

The feature is gated behind `!self.style.prevent_mouse_interaction` so
it respects existing interaction-prevention settings.

## Testing

- `cargo test -p markdown`: 95 tests passed.
- Manually verified in markdown preview that badge/image links open
correctly and right-click shows "Copy Link".
- Verified existing markdown tests continue to pass.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed images inside markdown links not being clickable.


https://github.com/user-attachments/assets/df2a8d8f-b002-47f9-b139-815ee713700b

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Warya WayneandSmit Barmase authored and GitHub committed 2026-07-02 08:00:19 +00:00
1 parent 7543759671
commit e2deea1d5b
3 files changed
+157 -17

No files matched your search

Generated
+1
View File
@@ -10665,6 +10665,7 @@ dependencies = [
"gpui",
"gpui_platform",
"html5ever 0.27.0",
"image",
"language",
"languages",
"linkify",
+1
View File
@@ -46,6 +46,7 @@ env_logger.workspace = true
fs = {workspace = true, features = ["test-support"]}
gpui = { workspace = true, features = ["test-support"] }
gpui_platform = { workspace = true, features = ["wayland", "x11"] }
image.workspace = true
language = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["load-grammars"] }
node_runtime.workspace = true
+155 -17
View File
@@ -1221,7 +1221,7 @@ pub struct MarkdownElement {
markdown: Entity<Markdown>,
style: MarkdownStyle,
code_block_renderer: CodeBlockRenderer,
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
on_url_click: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
code_span_link: Option<CodeSpanLinkCallback>,
on_source_click: Option<SourceClickCallback>,
on_checkbox_toggle: Option<CheckboxToggleCallback>,
@@ -1280,7 +1280,7 @@ impl MarkdownElement {
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_url_click = Some(Box::new(handler));
self.on_url_click = Some(Rc::new(handler));
self
}
@@ -1378,18 +1378,58 @@ impl MarkdownElement {
width: Option<DefiniteLength>,
height: Option<DefiniteLength>,
) {
let image_element = div().min_w_0().child(
img(source)
.id(("markdown-image", range.start))
.min_w_0()
.max_w_full()
.rounded_md()
.mr_1()
.mb_1()
.when_some(height, |this, height| this.h(height))
.when_some(width, |this, width| this.w(width))
.with_fallback(move || image_fallback_element(dest_url.clone(), alt_text.clone())),
);
let enclosing_link_url = (builder.link_depth > 0)
.then(|| builder.rendered_links.last())
.flatten()
.map(|link| link.destination_url.clone());
let fallback_opens_image_url = enclosing_link_url.is_none();
let image_element = {
let wrapper = div().id(("markdown-image-link", range.start)).min_w_0();
let wrapper = if !self.style.prevent_mouse_interaction
&& let Some(url) = enclosing_link_url
{
let click_url = url.clone();
let markdown = self.markdown.clone();
let url_click = self.on_url_click.clone();
wrapper
.cursor_pointer()
.on_click(move |_, window, cx| {
if let Some(ref on_url_click) = url_click {
on_url_click(click_url.clone(), window, cx);
} else {
cx.open_url(&click_url);
}
})
.capture_any_mouse_down(move |event, _window, cx| {
if event.button == MouseButton::Right {
markdown.update(cx, |md, _| {
md.capture_for_context_menu(Some(url.clone()), None)
});
}
})
} else {
wrapper
};
wrapper.child(
img(source)
.id(("markdown-image", range.start))
.min_w_0()
.max_w_full()
.rounded_md()
.mr_1()
.mb_1()
.when_some(height, |this, height| this.h(height))
.when_some(width, |this, width| this.w(width))
.with_fallback(move || {
image_fallback_element(
dest_url.clone(),
alt_text.clone(),
fallback_opens_image_url,
)
}),
)
};
builder.push_image_child(image_element);
}
@@ -2795,7 +2835,11 @@ fn collect_image_alt_text(
}
}
fn image_fallback_element(dest_url: SharedString, alt_text: Option<SharedString>) -> AnyElement {
fn image_fallback_element(
dest_url: SharedString,
alt_text: Option<SharedString>,
open_image_url_on_click: bool,
) -> AnyElement {
let link_label = alt_text
.filter(|alt| !alt.is_empty())
.unwrap_or_else(|| dest_url.clone());
@@ -2804,13 +2848,15 @@ fn image_fallback_element(dest_url: SharedString, alt_text: Option<SharedString>
div()
.id("image-fallback")
.cursor_pointer()
.min_w_0()
.child(Label::new(label).color(Color::Warning).underline())
.tooltip(Tooltip::text(
"Image failed to load. Open `zed: log` for more details.",
))
.on_click(move |_, _, cx| cx.open_url(&dest_url))
.when(open_image_url_on_click, |this| {
this.cursor_pointer()
.on_click(move |_, _, cx| cx.open_url(&dest_url))
})
.into_any_element()
}
@@ -5289,6 +5335,98 @@ mod tests {
});
}
fn failing_image_source() -> ImageSource {
ImageSource::Custom(Arc::new(|_, _| {
Some(Err(gpui::ImageCacheError::Asset(
"failed to load image".into(),
)))
}))
}
fn loaded_image_source() -> ImageSource {
let buffer = image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 0, 0, 255]));
ImageSource::Render(Arc::new(gpui::RenderImage::new(SmallVec::from_elem(
image::Frame::new(buffer),
1,
))))
}
fn open_markdown_image_test_window<'a>(
source: &str,
image_source: ImageSource,
cx: &'a mut TestAppContext,
) -> &'a mut gpui::VisualTestContext {
struct ImageTestView {
markdown: Entity<Markdown>,
image_source: ImageSource,
}
impl Render for ImageTestView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let image_source = self.image_source.clone();
div().size_full().child(
MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default())
.image_resolver(move |_| Some(image_source.clone())),
)
}
}
ensure_theme_initialized(cx);
let source = source.to_string();
let (_, cx) = cx.add_window_view(|_, cx| ImageTestView {
markdown: cx.new(|cx| Markdown::new(source.into(), None, None, cx)),
image_source,
});
cx.run_until_parked();
cx
}
#[gpui::test]
fn test_clicking_image_fallback_opens_image_url(cx: &mut TestAppContext) {
let cx = open_markdown_image_test_window(
"![alt text](https://example.com/image.png)",
failing_image_source(),
cx,
);
cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
assert_eq!(
cx.opened_url(),
Some("https://example.com/image.png".to_string())
);
}
#[gpui::test]
fn test_clicking_image_fallback_inside_link_opens_link_url(cx: &mut TestAppContext) {
let cx = open_markdown_image_test_window(
"[![alt text](https://example.com/image.png)](https://example.com/link)",
failing_image_source(),
cx,
);
cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
assert_eq!(
cx.opened_url(),
Some("https://example.com/link".to_string())
);
}
#[gpui::test]
fn test_clicking_loaded_image_inside_link_opens_link_url(cx: &mut TestAppContext) {
let cx = open_markdown_image_test_window(
"[![alt text](https://example.com/image.png)](https://example.com/link)",
loaded_image_source(),
cx,
);
cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
assert_eq!(
cx.opened_url(),
Some("https://example.com/link".to_string())
);
}
#[track_caller]
fn assert_mappings(rendered: &RenderedText, expected: Vec<Vec<(usize, usize)>>) {
assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");