collective

Browse / github.com/storytold/deckcraft / patch

Crop pictures like PowerPoint: crop mode with crop handles, move and scale the picture inside

Crop pictures like PowerPoint: crop mode with crop handles, move and scale the picture inside Picture Format › Crop had no crop mode: only fixed "10% / 20% each side" presets, and `picture.crop` changed the crop fractions while keeping the box, so the visible part stretched into it. This adds cropping the way PowerPoint does it. **Crop mode** (`picture.cropMode`, the top half of the now split Crop button, and Crop in the picture's context menu): - Black crop marks on the box: dragging them moves the box's edges over the picture, which keeps its size and place. - The rest of the picture shows faded outside the box, with round handles at its edges. Dragging the picture moves it under t

Status
open
Score
0.00
Adopters
0 other operators
Author
kinower (imported from pull request #1069, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+1194 −18 in 18 files
Review
pending
Shared
2026-10-28
Id
22f6a2a3a1b6b41a6b64a282

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/src/cmd/crop.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/shape.rs crates/engine/src/cmd/shape/picture.rs crates/engine/src/keys.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/engine/src/tools.rs crates/engine/src/tools/click.rs crates/engine/src/tools/drag.rs crates/engine/src/tools/geometry.rs crates/engine/src/tools/pointer.rs crates/ui-egui/src/canvas/context_menu.rs crates/ui-egui/src/canvas/input.rs crates/ui-egui/src/canvas/overlays.rs crates/ui-egui/src/ribbon/picture_format.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-26 | Picture crop handles and image drags snap to slide edges and centres, visible drawing guides, other shapes and the enabled grid, using the same rules as shape resizing; Ctrl/Cmd bypasses snapping and rotated pictures keep free crop gestures (#1069) | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs--- a/crates/engine/src/cmd.rs+++ b/crates/engine/src/cmd.rs@@ -5,6 +5,7 @@ pub mod arrange; pub mod artistic; pub mod background; pub mod compress;+pub mod crop; pub mod design; pub mod edit; pub mod file;@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {         v.extend(selection::specs());         v.extend(merge::specs());         v.extend(points::specs());+        v.extend(crop::specs());         v.extend(text::specs());         v.extend(format::specs());         v.extend(design::specs());diff --git a/crates/engine/src/cmd/crop.rs b/crates/engine/src/cmd/crop.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/crop.rs@@ -0,0 +1,374 @@+//! Crop (Picture Format ▸ Size ▸ Crop), the way PowerPoint does it.+//!+//! A picture's `crop` fractions say which part of the image fills its box, so the whole image sits+//! at a rectangle of its own in the box's local points ([`image_rect`]). Cropping keeps that image+//! rectangle where it is on the slide and moves the box's edges over it: the visible part never+//! stretches. In crop mode ([`picture.cropMode`]) the box's edges are dragged with the crop+//! handles, the image is moved under the box by dragging it and scaled from its own corners.+//!+//! Everything here works in the box's local frame (before rotation and flips), so it holds for+//! rotated and flipped pictures alike.++use deckcraft_geom::{Point, Rect, Xfrm};+use deckcraft_model::{ShapeId, ShapeKind};+use serde_json::{Value, json};++use super::*;+use crate::{Result, Session};++/// Smallest box or image side a crop gesture leaves, in points.+pub const MIN_SIDE: f64 = 1.0;++pub fn specs() -> Vec<CommandSpec> {+    vec![+        cmd!(+            noundo "picture.cropMode",+            "Crop",+            ["Picture Format", "Size", "Crop"],+            None,+            "{on?: bool (default: toggle), id?} — show the crop handles on one selected picture: drag them to crop, drag the picture to move it inside, its round handles to scale it; Enter, Escape or a click elsewhere ends",+            has_one_picture,+            crop_mode+        ),+        cmd!(+            "picture.cropAspect",+            "Aspect Ratio",+            ["Picture Format", "Size", "Crop", "Aspect Ratio"],+            None,+            "{ratio: \"w:h\" (1:1, 2:3, 3:4, 3:5, 4:5, 3:2, 4:3, 5:3, 5:4, 16:9, 16:10…), ids?} — crop to that shape, as large as the picture allows, centred",+            has_picture,+            crop_aspect+        ),+        cmd!(+            "picture.cropFill",+            "Fill / Fit",+            ["Picture Format", "Size", "Crop"],+            None,+            "{mode?: fill|fit (default fill), ids?} — keep the box; fill: the picture covers it, fit: the whole picture shows inside it",+            has_picture,+            crop_fill+        ),+    ]+}++pub fn has_picture(s: &Session) -> std::result::Result<(), String> {+    has_selection(s)?;+    let any = s.active().is_some_and(|d| d.selected_shapes().iter().any(|sh| matches!(sh.kind, ShapeKind::Picture { .. })));+    if any { Ok(()) } else { Err("select a picture".into()) }+}++/// Crop mode works on one picture: with anything else selected too it is off.+pub fn has_one_picture(s: &Session) -> std::result::Result<(), String> {+    has_selection(s)?;+    let one = s.active().is_some_and(|d| matches!(d.selected_shapes().as_slice(), [sh] if matches!(sh.kind, ShapeKind::Picture { .. })));+    if one { Ok(()) } else { Err("select one picture".into()) }+}++/// The whole image in the box's local points (`w`×`h` box, `crop` = left, top, right, bottom).+pub fn image_rect(w: f64, h: f64, crop: [f64; 4]) -> Option<Rect> {+    let [l, t, r, b] = crop;+    let (fx, fy) = (1.0 - l - r, 1.0 - t - b);+    if !(fx > 1e-6 && fy > 1e-6 && w.is_finite() && h.is_finite()) {+        return None;+    }+    let (iw, ih) = (w / fx, h / fy);+    let (x0, y0) = (-l * iw, -t * ih);+    let r = Rect::new(x0, y0, x0 + iw, y0 + ih);+    (r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()).then_some(r)+}++/// The crop fractions that put the image at `img` in a `w`×`h` box (inverse of [`image_rect`]).+pub fn crop_of(w: f64, h: f64, img: Rect) -> Option<[f64; 4]> {+    let (iw, ih) = (img.width(), img.height());+    if !(iw > 1e-6 && ih > 1e-6) {+        return None;+    }+    let c = [-img.x0 / iw, -img.y0 / ih, (img.x1 - w) / iw, (img.y1 - h) / ih];+    c.iter().all(|v| v.is_finite()).then_some(c)+}++/// The box that shows local rectangle `f` of box `orig` in place on the slide (rotation and flips+/// kept). Points of `orig`'s frame at `q` are at `q - f.origin()` in the new one.+pub fn frame_xfrm(orig: &Xfrm, f: Rect) -> Xfrm {+    let c = orig.affine() * f.center();+    let (w, h) = (f.width(), f.height());+    Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..*orig }+}++/// Handle positions of a local rectangle, in the order of box handles: NW, N, NE, E, SE, S, SW, W.+pub fn rect_handles(r: Rect) -> [Point; 8] {+    let (cx, cy) = ((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0);+    [+        Point::new(r.x0, r.y0),+        Point::new(cx, r.y0),+        Point::new(r.x1, r.y0),+        Point::new(r.x1, cy),+        Point::new(r.x1, r.y1),+        Point::new(cx, r.y1),+        Point::new(r.x0, r.y1),+        Point::new(r.x0, cy),+    ]+}++/// Which sides a handle moves: (left, top, right, bottom).+fn sides(handle: u8) -> [bool; 4] {+    match handle {+        0 => [true, true, false, false],+        1 => [false, true, false, false],+        2 => [false, true, true, false],+        3 => [false, false, true, false],+        4 => [false, false, true, true],+        5 => [false, false, false, true],+        6 => [true, false, false, true],+        _ => [true, false, false, false],+    }+}++/// Crop handle `handle` of a `w`×`h` box dragged by local `d`: the new frame (orig's local points).+pub fn drag_frame(w: f64, h: f64, handle: u8, d: deckcraft_geom::Vec2) -> Rect {+    let [l, t, r, b] = sides(handle);+    let mut x0 = if l { d.x } else { 0.0 };+    let mut y0 = if t { d.y } else { 0.0 };+    let mut x1 = if r { w + d.x } else { w };+    let mut y1 = if b { h + d.y } else { h };+    if l {+        x0 = x0.min(x1 - MIN_SIDE);+    } else if r {+        x1 = x1.max(x0 + MIN_SIDE);+    }+    if t {+        y0 = y0.min(y1 - MIN_SIDE);+    } else if b {+        y1 = y1.max(y0 + MIN_SIDE);+    }+    Rect::new(x0, y0, x1, y1)+}++/// The image's own handle `handle` dragged by local `d`, the opposite side staying put. Corners+/// keep the picture's proportions (`free` lets them go); sides stretch one way.+pub fn drag_image(img: Rect, handle: u8, d: deckcraft_geom::Vec2, free: bool) -> Rect {+    let [l, t, r, b] = sides(handle);+    let corner = (l || r) && (t || b);+    if corner && !free {+        let anchor = Point::new(if l { img.x1 } else { img.x0 }, if t { img.y1 } else { img.y0 });+        let from = Point::new(if l { img.x0 } else { img.x1 }, if t { img.y0 } else { img.y1 });+        let diag = from - anchor;+        let len2 = diag.hypot2();+        if len2 <= 1e-9 {+            return img;+        }+        let min = MIN_SIDE / img.width().min(img.height()).max(1e-9);+        let s = ((from + d - anchor).dot(diag) / len2).max(min);+        let to = anchor + diag * s;+        return Rect::from_points(anchor, to);+    }+    let mut n = img;+    if l {+        n.x0 = (img.x0 + d.x).min(img.x1 - MIN_SIDE);+    }+    if r {+        n.x1 = (img.x1 + d.x).max(img.x0 + MIN_SIDE);+    }+    if t {+        n.y0 = (img.y0 + d.y).min(img.y1 - MIN_SIDE);+    }+    if b {+        n.y1 = (img.y1 + d.y).max(img.y0 + MIN_SIDE);+    }+    n+}++/// The one picture crop mode works on: given by `id` or the one selected, on the slide itself.+fn crop_target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {+    let st = s.doc()?;+    let id = match id_param(p, "id", cmd)? {+        Some(i) => i,+        None => match st.selection.shapes.as_slice() {+            [one] => *one,+            _ => return Err(bad(cmd, "select one picture")),+        },+    };+    let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?;+    if !matches!(sh.kind, ShapeKind::Picture { .. }) {+        return Err(bad(cmd, "only a picture can be cropped"));+    }+    if sh.locked {+        return Err(bad(cmd, "the object is locked"));+    }+    if !st.shapes().iter().any(|t| t.id == id) {+        return Err(bad(cmd, "a picture in a group can't be cropped; ungroup it first"));+    }+    Ok(id)+}++fn crop_mode(s: &mut Session, p: &Value) -> Result<Value> {+    let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.crop.is_none());+    if !on {+        s.exit_crop();+        return Ok(json!({"cropping": Value::Null}));+    }+    let id = crop_target(s, p, "picture.cropMode")?;+    s.select(|_, sel| {+        sel.shapes = vec![id];+        sel.text = None;+        sel.cells = None;+    })?;+    s.tool.edit_points = None;+    s.tool.edit_vertex = None;+    s.tool.crop = Some(id);+    Ok(json!({"cropping": id.0}))+}++/// Each target picture's box (slide points) and crop, before the change.+fn pictures(s: &Session, p: &Value) -> Result<Vec<(ShapeId, Xfrm, [f64; 4])>> {+    let ids = targets(s, p)?;+    let st = s.doc()?;+    Ok(ids+        .iter()+        .filter_map(|id| {+            let sh = st.shape(*id)?;+            match &sh.kind {+                ShapeKind::Picture { fill } if !sh.locked => Some((*id, xfrm_of(&st.doc, &st.selection, sh), fill.crop)),+                _ => None,+            }+        })+        .collect())+}++/// Store a new box and crop on each picture (`None` leaves the box).+pub(crate) fn store(s: &mut Session, cmd: &str, items: &[(ShapeId, Option<Xfrm>, [f64; 4])]) -> Result<Value> {+    s.edit(|doc, sel| {+        let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;+        for (id, x, c) in items {+            if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {+                if let Some(x) = x {+                    sh.xfrm = Some(*x);+                }+                if let ShapeKind::Picture { fill } = &mut sh.kind {+                    fill.crop = *c;+                }+            }+        }+        Ok(())+    })?;+    Ok(json!({"cropped": items.len()}))+}++/// `picture.crop` with the frame following the crop (PowerPoint): the image keeps its size and+/// place and the box shrinks or grows over it.+pub(crate) fn crop_keeping_scale(s: &mut Session, p: &Value, want: [Option<f64>; 4]) -> Result<Value> {+    let mut out = vec![];+    for (id, x, old) in pictures(s, p)? {+        let Some(img) = image_rect(x.w, x.h, old) else { continue };+        let mut c = old;+        for (slot, v) in c.iter_mut().zip(want) {+            if let Some(v) = v.filter(|v| v.is_finite()) {+                *slot = v.clamp(-10.0, 0.95);+            }+        }+        // Keep at least a sliver showing either way.+        if c[0] + c[2] > 0.99 || c[1] + c[3] > 0.99 {+            return Err(bad("picture.crop", "the crop leaves nothing of the picture"));+        }+        let (iw, ih) = (img.width(), img.height());+        let f = Rect::new(img.x0 + c[0] * iw, img.y0 + c[1] * ih, img.x1 - c[2] * iw, img.y1 - c[3] * ih);+        out.push((id, Some(frame_xfrm(&x, f)), c));+    }+    store(s, "picture.crop", &out)+}++fn parse_ratio(v: &Value) -> Option<f64> {+    let r = match v {+        Value::String(t) => {+            let (a, b) = t.split_once([':', '/', 'x'])?;+            a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?+        }+        Value::Array(a) => a.first()?.as_f64()? / a.get(1)?.as_f64()?,+        _ => v.as_f64()?,+    };+    (r.is_finite() && (0.01..=100.0).contains(&r)).then_some(r)+}++fn crop_aspect(s: &mut Session, p: &Value) -> Result<Value> {+    let ratio = p.get("ratio").and_then(parse_ratio).ok_or_else(|| bad("picture.cropAspect", "ratio must be like \"16:9\""))?;+    let mut out = vec![];+    for (id, x, c) in pictures(s, p)? {+        let Some(img) = image_rect(x.w, x.h, c) else { continue };+        // The largest box of that shape inside the picture, centred on it.+        let (iw, ih) = (img.width(), img.height());+        let (w, h) = if iw / ih > ratio { (ih * ratio, ih) } else { (iw, iw / ratio) };+        let ctr = img.center();+        let f = Rect::new(ctr.x - w / 2.0, ctr.y - h / 2.0, ctr.x + w / 2.0, ctr.y + h / 2.0);+        let Some(nc) = crop_of(w, h, img - f.origin().to_vec2()) else { continue };+        out.push((id, Some(frame_xfrm(&x, f)), nc));+    }+    let r = store(s, "picture.cropAspect", &out)?;+    // Then straight into crop mode to adjust it, as PowerPoint does.+    if let [(id, ..)] = out.as_slice() {+        s.tool.crop = Some(*id);+    }+    Ok(r)+}++fn crop_fill(s: &mut Session, p: &Value) -> Result<Value> {+    let fit = match str_param(p, "mode").unwrap_or("fill") {+        "fill" => false,+        "fit" => true,+        m => return Err(bad("picture.cropFill", format!("mode {m:?} is not fill or fit"))),+    };+    let mut out = vec![];+    for (id, x, c) in pictures(s, p)? {+        let Some(img) = image_rect(x.w, x.h, c) else { continue };+        let (kx, ky) = (x.w / img.width(), x.h / img.height());+        let k = if fit { kx.min(ky) } else { kx.max(ky) };+        let (w, h) = (img.width() * k, img.height() * k);+        let (x0, y0) = ((x.w - w) / 2.0, (x.h - h) / 2.0);+        let Some(nc) = crop_of(x.w, x.h, Rect::new(x0, y0, x0 + w, y0 + h)) else { continue };+        out.push((id, None, nc));+    }+    store(s, "picture.cropFill", &out)+}++#[cfg(test)]+mod tests {+    use super::*;+    use deckcraft_geom::Vec2;++    #[test]+    fn image_rect_and_crop_of_round_trip() {+        let c = [0.1, 0.2, 0.3, 0.05];+        let img = image_rect(60.0, 75.0, c).unwrap();+        assert!((img.width() - 100.0).abs() < 1e-9 && (img.height() - 100.0).abs() < 1e-9);+        let back = crop_of(60.0, 75.0, img).unwrap();+        for (a, b) in back.iter().zip(c) {+            assert!((a - b).abs() < 1e-9);+        }+        assert!(image_rect(10.0, 10.0, [0.5, 0.0, 0.5, 0.0]).is_none());+    }++    #[test]+    fn frame_handles_keep_a_minimum_size() {+        let f = drag_frame(100.0, 50.0, 3, Vec2::new(-500.0, 0.0));+        assert!((f.width() - MIN_SIDE).abs() < 1e-9);+        let f = drag_frame(100.0, 50.0, 0, Vec2::new(10.0, 5.0));+        assert_eq!((f.x0, f.y0, f.x1, f.y1), (10.0, 5.0, 100.0, 50.0));+    }++    #[test]+    fn image_corners_keep_proportions() {+        let img = Rect::new(0.0, 0.0, 200.0, 100.0);+        let n = drag_image(img, 4, Vec2::new(200.0, 0.0), false);+        assert!((n.width() / n.height() - 2.0).abs() < 1e-9);+        assert_eq!((n.x0, n.y0), (0.0, 0.0));+        let n = drag_image(img, 3, Vec2::new(50.0, 0.0), false);+        assert_eq!((n.width(), n.height()), (250.0, 100.0));+    }++    #[test]+    fn ratios_parse() {+        assert!((parse_ratio(&json!("16:9")).unwrap() - 16.0 / 9.0).abs() < 1e-12);+        assert_eq!(parse_ratio(&json!([4, 3])), Some(4.0 / 3.0));+        assert!(parse_ratio(&json!("0:3")).is_none());+        assert!(parse_ratio(&json!("a:b")).is_none());+    }+}diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -47,6 +47,8 @@ pub fn specs() -> Vec<CommandSpec> { }  fn restore(s: &mut Session, redo: bool) -> Result<Value> {+    // A crop gesture in progress becomes its own step first, so Undo takes exactly that back.+    s.end_crop_drag();     let vertex_kind = s.tool.vertex_kind.clone();     let st = s.doc_mut()?;     let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };@@ -58,6 +60,7 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {     // A later shape can get an undone copy's id: Duplicate starts over with the usual offset.     st.last_duplicate = None;     st.revision += 1;+    s.settle_crop();     // The point type Edit Points remembered with the restored document: handle drags follow it.     s.tool.vertex_kind = e.vertex_kind;     Ok(json!({"label": e.label}))diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs--- a/crates/engine/src/cmd/shape.rs+++ b/crates/engine/src/cmd/shape.rs@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Crop",             ["Picture Format", "Size"],             None,-            "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",+            "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, keepFrame?: bool (default false: the box follows the crop and the picture keeps its size, as in PowerPoint; true: the box stays and the rest stretches into it), ids?}",             has_selection,             crop         ),diff --git a/crates/engine/src/cmd/shape/picture.rs b/crates/engine/src/cmd/shape/picture.rs--- a/crates/engine/src/cmd/shape/picture.rs+++ b/crates/engine/src/cmd/shape/picture.rs@@ -2,6 +2,9 @@ use super::*;  pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {     let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];+    if !bool_or(p, "keepFrame", false) {+        return crate::cmd::crop::crop_keeping_scale(s, p, c);+    }     edit_geometry(s, p, "picture.crop", |sh| {         if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {             for (i, v) in c.iter().enumerate() {@@ -9,6 +12,10 @@ pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {                     *slot = v.clamp(-10.0, 0.95);                 }             }+            // Keep at least a sliver showing, as without keepFrame.+            if fill.crop[0] + fill.crop[2] > 0.99 || fill.crop[1] + fill.crop[3] > 0.99 {+                return Err(bad("picture.crop", "the crop leaves nothing of the picture"));+            }         }         Ok(())     })diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs--- a/crates/engine/src/keys.rs+++ b/crates/engine/src/keys.rs@@ -60,6 +60,11 @@ impl Session {         if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {             return self.finish_freeform(false);         }+        self.settle_crop();+        if self.tool.crop.is_some() && matches!(k, "enter" | "return" | "escape") {+            self.exit_crop();+            return Ok(json!({"handled": true, "command": "picture.cropMode"}));+        }         if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {             self.tool.edit_points = None;             self.tool.edit_vertex = None;diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -584,25 +584,40 @@ impl Session {     pub fn set_active(&mut self, i: usize) {         let was = self.active().map(|d| d.uid);         if i < self.docs.len() {+            let before = self.active_uid();+            if before != self.docs.get(i).map(|d| d.uid) {+                self.end_crop_drag();+            }             self.active = Some(i);+            self.document_changed(before);         }         self.forget_vertex_kind_unless(was);     }     pub fn add_document(&mut self, d: DocState) -> usize {-        let was = self.active().map(|d| d.uid);+        let before = self.active_uid();+        self.end_crop_drag();         self.docs.push(d);         self.active = Some(self.docs.len() - 1);-        self.forget_vertex_kind_unless(was);+        self.document_changed(before);+        self.forget_vertex_kind_unless(before);         self.docs.len() - 1     }     pub fn close_document(&mut self, i: usize) {         let was = self.active().map(|d| d.uid);         if i < self.docs.len() {+            let before = self.active_uid();+            // The presentation that takes index `i` (or the last one) becomes active, even when+            // `i` wasn't: a crop drag ends wherever the active presentation changes.+            let next = self.docs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, d)| d.uid).nth(i.min(self.docs.len().saturating_sub(2)));+            if next != before {+                self.end_crop_drag();+            }             let d = self.docs.remove(i);             if let Some(dir) = &self.recovery_dir {                 recovery::discard(dir, d.uid);             }             self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };+            self.document_changed(before);         }         self.forget_vertex_kind_unless(was);     }@@ -613,6 +628,18 @@ impl Session {             self.tool.vertex_kind = None;         }     }+    fn active_uid(&self) -> Option<u64> {+        self.active().map(|d| d.uid)+    }+    /// Crop mode belongs to a picture in one presentation: another one becoming active ends it,+    /// so coming back doesn't bring it back.+    fn document_changed(&mut self, before: Option<u64>) {+        if self.active_uid() != before {+            self.tool.crop = None;+            self.tool.drop_crop_drag();+        }+        self.settle_crop();+    }     pub fn next_untitled(&mut self) -> String {         self.untitled += 1;         format!("Presentation{}", self.untitled)@@ -667,6 +694,7 @@ impl Session {         if let Some(st) = self.active_mut() {             clamp_selection(st);         }+        self.settle_crop();         if let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut())             && st.uid == uid             && !Arc::ptr_eq(&old, &st.doc)@@ -754,6 +782,7 @@ impl Session {         if st.composition.is_some() && owner(&st.selection) != before {             self.settle_composition();         }+        self.settle_crop();         Ok(())     } }diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -5462,6 +5462,419 @@ fn a_shape_in_front_of_edited_text_takes_the_click() {     assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } +/// A 1×1 PNG inserted at [100, 100, 200, 150]; returns its id.+fn cropped_picture(s: &mut Session) -> u64 {+    const PNG: &[u8] = &[+        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,+        0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf,+        0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,+        0x82,+    ];+    s.prefs.snap_to_grid = false;+    s.prefs.smart_guides = false;+    let r = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]}));+    r.unwrap()["id"].as_u64().unwrap()+}++/// The picture's box (x, y, w, h) and crop.+fn crop_state(s: &Session, id: u64) -> ([f64; 4], [f64; 4]) {+    let st = s.doc().unwrap();+    let sh = st.shapes().iter().find(|sh| sh.id.0 as u64 == id).unwrap();+    let x = sh.xfrm.unwrap();+    let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("not a picture") };+    ([x.x, x.y, x.w, x.h], fill.crop)+}++fn near(a: [f64; 4], b: [f64; 4]) -> bool {+    a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6)+}++#[test]+fn crop_command_moves_the_box_over_the_picture_like_powerpoint() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.25, "bottom": 0.2})).unwrap();+    let (b, c) = crop_state(&s, id);+    // The picture keeps its size and place: the box loses a quarter on the left, a fifth below.+    assert!(near(b, [150.0, 100.0, 150.0, 120.0]), "{b:?}");+    assert!(near(c, [0.25, 0.0, 0.0, 0.2]), "{c:?}");+    // Resetting the crop brings the whole picture back at the same scale.+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})).unwrap();+    assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));+    // keepFrame: the old behaviour, the box stays and the rest stretches into it.+    s.execute("picture.crop", &json!({"ids": [id], "left": 0.5, "keepFrame": true})).unwrap();+    assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));+    assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6})).is_err(), "nothing would be left");+}++#[test]+fn crop_mode_handles_crop_move_and_scale_the_picture() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };+    let r = s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    assert_eq!(r["cropping"], id);+    // The right crop handle, dragged in by 50.+    let hit = s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();+    assert_eq!(hit["hit"]["kind"], "crop");+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 250.0, 175.0)).unwrap();+    let (b, c) = crop_state(&s, id);+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}");+    assert!(near(c, [0.0, 0.0, 0.25, 0.0]), "{c:?}");+    assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _)), "still cropping");+    // Drag the picture 20 to the left under the box.+    s.pointer(ev(PointerKind::Down, 200.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 180.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 180.0, 175.0)).unwrap();+    let (b, c) = crop_state(&s, id);+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "the box stays: {b:?}");+    assert!(near(c, [0.1, 0.0, 0.15, 0.0]), "{c:?}");+    // Scale the picture from its bottom-right corner (now at 280, 250): proportions kept.+    s.pointer(ev(PointerKind::Down, 280.0, 250.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 380.0, 300.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 380.0, 300.0)).unwrap();+    let (b, c) = crop_state(&s, id);+    assert!(near(b, [100.0, 100.0, 150.0, 150.0]), "{b:?}");+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();+    assert!((img.width() / img.height() - 200.0 / 150.0).abs() < 1e-6, "{img:?}");+    assert!((img.x0 + 20.0).abs() < 1e-6 && img.y0.abs() < 1e-6, "the far corner stays: {img:?}");+    // Each gesture is one undo step.+    s.execute("edit.undo", &json!({})).unwrap();+    assert!(near(crop_state(&s, id).1, [0.1, 0.0, 0.15, 0.0]));+    // Escape ends crop mode; so does a click away from the picture.+    s.key("Escape", Mods::default()).unwrap();+    assert_eq!(s.tool.crop, None);+    s.execute("picture.cropMode", &json!({})).unwrap();+    assert!(s.tool.crop.is_some());+    s.pointer(ev(PointerKind::Down, 600.0, 500.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 600.0, 500.0)).unwrap();+    assert_eq!(s.tool.crop, None);+}++#[test]+fn crop_frame_snaps_only_dragged_edges_and_keeps_the_image_in_place() {+    for target in ["guide", "slide", "center", "shape", "grid", "hidden", "disabled", "ctrl", "self"] {+        let mut s = session();+        let id = cropped_picture(&mut s);+        s.prefs.smart_guides = target != "disabled";+        s.tool.guides_visible = target != "hidden";+        s.edit(|doc, _| {+            doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 240.0 });+            Ok(())+        })+        .unwrap();+        if target == "shape" {+            s.execute("shape.insert", &json!({"preset": "rect", "rect": [240, 310, 40, 40]})).unwrap();+        }+        if target == "grid" {+            s.tool.guides_visible = false;+            s.prefs.snap_to_grid = true;+            s.prefs.grid_spacing = 20.0;+        }+        let (at, expected) = match target {+            "slide" => (s.doc().unwrap().doc.slide_size.width - 2.0, s.doc().unwrap().doc.slide_size.width),+            "center" => (s.doc().unwrap().doc.slide_size.width / 2.0 - 2.0, s.doc().unwrap().doc.slide_size.width / 2.0),+            "hidden" | "disabled" | "ctrl" => (238.0, 238.0),+            "self" => (298.0, 298.0),+            _ => (238.0, 240.0),+        };+        let mods = Mods { cmd: target == "ctrl", ..Mods::default() };+        let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };+        s.execute("edit.select", &json!({"ids": [id]})).unwrap();+        s.execute("picture.cropMode", &json!({"id": id})).unwrap();+        s.pointer(ev(PointerKind::Down, 300.0)).unwrap();+        s.pointer(ev(PointerKind::Drag, at)).unwrap();+        let (b, c) = crop_state(&s, id);+        assert!(near(b, [100.0, 100.0, expected - 100.0, 150.0]), "{target}: {b:?}");+        let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+        assert!(near([image.x0 + b[0], image.y0 + b[1], image.width(), image.height()], [100.0, 100.0, 200.0, 150.0]), "{target}: {image:?}");+        if matches!(target, "guide" | "slide" | "center" | "shape") {+            assert!(s.tool.guides.iter().any(|g| g.vertical && (g.pos - expected).abs() < 1e-6), "{target}");+        } else {+            assert!(s.tool.guides.is_empty(), "{target}");+        }+        s.pointer(ev(PointerKind::Up, at)).unwrap();+        s.execute("edit.undo", &json!({})).unwrap();+        assert!(near(crop_state(&s, id).0, [100.0, 100.0, 200.0, 150.0]));+    }+}++#[test]+fn crop_image_handles_and_pan_snap_without_moving_the_frame() {+    for pan in [false, true] {+        for ctrl in [false, true] {+            let mut s = session();+            let id = cropped_picture(&mut s);+            s.execute("picture.crop", &json!({"ids": [id], "right": 0.25})).unwrap();+            s.prefs.smart_guides = true;+            s.tool.guides_visible = true;+            s.edit(|doc, _| {+                doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: 340.0 });+                Ok(())+            })+            .unwrap();+            s.execute("picture.cropMode", &json!({"id": id})).unwrap();+            let mods = Mods { cmd: ctrl, ..Mods::default() };+            let ev = |kind, x| PointerEvent { kind, x, y: 175.0, mods, tol: 3.0, pressure: None };+            let start = if pan { 200.0 } else { 300.0 };+            let hit = s.pointer(ev(PointerKind::Down, start)).unwrap();+            assert_eq!(hit["hit"]["kind"], if pan { "cropPan" } else { "cropImage" });+            s.pointer(ev(PointerKind::Drag, start + 38.0)).unwrap();+            let (b, c) = crop_state(&s, id);+            assert!(near(b, [100.0, 100.0, 150.0, 150.0]));+            let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+            assert!((image.x1 + b[0] - if ctrl { 338.0 } else { 340.0 }).abs() < 1e-6, "{image:?}");+            assert_eq!(s.tool.guides.is_empty(), ctrl);+        }+    }+}++#[test]+fn crop_snapping_handles_flips_and_skips_rotated_pictures() {+    for rotated in [false, true] {+        let mut s = session();+        let id = cropped_picture(&mut s);+        let sid = ShapeId(id as _);+        s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap();+        if rotated {+            s.execute("shape.rotate", &json!({"ids": [id], "by": 30.0})).unwrap();+        }+        let orig = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();+        let start = orig.affine() * deckcraft_geom::Point::new(200.0, 75.0);+        let to = orig.affine() * deckcraft_geom::Point::new(138.0, 75.0);+        let guide = to.x + 2.0;+        s.prefs.smart_guides = true;+        s.tool.guides_visible = true;+        s.edit(|doc, _| {+            doc.guides.push(deckcraft_model::DrawGuide { vertical: true, pos: guide });+            Ok(())+        })+        .unwrap();+        s.execute("picture.cropMode", &json!({"id": id})).unwrap();+        let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 3.0, pressure: None };+        s.pointer(ev(PointerKind::Down, start)).unwrap();+        s.pointer(ev(PointerKind::Drag, to)).unwrap();+        let (b, c) = crop_state(&s, id);+        assert!((b[2] - if rotated { 138.0 } else { 136.0 }).abs() < 1e-6, "{b:?}");+        assert_eq!(s.tool.guides.is_empty(), rotated);+        let x = s.doc().unwrap().shape(sid).unwrap().xfrm.unwrap();+        let image = cmd::crop::image_rect(b[2], b[3], c).unwrap();+        let image_origin = x.affine() * image.origin();+        let before = orig.affine() * deckcraft_geom::Point::ORIGIN;+        assert!((image_origin - before).hypot() < 1e-6);+        assert!((image.width() - 200.0).abs() < 1e-6);+    }+}++#[test]+fn crop_mode_ends_when_the_picture_stops_being_selected() {+    let mut s = session();+    let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap();+    let id = cropped_picture(&mut s);+    let pic = deckcraft_model::ShapeId(id as _);+    let cropping = |s: &Session| s.tool.cropping(s.doc().unwrap());+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    // Selecting the same picture again keeps crop mode on.+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();+    s.select(|_, sel| sel.shapes = vec![pic]).unwrap();+    assert_eq!(s.tool.crop, Some(pic));+    // Tab moves to the other shape: crop mode is over, and coming back doesn't bring it back.+    s.key("Tab", Mods::default()).unwrap();+    assert_ne!(s.doc().unwrap().selection.shapes, vec![pic]);+    assert_eq!(s.tool.crop, None);+    for _ in 0..10 {+        if s.doc().unwrap().selection.shapes == vec![pic] {+            break;+        }+        s.key("Tab", Mods::default()).unwrap();+    }+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);+    assert_eq!(cropping(&s), None);+    let r = s.key("Enter", Mods::default());+    assert!(!r.is_ok_and(|v| v["command"] == "picture.cropMode"), "Enter isn't taken by a crop mode that ended");+    s.execute("edit.deselect", &json!({})).unwrap();+    // The toggle starts crop mode again rather than turning off a stale one.+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();+    assert_eq!(s.execute("picture.cropMode", &json!({})).unwrap()["cropping"], id);+    // Undo takes the picture away (the rectangle is selected again); Redo brings it back+    // selected, but not in crop mode.+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(s.doc().unwrap().selection.shapes, vec![deckcraft_model::ShapeId(rect as _)]);+    assert_eq!(s.tool.crop, None);+    s.execute("edit.redo", &json!({})).unwrap();+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);+    assert_eq!(cropping(&s), None);+}++#[test]+fn crop_mode_ends_when_another_presentation_becomes_active() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    let pic = deckcraft_model::ShapeId(id as _);+    s.execute("file.new", &json!({})).unwrap();+    s.set_active(0);+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    // Activating the presentation that is already active keeps crop mode on.+    s.set_active(0);+    assert_eq!(s.tool.crop, Some(pic));+    // A → B ends it; back in A the picture is still selected, but not in crop mode.+    s.set_active(1);+    assert_eq!(s.tool.crop, None);+    s.set_active(0);+    assert_eq!(s.doc().unwrap().selection.shapes, vec![pic]);+    assert_eq!(s.tool.cropping(s.doc().unwrap()), None);+    // Closing another presentation keeps it; closing this one ends it.+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    s.close_document(1);+    assert_eq!(s.tool.crop, Some(pic));+    s.close_document(0);+    assert_eq!(s.tool.crop, None);+}++#[test]+fn a_crop_drag_ends_in_its_own_presentation_when_another_becomes_active() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    let undo_before = s.doc().unwrap().history.undo.len();+    // Halfway through dragging the right crop handle in, a new presentation opens.+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();+    s.execute("file.new", &json!({})).unwrap();+    assert!(!s.tool.dragging() && s.tool.crop.is_none());+    let b = s.doc().unwrap().doc.clone();+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();+    let st = s.doc().unwrap();+    assert!(std::sync::Arc::ptr_eq(&b, &st.doc) && st.history.undo.is_empty(), "the new presentation is untouched");+    // The first presentation kept the crop so far as one finished undo step.+    s.set_active(0);+    let st = s.doc().unwrap();+    assert!(st.interaction.is_none());+    assert_eq!(st.history.undo.len(), undo_before + 1);+    assert_eq!(st.history.undo.last().unwrap().label, "Crop");+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]));+    s.execute("edit.undo", &json!({})).unwrap();+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.0, 0.0]));+}++#[test]+fn a_crop_drag_ends_when_closing_another_presentation_changes_the_active_one() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };+    let uid = |s: &Session| s.doc().unwrap().uid;+    s.execute("file.new", &json!({})).unwrap();+    s.execute("file.new", &json!({})).unwrap();+    s.set_active(0);+    let a = uid(&s);+    let undo_before = s.doc().unwrap().history.undo.len();+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();+    // Closing B (index 1) while A is active makes C, which takes index 1, the active one.+    s.close_document(1);+    assert_ne!(uid(&s), a);+    assert!(!s.tool.dragging() && s.tool.crop.is_none());+    let c = s.doc().unwrap().doc.clone();+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();+    let st = s.doc().unwrap();+    assert!(std::sync::Arc::ptr_eq(&c, &st.doc) && st.history.undo.is_empty(), "C is untouched");+    s.set_active(0);+    let st = s.doc().unwrap();+    assert!(st.interaction.is_none());+    assert_eq!(st.history.undo.len(), undo_before + 1);+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]));+    // Closing C (index 1) leaves A active: the drag goes on and finishes on release.+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap();+    s.close_document(1);+    assert_eq!(uid(&s), a);+    assert!(s.tool.dragging());+    s.pointer(ev(PointerKind::Up, 225.0, 175.0)).unwrap();+    assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 2);+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));+}++#[test]+fn every_way_out_of_a_crop_gesture_leaves_it_as_one_undo_step() {+    let mut s = session();+    let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].as_u64().unwrap();+    let id = cropped_picture(&mut s);+    let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };+    let undos = |s: &Session| s.doc().unwrap().history.undo.len();+    let ended = |s: &Session| !s.tool.dragging() && s.doc().unwrap().interaction.is_none();+    // Selecting another shape mid-gesture: crop mode ends and the gesture is its own step.+    s.execute("picture.cropMode", &json!({"id": id})).unwrap();+    let n = undos(&s);+    s.pointer(ev(PointerKind::Down, 300.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 250.0, 175.0)).unwrap();+    s.execute("edit.select", &json!({"ids": [rect]})).unwrap();+    assert!(ended(&s) && s.tool.crop.is_none());+    assert_eq!(undos(&s), n + 1);+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 200.0, 175.0)).unwrap();+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.25, 0.0]), "later events change nothing");+    assert_eq!(undos(&s), n + 1);+    // Another tool mid-gesture: the same, and crop mode stays on.+    s.execute("edit.select", &json!({"ids": [id]})).unwrap();+    s.execute("picture.cropMode", &json!({})).unwrap();+    s.pointer(ev(PointerKind::Down, 250.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 225.0, 175.0)).unwrap();+    s.set_tool(ToolKind::Select);+    assert!(ended(&s));+    assert_eq!(undos(&s), n + 2);+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));+    // Undo mid-gesture takes back exactly that gesture; Redo brings it back.+    s.pointer(ev(PointerKind::Down, 225.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 200.0, 175.0)).unwrap();+    s.execute("edit.undo", &json!({})).unwrap();+    assert!(ended(&s));+    assert_eq!(undos(&s), n + 2);+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));+    s.pointer(ev(PointerKind::Drag, 150.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 150.0, 175.0)).unwrap();+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.375, 0.0]));+    s.execute("edit.redo", &json!({})).unwrap();+    assert!(near(crop_state(&s, id).1, [0.0, 0.0, 0.5, 0.0]));+    // Panning the picture off the box stores the crop the renderer draws.+    assert_eq!(s.tool.crop, Some(deckcraft_model::ShapeId(id as _)));+    s.pointer(ev(PointerKind::Down, 150.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Drag, 550.0, 175.0)).unwrap();+    s.pointer(ev(PointerKind::Up, 550.0, 175.0)).unwrap();+    let c = crop_state(&s, id).1;+    assert!(c.iter().all(|v| (-10.0..=0.99).contains(v)) && c[2] == 0.99, "{c:?}");+    // keepFrame can't crop the whole picture away either.+    assert!(s.execute("picture.crop", &json!({"ids": [id], "left": 0.6, "right": 0.6, "keepFrame": true})).is_err());+    // Crop mode needs exactly one picture selected.+    s.execute("edit.select", &json!({"ids": [id, rect]})).unwrap();+    assert!(s.execute("picture.cropMode", &json!({})).is_err());+}++#[test]+fn crop_aspect_fill_and_fit() {+    let mut s = session();+    let id = cropped_picture(&mut s);+    s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "1:1"})).unwrap();+    let (b, _) = crop_state(&s, id);+    assert!(near(b, [125.0, 100.0, 150.0, 150.0]), "largest square, centred: {b:?}");+    assert!(s.tool.crop.is_some(), "then into crop mode");+    // Fit: the whole picture inside the square box; fill: the box covered again.+    s.execute("picture.cropFill", &json!({"ids": [id], "mode": "fit"})).unwrap();+    let (b, c) = crop_state(&s, id);+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();+    assert!((img.width() - 150.0).abs() < 1e-6 && (img.height() - 112.5).abs() < 1e-6, "{img:?}");+    s.execute("picture.cropFill", &json!({"ids": [id]})).unwrap();+    let (b, c) = crop_state(&s, id);+    let img = cmd::crop::image_rect(b[2], b[3], c).unwrap();+    assert!((img.height() - 150.0).abs() < 1e-6 && (img.width() - 200.0).abs() < 1e-6, "{img:?}");+    assert!(s.execute("picture.cropAspect", &json!({"ids": [id], "ratio": "wide"})).is_err());+}+ /// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off. /// The first box is selected. fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) {diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs--- a/crates/engine/src/tools.rs+++ b/crates/engine/src/tools.rs@@ -136,6 +136,28 @@ pub enum Hit {         id: ShapeId,         row: usize,     },+    /// Crop mode: crop handle 0..8 (NW, N, NE, E, SE, S, SW, W) on the picture's box.+    Crop {+        id: ShapeId,+        handle: u8,+    },+    /// Crop mode: handle 0..8 of the whole image, which scales it under the box.+    CropImage {+        id: ShapeId,+        handle: u8,+    },+    /// Crop mode: the picture itself, which moves under the box.+    CropPan {+        id: ShapeId,+    },+}++/// What a crop-mode drag changes.+#[derive(Clone, Copy, Debug)]+enum CropPart {+    Frame(u8),+    Image(u8),+    Pan, }  #[derive(Clone, Debug)]@@ -223,6 +245,14 @@ enum Drag {     Ink {         points: Vec<(f64, f64, f32)>,     },+    /// Crop mode: `orig` is the box and `img` the whole image in its local points at the press.+    Crop {+        id: ShapeId,+        part: CropPart,+        orig: Xfrm,+        img: Rect,+        start: Point,+    },     /// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while     /// the button is down (dragging adds scribbled points).     Freeform {@@ -292,6 +322,8 @@ pub struct ToolState {     pub guide_hover: Option<usize>,     /// Pressure from the pointer event being handled, if the device reported one.     pressure: Option<f32>,+    /// The picture in crop mode (Picture Format ▸ Crop): its crop handles replace the box's.+    pub crop: Option<ShapeId>, }  impl ToolState {@@ -309,6 +341,22 @@ impl ToolState {         })     } +    /// The picture in crop mode, while it is still the one selected shape.+    pub fn cropping(&self, st: &crate::DocState) -> Option<ShapeId> {+        self.crop.filter(|id| {+            st.selection.shapes.as_slice() == [*id]+                && st.selection.text.is_none()+                && st.shape(*id).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. }) && !s.locked)+        })+    }++    /// Forget a crop drag without finishing it: its presentation is no longer the active one.+    pub(crate) fn drop_crop_drag(&mut self) {+        if matches!(self.drag, Some(Drag::Crop { .. })) {+            self.drag = None;+        }+    }+     /// A freeform path is being drawn (Enter/Escape/double-click finish it).     pub fn drawing_freeform(&self) -> bool {         matches!(self.drag, Some(Drag::Freeform { .. }))diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs--- a/crates/engine/src/tools/click.rs+++ b/crates/engine/src/tools/click.rs@@ -91,7 +91,10 @@ impl Session {                 | Hit::Frame { id }                 | Hit::TableColumn { id, .. }                 | Hit::TableRow { id, .. }-                | Hit::Warp { id, .. },+                | Hit::Warp { id, .. }+                | Hit::Crop { id, .. }+                | Hit::CropImage { id, .. }+                | Hit::CropPan { id },             ) => Some(*id),             None => None,         };diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs--- a/crates/engine/src/tools/drag.rs+++ b/crates/engine/src/tools/drag.rs@@ -63,6 +63,41 @@ impl Session {                 self.tool.drag =                     Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) });             }+            Drag::Crop { id, part, orig, img, start } => {+                let inv = orig.affine().inverse();+                let mut d = inv * p - inv * start;+                if !(d.x.is_finite() && d.y.is_finite()) {+                    return Ok(Value::Null);+                }+                let (nx, nc) = match part {+                    CropPart::Frame(h) => {+                        let f = cmd::crop::drag_frame(orig.w, orig.h, h, d);+                        let f = self.snap_crop_rect(f, &orig, Some(h), id, tol, !mods.cmd);+                        let c = cmd::crop::crop_of(f.width(), f.height(), img - f.origin().to_vec2());+                        (cmd::crop::frame_xfrm(&orig, f), c)+                    }+                    CropPart::Image(h) => {+                        let image = cmd::crop::drag_image(img, h, d, mods.shift);+                        let image = self.snap_crop_rect(image, &orig, Some(h), id, tol, !mods.cmd);+                        (orig, cmd::crop::crop_of(orig.w, orig.h, image))+                    }+                    CropPart::Pan => {+                        if mods.shift {+                            if d.x.abs() > d.y.abs() {+                                d.y = 0.0;+                            } else {+                                d.x = 0.0;+                            }+                        }+                        let image = self.snap_crop_rect(img + d, &orig, None, id, tol, !mods.cmd);+                        (orig, cmd::crop::crop_of(orig.w, orig.h, image))+                    }+                };+                // Stored as the renderer draws it, so the next gesture starts from what is shown.+                if let Some(c) = nc.map(|c| c.map(|v| v.clamp(-10.0, 0.99))) {+                    cmd::crop::store(self, "crop", &[(id, Some(nx), c)])?;+                }+            }             Drag::Resize { id, handle, orig, start } => {                 let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);                 let nx = self.snap_resize(nx, &orig, handle, id, tol);diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs--- a/crates/engine/src/tools/geometry.rs+++ b/crates/engine/src/tools/geometry.rs@@ -227,6 +227,29 @@ impl Session {         (off, guides)     } +    /// Snap a crop rectangle in slide coordinates, then return it to the original picture's+    /// local frame. Exclude the picture itself: its fixed crop box is not a snap target.+    pub(super) fn snap_crop_rect(&mut self, r: Rect, orig: &Xfrm, handle: Option<u8>, id: ShapeId, tol: f64, enabled: bool) -> Rect {+        if !enabled || orig.rot != 0.0 {+            self.tool.guides.clear();+            return r;+        }+        let x = cmd::crop::frame_xfrm(orig, r);+        let snapped = if let Some(h) = handle {+            self.snap_resize(x, orig, h, id, tol).rect()+        } else {+            let (off, guides) = self.snap_rect(x.rect(), &[id], tol * 1.5, true);+            self.tool.guides = guides;+            x.rect() + off+        };+        let local = orig.affine().inverse().transform_rect_bbox(snapped);+        if local.width() < cmd::crop::MIN_SIDE || local.height() < cmd::crop::MIN_SIDE {+            self.tool.guides.clear();+            return r;+        }+        local+    }+     /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.     pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {         if x.rot != 0.0 {diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs--- a/crates/engine/src/tools/pointer.rs+++ b/crates/engine/src/tools/pointer.rs@@ -2,7 +2,34 @@ use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_t use super::*;  impl Session {+    /// End crop mode once its picture is no longer the one selected shape (Tab, a slide change,+    /// Undo…), so its handles don't come back when the picture is selected again. Reselecting the+    /// same picture keeps crop mode on.+    pub(crate) fn settle_crop(&mut self) {+        if self.tool.crop.is_some() && self.active().is_none_or(|st| self.tool.cropping(st).is_none()) {+            self.exit_crop();+        }+    }++    /// Leave crop mode. A crop gesture still in progress is finished first, as its own undo step.+    /// Every way out of crop mode comes through here.+    pub(crate) fn exit_crop(&mut self) {+        self.end_crop_drag();+        self.tool.crop = None;+    }++    /// Finish a crop gesture in the presentation it began in, as releasing the pointer does:+    /// one undo step there, and later pointer events can't carry it on. Called wherever the+    /// gesture can be cut short (leaving crop mode, Undo/Redo, another tool, another presentation).+    pub(crate) fn end_crop_drag(&mut self) {+        if matches!(self.tool.drag, Some(Drag::Crop { .. })) {+            self.tool.drag = None;+            self.commit_named("Crop");+        }+    }+     pub fn set_tool(&mut self, kind: ToolKind) {+        self.end_crop_drag();         self.tool.kind = kind;         self.tool.drag = None;         self.tool.preview = None;@@ -12,6 +39,30 @@ impl Session {     /// What's under `p` on the current slide.     pub fn hit_test(&self, p: Point, tol: f64) -> Option<Hit> {         let st = self.active()?;+        // In crop mode the picture's crop handles, then the image's own, then the image itself.+        if let Some(id) = self.tool.cropping(st)+            && let Some(sh) = st.shape(id)+            && let ShapeKind::Picture { fill } = &sh.kind+        {+            let x = xfrm_of(&st.doc, &st.selection, sh);+            let a = x.affine();+            let frame = Rect::new(0.0, 0.0, x.w, x.h);+            for (i, q) in cmd::crop::rect_handles(frame).iter().enumerate() {+                if (a * *q - p).hypot() <= tol * 2.0 {+                    return Some(Hit::Crop { id, handle: i as u8 });+                }+            }+            if let Some(img) = cmd::crop::image_rect(x.w, x.h, fill.crop) {+                for (i, q) in cmd::crop::rect_handles(img).iter().enumerate() {+                    if (a * *q - p).hypot() <= tol * 1.6 {+                        return Some(Hit::CropImage { id, handle: i as u8 });+                    }+                }+                if img.union(frame).contains(a.inverse() * p) {+                    return Some(Hit::CropPan { id });+                }+            }+        }         // In Edit Points, the edited shape's vertices come first.         if let Some(id) = self.tool.editing_points(st)             && let Some(sh) = st.shape(id)@@ -103,6 +154,7 @@ impl Session {         if !matches!(ev.kind, PointerKind::Move) {             self.settle_composition();         }+        self.settle_crop();         match ev.kind {             PointerKind::Move => {                 if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {@@ -265,6 +317,30 @@ impl Session {             return Ok(json!({"caret": [pos.0, pos.1]}));         }         let hit = self.hit_test(p, tol);+        // Crop mode: the crop handles, the image's handles and the image itself; a press anywhere+        // else ends it and goes on as a normal press.+        if let Some(cid) = self.doc().ok().and_then(|st| self.tool.cropping(st)) {+            let part = match hit {+                Some(Hit::Crop { id, handle }) if id == cid => Some(CropPart::Frame(handle)),+                Some(Hit::CropImage { id, handle }) if id == cid => Some(CropPart::Image(handle)),+                Some(Hit::CropPan { id }) if id == cid => Some(CropPart::Pan),+                _ => None,+            };+            let st = self.doc()?;+            let found = st.shape(cid).and_then(|sh| match &sh.kind {+                ShapeKind::Picture { fill } => {+                    let x = xfrm_of(&st.doc, &st.selection, sh);+                    cmd::crop::image_rect(x.w, x.h, fill.crop).map(|img| (x, img))+                }+                _ => None,+            });+            if let (Some(part), Some((orig, img))) = (part, found) {+                self.begin("Crop");+                self.tool.drag = Some(Drag::Crop { id: cid, part, orig, img, start: p });+                return Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}));+            }+            self.exit_crop();+        }         // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;         // a click anywhere else leaves Edit Points.         if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) {@@ -316,8 +392,8 @@ impl Session {                 self.begin("Rotate");                 self.tool.drag = Some(Drag::Rotate { ids, center, start, origs });             }-            // Only reached outside Edit Points (handled above).-            Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {}+            // Only reached outside Edit Points and crop mode (handled above).+            Some(Hit::Vertex { .. } | Hit::Handle { .. } | Hit::Crop { .. } | Hit::CropImage { .. } | Hit::CropPan { .. }) => {}             Some(Hit::Adjust { id, index }) => {                 let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();                 self.begin("Adjust Shape");diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs--- a/crates/ui-egui/src/canvas/context_menu.rs+++ b/crates/ui-egui/src/canvas/context_menu.rs@@ -218,6 +218,10 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) {                 let _ = app.run("app.changePictureDialog", json!({}));                 ui.close();             }+            if picture && crate::ribbon::enabled(app, "picture.cropMode") && ui.button("Crop").clicked() {+                let _ = app.run("picture.cropMode", json!({"on": true}));+                ui.close();+            }             if ui.button("Link…").clicked() {                 app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));                 ui.close();diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs--- a/crates/ui-egui/src/canvas/input.rs+++ b/crates/ui-egui/src/canvas/input.rs@@ -143,6 +143,13 @@ pub(super) fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf             (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,             (ToolKind::TextBox, _) => egui::CursorIcon::Text,             (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,+            (_, Some(Hit::CropPan { .. })) => egui::CursorIcon::Move,+            (_, Some(Hit::Crop { handle, .. } | Hit::CropImage { handle, .. })) => match handle {+                0 | 4 => egui::CursorIcon::ResizeNwSe,+                2 | 6 => egui::CursorIcon::ResizeNeSw,+                1 | 5 => egui::CursorIcon::ResizeVertical,+                _ => egui::CursorIcon::ResizeHorizontal,+            },             (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle),             (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,             (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs--- a/crates/ui-egui/src/canvas/overlays.rs+++ b/crates/ui-egui/src/canvas/overlays.rs@@ -1,5 +1,113 @@ use super::*; +/// Crop mode, as in PowerPoint: the part of the picture outside its box shows faded with round+/// handles at its edges (they scale it), and the box carries black crop marks.+fn crop_overlay(+    ctx: &egui::Context,+    doc: &deckcraft_model::Presentation,+    painter: &egui::Painter,+    xf: Xf,+    x: &Xfrm,+    fill: &deckcraft_model::style::PictureFill,+    t: &Tokens,+) {+    use deckcraft_engine::cmd::crop;+    let a = x.affine();+    let to = |q: Point| xf.to_screen(a * q);+    let frame = deckcraft_geom::Rect::new(0.0, 0.0, x.w, x.h);+    if let Some(img) = crop::image_rect(x.w, x.h, fill.crop) {+        if let Some(tex) = crop_ghost(ctx, doc, fill.media) {+            // The image outside the box, in bands above, below, left and right of it.+            let (y0, y1) = (img.y0.max(frame.y0), img.y1.min(frame.y1));+            let bands = [+                (img.x0, img.y0, img.x1, img.y1.min(frame.y0)),+                (img.x0, img.y0.max(frame.y1), img.x1, img.y1),+                (img.x0, y0, img.x1.min(frame.x0), y1),+                (img.x0.max(frame.x1), y0, img.x1, y1),+            ];+            let mut mesh = egui::Mesh::with_texture(tex.id());+            let tint = Color32::from_white_alpha(120);+            for (bx0, by0, bx1, by1) in bands {+                if bx1 - bx0 <= 1e-6 || by1 - by0 <= 1e-6 {+                    continue;+                }+                let base = mesh.vertices.len() as u32;+                for (px, py) in [(bx0, by0), (bx1, by0), (bx1, by1), (bx0, by1)] {+                    let uv = pos2(((px - img.x0) / img.width()) as f32, ((py - img.y0) / img.height()) as f32);+                    mesh.vertices.push(egui::epaint::Vertex { pos: to(Point::new(px, py)), uv, color: tint });+                }+                mesh.add_triangle(base, base + 1, base + 2);+                mesh.add_triangle(base, base + 2, base + 3);+            }+            painter.add(mesh);+        }+        let ic = [Point::new(img.x0, img.y0), Point::new(img.x1, img.y0), Point::new(img.x1, img.y1), Point::new(img.x0, img.y1)].map(to);+        poly(painter, ic, Stroke::new(1.0, Color32::from_gray(150)));+        for q in crop::rect_handles(img) {+            painter.circle(to(q), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));+        }+    }+    let fc = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(to);+    poly(painter, fc, Stroke::new(1.0, t.selection));+    // Crop marks: an L at each corner and a bar mid-side, along the box's edges.+    let dir = |q: Point, d: deckcraft_geom::Vec2| {+        let v = to(q + d) - to(q);+        if v.length() > 1e-3 { v.normalized() } else { egui::Vec2::ZERO }+    };+    let (ux, uy) = (deckcraft_geom::Vec2::new(1.0, 0.0), deckcraft_geom::Vec2::new(0.0, 1.0));+    let len = 12.0;+    for (i, q) in crop::rect_handles(frame).into_iter().enumerate() {+        let c = to(q);+        let arms: Vec<(Pos2, Pos2)> = match i {+            0 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, uy) * len)],+            2 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, uy) * len)],+            4 => vec![(c, c + dir(q, -ux) * len), (c, c + dir(q, -uy) * len)],+            6 => vec![(c, c + dir(q, ux) * len), (c, c + dir(q, -uy) * len)],+            1 | 5 => vec![(c - dir(q, ux) * len * 0.75, c + dir(q, ux) * len * 0.75)],+            _ => vec![(c - dir(q, uy) * len * 0.75, c + dir(q, uy) * len * 0.75)],+        };+        for (s, e) in &arms {+            painter.line_segment([*s, *e], Stroke::new(6.0, Color32::WHITE));+        }+        for (s, e) in arms {+            painter.line_segment([s, e], Stroke::new(4.0, Color32::BLACK));+        }+    }+}++/// The picture drawn whole, for the faded part outside the box in crop mode. Decoded once per+/// picture and kept at most 1024 px on a side; `None` when it doesn't decode.+fn crop_ghost(ctx: &egui::Context, doc: &deckcraft_model::Presentation, media: deckcraft_model::MediaId) -> Option<egui::TextureHandle> {+    const SIDE: u32 = 1024;+    let item = doc.media(media)?;+    let key = egui::Id::unique(("crop-ghost", std::sync::Arc::as_ptr(&item.data) as usize, item.data.len()));+    if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(key)) {+        return t;+    }+    let tex = deckcraft_render::decode_image(&item.data).and_then(|im| {+        let (w, h) = (im.width(), im.height());+        if w == 0 || h == 0 {+            return None;+        }+        let k = (SIDE as f64 / w.max(h) as f64).min(1.0);+        let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));+        let raw = im.as_raw();+        let mut px = Vec::with_capacity((nw as usize) * (nh as usize) * 4);+        for y in 0..nh {+            let sy = ((y as u64 * h as u64) / nh as u64).min(h as u64 - 1) as usize;+            for x in 0..nw {+                let sx = ((x as u64 * w as u64) / nw as u64).min(w as u64 - 1) as usize;+                let i = (sy * w as usize + sx) * 4;+                px.extend_from_slice(raw.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]));+            }+        }+        let image = egui::ColorImage::from_rgba_unmultiplied([nw as usize, nh as usize], &px);+        Some(ctx.load_texture("crop-ghost", image, egui::TextureOptions::LINEAR))+    });+    ctx.data_mut(|d| d.insert_temp(key, tex.clone()));+    tex+}+ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) {     for i in 0..4 {         p.line_segment([pts[i], pts[(i + 1) % 4]], stroke);@@ -37,6 +145,12 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf:             continue;         }         let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes);+        if app.session.tool.cropping(st) == Some(sh.id)+            && let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind+        {+            crop_overlay(ui.ctx(), &st.doc, painter, xf, &x, fill, t);+            continue;+        }         if app.session.tool.editing_points(st) == Some(sh.id) {             // Edit Points: the outline's vertices are the handles.             // No selection box: the outline and its vertices are what's edited.diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs--- a/crates/ui-egui/src/ribbon/picture_format.rs+++ b/crates/ui-egui/src/ribbon/picture_format.rs@@ -152,19 +152,51 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {     });     arrange_group(app, ui);     group(ui, |ui| {-        let r = big_button(ui, Icon::Crop, "Crop", true);-        egui::Popup::menu(&r).show(|ui| {-            ui.label("Crop to Shape");-            if let Some(p) = shapes_gallery(ui) {-                run(app, "shape.change", json!({"preset": p}));-            }-            ui.separator();-            for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] {-                if ui.button(l).clicked() {-                    run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v}));+        // Split like PowerPoint's: the top enters (or leaves) crop mode, the bottom opens the menu.+        let en = enabled(app, "picture.cropMode");+        let on = app.session.active().is_some_and(|d| app.session.tool.cropping(d).is_some());+        let (main, arrow) = big_button_ex(ui, Icon::Crop, "Crop", en, true, on);+        let main = main.on_hover_text(tip("picture.cropMode", "Crop"));+        if main.clicked() {+            run(app, "picture.cropMode", json!({}));+        }+        if let Some(a) = arrow {+            egui::Popup::menu(&a).show(|ui| {+                if ui.button("Crop").clicked() {+                    run(app, "picture.cropMode", json!({"on": true}));                 }-            }-        });+                ui.menu_button("Crop to Shape", |ui| {+                    if let Some(p) = shapes_gallery(ui) {+                        run(app, "shape.change", json!({"preset": p}));+                    }+                });+                ui.menu_button("Aspect Ratio", |ui| {+                    for (head, ratios) in [+                        ("Square", &["1:1"][..]),+                        ("Portrait", &["2:3", "3:4", "3:5", "4:5"][..]),+                        ("Landscape", &["3:2", "4:3", "5:3", "5:4", "16:9", "16:10"][..]),+                    ] {+                        ui.label(head);+                        for r in ratios {+                            if ui.button(*r).clicked() {+                                run(app, "picture.cropAspect", json!({"ratio": r}));+                            }+                        }+                    }+                });+                ui.separator();+                if ui.button("Fill").on_hover_text("The picture covers the whole box").clicked() {+                    run(app, "picture.cropFill", json!({"mode": "fill"}));+                }+                if ui.button("Fit").on_hover_text("The whole picture shows inside the box").clicked() {+                    run(app, "picture.cropFill", json!({"mode": "fit"}));+                }+                ui.separator();+                if ui.button("Reset Crop").clicked() {+                    run(app, "picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0}));+                }+            });+        }     });     size_group(app, ui); }