mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Zoom In/Out shortcuts zoom around the pointer over the canvas (#2075)
This commit is contained in:
@@ -151,9 +151,24 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param
|
||||
if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) {
|
||||
return;
|
||||
}
|
||||
// Zoom In/Out by key equivalent: the pointer is still in the window. A click in the menu bar
|
||||
// takes it out, so that zooms around the centre.
|
||||
if params.is_null()
|
||||
&& let Some(p) = pointer_zoom_params(ctx, id)
|
||||
{
|
||||
activate(app, id, &p);
|
||||
return;
|
||||
}
|
||||
activate(app, id, params);
|
||||
}
|
||||
|
||||
/// Zoom In/Out from a key: the pointer's position (`at`), so the zoom keeps the point under it
|
||||
/// when it is over the canvas. `None` for other commands or with no pointer in the window.
|
||||
pub(super) fn pointer_zoom_params(ctx: &egui::Context, id: &str) -> Option<Value> {
|
||||
let at = ctx.pointer_hover_pos()?;
|
||||
matches!(id, "view.zoomIn" | "view.zoomOut").then(|| json!({"at": [at.x, at.y]}))
|
||||
}
|
||||
|
||||
/// The editing commands a focused text field takes from the native menu bar.
|
||||
pub(super) const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::invoke::activate;
|
||||
use super::invoke::{activate, pointer_zoom_params};
|
||||
use super::{DesignApp, ScreenMode, Value, json};
|
||||
|
||||
/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
|
||||
@@ -39,7 +39,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
}
|
||||
let typing = app.session.wants_text();
|
||||
let keys = ctx.input_mut(|i| take_keys(app, i, typing));
|
||||
run_keys(app, keys, typing);
|
||||
run_keys(app, ctx, keys, typing);
|
||||
}
|
||||
|
||||
/// Keys that a floating panel in its own OS window left over (craft-ui's
|
||||
@@ -59,7 +59,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec
|
||||
input.events = events;
|
||||
input.modifiers = modifiers;
|
||||
let keys = take_keys(app, &mut input, typing);
|
||||
run_keys(app, keys, typing);
|
||||
run_keys(app, ctx, keys, typing);
|
||||
crate::canvas::forwarded_keys(app, input.events, modifiers);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn take_keys(app: &mut DesignApp, input: &mut egui::InputState, typing: bool) ->
|
||||
}
|
||||
|
||||
/// Runs what [`take_keys`] took, then Esc's return to the Selection tool.
|
||||
fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
|
||||
fn run_keys(app: &mut DesignApp, ctx: &egui::Context, keys: Keys, typing: bool) {
|
||||
for step in keys.presentation {
|
||||
match step {
|
||||
None => {
|
||||
@@ -138,7 +138,11 @@ fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
|
||||
app.select_tool(&tool);
|
||||
} else {
|
||||
// Like choosing the menu item: "…" commands open their dialog.
|
||||
activate(app, &hit.command.id, &hit.command.params);
|
||||
// Zoom In/Out by key zoom around the pointer while it is over the canvas.
|
||||
match pointer_zoom_params(ctx, &hit.command.id) {
|
||||
Some(p) => activate(app, &hit.command.id, &p),
|
||||
None => activate(app, &hit.command.id, &hit.command.params),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Esc with no shortcut of its own goes back to the Selection tool.
|
||||
|
||||
@@ -575,7 +575,11 @@ fn run_ui_part3(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
Some(match id {
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
if let Some(r) = rect {
|
||||
crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
|
||||
// A keyboard zoom names the pointer ("at"); it counts only over the canvas.
|
||||
let xy = p.get("at").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>());
|
||||
let at = xy.filter(|v| v.len() == 2).map(|v| egui::pos2(v[0] as f32, v[1] as f32));
|
||||
let at = at.filter(|at| r.contains(*at)).unwrap_or_else(|| r.center());
|
||||
crate::canvas::zoom_at(app, at, if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -3152,3 +3152,70 @@ fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() {
|
||||
let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e);
|
||||
assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_commands_zoom_around_the_pointer_when_it_is_over_the_canvas() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let pointer = rect.center() + egui::vec2(200.0, 120.0);
|
||||
let under = |app: &crate::DesignApp, at: egui::Pos2| crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_canvas(at);
|
||||
let before = under(&app, pointer);
|
||||
let zoom = app.view().unwrap().zoom;
|
||||
|
||||
// The command with a pointer position keeps the point under it fixed.
|
||||
app.run("view.zoomIn", json!({"at": [pointer.x, pointer.y]})).unwrap();
|
||||
assert!(app.view().unwrap().zoom > zoom);
|
||||
let after = under(&app, pointer);
|
||||
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
|
||||
app.run("view.zoomOut", json!({"at": [pointer.x, pointer.y]})).unwrap();
|
||||
let after = under(&app, pointer);
|
||||
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
|
||||
|
||||
// A position outside the canvas (or none: the menu) zooms around the centre.
|
||||
let centre = under(&app, rect.center());
|
||||
app.run("view.zoomIn", json!({"at": [-50.0, -50.0]})).unwrap();
|
||||
let kept = under(&app, rect.center());
|
||||
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
|
||||
app.run("view.zoomIn", json!({})).unwrap();
|
||||
let kept = under(&app, rect.center());
|
||||
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
|
||||
|
||||
// The keyboard shortcut uses the pointer.
|
||||
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
|
||||
let before = under(&app, pointer);
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(pointer)]);
|
||||
let key = egui::Event::Key { key: egui::Key::Equals, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
frame(&mut app, vec![key]);
|
||||
assert!(app.view().unwrap().zoom > 1.0);
|
||||
let after = under(&app, pointer);
|
||||
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
|
||||
|
||||
// The native menu bar's key equivalent (macOS) leaves the pointer in the window and uses it.
|
||||
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
|
||||
let before = under(&app, pointer);
|
||||
native_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pointer)], Some("view.zoomIn"));
|
||||
assert!(app.view().unwrap().zoom > 1.0);
|
||||
let after = under(&app, pointer);
|
||||
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
|
||||
// A click in the menu bar takes the pointer out of the window: the centre stays.
|
||||
let centre = under(&app, rect.center());
|
||||
native_frame(&mut app, &ctx, vec![egui::Event::PointerGone], Some("view.zoomOut"));
|
||||
let kept = under(&app, rect.center());
|
||||
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"),
|
||||
("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"),
|
||||
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
|
||||
("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
|
||||
("view.fitSelection", "Fit Selection in Window", Some("Cmd+Alt+="), "{}"),
|
||||
("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"),
|
||||
|
||||
Reference in New Issue
Block a user