diff --git a/common/app/common/data.cljc b/common/app/common/data.cljc index cef92e02e3..ba0b92dc29 100644 --- a/common/app/common/data.cljc +++ b/common/app/common/data.cljc @@ -182,6 +182,13 @@ (assoc m key (apply f found args)) m))) +(defn assoc-when + [m key v] + (let [found (get m key sentinel)] + (if-not (identical? sentinel found) + (assoc m key v) + m))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Parsing / Conversion ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/app/common/pages.cljc b/common/app/common/pages.cljc index 3e93ec8313..6b6428c127 100644 --- a/common/app/common/pages.cljc +++ b/common/app/common/pages.cljc @@ -44,6 +44,9 @@ (integer? %) (>= % min-safe-int) (<= % max-safe-int))) +(s/def ::component-id uuid?) +(s/def ::component-file uuid?) +(s/def ::shape-ref uuid?) (s/def ::safe-number #(and @@ -216,7 +219,10 @@ (s/def ::shape (s/and ::minimal-shape ::shape-attrs - (s/keys :opt-un [::id]))) + (s/keys :opt-un [::id + ::component-id + ::component-file + ::shape-ref]))) (s/def :internal.page/objects (s/map-of uuid? ::shape)) @@ -356,6 +362,18 @@ (defmethod change-spec :del-media [_] (s/keys :req-un [::id])) +(s/def :internal.changes.add-component/shapes + (s/coll-of ::shape)) + +(defmethod change-spec :add-component [_] + (s/keys :req-un [::id ::name :internal.changes.add-component/shapes])) + +(defmethod change-spec :del-component [_] + (s/keys :req-un [::id])) + +(defmethod change-spec :update-component [_] + (s/keys :req-un [::id ::name :internal.changes.add-component/shapes])) + (s/def ::change (s/multi-spec change-spec :type)) (s/def ::changes (s/coll-of ::change)) @@ -473,6 +491,18 @@ :points [] :segments []))) +(defn make-minimal-group + [frame-id selection-rect group-name] + {:id (uuid/next) + :type :group + :name group-name + :shapes [] + :frame-id frame-id + :x (:x selection-rect) + :y (:y selection-rect) + :width (:width selection-rect) + :height (:height selection-rect)}) + (defn make-file-data ([] (make-file-data (uuid/next))) ([id] @@ -745,6 +775,24 @@ [data {:keys [id]}] (update data :media dissoc id)) +(defmethod process-change :add-component + [data {:keys [id name shapes]}] + (assoc-in data [:components id] + {:id id + :name name + :objects (d/index-by :id shapes)})) + +(defmethod process-change :del-component + [data {:keys [id]}] + (d/dissoc-in data [:components id])) + +(defmethod process-change :update-component + [data {:keys [id name shapes]}] + (update-in data [:components id] + #(assoc % + :name name + :objects (d/index-by :id shapes)))) + (defmethod process-operation :set [shape op] (let [attr (:attr op) diff --git a/common/app/common/pages_helpers.cljc b/common/app/common/pages_helpers.cljc index 27cc1836a8..53be7402a0 100644 --- a/common/app/common/pages_helpers.cljc +++ b/common/app/common/pages_helpers.cljc @@ -12,14 +12,53 @@ [app.common.data :as d] [app.common.uuid :as uuid])) +(defn walk-pages + "Go through all pages of a file and apply a function to each one" + ;; The function receives two parameters (page-id and page), and + ;; returns the updated page. + [f data] + (update data :pages-index #(d/mapm f %))) + +(defn select-objects + "Get a list of all objects in a page that satisfy a condition" + [f page] + (filter f (vals (get page :objects)))) + +(defn update-object-list + "Update multiple objects in a page at once" + [page objects-list] + (update page :objects + #(into % (d/index-by :id objects-list)))) + +(defn get-root-component + "Get the root shape linked to the component for this shape, if any" + [id objects] + (let [obj (get objects id)] + (if-let [component-id (:component-id obj)] + id + (if-let [parent-id (:parent-id obj)] + (get-root-component parent-id obj) + nil)))) + (defn get-children "Retrieve all children ids recursively for a given object" [id objects] - (let [shapes (get-in objects [id :shapes])] + ;; TODO: find why does this sometimes come as a list instead of vector + (let [shapes (vec (get-in objects [id :shapes]))] (if shapes (d/concat shapes (mapcat #(get-children % objects) shapes)) []))) +(defn get-children-objects + "Retrieve all children objects recursively for a given object" + [id objects] + (map #(get objects %) (get-children id objects))) + +(defn get-object-with-children + "Retrieve a list with an object and all of its children" + [id objects] + (map #(get objects %) (concat [id] (get-children id objects)))) + (defn is-shape-grouped "Checks if a shape is inside a group" [shape-id objects] @@ -113,3 +152,55 @@ (lazy-seq (loopfn (rest ids))))))] (loopfn (:shapes root)))) +(defn clone-object + "Gets a copy of the object and all its children, with new ids + and with the parent-children links correctly set. Admits functions + to make more transformations to the cloned objects and the + original ones. + + Returns the cloned object, the list of all new objects (including + the cloned one), and possibly a list of original objects modified." + + ([object parent-id objects update-new-object] + (clone-object object parent-id objects update-new-object identity)) + + ([object parent-id objects update-new-object update-original-object] + (let [new-id (uuid/next)] + (loop [child-ids (seq (:shapes object)) + new-direct-children [] + new-children [] + updated-children []] + + (if (empty? child-ids) + (let [new-object (cond-> object + true + (assoc :id new-id + :parent-id parent-id) + + (some? (:shapes object)) + (assoc :shapes (map :id new-direct-children))) + + new-object (update-new-object new-object object) + + new-objects (concat [new-object] new-children) + + updated-object (update-original-object object new-object) + + updated-objects (if (= object updated-object) + updated-children + (concat [updated-object] updated-children))] + + [new-object new-objects updated-objects]) + + (let [child-id (first child-ids) + child (get objects child-id) + + [new-child new-child-objects updated-child-objects] + (clone-object child new-id objects update-new-object update-original-object)] + + (recur + (next child-ids) + (concat new-direct-children [new-child]) + (concat new-children new-child-objects) + (concat updated-children updated-child-objects)))))))) + diff --git a/common/app/common/pages_migrations.cljc b/common/app/common/pages_migrations.cljc index a9dd9b21b2..33171c9fb2 100644 --- a/common/app/common/pages_migrations.cljc +++ b/common/app/common/pages_migrations.cljc @@ -52,4 +52,3 @@ ;; (assoc obj :parent-id parent-id))) ;; objects))))) - diff --git a/frontend/resources/images/icons/component.svg b/frontend/resources/images/icons/component.svg new file mode 100644 index 0000000000..2042881f9b --- /dev/null +++ b/frontend/resources/images/icons/component.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 635cc19c91..0e96a75837 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -288,7 +288,7 @@ } }, "dashboard.grid.add-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:146", "src/app/main/ui/dashboard/grid.cljs:166" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:166", "src/app/main/ui/workspace/header.cljs:146" ], "translations" : { "en" : "Add as Shared Library", "fr" : "", @@ -297,7 +297,7 @@ } }, "dashboard.grid.add-shared-accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:69", "src/app/main/ui/dashboard/grid.cljs:95" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:95", "src/app/main/ui/workspace/header.cljs:69" ], "translations" : { "en" : "Add as Shared Library", "fr" : "", @@ -306,7 +306,7 @@ } }, "dashboard.grid.add-shared-hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:68", "src/app/main/ui/dashboard/grid.cljs:94" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:94", "src/app/main/ui/workspace/header.cljs:68" ], "translations" : { "en" : "Once added as Shared Library, the assets of this file library will be available to be used among the rest of your files.", "fr" : "", @@ -315,7 +315,7 @@ } }, "dashboard.grid.add-shared-message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:67", "src/app/main/ui/dashboard/grid.cljs:93" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:93", "src/app/main/ui/workspace/header.cljs:67" ], "translations" : { "en" : "Add ā€œ%sā€ as Shared Library", "fr" : "", @@ -342,7 +342,7 @@ } }, "dashboard.grid.remove-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:144", "src/app/main/ui/dashboard/grid.cljs:165" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:165", "src/app/main/ui/workspace/header.cljs:144" ], "translations" : { "en" : "Remove as Shared Library", "fr" : "", @@ -351,7 +351,7 @@ } }, "dashboard.grid.remove-shared-accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:78", "src/app/main/ui/dashboard/grid.cljs:114" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:114", "src/app/main/ui/workspace/header.cljs:78" ], "translations" : { "en" : "Remove as Shared Library", "fr" : "", @@ -360,7 +360,7 @@ } }, "dashboard.grid.remove-shared-hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:77", "src/app/main/ui/dashboard/grid.cljs:113" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:113", "src/app/main/ui/workspace/header.cljs:77" ], "translations" : { "en" : "Once removed as Shared Library, the File Library of this file will stop being available to be used among the rest of your files.", "fr" : "", @@ -369,7 +369,7 @@ } }, "dashboard.grid.remove-shared-message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:76", "src/app/main/ui/dashboard/grid.cljs:112" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:112", "src/app/main/ui/workspace/header.cljs:76" ], "translations" : { "en" : "Remove ā€œ%sā€ as Shared Library", "fr" : "", @@ -621,6 +621,7 @@ "unused" : true }, "ds.button.save" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:66" ], "translations" : { "en" : "Save", "fr" : "Sauvegarder", @@ -774,7 +775,7 @@ } }, "errors.media-type-mismatch" : { - "used-in" : [ "src/app/main/data/media.cljs:62", "src/app/main/data/workspace/persistence.cljs:352" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:352", "src/app/main/data/media.cljs:62" ], "translations" : { "en" : "Seems that the contents of the image does not match the file extension.", "fr" : "", @@ -783,7 +784,7 @@ } }, "errors.media-type-not-allowed" : { - "used-in" : [ "src/app/main/data/media.cljs:59", "src/app/main/data/workspace/persistence.cljs:349" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:349", "src/app/main/data/media.cljs:59" ], "translations" : { "en" : "Seems that this is not a valid image.", "fr" : "", @@ -828,7 +829,7 @@ } }, "errors.unexpected-error" : { - "used-in" : [ "src/app/main/data/media.cljs:65", "src/app/main/ui/settings/change_email.cljs:51", "src/app/main/ui/auth/register.cljs:54", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66" ], + "used-in" : [ "src/app/main/data/media.cljs:65", "src/app/main/ui/settings/change_email.cljs:51", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66", "src/app/main/ui/auth/register.cljs:54" ], "translations" : { "en" : "An unexpected error occurred.", "fr" : "Une erreur inattendue c'est produite", @@ -873,7 +874,7 @@ } }, "media.loading" : { - "used-in" : [ "src/app/main/data/media.cljs:44", "src/app/main/data/workspace/persistence.cljs:334" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:334", "src/app/main/data/media.cljs:44" ], "translations" : { "en" : "Loading image...", "fr" : "Chargement de l'image...", @@ -882,6 +883,7 @@ } }, "modal.create-color.new-color" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:59" ], "translations" : { "en" : "New Color", "fr" : "Nouvelle couleur", @@ -1458,7 +1460,7 @@ } }, "workspace.assets.assets" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:374" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:476" ], "translations" : { "en" : "Assets", "fr" : "", @@ -1467,7 +1469,7 @@ } }, "workspace.assets.box-filter-all" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:394" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:496" ], "translations" : { "en" : "All assets", "fr" : "", @@ -1476,7 +1478,7 @@ } }, "workspace.assets.box-filter-colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:396" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:498" ], "translations" : { "en" : "Colors", "fr" : "", @@ -1485,7 +1487,7 @@ } }, "workspace.assets.box-filter-graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:395" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:497" ], "translations" : { "en" : "Graphics", "fr" : "", @@ -1494,7 +1496,7 @@ } }, "workspace.assets.colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:247" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:324" ], "translations" : { "en" : "Colors", "fr" : "", @@ -1502,8 +1504,17 @@ "es" : "Colores" } }, + "workspace.assets.components" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:106" ], + "translations" : { + "en" : "Components", + "fr" : "", + "ru" : "", + "es" : "Componentes" + } + }, "workspace.assets.delete" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:125", "src/app/main/ui/workspace/sidebar/assets.cljs:224" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:125", "src/app/main/ui/workspace/sidebar/assets.cljs:210", "src/app/main/ui/workspace/sidebar/assets.cljs:304" ], "translations" : { "en" : "Delete", "fr" : "", @@ -1512,7 +1523,7 @@ } }, "workspace.assets.edit" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:223" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:303" ], "translations" : { "en" : "Edit", "fr" : "", @@ -1521,7 +1532,7 @@ } }, "workspace.assets.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:309" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:401" ], "translations" : { "en" : "File library", "fr" : "", @@ -1530,7 +1541,7 @@ } }, "workspace.assets.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:99" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:184" ], "translations" : { "en" : "Graphics", "fr" : "", @@ -1539,7 +1550,7 @@ } }, "workspace.assets.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:377" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:479" ], "translations" : { "en" : "Libraries", "fr" : "", @@ -1548,7 +1559,7 @@ } }, "workspace.assets.not-found" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:339" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:440" ], "translations" : { "en" : "No assets found", "fr" : "", @@ -1557,7 +1568,7 @@ } }, "workspace.assets.rename" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:222" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:302" ], "translations" : { "en" : "Rename", "fr" : "", @@ -1566,7 +1577,7 @@ } }, "workspace.assets.search" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:381" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:483" ], "translations" : { "en" : "Search assets", "fr" : "", @@ -1575,7 +1586,7 @@ } }, "workspace.assets.shared" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:311" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:403" ], "translations" : { "en" : "SHARED", "fr" : "", diff --git a/frontend/resources/styles/common/dependencies/colors.scss b/frontend/resources/styles/common/dependencies/colors.scss index 57cc27f167..0e8a21688e 100644 --- a/frontend/resources/styles/common/dependencies/colors.scss +++ b/frontend/resources/styles/common/dependencies/colors.scss @@ -20,6 +20,8 @@ $color-warning: #FC8802; $color-danger: #E65244; $color-info: #59b9e2; $color-ocean: #4285f4; +$color-component: #76B0B8; +$color-component-highlight: #00E0FF; // Gray scale $color-gray-10: #E3E3E3; diff --git a/frontend/resources/styles/main/partials/sidebar-assets.scss b/frontend/resources/styles/main/partials/sidebar-assets.scss index 621db56e64..d4d6ff4ed9 100644 --- a/frontend/resources/styles/main/partials/sidebar-assets.scss +++ b/frontend/resources/styles/main/partials/sidebar-assets.scss @@ -170,6 +170,19 @@ grid-auto-rows: 7vh; column-gap: 0.5rem; row-gap: 0.5rem; + + &.big { + grid-template-columns: 1fr 1fr; + grid-auto-rows: 10vh; + + .grid-cell { + padding: $x-small; + + & svg { + height: 10vh; + } + } + } } .grid-cell { diff --git a/frontend/resources/styles/main/partials/sidebar-layers.scss b/frontend/resources/styles/main/partials/sidebar-layers.scss index 6658ff4f2a..ea8e23fd40 100644 --- a/frontend/resources/styles/main/partials/sidebar-layers.scss +++ b/frontend/resources/styles/main/partials/sidebar-layers.scss @@ -20,42 +20,42 @@ margin-right: 8px; width: 13px; } - + &.group { &.open { .toggle-content { flex-shrink: 0; - + svg { transform: rotate(270deg); } } } } - + &:hover { background-color: $color-primary; - + svg { fill: $color-gray-60 !important; } - + .element-icon, .element-actions { - + svg { fill: $color-gray-60; } } - + .element-actions > * { display: flex; } - + span { color: $color-gray-60; } - + .toggle-content { svg { fill: $color-gray-60; @@ -64,13 +64,12 @@ } &.selected { - + svg { fill: $color-primary; } - + .element-icon { - svg { fill: $color-primary; } @@ -79,10 +78,10 @@ span { color: $color-primary; } - + &:hover { background-color: $color-primary; - + .element-icon, .element-actions { svg { @@ -95,20 +94,55 @@ } } } - + &.drag-top { border-top: 40px solid $color-gray-60 !important; } - + &.drag-bottom { border-bottom: 40px solid $color-gray-60 !important; } - + &.drag-inside { border: 2px solid $color-primary !important; } } +.element-list li.component { + + .element-list-body { + span.element-name { + color: $color-component; + } + + svg { + fill: $color-component; + } + + &.selected { + span.element-name { + color: $color-component-highlight; + } + + svg { + fill: $color-component-highlight; + } + } + + &:hover { + background-color: $color-component-highlight; + + span.element-name { + color: $color-gray-60; + } + + svg { + fill: $color-gray-60; + } + } + } +} + .element-icon { svg { fill: $color-gray-30; @@ -132,7 +166,7 @@ span.element-name { white-space: nowrap; width: 100%; } - + .element-actions { display: flex; flex-shrink: 0; @@ -149,13 +183,13 @@ span.element-name { > * { display: none; } - + .toggle-element, .block-element { left: 0; position: absolute; top: 0; - + &.selected { display: flex; @@ -177,17 +211,17 @@ span.element-name { .toggle-content { margin-left: auto; width: 12px; - + svg { fill: $color-gray-20; transform: rotate(90deg); width: 10px; } - + &.inverse { svg { transform: rotate(270deg); } } - + &:hover { svg { fill: $color-gray-60; diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index ecf1a335c2..ed006e4a1b 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -22,12 +22,13 @@ [app.config :as cfg] [app.main.constants :as c] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.persistence :as dwp] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.transforms :as dwt] - [app.main.data.colors :as dwl] + [app.main.data.colors :as mdc] [app.main.repo :as rp] [app.main.store :as st] [app.main.streams :as ms] @@ -47,10 +48,6 @@ (s/def ::set-of-string (s/every string? :kind set?)) -;; --- Expose inner functions - -(defn interrupt? [e] (= e :interrupt)) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Workspace Initialization ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -949,7 +946,7 @@ ptk/WatchEvent (watch [_ state stream] (->> stream - (rx/filter interrupt?) + (rx/filter dwc/interrupt?) (rx/take 1) (rx/map (constantly clear-edition-mode)))))) @@ -978,7 +975,7 @@ ptk/WatchEvent (watch [_ state stream] (let [cancel-event? (fn [event] - (interrupt? event)) + (dwc/interrupt? event)) stoper (rx/filter (ptk/type? ::clear-drawing) stream)] (->> (rx/filter cancel-event? stream) (rx/take 1) @@ -1127,8 +1124,14 @@ (ptk/reify ::show-context-menu ptk/UpdateEvent (update [_ state] - (let [mdata {:position position + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + root-id (cph/get-root-component (:id shape) objects) + root-shape (get objects root-id) + + mdata {:position position :shape shape + :root-shape root-shape :selected (get-in state [:workspace-local :selected])}] (-> state (assoc-in [:workspace-local :context-menu] mdata)))) @@ -1260,70 +1263,19 @@ ;; GROUPS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn group-shape - [id frame-id selected selection-rect] - {:id id - :type :group - :name (name (gensym "Group-")) - :shapes [] - :frame-id frame-id - :x (:x selection-rect) - :y (:y selection-rect) - :width (:width selection-rect) - :height (:height selection-rect)}) - (def group-selected (ptk/reify ::group-selected ptk/WatchEvent (watch [_ state stream] - (let [id (uuid/next) - page-id (:current-page-id state) + (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) selected (get-in state [:workspace-local :selected]) - items (->> selected - (map #(get objects %)) - (filter #(not= :frame (:type %))) - (map #(assoc % ::index (cph/position-on-parent (:id %) objects))) - (sort-by ::index))] - - (when (not-empty items) - (let [selrect (geom/selection-rect items) - frame-id (-> items first :frame-id) - parent-id (-> items first :parent-id) - group (-> (group-shape id frame-id selected selrect) - (geom/setup selrect)) - - index (::index (first items)) - - rchanges [{:type :add-obj - :id id - :page-id page-id - :frame-id frame-id - :parent-id parent-id - :obj group - :index index} - {:type :mov-objects - :page-id page-id - :parent-id id - :shapes (->> items - (map :id) - (into #{}) - (vec))}] - - uchanges - (reduce (fn [res obj] - (conj res {:type :mov-objects - :page-id page-id - :parent-id (:parent-id obj) - :index (::index obj) - :shapes [(:id obj)]})) - [] - items) - - uchanges (conj uchanges {:type :del-obj :id id :page-id page-id})] - + shapes (dws/shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [[group rchanges uchanges] + (dws/prepare-create-group page-id shapes "Group-" false)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set id))))))))) + (dws/select-shapes (d/ordered-set (:id group)))))))))) (def ungroup-selected (ptk/reify ::ungroup-selected @@ -1336,34 +1288,11 @@ group (get objects group-id)] (when (and (= 1 (count selected)) (= (:type group) :group)) - (let [shapes (:shapes group) - parent-id (cph/get-parent group-id objects) - parent (get objects parent-id) - index-in-parent (->> (:shapes parent) - (map-indexed vector) - (filter #(#{group-id} (second %))) - (ffirst)) - rchanges [{:type :mov-objects - :page-id page-id - :parent-id parent-id - :shapes shapes - :index index-in-parent}] - uchanges [{:type :add-obj - :page-id page-id - :id group-id - :frame-id (:frame-id group) - :obj (assoc group :shapes [])} - {:type :mov-objects - :page-id page-id - :parent-id group-id - :shapes shapes} - {:type :mov-objects - :page-id page-id - :parent-id parent-id - :shapes [group-id] - :index index-in-parent}]] + (let [[rchanges uchanges] + (dws/prepare-remove-group page-id group objects)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Interactions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1506,6 +1435,7 @@ "+" #(st/emit! (increase-zoom nil)) "-" #(st/emit! (decrease-zoom nil)) "ctrl+g" #(st/emit! group-selected) + "ctrl+k" #(st/emit! dwl/add-component) "shift+g" #(st/emit! ungroup-selected) "shift+0" #(st/emit! reset-zoom) "shift+1" #(st/emit! zoom-to-fit-all) @@ -1537,5 +1467,5 @@ "right" #(st/emit! (dwt/move-selected :right false)) "left" #(st/emit! (dwt/move-selected :left false)) - "i" #(st/emit! (dwl/picker-for-selected-shape ))}) + "i" #(st/emit! (mdc/picker-for-selected-shape ))}) diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index 0bae8964c6..726f037862 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -44,6 +44,11 @@ ([state page-id] (get-in state [:workspace-data :pages-index page-id :options]))) +(defn interrupt? [e] (= e :interrupt)) + +(defn lookup-component-objects + ([state component-id] + (get-in state [:workspace-data :components component-id :objects]))) ;; --- Changes Handling @@ -454,3 +459,4 @@ objects (lookup-page-objects state page-id) [rchanges uchanges] (impl-gen-changes objects page-id (seq ids))] (rx/of (commit-changes rchanges uchanges {:commit-local? true}))))))) + diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 94636042dd..11d1df3c7a 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -12,12 +12,18 @@ [app.common.data :as d] [app.common.spec :as us] [app.common.uuid :as uuid] + [app.common.pages-helpers :as cph] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as geom] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.selection :as dws] [app.common.pages :as cp] [app.main.repo :as rp] [app.main.store :as st] + [app.main.streams :as ms] [app.util.color :as color] [app.util.i18n :refer [tr]] + [app.util.router :as rt] [beicon.core :as rx] [cljs.spec.alpha :as s] [potok.core :as ptk])) @@ -68,7 +74,7 @@ (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) (defn delete-color - [{:keys [id] :as color}] + [{:keys [id] :as params}] (us/assert ::us/uuid id) (ptk/reify ::delete-color ptk/WatchEvent @@ -94,7 +100,7 @@ (defn delete-media - [{:keys [id] :as media}] + [{:keys [id] :as params}] (us/assert ::us/uuid id) (ptk/reify ::delete-media ptk/WatchEvent @@ -106,3 +112,502 @@ :object prev}] (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) +(declare make-component-shape) + +(def add-component + (ptk/reify ::add-component + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + selected (get-in state [:workspace-local :selected]) + shapes (dws/shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [;; If the selected shape is a group, we can use it. If not, + ;; we need to create a group before creating the component. + [group rchanges uchanges] + (if (and (= (count shapes) 1) + (= (:type (first shapes)) :group)) + [(first shapes) [] []] + (dws/prepare-create-group page-id shapes "Component-" true)) + + [new-shape new-shapes updated-shapes] + (make-component-shape group nil objects) + + rchanges (conj rchanges + {:type :add-component + :id (:id new-shape) + :name (:name new-shape) + :shapes new-shapes}) + + rchanges (into rchanges + (map (fn [updated-shape] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val (:component-id updated-shape)} + {:type :set + :attr :component-file + :val nil} + {:type :set + :attr :shape-ref + :val (:shape-ref updated-shape)}]}) + updated-shapes)) + + uchanges (conj uchanges + {:type :del-component + :id (:id new-shape)}) + + uchanges (into uchanges + (map (fn [updated-shape] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val nil} + {:type :set + :attr :component-file + :val nil} + {:type :set + :attr :shape-ref + :val nil}]}) + updated-shapes))] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dws/select-shapes (d/ordered-set (:id group)))))))))) + +(defn- make-component-shape + "Clone the shape and all children. Generate new ids and detach + from parent and frame. Update the original shapes to have links + to the new ones." + [shape parent-id objects] + (let [update-new-shape (fn [new-shape original-shape] + (assoc new-shape :frame-id nil)) + + update-original-shape (fn [original-shape new-shape] + (cond-> original-shape + true + (assoc :shape-ref (:id new-shape)) + + (nil? (:parent-id new-shape)) + (assoc :component-id (:id new-shape))))] + + (cph/clone-object shape parent-id objects update-new-shape update-original-shape))) + +(defn delete-component + [{:keys [id] :as params}] + (us/assert ::us/uuid id) + (ptk/reify ::delete-component + ptk/WatchEvent + (watch [_ state stream] + (let [component (get-in state [:workspace-data :components id]) + + rchanges [{:type :del-component + :id id}] + + uchanges [{:type :add-component + :id id + :name (:name component) + :shapes (vals (:objects component))}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn instantiate-component + [file-id component-id] + (us/assert (s/nilable ::us/uuid) file-id) + (us/assert ::us/uuid component-id) + (ptk/reify ::instantiate-component + ptk/WatchEvent + (watch [_ state stream] + (let [component (if (nil? file-id) + (get-in state [:workspace-data :components component-id]) + (get-in state [:workspace-libraries file-id :data :components component-id])) + component-shape (get-in component [:objects (:id component)]) + + orig-pos (gpt/point (:x component-shape) (:y component-shape)) + mouse-pos @ms/mouse-position + delta (gpt/subtract mouse-pos orig-pos) + + _ (js/console.log "orig-pos" (clj->js orig-pos)) + _ (js/console.log "mouse-pos" (clj->js mouse-pos)) + _ (js/console.log "delta" (clj->js delta)) + + page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + unames (atom (dwc/retrieve-used-names objects)) + + all-frames (cph/select-frames objects) + + update-new-shape + (fn [new-shape original-shape] + (let [new-name + (dwc/generate-unique-name @unames (:name new-shape))] + + (swap! unames conj new-name) + + (cond-> new-shape + true + (as-> $ + (assoc $ :name new-name) + (geom/move $ delta) + (assoc $ :frame-id + (dwc/calculate-frame-overlap all-frames $)) + (assoc $ :parent-id + (or (:parent-id $) (:frame-id $))) + (assoc $ :shape-ref (:id original-shape))) + + (nil? (:parent-id original-shape)) + (assoc :component-id (:id original-shape)) + + (and (nil? (:parent-id original-shape)) (some? file-id)) + (assoc :component-file file-id)))) + + [new-shape new-shapes _] + (cph/clone-object component-shape + nil + (get component :objects) + update-new-shape) + + rchanges (map (fn [obj] + {:type :add-obj + :id (:id obj) + :page-id page-id + :frame-id (:frame-id obj) + :parent-id (:parent-id obj) + :obj obj}) + new-shapes) + + uchanges (map (fn [obj] + {:type :del-obj + :id (:id obj) + :page-id page-id}) + new-shapes)] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dws/select-shapes (d/ordered-set (:id new-shape)))))))) + +(defn detach-component + [id] + (us/assert ::us/uuid id) + (ptk/reify ::detach-component + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + root-id (cph/get-root-component id objects) + + shapes (cph/get-object-with-children root-id objects) + + rchanges (map (fn [obj] + {:type :mod-obj + :page-id page-id + :id (:id obj) + :operations [{:type :set + :attr :component-id + :val nil} + {:type :set + :attr :component-file + :val nil} + {:type :set + :attr :shape-ref + :val nil}]}) + shapes) + + uchanges (map (fn [obj] + {:type :mod-obj + :page-id page-id + :id (:id obj) + :operations [{:type :set + :attr :component-id + :val (:component-id obj)} + {:type :set + :attr :component-file + :val (:component-file obj)} + {:type :set + :attr :shape-ref + :val (:shape-ref obj)}]}) + shapes)] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn nav-to-component-file + [file-id] + (us/assert ::us/uuid file-id) + (ptk/reify ::nav-to-component-file + ptk/WatchEvent + (watch [_ state stream] + (let [file (get-in state [:workspace-libraries file-id]) + pparams {:project-id (:project-id file) + :file-id (:id file)} + qparams {:page-id (first (get-in file [:data :pages]))}] + (st/emit! (rt/nav-new-window :workspace pparams qparams)))))) + +(declare generate-sync-file) +(declare generate-sync-page) +(declare generate-sync-shape-and-children) +(declare generate-sync-shape) +(declare remove-component-and-ref) +(declare remove-ref) +(declare update-attrs) +(declare sync-attrs) +(declare calc-new-pos) + +(defn reset-component + [id] + (us/assert ::us/uuid id) + (ptk/reify ::reset-component + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + page (get-in state [:workspace-data :pages-index page-id]) + objects (dwc/lookup-page-objects state page-id) + root-id (cph/get-root-component id objects) + root-shape (get objects id) + file-id (get root-shape :component-file) + + components + (if (nil? file-id) + (get-in state [:workspace-data :components]) + (get-in state [:workspace-libraries file-id :data :components])) + + [rchanges uchanges] + (generate-sync-shape-and-children root-shape page components)] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn update-component + [id] + (us/assert ::us/uuid id) + (ptk/reify ::update-component + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + root-id (cph/get-root-component id objects) + root-shape (get objects id) + + component-id (get root-shape :component-id) + component-objs (dwc/lookup-component-objects state component-id) + component-obj (get component-objs component-id) + + ;; Clone again the original shape and its children, maintaing + ;; the ids of the cloned shapes. If the original shape has some + ;; new child shapes, the cloned ones will have new generated ids. + update-new-shape (fn [new-shape original-shape] + (cond-> new-shape + true + (assoc :frame-id nil) + + (some? (:shape-ref original-shape)) + (assoc :id (:shape-ref original-shape)))) + + [new-shape new-shapes _] + (cph/clone-object root-shape nil objects update-new-shape) + + rchanges [{:type :update-component + :id component-id + :name (:name new-shape) + :shapes new-shapes}] + + uchanges [{:type :update-component + :id component-id + :name (:name component-obj) + :shapes (vals component-objs)}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn sync-file + [{:keys [file-id] :as params}] + (us/assert (s/nilable ::us/uuid) file-id) + (ptk/reify ::sync-file + ptk/WatchEvent + (watch [_ state stream] + (let [[rchanges uchanges] (generate-sync-file state file-id)] + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn- generate-sync-file + [state file-id] + (let [components + (if (nil? file-id) + (get-in state [:workspace-data :components]) + (get-in state [:workspace-libraries file-id :data :components]))] + (loop [pages (seq (vals (get-in state [:workspace-data :pages-index]))) + rchanges [] + uchanges []] + (let [page (first pages)] + (if (nil? page) + [rchanges uchanges] + (let [[page-rchanges page-uchanges] + (generate-sync-page page components)] + (recur (next pages) + (concat rchanges page-rchanges) + (concat uchanges page-uchanges)))))))) + +(defn- generate-sync-page + [page components] + (let [linked-shapes + (cph/select-objects #(some? (:component-id %)) page)] + (loop [shapes (seq linked-shapes) + rchanges [] + uchanges []] + (let [shape (first shapes)] + (if (nil? shape) + [rchanges uchanges] + (let [[shape-rchanges shape-uchanges] + (generate-sync-shape-and-children shape page components)] + (recur (next shapes) + (concat rchanges shape-rchanges) + (concat uchanges shape-uchanges)))))))) + +(defn- generate-sync-shape-and-children + [root-shape page components] + (let [objects (get page :objects) + all-shapes (cph/get-object-with-children (:id root-shape) objects) + component (get components (:component-id root-shape)) + root-component (get-in component [:objects (:shape-ref root-shape)])] + (loop [shapes (seq all-shapes) + rchanges [] + uchanges []] + (let [shape (first shapes)] + (if (nil? shape) + [rchanges uchanges] + (let [[shape-rchanges shape-uchanges] + (generate-sync-shape shape root-shape root-component page component)] + (recur (next shapes) + (concat rchanges shape-rchanges) + (concat uchanges shape-uchanges)))))))) + +(defn- generate-sync-shape + [shape root-shape root-component page component] + (if (nil? component) + (remove-component-and-ref shape page) + (let [component-shape (get (:objects component) (:shape-ref shape))] + (if (nil? component-shape) + (remove-ref shape page) + (update-attrs shape component-shape root-shape root-component page))))) + +(defn- remove-component-and-ref + [shape page] + [[{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations [{:type :set + :attr :component-id + :val nil} + {:type :set + :attr :component-file + :val nil} + {:type :set + :attr :shape-ref + :val nil}]}] + [{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations [{:type :set + :attr :component-id + :val (:component-id shape)} + {:type :set + :attr :component-file + :val (:component-file shape)} + {:type :set + :attr :shape-ref + :val (:shape-ref shape)}]}]]) + +(defn- remove-ref + [shape page] + [[{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations [{:type :set + :attr :shape-ref + :val nil}]}] + [{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations [{:type :set + :attr :shape-ref + :val (:shape-ref shape)}]}]]) + +(defn- update-attrs + [shape component-shape root-shape root-component page] + (let [new-pos (calc-new-pos shape component-shape root-shape root-component)] + (loop [attrs (seq sync-attrs) + roperations [{:type :set + :attr :x + :val (:x new-pos)} + {:type :set + :attr :y + :val (:y new-pos)}] + uoperations [{:type :set + :attr :x + :val (:x shape)} + {:type :set + :attr :y + :val (:y shape)}]] + + (let [attr (first attrs)] + (if (nil? attr) + (let [rchanges [{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations roperations}] + uchanges [{:type :mod-obj + :page-id (:id page) + :id (:id shape) + :operations uoperations}]] + [rchanges uchanges]) + (if-not (contains? shape attr) + (recur (next attrs) + roperations + uoperations) + (let [roperation {:type :set + :attr attr + :val (get component-shape attr)} + uoperation {:type :set + :attr attr + :val (get shape attr)}] + (recur (next attrs) + (conj roperations roperation) + (conj uoperations uoperation))))))))) + +(def sync-attrs [:content + :fill-color + :fill-color-ref-file + :fill-color-ref-id + :fill-opacity + :font-family + :font-size + :font-style + :font-weight + :letter-spacing + :line-height + :proportion + :rx + :ry + :stroke-color + :stroke-color-ref-file + :stroke-color-ref-id + :stroke-opacity + :stroke-style + :stroke-width + :stroke-alignment + :text-align + :width + :height + :interactions + :points + :transform]) + +(defn- calc-new-pos + [shape component-shape root-shape root-component] + (let [root-pos (gpt/point (:x root-shape) (:y root-shape)) + root-component-pos (gpt/point (:x root-component) (:y root-component)) + component-pos (gpt/point (:x component-shape) (:y component-shape)) + delta (gpt/subtract component-pos root-component-pos) + shape-pos (gpt/point (:x shape) (:y shape)) + new-pos (gpt/add root-pos delta)] + new-pos)) + diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 77e0ad2dd8..27b32ac5bf 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -33,33 +33,6 @@ (s/def ::set-of-string (s/every string? :kind set?)) -;; Duplicate from workspace. -;; FIXME: Move these functions to a common place - -(defn interrupt? [e] (= e :interrupt)) - -(defn- retrieve-used-names - [objects] - (into #{} (map :name) (vals objects))) - -(defn- extract-numeric-suffix - [basename] - (if-let [[match p1 p2] (re-find #"(.*)-([0-9]+)$" basename)] - [p1 (+ 1 (d/parse-integer p2))] - [basename 1])) - -(defn- generate-unique-name - "A unique name generator" - [used basename] - (s/assert ::set-of-string used) - (s/assert ::us/string basename) - (let [[prefix initial] (extract-numeric-suffix basename)] - (loop [counter initial] - (let [candidate (str prefix "-" counter)] - (if (contains? used candidate) - (recur (inc counter)) - candidate))))) - ;; --- Selection Rect (declare select-shapes-by-current-selrect) @@ -88,7 +61,7 @@ (ptk/reify ::handle-selection ptk/WatchEvent (watch [_ state stream] - (let [stoper (rx/filter #(or (interrupt? %) + (let [stoper (rx/filter #(or (dwc/interrupt? %) (ms/mouse-up? %)) stream)] (rx/concat @@ -183,6 +156,88 @@ (rx/of deselect-all (select-shape (:id selected)))))))) +;; --- Group shapes + +(defn shapes-for-grouping + [objects selected] + (->> selected + (map #(get objects %)) + (filter #(not= :frame (:type %))) + (map #(assoc % ::index (cph/position-on-parent (:id %) objects))) + (sort-by ::index))) + +(defn- make-group + [shapes prefix keep-name] + (let [selrect (geom/selection-rect shapes) + frame-id (-> shapes first :frame-id) + parent-id (-> shapes first :parent-id) + group-name (if (and keep-name + (= (count shapes) 1) + (= (:type (first shapes)) :group)) + (:name (first shapes)) + (name (gensym prefix)))] + (-> (cp/make-minimal-group frame-id selrect group-name) + (geom/setup selrect) + (assoc :shapes (map :id shapes))))) + +(defn prepare-create-group + [page-id shapes prefix keep-name] + (let [group (make-group shapes prefix keep-name) + rchanges [{:type :add-obj + :id (:id group) + :page-id page-id + :frame-id (:frame-id (first shapes)) + :parent-id (:parent-id (first shapes)) + :obj group + :index (::index (first shapes))} + {:type :mov-objects + :page-id page-id + :parent-id (:id group) + :shapes (map :id shapes)}] + + uchanges (conj + (map (fn [obj] {:type :mov-objects + :page-id page-id + :parent-id (:parent-id obj) + :index (::index obj) + :shapes [(:id obj)]}) + shapes) + {:type :del-obj + :id (:id group) + :page-id page-id})] + [group rchanges uchanges])) + +(defn prepare-remove-group + [page-id group objects] + (let [shapes (:shapes group) + parent-id (cph/get-parent (:id group) objects) + parent (get objects parent-id) + index-in-parent (->> (:shapes parent) + (map-indexed vector) + (filter #(#{(:id group)} (second %))) + (ffirst)) + rchanges [{:type :mov-objects + :page-id page-id + :parent-id parent-id + :shapes shapes + :index index-in-parent}] + uchanges [{:type :add-obj + :page-id page-id + :id (:id group) + :frame-id (:frame-id group) + :obj (assoc group :shapes [])} + {:type :mov-objects + :page-id page-id + :parent-id (:id group) + :shapes shapes} + {:type :mov-objects + :page-id page-id + :parent-id parent-id + :shapes [(:id group)] + :index index-in-parent}]] + [rchanges uchanges])) + + ;; --- Duplicate Shapes (declare prepare-duplicate-change) (declare prepare-duplicate-frame-change) @@ -218,7 +273,7 @@ (defn- prepare-duplicate-shape-change [objects page-id names obj delta frame-id parent-id] (let [id (uuid/next) - name (generate-unique-name names (:name obj)) + name (dwc/generate-unique-name names (:name obj)) renamed-obj (assoc obj :id id :name name) moved-obj (geom/move renamed-obj delta) frames (cph/select-frames objects) @@ -258,7 +313,7 @@ (defn- prepare-duplicate-frame-change [objects page-id names obj delta] (let [frame-id (uuid/next) - frame-name (generate-unique-name names (:name obj)) + frame-name (dwc/generate-unique-name names (:name obj)) sch (->> (map #(get objects %) (:shapes obj)) (mapcat #(prepare-duplicate-shape-change objects page-id names % delta frame-id frame-id))) @@ -287,7 +342,7 @@ selected (get-in state [:workspace-local :selected]) delta (gpt/point 0 0) - unames (retrieve-used-names objects) + unames (dwc/retrieve-used-names objects) rchanges (prepare-duplicate-changes objects page-id unames selected delta) uchanges (mapv #(array-map :type :del-obj :page-id page-id :id (:id %)) diff --git a/frontend/src/app/main/exports.cljs b/frontend/src/app/main/exports.cljs index a72b72b2d3..8a2105d67e 100644 --- a/frontend/src/app/main/exports.cljs +++ b/frontend/src/app/main/exports.cljs @@ -156,3 +156,34 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :xmlns "http://www.w3.org/2000/svg"} [:& wrapper {:shape frame :view-box vbox}]])) + +(mf/defc component-svg + {::mf/wrap [mf/memo]} + [{:keys [objects group zoom] :or {zoom 1} :as props}] + (let [modifier (-> (gpt/point (:x group) (:y group)) + (gpt/negate) + (gmt/translate-matrix)) + + group-id (:id group) + + modifier-ids (concat [group-id] (cph/get-children group-id objects)) + update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) + objects (reduce update-fn objects modifier-ids) + group (assoc-in group [:modifiers :displacement] modifier) + + width (* (:width group) zoom) + height (* (:height group) zoom) + vbox (str "0 0 " (:width group 0) + " " (:height group 0)) + wrapper (mf/use-memo + (mf/deps objects) + #(group-wrapper-factory objects))] + + [:svg {:view-box vbox + :width width + :height height + :version "1.1" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :xmlns "http://www.w3.org/2000/svg"} + [:& wrapper {:shape group :view-box vbox}]])) + diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index b65165f3be..cf645ca837 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -67,4 +67,4 @@ (defn ^:export dump-objects [] (let [page-id (get @state :current-page-id)] - (logjs "state" (get-in @state [:workspace-data page-id :objects])))) + (logjs "state" (get-in @state [:workspace-data :pages-index page-id :objects])))) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 5368f6cae5..157bcddb2f 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -31,6 +31,7 @@ (def chat (icon-xref :chat)) (def circle (icon-xref :circle)) (def close (icon-xref :close)) +(def component (icon-xref :component)) (def copy (icon-xref :copy)) (def curve (icon-xref :curve)) (def download (icon-xref :download)) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 4488023f80..487708beee 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.ui.icons :as i] [app.util.dom :as dom] [app.main.data.workspace :as dw] + [app.main.data.workspace.libraries :as dwl] [app.main.ui.hooks :refer [use-rxsub]] [app.main.ui.components.dropdown :refer [dropdown]])) @@ -45,6 +46,7 @@ [{:keys [mdata] :as props}] (let [{:keys [id] :as shape} (:shape mdata) selected (:selected mdata) + root-shape (:root-shape mdata) do-duplicate #(st/emit! dw/duplicate-selected) do-delete #(st/emit! dw/delete-selected) @@ -59,7 +61,15 @@ do-lock-shape #(st/emit! (dw/update-shape-flags id {:blocked true})) do-unlock-shape #(st/emit! (dw/update-shape-flags id {:blocked false})) do-create-group #(st/emit! dw/group-selected) - do-remove-group #(st/emit! dw/ungroup-selected)] + do-remove-group #(st/emit! dw/ungroup-selected) + do-add-component #(st/emit! dwl/add-component) + do-detach-component #(st/emit! (dwl/detach-component id)) + do-reset-component #(st/emit! (dwl/reset-component id)) + do-update-component #(do + (st/emit! (dwl/update-component id)) + (st/emit! (dwl/sync-file {:file-id nil}))) + do-navigate-component-file #(st/emit! (dwl/nav-to-component-file + (:component-file root-shape)))] [:* [:& menu-entry {:title "Copy" :shortcut "Ctrl + c" @@ -101,13 +111,29 @@ [:& menu-entry {:title "Hide" :on-click do-hide-shape}]) - - (if (:blocked shape) [:& menu-entry {:title "Unlock" :on-click do-unlock-shape}] [:& menu-entry {:title "Lock" :on-click do-lock-shape}]) + + [:& menu-separator] + + (if (nil? (:shape-ref shape)) + [:& menu-entry {:title "Create component" + :shortcut "Ctrl + K" + :on-click do-add-component}] + [:* + [:& menu-entry {:title "Detach instance" + :on-click do-detach-component}] + [:& menu-entry {:title "Reset overrides" + :on-click do-reset-component}] + (if (nil? (:component-file root-shape)) + [:& menu-entry {:title "Update master component" + :on-click do-update-component}] + [:& menu-entry {:title "Go to master component file" + :on-click do-navigate-component-file}])]) + [:& menu-separator] [:& menu-entry {:title "Delete" :shortcut "Supr" diff --git a/frontend/src/app/main/ui/workspace/selection.cljs b/frontend/src/app/main/ui/workspace/selection.cljs index 87539d4bb0..eb73c081da 100644 --- a/frontend/src/app/main/ui/workspace/selection.cljs +++ b/frontend/src/app/main/ui/workspace/selection.cljs @@ -34,10 +34,11 @@ (def resize-point-circle-radius 10) (def resize-point-rect-size 8) (def resize-side-height 8) -(def selection-rect-color "#1FDEA7") +(def selection-rect-color-normal "#1FDEA7") +(def selection-rect-color-component "#00E0FF") (def selection-rect-width 1) -(mf/defc selection-rect [{:keys [transform rect zoom]}] +(mf/defc selection-rect [{:keys [transform rect zoom color]}] (let [{:keys [x y width height]} rect] [:rect.main {:x x @@ -45,7 +46,7 @@ :width width :height height :transform transform - :style {:stroke selection-rect-color + :style {:stroke color :stroke-width (/ selection-rect-width zoom) :fill "transparent"}}])) @@ -125,7 +126,7 @@ :on-mouse-down on-rotate}])) (mf/defc resize-point-handler - [{:keys [cx cy zoom position on-resize transform rotation]}] + [{:keys [cx cy zoom position on-resize transform rotation color]}] (let [{cx' :x cy' :y} (gpt/transform (gpt/point cx cy) transform) rot-square (case position :top-left 0 @@ -139,7 +140,7 @@ :vectorEffect "non-scaling-stroke" } :fill "#FFFFFF" - :stroke "#1FDEA7" + :stroke color :cx cx' :cy cy'}] @@ -173,6 +174,7 @@ [props] (let [shape (obj/get props "shape") zoom (obj/get props "zoom") + color (obj/get props "color") on-resize (obj/get props "on-resize") on-rotate (obj/get props "on-rotate") current-transform (mf/deref refs/current-transform) @@ -186,8 +188,10 @@ ;; Selection rect [:& selection-rect {:rect selrect :transform transform - :zoom zoom}] - [:& outline {:shape (geom/transform-shape shape)}] + :zoom zoom + :color color}] + [:& outline {:shape (geom/transform-shape shape) + :color color}] ;; Handlers (for [{:keys [type position props]} (handlers-for-selection selrect)] @@ -197,7 +201,8 @@ :on-rotate on-rotate :on-resize (partial on-resize position) :transform transform - :rotation (:rotation shape)} + :rotation (:rotation shape) + :color color} props (map->obj (merge common-props props))] (case type :rotation (when (not= :frame (:type shape)) [:> rotation-handler props]) @@ -206,7 +211,7 @@ ;; --- Selection Handlers (Component) (mf/defc path-edition-selection-handlers - [{:keys [shape modifiers zoom] :as props}] + [{:keys [shape modifiers zoom color] :as props}] (letfn [(on-mouse-down [event index] (dom/stop-propagation event) ;; TODO: this need code ux refactor @@ -240,26 +245,26 @@ :key index :on-mouse-down #(on-mouse-down % index) :fill "#ffffff" - :stroke "#1FDEA7" + :stroke color :style {:cursor cur/move-pointer}}]))]))) ;; TODO: add specs for clarity (mf/defc text-edition-selection-handlers - [{:keys [shape zoom] :as props}] + [{:keys [shape zoom color] :as props}] (let [{:keys [x y width height]} shape] [:g.controls [:rect.main {:x x :y y :transform (geom/transform-matrix shape) :width width :height height - :style {:stroke "#1FDEA7" + :style {:stroke color :stroke-width "0.5" :stroke-opacity "1" :fill "transparent"}}]])) (mf/defc multiple-selection-handlers - [{:keys [shapes selected zoom] :as props}] + [{:keys [shapes selected zoom color] :as props}] (let [shape (geom/selection-rect shapes) shape-center (geom/center shape) on-resize (fn [current-position initial-position event] @@ -272,13 +277,14 @@ [:* [:& controls {:shape shape :zoom zoom + :color color :on-resize on-resize :on-rotate on-rotate}] (when (debug? :selection-center) [:circle {:cx (:x shape-center) :cy (:y shape-center) :r 5 :fill "yellow"}])])) (mf/defc single-selection-handlers - [{:keys [shape zoom] :as props}] + [{:keys [shape zoom color] :as props}] (let [shape-id (:id shape) shape (geom/transform-shape shape) shape' (if (debug? :simple-selection) (geom/selection-rect [shape]) shape) @@ -293,6 +299,7 @@ [:* [:& controls {:shape shape' :zoom zoom + :color color :on-rotate on-rotate :on-resize on-resize}]])) @@ -304,7 +311,11 @@ shapes (->> (mf/deref (refs/objects-by-id selected)) (remove nil?)) num (count shapes) - {:keys [id type] :as shape} (first shapes)] + {:keys [id type] :as shape} (first shapes) + + color (if (or (> num 1) (nil? (:shape-ref shape))) + selection-rect-color-normal + selection-rect-color-component)] (cond (zero? num) nil @@ -312,18 +323,22 @@ (> num 1) [:& multiple-selection-handlers {:shapes shapes :selected selected - :zoom zoom}] + :zoom zoom + :color color}] (and (= type :text) (= edition (:id shape))) [:& text-edition-selection-handlers {:shape shape - :zoom zoom}] + :zoom zoom + :color color}] (and (or (= type :path) (= type :curve)) (= edition (:id shape))) [:& path-edition-selection-handlers {:shape shape - :zoom zoom}] + :zoom zoom + :color color}] :else [:& single-selection-handlers {:shape shape - :zoom zoom}]))) + :zoom zoom + :color color}]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/interactions.cljs b/frontend/src/app/main/ui/workspace/shapes/interactions.cljs index ab6d79d4da..fb36edad84 100644 --- a/frontend/src/app/main/ui/workspace/shapes/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/interactions.cljs @@ -158,7 +158,8 @@ :zoom zoom}] (when dest-shape - [:& outline {:shape dest-shape}])]))) + [:& outline {:shape dest-shape + :color "#31EFB8"}])]))) (mf/defc interaction-handle diff --git a/frontend/src/app/main/ui/workspace/shapes/outline.cljs b/frontend/src/app/main/ui/workspace/shapes/outline.cljs index 1b7efbd3f4..361636e446 100644 --- a/frontend/src/app/main/ui/workspace/shapes/outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/outline.cljs @@ -22,6 +22,7 @@ [props] (let [zoom (mf/deref refs/selected-zoom) shape (unchecked-get props "shape") + color (unchecked-get props "color") transform (gsh/transform-matrix shape) {:keys [id x y width height]} shape @@ -31,7 +32,7 @@ "rect") common {:fill "transparent" - :stroke "#31EFB8" + :stroke color :strokeWidth (/ 1 zoom) :pointerEvents "none" :transform transform} @@ -42,10 +43,10 @@ :cy (+ y (/ height 2)) :rx (/ width 2) :ry (/ height 2)} - + (:curve :path) {:d (path/render-path shape)} - + {:x x :y y :width width diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 7de10fcf53..79a51e1dab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -14,6 +14,7 @@ [app.common.geom.shapes :as geom] [app.common.media :as cm] [app.common.pages :as cp] + [app.common.pages-helpers :as cph] [app.common.uuid :as uuid] [app.config :as cfg] [app.main.data.workspace :as dw] @@ -21,6 +22,7 @@ [app.main.data.colors :as dc] [app.main.refs :as refs] [app.main.store :as st] + [app.main.exports :as exports] [app.main.ui.components.context-menu :refer [context-menu]] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.tab-container :refer [tab-container tab-element]] @@ -38,6 +40,63 @@ [okulary.core :as l] [rumext.alpha :as mf])) +(mf/defc components-box + [{:keys [file-id local? components] :as props}] + (let [state (mf/use-state {:menu-open false + :top nil + :left nil + :component-id nil}) + on-delete + (mf/use-callback + (mf/deps state) + (fn [] + (st/emit! (dwl/delete-component {:id (:component-id @state)})) + (st/emit! (dwl/sync-file {:file-id nil})))) + + on-context-menu + (mf/use-callback + (fn [component-id] + (fn [event] + (when local? + (let [pos (dom/get-client-position event) + top (:y pos) + left (- (:x pos) 20)] + (dom/prevent-default event) + (swap! state assoc :menu-open true + :top top + :left left + :component-id component-id)))))) + + on-drag-start + (mf/use-callback + (fn [component-id event] + (dnd/set-data! event "app/component" {:file-id (if local? nil file-id) + :component-id component-id}) + (dnd/set-allowed-effect! event "move")))] + + [:div.asset-group + [:div.group-title + (tr "workspace.assets.components") + [:span (str "\u00A0(") (count components) ")"]] ;; Unicode 00A0 is non-breaking space + [:div.group-grid.big + (for [component components] + [:div.grid-cell {:key (:id component) + :draggable true + :on-context-menu (on-context-menu (:id component)) + :on-drag-start (partial on-drag-start (:id component))} + [:& exports/component-svg {:group (get-in component [:objects (:id component)]) + :objects (:objects component)}] + [:div.cell-name (:name component)]]) + + (when local? + [:& context-menu + {:selectable false + :show (:menu-open @state) + :on-close #(swap! state assoc :menu-open false) + :top (:top @state) + :left (:left @state) + :options [[(tr "workspace.assets.delete") on-delete]]}])]])) + (mf/defc graphics-box [{:keys [file-id local? objects open? on-open on-close] :as props}] (let [input-ref (mf/use-ref nil) @@ -126,7 +185,6 @@ :left (:left @state) :options [[(tr "workspace.assets.delete") on-delete]]}])])])) - (mf/defc color-item [{:keys [color local? locale file-id] :as props}] (let [rename? (= (:color-for-rename @refs/workspace-local) (:id color)) @@ -287,32 +345,45 @@ (vals (get-in state [:workspace-libraries id :data :media]))))) st/state =)) +(defn file-components-ref + [id] + (l/derived (fn [state] + (let [wfile (:workspace-file state)] + (if (= (:id wfile) id) + (vals (get-in wfile [:data :components])) + (vals (get-in state [:workspace-libraries id :data :components]))))) + st/state =)) + (defn apply-filters [coll filters] - (filter (fn [item] - (or (matches-search (:name item "!$!") (:term filters)) - (matches-search (:value item "!$!") (:term filters)))) - coll)) + (->> coll + (filter (fn [item] + (or (matches-search (:name item "!$!") (:term filters)) + (matches-search (:value item "!$!") (:term filters))))) + (sort-by #(str/lower (:name %))))) (mf/defc file-library [{:keys [file local? open? filters locale] :as props}] - (let [open? (mf/use-state open?) - shared? (:is-shared file) - router (mf/deref refs/router) - toggle-open #(swap! open? not) + (let [open? (mf/use-state open?) + shared? (:is-shared file) + router (mf/deref refs/router) + toggle-open #(swap! open? not) - toggles (mf/use-state #{:graphics :colors}) + toggles (mf/use-state #{:graphics :colors}) - url (rt/resolve router :workspace - {:project-id (:project-id file) - :file-id (:id file)} - {:page-id (get-in file [:data :pages 0])}) + url (rt/resolve router :workspace + {:project-id (:project-id file) + :file-id (:id file)} + {:page-id (get-in file [:data :pages 0])}) - colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file))) - colors (apply-filters (mf/deref colors-ref) filters) + colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file))) + colors (apply-filters (mf/deref colors-ref) filters) - media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file))) - media (apply-filters (mf/deref media-ref) filters)] + media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file))) + media (apply-filters (mf/deref media-ref) filters) + + components-ref (mf/use-memo (mf/deps (:id file)) #(file-components-ref (:id file))) + components (apply-filters (mf/deref components-ref) filters)] [:div.tool-window [:div.tool-window-bar @@ -332,15 +403,23 @@ [:a {:href (str "#" url) :target "_blank"} i/chain]]])] (when @open? - (let [show-graphics? (and (or (= (:box filters) :all) - (= (:box filters) :graphics)) - (or (> (count media) 0) - (str/empty? (:term filters)))) - show-colors? (and (or (= (:box filters) :all) - (= (:box filters) :colors)) - (or (> (count colors) 0) - (str/empty? (:term filters))))] + (let [show-components? (and (or (= (:box filters) :all) + (= (:box filters) :components)) + (or (> (count components) 0) + (str/empty? (:term filters)))) + show-graphics? (and (or (= (:box filters) :all) + (= (:box filters) :graphics)) + (or (> (count media) 0) + (str/empty? (:term filters)))) + show-colors? (and (or (= (:box filters) :all) + (= (:box filters) :colors)) + (or (> (count colors) 0) + (str/empty? (:term filters))))] [:div.tool-window-content + (when show-components? + [:& components-box {:file-id (:id file) + :local? local? + :components components}]) (when show-graphics? [:& graphics-box {:file-id (:id file) :local? local? @@ -357,10 +436,11 @@ :on-open #(swap! toggles conj :colors) :on-close #(swap! toggles disj :colors)}]) - (when (and (not show-graphics?) (not show-colors?)) + (when (and (not show-components?) (not show-graphics?) (not show-colors?)) [:div.asset-group [:div.group-title (t locale "workspace.assets.not-found")]])]))])) + (mf/defc assets-toolbox [{:keys [team-id file] :as props}] (let [libraries (mf/deref refs/workspace-libraries) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index c5da4ad8d2..41c9288524 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -43,7 +43,9 @@ :rect i/box :curve i/curve :text i/text - :group i/folder + :group (if (nil? (:component-id shape)) + i/folder + i/component) nil)) ;; --- Layer Name @@ -186,6 +188,7 @@ [:li {:on-context-menu on-context-menu :ref dref :class (dom/classnames + :component (not (nil? (:component-id item))) :dnd-over (= (:over dprops) :center) :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot) @@ -285,7 +288,18 @@ (defn- strip-objects [objects] - (let [strip-data #(select-keys % [:id :name :blocked :hidden :shapes :type :content :parent-id :metadata])] + (let [strip-data #(select-keys % [:id + :name + :blocked + :hidden + :shapes + :type + :content + :parent-id + :component-id + :component-file + :shape-ref + :metadata])] (persistent! (reduce-kv (fn [res id obj] (assoc! res id (strip-data obj))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 2fec8d0f21..74957a7e56 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -22,6 +22,7 @@ [app.common.data :as d] [app.main.constants :as c] [app.main.data.workspace :as dw] + [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.drawing :as dd] [app.main.data.colors :as dwc] [app.main.data.fetch :as mdf] @@ -132,12 +133,16 @@ hover (or (unchecked-get props "hover") #{}) outline? (set/union selected hover) shapes (->> (vals objects) (filter (comp outline? :id))) - transform (mf/deref refs/current-transform)] + transform (mf/deref refs/current-transform) + color (if (or (> (count shapes) 1) (nil? (:shape-ref (first shapes)))) + "#31EFB8" + "#00E0FF")] (when (nil? transform) [:g.outlines (for [shape shapes] [:& outline {:key (str "outline-" (:id shape)) - :shape (gsh/transform-shape shape)}])]))) + :shape (gsh/transform-shape shape) + :color color}])]))) (mf/defc frames {::mf/wrap [mf/memo] @@ -454,6 +459,7 @@ on-drag-enter (fn [e] (when (or (dnd/has-type? e "app/shape") + (dnd/has-type? e "app/component") (dnd/has-type? e "Files") (dnd/has-type? e "text/uri-list")) (dom/prevent-default e))) @@ -461,6 +467,7 @@ on-drag-over (fn [e] (when (or (dnd/has-type? e "app/shape") + (dnd/has-type? e "app/component") (dnd/has-type? e "Files") (dnd/has-type? e "text/uri-list")) (dom/prevent-default e))) @@ -491,6 +498,10 @@ (assoc :x final-x) (assoc :y final-y))))) + (dnd/has-type? event "app/component") + (let [{:keys [component-id file-id]} (dnd/get-data event "app/component")] + (st/emit! (dwl/instantiate-component file-id component-id))) + (dnd/has-type? event "text/uri-list") (let [data (dnd/get-data event "text/uri-list") lines (str/lines data) diff --git a/frontend/src/app/util/router.cljs b/frontend/src/app/util/router.cljs index 8d6d8707ee..067c44e5fc 100644 --- a/frontend/src/app/util/router.cljs +++ b/frontend/src/app/util/router.cljs @@ -16,6 +16,7 @@ [potok.core :as ptk] [reitit.core :as r] [app.common.data :as d] + [app.config :as cfg] [app.util.browser-history :as bhistory] [app.util.timers :as ts]) (:import @@ -112,6 +113,19 @@ (def navigate nav) +(deftype NavigateNewWindow [id params qparams] + ptk/EffectEvent + (effect [_ state stream] + (let [router (:router state) + path (resolve router id params qparams) + uri (str cfg/public-uri "/#" path)] + (js/window.open uri "_blank")))) + +(defn nav-new-window + ([id] (nav-new-window id nil nil)) + ([id params] (nav-new-window id params nil)) + ([id params qparams] (NavigateNewWindow. id params qparams))) + ;; --- History API (defn initialize-history