Danny Shirely d45c6710b7
🎉 Implement independent image bounds resizing (#11430)
* 🎉 Implement independent image bounds resizing

Add canvas resize interaction mode that allows users to resize an
image object's bounding box independently from the underlying bitmap
content without scaling or distortion while holding the Mod key.

AI-assisted-by: gemini-2.5-pro

* ♻️ Address reviewer feedback from elenatorro

- Remove legacy cfh/image-shape? check in shape-has-image-fill?
- Guard bounds-resize with positive dimensions instead of clamping scalev to preserve flipping
- Remove :metadata from transform-attrs in modifiers.cljs
- Restore preserveAspectRatio logic based on keep-ar? in fills.cljs
- Compute source rect against destination rect for raster and SVG fills in WASM renderer

* 🔧 Fix clippy needless borrow warnings in wasm image fills

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-09 15:16:56 +02:00

374 lines
11 KiB
Rust

use skia_safe::{self as skia, Paint, Rect};
pub use super::Color;
use crate::utils::get_image;
use crate::uuid::Uuid;
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
start: (f32, f32),
end: (f32, f32),
opacity: u8,
width: f32,
colors: Vec<Color>,
offsets: Vec<f32>,
}
impl Gradient {
pub fn new(
start: (f32, f32),
end: (f32, f32),
opacity: u8,
width: f32,
stops: &[(Color, f32)],
) -> Self {
let mut gradient = Gradient {
start,
end,
opacity,
colors: vec![],
offsets: vec![],
width,
};
gradient.add_stops(stops);
gradient
}
fn add_stops(&mut self, stops: &[(Color, f32)]) {
let colors = stops.iter().map(|(color, _)| *color);
let offsets = stops.iter().map(|(_, offset)| *offset);
self.colors.extend(colors);
self.offsets.extend(offsets);
}
pub fn start(&self) -> (f32, f32) {
self.start
}
pub fn end(&self) -> (f32, f32) {
self.end
}
pub fn opacity(&self) -> u8 {
self.opacity
}
pub fn width(&self) -> f32 {
self.width
}
pub fn stops(&self) -> impl Iterator<Item = (Color, f32)> + '_ {
self.colors
.iter()
.copied()
.zip(self.offsets.iter().copied())
}
pub fn to_linear_shader(&self, rect: &Rect) -> Option<skia::Shader> {
let start = (
rect.left + self.start.0 * rect.width(),
rect.top + self.start.1 * rect.height(),
);
let end = (
rect.left + self.end.0 * rect.width(),
rect.top + self.end.1 * rect.height(),
);
skia::gradient_shader::linear(
(start, end),
self.colors.as_slice(),
Some(self.offsets.as_slice()),
skia::TileMode::Clamp,
None,
None,
)
}
pub fn to_radial_shader(&self, rect: &Rect) -> Option<skia::Shader> {
let center = skia::Point::new(
rect.left + self.start.0 * rect.width(),
rect.top + self.start.1 * rect.height(),
);
let end = skia::Point::new(
rect.left + self.end.0 * rect.width(),
rect.top + self.end.1 * rect.height(),
);
let direction = end - center;
let distance = (direction.x.powi(2) + direction.y.powi(2)).sqrt();
let angle = direction.y.atan2(direction.x).to_degrees();
// Based on the code from frontend/src/app/main/ui/shapes/gradients.cljs
let mut transform = skia::Matrix::new_identity();
transform.pre_translate((center.x, center.y));
transform.pre_rotate(angle + 90., skia::Point::new(0., 0.));
// We need an extra transform, because in skia radial gradients are circular and we need them to be ellipses if they must adapt to the shape
transform.pre_scale((self.width * rect.width() / rect.height(), 1.), None);
transform.pre_translate((-center.x, -center.y));
skia::gradient_shader::radial(
center,
distance,
self.colors.as_slice(),
Some(self.offsets.as_slice()),
skia::TileMode::Clamp,
None,
Some(&transform),
)
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct ImageFillTransform {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageFill {
id: Uuid,
opacity: u8,
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
}
impl ImageFill {
pub fn new(id: Uuid, opacity: u8, width: i32, height: i32, keep_aspect_ratio: bool) -> Self {
Self {
id,
opacity,
width,
height,
keep_aspect_ratio,
transform: None,
}
}
pub fn new_with_transform(
id: Uuid,
opacity: u8,
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
) -> Self {
Self {
id,
opacity,
width,
height,
keep_aspect_ratio,
transform,
}
}
pub fn id(&self) -> Uuid {
self.id
}
pub fn opacity(&self) -> u8 {
self.opacity
}
pub fn keep_aspect_ratio(&self) -> bool {
self.keep_aspect_ratio
}
pub fn width(&self) -> i32 {
self.width
}
pub fn height(&self) -> i32 {
self.height
}
pub fn transform(&self) -> Option<&ImageFillTransform> {
self.transform.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct SolidColor(pub Color);
#[derive(Debug, Clone, PartialEq)]
pub enum Fill {
Solid(SolidColor),
LinearGradient(Gradient),
RadialGradient(Gradient),
Image(ImageFill),
}
impl Fill {
pub fn opacity(&self) -> f32 {
match self {
Fill::Solid(SolidColor(color)) => color.a() as f32 / 255.0,
Fill::LinearGradient(g) => g.opacity as f32 / 255.0,
Fill::RadialGradient(g) => g.opacity as f32 / 255.0,
Fill::Image(i) => i.opacity as f32 / 255.0,
}
}
pub fn with_full_opacity(&self) -> Fill {
match self {
Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb(
255,
color.r(),
color.g(),
color.b(),
))),
Fill::LinearGradient(g) => Fill::LinearGradient(Gradient {
opacity: 255,
..g.clone()
}),
Fill::RadialGradient(g) => Fill::RadialGradient(Gradient {
opacity: 255,
..g.clone()
}),
Fill::Image(i) => Fill::Image(ImageFill {
opacity: 255,
..i.clone()
}),
}
}
pub fn to_paint(&self, rect: &Rect, anti_alias: bool) -> skia::Paint {
match self {
Self::Solid(SolidColor(color)) => {
let mut p = skia::Paint::default();
p.set_color(*color);
p.set_style(skia::PaintStyle::Fill);
p.set_anti_alias(anti_alias);
p.set_blend_mode(skia::BlendMode::SrcOver);
p
}
Self::LinearGradient(gradient) => {
let mut p = skia::Paint::default();
p.set_shader(gradient.to_linear_shader(rect));
p.set_alpha(gradient.opacity);
p.set_style(skia::PaintStyle::Fill);
p.set_anti_alias(anti_alias);
p.set_blend_mode(skia::BlendMode::SrcOver);
p
}
Self::RadialGradient(gradient) => {
let mut p = skia::Paint::default();
p.set_shader(gradient.to_radial_shader(rect));
p.set_alpha(gradient.opacity);
p.set_style(skia::PaintStyle::Fill);
p.set_anti_alias(anti_alias);
p.set_blend_mode(skia::BlendMode::SrcOver);
p
}
Self::Image(image_fill) => {
let mut p = skia::Paint::default();
p.set_style(skia::PaintStyle::Fill);
p.set_anti_alias(anti_alias);
p.set_blend_mode(skia::BlendMode::SrcOver);
p.set_alpha(image_fill.opacity);
p
}
}
}
}
pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option<skia::Shader> {
match fill {
Fill::Solid(SolidColor(color)) => Some(skia::shaders::color(*color)),
Fill::LinearGradient(gradient) => gradient.to_linear_shader(bounding_box),
Fill::RadialGradient(gradient) => gradient.to_radial_shader(bounding_box),
Fill::Image(image_fill) => {
let mut image_shader = None;
let image = get_image(&image_fill.id);
if let Some(image) = image {
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
// FIXME no image ratio applied, centered to the current rect
let tile_modes = (skia::TileMode::Clamp, skia::TileMode::Clamp);
let image_width = image_fill.width as f32;
let image_height = image_fill.height as f32;
let scale_x = bounding_box.width() / image_width;
let scale_y = bounding_box.height() / image_height;
let scale = scale_x.max(scale_y);
let scaled_width = image_width * scale;
let scaled_height = image_height * scale;
let pos_x = bounding_box.left() - (scaled_width - bounding_box.width()) / 2.0;
let pos_y = bounding_box.top() - (scaled_height - bounding_box.height()) / 2.0;
let mut matrix = skia::Matrix::new_identity();
matrix.pre_translate((pos_x, pos_y));
matrix.pre_scale((scale, scale), None);
let opacity = image_fill.opacity();
let alpha_color = skia::Color4f::new(1.0, 1.0, 1.0, opacity as f32 / 255.0);
let alpha_shader = skia::shaders::color(alpha_color.to_color());
image_shader = image.to_shader(tile_modes, sampling_options, &matrix);
if let Some(shader) = image_shader {
image_shader = Some(skia::shaders::blend(
skia::Blender::mode(skia::BlendMode::DstIn),
shader,
alpha_shader,
));
}
}
image_shader
}
}
}
pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
let mut fills_paint = skia::Paint::default();
if fills.is_empty() {
fills_paint.set_color(skia::Color::TRANSPARENT);
return fills_paint;
}
if fills.len() == 1 {
if let Fill::Solid(SolidColor(color)) = &fills[0] {
fills_paint.set_color(*color);
return fills_paint;
}
}
let mut combined_shader: Option<skia::Shader> = None;
for fill in fills {
let shader = get_fill_shader(fill, &bounding_box);
if let Some(shader) = shader {
combined_shader = match combined_shader {
// Use SrcOver and treat the newly encountered fill as the source (top),
// overlaying it over the previously composed shader (destination/bottom).
// This avoids edge bleed from underlying fills when anti-aliasing causes
// fractional coverage at shape boundaries.
Some(existing_shader) => Some(skia::shaders::blend(
skia::Blender::mode(skia::BlendMode::SrcOver),
shader,
existing_shader,
)),
None => Some(shader),
};
}
}
fills_paint.set_shader(combined_shader);
fills_paint
}
pub fn set_paint_fill(paint: &mut Paint, fill: &Fill, bounding_box: &Rect, remove_alpha: bool) {
if remove_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
return;
}
let shader = get_fill_shader(fill, bounding_box);
if let Some(shader) = shader {
paint.set_shader(shader);
}
}