🎉 Avoid interacting with clipped content (#11613)

* 🐛 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.

* 🐛 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.
This commit is contained in:
Eva Marco 2026-09-11 13:42:13 +02:00 committed by GitHub
parent 7ff76a9ebc
commit f3da8af7b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 83 additions and 5 deletions

View File

@ -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

View File

@ -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)))))

View File

@ -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)))