From 4c655e6e9e75f8b5499308ca7b6e9f2ba5eb48eb Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 13:40:26 +0200 Subject: [PATCH] :bug: 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 --- common/src/app/common/geom/image_crop.cljc | 16 +++++ .../common_tests/geom_image_crop_test.cljc | 28 ++++++++ .../shapes/frame/dynamic_modifiers.cljs | 64 ++++++++++++++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/common/src/app/common/geom/image_crop.cljc b/common/src/app/common/geom/image_crop.cljc index fdd9f89527..15ad24b9bd 100644 --- a/common/src/app/common/geom/image_crop.cljc +++ b/common/src/app/common/geom/image_crop.cljc @@ -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))))) diff --git a/common/test/common_tests/geom_image_crop_test.cljc b/common/test/common_tests/geom_image_crop_test.cljc index cdd30e444f..31c686fbab 100644 --- a/common/test/common_tests/geom_image_crop_test.cljc +++ b/common/test/common_tests/geom_image_crop_test.cljc @@ -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)))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 237ce41434..ace4f726a7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -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)))))))