image_viewer: Add anchored zoom and pan to the PDF viewer

The PDF viewport lacked the interaction set the image view already had:
ctrl/cmd+wheel and pinch zoomed nothing (plain wheel just scrolled),
middle-drag did not pan, and the zoom actions only applied image-mode
pan math, which does nothing for the scroll-based PDF layout.

- Ctrl/cmd+wheel zooms around the cursor: the native scroll handler on
  the same element runs first in the bubble phase, so the applied wheel
  delta is undone before anchoring the zoom.
- Pinch zooms around the touch point; zoom actions anchor the viewport
  center.
- Middle-drag pans, keeping left-drag for text selection and pen input.
- extract_document_details now falls back to a single mock US-Letter
  page when no native libpdfium is present, mirroring the mock
  rasterizer, so PDFs open on machines without the engine instead of
  failing with an image-format error.
- The annotation mouse test gates on native pdfium like the channel
  order test, since mock pages carry no text.

Anchor math lives in pdf_anchored_scroll_y with unit tests, and a
gpui integration test drives real wheel/drag events to assert the
document point under the cursor stays put while zooming.
This commit is contained in:
yenru0 committed 2026-10-03 20:33:08 +09:00
1 parent 5ee6aa1e50
commit 5fd4113081
2 files changed
+328 -5

No files matched your search

+308 -3
View File
@@ -112,6 +112,24 @@ const MIN_ZOOM: f32 = 0.1;
const MAX_ZOOM: f32 = 20.0;
const ZOOM_STEP: f32 = 1.1;
const SCROLL_LINE_MULTIPLIER: f32 = 20.0;
/// Scroll offset that keeps the document point under `anchor_rel_y` (offset from the
/// viewport top) stationary while the zoom scales by `zoom_ratio`. `max_offset_y` and
/// `viewport_height` describe the pre-zoom layout; the result is clamped to the scaled
/// scrollable range.
fn pdf_anchored_scroll_y(
offset_y: Pixels,
anchor_rel_y: Pixels,
zoom_ratio: f32,
max_offset_y: Pixels,
viewport_height: Pixels,
) -> Pixels {
// Zooming out below the fit height leaves the content shorter than the viewport,
// where the scrollable range collapses to zero rather than going negative.
let max_y = (((max_offset_y + viewport_height) * zoom_ratio) - viewport_height).max(px(0.0));
(anchor_rel_y * (1.0 - zoom_ratio) + offset_y * zoom_ratio).clamp(-max_y, px(0.0))
}
const BASE_SQUARE_SIZE: f32 = 32.0;
const ZOOM_EDITOR_MIN_DIGITS: usize = 3; // Reserve room for common values like 100%.
const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%.
@@ -270,6 +288,8 @@ pub struct ImageView {
text_editor: Option<Entity<Editor>>,
pdf_selection: Option<PdfSelection>,
pdf_mouse_down: bool,
/// Middle-drag pan in progress: (cursor position, scroll offset) captured at press.
pdf_middle_pan: Option<(Point<Pixels>, Point<Pixels>)>,
pdf_drag_position: Option<Point<Pixels>>,
pdf_autoscroll_task: Option<Task<()>>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
@@ -433,6 +453,7 @@ impl ImageView {
text_editor: None,
pdf_selection: None,
pdf_mouse_down: false,
pdf_middle_pan: None,
pdf_drag_position: None,
pdf_autoscroll_task: None,
context_menu: None,
@@ -529,14 +550,26 @@ impl ImageView {
}
fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx);
if self.image_item.read(cx).is_pdf() {
self.zoom_pdf_view(self.zoom_level * ZOOM_STEP, None, cx);
} else {
self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx);
}
}
fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context<Self>) {
self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx);
if self.image_item.read(cx).is_pdf() {
self.zoom_pdf_view(self.zoom_level / ZOOM_STEP, None, cx);
} else {
self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx);
}
}
fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context<Self>) {
if self.image_item.read(cx).is_pdf() {
self.zoom_pdf_view(1.0, None, cx);
return;
}
self.zoom_level = 1.0;
self.pan_offset = Point::default();
cx.notify();
@@ -570,6 +603,10 @@ impl ImageView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.image_item.read(cx).is_pdf() {
self.zoom_pdf_view(1.0, None, cx);
return;
}
self.zoom_level = 1.0;
self.pan_offset = Point::default();
cx.notify();
@@ -1621,6 +1658,98 @@ impl ImageView {
}
}
/// Zooms the PDF layout around `anchor` (a window position), adjusting the scroll
/// offset so the document point under the anchor stays put. `None` anchors the
/// viewport center.
///
/// `max_offset` still reflects the pre-zoom layout until the next paint, so the
/// scrollable range is scaled manually here.
fn zoom_pdf_view(
&mut self,
new_zoom: f32,
anchor: Option<Point<Pixels>>,
cx: &mut Context<Self>,
) {
let old_zoom = self.zoom_level;
let new_zoom = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM);
let zoom_ratio = new_zoom / old_zoom;
if (zoom_ratio - 1.0).abs() < f32::EPSILON {
return;
}
self.zoom_level = new_zoom;
self.pdf_middle_pan = None;
let viewport = self.pdf_scroll_handle.bounds();
if viewport.size.width <= px(0.0) || viewport.size.height <= px(0.0) {
cx.notify();
return;
}
let anchor_rel = match anchor {
Some(position) => position - viewport.origin,
None => point(viewport.size.width / 2.0, viewport.size.height / 2.0),
};
let new_y = pdf_anchored_scroll_y(
self.pdf_scroll_handle.offset().y,
anchor_rel.y,
zoom_ratio,
self.pdf_scroll_handle.max_offset().y,
viewport.size.height,
);
// The container only scrolls vertically; horizontal placement is layout-driven.
self.pdf_scroll_handle.set_offset(point(px(0.0), new_y));
cx.notify();
}
/// Ctrl/cmd+wheel zoom for the PDF viewport. The native scroll handler on the same
/// element is registered after this listener, so it runs first in the bubble phase
/// and has already applied the wheel delta; undo it before zooming around the cursor.
fn handle_pdf_scroll_wheel(
&mut self,
event: &ScrollWheelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !event.modifiers.control && !event.modifiers.platform {
return;
}
cx.stop_propagation();
let delta_y = event.delta.pixel_delta(window.line_height()).y;
let offset = self.pdf_scroll_handle.offset();
self.pdf_scroll_handle
.set_offset(point(offset.x, offset.y - delta_y));
let magnitude: f32 = delta_y.abs().into();
let zoom_factor = if delta_y > px(0.0) {
1.0 + magnitude * 0.01
} else {
1.0 / (1.0 + magnitude * 0.01)
};
self.zoom_pdf_view(self.zoom_level * zoom_factor, Some(event.position), cx);
}
fn handle_pdf_pinch(
&mut self,
event: &PinchEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.zoom_pdf_view(self.zoom_level * (1.0 + event.delta), Some(event.position), cx);
}
fn handle_pdf_middle_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
self.pdf_middle_pan = Some((event.position, self.pdf_scroll_handle.offset()));
cx.stop_propagation();
cx.notify();
}
fn handle_pdf_middle_mouse_up(&mut self, cx: &mut Context<Self>) {
if self.pdf_middle_pan.take().is_some() {
cx.notify();
}
}
/// Handles key events for the PDF viewport before they reach focused children.
/// Space only scrolls while the find bar is not focused, so it stays typeable.
fn handle_pdf_key_down(
@@ -3222,6 +3351,7 @@ impl Item for ImageView {
text_editor: None,
pdf_selection: self.pdf_selection.clone(),
pdf_mouse_down: false,
pdf_middle_pan: None,
pdf_drag_position: None,
pdf_autoscroll_task: None,
context_menu: None,
@@ -3436,10 +3566,28 @@ impl Render for ImageView {
}),
)
.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| {
if let Some((origin, origin_offset)) = this.pdf_middle_pan {
let delta = event.position - origin;
this.pdf_scroll_handle.set_offset(origin_offset - delta);
cx.notify();
return;
}
if this.pdf_mouse_down {
this.handle_pdf_general_mouse_move(event.position, cx);
}
}))
.on_mouse_up(
MouseButton::Middle,
cx.listener(|this, _: &MouseUpEvent, _window, cx| {
this.handle_pdf_middle_mouse_up(cx);
}),
)
.on_mouse_up_out(
MouseButton::Middle,
cx.listener(|this, _: &MouseUpEvent, _window, cx| {
this.handle_pdf_middle_mouse_up(cx);
}),
)
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
@@ -3474,9 +3622,18 @@ impl Render for ImageView {
.child(
div()
.id("pdf-scroll-container")
.debug_selector(|| "pdf-scroll-container".into())
.track_scroll(&self.pdf_scroll_handle)
.overflow_y_scroll()
.size_full()
.on_scroll_wheel(cx.listener(Self::handle_pdf_scroll_wheel))
.on_pinch(cx.listener(Self::handle_pdf_pinch))
.on_mouse_down(
MouseButton::Middle,
cx.listener(|this, event: &MouseDownEvent, _window, cx| {
this.handle_pdf_middle_mouse_down(event, cx);
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event: &MouseDownEvent, window, cx| {
@@ -4670,7 +4827,8 @@ mod tests {
use super::*;
use fs::{FakeFs, Fs as _};
use gpui::{
Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, VisualTestContext,
Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, TouchPhase,
VisualTestContext,
};
use settings::SettingsStore;
use util::rel_path::rel_path;
@@ -4931,6 +5089,25 @@ mod tests {
});
}
#[test]
fn test_pdf_anchored_scroll_keeps_anchor_stationary() {
// Zooming in 2x with the anchor 100px below the viewport top must keep the
// document point at that screen position: content coord 1100px (100 - (-1000))
// maps back to 1100 * 2 - 2100 = 100.
let scrolled = pdf_anchored_scroll_y(px(-1000.0), px(100.0), 2.0, px(2000.0), px(500.0));
assert_eq!(scrolled, px(-2100.0));
// Zooming out above the top edge clamps to the top instead of overscrolling.
let scrolled = pdf_anchored_scroll_y(px(-100.0), px(300.0), 0.5, px(100.0), px(500.0));
assert_eq!(scrolled, px(0.0));
// Zooming out with the viewport at the bottom edge clamps to the scaled bottom
// boundary instead of following the anchor: raw target 100*0.5 + (-2000)*0.5 =
// -950 exceeds the scaled max of (2000+500)*0.5-500 = 750.
let scrolled = pdf_anchored_scroll_y(px(-2000.0), px(100.0), 0.5, px(2000.0), px(500.0));
assert_eq!(scrolled, px(-750.0));
}
#[test]
fn test_pdf_selection_endpoints() {
let forward = PdfSelection {
@@ -5466,9 +5643,137 @@ mod tests {
out
}
#[gpui::test]
async fn test_pdf_wheel_zoom_and_middle_drag_pan(cx: &mut TestAppContext) {
init_test(cx);
let pdf_bytes = build_annotation_fixture();
let fs = FakeFs::new(cx.executor());
fs.create_dir(Path::new("/root"))
.await
.expect("test root should be created");
fs.insert_file("/root/fixture.pdf", pdf_bytes).await;
let project = Project::test(fs, [Path::new("/root")], cx).await;
let worktree_id = cx.update(|cx| {
project
.read(cx)
.worktrees(cx)
.next()
.expect("test project should contain a worktree")
.read(cx)
.id()
});
let image_item = project
.update(cx, |project, cx| {
project.open_image(
ProjectPath {
worktree_id,
path: rel_path("fixture.pdf").into(),
},
cx,
)
})
.await
.expect("test pdf should open");
let (image_view, cx) = cx.add_window_view(|window, cx| {
ImageView::new(image_item.clone(), project.clone(), window, cx)
});
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
let pdf_pages = cx.read(|cx| {
image_item
.read(cx)
.pdf_info
.as_ref()
.map(|info| info.total_pages)
});
let container = cx.debug_bounds("pdf-scroll-container").unwrap_or_else(|| {
panic!("pdf viewport should be laid out; pdf_info pages = {pdf_pages:?}")
});
let anchor = point(
container.origin.x + container.size.width * 0.5,
container.origin.y + container.size.height * 0.4,
);
let (zoom_before, offset_before) = cx.read(|cx| {
let view = image_view.read(cx);
(view.zoom_level, view.pdf_scroll_handle.offset())
});
cx.simulate_event(ScrollWheelEvent {
position: anchor,
delta: ScrollDelta::Lines(point(0.0, 3.0)),
modifiers: Modifiers {
control: true,
..Modifiers::default()
},
touch_phase: TouchPhase::Moved,
});
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
let (zoom_after, offset_after) = cx.read(|cx| {
let view = image_view.read(cx);
(view.zoom_level, view.pdf_scroll_handle.offset())
});
assert!(
zoom_after > zoom_before,
"ctrl+wheel should zoom in, got {zoom_before} -> {zoom_after}"
);
let zoom_ratio = zoom_after / zoom_before;
let anchor_rel_y = anchor.y - container.origin.y;
let content_y = anchor_rel_y - offset_before.y;
let anchored_screen_y = content_y * zoom_ratio + offset_after.y;
assert!(
(anchored_screen_y - anchor_rel_y).abs() < px(1.0),
"the document point under the cursor should stay put: \
{anchored_screen_y} vs {anchor_rel_y}"
);
// Middle-drag pans the viewport: dragging down scrolls the content up.
let drag_from = point(container.origin.x + px(40.0), container.origin.y + px(40.0));
cx.simulate_event(MouseDownEvent {
position: drag_from,
button: MouseButton::Middle,
modifiers: Modifiers::default(),
click_count: 1,
first_mouse: false,
});
cx.run_until_parked();
cx.simulate_event(MouseMoveEvent {
position: drag_from + point(px(0.0), px(80.0)),
pressed_button: Some(MouseButton::Middle),
modifiers: Modifiers::default(),
});
cx.run_until_parked();
cx.simulate_event(MouseUpEvent {
position: drag_from + point(px(0.0), px(80.0)),
button: MouseButton::Middle,
modifiers: Modifiers::default(),
click_count: 1,
});
cx.run_until_parked();
let offset_pan = cx.read(|cx| image_view.read(cx).pdf_scroll_handle.offset());
assert!(
offset_pan.y < offset_after.y,
"dragging down should scroll the viewport up, got {offset_pan:?} vs {offset_after:?}"
);
}
#[gpui::test]
async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) {
init_test(cx);
// Highlighting requires real text extraction; the mock fallback pages carry
// no character boxes, so only a native engine can exercise this flow.
if !PdfiumEngine::new().is_native_available() {
eprintln!("skipping: native libpdfium not available on this machine");
return;
}
let pdf_bytes = build_annotation_fixture();
let fs = FakeFs::new(cx.executor());
fs.create_dir(Path::new("/root"))
+20 -2
View File
@@ -1106,7 +1106,20 @@ impl PdfiumEngine {
pages: pages_details,
})
} else {
Ok(PdfDocumentDetails::default())
// Fallback mock document when the native engine binary is unavailable: one
// US-Letter page so PDFs still open and render via the mock rasterizer.
Ok(PdfDocumentDetails {
total_pages: 1,
full_text: String::new(),
pages: vec![PdfPageDetails {
page_index: 0,
width_pt: 612.0,
height_pt: 792.0,
text: String::new(),
links: Vec::new(),
text_segments: Vec::new(),
}],
})
}
}
@@ -2610,7 +2623,12 @@ mod tests {
let details = engine.extract_document_details(dummy_pdf);
assert!(details.is_ok());
let doc = details.unwrap();
assert_eq!(doc.total_pages, 0);
// Without the native library the engine still yields a single mock US-Letter
// page so callers can open the document instead of failing outright.
assert_eq!(doc.total_pages, 1);
let page = doc.pages.first().expect("mock page should exist");
assert_eq!(page.width_pt, 612.0);
assert_eq!(page.height_pt, 792.0);
}
#[test]