feat(pdf): clickable web links, text selection overlays, and background loading
This commit is contained in:
1 parent
5f81d1a45e
commit
5870e90b99
4 files changed
+219
-4
No files matched your search
Generated
+1
@@ -8759,6 +8759,7 @@ dependencies = [
|
||||
"file_icons",
|
||||
"fs",
|
||||
"gpui",
|
||||
"kkpdf-zed",
|
||||
"language",
|
||||
"log",
|
||||
"menu",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user