M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) (#1501)

* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464)

* M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464)

* M1: the handle overlay draws gradients and crops in their own helpers (#1464)
This commit is contained in:
BFlat
2026-10-29 15:07:21 +07:00
committed by GitHub
parent 7c9184b023
commit 7ba22fb2b6
22 changed files with 573 additions and 81 deletions
+46 -4
View File
@@ -1,12 +1,12 @@
//! Image paints: adjustment presets, built in and saved in the file.
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
use crate::{Result, Session, bad, spec};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!(
"image.crop",
"Crop image",
"{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it",
true,
crop
),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
@@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.crop";
let ids = req_targets(cmd, s, p)?;
// A selection with nothing to crop is refused as unavailable, not as bad input.
let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into());
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?;
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
// Like any paint edit, the layer leaves its paint style.
n.styles.fill = None;
}
s.request_ui(cmd, json!({"id": id, "index": index}));
Ok(json!({"id": id, "index": index}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();