Paste: keep the pasted image on the canvas (#6759) (#6798)

* Paste: keep the pasted image on the canvas (#6759)

Paste centred the clipboard image on the view centre with no clamp, so an
image that fit the canvas could land partly off it, and one larger than
the canvas could leave blank edges. Now, per axis, an image that fits stays
wholly inside the canvas, as close to the view centre as it can, and one
larger than the canvas starts at the canvas's top-left edge, as in
Photoshop. Paste Into / Outside still centre on the selection
("clampToCanvas": false). Centres are computed edge by edge in f64, so
extreme coordinates no longer overflow.

* Paste: doc comments for Paste Into and the paste placement tests

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Lucas Rodrigues
2026-10-23 18:35:21 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 6e52641508
commit 15db2d8560
3 changed files with 106 additions and 8 deletions
+96 -4
View File
@@ -450,15 +450,35 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
/// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the
/// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the
/// canvas centre.
/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly
/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false`
/// skips that, for Paste Into / Outside, which centre on the selection.
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
return (0, 0);
}
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0));
let (cx, cy) = c.unwrap_or((mid(canvas.x0, canvas.x1), mid(canvas.y0, canvas.y1)));
let b = clip.bounds;
((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32)
let (dx, dy) = ((cx - mid(b.x0, b.x1)).round(), (cy - mid(b.y0, b.y1)).round());
if p.get("clampToCanvas").and_then(Value::as_bool) == Some(false) || b.is_empty() {
return (dx as i32, dy as i32);
}
(clamp_shift(dx, b.x0, b.x1, canvas.x0, canvas.x1), clamp_shift(dy, b.y0, b.y1, canvas.y0, canvas.y1))
}
/// The midpoint of `a..b`, each edge converted before adding so extreme coordinates can't overflow.
fn mid(a: i32, b: i32) -> f64 {
(f64::from(a) + f64::from(b)) / 2.0
}
/// Shift `d` of the span `b0..b1` along one axis, changed as little as needed to keep the span
/// inside `c0..c1` when it fits; a span longer than the canvas starts at `c0`.
fn clamp_shift(d: f64, b0: i32, b1: i32, c0: i32, c1: i32) -> i32 {
let (b0, b1, c0, c1) = (i64::from(b0), i64::from(b1), i64::from(c0), i64::from(c1));
// `as` saturates a NaN or out-of-range shift, then the clamp brings it into range.
let start = if b1 - b0 <= c1 - c0 { b0.saturating_add(d as i64).clamp(c0, c1 - (b1 - b0)) } else { c0 };
i32::try_from(start - b0).unwrap_or(if start > b0 { i32::MAX } else { i32::MIN })
}
pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
@@ -994,7 +1014,7 @@ pub fn specs() -> Vec<CommandSpec> {
&["Edit"],
Some("Cmd+V"),
concat!(
r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"#,
r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#,
paste_target!(),
"} (with no document open: a new document from the clipboard)"
),
@@ -1288,6 +1308,78 @@ mod tests {
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
}
/// A `w`×`h` document with an opaque `cw`×`ch` image from another app on the clipboard.
fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
let mut surface = Surface::new(PixelFormat::RGBA8);
surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]);
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None });
s
}
/// #6759: wherever the view is centred, a paste that fits the canvas stays wholly on it.
#[test]
fn paste_that_fits_stays_on_the_canvas() {
for (w, h, center, want) in [
(60, 100, [40.0, 45.0], Rect::new(0, 0, 60, 100)),
(60, 100, [-500.0, 900.0], Rect::new(0, 0, 60, 100)),
(100, 100, [10.0, 80.0], Rect::new(0, 0, 60, 100)),
(100, 100, [95.0, 50.0], Rect::new(40, 0, 100, 100)),
(100, 120, [50.0, 60.0], Rect::new(20, 10, 80, 110)),
] {
let mut s = external_clip_session(w, h, 60, 100);
s.execute("edit.paste", json!({ "center": center })).unwrap();
assert_eq!(active_bounds(&s), want, "{w}x{h} canvas, view centre {center:?}");
}
}
/// #6759: a paste larger than the canvas starts at its top-left corner, wherever the view is.
#[test]
fn paste_larger_than_the_canvas_starts_at_its_top_left() {
for center in [[0.0, 0.0], [25.0, 20.0], [1000.0, 1000.0], [-1000.0, 30.0]] {
let mut s = external_clip_session(50, 40, 60, 100);
s.execute("edit.paste", json!({ "center": center })).unwrap();
assert_eq!(active_bounds(&s), Rect::new(0, 0, 60, 100), "view centre {center:?}");
}
// Wider but not taller: at the left edge, and wholly inside vertically.
for (center, want) in
[([500.0, 500.0], Rect::new(0, 100, 60, 200)), ([-500.0, -500.0], Rect::new(0, 0, 60, 100)), ([25.0, 120.0], Rect::new(0, 70, 60, 170))]
{
let mut s = external_clip_session(50, 200, 60, 100);
s.execute("edit.paste", json!({ "center": center })).unwrap();
assert_eq!(active_bounds(&s), want, "view centre {center:?}");
}
}
/// Paste Into centres on the selection even where that runs off the canvas.
#[test]
fn paste_into_is_not_clamped_to_the_canvas() {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("select.rect", json!({"x": 90, "y": 90, "width": 10, "height": 10})).unwrap();
s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(75, 85, 115, 105));
}
/// The clamp never overflows or panics: NaN, infinite and `i32`-limit shifts saturate (Rule 9).
#[test]
fn paste_offset_clamp_survives_extreme_coordinates() {
assert_eq!(clamp_shift(f64::NAN, 0, 10, 0, 100), 0);
assert_eq!(clamp_shift(f64::INFINITY, 0, 10, 0, 100), 90);
assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX);
assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX);
let clip = Clip {
surface: Surface::new(PixelFormat::RGBA8),
bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX),
layers: None,
resolution: None,
};
let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping");
}
#[test]
fn paste_with_malformed_center_does_not_panic() {
// A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre.
+5 -1
View File
@@ -336,6 +336,9 @@ fn can_reselect(s: &Session) -> std::result::Result<(), String> {
reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into())
}
/// Edit › Paste Special › Paste Into (or Outside): the clipboard centred on the selection, not
/// clamped to the canvas, as a new layer masked to the selection (or its inverse), or into the
/// targeted mask or channel. One history step.
fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?;
@@ -353,7 +356,8 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
selection
};
let label = if outside { "Paste Outside" } else { "Paste Into" };
let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0]});
// Centred on the selection, even where that runs off the canvas (not clamped like a paste).
let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0], "clampToCanvas": false});
if let Some(c) = p.get("center") {
params["center"] = c.clone();
}
+5 -3
View File
@@ -3606,10 +3606,11 @@ mod clipboard_tests {
app
}
/// Paste is enabled from the clipboard service alone (no read); the explicit paste imports
/// the OS image, once, and centres it in the view like Photoshop does for foreign clipboard
/// content.
#[test]
fn external_clipboard_enables_and_centres_paste() {
// Paste is enabled from the clipboard service alone (no read); the explicit paste imports
// the OS image, once, and centres it like Photoshop does for foreign clipboard content.
let (os, reads) = (OsClip::default(), Arc::default());
let mut app = app_with_os_clipboard(&os, &reads);
*os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16)));
@@ -3617,8 +3618,9 @@ mod clipboard_tests {
assert!(crate::menus::is_enabled(&app, "edit.paste"));
assert!(crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"));
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "enablement never reads the clipboard");
app.ui.views[0].center = [40.0, 20.0];
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap();
assert_ne!(r["offset"], serde_json::json!([0, 0]), "external image is centred: {r}");
assert_eq!(r["offset"], serde_json::json!([38, 18]), "external image is centred in the view: {r}");
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1, "one read per explicit paste");
// Without a clipboard service Paste greys until something is copied in the app.
let mut plain = PhotocraftApp::new(Session::new(), Services::default());