mirror of
https://github.com/penpot/penpot.git
synced 2026-08-20 03:38:52 +00:00
388 lines
15 KiB
Clojure
388 lines
15 KiB
Clojure
;; 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 app.main.ui.measurements
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.geom.rect :as grc]
|
|
[app.common.geom.shapes :as gsh]
|
|
[app.common.math :as mth]
|
|
[app.common.types.component :as ctk]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.constants :as mconst]
|
|
[app.main.ui.formats :as fmt]
|
|
[rumext.v2 :as mf]))
|
|
|
|
;; ------------------------------------------------
|
|
;; CONSTANTS
|
|
;; ------------------------------------------------
|
|
|
|
(def ^:private ^:const size-display-color "var(--app-white)")
|
|
(def ^:private ^:const size-display-opacity 0.7)
|
|
(def ^:private ^:const size-display-text-color "var(--app-black)")
|
|
(def ^:private ^:const size-display-width-min 50)
|
|
(def ^:private ^:const size-display-width-max 75)
|
|
(def ^:private ^:const size-display-height 16)
|
|
|
|
(def ^:private ^:const distance-border-radius 2)
|
|
(def ^:private ^:const distance-pill-width 50)
|
|
(def ^:private ^:const distance-pill-height 16)
|
|
(def ^:private ^:const distance-line-stroke 1)
|
|
|
|
(def ^:private ^:const selection-badge-bg-color "var(--color-accent-tertiary)")
|
|
(def ^:private ^:const selection-badge-bg-color-component "var(--color-accent-secondary)")
|
|
(def ^:private ^:const selection-badge-height 16)
|
|
(def ^:private ^:const selection-badge-padding-x 6)
|
|
(def ^:private ^:const selection-badge-vertical-gap 8)
|
|
(def ^:private ^:const selection-badge-border-radius 2)
|
|
(def ^:private ^:const selection-badge-char-width 6.5)
|
|
|
|
(def ^:private ^:const select-guide-width 1)
|
|
(def ^:private ^:const select-guide-dasharray 5)
|
|
|
|
(def ^:private ^:const hover-color "var(--color-accent-quaternary)")
|
|
|
|
;; ------------------------------------------------
|
|
;; HELPERS
|
|
;; ------------------------------------------------
|
|
|
|
(defn bound->selrect [bounds]
|
|
{:x (:x bounds)
|
|
:y (:y bounds)
|
|
:x1 (:x bounds)
|
|
:y1 (:y bounds)
|
|
:x2 (+ (:x bounds) (:width bounds))
|
|
:y2 (+ (:y bounds) (:height bounds))
|
|
:width (:width bounds)
|
|
:height (:height bounds)})
|
|
|
|
(defn calculate-guides
|
|
"Calculates coordinates for the selection guides"
|
|
[bounds selrect]
|
|
(let [{bounds-width :width bounds-height :height} bounds
|
|
{:keys [x y width height]} selrect]
|
|
[[(:x bounds) y (+ (:x bounds) bounds-width) y]
|
|
[(:x bounds) (+ y height) (+ (:x bounds) bounds-width) (+ y height)]
|
|
[x (:y bounds) x (+ (:y bounds) bounds-height)]
|
|
[(+ x width) (:y bounds) (+ x width) (+ (:y bounds) bounds-height)]]))
|
|
|
|
(defn calculate-distance-lines
|
|
"Given a start/end from two shapes gives the distance lines"
|
|
[from-s from-e to-s to-e]
|
|
(let [ss (- to-s from-s)
|
|
se (- to-e from-s)
|
|
es (- to-s from-e)
|
|
ee (- to-e from-e)]
|
|
(cond-> []
|
|
(or (and (neg? ss) (pos? se))
|
|
(and (pos? ss) (neg? ee))
|
|
(and (neg? ss) (> ss se)))
|
|
(conj [from-s (+ from-s ss)])
|
|
|
|
(and (neg? se) (<= ss se))
|
|
(conj [from-s (+ from-s se)])
|
|
|
|
(and (pos? es) (<= es ee))
|
|
(conj [from-e (+ from-e es)])
|
|
|
|
(or (and (pos? ee) (neg? es))
|
|
(and (neg? ee) (pos? ss))
|
|
(and (pos? ee) (< ee es)))
|
|
(conj [from-e (+ from-e ee)]))))
|
|
|
|
;; ------------------------------------------------
|
|
;; COMPONENTS
|
|
;; ------------------------------------------------
|
|
|
|
(mf/defc size-display* [{:keys [selrect zoom]}]
|
|
(let [{:keys [x y width height]} selrect
|
|
size-label (dm/str (fmt/format-number width) " x " (fmt/format-number height))
|
|
|
|
rect-height (/ size-display-height zoom)
|
|
rect-width (/ (if (<= (count size-label) 9)
|
|
size-display-width-min
|
|
size-display-width-max)
|
|
zoom)
|
|
text-padding (/ 4 zoom)]
|
|
[:g.size-display
|
|
[:rect {:x (+ x (/ width 2) (- (/ rect-width 2)))
|
|
:y (- (+ y height) rect-height)
|
|
:width rect-width
|
|
:height rect-height
|
|
:style {:fill size-display-color
|
|
:fill-opacity size-display-opacity}}]
|
|
|
|
[:text {:x (+ (+ x (/ width 2) (- (/ rect-width 2))) (/ rect-width 2))
|
|
:y (- (+ y height (+ text-padding (/ rect-height 2))) rect-height)
|
|
:width rect-width
|
|
:height rect-height
|
|
:text-anchor "middle"
|
|
:style {:fill size-display-text-color
|
|
:font-size (/ mconst/font-size zoom)}}
|
|
size-label]]))
|
|
|
|
(mf/defc distance-display-pill* [{:keys [x y zoom distance bounds]}]
|
|
(let [distance-pill-width (/ distance-pill-width zoom)
|
|
distance-pill-height (/ distance-pill-height zoom)
|
|
font-size (/ mconst/font-size zoom)
|
|
text-padding (/ 3 zoom)
|
|
distance-border-radius (/ distance-border-radius zoom)
|
|
|
|
{bounds-width :width bounds-height :height} bounds
|
|
|
|
rect-x (- x (/ distance-pill-width 2))
|
|
rect-y (- y (/ distance-pill-height 2))
|
|
|
|
text-x x
|
|
text-y (+ y text-padding)
|
|
|
|
offset-x (cond (< rect-x (:x bounds)) (- (:x bounds) rect-x)
|
|
(> (+ rect-x distance-pill-width) (+ (:x bounds) bounds-width))
|
|
(- (+ (:x bounds) bounds-width) (+ rect-x distance-pill-width))
|
|
:else 0)
|
|
|
|
offset-y (cond (< rect-y (:y bounds)) (- (:y bounds) rect-y)
|
|
(> (+ rect-y distance-pill-height) (+ (:y bounds) bounds-height))
|
|
(- (+ (:y bounds) bounds-height) (+ rect-y distance-pill-height (/ distance-pill-height 2)))
|
|
:else 0)]
|
|
[:g.distance-pill
|
|
[:rect {:x (+ rect-x offset-x)
|
|
:y (+ rect-y offset-y)
|
|
:rx distance-border-radius
|
|
:ry distance-border-radius
|
|
:width distance-pill-width
|
|
:height distance-pill-height
|
|
:style {:fill mconst/distance-color}}]
|
|
|
|
[:text {:x (+ text-x offset-x)
|
|
:y (+ text-y offset-y)
|
|
:rx distance-border-radius
|
|
:ry distance-border-radius
|
|
:text-anchor "middle"
|
|
:width distance-pill-width
|
|
:height distance-pill-height
|
|
:style {:fill mconst/distance-text-color
|
|
:font-size font-size}}
|
|
(fmt/format-pixels distance)]]))
|
|
|
|
(mf/defc selection-rect* [{:keys [selrect zoom]}]
|
|
(let [{:keys [x y width height]} selrect
|
|
selection-rect-width (/ mconst/selection-rect-width zoom)]
|
|
[:g.selection-rect
|
|
[:rect {:x x
|
|
:y y
|
|
:width width
|
|
:height height
|
|
:style {:fill "none"
|
|
:stroke hover-color
|
|
:stroke-width selection-rect-width}}]]))
|
|
|
|
(defn- get-edge-for-badge
|
|
"For each rotation range, select the 'most horizontal' edge at the bottom,
|
|
as seen from the user's perspective."
|
|
[rotation]
|
|
(let [rot (mod rotation 360)]
|
|
(cond
|
|
(or (< rot 45) (>= rot 315)) :bottom
|
|
(< rot 135) :right
|
|
(< rot 225) :top
|
|
:else :left)))
|
|
|
|
(defn- get-edge-points
|
|
"Get the points that define the selected side of a rectangle"
|
|
[points edge]
|
|
(let [[p0 p1 p2 p3] points]
|
|
(case edge
|
|
:bottom [p2 p3]
|
|
:right [p1 p2]
|
|
:top [p0 p1]
|
|
:left [p3 p0])))
|
|
|
|
(mf/defc selection-size-badge*
|
|
[{:keys [zoom shapes]}]
|
|
(let [badge-height (/ selection-badge-height zoom)
|
|
badge-padding-x (/ selection-badge-padding-x zoom)
|
|
badge-gap (/ selection-badge-vertical-gap zoom)
|
|
badge-radius (/ selection-badge-border-radius zoom)
|
|
badge-char-width (/ selection-badge-char-width zoom)
|
|
|
|
single-shape (and (= (count shapes) 1) (first shapes))
|
|
|
|
component-color? (if single-shape
|
|
(ctk/instance-head? single-shape)
|
|
(every? ctk/instance-head? shapes))
|
|
badge-bg-color (if component-color?
|
|
selection-badge-bg-color-component
|
|
selection-badge-bg-color)
|
|
|
|
rotation (when single-shape (dm/get-prop single-shape :rotation))
|
|
has-rotation? (and rotation (not (mth/almost-zero? rotation)))
|
|
|
|
;; Always compute the selrect from :points via shapes->rect.
|
|
;; This gives the correct bounding box for all shape types,
|
|
;; including component instances and shapes with transforms.
|
|
selrect (gsh/shapes->rect shapes)
|
|
|
|
;; For single shapes we show the original dimensions,
|
|
;; for multiple shapes show the bounding box
|
|
shape-width (if single-shape
|
|
(or (dm/get-prop single-shape :width)
|
|
(:width selrect))
|
|
(:width selrect))
|
|
shape-height (if single-shape
|
|
(or (dm/get-prop single-shape :height)
|
|
(:height selrect))
|
|
(:height selrect))
|
|
|
|
text (dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height))
|
|
|
|
text-width (* (count text) badge-char-width)
|
|
badge-width (+ text-width (* 2 badge-padding-x))]
|
|
|
|
(if has-rotation?
|
|
(let [edge (get-edge-for-badge rotation)
|
|
points (dm/get-prop single-shape :points)
|
|
|
|
[ep1 ep2] (get-edge-points points edge)
|
|
|
|
mid-point (gpt/lerp ep1 ep2 0.5)
|
|
normal (gpt/normal-right (gpt/subtract ep2 ep1))
|
|
|
|
rot-offset (case edge
|
|
:bottom 0
|
|
:right 270
|
|
:top 180
|
|
:left 90)
|
|
badge-rot (+ rotation rot-offset)
|
|
offset (+ badge-gap (/ badge-height 2))
|
|
|
|
badge-x (- (/ badge-width 2))
|
|
badge-y (- (/ badge-height 2))
|
|
badge-cx (+ (:x mid-point) (* (:x normal) offset))
|
|
badge-cy (+ (:y mid-point) (* (:y normal) offset))]
|
|
|
|
[:g.selection-size-badge {:pointer-events "none"
|
|
:transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")}
|
|
[:rect {:x badge-x
|
|
:y badge-y
|
|
:width badge-width
|
|
:height badge-height
|
|
:rx badge-radius
|
|
:ry badge-radius
|
|
:style {:fill badge-bg-color}}]
|
|
[:text {:class (stl/css :badge-text)
|
|
:x 0
|
|
:y 0
|
|
:text-anchor "middle"
|
|
:dominant-baseline "middle"}
|
|
text]])
|
|
|
|
(let [badge-x (- (/ badge-width 2))
|
|
badge-y (- (/ badge-height 2))
|
|
badge-cx (+ (:x selrect) (/ (:width selrect) 2))
|
|
badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))]
|
|
|
|
[:g.selection-size-badge {:pointer-events "none"
|
|
:transform (dm/str "translate(" badge-cx "," badge-cy ")")}
|
|
[:rect {:x badge-x
|
|
:y badge-y
|
|
:width badge-width
|
|
:height badge-height
|
|
:rx badge-radius
|
|
:ry badge-radius
|
|
:style {:fill badge-bg-color}}]
|
|
[:text {:class (stl/css :badge-text)
|
|
:x 0
|
|
:y 0
|
|
:text-anchor "middle"
|
|
:dominant-baseline "middle"}
|
|
text]]))))
|
|
|
|
(mf/defc distance-display* [{:keys [from to zoom bounds]}]
|
|
(let [fixed-x (if (gsh/fully-contained? from to)
|
|
(+ (:x to) (/ (:width to) 2))
|
|
(+ (:x from) (/ (:width from) 2)))
|
|
fixed-y (if (gsh/fully-contained? from to)
|
|
(+ (:y to) (/ (:height to) 2))
|
|
(+ (:y from) (/ (:height from) 2)))
|
|
|
|
v-lines (->> (calculate-distance-lines (:y1 from) (:y2 from) (:y1 to) (:y2 to))
|
|
(map (fn [[start end]] [fixed-x start fixed-x end])))
|
|
|
|
h-lines (->> (calculate-distance-lines (:x1 from) (:x2 from) (:x1 to) (:x2 to))
|
|
(map (fn [[start end]] [start fixed-y end fixed-y])))
|
|
|
|
lines (d/concat-vec v-lines h-lines)
|
|
|
|
distance-line-stroke (/ distance-line-stroke zoom)]
|
|
|
|
(for [[x1 y1 x2 y2] lines]
|
|
(let [center-x (+ x1 (/ (- x2 x1) 2))
|
|
center-y (+ y1 (/ (- y2 y1) 2))
|
|
distance (gpt/distance (gpt/point x1 y1) (gpt/point x2 y2))]
|
|
(when-not (mth/almost-zero? distance)
|
|
[:g.distance-line {:key (dm/str "line-" x1 "-" y1 "-" x2 "-" y2)}
|
|
[:line
|
|
{:x1 x1
|
|
:y1 y1
|
|
:x2 x2
|
|
:y2 y2
|
|
:style {:stroke mconst/distance-color
|
|
:stroke-width distance-line-stroke}}]
|
|
|
|
[:> distance-display-pill*
|
|
{:x center-x
|
|
:y center-y
|
|
:zoom zoom
|
|
:distance distance
|
|
:bounds bounds}]])))))
|
|
|
|
(mf/defc selection-guides* [{:keys [bounds selrect zoom]}]
|
|
[:g.selection-guides
|
|
(for [[idx [x1 y1 x2 y2]] (d/enumerate (calculate-guides bounds selrect))]
|
|
[:line {:key (dm/str "guide-" idx)
|
|
:x1 x1
|
|
:y1 y1
|
|
:x2 x2
|
|
:y2 y2
|
|
:style {:stroke mconst/select-color
|
|
:stroke-width (/ select-guide-width zoom)
|
|
:stroke-dasharray (/ select-guide-dasharray zoom)}}])])
|
|
|
|
(mf/defc measurement*
|
|
[{:keys [bounds frame selected-shapes hover-shape zoom]}]
|
|
(let [selected-ids (into #{} (map :id) selected-shapes)
|
|
selected-selrect (gsh/shapes->rect selected-shapes)
|
|
hover-selrect (-> hover-shape :points grc/points->rect)
|
|
bounds-selrect (bound->selrect bounds)
|
|
hover-selected-shape? (not (contains? selected-ids (:id hover-shape)))]
|
|
|
|
(when (seq selected-shapes)
|
|
[:g.measurement-feedback {:pointer-events "none"}
|
|
[:> selection-guides* {:selrect selected-selrect
|
|
:bounds bounds
|
|
:zoom zoom}]
|
|
[:> size-display* {:selrect selected-selrect :zoom zoom}]
|
|
|
|
(if (or (not hover-shape) (not hover-selected-shape?))
|
|
(when (and frame (not= uuid/zero (:id frame)))
|
|
(let [frame-bb (-> (:points frame) (grc/points->rect))]
|
|
[:g.hover-shapes
|
|
[:> selection-rect* {:type :hover :selrect frame-bb :zoom zoom}]
|
|
[:> distance-display* {:from frame-bb
|
|
:to selected-selrect
|
|
:zoom zoom
|
|
:bounds bounds-selrect}]]))
|
|
|
|
[:g.hover-shapes
|
|
[:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}]
|
|
[:> size-display* {:selrect hover-selrect :zoom zoom}]
|
|
[:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])])))
|
|
|