diff --git a/common/dev/user.clj b/common/dev/user.clj index d73499c25..0e7787ae7 100644 --- a/common/dev/user.clj +++ b/common/dev/user.clj @@ -12,6 +12,8 @@ [app.common.schema.desc-js-like :as smdj] [app.common.schema.desc-native :as smdn] [app.common.schema.generators :as sg] + [malli.core :as m] + [malli.util :as mu] [clojure.java.io :as io] [clojure.pprint :refer [pprint print-table]] [clojure.repl :refer :all] diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index d48d0b84d..1f0d40bb5 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -33,7 +33,18 @@ (def ^:private schema:operation - [:multi {:dispatch :type :title "Operation" ::smd/simplified true} + [:multi {:dispatch :type + :title "Operation" + :decode/json #(update % :type keyword) + ::smd/simplified true} + [:assign + [:map {:title "AssignOperation"} + [:type [:= :assign]] + ;; NOTE: the full decoding is happening on the handler because it + ;; needs a proper context of the current shape and its type + [:value [:map-of :keyword :any]] + [:ignore-touched {:optional true} :boolean] + [:ignore-geometry {:optional true} :boolean]]] [:set [:map {:title "SetOperation"} [:type [:= :set]] @@ -267,6 +278,16 @@ ;; Page Transformation Changes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:dynamic *touched-changes* + "A dynamic var that used for track changes that touch shapes on + first processing phase of changes. Should be set to a hash-set + instance and will contain changes that caused the touched + modification." + nil) + +(defmulti process-change (fn [_ change] (:type change))) +(defmulti process-operation (fn [_ op] (:type op))) + ;; Changes Processing Impl (defn validate-shapes! @@ -295,8 +316,19 @@ nil)))) (run! validate-shape!)))) -(defmulti process-change (fn [_ change] (:type change))) -(defmulti process-operation (fn [_ _ op] (:type op))) +(defn- process-touched-change + [data {:keys [id page-id component-id]}] + (let [objects (if page-id + (-> data :pages-index (get page-id) :objects) + (-> data :components (get component-id) :objects)) + shape (get objects id) + croot (ctn/get-component-shape objects shape {:allow-main? true})] + + (if (and (some? croot) (ctk/main-instance? croot)) + (ctkl/set-component-modified data (:component-id croot)) + (if (some? component-id) + (ctkl/set-component-modified data component-id) + data)))) (defn process-changes ([data items] @@ -310,10 +342,15 @@ "expected valid changes" (check-changes! items))) - (let [result (reduce #(or (process-change %1 %2) %1) data items)] - ;; Validate result shapes (only on the backend) - #?(:clj (validate-shapes! data result items)) - result))) + (binding [*touched-changes* (volatile! #{})] + (let [result (reduce #(or (process-change %1 %2) %1) data items) + result (reduce process-touched-change result @*touched-changes*)] + ;; Validate result shapes (only on the backend) + ;; + ;; TODO: (PERF) add changed shapes tracking and only validate + ;; the tracked changes instead of iterate over all shapes + #?(:clj (validate-shapes! data result items)) + result)))) (defmethod process-change :set-option [data {:keys [page-id option value]}] @@ -334,83 +371,51 @@ (d/update-in-when data [:pages-index page-id] update-container) (d/update-in-when data [:components component-id] update-container)))) +(defn- process-operations + [objects {:keys [id operations] :as change}] + (if-let [shape (get objects id)] + (let [shape (reduce process-operation shape operations) + touched? (-> shape meta ::ctn/touched)] + ;; NOTE: processing operation functions can assign + ;; the ::ctn/touched metadata on shapes, in this case we + ;; need to report them for to be used in the second + ;; phase of changes procesing + (when touched? (some-> *touched-changes* (vswap! conj change))) + (assoc objects id shape)) + + objects)) + (defmethod process-change :mod-obj - [data {:keys [id page-id component-id operations]}] - (let [changed? (atom false) + [data {:keys [page-id component-id] :as change}] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] process-operations change) + (d/update-in-when data [:components component-id :objects] process-operations change))) - process-and-register (partial process-operation - (fn [_shape] (reset! changed? true))) +(defn- process-children-reordering + [objects {:keys [parent-id shapes] :as change}] + (if-let [old-shapes (dm/get-in objects [parent-id :shapes])] + (let [id->idx + (update-vals + (->> (d/enumerate shapes) + (group-by second)) + (comp first first)) - update-fn (fn [objects] - (d/update-when objects id - #(reduce process-and-register % operations))) + new-shapes + (vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] - check-modify-component (fn [data] - (if @changed? - ;; When a shape is modified, if it belongs to a main component instance, - ;; the component needs to be marked as modified. - (let [objects (if page-id - (-> data :pages-index (get page-id) :objects) - (-> data :components (get component-id) :objects)) - shape (get objects id) - component-root (ctn/get-component-shape objects shape {:allow-main? true})] - (if (and (some? component-root) (ctk/main-instance? component-root)) - (ctkl/set-component-modified data (:component-id component-root)) - (if (some? component-id) - (ctkl/set-component-modified data component-id) - data))) - data))] + (if (not= old-shapes new-shapes) + (do + (some-> *touched-changes* (vswap! conj change)) + (update objects parent-id assoc :shapes new-shapes)) + objects)) - (as-> data $ - (if page-id - (d/update-in-when $ [:pages-index page-id :objects] update-fn) - (d/update-in-when $ [:components component-id :objects] update-fn)) - (check-modify-component $)))) + objects)) (defmethod process-change :reorder-children - [data {:keys [parent-id shapes page-id component-id]}] - (let [changed? (atom false) - - update-fn - (fn [objects] - (let [old-shapes (dm/get-in objects [parent-id :shapes]) - - id->idx - (update-vals - (->> shapes - d/enumerate - (group-by second)) - (comp first first)) - - new-shapes - (into [] (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] - - (reset! changed? (not= old-shapes new-shapes)) - - (cond-> objects - @changed? - (d/assoc-in-when [parent-id :shapes] new-shapes)))) - - check-modify-component - (fn [data] - (if @changed? - ;; When a shape is modified, if it belongs to a main component instance, - ;; the component needs to be marked as modified. - (let [objects (if page-id - (-> data :pages-index (get page-id) :objects) - (-> data :components (get component-id) :objects)) - shape (get objects parent-id) - component-root (ctn/get-component-shape objects shape {:allow-main? true})] - (if (and (some? component-root) (ctk/main-instance? component-root)) - (ctkl/set-component-modified data (:component-id component-root)) - data)) - data))] - - (as-> data $ - (if page-id - (d/update-in-when $ [:pages-index page-id :objects] update-fn) - (d/update-in-when $ [:components component-id :objects] update-fn)) - (check-modify-component $)))) + [data {:keys [page-id component-id] :as change}] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] process-children-reordering change) + (d/update-in-when data [:components component-id :objects] process-children-reordering change))) (defmethod process-change :del-obj [data {:keys [page-id component-id id ignore-touched]}] @@ -711,33 +716,49 @@ (ctyl/delete-typography data id)) ;; === Operations + +(def ^:private decode-shape + (sm/decoder cts/schema:shape sm/json-transformer)) + +(defmethod process-operation :assign + [{:keys [type] :as shape} {:keys [value] :as op}] + (let [modifications (assoc value :type type) + modifications (decode-shape modifications)] + (reduce-kv (fn [shape k v] + (process-operation shape {:type :set + :attr k + :val v + :ignore-touched (:ignore-touched op) + :ignore-geometry (:ignore-geometry op)})) + shape + modifications))) + (defmethod process-operation :set - [on-changed shape op] + [shape op] (ctn/set-shape-attr shape (:attr op) (:val op) - :on-changed on-changed :ignore-touched (:ignore-touched op) :ignore-geometry (:ignore-geometry op))) (defmethod process-operation :set-touched - [_ shape op] - (let [touched (:touched op) + [shape op] + (let [touched (:touched op) in-copy? (ctk/in-component-copy? shape)] (if (or (not in-copy?) (nil? touched) (empty? touched)) (dissoc shape :touched) (assoc shape :touched touched)))) (defmethod process-operation :set-remote-synced - [_ shape op] + [shape op] (let [remote-synced (:remote-synced op) - in-copy? (ctk/in-component-copy? shape)] + in-copy? (ctk/in-component-copy? shape)] (if (or (not in-copy?) (not remote-synced)) (dissoc shape :remote-synced) (assoc shape :remote-synced true)))) (defmethod process-operation :default - [_ _ op] + [_ op] (ex/raise :type :not-implemented :code :operation-not-implemented :context {:type (:type op)})) @@ -863,5 +884,3 @@ (defmethod frames-changed :default [_ _] nil) - - diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 8f62bd080..9cecfac38 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -540,38 +540,51 @@ ;; --- SHAPE UPDATE (defn set-shape-attr - [shape attr val & {:keys [on-changed ignore-touched ignore-geometry]}] - (let [group (get ctk/sync-attrs attr) - shape-val (get shape attr) - ignore (or ignore-touched (= attr :position-data)) ;; position-data is a derived attribute and - is-geometry? (and (or (= group :geometry-group) ;; never triggers touched by itself - (and (= group :content-group) (= (:type shape) :path))) - (not (#{:width :height} attr))) ;; :content in paths are also considered geometric - ;; TODO: the check of :width and :height probably may be removed - ;; after the check added in data/workspace/modifiers/check-delta - ;; function. Better check it and test toroughly when activating - ;; components-v2 mode. - in-copy? (ctk/in-component-copy? shape) + "Assign attribute to shape with touched logic. + + The returned shape will contain a metadata associated with it + indicating if shape is touched or not." + [shape attr val & {:keys [ignore-touched ignore-geometry]}] + (let [group (get ctk/sync-attrs attr) + shape-val (get shape attr) + + ignore? + (or ignore-touched + ;; position-data is a derived attribute + (= attr :position-data)) + + is-geometry? + (and (or (= group :geometry-group) ;; never triggers touched by itself + (and (= group :content-group) + (= (:type shape) :path))) + ;; :content in paths are also considered geometric + (not (#{:width :height} attr))) + + ;; TODO: the check of :width and :height probably may be + ;; removed after the check added in + ;; data/workspace/modifiers/check-delta function. Better check + ;; it and test toroughly when activating components-v2 mode. + in-copy? + (ctk/in-component-copy? shape) ;; For geometric attributes, there are cases in that the value changes ;; slightly (e.g. when rounding to pixel, or when recalculating text ;; positions in different zoom levels). To take this into account, we ;; ignore geometric changes smaller than 1 pixel. - equal? (if is-geometry? - (gsh/close-attrs? attr val shape-val 1) - (gsh/close-attrs? attr val shape-val))] + equal? + (if is-geometry? + (gsh/close-attrs? attr val shape-val 1) + (gsh/close-attrs? attr val shape-val)) - ;; Notify when value has changed, except when it has not moved relative to the - ;; component head. - (when (and on-changed group (not equal?) (not (and ignore-geometry is-geometry?))) - (on-changed shape)) + touched? + (and group (not equal?) (not (and ignore-geometry is-geometry?)))] (cond-> shape ;; Depending on the origin of the attribute change, we need or not to ;; set the "touched" flag for the group the attribute belongs to. ;; In some cases we need to ignore touched only if the attribute is ;; geometric (position, width or transformation). - (and in-copy? group (not ignore) (not equal?) + (and in-copy? group (not ignore?) (not equal?) (not (and ignore-geometry is-geometry?))) (-> (update :touched ctk/set-touched-group group) (dissoc :remote-synced)) @@ -580,4 +593,7 @@ (dissoc attr) (some? val) - (assoc attr val)))) + (assoc attr val) + + :always + (vary-meta assoc ::touched touched?)))) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index e6b75f125..b502c4c65 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -125,7 +125,7 @@ (sm/register! ::stroke schema:stroke) -(def ^:private schema:shape-base-attrs +(def schema:shape-base-attrs [:map {:title "ShapeMinimalRecord"} [:id ::sm/uuid] [:name :string] @@ -137,13 +137,14 @@ [:parent-id ::sm/uuid] [:frame-id ::sm/uuid]]) -(def ^:private schema:shape-geom-attrs +(def schema:shape-geom-attrs [:map {:title "ShapeGeometryAttrs"} [:x ::sm/safe-number] [:y ::sm/safe-number] [:width ::sm/safe-number] [:height ::sm/safe-number]]) +;; FIXME: rename to shape-generic-attrs (def schema:shape-attrs [:map {:title "ShapeAttrs"} [:component-id {:optional true} ::sm/uuid] @@ -159,7 +160,6 @@ [:masked-group {:optional true} :boolean] [:fills {:optional true} [:vector {:gen/max 2} schema:fill]] - [:hide-fill-on-export {:optional true} :boolean] [:proportion {:optional true} ::sm/safe-number] [:proportion-lock {:optional true} :boolean] [:constraints-h {:optional true} @@ -193,12 +193,10 @@ (def schema:group-attrs [:map {:title "GroupAttrs"} - [:type [:= :group]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]]]) (def ^:private schema:frame-attrs [:map {:title "FrameAttrs"} - [:type [:= :frame]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] [:hide-fill-on-export {:optional true} :boolean] [:show-content {:optional true} :boolean] @@ -206,26 +204,21 @@ (def ^:private schema:bool-attrs [:map {:title "BoolAttrs"} - [:type [:= :bool]] [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] [:bool-type [::sm/one-of bool-types]] [:bool-content ::ctsp/content]]) (def ^:private schema:rect-attrs - [:map {:title "RectAttrs"} - [:type [:= :rect]]]) + [:map {:title "RectAttrs"}]) (def ^:private schema:circle-attrs - [:map {:title "CircleAttrs"} - [:type [:= :circle]]]) + [:map {:title "CircleAttrs"}]) (def ^:private schema:svg-raw-attrs - [:map {:title "SvgRawAttrs"} - [:type [:= :svg-raw]]]) + [:map {:title "SvgRawAttrs"}]) (def schema:image-attrs [:map {:title "ImageAttrs"} - [:type [:= :image]] [:metadata [:map [:width {:gen/gen (sg/small-int :min 1)} :int] @@ -238,12 +231,10 @@ (def ^:private schema:path-attrs [:map {:title "PathAttrs"} - [:type [:= :path]] [:content ::ctsp/content]]) (def ^:private schema:text-attrs [:map {:title "TextAttrs"} - [:type [:= :text]] [:content {:optional true} [:maybe ::ctsx/content]]]) (defn- decode-shape