diff --git a/Cargo.lock b/Cargo.lock index de5e5175fa..b81ceac6f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8759,6 +8759,7 @@ dependencies = [ "file_icons", "fs", "gpui", + "kkpdf-zed", "language", "log", "menu", diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 46047c3b36..44d1be0255 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -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 diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c3369b12af..58fea825a5 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -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, project: Entity, @@ -89,6 +97,8 @@ pub struct ImageView { show_text_panel: bool, text_buffer: Option>, text_editor: Option>, + pdf_selection: Option, + 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) { + 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.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, + ) { + 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::>() + .join(" "); + } + cx.notify(); + } + } + } + + fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { + 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, + ) { + 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.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)) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index d780669720..457fc5ec20 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -116,6 +116,9 @@ pub struct PdfPageEntry { pub width: u32, pub height: u32, pub image: Arc, + pub links: Vec, + pub text_segments: Vec, + 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 { 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, Option)> { - 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)) }