feat(pdf): clickable web links, text selection overlays, and background loading

This commit is contained in:
Kushagra Kumar committed 2026-09-02 09:30:10 +05:30
1 parent 5f81d1a45e
commit 5870e90b99
4 files changed
+219 -4

No files matched your search

Generated
+1
View File
@@ -8759,6 +8759,7 @@ dependencies = [
"file_icons",
"fs",
"gpui",
"kkpdf-zed",
"language",
"log",
"menu",
+1
View File
@@ -21,6 +21,7 @@ db.workspace = true
editor.workspace = true
file_icons.workspace = true
gpui.workspace = true
kkpdf-zed.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
+186
View File
@@ -74,6 +74,14 @@ const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%.
const ZOOM_EDITOR_APPROX_CHAR_WIDTH: f32 = 8.0; // Approximate width of one small UI digit.
const ZOOM_EDITOR_HORIZONTAL_PADDING: f32 = 12.0; // Extra room for cursor and editor edge padding.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfSelection {
pub page_index: usize,
pub start_seg: usize,
pub end_seg: usize,
pub text: String,
}
pub struct ImageView {
image_item: Entity<ImageItem>,
project: Entity<Project>,
@@ -89,6 +97,8 @@ pub struct ImageView {
show_text_panel: bool,
text_buffer: Option<Entity<language::Buffer>>,
text_editor: Option<Entity<Editor>>,
pdf_selection: Option<PdfSelection>,
pdf_mouse_down: bool,
}
struct DisplayedImage {
@@ -202,6 +212,8 @@ impl ImageView {
show_text_panel: false,
text_buffer: None,
text_editor: None,
pdf_selection: None,
pdf_mouse_down: false,
}
}
@@ -315,6 +327,12 @@ impl ImageView {
}
fn copy_text(&mut self, _: &CopyText, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(ref sel) = self.pdf_selection {
if !sel.text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(sel.text.clone()));
return;
}
}
if let Some(text) = self.image_item.read(cx).extract_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
@@ -322,6 +340,79 @@ impl ImageView {
}
}
fn handle_pdf_segment_mouse_down(
&mut self,
page_index: usize,
seg_index: usize,
seg_text: String,
cx: &mut Context<Self>,
) {
self.pdf_mouse_down = true;
self.pdf_selection = Some(PdfSelection {
page_index,
start_seg: seg_index,
end_seg: seg_index,
text: seg_text,
});
cx.notify();
}
fn handle_pdf_segment_mouse_move(
&mut self,
page_index: usize,
seg_index: usize,
segments: &[kkpdf_zed::PdfTextSegment],
cx: &mut Context<Self>,
) {
if !self.pdf_mouse_down {
return;
}
if let Some(ref mut sel) = self.pdf_selection {
if sel.page_index == page_index && sel.end_seg != seg_index {
sel.end_seg = seg_index;
let min_idx = sel.start_seg.min(seg_index);
let max_idx = sel.start_seg.max(seg_index);
if max_idx < segments.len() {
sel.text = segments[min_idx..=max_idx]
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ");
}
cx.notify();
}
}
}
fn handle_pdf_mouse_up(&mut self, cx: &mut Context<Self>) {
if self.pdf_mouse_down {
self.pdf_mouse_down = false;
cx.notify();
}
}
fn handle_pdf_page_click(
&mut self,
page_index: usize,
page_text: String,
total_segments: usize,
click_count: usize,
cx: &mut Context<Self>,
) {
if click_count >= 2 && total_segments > 0 {
self.pdf_selection = Some(PdfSelection {
page_index,
start_seg: 0,
end_seg: total_segments.saturating_sub(1),
text: page_text,
});
} else if click_count == 1 {
self.pdf_selection = None;
}
self.pdf_mouse_down = false;
cx.notify();
}
fn toggle_text_panel(&mut self, _: &ToggleTextPanel, window: &mut Window, cx: &mut Context<Self>) {
self.show_text_panel = !self.show_text_panel;
if self.show_text_panel && self.text_editor.is_none() {
@@ -739,6 +830,8 @@ impl Item for ImageView {
show_text_panel: self.show_text_panel,
text_buffer: None,
text_editor: None,
pdf_selection: self.pdf_selection.clone(),
pdf_mouse_down: false,
})))
}
@@ -886,7 +979,15 @@ impl Render for ImageView {
.children(pdf_info.pages.into_iter().map(|page| {
let page_w = px(page.width as f32 * zoom_level);
let page_h = px(page.height as f32 * zoom_level);
let page_w_val = page.width as f32 * zoom_level;
let page_h_val = page.height as f32 * zoom_level;
let page_idx = page.page_index;
let page_text = page.page_text.clone();
let total_segs = page.text_segments.len();
let segments_arc = Arc::new(page.text_segments);
let links = page.links;
let pdf_selection = self.pdf_selection.clone();
v_flex()
.id(("pdf-page-container", page_idx))
.items_center()
@@ -894,17 +995,102 @@ impl Render for ImageView {
.child(
div()
.id(("pdf-page-canvas", page_idx))
.relative()
.w(page_w)
.h(page_h)
.shadow_md()
.border_1()
.border_color(border_color)
.bg(gpui::white())
.on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
this.handle_pdf_page_click(page_idx, page_text.clone(), total_segs, event.click_count, cx);
}))
.on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
this.handle_pdf_mouse_up(cx);
}))
.child(
img(page.image)
.id(("pdf-page-img", page_idx))
.size_full()
.absolute()
)
.children((0..total_segs).map(|seg_idx| {
let seg = &segments_arc[seg_idx];
let is_selected = pdf_selection.as_ref().is_some_and(|sel| {
sel.page_index == page_idx && {
let min_idx = sel.start_seg.min(sel.end_seg);
let max_idx = sel.start_seg.max(sel.end_seg);
seg_idx >= min_idx && seg_idx <= max_idx
}
});
let seg_left = px(seg.x * page_w_val);
let seg_top = px(seg.y * page_h_val);
let seg_w = px(seg.width * page_w_val);
let seg_h = px(seg.height * page_h_val);
let seg_text = seg.text.clone();
let segs_ref = Arc::clone(&segments_arc);
div()
.id(ElementId::Name(format!("pdf-text-seg-{page_idx}-{seg_idx}").into()))
.absolute()
.left(seg_left)
.top(seg_top)
.w(seg_w)
.h(seg_h)
.cursor(gpui::CursorStyle::IBeam)
.when(is_selected, |s| s.bg(gpui::rgba(0x3b82f644)).rounded_xs())
.when(!is_selected, |s| {
s.hover(|h| h.bg(gpui::rgba(0x3b82f618)).rounded_xs())
})
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _event: &MouseDownEvent, _window, cx| {
this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), cx);
cx.stop_propagation();
}),
)
.on_mouse_move(cx.listener(move |this, _event: &MouseMoveEvent, _window, cx| {
this.handle_pdf_segment_mouse_move(page_idx, seg_idx, &segs_ref, cx);
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
this.handle_pdf_mouse_up(cx);
cx.stop_propagation();
}),
)
}))
.children(links.into_iter().enumerate().map(|(link_idx, link)| {
let link_left = px(link.x * page_w_val);
let link_top = px(link.y * page_h_val);
let link_w = px(link.width * page_w_val);
let link_h = px(link.height * page_h_val);
let link_url = link.url.clone();
let link_url_tooltip = link.url.clone();
div()
.id(ElementId::Name(format!("pdf-link-{page_idx}-{link_idx}").into()))
.absolute()
.left(link_left)
.top(link_top)
.w(link_w)
.h(link_h)
.cursor(gpui::CursorStyle::PointingHand)
.hover(|s| {
s.bg(gpui::rgba(0x3b82f633))
.border_1()
.border_color(gpui::rgba(0x3b82f688))
.rounded_xs()
})
.tooltip(Tooltip::text(format!("Open: {}", link_url_tooltip)))
.on_mouse_down(
MouseButton::Left,
move |_event: &MouseDownEvent, _window, cx| {
cx.open_url(&link_url);
cx.stop_propagation();
},
)
}))
)
.child(
Label::new(format!("Page {} of {}", page_idx + 1, total_pages))
+31 -4
View File
@@ -116,6 +116,9 @@ pub struct PdfPageEntry {
pub width: u32,
pub height: u32,
pub image: Arc<gpui::Image>,
pub links: Vec<kkpdf_zed::PdfLinkAnnotation>,
pub text_segments: Vec<kkpdf_zed::PdfTextSegment>,
pub page_text: String,
}
#[derive(Clone)]
@@ -328,13 +331,18 @@ impl ImageItem {
let (tx, rx) = futures::channel::oneshot::channel();
let content = local_file.load_bytes(cx);
let background = cx.background_executor().clone();
self.reload_task = Some(cx.spawn(async move |this, cx| {
if let Ok(bytes) = content.await.context("Failed to load image content") {
let is_pdf = bytes.starts_with(b"%PDF-");
let cur_page = this.read_with(cx, |this, _| this.current_page()).unwrap_or(0);
if is_pdf {
if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&bytes) {
let bytes_clone = bytes.clone();
let pdf_res = background.spawn(async move {
create_gpui_images_from_pdf(&bytes_clone)
}).await;
if let Ok((pages, full_text)) = pdf_res {
let total_pages = pages.len();
let target_page = cur_page.min(total_pages.saturating_sub(1));
let first_image = pages
@@ -818,11 +826,16 @@ impl ImageStoreImpl for Entity<LocalImageStore> {
let load_file = worktree.update(cx, |worktree, cx| {
worktree.load_binary_file(path.as_ref(), cx)
});
let background = cx.background_executor().clone();
cx.spawn(async move |image_store, cx| {
let LoadedBinaryFile { file, content } = load_file.await?;
let is_pdf = content.starts_with(b"%PDF-");
let (image, pdf_info) = if is_pdf {
if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&content) {
let content_clone = content.clone();
let pdf_res = background.spawn(async move {
create_gpui_images_from_pdf(&content_clone)
}).await;
if let Ok((pages, full_text)) = pdf_res {
let first_image = pages
.first()
.map(|p| p.image.clone())
@@ -1115,7 +1128,8 @@ impl LocalImageStore {
pub fn create_gpui_images_from_pdf(
pdf_bytes: &[u8],
) -> anyhow::Result<(Vec<PdfPageEntry>, Option<String>)> {
let engine = kkpdf_zed::pdfium::PdfiumEngine::new();
let engine = kkpdf_zed::PdfiumEngine::new();
let doc_details = engine.extract_document_details(pdf_bytes).unwrap_or_default();
let doc = engine.load_document_from_bytes(pdf_bytes, None)?;
let total_pages = doc.total_pages();
let options = kkpdf_zed::rasterizer::RasterizerOptions {
@@ -1142,15 +1156,28 @@ pub fn create_gpui_images_from_pdf(
gpui::ImageFormat::Png,
png_bytes,
));
let page_detail = doc_details.pages.get(page_idx);
let links = page_detail.map(|d| d.links.clone()).unwrap_or_default();
let text_segments = page_detail.map(|d| d.text_segments.clone()).unwrap_or_default();
let page_text = page_detail.map(|d| d.text.clone()).unwrap_or_default();
pages.push(PdfPageEntry {
page_index: page_idx,
width: page.width,
height: page.height,
image,
links,
text_segments,
page_text,
});
}
let full_text = engine.extract_text_from_bytes(pdf_bytes, None).ok();
let full_text = if !doc_details.full_text.is_empty() {
Some(doc_details.full_text)
} else {
engine.extract_text_from_bytes(pdf_bytes, None).ok()
};
Ok((pages, full_text))
}