diff --git a/crates/engine/src/cmd/crop.rs b/crates/engine/src/cmd/crop.rs new file mode 100644 index 000000000..919c01346 --- /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 { + 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 { + 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 { + 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 { + 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> { + 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, [f64; 4])]) -> Result { + 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; 4]) -> Result { + 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 { + let r = match v { + Value::String(t) => { + let (a, b) = t.split_once([':', '/', 'x'])?; + a.trim().parse::().ok()? / b.trim().parse::().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 { + 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 { + 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 index fdb30f1ed..4c70f1ec4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -44,6 +44,8 @@ pub fn specs() -> Vec { } fn restore(s: &mut Session, redo: bool) -> Result { + // A crop gesture in progress becomes its own step first, so Undo takes exactly that back. + s.end_crop_drag(); 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) }; let Some(e) = from.pop() else { return ok() }; @@ -52,6 +54,7 @@ fn restore(s: &mut Session, redo: bool) -> Result { st.selection = e.selection; st.interaction = None; st.revision += 1; + s.settle_crop(); Ok(json!({"label": e.label})) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5695cc242..244adc78a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.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/shape.rs b/crates/engine/src/cmd/shape.rs index f1a050d1a..82fe64876 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "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 ), @@ -1053,6 +1053,9 @@ fn set_default(s: &mut Session, _p: &Value) -> Result { fn crop(s: &mut Session, p: &Value) -> Result { 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 super::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() { @@ -1060,6 +1063,10 @@ fn crop(s: &mut Session, p: &Value) -> Result { *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 index 38b5aa713..cb523151d 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -57,6 +57,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 index 418bff04d..09b4b4b92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -554,22 +554,50 @@ impl Session { } pub fn set_active(&mut self, i: usize) { 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); } } pub fn add_document(&mut self, d: DocState) -> usize { + let before = self.active_uid(); + self.end_crop_drag(); self.docs.push(d); self.active = Some(self.docs.len() - 1); + self.document_changed(before); self.docs.len() - 1 } pub fn close_document(&mut self, i: usize) { 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); + } + } + fn active_uid(&self) -> Option { + 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) { + 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; @@ -619,6 +647,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) @@ -681,6 +710,7 @@ impl Session { if st.composition.is_some() && owner(&st.selection) != before { self.end_composition(); } + self.settle_crop(); Ok(()) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08cd..510aa8772 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4072,3 +4072,301 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); 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_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()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a7190b734..6dc6ef31e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -125,6 +125,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)] @@ -207,6 +229,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 { @@ -271,6 +301,8 @@ pub struct ToolState { pub guide_hover: Option, /// Pressure from the pointer event being handled, if the device reported one. pressure: Option, + /// The picture in crop mode (Picture Format ▸ Crop): its crop handles replace the box's. + pub crop: Option, } impl ToolState { @@ -288,6 +320,22 @@ impl ToolState { }) } + /// The picture in crop mode, while it is still the one selected shape. + pub fn cropping(&self, st: &crate::DocState) -> Option { + 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 { .. })) @@ -347,7 +395,34 @@ fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f } 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; @@ -357,6 +432,30 @@ impl Session { /// What's under `p` on the current slide. pub fn hit_test(&self, p: Point, tol: f64) -> Option { 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) @@ -448,6 +547,7 @@ impl Session { if !matches!(ev.kind, PointerKind::Move) { self.end_composition(); } + self.settle_crop(); match ev.kind { PointerKind::Move => { if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { @@ -557,6 +657,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)) { @@ -608,8 +732,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"); @@ -764,6 +888,35 @@ 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 c = cmd::crop::crop_of(f.width(), f.height(), img - f.origin().to_vec2()); + (cmd::crop::frame_xfrm(&orig, f), c) + } + CropPart::Image(h) => (orig, cmd::crop::crop_of(orig.w, orig.h, cmd::crop::drag_image(img, h, d, mods.shift))), + CropPart::Pan => { + if mods.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + (orig, cmd::crop::crop_of(orig.w, orig.h, img + d)) + } + }; + // 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); @@ -1202,7 +1355,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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad81..f789a2696 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -758,7 +758,8 @@ 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::Resize { handle, .. })) => match handle { + (_, Some(Hit::CropPan { .. })) => egui::CursorIcon::Move, + (_, Some(Hit::Resize { handle, .. } | Hit::Crop { handle, .. } | Hit::CropImage { handle, .. })) => match handle { 0 | 4 => egui::CursorIcon::ResizeNwSe, 2 | 6 => egui::CursorIcon::ResizeNeSw, 1 | 5 => egui::CursorIcon::ResizeVertical, @@ -790,6 +791,114 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { } } +/// 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 { + const SIDE: u32 = 1024; + let item = doc.media(media)?; + let key = egui::Id::new(("crop-ghost", std::sync::Arc::as_ptr(&item.data) as usize, item.data.len())); + if let Some(t) = ctx.data(|d| d.get_temp::>(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); @@ -827,6 +936,12 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To 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. @@ -1248,6 +1363,10 @@ 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/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3a679dd11..dc76cdd0f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2735,19 +2735,51 @@ 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); }