0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-15 17:21:17 -05:00

Merge pull request #229 from uxbox/243/advanced_dynamic_alignment

Adds snap to distance
This commit is contained in:
Andrey Antukh 2020-05-28 15:33:29 +02:00 committed by GitHub
commit 751bd5e3d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 422 additions and 26 deletions

View file

@ -110,6 +110,33 @@
[mfn coll]
(into {} (map (fn [[key val]] [key (mfn key val)]) coll)))
(defn map-perm
"Maps a function to each pair of values that can be combined inside the
function without repetition.
Example:
(map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]"
[mfn coll]
(if (empty? coll)
[]
(core/concat
(map (partial mfn (first coll)) (rest coll))
(map-perm mfn (rest coll)))))
(defn join
"Returns a new collection with the cartesian product of both collections.
For example:
(join [1 2 3] [:a :b]) => ([1 :a] [1 :b] [2 :a] [2 :b] [3 :a] [3 :b])
You can pass a function to merge the items. By default is `vector`:
(join [1 2 3] [1 10 100] *) => (1 10 100 2 20 200 3 30 300)"
([col1 col2] (join col1 col2 vector '()))
([col1 col2 join-fn] (join col1 col2 join-fn '()))
([col1 col2 join-fn acc]
(cond
(empty? col1) acc
(empty? col2) acc
:else (recur (rest col1) col2 join-fn
(core/concat acc (map (partial join-fn (first col1)) col2))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Parsing / Conversion
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -73,12 +73,22 @@
(defn min
[{x1 :x y1 :y :as p1} {x2 :x y2 :y :as p2}]
(Point. (c/min x1 x2) (c/min y1 y2)))
([] (min nil nil))
([p1] (min p1 nil))
([{x1 :x y1 :y :as p1} {x2 :x y2 :y :as p2}]
(cond
(nil? p1) p2
(nil? p2) p1
:else (Point. (c/min x1 x2) (c/min y1 y2)))))
(defn max
[{x1 :x y1 :y :as p1} {x2 :x y2 :y :as p2}]
(Point. (c/max x1 x2) (c/max y1 y2)))
([] (max nil nil))
([p1] (max p1 nil))
([{x1 :x y1 :y :as p1} {x2 :x y2 :y :as p2}]
(cond
(nil? p1) p2
(nil? p2) p1
:else (Point. (c/max x1 x2) (c/max y1 y2)))))
(defn inverse
[{:keys [x y] :as p}]

View file

@ -48,7 +48,11 @@
(update :y inc-y))))]
(-> shape
(update :x inc-x)
(update :x1 inc-x)
(update :x2 inc-x)
(update :y inc-y)
(update :y1 inc-y)
(update :y2 inc-y)
(update-in [:selrect :x] inc-x)
(update-in [:selrect :x1] inc-x)
(update-in [:selrect :x2] inc-x)
@ -548,7 +552,6 @@
:type :rect}]
(overlaps? shape selrect)))
(defn calculate-rec-path-skew-angle
[path-shape]
(let [p1 (get-in path-shape [:segments 2])
@ -587,6 +590,63 @@
rot-sign (if (> (* (:y v1) (:x v2)) (* (:x v1) (:y v2))) -1 1)]
(* rot-sign rot-angle)))
(defn pad-selrec
([selrect] (pad-selrec selrect 1))
([selrec size]
(let [inc #(+ % size)
dec #(- % size)]
(-> selrec
(update :x dec)
(update :y dec)
(update :x1 dec)
(update :y1 dec)
(update :x2 inc)
(update :y2 inc)
(update :width (comp inc inc))
(update :height (comp inc inc))))))
(defn selrect->areas [bounds selrect]
(let [make-selrect
(fn [x1 y1 x2 y2]
{:x1 x1 :y1 y1 :x2 x2 :y2 y2 :x x1 :y y1
:width (- x2 x1) :height (- y2 y1) :type :rect})
{frame-x1 :x1 frame-x2 :x2 frame-y1 :y1 frame-y2 :y2
frame-width :width frame-height :height} bounds
{sr-x1 :x1 sr-x2 :x2 sr-y1 :y1 sr-y2 :y2
sr-width :width sr-height :height} selrect]
{:left (make-selrect frame-x1 sr-y1 sr-x1 sr-y2)
:top (make-selrect sr-x1 frame-y1 sr-x2 sr-y1)
:right (make-selrect sr-x2 sr-y1 frame-x2 sr-y2)
:bottom (make-selrect sr-x1 sr-y2 sr-x2 frame-y2)}))
(defn distance-selrect [selrect other]
(let [{:keys [x1 y1]} other
{:keys [x2 y2]} selrect]
(gpt/point (- x1 x2) (- y1 y2))))
(defn distance-shapes [shape other]
(distance-selrect (:selrect shape) (:selrect other)))
(defn overlap-coord?
"Checks if two shapes overlap in one axis"
[coord shape other]
(let [[s1c1 s1c2 s2c1 s2c2]
;; If checking if overlaps in x-axis we need to check the y
;; coordinates, and the other way around
(if (= coord :x)
[(get-in shape [:selrect :y1])
(get-in shape [:selrect :y2])
(get-in other [:selrect :y1])
(get-in other [:selrect :y2])]
[(get-in shape [:selrect :x1])
(get-in shape [:selrect :x2])
(get-in other [:selrect :x1])
(get-in other [:selrect :x2])])]
(or (and (>= s2c1 s1c1) (<= s2c1 s1c2))
(and (>= s2c2 s1c1) (<= s2c2 s1c2))
(and (>= s1c1 s2c1) (<= s1c1 s2c2))
(and (>= s1c2 s2c1) (<= s1c2 s2c2)))))
(defn transform-shape-point
"Transform a point around the shape center"
[point shape transform]

View file

@ -219,6 +219,7 @@
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get state :current-page-id)
objects (get-in state [:workspace-data page-id :objects])
shapes (mapv #(get-in state [:workspace-data page-id :objects %]) ids)
stopper (rx/filter ms/mouse-up? stream)
layout (get state :workspace-layout)]
@ -226,7 +227,7 @@
(->> ms/mouse-position
(rx/take-until stopper)
(rx/map #(gpt/to-vec from-position %))
(rx/switch-map #(snap/closest-snap-move page-id shapes layout %))
(rx/switch-map #(snap/closest-snap-move page-id shapes objects layout %))
(rx/map gmt/translate-matrix)
(rx/map #(set-modifiers ids {:displacement %})))

View file

@ -9,14 +9,18 @@
(ns uxbox.main.snap
(:require
[clojure.set :as set]
[beicon.core :as rx]
[uxbox.common.uuid :refer [zero]]
[uxbox.common.math :as mth]
[uxbox.common.data :as d]
[uxbox.common.geom.point :as gpt]
[uxbox.common.geom.shapes :as gsh]
[uxbox.main.worker :as uw]
[uxbox.util.geom.snap-points :as sp]))
(def ^:private snap-accuracy 5)
(def ^:private snap-distance-accuracy 10)
(defn- remove-from-snap-points [remove-id?]
(fn [query-result]
@ -77,16 +81,91 @@
(rx/map (remove-from-snap-points filter-shapes))
(rx/map (get-min-distance-snap points coord)))))
(defn snap->vector [[from-x to-x] [from-y to-y]]
(when (or from-x to-x from-y to-y)
(let [from (gpt/point (or from-x 0) (or from-y 0))
to (gpt/point (or to-x 0) (or to-y 0))]
(gpt/to-vec from to))))
(defn- closest-snap
[page-id frame-id points filter-shapes]
(let [snap-x (search-snap page-id frame-id points :x filter-shapes)
snap-y (search-snap page-id frame-id points :y filter-shapes)
snap-as-vector (fn [[from-x to-x] [from-y to-y]]
(let [from (gpt/point (or from-x 0) (or from-y 0))
to (gpt/point (or to-x 0) (or to-y 0))]
(gpt/to-vec from to)))]
snap-y (search-snap page-id frame-id points :y filter-shapes)]
;; snap-x is the second parameter because is the "source" to combine
(rx/combine-latest snap-as-vector snap-y snap-x)))
(rx/combine-latest snap->vector snap-y snap-x)))
(defn search-snap-distance [selrect coord shapes-lt shapes-gt]
(let [dist (fn [[sh1 sh2]] (-> sh1 (gsh/distance-shapes sh2) coord))
dist-lt (fn [other] (-> (:selrect other) (gsh/distance-selrect selrect) coord))
dist-gt (fn [other] (-> selrect (gsh/distance-selrect (:selrect other)) coord))
;; Calculates the distance between all the shapes given as argument
inner-distance (fn [shapes]
(->> shapes
(sort-by coord)
(d/map-perm vector)
(filter (fn [[sh1 sh2]] (gsh/overlap-coord? coord sh1 sh2)))
(map dist)
(filter #(> % 0))))
;; Calculates the snap distance when in the middle of two shapes
between-snap (fn [[sh-lt sh-gt]]
;; To calculate the middle snap.
;; Given x, the distance to a left shape and y to a right shape
;; x - v = y + v => v = (x - y)/2
;; v will be the vector that we need to move the shape so it "snaps"
;; in the middle
(/ (- (dist-gt sh-gt)
(dist-lt sh-lt)) 2))
]
(->> shapes-lt
(rx/combine-latest vector shapes-gt)
(rx/map (fn [[shapes-lt shapes-gt]]
(let [;; Distance between the elements in an area, these are the snap
;; candidates to either side
lt-cand (inner-distance shapes-lt)
gt-cand (inner-distance shapes-gt)
;; Distance between the elements to either side and the current shape
;; this is the distance that will "snap"
lt-dist (map dist-lt shapes-lt)
gt-dist (map dist-gt shapes-gt)
;; Calculate the snaps, we need to reverse depending on area
lt-snap (d/join lt-cand lt-dist -)
gt-snap (d/join gt-dist gt-cand -)
;; Calculate snap-between
between-snap (->> (d/join shapes-lt shapes-gt)
(map between-snap))
;; Search the minimum snap
min-snap (->> (concat lt-snap gt-snap between-snap)
(filter #(<= (mth/abs %) snap-distance-accuracy))
(reduce min ##Inf))]
(if (mth/finite? min-snap) [0 min-snap] nil))
)))))
(defn select-shapes-area [page-id shapes objects area-selrect]
(->> (uw/ask! {:cmd :selection/query
:page-id page-id
:rect area-selrect})
(rx/map #(set/difference % (into #{} (map :id shapes))))
(rx/map (fn [ids] (map #(get objects %) ids)))))
(defn closest-distance-snap [page-id shapes objects movev]
(->> (rx/of shapes)
(rx/map #(vector (->> % first :frame-id (get objects))
(-> % gsh/selection-rect (gsh/move movev))))
(rx/merge-map
(fn [[frame selrect]]
(let [areas (->> (gsh/selrect->areas (:selrect frame) selrect)
(d/mapm #(select-shapes-area page-id shapes objects %2)))
snap-x (search-snap-distance selrect :x (:left areas) (:right areas))
snap-y (search-snap-distance selrect :y (:top areas) (:bottom areas))]
(rx/combine-latest snap->vector snap-y snap-x))))))
(defn closest-snap-point
[page-id shapes layout point]
@ -98,11 +177,12 @@
(or (filter-shapes id)
(not (contains? layout :dynamic-alignment)))))]
(->> (closest-snap page-id frame-id [point] filter-shapes)
(rx/map #(or % (gpt/point 0 0)))
(rx/map #(gpt/add point %))
(rx/map gpt/round))))
(defn closest-snap-move
[page-id shapes layout movev]
[page-id shapes objects layout movev]
(let [frame-id (snap-frame-id shapes)
filter-shapes (into #{} (map :id shapes))
filter-shapes (fn [id] (if (= id :layout)
@ -116,6 +196,9 @@
;; Move the points in the translation vector
(map #(gpt/add % movev)))]
(->> (closest-snap page-id frame-id shapes-points filter-shapes)
(rx/map #(gpt/add movev %))
(rx/map gpt/round))))
(->> (rx/merge (closest-snap page-id frame-id shapes-points filter-shapes)
(when (contains? layout :dynamic-alignment)
(closest-distance-snap page-id shapes objects movev)))
(rx/reduce gpt/min)
(rx/map #(or % (gpt/point 0 0)))
(rx/map #(gpt/add movev %)))))

View file

@ -0,0 +1,212 @@
(ns uxbox.main.ui.workspace.snap-distances
(:require
[rumext.alpha :as mf]
[beicon.core :as rx]
[uxbox.common.uuid :as uuid]
[uxbox.main.refs :as refs]
[uxbox.main.snap :as snap]
[uxbox.util.geom.snap-points :as sp]
[uxbox.common.geom.point :as gpt]
[cuerdas.core :as str]
[uxbox.common.pages :as cp]
[uxbox.common.data :as d]
[uxbox.common.geom.shapes :as gsh]
[uxbox.common.math :as mth]
[uxbox.main.worker :as uw]
[clojure.set :as set]))
(def ^:private line-color "#D383DA")
(def ^:private segment-gap 2)
(def ^:private segment-gap-side 5)
(defn selected->cross-selrec [frame selrect coord]
(let [areas (gsh/selrect->areas (:selrect frame) selrect)]
(if (= :x coord)
[(gsh/pad-selrec (:left areas))
(gsh/pad-selrec (:right areas))]
[(gsh/pad-selrec (:top areas))
(gsh/pad-selrec (:bottom areas))])))
(defn half-point
"Calculates the middle point of the overlap between two selrects in the opposite axis"
[coord sr1 sr2]
(let [c1 (max (get sr1 (if (= :x coord) :y1 :x1))
(get sr2 (if (= :x coord) :y1 :x1)))
c2 (min (get sr1 (if (= :x coord) :y2 :x2))
(get sr2 (if (= :x coord) :y2 :x2)))
half-point (+ c1 (/ (- c2 c1) 2))]
half-point))
(mf/defc shape-distance-segment
"Displays a segment between two selrects with the distance between them"
[{:keys [sr1 sr2 coord zoom]}]
(let [from-c (min (get sr1 (if (= :x coord) :x2 :y2))
(get sr2 (if (= :x coord) :x2 :y2)))
to-c (max (get sr1 (if (= :x coord) :x1 :y1))
(get sr2 (if (= :x coord) :x1 :y1)))
distance (mth/round (- to-c from-c))
half-point (half-point coord sr1 sr2)]
[:g.distance-segment
(let [point [(+ from-c (/ distance 2))
(if (= coord :x)
(- half-point (/ 3 zoom))
(+ half-point (/ 5 zoom)))]
[x y] (if (= :x coord) point (reverse point))]
[:text {:x x
:y y
:font-size (/ 12 zoom)
:fill line-color
:text-anchor (if (= coord :x) "middle" "left")}
(mth/round distance)])
(let [p1 [(+ from-c (/ segment-gap zoom)) (+ half-point (/ segment-gap-side zoom))]
p2 [(+ from-c (/ segment-gap zoom)) (- half-point (/ segment-gap-side zoom))]
[x1 y1] (if (= :x coord) p1 (reverse p1))
[x2 y2] (if (= :x coord) p2 (reverse p2))]
[:line {:x1 x1 :y1 y1
:x2 x2 :y2 y2
:style {:stroke line-color :stroke-width (str (/ 1 zoom))}}])
(let [p1 [(- to-c (/ segment-gap zoom)) (+ half-point (/ segment-gap-side zoom))]
p2 [(- to-c (/ segment-gap zoom)) (- half-point (/ segment-gap-side zoom))]
[x1 y1] (if (= :x coord) p1 (reverse p1))
[x2 y2] (if (= :x coord) p2 (reverse p2))]
[:line {:x1 x1 :y1 y1
:x2 x2 :y2 y2
:style {:stroke line-color :stroke-width (str (/ 1 zoom))}}])
(let [p1 [(+ from-c (/ segment-gap zoom)) half-point]
p2 [(- to-c (/ segment-gap zoom)) half-point]
[x1 y1] (if (= :x coord) p1 (reverse p1))
[x2 y2] (if (= :x coord) p2 (reverse p2))]
[:line {:x1 x1 :y1 y1
:x2 x2 :y2 y2
:style {:stroke line-color :stroke-width (str (/ 1 zoom))}}])]))
(mf/defc shape-distance [{:keys [frame selrect page-id zoom coord selected]}]
(let [subject (mf/use-memo #(rx/subject))
to-measure (mf/use-state [])
pair->distance+pair
(fn [[sh1 sh2]]
[(-> (gsh/distance-shapes sh1 sh2) coord mth/round) [sh1 sh2]])
contains-selected?
(fn [selected pairs]
(let [has-selected?
(fn [[_ [sh1 sh2]]]
(or (selected (:id sh1))
(selected (:id sh2))))]
(some has-selected? pairs)))
query-worker
(fn [[selrect selected frame]]
(let [lt-side (if (= coord :x) :left :top)
gt-side (if (= coord :x) :right :bottom)
areas (gsh/selrect->areas (:selrect frame) selrect)
query-side (fn [side]
(->> (uw/ask! {:cmd :selection/query
:page-id page-id
:rect (gsh/pad-selrec (areas side))})
(rx/map #(set/difference % selected))
(rx/map #(->> % (map (partial get @refs/workspace-objects))))))]
(->> (query-side lt-side)
(rx/combine-latest vector (query-side gt-side)))))
distance-to-selrect
(fn [shape]
(let [sr (:selrect shape)]
(-> (if (<= (coord sr) (coord selrect))
(gsh/distance-selrect sr selrect)
(gsh/distance-selrect selrect sr))
coord
mth/round)))
get-shapes-match
(fn [pred? shapes]
(->> shapes
(sort-by coord)
(d/map-perm vector)
(filter (fn [[sh1 sh2]] (gsh/overlap-coord? coord sh1 sh2)))
(map pair->distance+pair)
(filter (comp pred? first))))
;; Left/Top shapes and right/bottom shapes (depends on `coord` parameter
[lt-shapes gt-shapes] @to-measure
;; Gets the distance to the current selection
lt-distances (->> lt-shapes (map distance-to-selrect) (filter pos?) (into #{}))
gt-distances (->> gt-shapes (map distance-to-selrect) (filter pos?) (into #{}))
;; We'll show the distances that match a distance from the selrect
show-candidate? (set/union lt-distances gt-distances)
;; Checks the distances between elements for distances that match the set of distances
distance-coincidences (concat (get-shapes-match show-candidate? lt-shapes)
(get-shapes-match show-candidate? gt-shapes))
;; Show the distances that either match one of the distances from the selrect
;; or are from the selrect and go to a shape on the left and to the right
show-distance? (into #{} (concat
(map first distance-coincidences)
(set/intersection lt-distances gt-distances)))
;; These are the segments whose distance will be displayed
;; First segments from segments different that the selectio
other-shapes-segments (->> distance-coincidences
(map second) ;; Retrieves list of [shape,shape] tuples
(map #(mapv :selrect %))) ;; Changes [shape,shape] to [selrec,selrec]
;; Segments from the selection to other
selection-segments (->> (concat lt-shapes gt-shapes)
(filter #(show-distance? (distance-to-selrect %)))
(map #(vector selrect (:selrect %))))
segments-to-display (concat other-shapes-segments selection-segments)]
(mf/use-effect
(fn []
(let [sub (->> subject
(rx/switch-map query-worker)
(rx/subs #(reset! to-measure %)))]
;; On unmount dispose
#(rx/dispose! sub))))
(mf/use-effect (mf/deps selrect selected) #(rx/push! subject [selrect selected frame]))
(for [[sr1 sr2] segments-to-display]
[:& shape-distance-segment {:key (str/join "-" [(:x sr1) (:y sr1) (:x sr2) (:y sr2)])
:sr1 sr1
:sr2 sr2
:coord coord
:zoom zoom}])))
(mf/defc snap-distances [{:keys [layout]}]
(let [page-id (mf/deref refs/workspace-page-id)
selected (mf/deref refs/selected-shapes)
shapes (->> (refs/objects-by-id selected)
(mf/deref)
(map gsh/transform-shape))
selrect (gsh/selection-rect shapes)
frame-id (-> shapes first :frame-id)
frame (mf/deref (refs/object-by-id frame-id))
zoom (mf/deref refs/selected-zoom)
current-transform (mf/deref refs/current-transform)
key (->> selected (map str) (str/join "-"))]
(when (and (contains? layout :dynamic-alignment)
(= current-transform :move)
(not (empty? selected)))
[:g.distance
(for [coord [:x :y]]
[:& shape-distance
{:key (str key (name coord))
:selrect selrect
:page-id page-id
:frame frame
:zoom zoom
:coord coord
:selected selected}])])))

View file

@ -1,4 +1,4 @@
(ns uxbox.main.ui.workspace.snap-feedback
(ns uxbox.main.ui.workspace.snap-points
(:require
[rumext.alpha :as mf]
[beicon.core :as rx]
@ -42,7 +42,7 @@
(rx/map #(vector point % coord)))))
(rx/reduce conj [])))
(mf/defc snap-feedback-points
(mf/defc snap-feedback
[{:keys [shapes page-id filter-shapes zoom] :as props}]
(let [state (mf/use-state [])
subject (mf/use-memo #(rx/subject))
@ -84,7 +84,7 @@
:point point
:zoom zoom}])]))
(mf/defc snap-feedback [{:keys [layout]}]
(mf/defc snap-points [{:keys [layout]}]
(let [page-id (mf/deref refs/workspace-page-id)
selected (mf/deref refs/selected-shapes)
selected-shapes (mf/deref (refs/objects-by-id selected))
@ -99,9 +99,10 @@
snap-data (mf/deref refs/workspace-snap-data)
shapes (if drawing [drawing] selected-shapes)
zoom (mf/deref refs/selected-zoom)]
(when (or drawing current-transform)
[:& snap-feedback-points {:shapes shapes
:page-id page-id
:filter-shapes filter-shapes
:zoom zoom}])))
(when (or drawing current-transform)
[:& snap-feedback {:shapes shapes
:page-id page-id
:filter-shapes filter-shapes
:zoom zoom}])))

View file

@ -28,7 +28,8 @@
[uxbox.main.ui.workspace.drawarea :refer [draw-area start-drawing]]
[uxbox.main.ui.workspace.selection :refer [selection-handlers]]
[uxbox.main.ui.workspace.presence :as presence]
[uxbox.main.ui.workspace.snap-feedback :refer [snap-feedback]]
[uxbox.main.ui.workspace.snap-points :refer [snap-points]]
[uxbox.main.ui.workspace.snap-distances :refer [snap-distances]]
[uxbox.main.ui.workspace.frame-grid :refer [frame-grid]]
[uxbox.common.math :as mth]
[uxbox.util.dom :as dom]
@ -389,7 +390,8 @@
(when (contains? layout :display-grid)
[:& frame-grid {:zoom zoom}])
[:& snap-feedback {:layout layout}]
[:& snap-points {:layout layout}]
[:& snap-distances {:layout layout}]
(when tooltip
[:& cursor-tooltip {:zoom zoom :tooltip tooltip}])]