diff --git a/CHANGES.md b/CHANGES.md index d6c0fa10f..634e6a503 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,13 +2,19 @@ ## :rocket: Next +### :sparkles: New features + +- Guides [Taiga #290](https://tree.taiga.io/project/penpot/us/290?milestone=307334) +- Improve file menu by adding semantically groups [Github #1203](https://github.com/penpot/penpot/issues/1203). +- Create e2e tests for drawing basic fors [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608). +- Create firsts e2e test [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608). + +## 1.11.0-beta + ### :boom: Breaking changes ### :sparkles: New features -- Create e2e tests for drawing basic fors [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608). -- Improve file menu by adding semantically groups [Github #1203](https://github.com/penpot/penpot/issues/1203). -- Create firsts e2e test [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608). - Add an option to hide artboards names on the viewport [Taiga #2034](https://tree.taiga.io/project/penpot/issue/2034). - Limit pasted object position to container boundaries [Taiga #2449](https://tree.taiga.io/project/penpot/us/2449). - Add new options for zoom widget in workspace and viewer mode [Taiga #896](https://tree.taiga.io/project/penpot/us/896). diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index f8acee0d3..a8db38407 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -15,4 +15,5 @@ (def info "#59B9E2") (def test "#fabada") (def white "#FFFFFF") +(def primary "#31EFB8") diff --git a/common/src/app/common/file_builder.cljc b/common/src/app/common/file_builder.cljc index 0d6c96ff4..e2dd9f30e 100644 --- a/common/src/app/common/file_builder.cljc +++ b/common/src/app/common/file_builder.cljc @@ -568,4 +568,78 @@ (dissoc :current-component-id) (update :parent-stack pop)))) +(defn delete-object + [file id] + (let [page-id (:current-page-id file)] + (commit-change + file + {:type :del-obj + :page-id page-id + :id id}))) +(defn update-object + [file old-obj new-obj] + (let [page-id (:current-page-id file) + new-obj (setup-selrect new-obj) + attrs (d/concat-set (keys old-obj) (keys new-obj)) + generate-operation + (fn [changes attr] + (let [old-val (get old-obj attr) + new-val (get new-obj attr)] + (if (= old-val new-val) + changes + (conj changes {:type :set :attr attr :val new-val}))))] + (-> file + (commit-change + {:type :mod-obj + :operations (reduce generate-operation [] attrs) + :page-id page-id + :id (:id old-obj)})))) + +(defn get-current-page + [file] + (let [page-id (:current-page-id file)] + (-> file (get-in [:data :pages-index page-id])))) + +(defn add-guide + [file guide] + + (let [guide (cond-> guide + (nil? (:id guide)) + (assoc :id (uuid/next))) + page-id (:current-page-id file) + old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {}) + new-guides (assoc old-guides (:id guide) guide)] + (-> file + (commit-change + {:type :set-option + :page-id page-id + :option :guides + :value new-guides}) + (assoc :last-id (:id guide))))) + +(defn delete-guide + [file id] + + (let [page-id (:current-page-id file) + old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {}) + new-guides (dissoc old-guides id)] + (-> file + (commit-change + {:type :set-option + :page-id page-id + :option :guides + :value new-guides})))) + +(defn update-guide + [file guide] + + (let [page-id (:current-page-id file) + old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {}) + new-guides (assoc old-guides (:id guide) guide)] + (-> file + (commit-change + {:type :set-option + :page-id page-id + :option :guides + :value new-guides})))) diff --git a/common/src/app/common/pages/changes_builder.cljc b/common/src/app/common/pages/changes_builder.cljc index d39374224..81e913975 100644 --- a/common/src/app/common/pages/changes_builder.cljc +++ b/common/src/app/common/pages/changes_builder.cljc @@ -13,12 +13,21 @@ ;; Auxiliary functions to help create a set of changes (undo + redo) (defn empty-changes - [origin page-id] - (let [changes {:redo-changes [] - :undo-changes [] - :origin origin}] - (with-meta changes - {::page-id page-id}))) + ([origin page-id] + (let [changes (empty-changes origin)] + (with-meta changes + {::page-id page-id}))) + + ([origin] + {:redo-changes [] + :undo-changes [] + :origin origin})) + +(defn with-page [changes page] + (vary-meta changes assoc + ::page page + ::page-id (:id page) + ::objects (:objects page))) (defn with-objects [changes objects] (vary-meta changes assoc ::objects objects)) @@ -167,10 +176,25 @@ (reduce add-undo-change-parent $ ids) (reduce add-undo-change-shape $ ids)))))) - (defn move-page [chdata index prev-index] (let [page-id (::page-id (meta chdata))] (-> chdata (update :redo-changes conj {:type :mov-page :id page-id :index index}) (update :undo-changes conj {:type :mov-page :id page-id :index prev-index})))) + +(defn set-page-option + [chdata option-key option-val] + (let [page-id (::page-id (meta chdata)) + page (::page (meta chdata)) + old-val (get-in page [:options option-key])] + + (-> chdata + (update :redo-changes conj {:type :set-option + :page-id page-id + :option option-key + :value option-val}) + (update :undo-changes conj {:type :set-option + :page-id page-id + :option option-key + :value old-val})))) diff --git a/common/src/app/common/pages/diff.cljc b/common/src/app/common/pages/diff.cljc new file mode 100644 index 000000000..46ba2f46b --- /dev/null +++ b/common/src/app/common/pages/diff.cljc @@ -0,0 +1,170 @@ +;; 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) UXBOX Labs SL + +(ns app.common.pages.diff + "Given a page in its old version and the new will retrieve a map with + the differences that will have an impact in the snap data" + (:require + [app.common.data :as d] + [clojure.set :as set])) + +(defn calculate-page-diff + [old-page page check-attrs] + + (let [old-objects (get old-page :objects) + old-guides (or (get-in old-page [:options :guides]) []) + + new-objects (get page :objects) + new-guides (or (get-in page [:options :guides]) []) + + changed-object? + (fn [id] + (let [oldv (get old-objects id) + newv (get new-objects id)] + ;; Check first without select-keys because is faster if they are + ;; the same reference + (and (not= oldv newv) + (not= (select-keys oldv check-attrs) + (select-keys newv check-attrs))))) + + frame? + (fn [id] + (or (= :frame (get-in new-objects [id :type])) + (= :frame (get-in old-objects [id :type])))) + + changed-guide? + (fn [id] + (not= (get old-guides id) + (get new-guides id))) + + deleted-object? + #(and (contains? old-objects %) + (not (contains? new-objects %))) + + deleted-guide? + #(and (contains? old-guides %) + (not (contains? new-guides %))) + + new-object? + #(and (not (contains? old-objects %)) + (contains? new-objects %)) + + new-guide? + #(and (not (contains? old-guides %)) + (contains? new-guides %)) + + changed-frame-object? + #(and (contains? new-objects %) + (contains? old-objects %) + (not= (get-in old-objects [% :frame-id]) + (get-in new-objects [% :frame-id]))) + + changed-frame-guide? + #(and (contains? new-guides %) + (contains? old-guides %) + (not= (get-in old-objects [% :frame-id]) + (get-in new-objects [% :frame-id]))) + + changed-attrs-object? + #(and (contains? new-objects %) + (contains? old-objects %) + (= (get-in old-objects [% :frame-id]) + (get-in new-objects [% :frame-id]))) + + changed-attrs-guide? + #(and (contains? new-guides %) + (contains? old-guides %) + (= (get-in old-objects [% :frame-id]) + (get-in new-objects [% :frame-id]))) + + changed-object-ids + (into #{} + (filter changed-object?) + (set/union (set (keys old-objects)) + (set (keys new-objects)))) + + changed-guides-ids + (into #{} + (filter changed-guide?) + (set/union (set (keys old-guides)) + (set (keys new-guides)))) + + get-diff-object (fn [id] [(get old-objects id) (get new-objects id)]) + get-diff-guide (fn [id] [(get old-guides id) (get new-guides id)]) + + ;; Shapes with different frame owner + change-frame-shapes + (->> changed-object-ids + (into [] (comp (filter changed-frame-object?) + (map get-diff-object)))) + + ;; Guides that changed frames + change-frame-guides + (->> changed-guides-ids + (into [] (comp (filter changed-frame-guide?) + (map get-diff-guide)))) + + removed-frames + (->> changed-object-ids + (into [] (comp (filter frame?) + (filter deleted-object?) + (map (d/getf old-objects))))) + + removed-shapes + (->> changed-object-ids + (into [] (comp (remove frame?) + (filter deleted-object?) + (map (d/getf old-objects))))) + + removed-guides + (->> changed-guides-ids + (into [] (comp (filter deleted-guide?) + (map (d/getf old-guides))))) + + updated-frames + (->> changed-object-ids + (into [] (comp (filter frame?) + (filter changed-attrs-object?) + (map get-diff-object)))) + + updated-shapes + (->> changed-object-ids + (into [] (comp (remove frame?) + (filter changed-attrs-object?) + (map get-diff-object)))) + + updated-guides + (->> changed-guides-ids + (into [] (comp (filter changed-attrs-guide?) + (map get-diff-guide)))) + + new-frames + (->> changed-object-ids + (into [] (comp (filter frame?) + (filter new-object?) + (map (d/getf new-objects))))) + + new-shapes + (->> changed-object-ids + (into [] (comp (remove frame?) + (filter new-object?) + (map (d/getf new-objects))))) + + new-guides + (->> changed-guides-ids + (into [] (comp (filter new-guide?) + (map (d/getf new-guides)))))] + {:change-frame-shapes change-frame-shapes + :change-frame-guides change-frame-guides + :removed-frames removed-frames + :removed-shapes removed-shapes + :removed-guides removed-guides + :updated-frames updated-frames + :updated-shapes updated-shapes + :updated-guides updated-guides + :new-frames new-frames + :new-shapes new-shapes + :new-guides new-guides})) diff --git a/common/src/app/common/types/page_options.cljc b/common/src/app/common/types/page_options.cljc index 4901b8722..687e98985 100644 --- a/common/src/app/common/types/page_options.cljc +++ b/common/src/app/common/types/page_options.cljc @@ -61,12 +61,29 @@ (s/def ::flows (s/coll-of ::flow :kind vector?)) +;; --- Guides + +(s/def :guides/id ::us/uuid) +(s/def :guides/axis #{:x :y}) +(s/def :guides/position ::us/safe-number) +(s/def :guides/frame-id (s/nilable ::us/uuid)) + +(s/def ::guide + (s/keys :req-un [:guides/id + :guides/axis + :guides/position] + :opt-un [:guides/frame-id])) + +(s/def ::guides + (s/map-of uuid? ::guide)) + ;; --- Options (s/def ::options (s/keys :opt-un [::background ::saved-grids - ::flows])) + ::flows + ::guides])) ;; --- Helpers for flow diff --git a/frontend/resources/images/cursors/resize-h-2.svg b/frontend/resources/images/cursors/resize-h-2.svg new file mode 100644 index 000000000..5522037b1 --- /dev/null +++ b/frontend/resources/images/cursors/resize-h-2.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index d2d08f155..6af0751cb 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -30,6 +30,7 @@ [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.fix-bool-contents :as fbc] [app.main.data.workspace.groups :as dwg] + [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.libraries :as dwl] @@ -83,7 +84,8 @@ :snap-grid :scale-text :dynamic-alignment - :display-artboard-names}) + :display-artboard-names + :snap-guides}) (s/def ::layout-flags (s/coll-of ::layout-flag)) @@ -95,7 +97,8 @@ :display-grid :snap-grid :dynamic-alignment - :display-artboard-names}) + :display-artboard-names + :snap-guides}) (def layout-presets {:assets @@ -2033,3 +2036,8 @@ ;; Shapes to path (d/export dwps/convert-selected-to-path) + +;; Guides +(d/export dwgu/update-guides) +(d/export dwgu/remove-guide) + diff --git a/frontend/src/app/main/data/workspace/guides.cljs b/frontend/src/app/main/data/workspace/guides.cljs new file mode 100644 index 000000000..c446d8876 --- /dev/null +++ b/frontend/src/app/main/data/workspace/guides.cljs @@ -0,0 +1,88 @@ +;; 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) UXBOX Labs SL + +(ns app.main.data.workspace.guides + (:require + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.pages.changes-builder :as pcb] + [app.common.spec :as us] + [app.common.types.page-options :as tpo] + [app.main.data.workspace.changes :as dwc] + [app.main.data.workspace.state-helpers :as wsh] + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [potok.core :as ptk])) + +(defn make-update-guide [guide] + (fn [other] + (cond-> other + (= (:id other) (:id guide)) + (merge guide)))) + +(defn update-guides [guide] + (us/verify ::tpo/guide guide) + (ptk/reify ::update-guides + ptk/WatchEvent + (watch [it state _] + (let [page (wsh/lookup-page state) + guides (get-in page [:options :guides] {}) + new-guides (assoc guides (:id guide) guide) + + changes + (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-page-option :guides new-guides))] + (rx/of (dwc/commit-changes changes)))))) + +(defn remove-guide [guide] + (us/verify ::tpo/guide guide) + (ptk/reify ::remove-guide + ptk/WatchEvent + (watch [it state _] + (let [page (wsh/lookup-page state) + guides (get-in page [:options :guides] {}) + new-guides (dissoc guides (:id guide)) + + changes + (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-page-option :guides new-guides))] + (rx/of (dwc/commit-changes changes)))))) + +(defn move-frame-guides + "Move guides that are inside a frame when that frame is moved" + [ids] + (us/verify (s/coll-of uuid?) ids) + + (ptk/reify ::move-frame-guides + ptk/WatchEvent + (watch [_ state _] + (let [objects (wsh/lookup-page-objects state) + + is-frame? (fn [id] (= :frame (get-in objects [id :type]))) + frame-ids? (into #{} (filter is-frame?) ids) + + object-modifiers (get state :workspace-modifiers) + + build-move-event + (fn [guide] + (let [frame (get objects (:frame-id guide)) + frame' (-> (merge frame (get object-modifiers (:frame-id guide))) + (gsh/transform-shape)) + + moved (gpt/to-vec (gpt/point (:x frame) (:y frame)) + (gpt/point (:x frame') (:y frame'))) + + guide (update guide :position + (get moved (:axis guide)))] + (update-guides guide)))] + + (->> (wsh/lookup-page-options state) + :guides + (vals) + (filter (comp frame-ids? :frame-id)) + (map build-move-event) + (rx/from)))))) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 939f9e26e..7c6cc2212 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -58,6 +58,10 @@ :command (ds/c-mod "shift+'") :fn #(st/emit! (dw/toggle-layout-flags :snap-grid))} + :toggle-snap-guide {:tooltip (ds/meta-shift "G") + :command (ds/c-mod "shift+G") + :fn #(st/emit! (dw/toggle-layout-flags :snap-guides))} + :toggle-alignment {:tooltip (ds/meta "\\") :command (ds/c-mod "\\") :fn #(st/emit! (dw/toggle-layout-flags :dynamic-alignment))} diff --git a/frontend/src/app/main/data/workspace/state_helpers.cljs b/frontend/src/app/main/data/workspace/state_helpers.cljs index f144670ba..661b272ad 100644 --- a/frontend/src/app/main/data/workspace/state_helpers.cljs +++ b/frontend/src/app/main/data/workspace/state_helpers.cljs @@ -9,6 +9,12 @@ [app.common.data :as d] [app.common.pages :as cp])) +(defn lookup-page + ([state] + (lookup-page state (:current-page-id state))) + ([state page-id] + (get-in state [:workspace-data :pages-index page-id]))) + (defn lookup-page-objects ([state] (lookup-page-objects state (:current-page-id state))) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 94c3c3af0..5edec0efb 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -16,6 +16,7 @@ [app.common.spec :as us] [app.main.data.workspace.changes :as dch] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.guides :as dwg] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.undo :as dwu] @@ -155,6 +156,8 @@ (update state :workspace-modifiers #(reduce update-shape % shapes))))))) + + (defn- apply-modifiers [ids] (us/verify (s/coll-of uuid?) ids) @@ -168,6 +171,7 @@ ignore-tree (get-ignore-tree object-modifiers objects ids)] (rx/of (dwu/start-undo-transaction) + (dwg/move-frame-guides ids-with-children) (dch/update-shapes ids-with-children (fn [shape] diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 26a197e5a..91d6dd72d 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -204,7 +204,9 @@ :height "100%" :background background-color}} - [:& export/export-page {:options (:options data)}] + (when include-metadata? + [:& export/export-page {:options (:options data)}]) + [:& ff/fontfaces-style {:shapes root-children}] (for [item shapes] (let [frame? (= (:type item) :frame)] diff --git a/frontend/src/app/main/snap.cljs b/frontend/src/app/main/snap.cljs index 41bd7ae79..1de5b1aab 100644 --- a/frontend/src/app/main/snap.cljs +++ b/frontend/src/app/main/snap.cljs @@ -19,20 +19,38 @@ [beicon.core :as rx] [clojure.set :as set])) -(def ^:const snap-accuracy 5) +(def ^:const snap-accuracy 10) (def ^:const snap-path-accuracy 10) (def ^:const snap-distance-accuracy 10) (defn- remove-from-snap-points - [remove-id?] + [remove-snap?] (fn [query-result] (->> query-result - (map (fn [[value data]] [value (remove (comp remove-id? second) data)])) + (map (fn [[value data]] [value (remove remove-snap? data)])) (filter (fn [[_ data]] (seq data)))))) +(defn make-remove-snap + "Creates a filter for the snap data. Used to disable certain layouts" + [layout filter-shapes] + + (fn [{:keys [type id]}] + (cond + (= type :layout) + (or (not (contains? layout :display-grid)) + (not (contains? layout :snap-grid))) + + (= type :guide) + (or (not (contains? layout :rules)) + (not (contains? layout :snap-guides))) + + :else + (or (contains? filter-shapes id) + (not (contains? layout :dynamic-alignment)))))) + (defn- flatten-to-points [query-result] - (mapcat (fn [[_ data]] (map (fn [[point _]] point) data)) query-result)) + (mapcat (fn [[_ data]] (map :pt data)) query-result)) (defn- calculate-distance [query-result point coord] (->> query-result @@ -57,19 +75,19 @@ ;; Otherwise the root frame is the common :else zero))) -(defn get-snap-points [page-id frame-id filter-shapes point coord] +(defn get-snap-points [page-id frame-id remove-snap? point coord] (let [value (get point coord)] (->> (uw/ask! {:cmd :snaps/range-query :page-id page-id :frame-id frame-id - :coord coord + :axis coord :ranges [[(- value 0.5) (+ value 0.5)]]}) (rx/first) - (rx/map (remove-from-snap-points filter-shapes)) + (rx/map (remove-from-snap-points remove-snap?)) (rx/map flatten-to-points)))) (defn- search-snap - [page-id frame-id points coord filter-shapes zoom] + [page-id frame-id points coord remove-snap? zoom] (let [snap-accuracy (/ snap-accuracy zoom) ranges (->> points (map coord) @@ -78,10 +96,10 @@ (->> (uw/ask! {:cmd :snaps/range-query :page-id page-id :frame-id frame-id - :coord coord + :axis coord :ranges ranges}) (rx/first) - (rx/map (remove-from-snap-points filter-shapes)) + (rx/map (remove-from-snap-points remove-snap?)) (rx/map (get-min-distance-snap points coord))))) (defn snap->vector [[[from-x to-x] [from-y to-y]]] @@ -91,13 +109,12 @@ (gpt/to-vec from to)))) (defn- closest-snap - [page-id frame-id points filter-shapes zoom] - (let [snap-x (search-snap page-id frame-id points :x filter-shapes zoom) - snap-y (search-snap page-id frame-id points :y filter-shapes zoom)] + [page-id frame-id points remove-snap? zoom] + (let [snap-x (search-snap page-id frame-id points :x remove-snap? zoom) + snap-y (search-snap page-id frame-id points :y remove-snap? zoom)] (->> (rx/combine-latest snap-x snap-y) (rx/map snap->vector)))) - (defn sr-distance [coord sr1 sr2] (let [c1 (if (= coord :x) :x1 :y1) c2 (if (= coord :x) :x2 :y2) @@ -209,12 +226,8 @@ [page-id shapes layout zoom point] (let [frame-id (snap-frame-id shapes) filter-shapes (into #{} (map :id shapes)) - filter-shapes (fn [id] (if (= id :layout) - (or (not (contains? layout :display-grid)) - (not (contains? layout :snap-grid))) - (or (filter-shapes id) - (not (contains? layout :dynamic-alignment)))))] - (->> (closest-snap page-id frame-id [point] filter-shapes zoom) + remove-snap? (make-remove-snap layout filter-shapes)] + (->> (closest-snap page-id frame-id [point] remove-snap? zoom) (rx/map #(or % (gpt/point 0 0))) (rx/map #(gpt/add point %))))) @@ -222,11 +235,8 @@ [page-id shapes objects layout zoom movev] (let [frame-id (snap-frame-id shapes) filter-shapes (into #{} (map :id shapes)) - filter-shapes (fn [id] (if (= id :layout) - (or (not (contains? layout :display-grid)) - (not (contains? layout :snap-grid))) - (or (filter-shapes id) - (not (contains? layout :dynamic-alignment))))) + remove-snap? (make-remove-snap layout filter-shapes) + shape (if (> (count shapes) 1) (->> shapes (map gsh/transform-shape) gsh/selection-rect (gsh/setup {:type :rect})) (->> shapes (first))) @@ -236,7 +246,7 @@ ;; Move the points in the translation vector (map #(gpt/add % movev)))] - (->> (rx/merge (closest-snap page-id frame-id shapes-points filter-shapes zoom) + (->> (rx/merge (closest-snap page-id frame-id shapes-points remove-snap? zoom) (when (contains? layout :dynamic-alignment) (closest-distance-snap page-id shapes objects zoom movev))) (rx/reduce gpt/min) diff --git a/frontend/src/app/main/ui/cursors.cljs b/frontend/src/app/main/ui/cursors.cljs index c52be6891..a57575099 100644 --- a/frontend/src/app/main/ui/cursors.cljs +++ b/frontend/src/app/main/ui/cursors.cljs @@ -38,6 +38,9 @@ (def resize-nwse (cursor-fn :resize-h 135)) (def rotate (cursor-fn :rotate 90)) +;; +(def resize-ew-2 (cursor-fn :resize-h-2 0)) +(def resize-ns-2 (cursor-fn :resize-h-2 90)) (mf/defc debug-preview {::mf/wrap-props false} diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 1ea58e5ed..bde04d442 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -155,20 +155,30 @@ :name name :starting-frame starting-frame}])]) +(mf/defc export-guides + [{:keys [guides]}] + [:> "penpot:guides" #js {} + (for [{:keys [position frame-id axis]} (vals guides)] + [:> "penpot:guide" #js {:position position + :frame-id frame-id + :axis (d/name axis)}])]) + (mf/defc export-page [{:keys [options]}] (let [saved-grids (get options :saved-grids) - flows (get options :flows)] - (when (or (seq saved-grids) (seq flows)) - (let [parse-grid - (fn [[type params]] - {:type type :params params}) + flows (get options :flows) + guides (get options :guides)] + [:> "penpot:page" #js {} + (when (d/not-empty? saved-grids) + (let [parse-grid (fn [[type params]] {:type type :params params}) grids (->> saved-grids (mapv parse-grid))] - [:> "penpot:page" #js {} - (when (seq saved-grids) - [:& export-grid-data {:grids grids}]) - (when (seq flows) - [:& export-flows {:flows flows}])])))) + [:& export-grid-data {:grids grids}])) + + (when (d/not-empty? flows) + [:& export-flows {:flows flows}]) + + (when (d/not-empty? guides) + [:& export-guides {:guides guides}])])) (defn- export-shadow-data [{:keys [shadow]}] (mf/html diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 43f89e04e..541fb0b84 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -20,7 +20,6 @@ [app.main.ui.workspace.header :refer [header]] [app.main.ui.workspace.left-toolbar :refer [left-toolbar]] [app.main.ui.workspace.libraries] - [app.main.ui.workspace.rules :refer [horizontal-rule vertical-rule]] [app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]] [app.main.ui.workspace.viewport :refer [viewport]] [app.util.dom :as dom] @@ -31,45 +30,22 @@ ;; --- Workspace -(mf/defc workspace-rules - {::mf/wrap-props false - ::mf/wrap [mf/memo]} - [props] - (let [zoom (or (obj/get props "zoom") 1) - vbox (obj/get props "vbox") - vport (obj/get props "vport") - colorpalette? (obj/get props "colorpalette?")] - - [:* - [:div.empty-rule-square] - [:& horizontal-rule {:zoom zoom - :vbox vbox - :vport vport}] - [:& vertical-rule {:zoom zoom - :vbox vbox - :vport vport}] - [:& coordinates/coordinates {:colorpalette? colorpalette?}]])) - (mf/defc workspace-content {::mf/wrap-props false} [props] (let [selected (mf/deref refs/selected-shapes) local (mf/deref refs/viewport-data) - {:keys [zoom vbox vport options-mode]} local + {:keys [options-mode]} local file (obj/get props "file") - layout (obj/get props "layout")] + layout (obj/get props "layout") + colorpalette? (:colorpalette layout)] [:* - (when (:colorpalette layout) - [:& colorpalette]) + (when colorpalette? [:& colorpalette]) [:section.workspace-content [:section.workspace-viewport - (when (contains? layout :rules) - [:& workspace-rules {:zoom zoom - :vbox vbox - :vport vport - :colorpalette? (contains? layout :colorpalette)}]) + [:& coordinates/coordinates {:colorpalette? colorpalette?}] [:& viewport {:file file :local local diff --git a/frontend/src/app/main/ui/workspace/header.cljs b/frontend/src/app/main/ui/workspace/header.cljs index 6a7c5aaab..f400f2f7b 100644 --- a/frontend/src/app/main/ui/workspace/header.cljs +++ b/frontend/src/app/main/ui/workspace/header.cljs @@ -194,7 +194,13 @@ (fn [_error] (st/emit! (dm/error (tr "errors.unexpected-error")))) (st/emitf dm/hide))))))) - on-item-click (fn [item] (fn [event] (do (dom/stop-propagation event) (reset! show-sub-menu? item))))] + + on-item-click + (mf/use-callback + (fn [item] + (fn [event] + (dom/stop-propagation event) + (reset! show-sub-menu? item))))] (mf/use-effect (mf/deps @editing?) @@ -314,12 +320,12 @@ [:& dropdown {:show (= @show-sub-menu? :preferences) :on-close #(reset! show-sub-menu? false)} [:ul.sub-menu.preferences - #_[:li {:on-click #()} - [:span - (if (contains? layout :snap-guide) - (tr "workspace.header.menu.disable-snap-guides") - (tr "workspace.header.menu.enable-snap-guides"))] - [:span.shortcut (sc/get-tooltip :toggle-snap-grid)]] + [:li {:on-click #(st/emit! (dw/toggle-layout-flags :snap-guides))} + [:span + (if (contains? layout :snap-guides) + (tr "workspace.header.menu.disable-snap-guides") + (tr "workspace.header.menu.enable-snap-guides"))] + [:span.shortcut (sc/get-tooltip :toggle-snap-guide)]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :snap-grid))} [:span diff --git a/frontend/src/app/main/ui/workspace/rules.cljs b/frontend/src/app/main/ui/workspace/rules.cljs deleted file mode 100644 index a9d5b34b2..000000000 --- a/frontend/src/app/main/ui/workspace/rules.cljs +++ /dev/null @@ -1,122 +0,0 @@ -;; 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) UXBOX Labs SL - -(ns app.main.ui.workspace.rules - (:require - [app.common.colors :as colors] - [app.common.math :as mth] - [app.util.object :as obj] - [rumext.alpha :as mf])) - -(defn- calculate-step-size - [zoom] - (cond - (< 0 zoom 0.008) 10000 - (< 0.008 zoom 0.015) 5000 - (< 0.015 zoom 0.04) 2500 - (< 0.04 zoom 0.07) 1000 - (< 0.07 zoom 0.2) 500 - (< 0.2 zoom 0.5) 250 - (< 0.5 zoom 1) 100 - (<= 1 zoom 2) 50 - (< 2 zoom 4) 25 - (< 4 zoom 6) 10 - (< 6 zoom 15) 5 - (< 15 zoom 25) 2 - (< 25 zoom) 1 - :else 1)) - -(defn draw-rule! - [dctx {:keys [zoom size start type]}] - (when start - (let [txfm (- (* (- 0 start) zoom) 20) - step (calculate-step-size zoom) - - minv (max (mth/round start) -100000) - minv (* (mth/ceil (/ minv step)) step) - - maxv (min (mth/round (+ start (/ size zoom))) 100000) - maxv (* (mth/floor (/ maxv step)) step) - - path (js/Path2D.)] - - (if (= type :horizontal) - (.translate dctx txfm 0) - (.translate dctx 0 txfm)) - - (obj/set! dctx "font" "12px worksans") - (obj/set! dctx "fillStyle" colors/gray-30) - (obj/set! dctx "strokeStyle" colors/gray-30) - (obj/set! dctx "textAlign" "center") - - (loop [i minv] - (if (<= i maxv) - (let [pos (+ (* i zoom) 0)] - (.save dctx) - (if (= type :horizontal) - (do - ;; Write the rule numbers - (.fillText dctx (str i) pos 13) - - ;; Build the rules lines - (.moveTo path pos 17) - (.lineTo path pos 20)) - (do - ;; Write the rule numbers - (.translate dctx 12 pos) - (.rotate dctx (/ (* 270 js/Math.PI) 180)) - (.fillText dctx (str i) 0 0) - - ;; Build the rules lines - (.moveTo path 17 pos) - (.lineTo path 20 pos))) - (.restore dctx) - (recur (+ i step))) - - ;; Put the path in the canvas - (.stroke dctx path)))))) - - -(mf/defc horizontal-rule - [{:keys [zoom vbox vport] :as props}] - (let [canvas (mf/use-ref) - width (- (:width vport) 20)] - (mf/use-layout-effect - (mf/deps zoom width (:x vbox)) - (fn [] - (let [node (mf/ref-val canvas) - dctx (.getContext ^js node "2d")] - (obj/set! node "width" width) - (draw-rule! dctx {:zoom zoom - :type :horizontal - :size width - :start (+ (:x vbox) (:left-offset vbox))})))) - - [:canvas.horizontal-rule - {:ref canvas - :width width - :height 20}])) - -(mf/defc vertical-rule - [{:keys [zoom vbox vport] :as props}] - (let [canvas (mf/use-ref) - height (- (:height vport) 20)] - (mf/use-layout-effect - (mf/deps zoom height (:y vbox)) - (fn [] - (let [node (mf/ref-val canvas) - dctx (.getContext ^js node "2d")] - (obj/set! node "height" height) - (draw-rule! dctx {:zoom zoom - :type :vertical - :size height - :count 100 - :start (:y vbox)})))) - - [:canvas.vertical-rule - {:ref canvas - :width 20 - :height height}])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index bb1eb3605..2ca92bb7f 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -21,11 +21,13 @@ [app.main.ui.workspace.viewport.drawarea :as drawarea] [app.main.ui.workspace.viewport.frame-grid :as frame-grid] [app.main.ui.workspace.viewport.gradients :as gradients] + [app.main.ui.workspace.viewport.guides :as guides] [app.main.ui.workspace.viewport.hooks :as hooks] [app.main.ui.workspace.viewport.interactions :as interactions] [app.main.ui.workspace.viewport.outline :as outline] [app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay] [app.main.ui.workspace.viewport.presence :as presence] + [app.main.ui.workspace.viewport.rules :as rules] [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] @@ -89,6 +91,13 @@ ;; STREAMS move-stream (mf/use-memo #(rx/subject)) + frame-parent (mf/use-memo + (mf/deps @hover-ids base-objects) + (fn [] + (let [parent (get base-objects (last @hover-ids))] + (when (= :frame (:type parent)) + parent)))) + zoom (d/check-num zoom 1) drawing-tool (:tool drawing) drawing-obj (:object drawing) @@ -145,7 +154,10 @@ (or drawing-obj transform)) show-selrect? (and selrect (empty? drawing)) show-measures? (and (not transform) (not node-editing?) show-distances?) - show-artboard-names? (contains? layout :display-artboard-names)] + show-artboard-names? (contains? layout :display-artboard-names) + show-rules? (contains? layout :rules) + + disabled-guides? (or drawing-tool transform)] (hooks/setup-dom-events viewport-ref zoom disable-paste in-viewport?) (hooks/setup-viewport-size viewport-ref) @@ -157,6 +169,8 @@ (hooks/setup-shortcuts node-editing? drawing-path?) (hooks/setup-active-frames base-objects vbox hover active-frames) + + [:div.viewport [:div.viewport-overlays @@ -294,7 +308,9 @@ (when show-grids? [:& frame-grid/frame-grid - {:zoom zoom :selected selected :transform transform}]) + {:zoom zoom + :selected selected + :transform transform}]) (when show-pixel-grid? [:& widgets/pixel-grid @@ -325,12 +341,6 @@ {:zoom zoom :tooltip tooltip}]) - (when show-presence? - [:& presence/active-cursors - {:page-id page-id}]) - - [:& widgets/viewport-actions] - (when show-prototypes? [:& interactions/interactions {:selected selected @@ -341,5 +351,24 @@ (when show-selrect? [:& widgets/selection-rect {:data selrect - :zoom zoom}])]]])) + :zoom zoom}]) + + (when show-presence? + [:& presence/active-cursors + {:page-id page-id}]) + + [:& widgets/viewport-actions] + + (when show-rules? + [:* + [:& rules/rules + {:zoom zoom + :vbox vbox}] + + [:& guides/viewport-guides + {:zoom zoom + :vbox vbox + :hover-frame frame-parent + :modifiers modifiers + :disabled-guides? disabled-guides?}]])]]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs new file mode 100644 index 000000000..f18d913f6 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -0,0 +1,468 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.workspace.viewport.guides + (:require + [app.common.colors :as colors] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.uuid :as uuid] + [app.main.data.workspace :as dw] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.streams :as ms] + [app.main.ui.cursors :as cur] + [app.main.ui.workspace.viewport.rules :as rules] + [app.util.dom :as dom] + [rumext.alpha :as mf])) + +(def guide-width 1) +(def guide-opacity 0.7) +(def guide-opacity-hover 1) +(def guide-color colors/primary) +(def guide-pill-width 34) +(def guide-pill-height 20) +(def guide-pill-corner-radius 4) +(def guide-active-area 16) + +(defn use-guide + "Hooks to support drag/drop for existing guides and new guides" + [on-guide-change get-hover-frame zoom {:keys [position axis frame-id]}] + (let [dragging-ref (mf/use-ref false) + start-ref (mf/use-ref nil) + start-pos-ref (mf/use-ref nil) + state (mf/use-state {:hover false + :new-position nil + :new-frame-id frame-id}) + + frame-id (:new-frame-id @state) + + frame-ref (mf/use-memo (mf/deps frame-id) #(refs/object-by-id frame-id)) + frame (mf/deref frame-ref) + + on-pointer-enter + (mf/use-callback + (fn [] + (swap! state assoc :hover true))) + + on-pointer-leave + (mf/use-callback + (fn [] + (swap! state assoc :hover false))) + + on-pointer-down + (mf/use-callback + (fn [event] + (dom/capture-pointer event) + (mf/set-ref-val! dragging-ref true) + (mf/set-ref-val! start-ref (dom/get-client-position event)) + (mf/set-ref-val! start-pos-ref (get @ms/mouse-position axis)))) + + on-pointer-up + (mf/use-callback + (mf/deps (select-keys @state [:new-position :new-frame-id]) on-guide-change) + (fn [] + (when (some? on-guide-change) + (when (some? (:new-position @state)) + (on-guide-change {:position (:new-position @state) + :frame-id (:new-frame-id @state)}))))) + + on-lost-pointer-capture + (mf/use-callback + (fn [event] + (dom/release-pointer event) + (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! start-ref nil) + (mf/set-ref-val! start-pos-ref nil) + (swap! state assoc :new-position nil))) + + on-mouse-move + (mf/use-callback + (mf/deps position zoom) + (fn [event] + + (when-let [_ (mf/ref-val dragging-ref)] + (let [start-pt (mf/ref-val start-ref) + start-pos (mf/ref-val start-pos-ref) + current-pt (dom/get-client-position event) + delta (/ (- (get current-pt axis) (get start-pt axis)) zoom) + new-position (if (some? position) + (+ position delta) + (+ start-pos delta)) + + ;; TODO: Change when pixel-grid flag exists + new-position (mth/round new-position) + new-frame-id (:id (get-hover-frame))] + (swap! state assoc + :new-position new-position + :new-frame-id new-frame-id)))))] + {:on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave + :on-pointer-down on-pointer-down + :on-pointer-up on-pointer-up + :on-lost-pointer-capture on-lost-pointer-capture + :on-mouse-move on-mouse-move + :state state + :frame frame})) + +;; This functions are auxiliary to get the coords of components depending on the axis +;; we're handling + +(defn guide-area-axis + [pos vbox zoom frame axis] + (let [rules-pos (/ rules/rules-pos zoom) + guide-active-area (/ guide-active-area zoom)] + (cond + (and (some? frame) (= axis :x)) + {:x (- pos (/ guide-active-area 2)) + :y (:y frame) + :width guide-active-area + :height (:height frame)} + + (some? frame) + {:x (:x frame) + :y (- pos (/ guide-active-area 2)) + :width (:width frame) + :height guide-active-area} + + (= axis :x) + {:x (- pos (/ guide-active-area 2)) + :y (+ (:y vbox) rules-pos) + :width guide-active-area + :height (:height vbox)} + + :else + {:x (+ (:x vbox) rules-pos) + :y (- pos (/ guide-active-area 2)) + :width (:width vbox) + :height guide-active-area}))) + +(defn guide-line-axis + ([pos vbox axis] + (if (= axis :x) + {:x1 pos + :y1 (:y vbox) + :x2 pos + :y2 (+ (:y vbox) (:height vbox))} + + {:x1 (:x vbox) + :y1 pos + :x2 (+ (:x vbox) (:width vbox)) + :y2 pos})) + + ([pos vbox frame axis] + (if (= axis :x) + {:l1-x1 pos + :l1-y1 (:y vbox) + :l1-x2 pos + :l1-y2 (:y frame) + :l2-x1 pos + :l2-y1 (:y frame) + :l2-x2 pos + :l2-y2 (+ (:y frame) (:height frame)) + :l3-x1 pos + :l3-y1 (+ (:y frame) (:height frame)) + :l3-x2 pos + :l3-y2 (+ (:y vbox) (:height vbox))} + {:l1-x1 (:x vbox) + :l1-y1 pos + :l1-x2 (:x frame) + :l1-y2 pos + :l2-x1 (:x frame) + :l2-y1 pos + :l2-x2 (+ (:x frame) (:width frame)) + :l2-y2 pos + :l3-x1 (+ (:x frame) (:width frame)) + :l3-y1 pos + :l3-x2 (+ (:x vbox) (:width vbox)) + :l3-y2 pos}))) + +(defn guide-pill-axis + [pos vbox zoom axis] + (let [rules-pos (/ rules/rules-pos zoom) + guide-pill-width (/ guide-pill-width zoom) + guide-pill-height (/ guide-pill-height zoom)] + + (if (= axis :x) + {:rect-x (- pos (/ guide-pill-width 2)) + :rect-y (+ (:y vbox) rules-pos (- (/ guide-pill-width 2)) (/ 2 zoom)) + :rect-width guide-pill-width + :rect-height guide-pill-height + :text-x pos + :text-y (+ (:y vbox) rules-pos (- (/ 3 zoom)))} + + {:rect-x (+ (:x vbox) rules-pos (- (/ guide-pill-height 2)) (- (/ 5 zoom))) + :rect-y (- pos (/ guide-pill-width 2)) + :rect-width guide-pill-height + :rect-height guide-pill-width + :text-x (+ (:x vbox) rules-pos (- (/ 3 zoom))) + :text-y pos}))) + +(defn guide-inside-vbox? + ([vbox] + (partial guide-inside-vbox? vbox)) + + ([{:keys [x y width height]} {:keys [axis position]}] + (let [x1 x + x2 (+ x width) + y1 y + y2 (+ y height)] + (if (= axis :x) + (and (>= position x1) + (<= position x2)) + (and (>= position y1) + (<= position y2)))))) + +(defn guide-creation-area + [vbox zoom axis] + (if (= axis :x) + {:x (:x vbox) + :y (:y vbox) + :width (/ 24 zoom) + :height (:height vbox)} + + {:x (:x vbox) + :y (:y vbox) + :width (:width vbox) + :height (/ 24 zoom)})) + +(defn is-guide-inside-frame? + [guide frame] + + (if (= :x (:axis guide)) + (and (>= (:position guide) (:x frame) ) + (<= (:position guide) (+ (:x frame) (:width frame)) )) + + (and (>= (:position guide) (:y frame) ) + (<= (:position guide) (+ (:y frame) (:height frame)) )))) + +(mf/defc guide + {::mf/wrap [mf/memo]} + [{:keys [guide hover? on-guide-change get-hover-frame vbox zoom hover-frame disabled-guides? frame-modifier]}] + + (let [axis (:axis guide) + + handle-change-position + (mf/use-callback + (mf/deps on-guide-change) + (fn [changes] + (when on-guide-change + (on-guide-change (merge guide changes))))) + + {:keys [on-pointer-enter + on-pointer-leave + on-pointer-down + on-pointer-up + on-lost-pointer-capture + on-mouse-move + state + frame]} (use-guide handle-change-position get-hover-frame zoom guide) + + base-frame (or frame hover-frame) + frame (gsh/transform-shape (merge base-frame frame-modifier)) + + move-vec (gpt/to-vec (gpt/point (:x base-frame) (:y base-frame)) + (gpt/point (:x frame) (:y frame))) + + pos (+ (or (:new-position @state) (:position guide)) (get move-vec axis)) + guide-width (/ guide-width zoom) + guide-pill-corner-radius (/ guide-pill-corner-radius zoom)] + + (when (or (nil? frame) + (is-guide-inside-frame? (assoc guide :position pos) frame) + (:hover @state true)) + [:g.guide-area + (when-not disabled-guides? + (let [{:keys [x y width height]} (guide-area-axis pos vbox zoom frame axis)] + [:rect {:x x + :y y + :width width + :height height + :style {:fill "none" + :pointer-events "fill" + :cursor (if (= axis :x) (cur/resize-ew 0) (cur/resize-ns 0))} + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave + :on-pointer-down on-pointer-down + :on-pointer-up on-pointer-up + :on-lost-pointer-capture on-lost-pointer-capture + :on-mouse-move on-mouse-move}])) + + (if (some? frame) + (let [{:keys [l1-x1 l1-y1 l1-x2 l1-y2 + l2-x1 l2-y1 l2-x2 l2-y2 + l3-x1 l3-y1 l3-x2 l3-y2]} + (guide-line-axis pos vbox frame axis)] + [:g + (when (or hover? (:hover @state)) + [:line {:x1 l1-x1 + :y1 l1-y1 + :x2 l1-x2 + :y2 l1-y2 + :style {:stroke guide-color + :stroke-opacity guide-opacity-hover + :stroke-dasharray (str "0, " (/ 6 zoom)) + :stroke-linecap "round" + :stroke-width guide-width}}]) + [:line {:x1 l2-x1 + :y1 l2-y1 + :x2 l2-x2 + :y2 l2-y2 + :style {:stroke guide-color + :stroke-width guide-width + :stroke-opacity (if (or hover? (:hover @state)) + guide-opacity-hover + guide-opacity)}}] + (when (or hover? (:hover @state)) + [:line {:x1 l3-x1 + :y1 l3-y1 + :x2 l3-x2 + :y2 l3-y2 + :style {:stroke guide-color + :stroke-opacity guide-opacity-hover + :stroke-width guide-width + :stroke-dasharray (str "0, " (/ 6 zoom)) + :stroke-linecap "round"}}])]) + + (let [{:keys [x1 y1 x2 y2]} (guide-line-axis pos vbox axis)] + [:line {:x1 x1 + :y1 y1 + :x2 x2 + :y2 y2 + :style {:stroke guide-color + :stroke-width guide-width + :stroke-opacity (if (or hover? (:hover @state)) + guide-opacity-hover + guide-opacity)}}])) + + (when (or hover? (:hover @state)) + (let [{:keys [rect-x rect-y rect-width rect-height text-x text-y]} + (guide-pill-axis pos vbox zoom axis)] + [:g.guide-pill + [:rect {:x rect-x + :y rect-y + :width rect-width + :height rect-height + :rx guide-pill-corner-radius + :ry guide-pill-corner-radius + :style {:fill guide-color}}] + + [:text {:x text-x + :y text-y + :text-anchor "middle" + :dominant-baseline "middle" + :transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")")) + :style {:font-size (/ 13 zoom) + :font-family "sourcesanspro" + :fill colors/black}} + (str (mth/round pos))]]))]))) + +(mf/defc new-guide-area + [{:keys [vbox zoom axis get-hover-frame disabled-guides?]}] + + (let [on-guide-change + (mf/use-callback + (mf/deps vbox) + (fn [guide] + (let [guide (-> guide + (assoc :id (uuid/next) + :axis axis))] + (when (guide-inside-vbox? vbox guide) + (st/emit! (dw/update-guides guide)))))) + + {:keys [on-pointer-enter + on-pointer-leave + on-pointer-down + on-pointer-up + on-lost-pointer-capture + on-mouse-move + state + frame]} (use-guide on-guide-change get-hover-frame zoom {:axis axis})] + + [:g.new-guides + (when-not disabled-guides? + (let [{:keys [x y width height]} (guide-creation-area vbox zoom axis)] + [:rect {:x x + :y y + :width width + :height height + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave + :on-pointer-down on-pointer-down + :on-pointer-up on-pointer-up + :on-lost-pointer-capture on-lost-pointer-capture + :on-mouse-move on-mouse-move + :style {:fill "none" + :pointer-events "fill" + :cursor (if (= axis :x) (cur/resize-ew 0) (cur/resize-ns 0))}}])) + + (when (:new-position @state) + [:& guide {:guide {:axis axis + :position (:new-position @state)} + :get-hover-frame get-hover-frame + :vbox vbox + :zoom zoom + :hover? true + :hover-frame frame}])])) + +(mf/defc viewport-guides + {::mf/wrap [mf/memo]} + [{:keys [zoom vbox hover-frame disabled-guides? modifiers]}] + + (let [page (mf/deref refs/workspace-page) + + guides (mf/use-memo + (mf/deps page vbox) + #(->> (get-in page [:options :guides] {}) + (vals) + (filter (guide-inside-vbox? vbox)))) + + hover-frame-ref (mf/use-ref nil) + + ;; We use the ref to not redraw every guide everytime the hovering frame change + ;; we're only interested to get the frame in the guide we're moving + get-hover-frame + (mf/use-callback + (fn [] + (mf/ref-val hover-frame-ref))) + + on-guide-change + (mf/use-callback + (mf/deps vbox) + (fn [guide] + (if (guide-inside-vbox? vbox guide) + (st/emit! (dw/update-guides guide)) + (st/emit! (dw/remove-guide guide)))))] + + (mf/use-effect + (mf/deps hover-frame) + (fn [] + (mf/set-ref-val! hover-frame-ref hover-frame))) + + [:g.guides {:pointer-events "none"} + [:& new-guide-area {:vbox vbox + :zoom zoom + :axis :x + :get-hover-frame get-hover-frame + :disabled-guides? disabled-guides?}] + + [:& new-guide-area {:vbox vbox + :zoom zoom + :axis :y + :get-hover-frame get-hover-frame + :disabled-guides? disabled-guides?}] + + (for [current guides] + [:& guide {:key (str "guide-" (:id current)) + :guide current + :vbox vbox + :zoom zoom + :frame-modifier (get modifiers (:frame-id current)) + :get-hover-frame get-hover-frame + :on-guide-change on-guide-change + :disabled-guides? disabled-guides?}])])) + diff --git a/frontend/src/app/main/ui/workspace/viewport/rules.cljs b/frontend/src/app/main/ui/workspace/viewport/rules.cljs new file mode 100644 index 000000000..6b2145274 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/viewport/rules.cljs @@ -0,0 +1,137 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.workspace.viewport.rules + (:require + [app.common.colors :as colors] + [app.common.data :as d] + [app.common.math :as mth] + [app.util.object :as obj] + [rumext.alpha :as mf])) + +(def rules-pos 15) +(def rules-size 4) +(def rules-width 1) + +;; ---------------- +;; RULES +;; ---------------- + +(defn- calculate-step-size + [zoom] + (cond + (< 0 zoom 0.008) 10000 + (< 0.008 zoom 0.015) 5000 + (< 0.015 zoom 0.04) 2500 + (< 0.04 zoom 0.07) 1000 + (< 0.07 zoom 0.2) 500 + (< 0.2 zoom 0.5) 250 + (< 0.5 zoom 1) 100 + (<= 1 zoom 2) 50 + (< 2 zoom 4) 25 + (< 4 zoom 6) 10 + (< 6 zoom 15) 5 + (< 15 zoom 25) 2 + (< 25 zoom) 1 + :else 1)) + +(defn get-clip-area + [vbox zoom axis] + (if (= axis :x) + (let [x (+ (:x vbox) (/ 25 zoom)) + y (:y vbox) + width (- (:width vbox) (/ 21 zoom)) + height (/ 25 zoom)] + {:x x :y y :width width :height height}) + + (let [x (:x vbox) + y (+ (:y vbox) (/ 25 zoom)) + width (/ 25 zoom) + height (- (:height vbox) (/ 21 zoom))] + {:x x :y y :width width :height height}))) + +(defn get-rule-params + [vbox axis] + (if (= axis :x) + (let [start (:x vbox) + end (+ start (:width vbox))] + {:start start :end end}) + + (let [start (:y vbox) + end (+ start (:height vbox))] + {:start start :end end}))) + +(defn get-rule-axis + [val vbox zoom axis] + (let [rules-pos (/ rules-pos zoom) + rules-size (/ rules-size zoom)] + (if (= axis :x) + {:text-x val + :text-y (+ (:y vbox) (- rules-pos (/ 4 zoom))) + :line-x1 val + :line-y1 (+ (:y vbox) rules-pos (/ 2 zoom)) + :line-x2 val + :line-y2 (+ (:y vbox) rules-pos (/ 2 zoom) rules-size)} + + {:text-x (+ (:x vbox) (- rules-pos (/ 4 zoom))) + :text-y val + :line-x1 (+ (:x vbox) rules-pos (/ 2 zoom)) + :line-y1 val + :line-x2 (+ (:x vbox) rules-pos (/ 2 zoom) rules-size) + :line-y2 val}))) + +(mf/defc rules-axis + [{:keys [zoom vbox axis]}] + (let [rules-width (/ rules-width zoom) + step (calculate-step-size zoom) + clip-id (str "clip-rule-" (d/name axis))] + + [:g.rules {:clipPath (str "url(#" clip-id ")")} + + [:defs + [:clipPath {:id clip-id} + (let [{:keys [x y width height]} (get-clip-area vbox zoom axis)] + [:rect {:x x :y y :width width :height height}])]] + + (let [{:keys [start end]} (get-rule-params vbox axis) + minv (max (mth/round start) -100000) + minv (* (mth/ceil (/ minv step)) step) + maxv (min (mth/round end) 100000) + maxv (* (mth/floor (/ maxv step)) step)] + + (for [step-val (range minv (inc maxv) step)] + (let [{:keys [text-x text-y line-x1 line-y1 line-x2 line-y2]} + (get-rule-axis step-val vbox zoom axis)] + [:* + [:text {:key (str "text-" (d/name axis) "-" step-val) + :x text-x + :y text-y + :text-anchor "middle" + :dominant-baseline "middle" + :transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")")) + :style {:font-size (/ 13 zoom) + :font-family "sourcesanspro" + :fill colors/gray-30}} + (str (mth/round step-val))] + + [:line {:key (str "line-" (d/name axis) "-" step-val) + :x1 line-x1 + :y1 line-y1 + :x2 line-x2 + :y2 line-y2 + :style {:stroke colors/gray-30 + :stroke-width rules-width}}]])))])) + +(mf/defc rules + {::mf/wrap-props false + ::mf/wrap [mf/memo]} + [props] + (let [zoom (obj/get props "zoom") + vbox (obj/get props "vbox")] + (when (some? vbox) + [:g.rules {:pointer-events "none"} + [:& rules-axis {:zoom zoom :vbox vbox :axis :x}] + [:& rules-axis {:zoom zoom :vbox vbox :axis :y}]]))) diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index ec95528ee..d36109911 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -52,7 +52,7 @@ :opacity line-opacity}]) (defn get-snap - [coord {:keys [shapes page-id filter-shapes modifiers]}] + [coord {:keys [shapes page-id remove-snap? modifiers]}] (let [shape (if (> (count shapes) 1) (->> shapes (map gsh/transform-shape) gsh/selection-rect (gsh/setup {:type :rect})) (->> shapes (first))) @@ -68,7 +68,7 @@ (->> (sp/shape-snap-points shape) (map #(vector frame-id %))))) (rx/flat-map (fn [[frame-id point]] - (->> (snap/get-snap-points page-id frame-id filter-shapes point coord) + (->> (snap/get-snap-points page-id frame-id remove-snap? point coord) (rx/map #(vector point % coord))))) (rx/reduce conj [])))) @@ -104,7 +104,7 @@ (hash-map coord fixedv (flip coord) maxv)])))) (mf/defc snap-feedback - [{:keys [shapes filter-shapes zoom modifiers] :as props}] + [{:keys [shapes remove-snap? zoom modifiers] :as props}] (let [state (mf/use-state []) subject (mf/use-memo #(rx/subject)) @@ -129,7 +129,7 @@ #(rx/dispose! sub)))) (mf/use-effect - (mf/deps shapes filter-shapes modifiers) + (mf/deps shapes remove-snap? modifiers) (fn [] (rx/push! subject props))) @@ -152,29 +152,23 @@ {::mf/wrap [mf/memo]} [{:keys [layout zoom objects selected page-id drawing transform modifiers] :as props}] - (let [;; shapes (mf/deref (refs/objects-by-id selected)) - ;; filter-shapes (mf/deref refs/selected-shapes-with-children) + (let [shapes (into [] (keep (d/getf objects)) selected) - shapes (->> selected - (map #(get objects %)) - (filterv (comp not nil?))) - filter-shapes (into #{} - (comp (mapcat #(cp/get-object-with-children % objects)) - (map :id)) - selected) + filter-shapes + (into #{} + (comp (mapcat #(cp/get-object-with-children % objects)) + (map :id)) + selected) - filter-shapes (fn [id] - (if (= id :layout) - (or (not (contains? layout :display-grid)) - (not (contains? layout :snap-grid))) - (or (filter-shapes id) - (not (contains? layout :dynamic-alignment))))) + remove-snap? (mf/use-memo + (mf/deps layout filter-shapes) + #(snap/make-remove-snap layout filter-shapes)) shapes (if drawing [drawing] shapes)] (when (or drawing transform) [:& snap-feedback {:shapes shapes :page-id page-id - :filter-shapes filter-shapes + :remove-snap? remove-snap? :zoom zoom :modifiers modifiers}]))) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 37e75d1a9..e6505c51a 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -213,9 +213,20 @@ (.-innerText el))) (defn query - [^js el ^string query] - (when (some? el) - (.querySelector el query))) + ([^string query] + (query globals/document query)) + + ([^js el ^string query] + (when (some? el) + (.querySelector el query)))) + +(defn query-all + ([^string query] + (query-all globals/document query)) + + ([^js el ^string query] + (when (some? el) + (.querySelectorAll el query)))) (defn get-client-position [^js event] diff --git a/frontend/src/app/util/geom/snap_points.cljs b/frontend/src/app/util/geom/snap_points.cljs index 669a29113..8fccf5bf3 100644 --- a/frontend/src/app/util/geom/snap_points.cljs +++ b/frontend/src/app/util/geom/snap_points.cljs @@ -28,3 +28,9 @@ (case (:type shape) :frame (-> shape :selrect frame-snap-points) (into #{(gsh/center-shape shape)} (:points shape))))) + +(defn guide-snap-points + [guide] + (if (= :x (:axis guide)) + #{(gpt/point (:position guide) 0)} + #{(gpt/point 0 (:position guide))})) diff --git a/frontend/src/app/util/import/parser.cljs b/frontend/src/app/util/import/parser.cljs index dec08ba7c..35f9e74f0 100644 --- a/frontend/src/app/util/import/parser.cljs +++ b/frontend/src/app/util/import/parser.cljs @@ -515,6 +515,20 @@ (let [flows-node (get-data node :penpot:flows)] (->> flows-node :content (mapv parse-flow-node)))) +(defn parse-guide-node [node] + (let [attrs (-> node :attrs remove-penpot-prefix)] + (println attrs) + (let [id (uuid/next)] + [id + {:id id + :frame-id (when (:frame-id attrs) (-> attrs :frame-id uuid)) + :axis (-> attrs :axis keyword) + :position (-> attrs :position d/parse-double)}]))) + +(defn parse-guides [node] + (let [guides-node (get-data node :penpot:guides)] + (->> guides-node :content (map parse-guide-node) (into {})))) + (defn extract-from-data ([node tag] (extract-from-data node tag identity)) @@ -764,7 +778,8 @@ grids (->> (parse-grids node) (group-by :type) (d/mapm (fn [_ v] (-> v first :params)))) - flows (parse-flows node)] + flows (parse-flows node) + guides (parse-guides node)] (cond-> {} (some? background) (assoc-in [:options :background] background) @@ -773,7 +788,10 @@ (assoc-in [:options :saved-grids] grids) (d/not-empty? flows) - (assoc-in [:options :flows] flows)))) + (assoc-in [:options :flows] flows) + + (d/not-empty? guides) + (assoc-in [:options :guides] guides)))) (defn parse-interactions [node] diff --git a/frontend/src/app/util/snap_data.cljs b/frontend/src/app/util/snap_data.cljs new file mode 100644 index 000000000..6c667c217 --- /dev/null +++ b/frontend/src/app/util/snap_data.cljs @@ -0,0 +1,251 @@ +;; 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) UXBOX Labs SL + +(ns app.util.snap-data + "Data structure that holds and retrieves the data to make the snaps. Internaly + is implemented with a balanced binary tree that queries by range. + https://en.wikipedia.org/wiki/Range_tree" + (:require + [app.common.data :as d] + [app.common.pages :as cp] + [app.common.pages.diff :as diff] + [app.common.uuid :as uuid] + [app.util.geom.grid :as gg] + [app.util.geom.snap-points :as snap] + [app.util.range-tree :as rt])) + +(def snap-attrs [:frame-id :x :y :width :height :hidden :selrect :grids]) + +;; PRIVATE FUNCTIONS + +(defn- make-insert-tree-data + "Inserts all data in it's corresponding axis bucket" + [shape-data axis] + (fn [tree] + (let [tree (or tree (rt/make-tree)) + + insert-data + (fn [tree data] + (rt/insert tree (get-in data [:pt axis]) data))] + + (reduce insert-data tree shape-data)))) + +(defn- make-delete-tree-data + "Removes all data in it's corresponding axis bucket" + [shape-data axis] + (fn [tree] + (let [tree (or tree (rt/make-tree)) + + remove-data + (fn [tree data] + (rt/remove tree (get-in data [:pt axis]) data))] + + (reduce remove-data tree shape-data)))) + +(defn- add-root-frame + [page-data] + (let [frame-id uuid/zero] + + (-> page-data + (assoc-in [frame-id :x] (rt/make-tree)) + (assoc-in [frame-id :y] (rt/make-tree))))) + +(defn- add-frame + [page-data frame] + (let [frame-id (:id frame) + parent-id (:parent-id frame) + frame-data (->> (snap/shape-snap-points frame) + (mapv #(array-map :type :shape + :id frame-id + :pt %))) + + grid-x-data (->> (gg/grid-snap-points frame :x) + (mapv #(array-map :type :grid-x + :id frame-id + :pt %))) + + grid-y-data (->> (gg/grid-snap-points frame :y) + (mapv #(array-map :type :grid-y + :id frame-id + :pt %)))] + + (-> page-data + ;; Update root frame information + (assoc-in [uuid/zero :objects-data frame-id] frame-data) + (update-in [parent-id :x] (make-insert-tree-data frame-data :x)) + (update-in [parent-id :y] (make-insert-tree-data frame-data :y)) + + ;; Update frame information + (assoc-in [frame-id :objects-data frame-id] (d/concat-vec frame-data grid-x-data grid-y-data)) + (update-in [frame-id :x] #(or % (rt/make-tree))) + (update-in [frame-id :y] #(or % (rt/make-tree))) + (update-in [frame-id :x] (make-insert-tree-data (d/concat-vec frame-data grid-x-data) :x)) + (update-in [frame-id :y] (make-insert-tree-data (d/concat-vec frame-data grid-y-data) :y))))) + +(defn- add-shape + [page-data shape] + (let [frame-id (:frame-id shape) + snap-points (snap/shape-snap-points shape) + shape-data (->> snap-points + (mapv #(array-map + :type :shape + :id (:id shape) + :pt %)))] + (-> page-data + (assoc-in [frame-id :objects-data (:id shape)] shape-data) + (update-in [frame-id :x] (make-insert-tree-data shape-data :x)) + (update-in [frame-id :y] (make-insert-tree-data shape-data :y))))) + + +(defn- add-guide + [page-data guide] + + (let [guide-data (->> (snap/guide-snap-points guide) + (mapv #(array-map + :type :guide + :id (:id guide) + :pt %)))] + (if-let [frame-id (:frame-id guide)] + ;; Guide inside frame, we add the information only on that frame + (-> page-data + (assoc-in [frame-id :objects-data (:id guide)] guide-data) + (update-in [frame-id (:axis guide)] (make-insert-tree-data guide-data (:axis guide)))) + + ;; Guide outside the frame. We add the information in the global guides data + (-> page-data + (assoc-in [:guides :objects-data (:id guide)] guide-data) + (update-in [:guides (:axis guide)] (make-insert-tree-data guide-data (:axis guide))))))) + +(defn- remove-frame + [page-data frame] + (let [frame-id (:id frame) + root-data (get-in page-data [uuid/zero :objects-data frame-id])] + (-> page-data + (d/dissoc-in [uuid/zero :objects-data frame-id]) + (update-in [uuid/zero :x] (make-delete-tree-data root-data :x)) + (update-in [uuid/zero :y] (make-delete-tree-data root-data :y)) + (dissoc frame-id)))) + +(defn- remove-shape + [page-data shape] + + (let [frame-id (:frame-id shape) + shape-data (get-in page-data [frame-id :objects-data (:id shape)])] + (-> page-data + (d/dissoc-in [frame-id :objects-data (:id shape)]) + (update-in [frame-id :x] (make-delete-tree-data shape-data :x)) + (update-in [frame-id :y] (make-delete-tree-data shape-data :y))))) + +(defn- remove-guide + [page-data guide] + (if-let [frame-id (:frame-id guide)] + (let [guide-data (get-in page-data [frame-id :objects-data (:id guide)])] + (-> page-data + (d/dissoc-in [frame-id :objects-data (:id guide)]) + (update-in [frame-id (:axis guide)] (make-delete-tree-data guide-data (:axis guide))))) + + ;; Guide outside the frame. We add the information in the global guides data + (let [guide-data (get-in page-data [:guides :objects-data (:id guide)])] + (-> page-data + (d/dissoc-in [:guides :objects-data (:id guide)]) + (update-in [:guides (:axis guide)] (make-delete-tree-data guide-data (:axis guide))))))) + +(defn- update-frame + [page-data [_ new-frame]] + (let [frame-id (:id new-frame) + root-data (get-in page-data [uuid/zero :objects-data frame-id]) + frame-data (get-in page-data [frame-id :objects-data frame-id])] + (-> page-data + (update-in [uuid/zero :x] (make-delete-tree-data root-data :x)) + (update-in [uuid/zero :y] (make-delete-tree-data root-data :y)) + (update-in [frame-id :x] (make-delete-tree-data frame-data :x)) + (update-in [frame-id :y] (make-delete-tree-data frame-data :y)) + (add-frame new-frame)))) + +(defn- update-shape + [page-data [old-shape new-shape]] + (-> page-data + (remove-shape old-shape) + (add-shape new-shape))) + +(defn- update-guide + [page-data [old-guide new-guide]] + (-> page-data + (remove-guide old-guide) + (add-guide new-guide))) + +;; PUBLIC API + +(defn make-snap-data + "Creates an empty snap index" + [] + {}) + +(defn add-page + "Adds page information" + [snap-data {:keys [objects options] :as page}] + + (let [frames (cp/select-frames objects) + shapes (cp/select-objects #(not= :frame (:type %)) page) + guides (vals (:guides options)) + + page-data + (as-> {} $ + (add-root-frame $) + (reduce add-frame $ frames) + (reduce add-shape $ shapes) + (reduce add-guide $ guides))] + (assoc snap-data (:id page) page-data))) + +(defn update-page + "Updates a previously inserted page with new data" + [snap-data old-page page] + + (if (contains? snap-data (:id page)) + ;; Update page + (update snap-data (:id page) + (fn [page-data] + (let [{:keys [change-frame-shapes + change-frame-guides + removed-frames + removed-shapes + removed-guides + updated-frames + updated-shapes + updated-guides + new-frames + new-shapes + new-guides]} + (diff/calculate-page-diff old-page page snap-attrs)] + + (as-> page-data $ + (reduce update-shape $ change-frame-shapes) + (reduce remove-frame $ removed-frames) + (reduce remove-shape $ removed-shapes) + (reduce update-frame $ updated-frames) + (reduce update-shape $ updated-shapes) + (reduce add-frame $ new-frames) + (reduce add-shape $ new-shapes) + (reduce update-guide $ change-frame-guides) + (reduce remove-guide $ removed-guides) + (reduce update-guide $ updated-guides) + (reduce add-guide $ new-guides))))) + + ;; Page doesn't exist, we create a new entry + (add-page snap-data page))) + +(defn query + "Retrieve the shape data for the snaps in that range" + [snap-data page-id frame-id axis [from to]] + + (d/concat-vec + (-> snap-data + (get-in [page-id frame-id axis]) + (rt/range-query from to)) + + (-> snap-data + (get-in [page-id :guides axis]) + (rt/range-query from to)))) diff --git a/frontend/src/app/worker/impl.cljs b/frontend/src/app/worker/impl.cljs index 13b7d167f..977c8d85e 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -40,14 +40,13 @@ (defmethod handler :update-page-indices [{:keys [page-id changes] :as message}] - (let [old-objects (get-in @state [:pages-index page-id :objects])] + (let [old-page (get-in @state [:pages-index page-id])] (swap! state ch/process-changes changes false) - (let [new-objects (get-in @state [:pages-index page-id :objects]) + (let [new-page (get-in @state [:pages-index page-id]) message (assoc message - :objects new-objects - :new-objects new-objects - :old-objects old-objects)] + :old-page old-page + :new-page new-page)] (handler (-> message (assoc :cmd :selection/update-index))) (handler (-> message diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 983e5d83d..e6bdb5d49 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -283,7 +283,6 @@ (defn setup-interactions [file] - (letfn [(add-interactions [file [id interactions]] (->> interactions @@ -294,7 +293,6 @@ (let [interactions (:interactions file) file (dissoc file :interactions)] (->> interactions (reduce add-interactions file))))] - (-> file process-interactions))) (defn resolve-media @@ -328,7 +326,12 @@ (assoc :id (resolve page-id))) flows (->> (get-in page-data [:options :flows]) (mapv #(update % :starting-frame resolve))) - page-data (d/assoc-in-when page-data [:options :flows] flows) + guides (->> (get-in page-data [:options :guides]) + (d/mapm #(update %2 :frame-id resolve))) + + page-data (-> page-data + (d/assoc-in-when [:options :flows] flows) + (d/assoc-in-when [:options :guides] guides)) file (-> file (fb/add-page page-data))] (->> (rx/from nodes) (rx/filter cip/shape?) diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index 3b9d92291..d0b229034 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -170,8 +170,10 @@ nil)) (defmethod impl/handler :selection/update-index - [{:keys [page-id old-objects new-objects] :as message}] - (let [update-page-index + [{:keys [page-id old-page new-page] :as message}] + (let [old-objects (:objects old-page) + new-objects (:objects new-page) + update-page-index (fn [index] (let [old-bounds (:bounds index) new-bounds (objects-bounds new-objects)] diff --git a/frontend/src/app/worker/snaps.cljs b/frontend/src/app/worker/snaps.cljs index 66a4dff83..da872c2f7 100644 --- a/frontend/src/app/worker/snaps.cljs +++ b/frontend/src/app/worker/snaps.cljs @@ -6,179 +6,32 @@ (ns app.worker.snaps (:require - [app.common.data :as d] - [app.common.uuid :as uuid] - [app.util.geom.grid :as gg] - [app.util.geom.snap-points :as snap] - [app.util.range-tree :as rt] + [app.util.snap-data :as sd] [app.worker.impl :as impl] - [clojure.set :as set] [okulary.core :as l])) (defonce state (l/atom {})) -(defn process-shape [frame-id coord] - (fn [shape] - (let [points (when-not (:hidden shape) (snap/shape-snap-points shape)) - shape-data (->> points (mapv #(vector % (:id shape))))] - (if (= (:id shape) frame-id) - (into shape-data - - ;; The grid points are only added by the "root" of the coord-dat - (->> (gg/grid-snap-points shape coord) - (map #(vector % :layout)))) - shape-data)))) - -(defn- add-coord-data - "Initializes the range tree given the shapes" - [data frame-id shapes coord] - (letfn [(into-tree [tree [point _ :as data]] - (rt/insert tree (coord point) data))] - (->> shapes - (mapcat (process-shape frame-id coord)) - (reduce into-tree (or data (rt/make-tree)))))) - -(defn remove-coord-data - [data frame-id shapes coord] - (letfn [(remove-tree [tree [point _ :as data]] - (rt/remove tree (coord point) data))] - (->> shapes - (mapcat (process-shape frame-id coord)) - (reduce remove-tree (or data (rt/make-tree)))))) - -(defn aggregate-data - ([objects] - (aggregate-data objects (keys objects))) - - ([objects ids] - (->> ids - (filter #(contains? objects %)) - (map #(get objects %)) - (filter :frame-id) - (group-by :frame-id) - ;; Adds the frame - (d/mapm #(conj %2 (get objects %1)))))) - -(defn- initialize-snap-data - "Initialize the snap information with the current workspace information" - [objects] - (let [shapes-data (aggregate-data objects) - - create-index - (fn [frame-id shapes] - {:x (-> (rt/make-tree) (add-coord-data frame-id shapes :x)) - :y (-> (rt/make-tree) (add-coord-data frame-id shapes :y))})] - (d/mapm create-index shapes-data))) - -;; Attributes that will change the values of their snap -(def snap-attrs [:x :y :width :height :hidden :selrect :grids]) - -(defn- update-snap-data - [snap-data old-objects new-objects] - - (let [changed? (fn [id] - (let [oldv (get old-objects id) - newv (get new-objects id)] - ;; Check first without select-keys because is faster if they are - ;; the same reference - (and (not= oldv newv) - (not= (select-keys oldv snap-attrs) - (select-keys newv snap-attrs))))) - - is-deleted-frame? #(and (not= uuid/zero %) - (contains? old-objects %) - (not (contains? new-objects %)) - (= :frame (get-in old-objects [% :type]))) - is-new-frame? #(and (not= uuid/zero %) - (contains? new-objects %) - (not (contains? old-objects %)) - (= :frame (get-in new-objects [% :type]))) - - changed-ids (into #{} - (filter changed?) - (set/union (set (keys old-objects)) - (set (keys new-objects)))) - - to-delete (aggregate-data old-objects changed-ids) - to-add (aggregate-data new-objects changed-ids) - - frames-to-delete (->> changed-ids (filter is-deleted-frame?)) - frames-to-add (->> changed-ids (filter is-new-frame?)) - - delete-data - (fn [snap-data [frame-id shapes]] - (-> snap-data - (update-in [frame-id :x] remove-coord-data frame-id shapes :x) - (update-in [frame-id :y] remove-coord-data frame-id shapes :y))) - - add-data - (fn [snap-data [frame-id shapes]] - (-> snap-data - (update-in [frame-id :x] add-coord-data frame-id shapes :x) - (update-in [frame-id :y] add-coord-data frame-id shapes :y))) - - delete-frames - (fn [snap-data frame-id] - (dissoc snap-data frame-id)) - - add-frames - (fn [snap-data frame-id] - (assoc snap-data frame-id {:x (rt/make-tree) - :y (rt/make-tree)}))] - - (as-> snap-data $ - (reduce delete-data $ to-delete) - (reduce add-frames $ frames-to-add) - (reduce add-data $ to-add) - (reduce delete-frames $ frames-to-delete)))) - -;; (defn- log-state -;; "Helper function to print a friendly version of the snap tree. Debugging purposes" -;; [] -;; (let [process-frame-data #(d/mapm rt/as-map %) -;; process-page-data #(d/mapm process-frame-data %)] -;; (js/console.log "STATE" (clj->js (d/mapm process-page-data @state))))) - -(defn- index-page [state page-id objects] - (let [snap-data (initialize-snap-data objects)] - (assoc state page-id snap-data))) - -(defn- update-page [state page-id old-objects new-objects] - (let [snap-data (get state page-id) - snap-data (update-snap-data snap-data old-objects new-objects)] - (assoc state page-id snap-data))) - ;; Public API (defmethod impl/handler :snaps/initialize-index [{:keys [data] :as message}] - ;; Create the index - (letfn [(process-page [state page] - (let [id (:id page) - objects (:objects page)] - (index-page state id objects)))] - (swap! state #(reduce process-page % (vals (:pages-index data)))) - ;; (log-state) - ;; Return nil so the worker will not answer anything back - nil)) + + (let [pages (vals (:pages-index data))] + (reset! state (reduce sd/add-page (sd/make-snap-data) pages))) + + nil) (defmethod impl/handler :snaps/update-index - [{:keys [page-id old-objects new-objects] :as message}] - (swap! state update-page page-id old-objects new-objects) - - ;; Uncomment this to regenerate the index everytime - #_(swap! state index-page page-id new-objects) - ;; (log-state) + [{:keys [old-page new-page] :as message}] + (swap! state sd/update-page old-page new-page) nil) (defmethod impl/handler :snaps/range-query - [{:keys [page-id frame-id coord ranges] :as message}] - (letfn [(calculate-range [[from to]] - (-> @state - (get-in [page-id frame-id coord]) - (rt/range-query from to)))] - (->> ranges - (mapcat calculate-range) - set ;; unique - (into [])))) + [{:keys [page-id frame-id axis ranges] :as message}] + + (into [] + (comp (mapcat #(sd/query @state page-id frame-id axis %)) + (distinct)) + ranges)) diff --git a/frontend/test/app/util/snap_data_test.cljs b/frontend/test/app/util/snap_data_test.cljs new file mode 100644 index 000000000..6982d4f6f --- /dev/null +++ b/frontend/test/app/util/snap_data_test.cljs @@ -0,0 +1,431 @@ +;; 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) UXBOX Labs SL + +(ns app.util.snap-data-test + (:require + [app.common.uuid :as uuid] + [cljs.test :as t :include-macros true] + [cljs.pprint :refer [pprint]] + [app.common.pages.init :as init] + [app.common.file-builder :as fb] + [app.util.snap-data :as sd])) + +(t/deftest test-create-index + (t/testing "Create empty data" + (let [data (sd/make-snap-data)] + (t/is (some? data)))) + + (t/testing "Add empty page (only root-frame)" + (let [page (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/get-current-page)) + + data (-> (sd/make-snap-data) + (sd/add-page page))] + (t/is (some? data)))) + + (t/testing "Create simple shape on root" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/create-rect + {:x 0 + :y 0 + :width 100 + :height 100})) + page (fb/get-current-page file) + + data (-> (sd/make-snap-data) + (sd/add-page page)) + + result-x (sd/query data (:id page) uuid/zero :x [0 100])] + + (t/is (some? data)) + + ;; 3 = left side, center and right side + (t/is (= (count result-x) 3)) + + ;; Left side: two points + (t/is (= (first (nth result-x 0)) 0)) + + ;; Center one point + (t/is (= (first (nth result-x 1)) 50)) + + ;; Right side two points + (t/is (= (first (nth result-x 2)) 100)))) + + (t/testing "Add page with single empty frame" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard + {:x 0 + :y 0 + :width 100 + :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100])] + + (t/is (some? data)) + (t/is (= (count result-zero-x) 3)) + (t/is (= (count result-frame-x) 3)))) + + (t/testing "Add page with some shapes inside frames" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard + {:x 0 + :y 0 + :width 100 + :height 100})) + frame-id (:last-id file) + + file (-> file + (fb/create-rect + {:x 25 + :y 25 + :width 50 + :height 50}) + (fb/close-artboard)) + + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100])] + + (t/is (some? data)) + (t/is (= (count result-zero-x) 3)) + (t/is (= (count result-frame-x) 5)))) + + (t/testing "Add a global guide" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-guide {:position 50 :axis :x}) + (fb/add-artboard {:x 200 :y 200 :width 100 :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-y (sd/query data (:id page) uuid/zero :y [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100]) + result-frame-y (sd/query data (:id page) frame-id :y [0 100])] + + (t/is (some? data)) + ;; We can snap in the root + (t/is (= (count result-zero-x) 1)) + (t/is (= (count result-zero-y) 0)) + + ;; We can snap in the frame + (t/is (= (count result-frame-x) 1)) + (t/is (= (count result-frame-y) 0)))) + + (t/testing "Add a frame guide" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard {:x 200 :y 200 :width 100 :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + + file (-> file + (fb/add-guide {:position 50 :axis :x :frame-id frame-id})) + + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-y (sd/query data (:id page) uuid/zero :y [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100]) + result-frame-y (sd/query data (:id page) frame-id :y [0 100])] + (t/is (some? data)) + ;; We can snap in the root + (t/is (= (count result-zero-x) 0)) + (t/is (= (count result-zero-y) 0)) + + ;; We can snap in the frame + (t/is (= (count result-frame-x) 1)) + (t/is (= (count result-frame-y) 0))))) + +(t/deftest test-update-index + (t/testing "Create frame on root and then remove it." + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard + {:x 0 + :y 0 + :width 100 + :height 100}) + (fb/close-artboard)) + + shape-id (:last-id file) + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + file (-> file + (fb/delete-object shape-id)) + + new-page (fb/get-current-page file) + data (sd/update-page data page new-page) + + result-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-y (sd/query data (:id page) uuid/zero :y [0 100])] + + (t/is (some? data)) + (t/is (= (count result-x) 0)) + (t/is (= (count result-y) 0)))) + + (t/testing "Create simple shape on root. Then remove it" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/create-rect + {:x 0 + :y 0 + :width 100 + :height 100})) + + shape-id (:last-id file) + page (fb/get-current-page file) + + ;; frame-id (:last-id file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + file (fb/delete-object file shape-id) + + new-page (fb/get-current-page file) + data (sd/update-page data page new-page) + + result-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-y (sd/query data (:id page) uuid/zero :y [0 100])] + + (t/is (some? data)) + (t/is (= (count result-x) 0)) + (t/is (= (count result-y) 0)))) + + (t/testing "Create shape inside frame, then remove it" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard + {:x 0 + :y 0 + :width 100 + :height 100})) + frame-id (:last-id file) + + file (fb/create-rect file {:x 25 :y 25 :width 50 :height 50}) + shape-id (:last-id file) + + file (fb/close-artboard file) + + page (fb/get-current-page file) + data (-> (sd/make-snap-data) + (sd/add-page page)) + + file (fb/delete-object file shape-id) + new-page (fb/get-current-page file) + + data (sd/update-page data page new-page) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100])] + + (t/is (some? data)) + (t/is (= (count result-zero-x) 3)) + (t/is (= (count result-frame-x) 3)))) + + (t/testing "Create global guide then remove it" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-guide {:position 50 :axis :x})) + + guide-id (:last-id file) + + file (-> (fb/add-artboard file {:x 200 :y 200 :width 100 :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + page (fb/get-current-page file) + data (-> (sd/make-snap-data) (sd/add-page page)) + + new-page (-> (fb/delete-guide file guide-id) + (fb/get-current-page)) + + data (sd/update-page data page new-page) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-y (sd/query data (:id page) uuid/zero :y [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100]) + result-frame-y (sd/query data (:id page) frame-id :y [0 100])] + + (t/is (some? data)) + ;; We can snap in the root + (t/is (= (count result-zero-x) 0)) + (t/is (= (count result-zero-y) 0)) + + ;; We can snap in the frame + (t/is (= (count result-frame-x) 0)) + (t/is (= (count result-frame-y) 0)))) + + (t/testing "Create frame guide then remove it" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard {:x 200 :y 200 :width 100 :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + file (fb/add-guide file {:position 50 :axis :x :frame-id frame-id}) + guide-id (:last-id file) + + page (fb/get-current-page file) + data (-> (sd/make-snap-data) (sd/add-page page)) + + new-page (-> (fb/delete-guide file guide-id) + (fb/get-current-page)) + + data (sd/update-page data page new-page) + + result-zero-x (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-y (sd/query data (:id page) uuid/zero :y [0 100]) + result-frame-x (sd/query data (:id page) frame-id :x [0 100]) + result-frame-y (sd/query data (:id page) frame-id :y [0 100])] + (t/is (some? data)) + ;; We can snap in the root + (t/is (= (count result-zero-x) 0)) + (t/is (= (count result-zero-y) 0)) + + ;; We can snap in the frame + (t/is (= (count result-frame-x) 0)) + (t/is (= (count result-frame-y) 0)))) + + (t/testing "Update frame coordinates" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-artboard + {:x 0 + :y 0 + :width 100 + :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + page (fb/get-current-page file) + data (-> (sd/make-snap-data) (sd/add-page page)) + + frame (fb/lookup-shape file frame-id) + new-frame (-> frame + (assoc :x 200 :y 200)) + + file (fb/update-object file frame new-frame) + new-page (fb/get-current-page file) + + data (sd/update-page data page new-page) + + result-zero-x-1 (sd/query data (:id page) uuid/zero :x [0 100]) + result-frame-x-1 (sd/query data (:id page) frame-id :x [0 100]) + result-zero-x-2 (sd/query data (:id page) uuid/zero :x [200 300]) + result-frame-x-2 (sd/query data (:id page) frame-id :x [200 300])] + + (t/is (some? data)) + (t/is (= (count result-zero-x-1) 0)) + (t/is (= (count result-frame-x-1) 0)) + (t/is (= (count result-zero-x-2) 3)) + (t/is (= (count result-frame-x-2) 3)))) + + (t/testing "Update shape coordinates" + (let [file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/create-rect + {:x 0 + :y 0 + :width 100 + :height 100})) + + shape-id (:last-id file) + page (fb/get-current-page file) + data (-> (sd/make-snap-data) (sd/add-page page)) + + shape (fb/lookup-shape file shape-id) + new-shape (-> shape + (assoc :x 200 :y 200)) + + file (fb/update-object file shape new-shape) + new-page (fb/get-current-page file) + + data (sd/update-page data page new-page) + + result-zero-x-1 (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-x-2 (sd/query data (:id page) uuid/zero :x [200 300])] + + (t/is (some? data)) + (t/is (= (count result-zero-x-1) 0)) + (t/is (= (count result-zero-x-2) 3)))) + + (t/testing "Update global guide" + (let [guide {:position 50 :axis :x} + file (-> (fb/create-file "Test") + (fb/add-page {:name "Page-1"}) + (fb/add-guide guide)) + + guide-id (:last-id file) + guide (assoc guide :id guide-id) + + file (-> (fb/add-artboard file {:x 500 :y 500 :width 100 :height 100}) + (fb/close-artboard)) + + frame-id (:last-id file) + page (fb/get-current-page file) + data (-> (sd/make-snap-data) (sd/add-page page)) + + new-page (-> (fb/update-guide file (assoc guide :position 150)) + (fb/get-current-page)) + + data (sd/update-page data page new-page) + + result-zero-x-1 (sd/query data (:id page) uuid/zero :x [0 100]) + result-zero-y-1 (sd/query data (:id page) uuid/zero :y [0 100]) + result-frame-x-1 (sd/query data (:id page) frame-id :x [0 100]) + result-frame-y-1 (sd/query data (:id page) frame-id :y [0 100]) + + result-zero-x-2 (sd/query data (:id page) uuid/zero :x [0 200]) + result-zero-y-2 (sd/query data (:id page) uuid/zero :y [0 200]) + result-frame-x-2 (sd/query data (:id page) frame-id :x [0 200]) + result-frame-y-2 (sd/query data (:id page) frame-id :y [0 200]) + ] + + (t/is (some? data)) + + (t/is (= (count result-zero-x-1) 0)) + (t/is (= (count result-zero-y-1) 0)) + (t/is (= (count result-frame-x-1) 0)) + (t/is (= (count result-frame-y-1) 0)) + + (t/is (= (count result-zero-x-2) 1)) + (t/is (= (count result-zero-y-2) 0)) + (t/is (= (count result-frame-x-2) 1)) + (t/is (= (count result-frame-y-2) 0))))) diff --git a/version.txt b/version.txt index 4beb19d04..08c36205d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.11.0-beta +1.12.0-beta