From bbd418d5a245e4e806fba92180cdb12f99e2aeb7 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 10:06:41 +0530 Subject: [PATCH] feat(image_viewer): browser-style mouse drag text selection and right-click context menu for PDF viewer --- crates/image_viewer/src/image_viewer.rs | 182 ++++++++++++++++++++---- 1 file changed, 153 insertions(+), 29 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 58fea825a5..d5bf393a37 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -10,12 +10,12 @@ use editor::{ }; use file_icons::FileIcons; use gpui::{ - AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, - IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, ScrollWheelEvent, - Style, Styled, Subscription, Task, WeakEntity, Window, actions, checkerboard, div, img, point, - px, size, + AnyElement, App, Bounds, Context, DismissEvent, DispatchPhase, Element, ElementId, Entity, + EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, + InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, + ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, + checkerboard, deferred, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; @@ -24,7 +24,7 @@ use project::{ }; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{Divider, Tooltip, prelude::*}; +use ui::{ContextMenu, Divider, Tooltip, prelude::*}; use util::{ResultExt as _, paths::PathExt}; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -99,6 +99,7 @@ pub struct ImageView { text_editor: Option>, pdf_selection: Option, pdf_mouse_down: bool, + context_menu: Option<(Entity, Point, Subscription)>, } struct DisplayedImage { @@ -214,6 +215,7 @@ impl ImageView { text_editor: None, pdf_selection: None, pdf_mouse_down: false, + context_menu: None, } } @@ -340,20 +342,104 @@ impl ImageView { } } + fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + if let Some(pdf_info) = pdf_info { + let cur_page = self.image_item.read(cx).current_page(); + let target_page = self + .pdf_selection + .as_ref() + .map(|s| s.page_index) + .unwrap_or(cur_page); + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == target_page) { + let total_segs = page.text_segments.len(); + if total_segs > 0 { + self.pdf_selection = Some(PdfSelection { + page_index: target_page, + start_seg: 0, + end_seg: total_segs.saturating_sub(1), + text: page.page_text.clone(), + }); + cx.notify(); + } + } + } + } + + fn deploy_context_menu( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let has_selection = self + .pdf_selection + .as_ref() + .is_some_and(|s| !s.text.is_empty()); + let is_pdf = self.image_item.read(cx).is_pdf(); + let has_doc_text = self + .image_item + .read(cx) + .extract_text() + .is_some_and(|t| !t.is_empty()); + let can_copy = has_selection || has_doc_text; + let focus_handle = self.focus_handle.clone(); + + let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { + menu.context(focus_handle).map(|menu| { + menu.action_disabled_when(!can_copy, "Copy", Box::new(CopyText)) + .when(is_pdf, |menu| { + menu.action("Select All", Box::new(SelectAll)) + .separator() + .action("Toggle Text Panel", Box::new(ToggleTextPanel)) + }) + .separator() + .action("Zoom In", Box::new(ZoomIn)) + .action("Zoom Out", Box::new(ZoomOut)) + .action("Reset Zoom", Box::new(ResetZoom)) + .action("Fit to View", Box::new(FitToView)) + .separator() + .action("Reveal in File Manager", Box::new(RevealInFileManager)) + }) + }); + + window.focus(&context_menu.focus_handle(cx), cx); + let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + this.context_menu.take(); + cx.notify(); + }); + self.context_menu = Some((context_menu, position, subscription)); + cx.notify(); + } + fn handle_pdf_segment_mouse_down( &mut self, page_index: usize, seg_index: usize, seg_text: String, + click_count: usize, 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, - }); + if click_count >= 3 { + if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: 0, + end_seg: page.text_segments.len().saturating_sub(1), + text: page.page_text.clone(), + }); + } + } + } else { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: seg_index, + end_seg: seg_index, + text: seg_text, + }); + } cx.notify(); } @@ -391,22 +477,24 @@ impl ImageView { } } - fn handle_pdf_page_click( + fn handle_pdf_canvas_mouse_down( &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 { + if click_count >= 2 { + if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: 0, + end_seg: page.text_segments.len().saturating_sub(1), + text: page.page_text.clone(), + }); + } + } + } else { self.pdf_selection = None; } self.pdf_mouse_down = false; @@ -832,6 +920,7 @@ impl Item for ImageView { text_editor: None, pdf_selection: self.pdf_selection.clone(), pdf_mouse_down: false, + context_menu: None, }))) } @@ -956,6 +1045,7 @@ impl Render for ImageView { .on_action(cx.listener(Self::last_page)) .on_action(cx.listener(Self::copy_text)) .on_action(cx.listener(Self::toggle_text_panel)) + .on_action(cx.listener(Self::select_all)) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -982,7 +1072,6 @@ impl Render for ImageView { 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; @@ -1002,12 +1091,16 @@ impl Render for ImageView { .border_1() .border_color(border_color) .bg(gpui::white()) + .cursor(gpui::CursorStyle::IBeam) .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); + this.handle_pdf_canvas_mouse_down(page_idx, event.click_count, cx); })) .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { this.handle_pdf_mouse_up(cx); })) + .on_mouse_down(MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.deploy_context_menu(event.position, window, cx); + })) .child( img(page.image) .id(("pdf-page-img", page_idx)) @@ -1028,6 +1121,7 @@ impl Render for ImageView { 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 seg_text_clone = seg.text.clone(); let segs_ref = Arc::clone(&segments_arc); div() @@ -1040,12 +1134,12 @@ impl Render for ImageView { .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()) + 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.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), event.click_count, cx); cx.stop_propagation(); }), ) @@ -1059,6 +1153,24 @@ impl Render for ImageView { cx.stop_propagation(); }), ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let need_select = this.pdf_selection.as_ref().map_or(true, |s| { + s.page_index != page_idx || (seg_idx < s.start_seg.min(s.end_seg) || seg_idx > s.start_seg.max(s.end_seg)) + }); + if need_select { + this.pdf_selection = Some(PdfSelection { + page_index: page_idx, + start_seg: seg_idx, + end_seg: seg_idx, + text: seg_text_clone.clone(), + }); + } + this.deploy_context_menu(event.position, window, cx); + cx.stop_propagation(); + }), + ) })) .children(links.into_iter().enumerate().map(|(link_idx, link)| { let link_left = px(link.x * page_w_val); @@ -1142,6 +1254,9 @@ impl Render for ImageView { .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.deploy_context_menu(event.position, window, cx); + })) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) @@ -1150,6 +1265,15 @@ impl Render for ImageView { container.into_any_element() } }) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Anchor::TopLeft) + .child(menu.clone()), + ) + .with_priority(3) + })) } }