mirror of
https://github.com/penpot/penpot.git
synced 2025-06-28 04:16:59 +02:00
✨ Replace overlapping bubbles with a bubble group (#6059)
This commit is contained in:
parent
0efbebd94f
commit
86022a967c
9 changed files with 299 additions and 46 deletions
|
@ -20,6 +20,7 @@
|
|||
[app.main.data.workspace.drawing :as dwd]
|
||||
[app.main.data.workspace.edition :as dwe]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.zoom :as dwz]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.streams :as ms]
|
||||
|
@ -102,26 +103,6 @@
|
|||
ny (- (:y position) nh)]
|
||||
(update local :vbox assoc :x nx :y ny)))))))
|
||||
|
||||
(defn navigate
|
||||
[thread]
|
||||
(dm/assert!
|
||||
"expected valid comment thread"
|
||||
(dcmt/check-comment-thread! thread))
|
||||
(ptk/reify ::open-comment-thread
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(rx/merge
|
||||
(rx/of (dcm/go-to-workspace :file-id (:file-id thread)
|
||||
:page-id (:page-id thread)))
|
||||
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dcmt/comment-threads-fetched))
|
||||
(rx/take 1)
|
||||
(rx/mapcat #(rx/of (center-to-comment-thread thread)
|
||||
(dwd/select-for-drawing :comments)
|
||||
(with-meta (dcmt/open-thread thread)
|
||||
{::ev/origin "workspace"}))))))))
|
||||
|
||||
(defn update-comment-thread-position
|
||||
([thread [new-x new-y]]
|
||||
(update-comment-thread-position thread [new-x new-y] nil))
|
||||
|
@ -192,6 +173,66 @@
|
|||
(map build-move-event)
|
||||
(rx/from))))))
|
||||
|
||||
(defn overlap-bubbles?
|
||||
"Detect if two bubbles overlap"
|
||||
[zoom thread-1 thread-2]
|
||||
(let [distance (gpt/distance (:position thread-1) (:position thread-2))
|
||||
distance-zoom (* distance zoom)
|
||||
distance-overlap 32]
|
||||
(< distance-zoom distance-overlap)))
|
||||
|
||||
(defn- calculate-zoom-scale-to-ungroup-current-bubble
|
||||
"Calculate the minimum zoom scale needed to keep the current bubble ungrouped from the rest"
|
||||
[zoom thread threads]
|
||||
(let [threads-rest (filterv #(not= (:id %) (:id thread)) threads)
|
||||
zoom-scale-step 1.75]
|
||||
(if (some #(overlap-bubbles? zoom thread %) threads-rest)
|
||||
(calculate-zoom-scale-to-ungroup-current-bubble (* zoom zoom-scale-step) thread threads)
|
||||
zoom)))
|
||||
|
||||
(defn set-zoom-to-separate-grouped-bubbles
|
||||
[thread]
|
||||
(dm/assert!
|
||||
"zoom-to-separate-bubbles"
|
||||
(dcmt/check-comment-thread! thread))
|
||||
(ptk/reify ::set-zoom-to-separate-grouped-bubbles
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [local (:workspace-local state)
|
||||
zoom (:zoom local)
|
||||
page-id (:page-id thread)
|
||||
|
||||
threads-map (:comment-threads state)
|
||||
threads-all (vals threads-map)
|
||||
threads (filterv #(= (:page-id %) page-id) threads-all)
|
||||
|
||||
updated-zoom (calculate-zoom-scale-to-ungroup-current-bubble zoom thread threads)
|
||||
scale-zoom (/ updated-zoom zoom)]
|
||||
|
||||
(rx/of (dwz/set-zoom scale-zoom))))))
|
||||
|
||||
(defn navigate-to-comment-from-dashboard
|
||||
[thread]
|
||||
(dm/assert!
|
||||
"expected valid comment thread"
|
||||
(dcmt/check-comment-thread! thread))
|
||||
(ptk/reify ::navigate-to-comment-from-dashboard
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(rx/merge
|
||||
(rx/of (dcm/go-to-workspace :file-id (:file-id thread)
|
||||
:page-id (:page-id thread)))
|
||||
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? :app.main.data.workspace/workspace-initialized))
|
||||
(rx/observe-on :async)
|
||||
(rx/take 1)
|
||||
(rx/mapcat #(rx/of (dwd/select-for-drawing :comments)
|
||||
(set-zoom-to-separate-grouped-bubbles thread)
|
||||
(center-to-comment-thread thread)
|
||||
(with-meta (dcmt/open-thread thread)
|
||||
{::ev/origin "workspace"}))))))))
|
||||
|
||||
(defn navigate-to-comment
|
||||
[thread]
|
||||
(ptk/reify ::navigate-to-comment
|
||||
|
@ -208,6 +249,7 @@
|
|||
(rx/empty))
|
||||
(->> (rx/of
|
||||
(dwd/select-for-drawing :comments)
|
||||
(set-zoom-to-separate-grouped-bubbles thread)
|
||||
(center-to-comment-thread thread)
|
||||
(with-meta (dcmt/open-thread thread) {::ev/origin "workspace"}))
|
||||
(rx/observe-on :async))))))
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
[app.main.data.comments :as dcm]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.workspace.comments :as dwcm]
|
||||
[app.main.data.workspace.zoom :as dwz]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
|
@ -589,22 +590,25 @@
|
|||
(def ^:private schema:comment-avatar
|
||||
[:map
|
||||
[:class {:optional true} :string]
|
||||
[:image :string]
|
||||
[:image {:optional true} :string]
|
||||
[:variant {:optional true}
|
||||
[:maybe [:enum "read" "unread" "solved"]]]])
|
||||
|
||||
(mf/defc comment-avatar*
|
||||
{::mf/schema schema:comment-avatar}
|
||||
[{:keys [image variant class] :rest props}]
|
||||
[{:keys [image variant class children] :rest props}]
|
||||
(let [variant (or variant "read")
|
||||
class (dm/str class " " (stl/css-case :avatar true
|
||||
:avatar-read (= variant "read")
|
||||
:avatar-unread (= variant "unread")
|
||||
:avatar-solved (= variant "solved")))
|
||||
props (mf/spread-props props {:class class})]
|
||||
class (dm/str class " " (stl/css-case :avatar true
|
||||
:avatar-read (= variant "read")
|
||||
:avatar-unread (= variant "unread")
|
||||
:avatar-solved (= variant "solved")))
|
||||
props (mf/spread-props props {:class class})]
|
||||
|
||||
[:> :div props
|
||||
[:img {:src image
|
||||
:class (stl/css :avatar-image)}]
|
||||
(if image
|
||||
[:img {:src image
|
||||
:class (stl/css :avatar-image)}]
|
||||
[:div {:class (stl/css :avatar-text)} children])
|
||||
[:div {:class (stl/css-case :avatar-mask true
|
||||
:avatar-darken (= variant "solved"))}]]))
|
||||
|
||||
|
@ -1072,22 +1076,83 @@
|
|||
|
||||
[:> mentions-panel*]])]))
|
||||
|
||||
(defn group-bubbles
|
||||
"Group bubbles in different vectors by proximity"
|
||||
([zoom circles]
|
||||
(group-bubbles zoom circles [] []))
|
||||
|
||||
([zoom circles visited groups]
|
||||
(if (empty? circles)
|
||||
groups
|
||||
(let [current (first circles)
|
||||
remaining (rest circles)
|
||||
overlapping-group (some (fn [group]
|
||||
(when (some (partial dwcm/overlap-bubbles? zoom current) group) group))
|
||||
groups)]
|
||||
(if overlapping-group
|
||||
(group-bubbles zoom remaining visited (map (fn [group]
|
||||
(if (= group overlapping-group)
|
||||
(cons current group)
|
||||
group))
|
||||
groups))
|
||||
(group-bubbles zoom remaining visited (cons [current] groups)))))))
|
||||
|
||||
(defn- calculate-zoom-scale-to-ungroup-bubbles
|
||||
"Calculate the minimum zoom scale needed for a group of bubbles to avoid overlap among them"
|
||||
[zoom threads]
|
||||
(let [num-threads (count threads)
|
||||
num-grouped-threads (count (group-bubbles zoom threads))
|
||||
zoom-scale-step 1.75]
|
||||
(if (= num-threads num-grouped-threads)
|
||||
zoom
|
||||
(calculate-zoom-scale-to-ungroup-bubbles (* zoom zoom-scale-step) threads))))
|
||||
|
||||
(mf/defc comment-floating-group*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [thread-group zoom position-modifier]}]
|
||||
(let [positions (mapv :position thread-group)
|
||||
|
||||
position (gpt/center-points positions)
|
||||
position (cond-> position
|
||||
(some? position-modifier)
|
||||
(gpt/transform position-modifier))
|
||||
pos-x (* (:x position) zoom)
|
||||
pos-y (* (:y position) zoom)
|
||||
|
||||
unread? (some #(pos? (:count-unread-comments %)) thread-group)
|
||||
num-threads (str (count thread-group))
|
||||
|
||||
test-id (str/join "-" (map :seqn (sort-by :seqn thread-group)))
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(mf/deps thread-group position)
|
||||
(fn []
|
||||
(let [updated-zoom (calculate-zoom-scale-to-ungroup-bubbles zoom thread-group)
|
||||
scale-zoom (/ updated-zoom zoom)]
|
||||
(st/emit! (dwz/set-zoom position scale-zoom)))))]
|
||||
|
||||
[:div {:style {:top (dm/str pos-y "px")
|
||||
:left (dm/str pos-x "px")}
|
||||
:on-click on-click
|
||||
:class (stl/css :floating-preview-wrapper :floating-preview-bubble)}
|
||||
[:> comment-avatar*
|
||||
{:class (stl/css :avatar-lg)
|
||||
:variant (if unread? "unread" "read")
|
||||
:data-testid (dm/str "floating-thread-bubble-" test-id)}
|
||||
num-threads]]))
|
||||
|
||||
(mf/defc comment-floating-bubble*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [thread zoom is-open on-click origin position-modifier]}]
|
||||
(let [owner (mf/with-memo [thread]
|
||||
(dcm/get-owner thread))
|
||||
base-pos (cond-> (:position thread)
|
||||
|
||||
position (:position thread)
|
||||
position (cond-> position
|
||||
(some? position-modifier)
|
||||
(gpt/transform position-modifier))
|
||||
|
||||
drag? (mf/use-ref nil)
|
||||
was-open? (mf/use-ref nil)
|
||||
|
||||
dragging-ref (mf/use-ref false)
|
||||
start-ref (mf/use-ref nil)
|
||||
|
||||
position (:position thread)
|
||||
frame-id (:frame-id thread)
|
||||
|
||||
state (mf/use-state
|
||||
|
@ -1097,8 +1162,14 @@
|
|||
:new-position-y nil
|
||||
:new-frame-id frame-id}))
|
||||
|
||||
pos-x (floor (* (or (:new-position-x @state) (:x base-pos)) zoom))
|
||||
pos-y (floor (* (or (:new-position-y @state) (:y base-pos)) zoom))
|
||||
pos-x (floor (* (or (:new-position-x @state) (:x position)) zoom))
|
||||
pos-y (floor (* (or (:new-position-y @state) (:y position)) zoom))
|
||||
|
||||
drag? (mf/use-ref nil)
|
||||
was-open? (mf/use-ref nil)
|
||||
|
||||
dragging-ref (mf/use-ref false)
|
||||
start-ref (mf/use-ref nil)
|
||||
|
||||
on-pointer-down
|
||||
(mf/use-fn
|
||||
|
|
|
@ -91,6 +91,17 @@
|
|||
border-radius: $br-circle;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
border-radius: $br-circle;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: $fs-12;
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.avatar-mask {
|
||||
border-radius: $br-circle;
|
||||
position: absolute;
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
on-navigate
|
||||
(mf/use-callback
|
||||
(fn [thread]
|
||||
(st/emit! (-> (dwcm/navigate thread)
|
||||
(st/emit! (-> (dwcm/navigate-to-comment-from-dashboard thread)
|
||||
(with-meta {::ev/origin "dashboard"})))))
|
||||
|
||||
on-read-all
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc comments-layer*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [vbox vport zoom file-id page-id]}]
|
||||
(let [vbox-x (dm/get-prop vbox :x)
|
||||
vbox-y (dm/get-prop vbox :y)
|
||||
|
@ -59,11 +60,18 @@
|
|||
:height (dm/str vport-h "px")}}
|
||||
[:div {:class (stl/css :threads)
|
||||
:style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}}
|
||||
(for [item threads]
|
||||
[:> cmt/comment-floating-bubble* {:thread item
|
||||
:zoom zoom
|
||||
:is-open (= (:id item) (:open local))
|
||||
:key (:seqn item)}])
|
||||
|
||||
(for [thread-group (cmt/group-bubbles zoom threads)]
|
||||
(let [group? (> (count thread-group) 1)
|
||||
thread (first thread-group)]
|
||||
(if group?
|
||||
[:> cmt/comment-floating-group* {:thread-group thread-group
|
||||
:zoom zoom
|
||||
:key (:seqn thread)}]
|
||||
[:> cmt/comment-floating-bubble* {:thread thread
|
||||
:zoom zoom
|
||||
:is-open (= (:id thread) (:open local))
|
||||
:key (:seqn thread)}])))
|
||||
|
||||
(when-let [id (:open local)]
|
||||
(when-let [thread (get threads-map id)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue