From f3da8af7b64eff94b87b00083c887e3d4f6971fc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 11 Sep 2026 13:42:13 +0200 Subject: [PATCH] :tada: Avoid interacting with clipped content (#11613) * :bug: Fix nested board drop target ignoring ancestor clip bounds Frame hit-testing (get-frame-by-position, get-frames-by-position and top-nested-frame) only checked a candidate board's own rectangle, without accounting for an ancestor board with clip content enabled. A nested board wider/taller than its clipping ancestor could still be picked as the drop target in its invisible, clipped-away area, so a dragged shape would get reparented there and disappear from view. Add clipped-by-ancestor? to reject a point when it falls outside the bounds of any ancestor board that has clip content enabled, so the lookup now stops at the correct visible ancestor instead of descending into the hidden region. * :bug: Fix Ctrl+click deep-select reaching into clipped board area The clip-aware quadtree query (query-index) filters candidate shapes by whether they overlap every clip-parent ancestor, but the whole filter was skipped whenever clip-children? was false. That flag is turned off while a modifier key (Ctrl/Cmd) is held for deep/penetrate selection, which was meant to let it reach past boolean/mask clip boundaries, but it also disabled enforcement for board "Clip content" ancestors, letting a modifier-held click select a shape sitting in a board's invisible, clipped-away region. overlaps-parent? now only relaxes the check for non-frame clip-parents (bool shapes / mask children) when clip-children? is false; board clip ancestors are always enforced regardless of the modifier key. --- common/src/app/common/types/shape_tree.cljc | 18 +++++- .../common_tests/types_shape_tree_test.cljc | 58 +++++++++++++++++++ frontend/src/app/worker/selection.cljs | 12 ++-- 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 common/test/common_tests/types_shape_tree_test.cljc diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 92a889a6d5..90109d5b46 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -272,6 +272,20 @@ -1)))) items)))) +(defn- clipped-by-ancestor? + "Checks whether position falls outside the visible (clipped) bounds of + some ancestor frame with clip content enabled. Used so that a nested + frame that extends beyond a clipping ancestor's own bounds is never + considered hit/reachable in the invisible, clipped-away region." + [objects shape position] + (->> (cfh/get-parent-ids objects (dm/get-prop shape :id)) + (keep (d/getf objects)) + (some (fn [ancestor] + (and (not= (dm/get-prop ancestor :id) uuid/zero) + ^boolean (cfh/frame-shape? ancestor) + (not (:show-content ancestor)) + (not ^boolean (gsh/has-point? ancestor position))))))) + (defn get-frame-by-position ([objects position] (get-frame-by-position objects position nil)) @@ -287,6 +301,7 @@ validator (or (get options :validator) #(-> true))] (or (d/seek #(and ^boolean (some? position) ^boolean (gsh/has-point? % position) + ^boolean (not (clipped-by-ancestor? objects % position)) ^boolean (validator %)) frames) (get objects uuid/zero))))) @@ -302,7 +317,8 @@ ([objects position options] (->> (get-frames objects options) (filter #(and ^boolean (some? position) - ^boolean (gsh/has-point? % position))) + ^boolean (gsh/has-point? % position) + ^boolean (not (clipped-by-ancestor? objects % position)))) (sort-z-index-objects objects)))) (defn top-nested-frame diff --git a/common/test/common_tests/types_shape_tree_test.cljc b/common/test/common_tests/types_shape_tree_test.cljc new file mode 100644 index 0000000000..423dbce37d --- /dev/null +++ b/common/test/common_tests/types_shape_tree_test.cljc @@ -0,0 +1,58 @@ +;; 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 INC Sucursal en EspaƱa SL + +(ns common-tests.types-shape-tree-test + (:require + [app.common.geom.point :as gpt] + [app.common.types.shape-tree :as ctt] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- make-frame + [id parent-id shapes x y width height show-content] + {:id id + :type :frame + :parent-id parent-id + :frame-id parent-id + :shapes (vec shapes) + :x x + :y y + :width width + :height height + :rotation nil + :hidden false + :blocked false + :show-content show-content}) + +(t/deftest top-nested-frame-clip-content-test + (t/testing "board A (clip) contains a wider board B; point inside both resolves to B" + (let [a-id (uuid/next) + b-id (uuid/next) + objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false) + b-id (make-frame b-id a-id [] 50 50 300 300 false)} + position (gpt/point 150 150) + result (ctt/top-nested-frame objects position)] + (t/is (= b-id result)))) + + (t/testing "point inside B but outside A's clipped bounds is not reachable at all" + (let [a-id (uuid/next) + b-id (uuid/next) + objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false) + b-id (make-frame b-id a-id [] 50 50 300 300 false)} + position (gpt/point 300 300) + result (ctt/top-nested-frame objects position)] + ;; Outside A (the clip ancestor) and B's visible/clipped region there is + ;; not visible either, so no frame should be resolved at that point. + (t/is (= uuid/zero result)))) + + (t/testing "with show-content true on A, the same point can resolve into B" + (let [a-id (uuid/next) + b-id (uuid/next) + objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 true) + b-id (make-frame b-id a-id [] 50 50 300 300 false)} + position (gpt/point 300 300) + result (ctt/top-nested-frame objects position)] + (t/is (= b-id result))))) diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index 4fc1039be8..d5a5ee06a0 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -245,16 +245,20 @@ overlaps-parent? (fn [clip-parents] - (->> clip-parents (some (comp not overlaps?)) not))] + (->> clip-parents + ;; When clip-children? is false (e.g. deep/penetrate selection with + ;; a modifier key held) we still must not reach into the clipped-away, + ;; invisible area of an ancestor board with clip content enabled. + ;; Only the bool/mask clip-parents (non-frame) are relaxed in that case. + (remove #(and (not clip-children?) (not ^boolean (cfh/frame-shape? %)))) + (every? overlaps?)))] ;; Shapes after filters of overlapping and criteria (into (d/ordered-set) (comp (map #(unchecked-get % "data")) (filter match-criteria?) (filter overlaps?) - (filter (if clip-children? - (comp overlaps-parent? :clip-parents) - (constantly true))) + (filter (comp overlaps-parent? :clip-parents)) (keep :id)) result)))