diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8e4bbb67d9..84ec2f2b94 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -625,7 +625,7 @@ (defn- process-operations [objects {:keys [page-id id operations] :as change}] (if-let [shape (get objects id)] - (let [shape (reduce process-operation shape operations) + (let [shape (reduce process-operation (vary-meta shape dissoc ::ctn/touched) operations) touched? (-> shape meta ::ctn/touched)] ;; NOTE: processing operation functions can assign ;; the ::ctn/touched metadata on shapes, in this case we @@ -1170,8 +1170,10 @@ (let [need-sync? (fn [operation] ; We need to trigger a sync if the shape has changed any ; attribute that participates in components synchronization. - (and (= (:type operation) :set) - (ctk/component-attr? (:attr operation)))) + (case (:type operation) + :set (ctk/component-attr? (:attr operation)) + :assign (some ctk/component-attr? (keys (:value operation))) + false)) any-sync? (some need-sync? operations)] (when any-sync? (if page-id diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 11c9377a42..eebae2e2ef 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -629,12 +629,21 @@ new-obj (if with-objects? (update-fn old-obj objects) (update-fn old-obj))] (if (= old-obj new-obj) changes - (let [[rops uops] (-> (or attrs (d/concat-set (keys old-obj) (keys new-obj))) - (generate-operations old-obj new-obj)) + (let [attrs (or attrs (d/concat-set (keys old-obj) (keys new-obj))) + ;; the touched groups the update sets go first, so the + ;; attributes it changes touch on top of them + attrs (if (some #{:touched} attrs) + (cons :touched (remove #{:touched} attrs)) + attrs) + [rops uops] (generate-operations attrs old-obj new-obj) + ;; a touch also clears :remote-synced uops (cond-> uops (seq uops) - (conj {:type :set-touched :touched (:touched old-obj)})) + (conj {:type :set-touched :touched (:touched old-obj)}) + + (and (seq uops) (:remote-synced old-obj)) + (conj {:type :set-remote-synced :remote-synced true})) change (cond-> {:type :mod-obj :id id} (some? page-id) diff --git a/common/src/app/common/geom/shapes.cljc b/common/src/app/common/geom/shapes.cljc index 4f0ac90fe9..7cfa86338a 100644 --- a/common/src/app/common/geom/shapes.cljc +++ b/common/src/app/common/geom/shapes.cljc @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes.common :as gco] @@ -16,7 +17,8 @@ [app.common.geom.shapes.fit-frame :as gsff] [app.common.geom.shapes.intersect :as gsi] [app.common.geom.shapes.transforms :as gtr] - [app.common.math :as mth])) + [app.common.math :as mth] + [app.common.types.path :as path])) ;; --- Outer Rect @@ -145,6 +147,31 @@ (close-val? (:y (first %)) (:y (second %)))) (d/zip val1 val2)) + ;; a matrix holds scale and rotation terms, so it is compared with a + ;; precision of its own + (and (or (= attr :transform) (= attr :transform-inverse)) + (or (nil? val1) (gmt/matrix? val1)) + (or (nil? val2) (gmt/matrix? val2))) + (gmt/close? (or val1 (gmt/matrix)) (or val2 (gmt/matrix))) + + (and (= attr :content) (path/content? val1) (path/content? val2)) + (let [segments1 (vec val1) + segments2 (vec val2) + close-params? + (fn [params1 params2] + (and (= (set (keys params1)) (set (keys params2))) + (every? (fn [[k v]] + (let [other (get params2 k)] + (if (and (number? v) (number? other)) + (close-val? v other) + (= v other)))) + params1)))] + (and (= (count segments1) (count segments2)) + (every? (fn [[s1 s2]] + (and (= (:command s1) (:command s2)) + (close-params? (:params s1) (:params s2)))) + (d/zip segments1 segments2)))) + :else (= val1 val2))))) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 3757b6c692..6d160cb25e 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -606,8 +606,9 @@ (defn set-shape-attr "Assign attribute to shape with touched logic. - The returned shape will contain a metadata associated with it - indicating if shape is touched or not." + The returned shape carries in its metadata (`::touched`) whether this or + an earlier attribute set on it changed something component sync reads; + `:position-data`, re-measured from the content, never does." [shape attr val & {:keys [ignore-touched ignore-geometry]}] (let [type (get shape :type) group (ctk/resolve-sync-group type attr) @@ -643,6 +644,7 @@ touched? (and group (not equal?) + (not= attr :position-data) (not (and ignore-geometry is-geometry?))) ;; Depending on the origin of the attribute change, we need or not to @@ -686,4 +688,4 @@ (assoc attr val) :always - (vary-meta assoc ::touched touched?)))) + (vary-meta update ::touched #(or % touched?))))) diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index ff7aa1ee0e..4da84286c9 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -7,6 +7,7 @@ (ns common-tests.logic.comp-sync-test (:require [app.common.data :as d] + [app.common.files.changes :as ch] [app.common.files.changes-builder :as pcb] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] @@ -19,6 +20,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.file :as ctf] [app.common.types.shape-tree :as ctst] [clojure.test :as t])) @@ -580,3 +582,40 @@ ;; ==== Check (t/is (true? (:hidden main-child2'))) (t/is (= [(thi/id :main-child2) (thi/id :main-child1)] (:shapes main-root'))))) + +(t/deftest test-an-assign-on-a-main-changes-its-component + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root)) + page (thf/current-page file) + change (fn [operation] + {:type :mod-obj + :id (thi/id :main-child) + :page-id (:id page) + :operations [operation]})] + (t/is (= #{(thi/id :component1)} + (ch/components-changed (:data file) (change {:type :set :attr :opacity :val 0.5})))) + (t/is (= #{(thi/id :component1)} + (ch/components-changed (:data file) (change {:type :assign :value {:opacity 0.5}})))) + (t/is (nil? (ch/components-changed (:data file) (change {:type :assign :value {:interactions []}})))))) + +(t/deftest test-which-changes-of-a-main-mark-its-component-modified + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :main-root :main-child) + (ths/update-shape :main-child :position-data [{:x 0 :y 0 :width 3 :height 4 :text "a"}]) + (ctf/update-file-data #(assoc-in % [:components (thi/id :component1) :modified-at] :old))) + page (thf/current-page file) + apply-op (fn [operations] + (-> (thf/apply-changes file {:redo-changes [{:type :mod-obj + :id (thi/id :main-child) + :page-id (:id page) + :operations operations}]} + :validate? false) + (thc/get-component :component1) + :modified-at)) + fills {:type :set :attr :fills :val (ths/sample-fills-color :fill-color "#ff0000")} + pos-data {:type :set :attr :position-data :val [{:x 1 :y 2 :width 3 :height 4 :text "a"}]}] + (t/is (not= :old (apply-op [fills]))) + (t/testing "a re-measured text is not a change of the component" + (t/is (= :old (apply-op [pos-data])))) + (t/testing "any synced attribute of the change counts, not only the last" + (t/is (not= :old (apply-op [fills pos-data])))))) diff --git a/common/test/common_tests/logic/comp_touched_test.cljc b/common/test/common_tests/logic/comp_touched_test.cljc index ff8ec9181a..40bf69268e 100644 --- a/common/test/common_tests/logic/comp_touched_test.cljc +++ b/common/test/common_tests/logic/comp_touched_test.cljc @@ -289,3 +289,46 @@ (t/is (= (:fill-opacity fill') 1)) (t/is (= (:touched copy2-root') nil)) (t/is (= (:touched copy2-child') #{:fill-group})))) + +(t/deftest test-undo-of-a-touch-gives-back-remote-synced + ;; a copy shape synced from a remote main (after a reset below the copy + ;; root, say) loses :remote-synced when it is touched + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root + :copy-root-params {:children-labels [:copy-child]}) + (ths/update-shape :copy-child :remote-synced true)) + page (thf/current-page file) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :copy-child)} + #(assoc % :opacity 0.5) + (:objects page) + {}) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes)] + (t/is (nil? (:remote-synced (ths/get-shape file' :copy-child)))) + (t/is (true? (:remote-synced (ths/get-shape file'' :copy-child)))) + (t/is (nil? (:touched (ths/get-shape file'' :copy-child)))))) + +(t/deftest test-touched-set-by-an-update-then-its-attrs-touch + ;; an update sets the touched groups of a copy shape and changes an + ;; attribute: the attribute touches its group on top of them, whatever the + ;; order of the shape's keys + (doseq [[attr value group] [[:fills (ths/sample-fills-color :fill-color "#ff0000") :fill-group] + [:opacity 0.5 :layer-effects-group] + [:strokes [{:stroke-color "#ff0000" :stroke-opacity 1 :stroke-width 2}] :stroke-group] + [:hidden true :visibility-group] + [:blocked true :modifiable-group] + [:r1 5 :radius-group] + [:name "Renamed" :name-group]]] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root + :copy-root-params {:children-labels [:copy-child]})) + page (thf/current-page file) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :copy-child)} + #(assoc % attr value :touched #{:shadow-group}) + (:objects page) + {}) + file' (thf/apply-changes file changes)] + (t/is (= #{:shadow-group group} (:touched (ths/get-shape file' :copy-child))) (name attr))))) + diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc index 6857a0c0fc..65d64b920d 100644 --- a/common/test/common_tests/types/container_test.cljc +++ b/common/test/common_tests/types/container_test.cljc @@ -6,6 +6,7 @@ (ns common-tests.types.container-test (:require + [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -16,6 +17,7 @@ [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctc] + [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [clojure.test :as t])) @@ -423,3 +425,30 @@ (t/is (= #{:objects} (ctk/diff-components component changed))) (t/is (= #{} (ctk/diff-components component component))))) + +(defn- copy-shape + "A shape of a copy: one with a ref to its main." + [& {:as attrs}] + (assoc (make-shape (uuid/next) attrs) :shape-ref (uuid/next))) + +(defn- touched-by + [shape attr val] + (:touched (ctc/set-shape-attr shape attr val))) + +(t/deftest float-noise-in-a-transform-touches-nothing + (let [shape (copy-shape) + noisy (-> (gmt/matrix) + (assoc :a 1.0000000001 :b 1e-10))] + (t/is (nil? (touched-by shape :transform noisy))) + (t/is (nil? (touched-by shape :transform-inverse noisy))) + (t/is (= #{:geometry-group} + (touched-by shape :transform (gmt/rotate-matrix 30)))))) + +(t/deftest float-noise-in-a-path-touches-nothing + (let [content (fn [x] + (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x x :y 10}}])) + shape (copy-shape :type :path :content (content 10))] + (t/is (nil? (touched-by shape :content (content 10.01)))) + (t/is (= #{:geometry-group} + (touched-by shape :content (content 30))))))