🐛 Keep the picture in place when a crop crosses the opposite edge (#12361)

With the mod key, resizing a shape with an image fill crops it: the
shape is a window over a picture that keeps its size and position.
When the dragged edge crossed the opposite one, the crop mode turned
off and a normal resize with a negative scale flipped the shape. The
image transform is relative to the shape, so the picture was mirrored
and shrank a bit more on every crossing until it disappeared.

Keep the crop mode when the edge crosses: the window moves to the
other side of the fixed edge without flipping, the part outside the
picture shows empty, and moving the window back shows the picture
again. The crop math lives in app.common.geom.image-crop and replaces
the image transform code that was duplicated for fills and fill-image.

Closes #12349

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-15 16:23:32 +02:00 committed by GitHub
parent 1783f9380d
commit 98f8b7e625
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 143 additions and 47 deletions

View File

@ -0,0 +1,38 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.image-crop
"Geometry of the image crop (resize with the mod key on a shape with an
image fill). The shape is a window over a picture that keeps its size
and position; the window never flips."
(:require
[app.common.math :as mth]))
(defn crop-axis
"Resize one axis of the crop window. `size` is the current window size,
`origin` the fixed point relative to the window start and `scale` the
resize factor, negative when the dragged edge crosses the fixed point.
Returns `[offset size]`: the new window start relative to the old one,
and the new size, always positive."
[size origin scale]
(let [start (* origin (- 1 scale))
end (+ origin (* (- size origin) scale))]
[(mth/min start end) (mth/abs (- end start))]))
(defn update-image-transform
"Return the image fill transform that keeps the picture in place when
the window of `width` x `height` moves by `dx`, `dy` and gets a size of
`width'` x `height'`. The transform values are relative to the window
size, and a nil transform means the picture fills the window."
[transform width height dx dy width' height']
(let [x (get transform :x 0.0)
y (get transform :y 0.0)
w (get transform :width 1.0)
h (get transform :height 1.0)]
{:x (/ (- (* x width) dx) width')
:y (/ (- (* y height) dy) height')
:width (/ (* w width) width')
:height (/ (* h height) height')}))

View File

@ -0,0 +1,67 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-image-crop-test
(:require
[app.common.geom.image-crop :as gic]
[app.common.math :as mth]
[clojure.test :as t]))
(defn- close-vals?
[a b]
(every? true? (map mth/close? a b)))
(defn- picture-box
"Picture position and size relative to the original window start, for a
window at `offset` with `size` and the given image transform."
[{:keys [x y width height]} [dx dy] [w h]]
[(+ dx (* x w)) (+ dy (* y h)) (* width w) (* height h)])
(t/deftest crop-axis-test
(t/testing "shrinks the window towards the fixed edge"
;; Top handler: the bottom edge (300) stays fixed.
(t/is (close-vals? [60 240] (gic/crop-axis 300 300 0.8))))
(t/testing "grows the window away from the fixed edge"
(t/is (close-vals? [-60 360] (gic/crop-axis 300 300 1.2))))
(t/testing "crossing the fixed edge moves the window to the other side"
;; Top handler dragged 100px below the bottom edge.
(t/is (close-vals? [300 100] (gic/crop-axis 300 300 (/ -1 3))))
;; Bottom handler dragged 150px above the top edge.
(t/is (close-vals? [-150 150] (gic/crop-axis 300 0 -0.5))))
(t/testing "resize from the center keeps the center when crossing"
(t/is (close-vals? [75 150] (gic/crop-axis 300 150 -0.5)))))
(t/deftest update-image-transform-test
(let [width 400
height 300
tf {:x 0 :y -0.2 :width 1 :height 1.4}]
(t/testing "the picture keeps its position and size"
(doseq [[dx dy w' h'] [[0 60 400 240]
[0 300 400 100]
[0 -150 400 150]
[-50 75 500 150]]]
(let [tf' (gic/update-image-transform tf width height dx dy w' h')]
(t/is (close-vals? (picture-box tf [0 0] [width height])
(picture-box tf' [dx dy] [w' h']))
(str "window " [dx dy w' h'])))))
(t/testing "a missing transform means the picture fills the window"
(t/is (close-vals? [0 -0.25 1 1.25]
((juxt :x :y :width :height)
(gic/update-image-transform nil 400 300 0 60 400 240)))))
(t/testing "moving the window back restores the original transform"
(let [[dy h'] (gic/crop-axis height height (/ -1 3))
crossed (gic/update-image-transform tf width height 0 dy width h')
[dy2 h2] (gic/crop-axis h' 0 (/ height h' -1))
back (gic/update-image-transform crossed width h' 0 dy2 width h2)]
(t/is (close-vals? [(- dy) height] [dy2 h2]) "window back at its original place")
(t/is (close-vals? ((juxt :x :y :width :height) tf)
((juxt :x :y :width :height) back)))))))

View File

@ -35,6 +35,7 @@
[common-tests.geom-grid-layout-test]
[common-tests.geom-grid-test]
[common-tests.geom-image-bounds-resize-test]
[common-tests.geom-image-crop-test]
[common-tests.geom-line-test]
[common-tests.geom-modif-tree-test]
[common-tests.geom-modifiers-test]
@ -138,6 +139,7 @@
'common-tests.geom-grid-layout-test
'common-tests.geom-grid-test
'common-tests.geom-image-bounds-resize-test
'common-tests.geom-image-crop-test
'common-tests.geom-line-test
'common-tests.geom-modif-tree-test
'common-tests.geom-modifiers-test

View File

@ -11,6 +11,7 @@
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.image-crop :as gic]
[app.common.geom.matrix :as gmt]
[app.common.geom.modifiers :as gm]
[app.common.geom.point :as gpt]
@ -212,10 +213,29 @@
;; Resize origin point given the selected handler
selrect (dm/get-prop shape :selrect)
handler-origin (get-handler-resize-origin selrect handler)
selrect-start (gpt/point (dm/get-prop selrect :x) (dm/get-prop selrect :y))
;; Image crop (mod key): the shape is a window over a
;; picture that keeps its size and position. Dragging an
;; edge past the fixed one moves the window to the other
;; side instead of flipping it.
crop-origin (if ^boolean center?
(gpt/point (/ width 2) (/ height 2))
(gpt/subtract handler-origin selrect-start))
[crop-dx crop-w] (gic/crop-axis width (dm/get-prop crop-origin :x) (dm/get-prop scalev :x))
[crop-dy crop-h] (gic/crop-axis height (dm/get-prop crop-origin :y) (dm/get-prop scalev :y))
scalev (if ^boolean bounds-resize?
(gpt/point (/ crop-w width) (/ crop-h height))
scalev)
;; If we want resize from center, displace the shape
;; so it is still centered after resize.
displacement (when ^boolean center?
displacement (cond
^boolean bounds-resize?
(gpt/transform (gpt/point crop-dx crop-dy) shape-transform)
^boolean center?
(-> shape-center
(gpt/subtract handler-origin)
(gpt/multiply scalev)
@ -224,7 +244,10 @@
(gpt/multiply (gpt/point -1 -1))
(gpt/transform shape-transform)))
resize-origin (gmt/transform-point-center handler-origin shape-center shape-transform)
resize-origin (gmt/transform-point-center
(if ^boolean bounds-resize? selrect-start handler-origin)
shape-center
shape-transform)
resize-origin (if (some? displacement)
(gpt/add resize-origin displacement)
resize-origin)
@ -242,57 +265,23 @@
change-height?
(not (mth/close? (dm/get-prop scalev :y) 1))
;; Calculate independent image bounds resize transform
sx (dm/get-prop scalev :x)
sy (dm/get-prop scalev :y)
w-new (* width sx)
h-new (* height sy)
bounds-resize? (and ^boolean bounds-resize?
(pos? w-new)
(pos? h-new))
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
;; Keep the picture in place inside the new crop window
update-image
(fn [img-fill]
(update img-fill :transform gic/update-image-transform
width height crop-dx crop-dy crop-w crop-h))
new-fills
(when (and bounds-resize? (seq (:fills shape)))
(when (and ^boolean bounds-resize? (seq (:fills shape)))
(mapv (fn [fill]
(if-let [img-fill (:fill-image fill)]
(let [tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc-in fill [:fill-image :transform]
{:x nx' :y ny' :width nw' :height nh'}))
fill))
(cond-> fill
(some? (:fill-image fill))
(update :fill-image update-image)))
(:fills shape)))
new-fill-image
(when (and bounds-resize? (some? (:fill-image shape)))
(let [img-fill (:fill-image shape)
tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))]
(when (and ^boolean bounds-resize? (some? (:fill-image shape)))
(update-image (:fill-image shape)))]
(cond-> (ctm/empty)
(some? displacement)