penpot/frontend/src/app/main/data/workspace/modifiers.cljs
2023-10-09 11:58:55 +02:00

491 lines
20 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
(ns app.main.data.workspace.modifiers
"Events related with shapes transformations"
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.pages.common :as cpc]
[app.common.pages.helpers :as cph]
[app.common.types.container :as ctn]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.main.constants :refer [zoom-half-pixel-precision]]
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.comments :as-alias dwcm]
[app.main.data.workspace.guides :as-alias dwg]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.undo :as dwu]
[beicon.core :as rx]
[potok.core :as ptk]))
;; -- temporary modifiers -------------------------------------------
;; During an interactive transformation of shapes (e.g. when resizing or rotating
;; a group with the mouse), there are a lot of objects that need to be modified
;; (in this case, the group and all its children).
;;
;; To avoid updating the shapes theirselves, and forcing redraw of all components
;; that depend on the "objects" global state, we set a "modifiers" structure, with
;; the changes that need to be applied, and store it in :workspace-modifiers global
;; variable. The viewport reads this and merges it into the objects list it uses to
;; paint the viewport content, redrawing only the objects that have new modifiers.
;;
;; When the interaction is finished (e.g. user releases mouse button), the
;; apply-modifiers event is done, that consolidates all modifiers into the base
;; geometric attributes of the shapes.
(defn- check-delta
"If the shape is a component instance, check its relative position respect the
root of the component, and see if it changes after applying a transformation."
[shape root transformed-shape transformed-root objects modif-tree]
(let [root
(cond
(:component-root? shape)
shape
(nil? root)
(ctn/get-component-shape objects shape {:allow-main? true})
:else root)
transformed-root
(cond
(:component-root? transformed-shape)
transformed-shape
(nil? transformed-root)
(as-> (ctn/get-component-shape objects transformed-shape {:allow-main? true}) $
(gsh/transform-shape (merge $ (get modif-tree (:id $)))))
:else transformed-root)
shape-delta
(when root
(gpt/point (- (gsh/left-bound shape) (gsh/left-bound root))
(- (gsh/top-bound shape) (gsh/top-bound root))))
transformed-shape-delta
(when transformed-root
(gpt/point (- (gsh/left-bound transformed-shape) (gsh/left-bound transformed-root))
(- (gsh/top-bound transformed-shape) (gsh/top-bound transformed-root))))
distance (if (and shape-delta transformed-shape-delta)
(gpt/distance-vector shape-delta transformed-shape-delta)
(gpt/point 0 0))
selrect (:selrect shape)
transformed-selrect (:selrect transformed-shape)
;; There are cases in that the coordinates change slightly (e.g. when rounding
;; to pixel, or when recalculating text positions in different zoom levels).
;; To take this into account, we ignore movements smaller than 1 pixel.
;;
;; When the change is a resize, also has a transformation that may have the
;; shape position unchanged. But in this case we do not want to ignore it.
ignore-geometry? (and (and (< (:x distance) 1) (< (:y distance) 1))
(mth/close? (:width selrect) (:width transformed-selrect))
(mth/close? (:height selrect) (:height transformed-selrect)))]
[root transformed-root ignore-geometry?]))
(defn- get-ignore-tree
"Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers"
([modif-tree objects shape]
(get-ignore-tree modif-tree objects shape nil nil {}))
([modif-tree objects shape root transformed-root ignore-tree]
(let [children (map (d/getf objects) (:shapes shape))
shape-id (:id shape)
transformed-shape (gsh/transform-shape shape (dm/get-in modif-tree [shape-id :modifiers]))
[root transformed-root ignore-geometry?]
(check-delta shape root transformed-shape transformed-root objects modif-tree)
ignore-tree (assoc ignore-tree shape-id ignore-geometry?)
set-child
(fn [ignore-tree child]
(get-ignore-tree modif-tree objects child root transformed-root ignore-tree))]
(reduce set-child ignore-tree children))))
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
(gpt/point (:selrect shape)))
position-data
(-> position-data
(gsh/move-position-data deltav))]
(cond-> shape
(d/not-empty? position-data)
(assoc :position-data position-data))))
(defn update-grow-type
[shape old-shape]
(let [auto-width? (= :auto-width (:grow-type shape))
auto-height? (= :auto-height (:grow-type shape))
changed-width? (> (mth/abs (- (:width shape) (:width old-shape))) 0.1)
changed-height? (> (mth/abs (- (:height shape) (:height old-shape))) 0.1)
change-to-fixed? (or (and auto-width? (or changed-height? changed-width?))
(and auto-height? changed-height?))]
(cond-> shape
change-to-fixed?
(assoc :grow-type :fixed))))
(defn- clear-local-transform []
(ptk/reify ::clear-local-transform
ptk/UpdateEvent
(update [_ state]
(-> state
(dissoc :workspace-modifiers)
(dissoc :app.main.data.workspace.transforms/current-move-selected)))))
(defn create-modif-tree
[ids modifiers]
(dm/assert!
"expected valid coll of uuids"
(every? uuid? ids))
(into {} (map #(vector % {:modifiers modifiers})) ids))
(defn build-modif-tree
[ids objects get-modifier]
(dm/assert!
"expected valid coll of uuids"
(every? uuid? ids))
(into {} (map #(vector % {:modifiers (get-modifier (get objects %))})) ids))
(defn modifier-remove-from-parent
[modif-tree objects shapes]
(->> shapes
(reduce
(fn [modif-tree child-id]
(let [parent-id (get-in objects [child-id :parent-id])]
(update-in modif-tree [parent-id :modifiers] ctm/remove-children [child-id])))
modif-tree)))
(defn build-change-frame-modifiers
[modif-tree objects selected target-frame-id drop-index]
(let [origin-frame-ids (->> selected (group-by #(get-in objects [% :frame-id])))
child-set (set (get-in objects [target-frame-id :shapes]))
target-frame (get objects target-frame-id)
target-flex-layout? (ctl/flex-layout? target-frame)
children-ids (concat (:shapes target-frame) selected)
set-parent-ids
(fn [modif-tree shapes target-frame-id]
(reduce
(fn [modif-tree id]
(update-in
modif-tree
[id :modifiers]
#(-> %
(ctm/change-property :frame-id target-frame-id)
(ctm/change-property :parent-id target-frame-id))))
modif-tree
shapes))
update-frame-modifiers
(fn [modif-tree [original-frame shapes]]
(let [shapes (->> shapes (d/removev #(= target-frame-id %)))
shapes (cond->> shapes
(and target-flex-layout? (= original-frame target-frame-id))
;; When movining inside a layout frame remove the shapes that are not immediate children
(filterv #(contains? child-set %)))
children-ids (->> (dm/get-in objects [original-frame :shapes])
(remove (set selected)))
h-sizing? (ctl/change-h-sizing? original-frame objects children-ids)
v-sizing? (ctl/change-v-sizing? original-frame objects children-ids)]
(cond-> modif-tree
(not= original-frame target-frame-id)
(-> (modifier-remove-from-parent objects shapes)
(update-in [target-frame-id :modifiers] ctm/add-children shapes drop-index)
(set-parent-ids shapes target-frame-id)
(cond-> h-sizing?
(update-in [original-frame :modifiers] ctm/change-property :layout-item-h-sizing :fix))
(cond-> v-sizing?
(update-in [original-frame :modifiers] ctm/change-property :layout-item-v-sizing :fix)))
(and target-flex-layout? (= original-frame target-frame-id))
(update-in [target-frame-id :modifiers] ctm/add-children shapes drop-index))))]
(as-> modif-tree $
(reduce update-frame-modifiers $ origin-frame-ids)
(cond-> $
(ctl/change-h-sizing? target-frame-id objects children-ids)
(update-in [target-frame-id :modifiers] ctm/change-property :layout-item-h-sizing :fix))
(cond-> $
(ctl/change-v-sizing? target-frame-id objects children-ids)
(update-in [target-frame-id :modifiers] ctm/change-property :layout-item-v-sizing :fix)))))
(defn modif->js
[modif-tree objects]
(clj->js (into {}
(map (fn [[k v]]
[(get-in objects [k :name]) v]))
modif-tree)))
(defn apply-text-modifier
[shape {:keys [width height]}]
(cond-> shape
(some? width)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true}))
(some? height)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true}))))
(defn apply-text-modifiers
[objects text-modifiers]
(loop [modifiers (seq text-modifiers)
result objects]
(if (empty? modifiers)
result
(let [[id text-modifier] (first modifiers)]
(recur (rest modifiers)
(d/update-when result id apply-text-modifier text-modifier))))))
#_(defn apply-path-modifiers
[objects path-modifiers]
(letfn [(apply-path-modifier
[shape {:keys [content-modifiers]}]
(let [shape (update shape :content upc/apply-content-modifiers content-modifiers)
[points selrect] (helpers/content->points+selrect shape (:content shape))]
(assoc shape :selrect selrect :points points)))]
(loop [modifiers (seq path-modifiers)
result objects]
(if (empty? modifiers)
result
(let [[id path-modifier] (first modifiers)]
(recur (rest modifiers)
(update objects id apply-path-modifier path-modifier)))))))
(defn- calculate-modifiers
([state modif-tree]
(calculate-modifiers state false false modif-tree))
([state ignore-constraints ignore-snap-pixel modif-tree]
(calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree nil))
([state ignore-constraints ignore-snap-pixel modif-tree params]
(let [objects
(wsh/lookup-page-objects state)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
zoom (dm/get-in state [:workspace-local :zoom])
snap-precision (if (>= zoom zoom-half-pixel-precision) 0.5 1)]
(as-> objects $
(apply-text-modifiers $ (get state :workspace-text-modifier))
;;(apply-path-modifiers $ (get-in state [:workspace-local :edit-path]))
(gsh/set-objects-modifiers modif-tree $ (merge
params
{:ignore-constraints ignore-constraints
:snap-pixel? snap-pixel?
:snap-precision snap-precision}))))))
(defn- calculate-update-modifiers
[old-modif-tree state ignore-constraints ignore-snap-pixel modif-tree]
(let [objects
(wsh/lookup-page-objects state)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
zoom (dm/get-in state [:workspace-local :zoom])
snap-precision (if (>= zoom zoom-half-pixel-precision) 0.5 1)
objects
(-> objects
(apply-text-modifiers (get state :workspace-text-modifier)))]
(gsh/set-objects-modifiers old-modif-tree modif-tree objects {:ignore-constraints ignore-constraints :snap-pixel? snap-pixel? :snap-precision snap-precision})))
(defn update-modifiers
([modif-tree]
(update-modifiers modif-tree false))
([modif-tree ignore-constraints]
(update-modifiers modif-tree ignore-constraints false))
([modif-tree ignore-constraints ignore-snap-pixel]
(ptk/reify ::update-modifiers
ptk/UpdateEvent
(update [_ state]
(update state :workspace-modifiers calculate-update-modifiers state ignore-constraints ignore-snap-pixel modif-tree)))))
(defn set-modifiers
([modif-tree]
(set-modifiers modif-tree false))
([modif-tree ignore-constraints]
(set-modifiers modif-tree ignore-constraints false))
([modif-tree ignore-constraints ignore-snap-pixel]
(set-modifiers modif-tree ignore-constraints ignore-snap-pixel nil))
([modif-tree ignore-constraints ignore-snap-pixel params]
(ptk/reify ::set-modifiers
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree params))))))
;; Rotation use different algorithm to calculate children modifiers (and do not use child constraints).
(defn set-rotation-modifiers
([angle shapes]
(set-rotation-modifiers angle shapes (-> shapes gsh/selection-rect gsh/center-selrect)))
([angle shapes center]
(ptk/reify ::set-rotation-modifiers
ptk/UpdateEvent
(update [_ state]
(let [objects (wsh/lookup-page-objects state)
ids
(->> shapes
(remove #(get % :blocked false))
(filter #((cpc/editable-attrs (:type %)) :rotation))
(map :id))
get-modifier
(fn [shape]
(ctm/rotation-modifiers shape center angle))
modif-tree
(-> (build-modif-tree ids objects get-modifier)
(gsh/set-objects-modifiers objects))]
(assoc state :workspace-modifiers modif-tree))))))
;; This function is similar to set-rotation-modifiers but:
;; - It consideres the center for everyshape instead of the center of the total selrect
;; - The angle param is the desired final value, not a delta
(defn set-delta-rotation-modifiers
([angle shapes]
(ptk/reify ::set-delta-rotation-modifiers
ptk/UpdateEvent
(update [_ state]
(let [objects (wsh/lookup-page-objects state)
ids
(->> shapes
(remove #(get % :blocked false))
(filter #((cpc/editable-attrs (:type %)) :rotation))
(map :id))
get-modifier
(fn [shape]
(let [delta (- angle (:rotation shape))
center (gsh/center-shape shape)]
(ctm/rotation-modifiers shape center delta)))
modif-tree
(-> (build-modif-tree ids objects get-modifier)
(gsh/set-objects-modifiers objects))]
(assoc state :workspace-modifiers modif-tree))))))
(defn apply-modifiers
([]
(apply-modifiers nil))
([{:keys [modifiers undo-transation? stack-undo? ignore-constraints ignore-snap-pixel]
:or {undo-transation? true stack-undo? false ignore-constraints false ignore-snap-pixel false}}]
(ptk/reify ::apply-modifiers
ptk/WatchEvent
(watch [_ state _]
(let [text-modifiers (get state :workspace-text-modifier)
objects (wsh/lookup-page-objects state)
object-modifiers (if modifiers
(calculate-modifiers state ignore-constraints ignore-snap-pixel modifiers)
(get state :workspace-modifiers))
ids (or (keys object-modifiers) [])
ids-with-children (into (vec ids) (mapcat #(cph/get-children-ids objects %)) ids)
shapes (map (d/getf objects) ids)
ignore-tree (->> (map #(get-ignore-tree object-modifiers objects %) shapes)
(reduce merge {}))
undo-id (js/Symbol)]
(rx/concat
(if undo-transation?
(rx/of (dwu/start-undo-transaction undo-id))
(rx/empty))
(rx/of (ptk/event ::dwg/move-frame-guides ids-with-children)
(ptk/event ::dwcm/move-frame-comment-threads ids-with-children)
(dch/update-shapes
ids
(fn [shape]
(let [modif (get-in object-modifiers [(:id shape) :modifiers])
text-shape? (cph/text-shape? shape)
position-data (when text-shape?
(dm/get-in text-modifiers [(:id shape) :position-data]))]
(-> shape
(gsh/transform-shape modif)
(cond-> (d/not-empty? position-data)
(assoc-position-data position-data shape))
(cond-> text-shape?
(update-grow-type shape)))))
{:reg-objects? true
:stack-undo? stack-undo?
:ignore-tree ignore-tree
;; Attributes that can change in the transform. This way we don't have to check
;; all the attributes
:attrs [:selrect
:points
:x
:y
:rx
:ry
:r1
:r2
:r3
:r4
:shadow
:blur
:strokes
:width
:height
:content
:transform
:transform-inverse
:rotation
:flip-x
:flip-y
:grow-type
:position-data
:layout-gap
:layout-padding
:layout-item-h-sizing
:layout-item-margin
:layout-item-max-h
:layout-item-max-w
:layout-item-min-h
:layout-item-min-w
:layout-item-v-sizing
:layout-padding-type
:layout-gap
:layout-item-margin
:layout-item-margin-type
]})
;; We've applied the text-modifier so we can dissoc the temporary data
(fn [state]
(update state :workspace-text-modifier #(apply dissoc % ids)))
(clear-local-transform))
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty))))))))