From 63a339dd31153f279aa872457d21a76e35867ca1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Feb 2020 17:44:43 +0100 Subject: [PATCH] :recycle: Add undo/redo. Reimplement :mov-shape change type operation. --- .../tests/uxbox/tests/test_common_pages.clj | 163 +++++++++ common/uxbox/common/pages.cljc | 33 +- frontend/src/uxbox/main/data/workspace.cljs | 308 ++++++++++-------- .../uxbox/main/ui/workspace/shortcuts.cljs | 6 +- .../main/ui/workspace/sidebar/layers.cljs | 5 +- 5 files changed, 348 insertions(+), 167 deletions(-) create mode 100644 backend/tests/uxbox/tests/test_common_pages.clj diff --git a/backend/tests/uxbox/tests/test_common_pages.clj b/backend/tests/uxbox/tests/test_common_pages.clj new file mode 100644 index 0000000000..a2e210d527 --- /dev/null +++ b/backend/tests/uxbox/tests/test_common_pages.clj @@ -0,0 +1,163 @@ +;; 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) 2019 Andrey Antukh + +(ns uxbox.tests.test-common-pages + (:require + [clojure.test :as t] + [promesa.core :as p] + [mockery.core :refer [with-mock]] + [uxbox.common.pages :as cp] + [uxbox.util.uuid :as uuid] + [uxbox.tests.helpers :as th])) + +(t/deftest process-change-add-shape + (let [data cp/default-page-data + id (uuid/next) + chg {:type :add-shape + :id id + :session-id (uuid/next) + :shape {:id id + :type :rect + :name "rect"}} + res (cp/process-changes data [chg])] + + (t/is (= 1 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + + (t/is (= id (get-in res [:shapes 0]))) + (t/is (= (:shape chg) + (get-in res [:shapes-by-id id]))))) + +(t/deftest process-change-add-canvas + (let [data cp/default-page-data + id (uuid/next) + chg {:type :add-canvas + :id id + :session-id (uuid/next) + :shape {:id id + :type :rect + :name "rect"}} + res (cp/process-changes data [chg])] + (t/is (= 0 (count (:shapes res)))) + (t/is (= 1 (count (:canvas res)))) + + (t/is (= id (get-in res [:canvas 0]))) + (t/is (= (:shape chg) + (get-in res [:shapes-by-id id]))))) + + +(t/deftest process-change-mod-shape + (let [id (uuid/next) + data (merge cp/default-page-data + {:shapes [id] + :shapes-by-id {id {:id id + :type :rect + :name "rect"}}}) + + chg {:type :mod-shape + :id id + :session-id (uuid/next) + :operations [[:set :name "foobar"]]} + res (cp/process-changes data [chg])] + + (t/is (= 1 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + (t/is (= "foobar" + (get-in res [:shapes-by-id id :name]))))) + +(t/deftest process-change-mod-opts + (t/testing "mod-opts add" + (let [data cp/default-page-data + chg {:type :mod-opts + :session-id (uuid/next) + :operations [[:set :foo "bar"]]} + res (cp/process-changes data [chg])] + + (t/is (= 0 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + (t/is (empty? (:shapes-by-id res))) + (t/is (= "bar" (get-in res [:options :foo]))))) + + (t/testing "mod-opts set nil" + (let [data (merge cp/default-page-data + {:options {:foo "bar"}}) + chg {:type :mod-opts + :session-id (uuid/next) + :operations [[:set :foo nil]]} + res (cp/process-changes data [chg])] + + (t/is (= 0 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + (t/is (empty? (:shapes-by-id res))) + (t/is (not (contains? (:options res) :foo))))) + ) + + +(t/deftest process-change-del-shape + (let [id (uuid/next) + data (merge cp/default-page-data + {:shapes [id] + :shapes-by-id {id {:id id + :type :rect + :name "rect"}}}) + chg {:type :del-shape + :id id + :session-id (uuid/next)} + res (cp/process-changes data [chg])] + + (t/is (= 0 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + (t/is (empty? (:shapes-by-id res))))) + +(t/deftest process-change-del-canvas + (let [id (uuid/next) + data (merge cp/default-page-data + {:canvas [id] + :shapes-by-id {id {:id id + :type :canvas + :name "rect"}}}) + chg {:type :del-canvas + :id id + :session-id (uuid/next)} + res (cp/process-changes data [chg])] + + (t/is (= 0 (count (:shapes res)))) + (t/is (= 0 (count (:canvas res)))) + (t/is (empty? (:shapes-by-id res))))) + + +(t/deftest process-change-mov-shape + (let [id1 (uuid/next) + id2 (uuid/next) + id3 (uuid/next) + data (merge cp/default-page-data + {:shapes [id1 id2 id3]})] + + (t/testing "mov-canvas 1" + (let [chg {:type :mov-shape + :id id3 + :index 0 + :session-id (uuid/next)} + res (cp/process-changes data [chg])] + (t/is (= [id3 id1 id2] (:shapes res))))) + + (t/testing "mov-canvas 2" + (let [chg {:type :mov-shape + :id id3 + :index 100 + :session-id (uuid/next)} + res (cp/process-changes data [chg])] + (t/is (= [id1 id2 id3] (:shapes res))))) + + (t/testing "mov-canvas 3" + (let [chg {:type :mov-shape + :id id3 + :index 1 + :session-id (uuid/next)} + res (cp/process-changes data [chg])] + (t/is (= [id1 id3 id2] (:shapes res))))) + )) + diff --git a/common/uxbox/common/pages.cljc b/common/uxbox/common/pages.cljc index f7ec015274..5e77cbdf21 100644 --- a/common/uxbox/common/pages.cljc +++ b/common/uxbox/common/pages.cljc @@ -53,6 +53,7 @@ (s/def ::cy number?) (s/def ::width number?) (s/def ::height number?) +(s/def ::index integer?) (s/def ::shape-attrs (s/keys :opt-un [::blocked @@ -65,7 +66,6 @@ ::font-style ::font-weight ::hidden - ;; ::page-id ?? ::letter-spacing ::line-height ::locked @@ -103,7 +103,6 @@ ;; Changes related (s/def ::operation (s/tuple #{:set} keyword? any?)) -(s/def ::move-after-id (s/nilable uuid?)) (s/def ::operations (s/coll-of ::operation :kind vector?)) @@ -120,7 +119,7 @@ (s/keys :req-un [::id ::operations ::session-id])) (defmethod change-spec-impl :mov-shape [_] - (s/keys :req-un [::id ::move-after-id ::session-id])) + (s/keys :req-un [::id ::index ::session-id])) (defmethod change-spec-impl :mod-opts [_] (s/keys :req-un [::operations ::session-id])) @@ -198,20 +197,6 @@ % operations)) data)) -;; (defn- process-mod-shape -;; [data {:keys [id operations] :as change}] -;; (if-let [shape (get-in data [:shapes-by-id id])] -;; (let [shape (reduce (fn [shape [_ att val]] -;; (if (nil? val) -;; (dissoc shape att) -;; (assoc shape att val))) -;; shape -;; operations)] -;; (if (empty? shape) -;; (update data :shapes-by-id dissoc id) -;; (update data :shapes-by-id assoc id shape))) -;; data)) - (defn- process-mod-opts [data {:keys [operations]}] (update data :options @@ -222,19 +207,19 @@ % operations))) (defn- process-mov-shape - [data {:keys [id move-after-id]}] + [data {:keys [id index]}] (let [shapes (:shapes data) - shapes' (into [] (remove #(= % id) shapes)) - index (d/index-of shapes' move-after-id)] + current-index (d/index-of shapes id) + shapes' (into [] (remove #(= % id) shapes))] (cond - (= id move-after-id) - (assoc data :shapes shapes) + (= index current-index) + data - (nil? index) + (nil? current-index) (assoc data :shapes (d/concat [id] shapes')) :else - (let [[before after] (split-at (inc index) shapes')] + (let [[before after] (split-at index shapes')] (assoc data :shapes (d/concat [] before [id] after)))))) (defn- process-del-shape diff --git a/frontend/src/uxbox/main/data/workspace.cljs b/frontend/src/uxbox/main/data/workspace.cljs index 21909ef92b..f9f6270bf3 100644 --- a/frontend/src/uxbox/main/data/workspace.cljs +++ b/frontend/src/uxbox/main/data/workspace.cljs @@ -165,95 +165,84 @@ (ptk/reify ::handle-page-change ptk/WatchEvent (watch [_ state stream] - (prn "handle-page-change") (let [page-id' (get-in state [:workspace-page :id])] (when (= page-id page-id') (rx/of (shapes-changes-commited msg))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Undo/Redo ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; (def undo-hierarchy -;; (-> (make-hierarchy) -;; (derive ::update-shape ::undo-signal) -;; (derive ::update-options ::undo-signal) -;; (derive ::move-selected-layer ::undo-signal) -;; (derive ::materialize-temporal-modifier-in-bulk ::undo-signal) -;; (derive ::add-shape ::undo-signal) -;; (derive ::add-canvas ::undo-signal))) +(def MAX-UNDO-SIZE 50) -;; (def MAX-UNDO-SIZE 50) +(defn- conj-undo-entry + [undo data] + (let [undo (conj undo data)] + (if (> (count undo) MAX-UNDO-SIZE) + (into [] (take MAX-UNDO-SIZE undo)) + undo))) -;; (defn- conj-undo-entry -;; [undo data] -;; (let [undo (conj undo data)] -;; (if (> (count undo) MAX-UNDO-SIZE) -;; (into [] (take MAX-UNDO-SIZE undo)) -;; undo))) +(defn- materialize-undo + [changes index] + (ptk/reify ::materialize-undo + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-data cp/process-changes changes) + (assoc-in [:workspace-local :undo-index] index))))) -;; ptk/UpdateEvent -;; (update [_ state] -;; (let [pid (get-in state [:workspace-page :id]) -;; data (:workspace-data state) -;; undo (-> (get-in state [:undo pid] []) -;; (conj-undo-entry data))] -;; (prn "diff-and-commit-changes" "undo=" (count undo)) -;; (-> state -;; (assoc-in [:undo pid] undo) -;; (update :workspace-local dissoc :undo-index)))) +(defn- reset-undo + [index] + (ptk/reify ::reset-undo + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-local dissoc :undo-index) + (update-in [:workspace-local :undo] + (fn [queue] + (into [] (take (inc index) queue)))))))) -;; (defn initialize-undo -;; [page-id] -;; (ptk/reify ::initialize-page -;; ptk/WatchEvent -;; (watch [_ state stream] -;; (let [stoper (rx/filter #(or (ptk/type? ::finalize %) -;; (ptk/type? ::initialize-page %)) -;; stream) -;; undo-event? #(or (isa? (ptk/type %) ::undo-signal) -;; (satisfies? IBatchedChange %))] -;; (->> stream -;; (rx/filter #(satisfies? IBatchedChange %)) -;; (rx/debounce 200) -;; (rx/map (constantly diff-and-commit-changes)) -;; (rx/take-until stoper)))))) +(s/def ::undo-changes ::cp/changes) +(s/def ::redo-changes ::cp/changes) +(s/def ::undo-entry + (s/keys :req-un [::undo-changes ::redo-changes])) -;; (def undo -;; (ptk/reify ::undo -;; ptk/UpdateEvent -;; (update [_ state] -;; (let [pid (get-in state [:workspace-page :id]) -;; undo (get-in state [:undo pid] []) -;; index (get-in state [:workspace-local :undo-index]) -;; index (or index (dec (count undo)))] -;; (if (or (empty? undo) (= index 0)) -;; state -;; (let [index (dec index)] -;; (-> state -;; (assoc :workspace-data (nth undo index)) -;; (assoc-in [:workspace-local :undo-index] index)))))))) +(defn- append-undo + [entry] + (us/verify ::undo-entry entry) + (ptk/reify ::append-undo + ptk/UpdateEvent + (update [_ state] + (update-in state [:workspace-local :undo] (fnil conj-undo-entry []) entry)))) + +(def undo + (ptk/reify ::undo + ptk/WatchEvent + (watch [_ state stream] + (let [local (:workspace-local state) + undo (:undo local []) + index (or (:undo-index local) + (dec (count undo)))] + (when-not (or (empty? undo) (= index -1)) + (let [changes (get-in undo [index :undo-changes])] + (rx/of (materialize-undo changes (dec index)) + (commit-changes changes [] false)))))))) + +(def redo + (ptk/reify ::redo + ptk/WatchEvent + (watch [_ state stream] + (let [local (:workspace-local state) + undo (:undo local []) + index (or (:undo-index local) + (dec (count undo)))] + (when-not (or (empty? undo) (= index (dec (count undo)))) + (let [changes (get-in undo [(inc index) :redo-changes])] + (rx/of (materialize-undo changes (inc index)) + (commit-changes changes [] false)))))))) -;; (def redo -;; (ptk/reify ::redo -;; ptk/UpdateEvent -;; (update [_ state] -;; (let [pid (get-in state [:workspace-page :id]) -;; undo (get-in state [:undo pid] []) -;; index (get-in state [:workspace-local :undo-index]) -;; index (or index (dec (count undo)))] -;; (if (or (empty? undo) (= index (dec (count undo)))) -;; state -;; (let [index (inc index)] -;; (-> state -;; (assoc :workspace-data (nth undo index)) -;; (assoc-in [:workspace-local :undo-index] index)))))))) -;; (def reset-undo-index -;; (ptk/reify ::reset-undo-index -;; ptk/UpdateEvent -;; (update [_ state] -;; (update :workspace-local dissoc :undo-index)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Workspace Initialization @@ -341,9 +330,10 @@ ptk/UpdateEvent (update [_ state] (let [page (get-in state [:pages page-id]) - data (get-in state [:pages-data page-id])] + data (get-in state [:pages-data page-id]) + local (get-in state [:workspace-cache page-id] workspace-default)] (assoc state - :workspace-local workspace-default + :workspace-local local :workspace-data data :workspace-page page))) @@ -365,10 +355,24 @@ (ptk/reify ::finalize ptk/UpdateEvent (update [_ state] - state - #_(dissoc state - :workspace-page - :workspace-data)))) + (let [local (:workspace-local state)] + (assoc-in state [:workspace-cache page-id] local))))) + +(defn- generate-changes + [session-id prev curr] + (let [diff (d/diff-maps prev curr)] + (loop [scs (rest diff) + sc (first diff) + res []] + (if (nil? sc) + res + (let [[_ id shape] sc] + (recur (rest scs) + (first scs) + (conj res {:type :mod-shape + :session-id session-id + :operations (d/diff-maps (get prev id) shape) + :id id}))))))) (def diff-and-commit-changes (ptk/reify ::diff-and-commit-changes @@ -377,23 +381,11 @@ (let [pid (get-in state [:workspace-page :id]) curr (get-in state [:workspace-data :shapes-by-id]) prev (get-in state [:pages-data pid :shapes-by-id]) - - diff (d/diff-maps prev curr) - changes (loop [scs (rest diff) - sc (first diff) - res []] - (if (nil? sc) - res - (let [[_ id shape] sc] - (recur (rest scs) - (first scs) - (conj res {:type :mod-shape - :session-id (:session-id state) - :operations (d/diff-maps (get prev id) shape) - :id id})))))] + session-id (:session-id state) + changes (generate-changes session-id prev curr) + undo-changes (generate-changes session-id curr prev)] (when-not (empty? changes) - (rx/of (commit-changes changes))))))) - + (rx/of (commit-changes changes undo-changes))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Fetching & Uploading @@ -924,6 +916,9 @@ (rx/of (commit-changes [{:type :add-shape :session-id sid :shape shape + :id id}] + [{:type :del-shape + :session-id sid :id id}]) (select-shape id))))))) @@ -952,6 +947,9 @@ (rx/of (commit-changes [{:type :add-canvas :session-id sid :shape shape + :id id}] + [{:type :del-canvas + :session-id sid :id id}]))))))) @@ -975,10 +973,15 @@ :id (:id shape) :shape shape :session-id sid}) + shapes) + uchanges (mapv (fn [shape] + {:type :del-shape + :id (:id shape) + :session-id sid}) shapes)] (rx/merge (rx/from (map (fn [s] #(impl-assoc-shape % s)) shapes)) - (rx/of (commit-changes changes))))))) + (rx/of (commit-changes changes uchanges))))))) ;; --- Toggle shape's selection status (selected or deselected) @@ -1151,11 +1154,18 @@ (map (fn [{:keys [type id] :as shape}] {:type (if (= type :canvas) :del-canvas :del-shape) :session-id session-id - :id id})))] + :id id}))) + uchanges (->> selected + (map lookup-shape) + (map (fn [{:keys [type id] :as shape}] + {:type (if (= type :canvas) :add-canvas :add-shape) + :session-id session-id + :shape shape + :id id})))] (rx/concat (rx/of deselect-all) (rx/from (map impl-dissoc-shape selected)) - (rx/of (commit-changes changes))))))) + (rx/of (commit-changes changes uchanges))))))) ;; --- Rename Shape @@ -1164,18 +1174,19 @@ (us/verify ::us/uuid id) (us/verify string? name) (ptk/reify ::rename-shape - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:shapes id :name] name)) - ptk/WatchEvent (watch [_ state stream] - (let [session-id (:session-id state) - change {:type :mod-shape - :id id - :session-id session-id - :operations [[:set :name name]]}] - (rx/of (commit-changes [change])))))) + (let [shape (get-in state [:workspace-data :shapes-by-id id]) + session-id (:session-id state) + change {:type :mod-shape + :id id + :session-id session-id + :operations [[:set :name name]]} + uchange {:type :mod-shape + :id id + :session-id session-id + :operations [[:set :name (:name shape)]]}] + (rx/of (commit-changes [change] [uchange])))))) ;; --- Shape Vertical Ordering @@ -1211,7 +1222,7 @@ ;; --- Change Shape Order (D&D Ordering) -(defn temporal-shape-order-change +(defn shape-order-change [id index] (us/verify ::us/uuid id) (us/verify number? index) @@ -1221,22 +1232,31 @@ (let [shapes (get-in state [:workspace-data :shapes]) shapes (into [] (remove #(= % id)) shapes) [before after] (split-at index shapes) - shapes (d/concat [] before [id] after) - change {:type :mov-shape - :session-id (:session-id state) - :move-after-id (last before) - :id id}] - (-> state - (assoc-in [:workspace-data :shapes] shapes) - (assoc ::tmp-shape-order-change change)))))) + shapes (d/concat [] before [id] after)] + (assoc-in state [:workspace-data :shapes] shapes))))) -(def commit-shape-order-change +(defn commit-shape-order-change + [id] (ptk/reify ::commit-shape-order-change ptk/WatchEvent (watch [_ state stream] - (let [change (::tmp-shape-order-change state)] - (rx/of #(dissoc state ::tmp-shape-order-change) - (commit-changes [change])))))) + (let [page-id (get-in state [:workspace-page :id]) + curr-shapes (get-in state [:workspace-data :shapes]) + prev-shapes (get-in state [:pages-data page-id :shapes]) + + curr-index (d/index-of curr-shapes id) + prev-index (d/index-of prev-shapes id) + session-id (:session-id state) + + change {:type :mov-shape + :session-id session-id + :id id + :index curr-index} + uchange {:type :mov-shape + :session-id session-id + :id id + :index prev-index}] + (rx/of (commit-changes [change] [uchange])))))) ;; --- Change Canvas Order (D&D Ordering) @@ -1326,23 +1346,35 @@ (reduce process-shape state ids))))) (defn commit-changes - [changes] - (us/verify ::cp/changes changes) - (ptk/reify ::commit-changes - ptk/UpdateEvent - (update [_ state] - (let [pid (get-in state [:workspace-page :id]) - data (get-in state [:pages-data pid])] - (update-in state [:pages-data pid] cp/process-changes changes))) + ([changes undo-changes] (commit-changes changes undo-changes true)) + ([changes undo-changes save-undo?] + (us/verify ::cp/changes changes) + (us/verify ::cp/changes undo-changes) + (ptk/reify ::commit-changes + ptk/UpdateEvent + (update [_ state] + (let [pid (get-in state [:workspace-page :id]) + data (get-in state [:pages-data pid])] + (update-in state [:pages-data pid] cp/process-changes changes))) - ptk/WatchEvent - (watch [_ state stream] - (let [page (:workspace-page state) - params {:id (:id page) - :revn (:revn page) - :changes (vec changes)}] - (->> (rp/mutation :update-page params) - (rx/map shapes-changes-commited)))))) + ptk/WatchEvent + (watch [_ state stream] + (let [page (:workspace-page state) + uidx (get-in state [:workspace-local :undo-index] ::not-found) + params {:id (:id page) + :revn (:revn page) + :changes (vec changes)}] + (rx/concat + (when (and save-undo? (not= uidx ::not-found)) + (rx/of (reset-undo uidx))) + + (when save-undo? + (let [entry {:undo-changes undo-changes + :redo-changes changes}] + (rx/of (append-undo entry)))) + + (->> (rp/mutation :update-page params) + (rx/map shapes-changes-commited)))))))) (s/def ::shapes-changes-commited (s/keys :req-un [::page-id ::revn ::cp/changes])) diff --git a/frontend/src/uxbox/main/ui/workspace/shortcuts.cljs b/frontend/src/uxbox/main/ui/workspace/shortcuts.cljs index f2e4397150..19f4cdfe25 100644 --- a/frontend/src/uxbox/main/ui/workspace/shortcuts.cljs +++ b/frontend/src/uxbox/main/ui/workspace/shortcuts.cljs @@ -31,9 +31,9 @@ :ctrl+0 #(st/emit! (dw/reset-zoom)) ;; :ctrl+r #(st/emit! (dw/toggle-flag :ruler)) :ctrl+d #(st/emit! dw/duplicate-selected) - ;; :ctrl+z #(st/emit! dw/undo) - ;; :ctrl+shift+z #(st/emit! dw/redo) - ;; :ctrl+y #(st/emit! du/redo) + :ctrl+z #(st/emit! dw/undo) + :ctrl+shift+z #(st/emit! dw/redo) + :ctrl+y #(st/emit! dw/redo) :ctrl+b #(st/emit! (dw/select-for-drawing :rect)) :ctrl+e #(st/emit! (dw/select-for-drawing :circle)) :ctrl+t #(st/emit! (dw/select-for-drawing :text)) diff --git a/frontend/src/uxbox/main/ui/workspace/sidebar/layers.cljs b/frontend/src/uxbox/main/ui/workspace/sidebar/layers.cljs index dae036fa51..94d030c021 100644 --- a/frontend/src/uxbox/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/uxbox/main/ui/workspace/sidebar/layers.cljs @@ -118,11 +118,12 @@ on-drop (fn [item monitor] - (st/emit! dw/commit-shape-order-change)) + (prn "index" index) + (st/emit! (dw/commit-shape-order-change (:shape-id item)))) on-hover (fn [item monitor] - (st/emit! (dw/temporal-shape-order-change (:shape-id item) index))) + (st/emit! (dw/shape-order-change (:shape-id item) index))) [dprops dnd-ref] (use-sortable {:type "layer-item"