🐛 Keep the picture in place in the SVG preview of an image crop (#12387)

With the SVG renderer, resizing a shape with an image fill and the mod
key showed the picture scaled during the drag, as in a normal resize.
The crop only showed after releasing the mouse.

The SVG preview applies the resize matrix to the shape node and ignores
the fill transform change carried by the modifiers. Now the pictures of
the shape fill patterns get a matrix that takes them from the old fill
transform to the new one, which undoes that resize and keeps them in
place. This works for shapes and boards.

Closes #12349

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-19 13:40:26 +02:00 committed by GitHub
parent c401151541
commit 4c655e6e9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 100 additions and 8 deletions

View File

@ -9,6 +9,7 @@
image fill). The shape is a window over a picture that keeps its size
and position; the window never flips."
(:require
[app.common.geom.matrix :as gmt]
[app.common.math :as mth]))
(defn crop-axis
@ -36,3 +37,18 @@
:y (/ (- (* y height) dy) height')
:width (/ (* w width) width')
:height (/ (* h height) height')}))
(defn preview-image-matrix
"Return the matrix that takes the picture of a `width` x `height` window
from its box under `transform` to its box under `transform'`, both in
the coordinates of the window before the resize. While dragging, the
SVG preview resizes the whole shape with one matrix; this one, applied
to the picture first, undoes that resize so the picture stays in place."
[transform transform' width height]
(let [x (* (get transform :x 0.0) width)
y (* (get transform :y 0.0) height)
x' (* (get transform' :x 0.0) width)
y' (* (get transform' :y 0.0) height)
sx (/ (get transform' :width 1.0) (get transform :width 1.0))
sy (/ (get transform' :height 1.0) (get transform :height 1.0))]
(gmt/matrix sx 0 0 sy (- x' (* x sx)) (- y' (* y sy)))))

View File

@ -7,6 +7,8 @@
(ns common-tests.geom-image-crop-test
(:require
[app.common.geom.image-crop :as gic]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.math :as mth]
[clojure.test :as t]))
@ -65,3 +67,29 @@
(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)))))))
(t/deftest preview-image-matrix-test
(let [width 400
height 300
tf {:x 0 :y -0.2 :width 1 :height 1.4}]
(t/testing "the picture keeps its place once the window is resized"
(doseq [[dx dy w' h'] [[0 60 400 240]
[0 300 400 100]
[-50 75 500 150]]]
(let [tf' (gic/update-image-transform tf width height dx dy w' h')
matrix (gic/preview-image-matrix tf tf' width height)
;; Resize the preview applies to the whole shape: the old
;; window goes to the new one.
window (-> (gmt/matrix)
(gmt/translate (gpt/point dx dy))
(gmt/scale (gpt/point (/ w' width) (/ h' height))))
[x y w h] (picture-box tf [0 0] [width height])
p1 (gpt/transform (gpt/point x y) (gmt/multiply window matrix))
p2 (gpt/transform (gpt/point (+ x w) (+ y h)) (gmt/multiply window matrix))]
(t/is (close-vals? [x y (+ x w) (+ y h)] [(:x p1) (:y p1) (:x p2) (:y p2)])
(str "window " [dx dy w' h'])))))
(t/testing "no change gives the identity"
(t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix tf tf width height)))
(t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix nil nil width height))))))

View File

@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.image-crop :as gic]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
@ -20,6 +21,7 @@
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.timers :as ts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn get-shape-node
@ -31,6 +33,14 @@
base-node
(dom/query base-node (dm/str "#shape-" id)))))
(defn- get-fill-image-nodes
"Retrieve the pictures of the shape image fills, inside its own fill
patterns"
[shape shape-node]
(when (or (some :fill-image (:fills shape))
(some? (:fill-image shape)))
(dom/query-all shape-node ":scope > defs > pattern image")))
(defn get-nodes
"Retrieve the DOM nodes to apply the matrix transformation"
[base-node {:keys [id parent-id] :as shape}]
@ -43,11 +53,13 @@
masking-child? (:masking-child? (meta shape))]
(cond
frame?
[shape-node
(dom/query shape-node ".frame-children")
(dom/query (dm/str "#thumbnail-container-" id))
(dom/query (dm/str "#thumbnail-" id))
(dom/query (dm/str "#frame-title-" id))]
(d/concat-vec
[shape-node
(dom/query shape-node ".frame-children")
(dom/query (dm/str "#thumbnail-container-" id))
(dom/query (dm/str "#thumbnail-" id))
(dom/query (dm/str "#frame-title-" id))]
(get-fill-image-nodes shape shape-node))
;; For groups we don't want to transform the whole group but only
;; its filters/masks
@ -72,9 +84,38 @@
(dom/query (dm/str "#frame-title-" id)))]
:else
[shape-node
(when (dbg/enabled? :shape-titles)
(dom/query (dm/str "#frame-title-" id)))]))))
(d/concat-vec
[shape-node
(when (dbg/enabled? :shape-titles)
(dom/query (dm/str "#frame-title-" id)))]
(get-fill-image-nodes shape shape-node))))))
(defn- fill-image-matrix
"Return the matrix that keeps the picture of an image fill `node` in
place while `modifiers` change the fill transform (resize with the mod
key). Return nil when the modifiers do not change the fill."
[shape modifiers node]
(let [changes (->> (:structure-parent modifiers)
(filter #(= :change-property (:type %)))
(d/index-by :property :value))
selrect (dm/get-prop shape :selrect)
id (dom/get-attribute node "id")
[transform transform' :as change]
(if (some? id)
;; Fill images have an id ending with the fill index
(when-let [fills' (get changes :fills)]
(let [index (d/parse-integer (last (str/split id "-")))]
[(dm/get-in (nth (:fills shape) index nil) [:fill-image :transform])
(dm/get-in (nth fills' index nil) [:fill-image :transform])]))
(when-let [fill-image' (get changes :fill-image)]
[(dm/get-in shape [:fill-image :transform])
(get fill-image' :transform)]))]
(when (some? change)
(gic/preview-image-matrix transform transform'
(dm/get-prop selrect :width)
(dm/get-prop selrect :height)))))
(defn transform-region!
[node modifiers]
@ -167,6 +208,13 @@
(= (dom/get-tag-name node) "pattern")
(set-transform-att! node "patternTransform" transform)
;; Picture of an image fill: it gets the shape transform
;; through the shape node, and only needs its own when the
;; fill transform changes
(= (dom/get-tag-name node) "image")
(when-let [matrix (fill-image-matrix shape modifiers node)]
(set-transform-att! node "transform" matrix))
(and (some? transform) (some? node))
(set-transform-att! node "transform" transform)))))))