diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn new file mode 100644 index 000000000..5201014d5 --- /dev/null +++ b/.clj-kondo/config.edn @@ -0,0 +1,29 @@ +{:lint-as {potok.core/reify clojure.core/reify + promesa.core/let clojure.core/let + app.db/with-atomic clojure.core/with-open} + :output + {:exclude-files ["data_readers.clj"]} + + :linters + {:unsorted-required-namespaces + {:level :warning} + + :unresolved-namespace + {:level :warning + :exclude [data_readers]} + + :single-key-in + {:level :warning} + + :unused-binding + {:exclude-destructured-as true + :exclude-destructured-keys-in-fn-args false + } + + :unresolved-symbol + {:exclude ['(app.services.mutations/defmutation) + '(app.services.queries/defquery) + '(app.util.dispatcher/defservice) + '(mount.core/defstate) + ]}}} + diff --git a/.gitignore b/.gitignore index a7cb67059..b78c9815b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,10 @@ node_modules /frontend/resources/public/* /exporter/target /exporter/.shadow-cljs -/docker/testenv/bundle +/docker/images/bundle +/.clj-kondo/.cache /bundle* /media /deploy /web +/_dump diff --git a/README.md b/README.md index 4fcbb7c0b..ce1214b4a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # PENPOT # -We’re excited to share that Uxbox is now Penpot! We’re changing the name, but keeping the same project essence. Stay in the loop for more news comming early 2021. Alpha release is close! +We’re excited to share that Uxbox is now Penpot! We’re changing the name, but keeping the same project essence. Stay in the loop for more news coming early 2021. Alpha release is close! ![PENPOT](https://raw.githubusercontent.com/penpot/penpot/develop/docs/screenshot.png) diff --git a/backend/deps.edn b/backend/deps.edn index 15cb1b4a7..782997986 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -4,6 +4,7 @@ "jcenter" {:url "https://jcenter.bintray.com/"}} :deps {org.clojure/clojure {:mvn/version "1.10.1"} + org.clojure/clojurescript {:mvn/version "1.10.773"} org.clojure/data.json {:mvn/version "1.0.0"} org.clojure/core.async {:mvn/version "1.3.610"} @@ -33,14 +34,14 @@ org.postgresql/postgresql {:mvn/version "42.2.16"} com.zaxxer/HikariCP {:mvn/version "3.4.5"} + funcool/log4j2-clojure {:mvn/version "2020.11.23-1"} funcool/datoteka {:mvn/version "1.2.0"} funcool/promesa {:mvn/version "5.1.0"} funcool/cuerdas {:mvn/version "2020.03.26-3"} - - buddy/buddy-core {:mvn/version "1.8.0"} - buddy/buddy-hashers {:mvn/version "1.6.0"} - buddy/buddy-sign {:mvn/version "3.2.0"} + buddy/buddy-core {:mvn/version "1.9.0"} + buddy/buddy-hashers {:mvn/version "1.7.0"} + buddy/buddy-sign {:mvn/version "3.3.0"} lambdaisland/uri {:mvn/version "1.4.54" :exclusions [org.clojure/data.json]} diff --git a/backend/resources/emails-mjml/change-email/en.mjml b/backend/resources/emails-mjml/change-email/en.mjml index fc9a7f438..ae388201a 100644 --- a/backend/resources/emails-mjml/change-email/en.mjml +++ b/backend/resources/emails-mjml/change-email/en.mjml @@ -45,10 +45,10 @@ - - - - + + + + diff --git a/backend/resources/emails-mjml/invite-to-team/en.mjml b/backend/resources/emails-mjml/invite-to-team/en.mjml index dbef7afe5..48af2706a 100644 --- a/backend/resources/emails-mjml/invite-to-team/en.mjml +++ b/backend/resources/emails-mjml/invite-to-team/en.mjml @@ -38,10 +38,10 @@ - - - - + + + + diff --git a/backend/resources/emails-mjml/password-recovery/en.mjml b/backend/resources/emails-mjml/password-recovery/en.mjml index fe2fa2048..36f323dc1 100644 --- a/backend/resources/emails-mjml/password-recovery/en.mjml +++ b/backend/resources/emails-mjml/password-recovery/en.mjml @@ -47,10 +47,10 @@ - - - - + + + + diff --git a/backend/resources/emails-mjml/register/en.mjml b/backend/resources/emails-mjml/register/en.mjml index 28d1249e6..d3a12f256 100644 --- a/backend/resources/emails-mjml/register/en.mjml +++ b/backend/resources/emails-mjml/register/en.mjml @@ -44,10 +44,10 @@ - - - - + + + + diff --git a/backend/resources/emails/change-email/en.html b/backend/resources/emails/change-email/en.html index 7aa3fd613..547e73e9b 100644 --- a/backend/resources/emails/change-email/en.html +++ b/backend/resources/emails/change-email/en.html @@ -330,7 +330,7 @@ @@ -370,7 +370,7 @@
- +
diff --git a/backend/resources/emails/invite-to-team/en.html b/backend/resources/emails/invite-to-team/en.html index 0f85d5a46..d9754903d 100644 --- a/backend/resources/emails/invite-to-team/en.html +++ b/backend/resources/emails/invite-to-team/en.html @@ -320,7 +320,7 @@
- +
@@ -360,7 +360,7 @@
- +
diff --git a/backend/resources/emails/password-recovery/en.html b/backend/resources/emails/password-recovery/en.html index 226232507..4ae4036b0 100644 --- a/backend/resources/emails/password-recovery/en.html +++ b/backend/resources/emails/password-recovery/en.html @@ -325,7 +325,7 @@
- +
@@ -365,7 +365,7 @@
- +
diff --git a/backend/resources/emails/register/en.html b/backend/resources/emails/register/en.html index dd2f7a69f..6b8c5cc83 100644 --- a/backend/resources/emails/register/en.html +++ b/backend/resources/emails/register/en.html @@ -320,7 +320,7 @@
- +
@@ -360,7 +360,7 @@
- +
diff --git a/backend/resources/log4j2.xml b/backend/resources/log4j2.xml index df7bc897e..26a71e7bc 100644 --- a/backend/resources/log4j2.xml +++ b/backend/resources/log4j2.xml @@ -12,6 +12,10 @@ + + + + @@ -23,13 +27,17 @@ + + + + + - diff --git a/backend/scripts/build.sh b/backend/scripts/build.sh index 2be3d8d2f..a25d5bb82 100755 --- a/backend/scripts/build.sh +++ b/backend/scripts/build.sh @@ -47,7 +47,7 @@ if [ -f ./environ ]; then fi set -x -exec \$JAVA_CMD \$JVM_OPTS -classpath \$CP -Dlog4j.configurationFile=./log4j2.xml "\$@" clojure.main -m app.main +exec \$JAVA_CMD \$JVM_OPTS -Dapp.enable-asserts=false -classpath \$CP -Dlog4j.configurationFile=./log4j2.xml "\$@" clojure.main -m app.main EOF chmod +x ./target/dist/run.sh diff --git a/backend/src/app/cli/fixtures.clj b/backend/src/app/cli/fixtures.clj index 5e5c9f07f..9c1997581 100644 --- a/backend/src/app/cli/fixtures.clj +++ b/backend/src/app/cli/fixtures.clj @@ -10,17 +10,16 @@ (ns app.cli.fixtures "A initial fixtures." (:require - [clojure.tools.logging :as log] - [mount.core :as mount] - [buddy.hashers :as hashers] - [app.common.data :as d] [app.common.pages :as cp] [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] [app.migrations] [app.services.mutations.profile :as profile] - [app.util.blob :as blob])) + [app.util.blob :as blob] + [buddy.hashers :as hashers] + [clojure.tools.logging :as log] + [mount.core :as mount])) (defn- mk-uuid [prefix & args] diff --git a/backend/src/app/cli/media_loader.clj b/backend/src/app/cli/media_loader.clj index e038f4eb4..946fd8880 100644 --- a/backend/src/app/cli/media_loader.clj +++ b/backend/src/app/cli/media_loader.clj @@ -5,27 +5,27 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2016-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.cli.media-loader "Media libraries importer (command line helper)." - (:require - [clojure.tools.logging :as log] - [clojure.spec.alpha :as s] - [clojure.java.io :as io] - [mount.core :as mount] - [datoteka.core :as fs] - [app.config] + #_(:require [app.common.spec :as us] - [app.db :as db] - [app.media] - [app.media-storage] - [app.migrations] [app.common.uuid :as uuid] - [app.services.mutations.projects :as projects] + [app.config] + [app.db :as db] + [app.media-storage] + [app.media] + [app.migrations] [app.services.mutations.files :as files] - [app.services.mutations.media :as media]) - (:import + [app.services.mutations.media :as media] + [app.services.mutations.projects :as projects] + [clojure.java.io :as io] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [datoteka.core :as fs] + [mount.core :as mount]) + #_(:import java.io.PushbackReader)) ;; --- Constants & Helpers diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 9a6802d06..9a6f3a3c6 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -10,14 +10,13 @@ (ns app.config "A configuration management." (:require + [app.common.spec :as us] + [app.common.version :as v] + [app.util.time :as dt] [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] [cuerdas.core :as str] [environ.core :refer [env]] - [mount.core :refer [defstate]] - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.util.time :as dt])) + [mount.core :refer [defstate]])) (def defaults {:http-server-port 6060 @@ -41,6 +40,8 @@ :smtp-default-reply-to "no-reply@example.com" :smtp-default-from "no-reply@example.com" + :host "devenv" + :allow-demo-users true :registration-enabled true :registration-domain-whitelist "" @@ -50,7 +51,7 @@ ;; modification in order to make the file ellegible for ;; trimming. The value only supports s(econds) m(inutes) and ;; h(ours) as time unit. - :file-trimming-max-age "72h" + :file-trimming-threshold "72h" ;; LDAP auth disabled by default. Set ldap-auth-host to enable ;:ldap-auth-host "ldap.mysupercompany.com" @@ -79,6 +80,9 @@ (s/def ::media-uri ::us/string) (s/def ::media-directory ::us/string) (s/def ::secret-key ::us/string) + +(s/def ::host ::us/string) +(s/def ::error-report-webhook ::us/string) (s/def ::smtp-enabled ::us/boolean) (s/def ::smtp-default-reply-to ::us/email) (s/def ::smtp-default-from ::us/email) @@ -94,7 +98,9 @@ (s/def ::debug-humanize-transit ::us/boolean) (s/def ::public-uri ::us/string) (s/def ::backend-uri ::us/string) + (s/def ::image-process-max-threads ::us/integer) +(s/def ::file-trimming-threshold ::dt/duration) (s/def ::google-client-id ::us/string) (s/def ::google-client-secret ::us/string) @@ -115,7 +121,6 @@ (s/def ::ldap-auth-email-attribute ::us/string) (s/def ::ldap-auth-fullname-attribute ::us/string) (s/def ::ldap-auth-avatar-attribute ::us/string) -(s/def ::file-trimming-threshold ::dt/duration) (s/def ::config (s/keys :opt-un [::http-server-cors @@ -135,6 +140,7 @@ ::assets-uri ::media-directory ::media-uri + ::error-report-webhook ::secret-key ::smtp-default-from ::smtp-default-reply-to @@ -145,7 +151,8 @@ ::smtp-password ::smtp-tls ::smtp-ssl - ::file-trimming-max-age + ::host + ::file-trimming-threshold ::debug-humanize-transit ::allow-demo-users ::registration-enabled @@ -198,6 +205,9 @@ (def default-deletion-delay (dt/duration {:hours 48})) +(def version + (delay (v/parse "%version%"))) + (defn smtp [cfg] {:host (:smtp-host cfg "localhost") diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index 74b41e858..50b492800 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -13,19 +13,15 @@ [app.common.geom.point :as gpt] [app.config :as cfg] [app.metrics :as mtx] - [app.util.data :as data] [app.util.time :as dt] [app.util.transit :as t] [clojure.data.json :as json] [clojure.spec.alpha :as s] [clojure.string :as str] - [clojure.tools.logging :as log] - [lambdaisland.uri :refer [uri]] [mount.core :as mount :refer [defstate]] [next.jdbc :as jdbc] [next.jdbc.date-time :as jdbc-dt] [next.jdbc.optional :as jdbc-opt] - [next.jdbc.result-set :as jdbc-rs] [next.jdbc.sql :as jdbc-sql] [next.jdbc.sql.builder :as jdbc-bld]) (:import @@ -34,8 +30,8 @@ com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory java.sql.Connection java.sql.Savepoint - org.postgresql.jdbc.PgArray org.postgresql.geometric.PGpoint + org.postgresql.jdbc.PgArray org.postgresql.util.PGInterval org.postgresql.util.PGobject)) @@ -83,6 +79,8 @@ (jdbc-dt/read-as-instant) (HikariDataSource. dsc))) +(declare pool) + (defstate pool :start (create-pool cfg/config) :stop (.close pool)) @@ -221,15 +219,6 @@ :else (ex/raise :type :not-implemented))) -(defn decode-pgobject - [^PGobject obj] - (let [typ (.getType obj) - val (.getValue obj)] - (if (or (= typ "json") - (= typ "jsonb")) - (json/read-str val) - val))) - (defn decode-json-pgobject [^PGobject o] (let [typ (.getType o) diff --git a/backend/src/app/emails.clj b/backend/src/app/emails.clj index 5d471add0..d6408b8e4 100644 --- a/backend/src/app/emails.clj +++ b/backend/src/app/emails.clj @@ -10,14 +10,11 @@ (ns app.emails "Main api for send emails." (:require - [clojure.spec.alpha :as s] - [promesa.core :as p] - [app.config :as cfg] - [app.common.exceptions :as ex] [app.common.spec :as us] - [app.db :as db] + [app.config :as cfg] [app.tasks :as tasks] - [app.util.emails :as emails])) + [app.util.emails :as emails] + [clojure.spec.alpha :as s])) ;; --- Defaults diff --git a/backend/src/app/error_reporter.clj b/backend/src/app/error_reporter.clj new file mode 100644 index 000000000..a567c17ab --- /dev/null +++ b/backend/src/app/error_reporter.clj @@ -0,0 +1,83 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 Andrey Antukh + +(ns app.error-reporter + "A mattermost integration for error reporting." + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cfg] + [app.db :as db] + [app.tasks :as tasks] + [app.util.async :as aa] + [app.worker :as wrk] + [app.util.http :as http] + [clojure.core.async :as a] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] + [cuerdas.core :as str] + [mount.core :as mount :refer [defstate]] + [promesa.exec :as px])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Public API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defonce enqueue identity) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Implementation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- send-to-mattermost! + [log-event] + (try + (let [text (str/fmt "Unhandled exception: `host='%s'`, `version=%s`.\n@channel ⇊\n```%s\n```" + (:host cfg/config) + (:full @cfg/version) + (str log-event)) + rsp (http/send! {:uri (:error-reporter-webhook cfg/config) + :method :post + :headers {"content-type" "application/json"} + :body (json/write-str {:text text})})] + (when (not= (:status rsp) 200) + (log/warnf "Error reporting webhook replying with unexpected status: %s\n%s" + (:status rsp) + (pr-str rsp)))) + (catch Exception e + (log/warnf e "Unexpected exception on error reporter.")))) + +(defn- send! + [val] + (aa/thread-call wrk/executor (partial send-to-mattermost! val))) + +(defn- start + [] + (let [qch (a/chan (a/sliding-buffer 128))] + (log/info "Starting error reporter loop.") + + ;; Only enable when a valid URL is provided. + (when (:error-reporter-webhook cfg/config) + (alter-var-root #'enqueue (constantly #(a/>!! qch %))) + (a/go-loop [] + (let [val (a/ (get-in req [:params :code]) (get-access-token) (get-user-info))] diff --git a/backend/src/app/http/auth/google.clj b/backend/src/app/http/auth/google.clj index 97b24a1b9..33fba3179 100644 --- a/backend/src/app/http/auth/google.clj +++ b/backend/src/app/http/auth/google.clj @@ -11,7 +11,6 @@ (:require [app.common.exceptions :as ex] [app.config :as cfg] - [app.db :as db] [app.http.session :as session] [app.services.mutations :as sm] [app.services.tokens :as tokens] @@ -84,9 +83,8 @@ nil)))) (defn auth - [req] - (let [token (tokens/generate {:iss :google-oauth - :exp (dt/in-future "15m")}) + [_req] + (let [token (tokens/generate {:iss :google-oauth :exp (dt/in-future "15m")}) params {:scope scope :access_type "offline" :include_granted_scopes true @@ -104,7 +102,7 @@ (defn callback [req] (let [token (get-in req [:params :state]) - tdata (tokens/verify token {:iss :google-oauth}) + _ (tokens/verify token {:iss :google-oauth}) info (some-> (get-in req [:params :code]) (get-access-token) (get-user-info))] diff --git a/backend/src/app/http/auth/ldap.clj b/backend/src/app/http/auth/ldap.clj index 1018eb44f..1dc61b0d4 100644 --- a/backend/src/app/http/auth/ldap.clj +++ b/backend/src/app/http/auth/ldap.clj @@ -1,18 +1,29 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + (ns app.http.auth.ldap (:require - [clj-ldap.client :as client] - [clojure.set :as set] - [mount.core :refer [defstate]] - [app.common.exceptions :as ex] - [app.config :as cfg] - [app.services.mutations :as sm] - [app.http.session :as session] - [clojure.tools.logging :as log])) - + [app.common.exceptions :as ex] + [app.config :as cfg] + [app.http.session :as session] + [app.services.mutations :as sm] + [clj-ldap.client :as client] + [clojure.set :as set] + [clojure.string] + [clojure.tools.logging :as log] + [mount.core :refer [defstate]])) (defn replace-several [s & {:as replacements}] (reduce-kv clojure.string/replace s replacements)) +(declare *ldap-pool) + (defstate *ldap-pool :start (delay (try diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj deleted file mode 100644 index e0df75670..000000000 --- a/backend/src/app/http/debug.clj +++ /dev/null @@ -1,24 +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) 2019 Andrey Antukh - -(ns app.http.debug - "Debug related handlers." - (:require - [clojure.tools.logging :as log] - [promesa.core :as p] - [app.http.errors :as errors] - [app.http.session :as session] - [app.common.uuid :as uuid])) - -(defn emails-list - [req] - {:status 200 - :body "Hello world\n"}) - -(defn email - [req] - {:status 200 - :body "Hello world\n"}) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index fe87fe3cf..58acd948c 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -2,43 +2,55 @@ ;; 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) 2016-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.http.errors "A errors handling for the http server." (:require + [app.common.exceptions :as ex] [clojure.tools.logging :as log] [cuerdas.core :as str] - [app.metrics :as mtx] - [io.aviso.exception :as e])) + [expound.alpha :as expound])) (defmulti handle-exception - (fn [err & rest] - (:type (ex-data err)))) + (fn [err & _rest] + (let [edata (ex-data err)] + (or (:type edata) + (class err))))) + +(defmethod handle-exception :authorization + [err _] + {:status 403 + :body (ex-data err)}) (defmethod handle-exception :validation [err req] (let [header (get-in req [:headers "accept"]) - response (ex-data err)] + edata (ex-data err)] (cond (and (str/starts-with? header "text/html") - (= :spec-validation (:code response))) + (= :spec-validation (:code edata))) {:status 400 :headers {"content-type" "text/html"} - :body (str "
" (:explain response) "
\n")} - + :body (str "
"
+                  (with-out-str
+                    (:data edata))
+                  "
\n")} :else {:status 400 - :body response}))) + :body edata}))) (defmethod handle-exception :ratelimit - [err req] + [_ _] {:status 429 :headers {"retry-after" 1000} :body ""}) (defmethod handle-exception :not-found - [err req] + [err _] (let [response (ex-data err)] {:status 404 :body response})) @@ -48,16 +60,43 @@ (handle-exception (.getCause ^Throwable err) req)) (defmethod handle-exception :parse - [err req] + [err _] {:status 400 :body {:type :parse :message (ex-message err)}}) +(defn get-context-string + [err request] + (str + "=| uri: " (pr-str (:uri request)) "\n" + "=| method: " (pr-str (:request-method request)) "\n" + "=| path-params: " (pr-str (:path-params request)) "\n" + "=| query-params: " (pr-str (:query-params request)) "\n" + + (when-let [bparams (:body-params request)] + (str "=| body-params: " (pr-str bparams) "\n")) + + (when (ex/ex-info? err) + (str "=| ex-data: " (pr-str (ex-data err)) "\n")) + + "\n")) + + +(defmethod handle-exception :assertion + [err request] + (let [{:keys [data] :as edata} (ex-data err)] + (log/errorf err + (str "Assertion error\n" + (get-context-string err request) + (with-out-str (expound/printer data)))) + {:status 500 + :body {:type :internal-error + :message "Assertion error" + :data (ex-data err)}})) + (defmethod handle-exception :default - [err req] - (log/error "Unhandled exception on request:" (:path req) "\n" - (with-out-str - (.printStackTrace ^Throwable err (java.io.PrintWriter. *out*)))) + [err request] + (log/errorf err (str "Internal Error\n" (get-context-string err request))) {:status 500 :body {:type :internal-error :message (ex-message err) diff --git a/backend/src/app/http/handlers.clj b/backend/src/app/http/handlers.clj index f859b7a36..1265a97e1 100644 --- a/backend/src/app/http/handlers.clj +++ b/backend/src/app/http/handlers.clj @@ -9,6 +9,7 @@ (ns app.http.handlers (:require + [app.common.data :as d] [app.common.exceptions :as ex] [app.emails :as emails] [app.http.session :as session] @@ -28,36 +29,40 @@ :login}) (defn query-handler - [req] - (let [type (keyword (get-in req [:path-params :type])) - data (merge (:params req) - {::sq/type type}) - data (cond-> data - (:profile-id req) (assoc :profile-id (:profile-id req)))] - (if (or (:profile-id req) (contains? unauthorized-services type)) + [{:keys [profile-id] :as request}] + (let [type (keyword (get-in request [:path-params :type])) + data (assoc (:params request) ::sq/type type) + data (if profile-id + (assoc data :profile-id profile-id) + (dissoc data :profile-id))] + + (if (or (uuid? profile-id) + (contains? unauthorized-services type)) {:status 200 - :body (sq/handle (with-meta data {:req req}))} + :body (sq/handle (with-meta data {:req request}))} {:status 403 :body {:type :authentication :code :unauthorized}}))) (defn mutation-handler - [req] - (let [type (keyword (get-in req [:path-params :type])) - data (merge (:params req) - (:body-params req) - (:uploads req) - {::sm/type type}) - data (cond-> data - (:profile-id req) (assoc :profile-id (:profile-id req)))] - (if (or (:profile-id req) (contains? unauthorized-services type)) - (let [result (sm/handle (with-meta data {:req req})) + [{:keys [profile-id] :as request}] + (let [type (keyword (get-in request [:path-params :type])) + data (d/merge (:params request) + (:body-params request) + (:uploads request) + {::sm/type type}) + data (if profile-id + (assoc data :profile-id profile-id) + (dissoc data :profile-id))] + + (if (or (uuid? profile-id) + (contains? unauthorized-services type)) + (let [result (sm/handle (with-meta data {:req request})) mdata (meta result) resp {:status (if (nil? (seq result)) 204 200) :body result}] (cond->> resp - (:transform-response mdata) ((:transform-response mdata) req))) - + (:transform-response mdata) ((:transform-response mdata) request))) {:status 403 :body {:type :authentication :code :unauthorized}}))) diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index ab02d9675..e2fafae1a 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -5,20 +5,19 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2019-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.http.middleware (:require - [clojure.tools.logging :as log] + [app.common.exceptions :as ex] + [app.config :as cfg] + [app.metrics :as mtx] + [app.util.transit :as t] [ring.middleware.cookies :refer [wrap-cookies]] [ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.multipart-params :refer [wrap-multipart-params]] [ring.middleware.params :refer [wrap-params]] - [ring.middleware.resource :refer [wrap-resource]] - [app.metrics :as mtx] - [app.common.exceptions :as ex] - [app.config :as cfg] - [app.util.transit :as t])) + [ring.middleware.resource :refer [wrap-resource]])) (defn- wrap-parse-request-body [handler] @@ -126,13 +125,13 @@ (def development-cors {:name ::development-cors - :compile (fn [& args] + :compile (fn [& _args] (when *assert* wrap-development-cors))}) (def development-resources {:name ::development-resources - :compile (fn [& args] + :compile (fn [& _args] (when *assert* #(wrap-resource % "public")))}) diff --git a/backend/src/app/http/ws.clj b/backend/src/app/http/ws.clj index c47077694..0f54a0d23 100644 --- a/backend/src/app/http/ws.clj +++ b/backend/src/app/http/ws.clj @@ -10,16 +10,14 @@ (ns app.http.ws "Web Socket handlers" (:require - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [ring.adapter.jetty9 :as jetty] - [ring.middleware.cookies :refer [wrap-cookies]] - [ring.middleware.keyword-params :refer [wrap-keyword-params]] - [ring.middleware.params :refer [wrap-params]] [app.common.spec :as us] [app.db :as db] [app.http.session :refer [wrap-session]] - [app.services.notifications :as nf])) + [app.services.notifications :as nf] + [clojure.spec.alpha :as s] + [ring.middleware.cookies :refer [wrap-cookies]] + [ring.middleware.keyword-params :refer [wrap-keyword-params]] + [ring.middleware.params :refer [wrap-params]])) (s/def ::file-id ::us/uuid) (s/def ::session-id ::us/uuid) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index f116ace24..d223de175 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -9,6 +9,8 @@ (ns app.main (:require + [app.config :as cfg] + [clojure.tools.logging :as log] [mount.core :as mount])) (defn- enable-asserts @@ -25,16 +27,16 @@ ;; --- Entry point (defn run - [params] - (require 'app.config + [_params] + (require 'app.srepl.server + 'app.services 'app.migrations 'app.worker 'app.media 'app.http) - (mount/start)) - - + (mount/start) + (log/infof "Welcome to penpot! Version: '%s'." (:full @cfg/version))) (defn -main - [& args] + [& _args] (run {})) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 91a59d611..51b7186d3 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -10,14 +10,11 @@ (ns app.media "Media postprocessing." (:require - [app.common.data :as d] [app.common.exceptions :as ex] [app.common.media :as cm] [app.common.spec :as us] [app.config :as cfg] - [app.media-storage :as mst] [app.util.http :as http] - [app.util.storage :as ust] [clojure.core.async :as a] [clojure.java.io :as io] [clojure.spec.alpha :as s] @@ -25,11 +22,12 @@ [mount.core :refer [defstate]]) (:import java.io.ByteArrayInputStream - java.io.InputStream java.util.concurrent.Semaphore org.im4java.core.ConvertCmd - org.im4java.core.Info - org.im4java.core.IMOperation)) + org.im4java.core.IMOperation + org.im4java.core.Info)) + +(declare semaphore) (defstate semaphore :start (Semaphore. (:image-process-max-threads cfg/config 1))) @@ -73,7 +71,7 @@ ;; http://www.imagemagick.org/Usage/thumbnails/ (defn- generic-process - [{:keys [input format quality operation] :as params}] + [{:keys [input format operation] :as params}] (let [{:keys [path mtype]} input format (or (cm/mtype->format mtype) format) ext (cm/format->extension format) @@ -160,35 +158,6 @@ ;; --- Utility functions -(defn resolve-urls - [row src dst] - (s/assert map? row) - (if (and src dst) - (let [src (if (vector? src) src [src]) - dst (if (vector? dst) dst [dst]) - value (get-in row src)] - (if (empty? value) - row - (let [url (ust/public-uri mst/media-storage value)] - (assoc-in row dst (str url))))) - row)) - -(defn- resolve-uri - [storage row src dst] - (let [src (if (vector? src) src [src]) - dst (if (vector? dst) dst [dst]) - value (get-in row src)] - (if (empty? value) - row - (let [url (ust/public-uri mst/media-storage value)] - (assoc-in row dst (str url)))))) - -(defn resolve-media-uris - [row & pairs] - (us/assert map? row) - (us/assert (s/coll-of vector?) pairs) - (reduce #(resolve-uri mst/media-storage %1 (nth %2 0) (nth %2 1)) row pairs)) - (defn validate-media-type [media-type] (when-not (cm/valid-media-types media-type) @@ -196,6 +165,11 @@ :code :media-type-not-allowed :hint "Seems like you are uploading an invalid media object"))) + +;; TODO: rewrite using jetty http client instead of jvm +;; builtin (because builtin http client uses a lot of memory for the +;; same operation. + (defn download-media-object [url] (let [result (http/get! url {:as :byte-array}) diff --git a/backend/src/app/media_storage.clj b/backend/src/app/media_storage.clj index 3accc3026..df763c999 100644 --- a/backend/src/app/media_storage.clj +++ b/backend/src/app/media_storage.clj @@ -5,24 +5,25 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2017-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.media-storage "A media storage impl for app." (:require - [mount.core :refer [defstate]] - [clojure.java.io :as io] - [cuerdas.core :as str] - [datoteka.core :as fs] + [app.config :refer [config]] [app.util.storage :as ust] - [app.config :refer [config]])) + [mount.core :refer [defstate]])) ;; --- State +(declare assets-storage) + (defstate assets-storage :start (ust/create {:base-path (:assets-directory config) :base-uri (:assets-uri config)})) +(declare media-storage) + (defstate media-storage :start (ust/create {:base-path (:media-directory config) :base-uri (:media-uri config) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index 39d08970c..4f9a30ad2 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -8,9 +8,6 @@ ;; Copyright (c) 2020 UXBOX Labs SL (ns app.metrics - (:require - [clojure.tools.logging :as log] - [cuerdas.core :as str]) (:import io.prometheus.client.CollectorRegistry io.prometheus.client.Counter @@ -172,7 +169,7 @@ (assoc mdata ::summary-original original))))))) (defn dump - [& args] + [& _args] (let [samples (.metricFamilySamples ^CollectorRegistry registry) writer (StringWriter.)] (TextFormat/write004 writer samples) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 54fa2a50d..c1ec7bd66 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -9,11 +9,10 @@ (ns app.migrations (:require - [mount.core :as mount :refer [defstate]] [app.db :as db] - [app.config :as cfg] [app.migrations.migration-0023 :as mg0023] - [app.util.migrations :as mg])) + [app.util.migrations :as mg] + [mount.core :as mount :refer [defstate]])) (def +migrations+ {:name "uxbox-main" @@ -110,6 +109,15 @@ {:name "0031-add-conversation-related-tables" :fn (mg/resource "app/migrations/sql/0031-add-conversation-related-tables.sql")} + + {:name "0032-del-unused-tables" + :fn (mg/resource "app/migrations/sql/0032-del-unused-tables.sql")} + + {:name "0033-mod-comment-thread-table" + :fn (mg/resource "app/migrations/sql/0033-mod-comment-thread-table.sql")} + + {:name "0034-mod-profile-table-add-props-field" + :fn (mg/resource "app/migrations/sql/0034-mod-profile-table-add-props-field.sql")} ]}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/migrations/sql/0032-del-unused-tables.sql b/backend/src/app/migrations/sql/0032-del-unused-tables.sql new file mode 100644 index 000000000..f6333939e --- /dev/null +++ b/backend/src/app/migrations/sql/0032-del-unused-tables.sql @@ -0,0 +1,3 @@ +DROP TABLE color; +DROP TABLE page_change; +DROP TABLE page_version; diff --git a/backend/src/app/migrations/sql/0033-mod-comment-thread-table.sql b/backend/src/app/migrations/sql/0033-mod-comment-thread-table.sql new file mode 100644 index 000000000..0e98ef6d1 --- /dev/null +++ b/backend/src/app/migrations/sql/0033-mod-comment-thread-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE comment_thread + ADD COLUMN page_name text NULL; diff --git a/backend/src/app/migrations/sql/0034-mod-profile-table-add-props-field.sql b/backend/src/app/migrations/sql/0034-mod-profile-table-add-props-field.sql new file mode 100644 index 000000000..11f80ec32 --- /dev/null +++ b/backend/src/app/migrations/sql/0034-mod-profile-table-add-props-field.sql @@ -0,0 +1 @@ +ALTER TABLE profile ADD COLUMN props jsonb NULL DEFAULT NULL; diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 39345e5a1..3edb30047 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -7,13 +7,9 @@ (ns app.redis (:refer-clojure :exclude [run!]) (:require - [clojure.tools.logging :as log] - [lambdaisland.uri :refer [uri]] - [mount.core :as mount :refer [defstate]] - [app.common.exceptions :as ex] [app.config :as cfg] - [app.util.data :as data] - [app.util.redis :as redis]) + [app.util.redis :as redis] + [mount.core :as mount :refer [defstate]]) (:import java.lang.AutoCloseable)) @@ -24,10 +20,14 @@ (let [uri (:redis-uri config "redis://redis/0")] (redis/client uri))) +(declare client) + (defstate client :start (create-client cfg/config) :stop (.close ^AutoCloseable client)) +(declare conn) + (defstate conn :start (redis/connect client) :stop (.close ^AutoCloseable conn)) diff --git a/backend/src/app/services.clj b/backend/src/app/services.clj new file mode 100644 index 000000000..2268ed791 --- /dev/null +++ b/backend/src/app/services.clj @@ -0,0 +1,43 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.services + "A initialization of services." + (:require + [app.services.middleware :as middleware] + [app.util.dispatcher :as uds] + [mount.core :as mount :refer [defstate]])) + +;; --- Initialization + +(defn- load-query-services + [] + (require 'app.services.queries.projects) + (require 'app.services.queries.files) + (require 'app.services.queries.comments) + (require 'app.services.queries.profile) + (require 'app.services.queries.recent-files) + (require 'app.services.queries.viewer)) + +(defn- load-mutation-services + [] + (require 'app.services.mutations.demo) + (require 'app.services.mutations.media) + (require 'app.services.mutations.projects) + (require 'app.services.mutations.files) + (require 'app.services.mutations.comments) + (require 'app.services.mutations.profile) + (require 'app.services.mutations.viewer) + (require 'app.services.mutations.verify-token)) + +(defstate query-services + :start (load-query-services)) + +(defstate mutation-services + :start (load-mutation-services)) diff --git a/backend/src/app/services/init.clj b/backend/src/app/services/init.clj index 4df4707ed..6223b121b 100644 --- a/backend/src/app/services/init.clj +++ b/backend/src/app/services/init.clj @@ -7,34 +7,4 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.services.init - "A initialization of services." - (:require - [mount.core :as mount :refer [defstate]])) - -(defn- load-query-services - [] - (require 'app.services.queries.media) - (require 'app.services.queries.projects) - (require 'app.services.queries.files) - (require 'app.services.queries.comments) - (require 'app.services.queries.profile) - (require 'app.services.queries.recent-files) - (require 'app.services.queries.viewer)) - -(defn- load-mutation-services - [] - (require 'app.services.mutations.demo) - (require 'app.services.mutations.media) - (require 'app.services.mutations.projects) - (require 'app.services.mutations.files) - (require 'app.services.mutations.comments) - (require 'app.services.mutations.profile) - (require 'app.services.mutations.viewer) - (require 'app.services.mutations.verify-token)) - -(defstate query-services - :start (load-query-services)) - -(defstate mutation-services - :start (load-mutation-services)) +(ns app.services.init) diff --git a/backend/src/app/services/middleware.clj b/backend/src/app/services/middleware.clj index 098020b75..916d791a6 100644 --- a/backend/src/app/services/middleware.clj +++ b/backend/src/app/services/middleware.clj @@ -10,13 +10,11 @@ (ns app.services.middleware "Common middleware for services." (:require - [clojure.tools.logging :as log] - [clojure.spec.alpha :as s] - [cuerdas.core :as str] - [expound.alpha :as expound] [app.common.exceptions :as ex] [app.common.spec :as us] - [app.metrics :as mtx])) + [app.metrics :as mtx] + [clojure.spec.alpha :as s] + [cuerdas.core :as str])) (defn wrap-spec [handler] @@ -45,7 +43,7 @@ (defn- get-prefix [nsname] - (let [[a b c] (str/split nsname ".")] + (let [[_ _ c] (str/split nsname ".")] c)) (defn wrap-metrics diff --git a/backend/src/app/services/mutations/comments.clj b/backend/src/app/services/mutations/comments.clj index af798bb07..b30f51081 100644 --- a/backend/src/app/services/mutations/comments.clj +++ b/backend/src/app/services/mutations/comments.clj @@ -9,32 +9,27 @@ (ns app.services.mutations.comments (:require - [clojure.spec.alpha :as s] [app.common.exceptions :as ex] [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.config :as cfg] [app.db :as db] [app.services.mutations :as sm] - [app.services.queries.projects :as proj] - [app.services.queries.files :as files] [app.services.queries.comments :as comments] - [app.tasks :as tasks] + [app.services.queries.files :as files] [app.util.blob :as blob] - [app.util.storage :as ust] - [app.util.transit :as t] - [app.util.time :as dt])) + [app.util.time :as dt] + [clojure.spec.alpha :as s])) ;; --- Mutation: Create Comment Thread (declare upsert-comment-thread-status!) (declare create-comment-thread) +(declare retrieve-page-name) +(s/def ::page-id ::us/uuid) (s/def ::file-id ::us/uuid) (s/def ::profile-id ::us/uuid) (s/def ::position ::us/point) (s/def ::content ::us/string) -(s/def ::page-id ::us/uuid) (s/def ::create-comment-thread (s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id])) @@ -53,25 +48,28 @@ (defn- create-comment-thread* [conn {:keys [profile-id file-id page-id position content] :as params}] - (let [seqn (retrieve-next-seqn conn file-id) - now (dt/now) - + (let [seqn (retrieve-next-seqn conn file-id) + now (dt/now) + pname (retrieve-page-name conn params) thread (db/insert! conn :comment-thread {:file-id file-id :owner-id profile-id :participants (db/tjson #{profile-id}) + :page-name pname :page-id page-id :created-at now :modified-at now :seqn seqn - :position (db/pgpoint position)}) - ;; Create a comment entry - comment (db/insert! conn :comment - {:thread-id (:id thread) - :owner-id profile-id - :created-at now - :modified-at now - :content content})] + :position (db/pgpoint position)})] + + + ;; Create a comment entry + (db/insert! conn :comment + {:thread-id (:id thread) + :owner-id profile-id + :created-at now + :modified-at now + :content content}) ;; Make the current thread as read. (upsert-comment-thread-status! conn profile-id (:id thread)) @@ -81,10 +79,7 @@ {:comment-thread-seqn seqn} {:id file-id}) - (-> (assoc thread - :content content - :comment comment) - (comments/decode-row)))) + (select-keys thread [:id :file-id :page-id]))) (defn- create-comment-thread [conn params] @@ -104,6 +99,12 @@ :else res)))) +(defn- retrieve-page-name + [conn {:keys [file-id page-id]}] + (let [{:keys [data]} (db/get-by-id conn :file file-id) + data (blob/decode data)] + (get-in data [:pages-index page-id :name]))) + ;; --- Mutation: Update Comment Thread Status @@ -164,14 +165,21 @@ [{:keys [profile-id thread-id content] :as params}] (db/with-atomic [conn db/pool] (let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true}) - (comments/decode-row))] + (comments/decode-row)) + pname (retrieve-page-name conn thread)] ;; Standard Checks - (when-not thread - (ex/raise :type :not-found)) + (when-not thread (ex/raise :type :not-found)) + ;; Permission Checks (files/check-read-permissions! conn profile-id (:file-id thread)) + ;; Update the page-name cachedattribute on comment thread table. + (when (not= pname (:page-name thread)) + (db/update! conn :comment-thread + {:page-name pname} + {:id thread-id})) + ;; NOTE: is important that all timestamptz related fields are ;; created or updated on the database level for avoid clock ;; inconsistencies (some user sees something read that is not @@ -216,15 +224,24 @@ (let [comment (db/get-by-id conn :comment id {:for-update true}) _ (when-not comment (ex/raise :type :not-found)) thread (db/get-by-id conn :comment-thread (:thread-id comment) {:for-update true}) - _ (when-not thread (ex/raise :type :not-found))] + _ (when-not thread (ex/raise :type :not-found)) + pname (retrieve-page-name conn thread)] (files/check-read-permissions! conn profile-id (:file-id thread)) + + ;; Don't allow edit comments to not owners + (when-not (= (:owner-id thread) profile-id) + (ex/raise :type :validation + :code :not-allowed)) + (db/update! conn :comment {:content content :modified-at (dt/now)} {:id (:id comment)}) + (db/update! conn :comment-thread - {:modified-at (dt/now)} + {:modified-at (dt/now) + :page-name pname} {:id (:id thread)}) nil))) @@ -237,13 +254,14 @@ (sm/defmutation ::delete-comment-thread [{:keys [profile-id id] :as params}] (db/with-atomic [conn db/pool] - (let [cthr (db/get-by-id conn :comment-thread id {:for-update true})] - (when-not (= (:owner-id cthr) profile-id) + (let [thread (db/get-by-id conn :comment-thread id {:for-update true})] + (when-not (= (:owner-id thread) profile-id) (ex/raise :type :validation :code :not-allowed)) (db/delete! conn :comment-thread {:id id}) nil))) + ;; --- Mutation: Delete comment (s/def ::delete-comment diff --git a/backend/src/app/services/mutations/demo.clj b/backend/src/app/services/mutations/demo.clj index 839deae81..95471c13f 100644 --- a/backend/src/app/services/mutations/demo.clj +++ b/backend/src/app/services/mutations/demo.clj @@ -10,17 +10,14 @@ (ns app.services.mutations.demo "A demo specific mutations." (:require - [clojure.spec.alpha :as s] - [buddy.core.codecs :as bc] - [buddy.core.nonce :as bn] - [app.common.exceptions :as ex] + [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] [app.services.mutations :as sm] [app.services.mutations.profile :as profile] [app.tasks :as tasks] - [app.common.uuid :as uuid] - [app.util.time :as tm])) + [buddy.core.codecs :as bc] + [buddy.core.nonce :as bn])) (sm/defmutation ::create-demo-profile [_] diff --git a/backend/src/app/services/mutations/files.clj b/backend/src/app/services/mutations/files.clj index 0246d58f4..17742805d 100644 --- a/backend/src/app/services/mutations/files.clj +++ b/backend/src/app/services/mutations/files.clj @@ -9,25 +9,22 @@ (ns app.services.mutations.files (:require - [clojure.spec.alpha :as s] - [datoteka.core :as fs] - [promesa.core :as p] [app.common.exceptions :as ex] [app.common.pages :as cp] - [app.common.pages-migrations :as pmg] + [app.common.pages.migrations :as pmg] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] [app.redis :as redis] [app.services.mutations :as sm] - [app.services.queries.projects :as proj] [app.services.queries.files :as files] + [app.services.queries.projects :as proj] [app.tasks :as tasks] [app.util.blob :as blob] - [app.util.storage :as ust] + [app.util.time :as dt] [app.util.transit :as t] - [app.util.time :as dt])) + [clojure.spec.alpha :as s])) ;; --- Helpers & Specs @@ -62,7 +59,7 @@ :can-edit true})) (defn create-file - [conn {:keys [id profile-id name project-id is-shared] + [conn {:keys [id name project-id is-shared] :or {is-shared false} :as params}] (let [id (or id (uuid/next)) @@ -162,11 +159,14 @@ (files/check-edition-permissions! conn profile-id file-id) (link-file-to-library conn params))) +(def sql:link-file-to-library + "insert into file_library_rel (file_id, library_file_id) + values (?, ?) + on conflict do nothing;") + (defn- link-file-to-library [conn {:keys [file-id library-id] :as params}] - (db/insert! conn :file-library-rel - {:file-id file-id - :library-file-id library-id})) + (db/exec-one! conn [sql:link-file-to-library file-id library-id])) ;; --- Mutation: Unlink file from library @@ -248,7 +248,8 @@ :add-media :mod-media :del-media :add-component :mod-component :del-component :add-typography :mod-typography :del-typography} (:type change)) - (and (= (:type change) :mod-obj) + (and (#{:add-obj :mod-obj :del-obj + :reg-objects :mov-objects} (:type change)) (some? (:component-id change))))) (declare update-file) @@ -282,7 +283,7 @@ (assoc :changes (blob/encode changes) :session-id sid)) - chng (insert-change conn file) + _ (insert-change conn file) msg {:type :file-change :profile-id (:profile-id params) :file-id (:id file) @@ -315,7 +316,7 @@ :data (:data file)} {:id (:id file)}) - (retrieve-lagged-changes conn chng params))) + (retrieve-lagged-changes conn params))) (defn- insert-change [conn {:keys [revn data changes session-id] :as file}] @@ -339,7 +340,7 @@ order by s.created_at asc") (defn- retrieve-lagged-changes - [conn snapshot params] + [conn params] (->> (db/exec! conn [sql:lagged-changes (:id params) (:revn params)]) (mapv files/decode-row))) diff --git a/backend/src/app/services/mutations/media.clj b/backend/src/app/services/mutations/media.clj index 1495656d3..f1812b7fe 100644 --- a/backend/src/app/services/mutations/media.clj +++ b/backend/src/app/services/mutations/media.clj @@ -9,21 +9,18 @@ (ns app.services.mutations.media (:require - [clojure.spec.alpha :as s] - [datoteka.core :as fs] - [app.common.media :as cm] [app.common.exceptions :as ex] + [app.common.media :as cm] [app.common.spec :as us] [app.common.uuid :as uuid] - [app.config :as cfg] [app.db :as db] [app.media :as media] + [app.media-storage :as mst] [app.services.mutations :as sm] [app.services.queries.teams :as teams] - [app.tasks :as tasks] - [app.media-storage :as mst] [app.util.storage :as ust] - [app.util.time :as dt])) + [clojure.spec.alpha :as s] + [datoteka.core :as fs])) (def thumbnail-options {:width 100 @@ -38,7 +35,6 @@ (s/def ::team-id ::us/uuid) (s/def ::url ::us/url) - ;; --- Create Media object (Upload and create from url) (declare create-media-object) @@ -51,20 +47,20 @@ (s/def ::add-media-object-from-url (s/keys :req-un [::profile-id ::file-id ::is-local ::url] - :opt-un [::id])) + :opt-un [::id ::name])) (s/def ::upload-media-object (s/keys :req-un [::profile-id ::file-id ::is-local ::name ::content] :opt-un [::id])) (sm/defmutation ::add-media-object-from-url - [{:keys [profile-id file-id url] :as params}] + [{:keys [profile-id file-id url name] :as params}] (db/with-atomic [conn db/pool] (let [file (select-file-for-update conn file-id)] (teams/check-edition-permissions! conn profile-id (:team-id file)) (let [content (media/download-media-object url) params' (merge params {:content content - :name (:filename content)})] + :name (or name (:filename content))})] (create-media-object conn params'))))) (sm/defmutation ::upload-media-object @@ -147,56 +143,3 @@ (-> thumb (dissoc :data :input) (assoc :path path)))) - -;; --- Mutation: Rename Media object - -(declare select-media-object-for-update) - -(s/def ::rename-media-object - (s/keys :req-un [::id ::profile-id ::name])) - -(sm/defmutation ::rename-media-object - [{:keys [id profile-id name] :as params}] - (db/with-atomic [conn db/pool] - (let [obj (select-media-object-for-update conn id)] - (teams/check-edition-permissions! conn profile-id (:team-id obj)) - (db/update! conn :media-object - {:name name} - {:id id})))) - -(def ^:private sql:select-media-object-for-update - "select obj.*, - p.team_id as team_id - from media_object as obj - inner join file as f on (f.id = obj.file_id) - inner join project as p on (p.id = f.project_id) - where obj.id = ? - for update of obj") - -(defn- select-media-object-for-update - [conn id] - (let [row (db/exec-one! conn [sql:select-media-object-for-update id])] - (when-not row - (ex/raise :type :not-found)) - row)) - -;; --- Delete Media object - -(s/def ::delete-media-object - (s/keys :req-un [::id ::profile-id])) - -(sm/defmutation ::delete-media-object - [{:keys [profile-id id] :as params}] - (db/with-atomic [conn db/pool] - (let [obj (select-media-object-for-update conn id)] - (teams/check-edition-permissions! conn profile-id (:team-id obj)) - - ;; Schedule object deletion - (tasks/submit! conn {:name "delete-object" - :delay cfg/default-deletion-delay - :props {:id id :type :media-object}}) - - (db/update! conn :media-object - {:deleted-at (dt/now)} - {:id id}) - nil))) diff --git a/backend/src/app/services/mutations/profile.clj b/backend/src/app/services/mutations/profile.clj index a2f587cac..01f11d69c 100644 --- a/backend/src/app/services/mutations/profile.clj +++ b/backend/src/app/services/mutations/profile.clj @@ -10,27 +10,24 @@ (ns app.services.mutations.profile (:require [app.common.exceptions :as ex] - [app.common.media :as cm] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cfg] [app.db :as db] [app.emails :as emails] - [app.media :as media] - [app.media-storage :as mst] [app.http.session :as session] + [app.media :as media] [app.services.mutations :as sm] [app.services.mutations.projects :as projects] [app.services.mutations.teams :as teams] + [app.services.mutations.verify-token :refer [process-token]] [app.services.queries.profile :as profile] [app.services.tokens :as tokens] - [app.services.mutations.verify-token :refer [process-token]] [app.tasks :as tasks] - [app.util.blob :as blob] - [app.util.storage :as ust] [app.util.time :as dt] [buddy.hashers :as hashers] [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] [cuerdas.core :as str])) ;; --- Helpers & Specs @@ -142,11 +139,20 @@ (defn- derive-password [password] - (hashers/derive password {:alg :bcrypt+sha512})) + (hashers/derive password + {:alg :argon2id + :memory 16384 + :iterations 20 + :parallelism 2})) (defn- verify-password [attempt password] - (hashers/verify attempt password)) + (try + (hashers/verify attempt password) + (catch Exception e + (log/warnf e "Error on verify password (only informative, nothing affected to user).") + {:update false + :valid false}))) (defn- create-profile "Create the profile entry on the database with limited input @@ -274,7 +280,7 @@ (defn- validate-password! [conn {:keys [profile-id old-password] :as params}] - (let [profile (profile/retrieve-profile-data conn profile-id)] + (let [profile (db/get-by-id conn :profile profile-id)] (when-not (:valid (verify-password old-password (:password profile))) (ex/raise :type :validation :code :old-password-not-match)))) @@ -304,7 +310,7 @@ [{:keys [profile-id file] :as params}] (media/validate-media-type (:content-type file)) (db/with-atomic [conn db/pool] - (let [profile (profile/retrieve-profile conn profile-id) + (let [profile (db/get-by-id conn :profile profile-id) _ (media/run {:cmd :info :input {:path (:tempfile file) :mtype (:content-type file)}}) photo (teams/upload-photo conn params)] @@ -361,7 +367,7 @@ (sm/defmutation ::request-profile-recovery [{:keys [email] :as params}] - (letfn [(create-recovery-token [conn {:keys [id] :as profile}] + (letfn [(create-recovery-token [{:keys [id] :as profile}] (let [token (tokens/generate {:iss :password-recovery :exp (dt/in-future "15m") @@ -377,7 +383,7 @@ (db/with-atomic [conn db/pool] (some->> email (profile/retrieve-profile-data-by-email conn) - (create-recovery-token conn) + (create-recovery-token) (send-email-notification conn)) nil))) @@ -390,7 +396,7 @@ (sm/defmutation ::recover-profile [{:keys [token password]}] - (letfn [(validate-token [conn token] + (letfn [(validate-token [token] (let [tdata (tokens/verify token {:iss :password-recovery})] (:profile-id tdata))) @@ -399,10 +405,31 @@ (db/update! conn :profile {:password pwd} {:id profile-id})))] (db/with-atomic [conn db/pool] - (->> (validate-token conn token) + (->> (validate-token token) (update-password conn)) nil))) +;; --- Mutation: Update Profile Props + +(s/def ::props map?) +(s/def ::update-profile-props + (s/keys :req-un [::profile-id ::props])) + +(sm/defmutation ::update-profile-props + [{:keys [profile-id props]}] + (db/with-atomic [conn db/pool] + (let [profile (profile/retrieve-profile-data conn profile-id) + props (reduce-kv (fn [props k v] + (if (nil? v) + (dissoc props k) + (assoc props k v))) + (:props profile) + props)] + (db/update! conn :profile + {:props (db/tjson props)} + {:id profile-id}) + nil))) + ;; --- Mutation: Delete Profile diff --git a/backend/src/app/services/mutations/projects.clj b/backend/src/app/services/mutations/projects.clj index bb36b8977..211da4300 100644 --- a/backend/src/app/services/mutations/projects.clj +++ b/backend/src/app/services/mutations/projects.clj @@ -5,12 +5,10 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2019-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.services.mutations.projects (:require - [clojure.spec.alpha :as s] - [app.common.exceptions :as ex] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cfg] @@ -18,7 +16,7 @@ [app.services.mutations :as sm] [app.services.queries.projects :as proj] [app.tasks :as tasks] - [app.util.blob :as blob])) + [clojure.spec.alpha :as s])) ;; --- Helpers & Specs @@ -48,7 +46,7 @@ (assoc proj :is-pinned true)))) (defn create-project - [conn {:keys [id profile-id team-id name default?] :as params}] + [conn {:keys [id team-id name default?] :as params}] (let [id (or id (uuid/next)) default? (if (boolean? default?) default? false)] (db/insert! conn :project @@ -107,11 +105,10 @@ (sm/defmutation ::rename-project [{:keys [id profile-id name] :as params}] (db/with-atomic [conn db/pool] - (let [project (db/get-by-id conn :project id {:for-update true})] - (proj/check-edition-permissions! conn profile-id id) - (db/update! conn :project - {:name name} - {:id id})))) + (proj/check-edition-permissions! conn profile-id id) + (db/update! conn :project + {:name name} + {:id id}))) ;; --- Mutation: Delete Project @@ -139,6 +136,6 @@ returning id") (defn mark-project-deleted - [conn {:keys [id profile-id] :as params}] + [conn {:keys [id] :as params}] (db/exec! conn [sql:mark-project-deleted id]) nil) diff --git a/backend/src/app/services/mutations/teams.clj b/backend/src/app/services/mutations/teams.clj index 305b70275..a7ecdf09c 100644 --- a/backend/src/app/services/mutations/teams.clj +++ b/backend/src/app/services/mutations/teams.clj @@ -20,9 +20,9 @@ [app.media-storage :as mst] [app.services.mutations :as sm] [app.services.mutations.projects :as projects] + [app.services.queries.profile :as profile] [app.services.queries.teams :as teams] [app.services.tokens :as tokens] - [app.services.queries.profile :as profile] [app.tasks :as tasks] [app.util.storage :as ust] [app.util.time :as dt] @@ -58,7 +58,7 @@ team))) (defn create-team - [conn {:keys [id profile-id name default?] :as params}] + [conn {:keys [id name default?] :as params}] (let [id (or id (uuid/next)) default? (if (boolean? default?) default? false)] (db/insert! conn :team @@ -268,7 +268,7 @@ (assoc team :photo (str photo))))) (defn upload-photo - [conn {:keys [file profile-id]}] + [_conn {:keys [file]}] (let [prefix (-> (bn/random-bytes 8) (bc/bytes->b64u) (bc/bytes->str)) diff --git a/backend/src/app/services/mutations/verify_token.clj b/backend/src/app/services/mutations/verify_token.clj index 638a04879..87dda705a 100644 --- a/backend/src/app/services/mutations/verify_token.clj +++ b/backend/src/app/services/mutations/verify_token.clj @@ -10,28 +10,16 @@ (ns app.services.mutations.verify-token (:require [app.common.exceptions :as ex] - [app.common.media :as cm] [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.config :as cfg] [app.db :as db] - [app.emails :as emails] [app.http.session :as session] - [app.media :as media] - [app.media-storage :as mst] [app.services.mutations :as sm] [app.services.mutations.teams :as teams] [app.services.queries.profile :as profile] [app.services.tokens :as tokens] - [app.tasks :as tasks] - [app.util.blob :as blob] - [app.util.storage :as ust] - [app.util.time :as dt] - [buddy.hashers :as hashers] - [clojure.spec.alpha :as s] - [cuerdas.core :as str])) + [clojure.spec.alpha :as s])) -(defmulti process-token (fn [conn params claims] (:iss claims))) +(defmulti process-token (fn [_ _ claims] (:iss claims))) (s/def ::verify-token (s/keys :req-un [::token] @@ -44,18 +32,17 @@ (process-token conn params claims)))) (defmethod process-token :change-email - [conn params {:keys [profile-id email] :as claims}] - (let [profile (db/get-by-id conn :profile profile-id {:for-update true})] - (when (profile/retrieve-profile-data-by-email conn email) - (ex/raise :type :validation - :code :email-already-exists)) - (db/update! conn :profile - {:email email} - {:id profile-id}) - claims)) + [conn _params {:keys [profile-id email] :as claims}] + (when (profile/retrieve-profile-data-by-email conn email) + (ex/raise :type :validation + :code :email-already-exists)) + (db/update! conn :profile + {:email email} + {:id profile-id}) + claims) (defmethod process-token :verify-email - [conn params {:keys [profile-id] :as claims}] + [conn _params {:keys [profile-id] :as claims}] (let [profile (db/get-by-id conn :profile profile-id {:for-update true})] (when (:is-active profile) (ex/raise :type :validation @@ -71,7 +58,7 @@ claims)) (defmethod process-token :auth - [conn params {:keys [profile-id] :as claims}] + [conn _params {:keys [profile-id] :as claims}] (let [profile (profile/retrieve-profile conn profile-id)] (assoc claims :profile profile))) @@ -137,7 +124,7 @@ ;; --- Default (defmethod process-token :default - [conn params claims] + [_ _ _] (ex/raise :type :validation :code :invalid-token)) diff --git a/backend/src/app/services/mutations/viewer.clj b/backend/src/app/services/mutations/viewer.clj index 71d189260..82ebb17b1 100644 --- a/backend/src/app/services/mutations/viewer.clj +++ b/backend/src/app/services/mutations/viewer.clj @@ -9,10 +9,7 @@ (ns app.services.mutations.viewer (:require - [app.common.exceptions :as ex] - [app.common.pages :as cp] [app.common.spec :as us] - [app.config :as cfg] [app.db :as db] [app.services.mutations :as sm] [app.services.queries.files :as files] diff --git a/backend/src/app/services/notifications.clj b/backend/src/app/services/notifications.clj index 3f837c982..4c2d18e23 100644 --- a/backend/src/app/services/notifications.clj +++ b/backend/src/app/services/notifications.clj @@ -10,17 +10,13 @@ (ns app.services.notifications "A websocket based notifications mechanism." (:require - [app.common.exceptions :as ex] - [app.common.uuid :as uuid] [app.db :as db] [app.metrics :as mtx] [app.redis :as redis] [app.util.async :as aa] - [app.util.time :as dt] [app.util.transit :as t] [clojure.core.async :as a] [clojure.tools.logging :as log] - [promesa.core :as p] [ring.adapter.jetty9 :as jetty])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -40,7 +36,7 @@ :help "A total number of messages handled by the notifications service."})) (defn websocket - [{:keys [file-id team-id profile-id] :as params}] + [{:keys [file-id team-id] :as params}] (let [in (a/chan 32) out (a/chan 32)] {:on-connect @@ -62,18 +58,18 @@ (a/close! sub)))) :on-error - (fn [conn e] + (fn [_conn _e] (a/close! out) (a/close! in)) :on-close - (fn [conn status-code reason] + (fn [_conn _status _reason] (metrics-active-connections :dec) (a/close! out) (a/close! in)) :on-text - (fn [ws message] + (fn [_ws message] (metrics-message-counter :inc) (let [message (t/decode-str message)] (a/>!! in message))) @@ -165,8 +161,7 @@ (defn- update-presence [file-id session-id profile-id] (aa/thread-try - (let [now (dt/now) - sql [sql:update-presence file-id session-id profile-id]] + (let [sql [sql:update-presence file-id session-id profile-id]] (db/exec-one! db/pool sql)))) (defn- delete-presence @@ -177,13 +172,13 @@ :session-id session-id}))) (defmulti handle-message - (fn [ws message] (:type message))) + (fn [_ message] (:type message))) ;; TODO: check permissions for join a file-id channel (probably using ;; single use token for avoid explicit database query). (defmethod handle-message :connect - [{:keys [file-id profile-id session-id output] :as ws} message] + [{:keys [file-id profile-id session-id] :as ws} _message] (log/debugf "profile '%s' is connected to file '%s'" profile-id file-id) (aa/go-try (aa/= coalesce(cts.modified_at, ct.created_at)) as count_unread_comments from comment_thread as ct inner join comment as c on (c.thread_id = ct.id) + inner join file as f on (f.id = ct.file_id) left join comment_thread_status as cts on (cts.thread_id = ct.id and cts.profile_id = ?) @@ -63,9 +64,58 @@ (defn- retrieve-comment-threads [conn {:keys [profile-id file-id]}] + (files/check-read-permissions! conn profile-id file-id) (->> (db/exec! conn [sql:comment-threads profile-id file-id]) (into [] (map decode-row)))) + +;; --- Query: Unread Comment Threads + +(declare retrieve-unread-comment-threads) + +(s/def ::team-id ::us/uuid) +(s/def ::unread-comment-threads + (s/keys :req-un [::profile-id ::team-id])) + +(sq/defquery ::unread-comment-threads + [{:keys [profile-id team-id] :as params}] + (with-open [conn (db/open)] + (teams/check-read-permissions! conn profile-id team-id) + (retrieve-unread-comment-threads conn params))) + +(def sql:comment-threads-by-team + "select distinct on (ct.id) + ct.*, + f.name as file_name, + f.project_id as project_id, + first_value(c.content) over w as content, + (select count(1) + from comment as c + where c.thread_id = ct.id) as count_comments, + (select count(1) + from comment as c + where c.thread_id = ct.id + and c.created_at >= coalesce(cts.modified_at, ct.created_at)) as count_unread_comments + from comment_thread as ct + inner join comment as c on (c.thread_id = ct.id) + inner join file as f on (f.id = ct.file_id) + inner join project as p on (p.id = f.project_id) + left join comment_thread_status as cts + on (cts.thread_id = ct.id and + cts.profile_id = ?) + where p.team_id = ? + window w as (partition by c.thread_id order by c.created_at asc)") + +(def sql:unread-comment-threads-by-team + (str "with threads as (" sql:comment-threads-by-team ")" + "select * from threads where count_unread_comments > 0")) + +(defn retrieve-unread-comment-threads + [conn {:keys [profile-id team-id]}] + (->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id]) + (into [] (map decode-row)))) + + ;; --- Query: Single Comment Thread (s/def ::id ::us/uuid) diff --git a/backend/src/app/services/queries/files.clj b/backend/src/app/services/queries/files.clj index 8fa7106ec..82393375e 100644 --- a/backend/src/app/services/queries/files.clj +++ b/backend/src/app/services/queries/files.clj @@ -5,20 +5,18 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2019-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.services.queries.files (:require - [clojure.spec.alpha :as s] - [promesa.core :as p] - [app.common.pages-migrations :as pmg] [app.common.exceptions :as ex] + [app.common.pages.migrations :as pmg] [app.common.spec :as us] [app.db :as db] - [app.media :as media] [app.services.queries :as sq] [app.services.queries.projects :as projects] - [app.util.blob :as blob])) + [app.util.blob :as blob] + [clojure.spec.alpha :as s])) (declare decode-row) (declare decode-row-xf) @@ -185,48 +183,11 @@ (let [file (retrieve-file conn file-id)] (get-in file [:data :pages-index id])))) -;; --- Query: File users - -(def ^:private sql:file-users - "select pf.id, pf.fullname, pf.photo - from profile as pf - inner join file_profile_rel as fpr on (fpr.profile_id = pf.id) - where fpr.file_id = ? - union - select pf.id, pf.fullname, pf.photo - from profile as pf - inner join team_profile_rel as tpr on (tpr.profile_id = pf.id) - inner join project as p on (tpr.team_id = p.team_id) - inner join file as f on (p.id = f.project_id) - where f.id = ?") - -(defn retrieve-file-users - [conn id] - (->> (db/exec! conn [sql:file-users id id]) - (mapv #(media/resolve-media-uris % [:photo :photo-uri])))) - -(s/def ::file-users - (s/keys :req-un [::profile-id ::id])) - -(sq/defquery ::file-users - [{:keys [profile-id id] :as params}] - (db/with-atomic [conn db/pool] - (check-edition-permissions! conn profile-id id) - (retrieve-file-users conn id))) ;; --- Query: Shared Library Files -;; TODO: remove the counts, because they are no longer needed. - (def ^:private sql:shared-files - "select f.*, - (select count(*) from color as c - where c.file_id = f.id - and c.deleted_at is null) as colors_count, - (select count(*) from media_object as m - where m.file_id = f.id - and m.is_local = false - and m.deleted_at is null) as graphics_count + "select f.* from file as f inner join project as p on (p.id = f.project_id) where f.is_shared = true diff --git a/backend/src/app/services/queries/media.clj b/backend/src/app/services/queries/media.clj deleted file mode 100644 index 94ff99dcb..000000000 --- a/backend/src/app/services/queries/media.clj +++ /dev/null @@ -1,109 +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/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2019-2020 Andrey Antukh - -(ns app.services.queries.media - (:require - [clojure.spec.alpha :as s] - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.db :as db] - [app.media :as media] - [app.services.queries :as sq] - [app.services.queries.teams :as teams])) - -(s/def ::id ::us/uuid) -(s/def ::name ::us/string) -(s/def ::profile-id ::us/uuid) -(s/def ::team-id ::us/uuid) -(s/def ::file-id ::us/uuid) - - -;; --- Query: Media objects (by file) - -(declare retrieve-media-objects) -(declare retrieve-file) - -(s/def ::is-local ::us/boolean) -(s/def ::media-objects - (s/keys :req-un [::profile-id ::file-id ::is-local])) - -;; TODO: check if we can resolve url with transducer for reduce -;; garbage generation for each request - -(sq/defquery ::media-objects - [{:keys [profile-id file-id is-local] :as params}] - (db/with-atomic [conn db/pool] - (let [file (retrieve-file conn file-id)] - (teams/check-read-permissions! conn profile-id (:team-id file)) - (->> (retrieve-media-objects conn file-id is-local) - (mapv #(media/resolve-urls % :path :uri)) - (mapv #(media/resolve-urls % :thumb-path :thumb-uri)))))) - -(def ^:private sql:media-objects - "select obj.*, - thumb.path as thumb_path - from media_object as obj - inner join media_thumbnail as thumb on obj.id = thumb.media_object_id - where obj.deleted_at is null - and obj.file_id = ? - and obj.is_local = ? - order by obj.created_at desc") - -(defn retrieve-media-objects - [conn file-id is-local] - (db/exec! conn [sql:media-objects file-id is-local])) - -(def ^:private sql:retrieve-file - "select file.*, - project.team_id as team_id - from file - inner join project on (project.id = file.project_id) - where file.id = ?") - -(defn- retrieve-file - [conn id] - (let [row (db/exec-one! conn [sql:retrieve-file id])] - (when-not row - (ex/raise :type :not-found)) - row)) - - -;; --- Query: Media object (by ID) - -(declare retrieve-media-object) - -(s/def ::id ::us/uuid) -(s/def ::media-object - (s/keys :req-un [::profile-id ::id])) - -(sq/defquery ::media-object - [{:keys [profile-id id] :as params}] - (db/with-atomic [conn db/pool] - (let [media-object (retrieve-media-object conn id)] - (teams/check-read-permissions! conn profile-id (:team-id media-object)) - (-> media-object - (media/resolve-urls :path :uri))))) - -(def ^:private sql:media-object - "select obj.*, - p.team_id as team_id - from media_object as obj - inner join file as f on (f.id = obj.file_id) - inner join project as p on (p.id = f.project_id) - where obj.deleted_at is null - and obj.id = ? - order by created_at desc") - -(defn retrieve-media-object - [conn id] - (let [row (db/exec-one! conn [sql:media-object id])] - (when-not row - (ex/raise :type :not-found)) - row)) - diff --git a/backend/src/app/services/queries/profile.clj b/backend/src/app/services/queries/profile.clj index 29b64583e..9f73e7c88 100644 --- a/backend/src/app/services/queries/profile.clj +++ b/backend/src/app/services/queries/profile.clj @@ -9,15 +9,13 @@ (ns app.services.queries.profile (:require - [clojure.spec.alpha :as s] - [cuerdas.core :as str] [app.common.exceptions :as ex] [app.common.spec :as us] - [app.db :as db] - [app.media :as media] - [app.services.queries :as sq] [app.common.uuid :as uuid] - [app.util.blob :as blob])) + [app.db :as db] + [app.services.queries :as sq] + [clojure.spec.alpha :as s] + [cuerdas.core :as str])) ;; --- Helpers & Specs @@ -25,7 +23,6 @@ (s/def ::email ::us/email) (s/def ::fullname ::us/string) -(s/def ::metadata any?) (s/def ::old-password ::us/string) (s/def ::password ::us/string) (s/def ::path ::us/string) @@ -75,14 +72,19 @@ {:default-team-id (:id team) :default-project-id (:id project)})) +(defn decode-profile-row + [{:keys [props] :as row}] + (cond-> row + (db/pgobject? props) (assoc :props (db/decode-transit-pgobject props)))) + (defn retrieve-profile-data [conn id] - (db/get-by-id conn :profile id)) + (-> (db/get-by-id conn :profile id) + (decode-profile-row))) (defn retrieve-profile [conn id] (let [profile (some-> (retrieve-profile-data conn id) - (media/resolve-urls :photo :photo-uri) (strip-private-attrs) (merge (retrieve-additional-data conn id)))] (when (nil? profile) @@ -100,7 +102,8 @@ (defn retrieve-profile-data-by-email [conn email] (let [email (str/lower email)] - (db/exec-one! conn [sql:profile-by-email email]))) + (-> (db/exec-one! conn [sql:profile-by-email email]) + (decode-profile-row)))) ;; --- Attrs Helpers diff --git a/backend/src/app/services/queries/projects.clj b/backend/src/app/services/queries/projects.clj index 892a122b5..1b3b36997 100644 --- a/backend/src/app/services/queries/projects.clj +++ b/backend/src/app/services/queries/projects.clj @@ -9,12 +9,12 @@ (ns app.services.queries.projects (:require - [clojure.spec.alpha :as s] - [app.common.spec :as us] [app.common.exceptions :as ex] + [app.common.spec :as us] [app.db :as db] [app.services.queries :as sq] - [app.services.queries.teams :as teams])) + [app.services.queries.teams :as teams] + [clojure.spec.alpha :as s])) ;; --- Check Project Permissions diff --git a/backend/src/app/services/queries/recent_files.clj b/backend/src/app/services/queries/recent_files.clj index 159bd3025..e52fd9ee9 100644 --- a/backend/src/app/services/queries/recent_files.clj +++ b/backend/src/app/services/queries/recent_files.clj @@ -5,18 +5,16 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2019-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.services.queries.recent-files (:require - [clojure.spec.alpha :as s] - [promesa.core :as p] - [app.db :as db] [app.common.spec :as us] + [app.db :as db] [app.services.queries :as sq] + [app.services.queries.files :refer [decode-row-xf]] [app.services.queries.teams :as teams] - [app.services.queries.projects :as projects :refer [retrieve-projects]] - [app.services.queries.files :refer [decode-row-xf]])) + [clojure.spec.alpha :as s])) (def sql:recent-files "with recent_files as ( diff --git a/backend/src/app/services/queries/teams.clj b/backend/src/app/services/queries/teams.clj index cddd8b5ab..e3fc6b85b 100644 --- a/backend/src/app/services/queries/teams.clj +++ b/backend/src/app/services/queries/teams.clj @@ -5,18 +5,16 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.services.queries.teams (:require - [clojure.spec.alpha :as s] [app.common.exceptions :as ex] [app.common.spec :as us] - [app.common.uuid :as uuid] [app.db :as db] [app.services.queries :as sq] [app.services.queries.profile :as profile] - [app.util.blob :as blob])) + [clojure.spec.alpha :as s])) ;; --- Team Edition Permissions @@ -130,3 +128,85 @@ (defn retrieve-team-members [conn team-id] (db/exec! conn [sql:team-members team-id])) + + +;; --- Query: Team Users + +(declare retrieve-users) +(declare retrieve-team-for-file) + +(s/def ::file-id ::us/uuid) +(s/def ::team-users + (s/and (s/keys :req-un [::profile-id] + :opt-un [::team-id ::file-id]) + #(or (:team-id %) (:file-id %)))) + +(sq/defquery ::team-users + [{:keys [profile-id team-id file-id]}] + (with-open [conn (db/open)] + (if team-id + (do + (check-edition-permissions! conn profile-id team-id) + (retrieve-users conn team-id)) + (let [{team-id :id} (retrieve-team-for-file conn file-id)] + (check-edition-permissions! conn profile-id team-id) + (retrieve-users conn team-id))))) + +;; This is a similar query to team members but can contain more data +;; because some user can be explicitly added to project or file (not +;; implemented in UI) + +(def sql:team-users + "select pf.id, pf.fullname, pf.photo + from profile as pf + inner join team_profile_rel as tpr on (tpr.profile_id = pf.id) + where tpr.team_id = ? + union + select pf.id, pf.fullname, pf.photo + from profile as pf + inner join project_profile_rel as ppr on (ppr.profile_id = pf.id) + inner join project as p on (ppr.project_id = p.id) + where p.team_id = ? + union + select pf.id, pf.fullname, pf.photo + from profile as pf + inner join file_profile_rel as fpr on (fpr.profile_id = pf.id) + inner join file as f on (fpr.file_id = f.id) + inner join project as p on (f.project_id = p.id) + where p.team_id = ?") + +(def sql:team-by-file + "select p.team_id as id + from project as p + join file as f on (p.id = f.project_id) + where f.id = ?") + +(defn retrieve-users + [conn team-id] + (db/exec! conn [sql:team-users team-id team-id team-id])) + +(defn retrieve-team-for-file + [conn file-id] + (->> [sql:team-by-file file-id] + (db/exec-one! conn))) + +;; --- Query: Team Stats + +(declare retrieve-team-stats) + +(s/def ::team-stats + (s/keys :req-un [::profile-id ::team-id])) + +(sq/defquery ::team-stats + [{:keys [profile-id team-id]}] + (with-open [conn (db/open)] + (check-read-permissions! conn profile-id team-id) + (retrieve-team-stats conn team-id))) + +(def sql:team-stats + "select (select count(*) from project where team_id = ?) as projects, + (select count(*) from file as f join project as p on (p.id = f.project_id) where p.team_id = ?) as files") + +(defn retrieve-team-stats + [conn team-id] + (db/exec-one! conn [sql:team-stats team-id team-id])) diff --git a/backend/src/app/services/queries/viewer.clj b/backend/src/app/services/queries/viewer.clj index bfd5b9aae..000f38362 100644 --- a/backend/src/app/services/queries/viewer.clj +++ b/backend/src/app/services/queries/viewer.clj @@ -14,6 +14,7 @@ [app.db :as db] [app.services.queries :as sq] [app.services.queries.files :as files] + [app.services.queries.teams :as teams] [clojure.spec.alpha :as s])) ;; --- Query: Viewer Bundle (by Page ID) @@ -23,7 +24,7 @@ (def ^:private sql:project - "select p.id, p.name + "select p.id, p.name, p.team_id from project as p where p.id = ? and p.deleted_at is null") @@ -35,40 +36,43 @@ (s/def ::id ::us/uuid) (s/def ::file-id ::us/uuid) (s/def ::page-id ::us/uuid) -(s/def ::share-token ::us/string) +(s/def ::token ::us/string) (s/def ::viewer-bundle (s/keys :req-un [::file-id ::page-id] - :opt-un [::profile-id ::share-token])) + :opt-un [::profile-id ::token])) (sq/defquery ::viewer-bundle - [{:keys [profile-id file-id page-id share-token] :as params}] + [{:keys [profile-id file-id page-id token] :as params}] (db/with-atomic [conn db/pool] (let [file (files/retrieve-file conn file-id) - project (retrieve-project conn (:project-id file)) page (get-in file [:data :pages-index page-id]) + file (merge (dissoc file :data) + (select-keys (:data file) [:colors :media :typographies])) + libs (files/retrieve-file-libraries conn false file-id) + users (teams/retrieve-users conn (:team-id project)) - file-library (select-keys (:data file) [:colors :media :typographies]) - bundle {:file (-> (dissoc file :data) - (merge file-library)) - :page (get-in file [:data :pages-index page-id]) - :project project} - ] - (if (string? share-token) + bundle {:file file + :page page + :users users + :project project + :libraries libs}] + + (if (string? token) (do - (check-shared-token! conn file-id page-id share-token) - (assoc bundle :share-token share-token)) - (let [token (retrieve-shared-token conn file-id page-id)] - (files/check-edition-permissions! conn profile-id file-id) - (assoc bundle :share-token token)))))) + (check-shared-token! conn file-id page-id token) + (assoc bundle :token token)) + (let [stoken (retrieve-shared-token conn file-id page-id)] + (files/check-read-permissions! conn profile-id file-id) + (assoc bundle :token (:token stoken))))))) (defn check-shared-token! [conn file-id page-id token] (let [sql "select exists(select 1 from file_share_token where file_id=? and page_id=? and token=?) as exists"] (when-not (:exists (db/exec-one! conn [sql file-id page-id token])) - (ex/raise :type :validation - :code :not-authorized)))) + (ex/raise :type :authorization + :code :unauthorized-token)))) (defn retrieve-shared-token [conn file-id page-id] diff --git a/backend/src/app/services/tokens.clj b/backend/src/app/services/tokens.clj index 4c57a3da0..fe0967156 100644 --- a/backend/src/app/services/tokens.clj +++ b/backend/src/app/services/tokens.clj @@ -10,16 +10,11 @@ (ns app.services.tokens (:require [app.common.exceptions :as ex] - [app.common.spec :as us] [app.config :as cfg] - [app.db :as db] [app.util.time :as dt] [app.util.transit :as t] - [buddy.core.codecs :as bc] [buddy.core.kdf :as bk] - [buddy.core.nonce :as bn] [buddy.sign.jwe :as jwe] - [clojure.spec.alpha :as s] [clojure.tools.logging :as log])) (defn- derive-tokens-secret diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj new file mode 100644 index 000000000..94f10626c --- /dev/null +++ b/backend/src/app/srepl/main.clj @@ -0,0 +1,6 @@ +(ns app.srepl.main + "A main namespace for server repl." + #_:clj-kondo/ignore + (:require + [clojure.pprint :refer [pprint]] + [app.db :as db])) diff --git a/backend/src/app/srepl/server.clj b/backend/src/app/srepl/server.clj new file mode 100644 index 000000000..51d23050f --- /dev/null +++ b/backend/src/app/srepl/server.clj @@ -0,0 +1,38 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.srepl.server + "Server Repl." + (:require + [app.srepl.main] + [clojure.core.server :as ccs] + [clojure.main :as cm] + [mount.core :as mount :refer [defstate]])) + +(defn- repl-init + [] + (ccs/repl-init) + (in-ns 'app.srepl.main)) + +(defn repl + [] + (cm/repl + :init repl-init + :read ccs/repl-read)) + +(defstate server + :start (ccs/start-server + {:address "127.0.0.1" + :port 6062 + :name "main" + :accept 'app.srepl.server/repl}) + :stop (ccs/stop-server "main")) + + + diff --git a/backend/src/app/tasks.clj b/backend/src/app/tasks.clj index 02b0164d4..c074358e2 100644 --- a/backend/src/app/tasks.clj +++ b/backend/src/app/tasks.clj @@ -11,7 +11,6 @@ (:require [app.common.spec :as us] [app.common.uuid :as uuid] - [app.config :as cfg] [app.db :as db] [app.metrics :as mtx] [app.util.time :as dt] diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index e09776557..d50b4e154 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -10,13 +10,11 @@ (ns app.tasks.delete-object "Generic task for permanent deletion of objects." (:require - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [app.common.exceptions :as ex] [app.common.spec :as us] [app.db :as db] [app.metrics :as mtx] - [app.util.storage :as ust])) + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log])) (s/def ::type keyword?) (s/def ::id ::us/uuid) @@ -24,10 +22,10 @@ (s/def ::props (s/keys :req-un [::id ::type])) -(defmulti handle-deletion (fn [conn props] (:type props))) +(defmulti handle-deletion (fn [_ props] (:type props))) (defmethod handle-deletion :default - [conn {:keys [type id] :as props}] + [_conn {:keys [type]}] (log/warn "no handler found for" type)) (defn handler @@ -55,13 +53,3 @@ [conn {:keys [id] :as props}] (let [sql "delete from media_object where id=? and deleted_at is not null"] (db/exec-one! conn [sql id]))) - -(defmethod handle-deletion :color - [conn {:keys [id] :as props}] - (let [sql "delete from color where id=? and deleted_at is not null"] - (db/exec-one! conn [sql id]))) - -(defmethod handle-deletion :page - [conn {:keys [id] :as props}] - (let [sql "delete from page where id=? and deleted_at is not null"] - (db/exec-one! conn [sql id]))) diff --git a/backend/src/app/tasks/delete_profile.clj b/backend/src/app/tasks/delete_profile.clj index a9304fb50..c1fe70728 100644 --- a/backend/src/app/tasks/delete_profile.clj +++ b/backend/src/app/tasks/delete_profile.clj @@ -10,13 +10,11 @@ (ns app.tasks.delete-profile "Task for permanent deletion of profiles." (:require - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [app.common.exceptions :as ex] [app.common.spec :as us] [app.db :as db] [app.metrics :as mtx] - [app.util.storage :as ust])) + [clojure.spec.alpha :as s] + [clojure.tools.logging :as log])) (declare delete-profile-data) (declare delete-teams) @@ -51,11 +49,6 @@ (delete-files conn profile-id) (delete-profile conn profile-id)) -(def ^:private sql:select-profile - "select id, is_demo, deleted_at - from profile - where id=? for update") - (def ^:private sql:remove-owned-teams "with teams as ( select distinct diff --git a/backend/src/app/tasks/maintenance.clj b/backend/src/app/tasks/maintenance.clj index c8a622021..786bdd6a6 100644 --- a/backend/src/app/tasks/maintenance.clj +++ b/backend/src/app/tasks/maintenance.clj @@ -10,8 +10,6 @@ (ns app.tasks.maintenance (:require [app.common.spec :as us] - [app.common.exceptions :as ex] - [app.config :as cfg] [app.db :as db] [app.metrics :as mtx] [app.util.time :as dt] @@ -22,6 +20,9 @@ ;; Task: Delete Executed Tasks ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; This tasks perform a cleanup of already executed tasks from the +;; database. + (s/def ::max-age ::dt/duration) (s/def ::delete-completed-tasks (s/keys :req-un [::max-age])) diff --git a/backend/src/app/tasks/remove_media.clj b/backend/src/app/tasks/remove_media.clj index f6d39df4a..2d1a52858 100644 --- a/backend/src/app/tasks/remove_media.clj +++ b/backend/src/app/tasks/remove_media.clj @@ -22,6 +22,10 @@ ;; Task: Remove Media ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Task responsible of explicit action of removing a media from file +;; system. Mainly used for profile photo change; when we really know +;; that the previous photo becomes unused. + (s/def ::path ::us/not-empty-string) (s/def ::props (s/keys :req-un [::path])) @@ -69,10 +73,10 @@ returning *") (defn trim-media-storage - [{:keys [props] :as task}] + [_task] (letfn [(decode-row [{:keys [data] :as row}] (cond-> row - (db/pgobject? data) (assoc :data (db/decode-pgobject data)))) + (db/pgobject? data) (assoc :data (db/decode-json-pgobject data)))) (retrieve-items [conn] (->> (db/exec! conn [sql:retrieve-peding-to-delete 10]) (map decode-row) diff --git a/backend/src/app/tasks/sendmail.clj b/backend/src/app/tasks/sendmail.clj index 54706b487..c078f2c1e 100644 --- a/backend/src/app/tasks/sendmail.clj +++ b/backend/src/app/tasks/sendmail.clj @@ -9,12 +9,10 @@ (ns app.tasks.sendmail (:require - [clojure.tools.logging :as log] - [app.common.data :as d] - [app.common.exceptions :as ex] - [app.util.emails :as emails] [app.config :as cfg] - [app.metrics :as mtx])) + [app.metrics :as mtx] + [app.util.emails :as emails] + [clojure.tools.logging :as log])) (defn- send-console! [config email] diff --git a/backend/src/app/tasks/trim_file.clj b/backend/src/app/tasks/trim_file.clj index b95888440..56e81777c 100644 --- a/backend/src/app/tasks/trim_file.clj +++ b/backend/src/app/tasks/trim_file.clj @@ -9,15 +9,13 @@ (ns app.tasks.trim-file (:require - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [app.common.exceptions :as ex] - [app.common.spec :as us] + [app.common.pages.migrations :as pmg] [app.config :as cfg] [app.db :as db] [app.tasks :as tasks] [app.util.blob :as blob] - [app.util.time :as dt])) + [app.util.time :as dt] + [clojure.tools.logging :as log])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Task: Trim File @@ -27,54 +25,62 @@ ;; associated with file but not used by any page. (defn decode-row - [{:keys [data metadata changes] :as row}] + [{:keys [data] :as row}] (cond-> row (bytes? data) (assoc :data (blob/decode data)))) (def sql:retrieve-files-to-trim - "select id from file as f + "select f.id, f.data + from file as f where f.has_media_trimmed is false and f.modified_at < now() - ?::interval order by f.modified_at asc limit 10") (defn retrieve-candidates + "Retrieves a list of ids of files that are candidates to be trimed. A + file is considered candidate when some time passes whith no + modification." [conn] - (let [interval (:file-trimming-max-age cfg/config)] - (->> (db/exec! conn [sql:retrieve-files-to-trim interval]) - (map :id)))) + (let [threshold (:file-trimming-threshold cfg/config) + interval (db/interval threshold)] + (db/exec! conn [sql:retrieve-files-to-trim interval]))) + +(def collect-media-xf + (comp + (map :objects) + (mapcat vals) + (filter #(= :image (:type %))) + (map :metadata) + (map :id))) (defn collect-used-media - [pages] - (let [xf (comp (filter #(= :image (:type %))) - (map :metadata) - (map :id))] - (reduce conj #{} (->> pages - (map :data) - (map :objects) - (mapcat vals) - (filter #(= :image (:type %))) - (map :metadata) - (map :id))))) + [data] + (-> #{} + (into collect-media-xf (vals (:pages-index data))) + (into collect-media-xf (vals (:components data))) + (into (keys (:media data))))) (defn process-file - [file-id] - (log/debugf "Processing file: '%s'." file-id) + [{:keys [id data] :as file}] + (log/debugf "Processing file: '%s'." id) (db/with-atomic [conn db/pool] - (let [mobjs (db/query conn :media-object {:file-id file-id}) - pages (->> (db/query conn :page {:file-id file-id}) - (map decode-row)) - used (collect-used-media pages) - unused (into #{} (comp (map :id) - (remove #(contains? used %))) mobjs)] + (let [mobjs (map :id (db/query conn :media-object {:file-id id})) + data (-> (blob/decode data) + (pmg/migrate-data)) + + used (collect-used-media data) + unused (into #{} (remove #(contains? used %)) mobjs)] + (log/debugf "Collected media ids: '%s'." (pr-str used)) (log/debugf "Unused media ids: '%s'." (pr-str unused)) (db/update! conn :file {:has-media-trimmed true} - {:id file-id}) + {:id id}) (doseq [id unused] + ;; TODO: add task batching (tasks/submit! conn {:name "delete-object" ;; :delay cfg/default-deletion-delay :delay 10000 @@ -86,7 +92,7 @@ nil))) (defn handler - [{:keys [props] :as task}] + [_task] (log/debug "Running 'trim-file' task.") (loop [] (let [files (retrieve-candidates db/pool)] diff --git a/backend/src/app/util/async.clj b/backend/src/app/util/async.clj index f455f03ca..afb221b59 100644 --- a/backend/src/app/util/async.clj +++ b/backend/src/app/util/async.clj @@ -6,9 +6,8 @@ (ns app.util.async (:require - [clojure.spec.alpha :as s] - [clojure.tools.logging :as log] - [clojure.core.async :as a]) + [clojure.core.async :as a] + [clojure.spec.alpha :as s]) (:import java.util.concurrent.Executor)) diff --git a/backend/src/app/util/data.clj b/backend/src/app/util/data.clj index d5e12cd8b..32bd107ee 100644 --- a/backend/src/app/util/data.clj +++ b/backend/src/app/util/data.clj @@ -12,7 +12,7 @@ ;; TODO: move to app.common.helpers (defn dissoc-in - [m [k & ks :as keys]] + [m [k & ks]] (if ks (if-let [nextmap (get m k)] (let [newmap (dissoc-in nextmap ks)] diff --git a/backend/src/app/util/dispatcher.clj b/backend/src/app/util/dispatcher.clj index 8aad12455..e86ae408c 100644 --- a/backend/src/app/util/dispatcher.clj +++ b/backend/src/app/util/dispatcher.clj @@ -8,14 +8,11 @@ "A generic service dispatcher implementation." (:refer-clojure :exclude [defmethod]) (:require - [clojure.spec.alpha :as s] - [expound.alpha :as expound] - [app.common.exceptions :as ex]) + [app.common.exceptions :as ex] + [clojure.spec.alpha :as s]) (:import - clojure.lang.IDeref - clojure.lang.MapEntry - java.util.Map - java.util.HashMap)) + java.util.HashMap + java.util.Map)) (definterface IDispatcher (^void add [key f])) diff --git a/backend/src/app/util/emails.clj b/backend/src/app/util/emails.clj index 2558c5207..f1371f594 100644 --- a/backend/src/app/util/emails.clj +++ b/backend/src/app/util/emails.clj @@ -5,27 +5,25 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2019-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.emails (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.util.template :as tmpl] [clojure.java.io :as io] [clojure.spec.alpha :as s] - [cuerdas.core :as str] - [app.common.spec :as us] - [app.common.exceptions :as ex] - [app.util.template :as tmpl]) + [cuerdas.core :as str]) (:import java.util.Properties - javax.mail.Message - javax.mail.Transport javax.mail.Message$RecipientType - javax.mail.PasswordAuthentication javax.mail.Session + javax.mail.Transport javax.mail.internet.InternetAddress - javax.mail.internet.MimeMultipart javax.mail.internet.MimeBodyPart - javax.mail.internet.MimeMessage)) + javax.mail.internet.MimeMessage + javax.mail.internet.MimeMultipart)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Email Building @@ -205,8 +203,7 @@ (defn- build-email-template [id context] - (let [lang (:lang context :en) - subj (render-email-template-part :subj id context) + (let [subj (render-email-template-part :subj id context) text (render-email-template-part :txt id context) html (render-email-template-part :html id context)] (when (or (not subj) diff --git a/backend/src/app/util/http.clj b/backend/src/app/util/http.clj index e2f46493b..fa8d5be28 100644 --- a/backend/src/app/util/http.clj +++ b/backend/src/app/util/http.clj @@ -2,14 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.http "Http client abstraction layer." (:require - [promesa.core :as p] - [promesa.exec :as px] - [java-http-clj.core :as http])) + [java-http-clj.core :as http] + [promesa.exec :as px])) (def default-client (delay (http/build-client {:executor @px/default-executor}))) diff --git a/backend/src/app/util/migrations.clj b/backend/src/app/util/migrations.clj index 479ffb7a2..9829082bc 100644 --- a/backend/src/app/util/migrations.clj +++ b/backend/src/app/util/migrations.clj @@ -2,13 +2,16 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.migrations (:require - [clojure.tools.logging :as log] [clojure.java.io :as io] [clojure.spec.alpha :as s] + [clojure.tools.logging :as log] [cuerdas.core :as str] [next.jdbc :as jdbc])) @@ -45,7 +48,7 @@ ((:fn migration) pool)))) (defn- impl-migrate - [conn migrations {:keys [fake] :or {fake false}}] + [conn migrations _opts] (s/assert ::migrations migrations) (let [mname (:name migrations) steps (:steps migrations)] diff --git a/backend/src/app/util/redis.clj b/backend/src/app/util/redis.clj index 1c6991132..0be8b5b46 100644 --- a/backend/src/app/util/redis.clj +++ b/backend/src/app/util/redis.clj @@ -2,14 +2,17 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.redis "Asynchronous posgresql client." (:refer-clojure :exclude [run!]) (:require - [promesa.core :as p] - [clojure.core.async :as a]) + [clojure.core.async :as a] + [promesa.core :as p]) (:import io.lettuce.core.RedisClient io.lettuce.core.RedisURI @@ -18,7 +21,6 @@ io.lettuce.core.api.StatefulRedisConnection io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.StatefulRedisPubSubConnection - io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands io.lettuce.core.pubsub.api.sync.RedisPubSubCommands )) @@ -87,7 +89,7 @@ output)) (defn subscribe - [{:keys [uri] :as client} {:keys [topic topics xform]}] + [{:keys [uri] :as client} {:keys [topics xform]}] (let [topics (if (vector? topics) (into-array String (map str topics)) (into-array String [(str topics)]))] @@ -100,7 +102,7 @@ true false)) -(defmulti impl-run (fn [conn cmd parmas] cmd)) +(defmulti impl-run (fn [_ cmd _] cmd)) (defn run! [conn cmd params] diff --git a/backend/src/app/util/sql.clj b/backend/src/app/util/sql.clj index f61e2963d..61274db15 100644 --- a/backend/src/app/util/sql.clj +++ b/backend/src/app/util/sql.clj @@ -164,7 +164,7 @@ (defn- process-param-tokens [sql] (let [cnt (java.util.concurrent.atomic.AtomicInteger. 1)] - (str/replace sql #"\?" (fn [& args] + (str/replace sql #"\?" (fn [& _args] (str "$" (.getAndIncrement cnt)))))) (def ^:private select-formatters diff --git a/backend/src/app/util/storage.clj b/backend/src/app/util/storage.clj index b4329df3c..e9b28d062 100644 --- a/backend/src/app/util/storage.clj +++ b/backend/src/app/util/storage.clj @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.storage "A local filesystem storage implementation." @@ -16,17 +16,14 @@ [clojure.java.io :as io] [clojure.spec.alpha :as s] [cuerdas.core :as str] - [datoteka.core :as fs] - [datoteka.proto :as fp]) + [datoteka.core :as fs]) (:import java.io.ByteArrayInputStream java.io.InputStream java.io.OutputStream java.net.URI - java.nio.file.Files java.nio.file.NoSuchFileException - java.nio.file.Path - java.security.MessageDigest)) + java.nio.file.Path)) (defn uri [v] @@ -54,7 +51,7 @@ (defn- transform-path [storage ^Path path] (if-let [xf (::xf storage)] - ((xf (fn [a b] b)) nil path) + ((xf (fn [_ b] b)) nil path) path)) (defn blob @@ -89,7 +86,7 @@ (normalize-path (::base-path storage)) (fs/delete)) true - (catch java.nio.file.NoSuchFileException e + (catch NoSuchFileException _e false))) (defn clear! diff --git a/backend/src/app/util/svg.clj b/backend/src/app/util/svg.clj index bb37d83b8..04d404a81 100644 --- a/backend/src/app/util/svg.clj +++ b/backend/src/app/util/svg.clj @@ -2,21 +2,23 @@ ;; 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) 2016-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.svg "Icons SVG parsing helpers." (:require - [clojure.spec.alpha :as s] - [cuerdas.core :as str] + [app.common.exceptions :as ex] [app.common.spec :as us] - [app.common.exceptions :as ex]) + [clojure.spec.alpha :as s] + [cuerdas.core :as str]) (:import org.jsoup.Jsoup org.jsoup.nodes.Attribute org.jsoup.nodes.Element - org.jsoup.nodes.Document - java.io.InputStream)) + org.jsoup.nodes.Document)) (s/def ::content string?) (s/def ::width ::us/number) @@ -65,19 +67,19 @@ content (.html element) attrs (parse-attrs element)] (assoc attrs :content content)) - (catch java.lang.IllegalArgumentException e + (catch java.lang.IllegalArgumentException _e (ex/raise :type :validation :code ::invalid-input :message "Input does not seems to be a valid svg.")) - (catch java.lang.NullPointerException e + (catch java.lang.NullPointerException _e (ex/raise :type :validation :code ::invalid-input :message "Input does not seems to be a valid svg.")) - (catch org.jsoup.UncheckedIOException e + (catch org.jsoup.UncheckedIOException _e (ex/raise :type :validation :code ::invalid-input :message "Input does not seems to be a valid svg.")) - (catch Exception e + (catch Exception _e (ex/raise :type :internal :code ::unexpected)))) diff --git a/backend/src/app/util/template.clj b/backend/src/app/util/template.clj index 0148c9894..f2dc3facd 100644 --- a/backend/src/app/util/template.clj +++ b/backend/src/app/util/template.clj @@ -2,18 +2,17 @@ ;; 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) 2016-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.template "A lightweight abstraction over mustache.java template engine. The documentation can be found: http://mustache.github.io/mustache.5.html" (:require - [clojure.tools.logging :as log] - [clojure.walk :as walk] - [clojure.java.io :as io] - [cuerdas.core :as str] - [selmer.parser :as sp] - [app.common.exceptions :as ex])) + [app.common.exceptions :as ex] + [selmer.parser :as sp])) ;; (sp/cache-off!) diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj index c855afd24..628835c33 100644 --- a/backend/src/app/util/time.clj +++ b/backend/src/app/util/time.clj @@ -5,12 +5,12 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2016-2020 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns app.util.time (:require - [clojure.spec.alpha :as s] [app.common.exceptions :as ex] + [clojure.spec.alpha :as s] [cognitect.transit :as t]) (:import java.time.Instant @@ -103,10 +103,11 @@ (letfn [(conformer [v] (cond (duration? v) v + (string? v) (try - (parse-duration v) - (catch java.time.format.DateTimeParseException e + (duration v) + (catch java.time.format.DateTimeParseException _e ::s/invalid)) :else diff --git a/backend/src/app/util/transit.clj b/backend/src/app/util/transit.clj index e188e9b04..59630313d 100644 --- a/backend/src/app/util/transit.clj +++ b/backend/src/app/util/transit.clj @@ -9,13 +9,11 @@ (ns app.util.transit (:require - [cognitect.transit :as t] - [clojure.java.io :as io] - [linked.core :as lk] - [app.util.time :as dt] - [app.util.data :as data] + [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] - [app.common.geom.matrix :as gmt]) + [app.util.time :as dt] + [cognitect.transit :as t] + [linked.core :as lk]) (:import linked.set.LinkedSet java.io.ByteArrayInputStream diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 4c0084f10..f7de49abf 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -15,12 +15,11 @@ [app.db :as db] [app.tasks.delete-object] [app.tasks.delete-profile] - [app.tasks.remove-media] [app.tasks.maintenance] + [app.tasks.remove-media] [app.tasks.sendmail] [app.tasks.trim-file] [app.util.async :as aa] - [app.util.blob :as blob] [app.util.time :as dt] [clojure.core.async :as a] [clojure.spec.alpha :as s] @@ -31,10 +30,7 @@ org.eclipse.jetty.util.thread.QueuedThreadPool java.util.concurrent.ExecutorService java.util.concurrent.Executors - java.util.concurrent.Executor - java.time.Duration - java.time.Instant - java.util.Date)) + java.time.Instant)) (declare start-scheduler-worker!) (declare start-worker!) @@ -61,19 +57,18 @@ :fn #'app.tasks.trim-file/handler} {:id "maintenance/delete-executed-tasks" - :cron #app/cron "0 0 */1 * * ?" ;; hourly + :cron #app/cron "0 0 0 */1 * ?" ;; daily :fn #'app.tasks.maintenance/delete-executed-tasks - :props {:max-age #app/duration "48h"}} + :props {:max-age #app/duration "24h"}} {:id "maintenance/delete-old-files-xlog" - :cron #app/cron "0 0 */1 * * ?" ;; hourly + :cron #app/cron "0 0 0 */1 * ?" ;; daily :fn #'app.tasks.maintenance/delete-old-files-xlog - :props {:max-age #app/duration "8h"}} + :props {:max-age #app/duration "12h"}} ]) (defstate executor - :start (thread-pool {:idle-timeout 10000 - :min-threads 0 + :start (thread-pool {:min-threads 0 :max-threads 256}) :stop (stop! executor)) @@ -149,7 +144,7 @@ nil)))) (defn- run-task - [{:keys [tasks conn]} item] + [{:keys [tasks]} item] (try (log/debugf "Started task '%s/%s/%s'." (:name item) (:id item) (:retry-num item)) (handle-task tasks item) @@ -187,7 +182,7 @@ for update skip locked") (defn- event-loop-fn* - [{:keys [tasks executor batch-size] :as opts}] + [{:keys [executor batch-size] :as opts}] (db/with-atomic [conn db/pool] (let [queue (:queue opts "default") items (->> (db/exec! conn [sql:select-next-tasks queue batch-size]) @@ -222,7 +217,7 @@ :opt-un [::poll-interval])) (defn start-worker! - [{:keys [poll-interval executor] + [{:keys [poll-interval] :or {poll-interval 5000} :as opts}] (us/assert ::start-worker-params opts) @@ -290,7 +285,7 @@ do update set cron_expr=?") (defn- synchronize-schedule-item - [conn {:keys [id cron] :as item}] + [conn {:keys [id cron]}] (let [cron (str cron)] (log/debugf "Initialize scheduled task '%s' (cron: '%s')." id cron) (db/exec-one! conn [sql:upsert-scheduled-task id cron cron]))) @@ -311,7 +306,7 @@ (.printStackTrace ^Throwable error (java.io.PrintWriter. *out*)))) (defn- execute-scheduled-task - [{:keys [scheduler executor] :as opts} {:keys [id cron] :as task}] + [{:keys [executor] :as opts} {:keys [id] :as task}] (letfn [(run-task [conn] (try (when (db/exec-one! conn [sql:lock-scheduled-task id]) @@ -384,8 +379,8 @@ (defn thread-pool ([] (thread-pool {})) - ([{:keys [min-threads max-threads idle-timeout name] - :or {min-threads 0 max-threads 128 idle-timeout 60000}}] + ([{:keys [min-threads max-threads name] + :or {min-threads 0 max-threads 256}}] (let [executor (QueuedThreadPool. max-threads min-threads)] (.setName executor (or name "default-tp")) (.start executor) diff --git a/backend/tests/app/tests/helpers.clj b/backend/tests/app/tests/helpers.clj index c99294344..4ff4232df 100644 --- a/backend/tests/app/tests/helpers.clj +++ b/backend/tests/app/tests/helpers.clj @@ -17,7 +17,7 @@ [mount.core :as mount] [environ.core :refer [env]] [app.common.pages :as cp] - [app.services.init] + [app.services] [app.services.mutations.profile :as profile] [app.services.mutations.projects :as projects] [app.services.mutations.teams :as teams] @@ -36,9 +36,9 @@ [] (doto (PGSimpleDataSource.) (.setServerName "postgres") - (.setDatabaseName "uxbox_test") - (.setUser "uxbox") - (.setPassword "uxbox"))) + (.setDatabaseName "penpot_test") + (.setUser "penpot") + (.setPassword "penpot"))) (defn state-init [next] @@ -50,8 +50,8 @@ #'app.redis/client #'app.redis/conn #'app.media/semaphore - #'app.services.init/query-services - #'app.services.init/mutation-services + #'app.services/query-services + #'app.services/mutation-services #'app.migrations/migrations #'app.media-storage/assets-storage #'app.media-storage/media-storage}) @@ -91,7 +91,8 @@ (let [params {:id (mk-uuid "profile" i) :fullname (str "Profile " i) :email (str "profile" i ".test@nodomain.com") - :password "123123"}] + :password "123123" + :demo? true}] (->> (#'profile/create-profile conn params) (#'profile/create-profile-relations conn)))) diff --git a/backend/tests/app/tests/test_common_geom.clj b/backend/tests/app/tests/test_common_geom.clj new file mode 100644 index 000000000..a333c3275 --- /dev/null +++ b/backend/tests/app/tests/test_common_geom.clj @@ -0,0 +1,94 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.tests.test_common_geom + (:require + [clojure.test :as t] + [app.common.geom.point :as gpt] + [app.common.geom.matrix :as gmt])) + +(t/deftest point-constructors-test + (t/testing "Create point with both coordinates" + (let [p (gpt/point 1 2)] + (t/is (= (:x p) 1)) + (t/is (= (:y p) 2)))) + + (t/testing "Create point with single coordinate" + (let [p (gpt/point 1)] + (t/is (= (:x p) 1)) + (t/is (= (:y p) 1)))) + + (t/testing "Create empty point" + (let [p (gpt/point)] + (t/is (= (:x p) 0)) + (t/is (= (:y p) 0))))) + +(t/deftest point-add-test + (t/testing "Adds two points together" + (let [p1 (gpt/point 1 1) + p2 (gpt/point 2 2) + p3 (gpt/add p1 p2)] + (t/is (= (:x p3) 3)) + (t/is (= (:y p3) 3))))) + +(t/deftest point-subtract-test + (t/testing "Point substraction" + (let [p1 (gpt/point 3 3) + p2 (gpt/point 2 2) + p3 (gpt/subtract p1 p2)] + (t/is (= (:x p3) 1)) + (t/is (= (:y p3) 1))))) + +(t/deftest point-distance-test + (let [p1 (gpt/point 0 0) + p2 (gpt/point 10 0) + d (gpt/distance p1 p2)] + (t/is (number? d)) + (t/is (= d 10.0)))) + +(t/deftest point-length-test + (let [p1 (gpt/point 10 0) + ln (gpt/length p1)] + (t/is (number? ln)) + (t/is (= ln 10.0)))) + +(t/deftest point-angle-test + (t/testing "Get angle a 90 degree angle" + (let [p1 (gpt/point 0 10) + angle (gpt/angle p1)] + (t/is (number? angle)) + (t/is (= angle 90.0)))) + + (t/testing "Get 45 degree angle" + (let [p1 (gpt/point 0 10) + p2 (gpt/point 10 10) + angle (gpt/angle-with-other p1 p2)] + (t/is (number? angle)) + (t/is (= angle 45.0))))) + +(t/deftest matrix-constructors-test + (let [m (gmt/matrix)] + (t/is (= (str m) "matrix(1,0,0,1,0,0)"))) + (let [m (gmt/matrix 1 1 1 2 2 2)] + (t/is (= (str m) "matrix(1,1,1,2,2,2)")))) + +(t/deftest matrix-translate-test + (let [m (-> (gmt/matrix) + (gmt/translate (gpt/point 2 10)))] + (t/is (= (str m) "matrix(1,0,0,1,2,10)")))) + +(t/deftest matrix-scale-test + (let [m (-> (gmt/matrix) + (gmt/scale (gpt/point 2)))] + (t/is (= (str m) "matrix(2,0,0,2,0,0)")))) + +(t/deftest matrix-rotate-test + (let [m (-> (gmt/matrix) + (gmt/rotate 10))] + (t/is (= (str m) "matrix(0.984807753012208,0.17364817766693033,-0.17364817766693033,0.984807753012208,0,0)")))) diff --git a/backend/tests/app/tests/test_common_geom_shapes.clj b/backend/tests/app/tests/test_common_geom_shapes.clj new file mode 100644 index 000000000..96fb1e669 --- /dev/null +++ b/backend/tests/app/tests/test_common_geom_shapes.clj @@ -0,0 +1,179 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.tests.test-common-geom-shapes + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.pages :refer [make-minimal-shape]] + [clojure.test :as t])) + +(def default-path [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 20 :y 20}} + {:command :line-to :params {:x 30 :y 30}} + {:command :curve-to :params {:x 40 :y 40 :c1x 35 :c1y 35 :c2x 45 :c2y 45}} + {:command :close-path}]) + +(defn add-path-data [shape] + (let [content (:content shape default-path) + selrect (gsh/content->selrect content) + points (gsh/rect->points selrect)] + (assoc shape + :content content + :selrect selrect + :points points))) + +(defn add-rect-data [shape] + (let [selrect (gsh/rect->selrect shape) + points (gsh/rect->points selrect)] + (assoc shape + :selrect selrect + :points points))) + +(defn create-test-shape + ([type] (create-test-shape type {})) + ([type params] + (-> (make-minimal-shape type) + (merge params) + (cond-> + (= type :path) (add-path-data) + (not= type :path) (add-rect-data))))) + + +(t/deftest transform-shape-tests + (t/testing "Shape without modifiers should stay the same" + (t/are [type] + (let [shape-before (create-test-shape type) + shape-after (gsh/transform-shape shape-before)] + (= shape-before shape-after)) + + :rect :path)) + + + (t/testing "Transform shape with translation modifiers" + (t/are [type] + (let [modifiers {:displacement (gmt/translate-matrix (gpt/point 10 -10))}] + (let [shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/is (not= shape-before shape-after)) + + (t/is (== (get-in shape-before [:selrect :x]) + (- 10 (get-in shape-after [:selrect :x])))) + + (t/is (== (get-in shape-before [:selrect :y]) + (+ 10 (get-in shape-after [:selrect :y])))) + + (t/is (== (get-in shape-before [:selrect :width]) + (get-in shape-after [:selrect :width]))) + + (t/is (== (get-in shape-before [:selrect :height]) + (get-in shape-after [:selrect :height]))))) + + :rect :path)) + + (t/testing "Transform with empty translation" + (t/are [type] + (let [modifiers {:displacement (gmt/matrix)} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/are [prop] + (t/is (== (get-in shape-before [:selrect prop]) + (get-in shape-after [:selrect prop]))) + :x :y :width :height :x1 :y1 :x2 :y2)) + :rect :path)) + + (t/testing "Transform shape with resize modifiers" + (t/are [type] + (let [modifiers {:resize-origin (gpt/point 0 0) + :resize-vector (gpt/point 2 2) + :resize-transform (gmt/matrix)} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/is (not= shape-before shape-after)) + + (t/is (== (get-in shape-before [:selrect :x]) + (get-in shape-after [:selrect :x]))) + + (t/is (== (get-in shape-before [:selrect :y]) + (get-in shape-after [:selrect :y]))) + + (t/is (== (* 2 (get-in shape-before [:selrect :width])) + (get-in shape-after [:selrect :width]))) + + (t/is (== (* 2 (get-in shape-before [:selrect :height])) + (get-in shape-after [:selrect :height])))) + :rect :path)) + + (t/testing "Transform with empty resize" + (t/are [type] + (let [modifiers {:resize-origin (gpt/point 0 0) + :resize-vector (gpt/point 1 1) + :resize-transform (gmt/matrix)} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/are [prop] + (t/is (== (get-in shape-before [:selrect prop]) + (get-in shape-after [:selrect prop]))) + :x :y :width :height :x1 :y1 :x2 :y2)) + :rect :path)) + + (t/testing "Transform with resize=0" + (t/are [type] + (let [modifiers {:resize-origin (gpt/point 0 0) + :resize-vector (gpt/point 0 0) + :resize-transform (gmt/matrix)} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/is (> (get-in shape-before [:selrect :width]) + (get-in shape-after [:selrect :width]))) + (t/is (> (get-in shape-after [:selrect :width]) 0)) + + (t/is (> (get-in shape-before [:selrect :height]) + (get-in shape-after [:selrect :height]))) + (t/is (> (get-in shape-after [:selrect :height]) 0))) + :rect :path)) + + (t/testing "Transform shape with rotation modifiers" + (t/are [type] + (let [modifiers {:rotation 30} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/is (not= shape-before shape-after)) + + (t/is (not (== (get-in shape-before [:selrect :x]) + (get-in shape-after [:selrect :x])))) + + (t/is (not (== (get-in shape-before [:selrect :y]) + (get-in shape-after [:selrect :y]))))) + :rect :path)) + + (t/testing "Transform shape with rotation = 0 should leave equal selrect" + (t/are [type] + (let [modifiers {:rotation 0} + shape-before (create-test-shape type {:modifiers modifiers}) + shape-after (gsh/transform-shape shape-before)] + (t/are [prop] + (t/is (== (get-in shape-before [:selrect prop]) + (get-in shape-after [:selrect prop]))) + :x :y :width :height :x1 :y1 :x2 :y2)) + :rect :path)) + + (t/testing "Transform shape with invalid selrect fails gracefuly" + (t/are [type selrect] + (let [modifiers {:displacement (gmt/matrix)} + shape-before (-> (create-test-shape type {:modifiers modifiers}) + (assoc :selrect selrect)) + shape-after (gsh/transform-shape shape-before)] + (= (:selrect shape-before) (:selrect shape-after))) + + :rect {:x 0 :y 0 :width ##Inf :height ##Inf} + :path {:x 0 :y 0 :width ##Inf :height ##Inf} + :rect nil + :path nil))) diff --git a/backend/tests/app/tests/test_emails.clj b/backend/tests/app/tests/test_emails.clj index a4c318f41..c06315deb 100644 --- a/backend/tests/app/tests/test_emails.clj +++ b/backend/tests/app/tests/test_emails.clj @@ -23,10 +23,10 @@ (let [result (emails/render emails/register {:to "example@app.io" :name "foo"})] (t/is (map? result)) (t/is (contains? result :subject)) - (t/is (contains? result :content)) + (t/is (contains? result :body)) (t/is (contains? result :to)) - (t/is (contains? result :reply-to)) - (t/is (vector? (:content result))))) + #_(t/is (contains? result :reply-to)) + (t/is (vector? (:body result))))) ;; (t/deftest email-sending-and-sendmail-job ;; (let [res @(emails/send! emails/register {:to "example@app.io" :name "foo"})] diff --git a/backend/tests/app/tests/test_services_media.clj b/backend/tests/app/tests/test_services_media.clj index 2680b6379..8b56c48e2 100644 --- a/backend/tests/app/tests/test_services_media.clj +++ b/backend/tests/app/tests/test_services_media.clj @@ -78,13 +78,13 @@ (t/is (string? (get-in out [:result :path]))) (t/is (string? (get-in out [:result :thumb-path]))))) - (t/testing "list media objects by file" + #_(t/testing "list media objects by file" (let [data {::sq/type :media-objects :profile-id (:id prof) :file-id (:id file) :is-local true} out (th/try-on! (sq/handle data))] - ;; (th/print-result! out) + (th/print-result! out) ;; Result is ordered by creation date descendent (t/is (= object-id-2 (get-in out [:result 0 :id]))) @@ -96,7 +96,7 @@ (t/is (string? (get-in out [:result 0 :path]))) (t/is (string? (get-in out [:result 0 :thumb-path]))))) - (t/testing "single media object" + #_(t/testing "single media object" (let [data {::sq/type :media-object :profile-id (:id prof) :id object-id-2} @@ -111,7 +111,7 @@ (t/is (string? (get-in out [:result :path]))))) - (t/testing "delete media objects" + #_(t/testing "delete media objects" (let [data {::sm/type :delete-media-object :profile-id (:id prof) :id object-id-1} @@ -121,7 +121,7 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out))))) - (t/testing "query media object after delete" + #_(t/testing "query media object after delete" (let [data {::sq/type :media-object :profile-id (:id prof) :id object-id-1} @@ -136,7 +136,7 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found))))) - (t/testing "query media objects after delete" + #_(t/testing "query media objects after delete" (let [data {::sq/type :media-objects :profile-id (:id prof) :file-id (:id file) diff --git a/backend/tests/app/tests/test_services_profile.clj b/backend/tests/app/tests/test_services_profile.clj index 6bae6de63..57e309b67 100644 --- a/backend/tests/app/tests/test_services_profile.clj +++ b/backend/tests/app/tests/test_services_profile.clj @@ -40,7 +40,7 @@ (let [error (ex-cause (:error out))] (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation)) - (t/is (th/ex-of-code? error :app.services.mutations.profile/wrong-credentials))))) + (t/is (th/ex-of-code? error :wrong-credentials))))) (t/testing "success" (let [event {::sm/type :login diff --git a/backend/tests/app/tests/test_services_viewer.clj b/backend/tests/app/tests/test_services_viewer.clj index c3b16938f..4b582eda1 100644 --- a/backend/tests/app/tests/test_services_viewer.clj +++ b/backend/tests/app/tests/test_services_viewer.clj @@ -43,7 +43,7 @@ (t/is (nil? (:error out))) (let [result (:result out)] - (t/is (contains? result :share-token)) + (t/is (contains? result :token)) (t/is (contains? result :page)) (t/is (contains? result :file)) (t/is (contains? result :project))))) @@ -78,12 +78,13 @@ (let [error (ex-cause (:error out)) error-data (ex-data error)] (t/is (th/ex-info? error)) - (t/is (= (:type error-data) :not-found))))) + (t/is (= (:type error-data) :validation)) + (t/is (= (:code error-data) :not-authorized))))) (t/testing "authenticated with token & profile" (let [data {::sq/type :viewer-bundle :profile-id (:id prof2) - :share-token @token + :token @token :file-id (:id file) :page-id (get-in file [:data :pages 0])} out (th/try-on! (sq/handle data))] @@ -97,7 +98,7 @@ (t/testing "authenticated with token" (let [data {::sq/type :viewer-bundle - :share-token @token + :token @token :file-id (:id file) :page-id (get-in file [:data :pages 0])} out (th/try-on! (sq/handle data))] diff --git a/common/app/common/attrs.cljc b/common/app/common/attrs.cljc new file mode 100644 index 000000000..9008c9ae1 --- /dev/null +++ b/common/app/common/attrs.cljc @@ -0,0 +1,71 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.attrs) + +(defn get-attrs-multi + [shapes attrs] + ;; Extract some attributes of a list of shapes. + ;; For each attribute, if the value is the same in all shapes, + ;; wll take this value. If there is any shape that is different, + ;; the value of the attribute will be the keyword :multiple. + ;; + ;; If some shape has the value nil in any attribute, it's + ;; considered a different value. If the shape does not contain + ;; the attribute, it's ignored in the final result. + ;; + ;; Example: + ;; (def shapes [{:stroke-color "#ff0000" + ;; :stroke-width 3 + ;; :fill-color "#0000ff" + ;; :x 1000 :y 2000 :rx nil} + ;; {:stroke-width "#ff0000" + ;; :stroke-width 5 + ;; :x 1500 :y 2000}]) + ;; + ;; (get-attrs-multi shapes [:stroke-color + ;; :stroke-width + ;; :fill-color + ;; :rx + ;; :ry]) + ;; >>> {:stroke-color "#ff0000" + ;; :stroke-width :multiple + ;; :fill-color "#0000ff" + ;; :rx nil + ;; :ry nil} + ;; + (let [defined-shapes (filter some? shapes) + + combine-value (fn [v1 v2] (cond + (= v1 v2) v1 + (= v1 :undefined) v2 + (= v2 :undefined) v1 + :else :multiple)) + + combine-values (fn [attrs shape values] + (map #(combine-value (get shape % :undefined) + (get values % :undefined)) attrs)) + + select-attrs (fn [shape attrs] + (zipmap attrs (map #(get shape % :undefined) attrs))) + + reducer (fn [result shape] + (zipmap attrs (combine-values attrs shape result))) + + combined (reduce reducer + (select-attrs (first defined-shapes) attrs) + (rest defined-shapes)) + + cleanup-value (fn [value] + (if (= value :undefined) nil value)) + + cleanup (fn [result] + (zipmap attrs (map #(cleanup-value (get result %)) attrs)))] + + (cleanup combined))) diff --git a/common/app/common/data.cljc b/common/app/common/data.cljc index 3034e369b..99a643129 100644 --- a/common/app/common/data.cljc +++ b/common/app/common/data.cljc @@ -6,13 +6,17 @@ (ns app.common.data "Data manipulation and query helper functions." - (:refer-clojure :exclude [concat read-string hash-map]) - (:require [clojure.set :as set] - [linked.set :as lks] - #?(:cljs [cljs.reader :as r] - :clj [clojure.edn :as r]) - #?(:cljs [cljs.core :as core] - :clj [clojure.core :as core])) + (:refer-clojure :exclude [concat read-string hash-map merge]) + #?(:cljs + (:require-macros [app.common.data])) + (:require + [linked.set :as lks] + [app.common.math :as mth] + #?(:clj [cljs.analyzer.api :as aapi]) + #?(:cljs [cljs.reader :as r] + :clj [clojure.edn :as r]) + #?(:cljs [cljs.core :as core] + :clj [clojure.core :as core])) #?(:clj (:import linked.set.LinkedSet))) @@ -35,7 +39,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn dissoc-in - [m [k & ks :as keys]] + [m [k & ks]] (if ks (if-let [nextmap (get m k)] (let [newmap (dissoc-in nextmap ks)] @@ -85,7 +89,7 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) + (loop [c (first coll) coll (rest coll) index 0] (if (nil? c) @@ -206,6 +210,17 @@ (assoc m key v) m))) +(defn merge + "A faster merge." + [& maps] + (loop [res (transient (or (first maps) {})) + maps (next maps)] + (if (nil? maps) + (persistent! res) + (recur (reduce-kv assoc! res (first maps)) + (next maps))))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Parsing / Conversion ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -219,7 +234,7 @@ #?(:cljs (js/parseInt v 10) :clj (try (Integer/parseInt v) - (catch Throwable e + (catch Throwable _ nil)))) (defn- impl-parse-double @@ -227,7 +242,7 @@ #?(:cljs (js/parseFloat v) :clj (try (Double/parseDouble v) - (catch Throwable e + (catch Throwable _ nil)))) (defn parse-integer @@ -261,3 +276,59 @@ (defn coalesce [val default] (or val default)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Data Parsing / Conversion +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn nilf + "Returns a new function that if you pass nil as any argument will + return nil" + [f] + (fn [& args] + (if (some nil? args) + nil + (apply f args)))) + +(defn check-num + "Function that checks if a number is nil or nan. Will return 0 when not + valid and the number otherwise." + [v] + (if (or (not v) (mth/nan? v)) 0 v)) + + +(defmacro export + "A helper macro that allows reexport a var in a current namespace." + [v] + (if (boolean (:ns &env)) + + ;; Code for ClojureScript + (let [mdata (aapi/resolve &env v) + arglists (second (get-in mdata [:meta :arglists])) + sym (symbol (name v)) + andsym (symbol "&") + procarg #(if (= % andsym) % (gensym "param"))] + (if (pos? (count arglists)) + `(def + ~(with-meta sym (:meta mdata)) + (fn ~@(for [args arglists] + (let [args (map procarg args)] + (if (some #(= andsym %) args) + (let [[sargs dargs] (split-with #(not= andsym %) args)] + `([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs)))) + `([~@args] (~v ~@args))))))) + `(def ~(with-meta sym (:meta mdata)) ~v))) + + ;; Code for Clojure + (let [vr (resolve v) + m (meta vr) + n (:name m) + n (with-meta n + (cond-> {} + (:dynamic m) (assoc :dynamic true) + (:protocol m) (assoc :protocol (:protocol m))))] + `(let [m# (meta ~vr)] + (def ~n (deref ~vr)) + (alter-meta! (var ~n) merge (dissoc m# :name)) + ;; (when (:macro m#) + ;; (.setMacro (var ~n))) + ~vr)))) diff --git a/common/app/common/exceptions.cljc b/common/app/common/exceptions.cljc index 2abd01426..389178255 100644 --- a/common/app/common/exceptions.cljc +++ b/common/app/common/exceptions.cljc @@ -6,6 +6,8 @@ (ns app.common.exceptions "A helpers for work with exceptions." + #?(:cljs + (:require-macros [app.common.exceptions])) (:require [clojure.spec.alpha :as s])) (s/def ::type keyword?) @@ -22,7 +24,7 @@ ::cause])) (defn error - [& {:keys [type code message hint cause] :as params}] + [& {:keys [message hint cause] :as params}] (s/assert ::error-params params) (let [message (or message hint "") payload (dissoc params :cause)] @@ -46,3 +48,7 @@ (defmacro try [& exprs] `(try* (^:once fn* [] ~@exprs) identity)) + +(defn ex-info? + [v] + (instance? #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) v)) diff --git a/common/app/common/geom/align.cljc b/common/app/common/geom/align.cljc new file mode 100644 index 000000000..f06bcb4bd --- /dev/null +++ b/common/app/common/geom/align.cljc @@ -0,0 +1,151 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.align + (:require + [clojure.spec.alpha :as s] + [app.common.geom.shapes :as gsh] + [app.common.data :as d])) + +;; --- Alignment + +(s/def ::align-axis #{:hleft :hcenter :hright :vtop :vcenter :vbottom}) + +(declare calc-align-pos) + +;; Duplicated from pages/helpers to remove cyclic dependencies +(defn- get-children [id objects] + (let [shapes (vec (get-in objects [id :shapes]))] + (if shapes + (d/concat shapes (mapcat #(get-children % objects) shapes)) + []))) + +(defn- recursive-move + "Move the shape and all its recursive children." + [shape dpoint objects] + (let [children-ids (get-children (:id shape) objects) + children (map #(get objects %) children-ids)] + (map #(gsh/move % dpoint) (cons shape children)))) + +(defn align-to-rect + "Move the shape so that it is aligned with the given rectangle + in the given axis. Take account the form of the shape and the + possible rotation. What is aligned is the rectangle that wraps + the shape with the given rectangle. If the shape is a group, + move also all of its recursive children." + [shape rect axis objects] + (let [wrapper-rect (gsh/selection-rect [shape]) + align-pos (calc-align-pos wrapper-rect rect axis) + delta {:x (- (:x align-pos) (:x wrapper-rect)) + :y (- (:y align-pos) (:y wrapper-rect))}] + (recursive-move shape delta objects))) + +(defn calc-align-pos + [wrapper-rect rect axis] + (case axis + :hleft (let [left (:x rect)] + {:x left + :y (:y wrapper-rect)}) + + :hcenter (let [center (+ (:x rect) (/ (:width rect) 2))] + {:x (- center (/ (:width wrapper-rect) 2)) + :y (:y wrapper-rect)}) + + :hright (let [right (+ (:x rect) (:width rect))] + {:x (- right (:width wrapper-rect)) + :y (:y wrapper-rect)}) + + :vtop (let [top (:y rect)] + {:x (:x wrapper-rect) + :y top}) + + :vcenter (let [center (+ (:y rect) (/ (:height rect) 2))] + {:x (:x wrapper-rect) + :y (- center (/ (:height wrapper-rect) 2))}) + + :vbottom (let [bottom (+ (:y rect) (:height rect))] + {:x (:x wrapper-rect) + :y (- bottom (:height wrapper-rect))}))) + +;; --- Distribute + +(s/def ::dist-axis #{:horizontal :vertical}) + +(defn distribute-space + "Distribute equally the space between shapes in the given axis. If + there is no space enough, it does nothing. It takes into account + the form of the shape and the rotation, what is distributed is + the wrapping recangles of the shapes. If any shape is a group, + move also all of its recursive children." + [shapes axis objects] + (let [coord (if (= axis :horizontal) :x :y) + other-coord (if (= axis :horizontal) :y :x) + size (if (= axis :horizontal) :width :height) + ; The rectangle that wraps the whole selection + wrapper-rect (gsh/selection-rect shapes) + ; Sort shapes by the center point in the given axis + sorted-shapes (sort-by #(coord (gsh/center-shape %)) shapes) + ; Each shape wrapped in its own rectangle + wrapped-shapes (map #(gsh/selection-rect [%]) sorted-shapes) + ; The total space between shapes + space (reduce - (size wrapper-rect) (map size wrapped-shapes))] + + (if (<= space 0) + shapes + (let [unit-space (/ space (- (count wrapped-shapes) 1)) + ; Calculate the distance we need to move each shape. + ; The new position of each one is the position of the + ; previous one plus its size plus the unit space. + deltas (loop [shapes' wrapped-shapes + start-pos (coord wrapper-rect) + deltas []] + + (let [first-shape (first shapes') + delta (- start-pos (coord first-shape)) + new-pos (+ start-pos (size first-shape) unit-space)] + + (if (= (count shapes') 1) + (conj deltas delta) + (recur (rest shapes') + new-pos + (conj deltas delta)))))] + + (mapcat #(recursive-move %1 {coord %2 other-coord 0} objects) + sorted-shapes deltas))))) + +;; Adjusto to viewport + +(defn adjust-to-viewport + ([viewport srect] (adjust-to-viewport viewport srect nil)) + ([viewport srect {:keys [padding] :or {padding 0}}] + (let [gprop (/ (:width viewport) (:height viewport)) + srect (-> srect + (update :x #(- % padding)) + (update :y #(- % padding)) + (update :width #(+ % padding padding)) + (update :height #(+ % padding padding))) + width (:width srect) + height (:height srect) + lprop (/ width height)] + (cond + (> gprop lprop) + (let [width' (* (/ width lprop) gprop) + padding (/ (- width' width) 2)] + (-> srect + (update :x #(- % padding)) + (assoc :width width'))) + + (< gprop lprop) + (let [height' (/ (* height lprop) gprop) + padding (/ (- height' height) 2)] + (-> srect + (update :y #(- % padding)) + (assoc :height height'))) + + :else srect)))) diff --git a/common/app/common/geom/matrix.cljc b/common/app/common/geom/matrix.cljc index f3b9a0007..737acc78c 100644 --- a/common/app/common/geom/matrix.cljc +++ b/common/app/common/geom/matrix.cljc @@ -9,7 +9,6 @@ (ns app.common.geom.matrix (:require - [cuerdas.core :as str] [app.common.math :as mth] [app.common.geom.point :as gpt])) @@ -21,8 +20,8 @@ (str "matrix(" a "," b "," c "," d "," e "," f ")"))) (defn multiply - ([{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f :as m1} - {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f :as m2}] + ([{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f} + {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f}] (Matrix. (+ (* m1a m2a) (* m1c m2b)) (+ (* m1b m2a) (* m1d m2b)) @@ -34,8 +33,8 @@ (reduce multiply (multiply m1 m2) others))) (defn substract - [{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f :as m1} - {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f :as m2}] + [{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f} + {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f}] (Matrix. (- m1a m2a) (- m1b m2b) (- m1c m2c) (- m1d m2d) (- m1e m2e) (- m1f m2f))) @@ -88,7 +87,7 @@ (defn skew-matrix ([angle-x angle-y point] (multiply (translate-matrix point) - (skew-matrix angle-y angle-y) + (skew-matrix angle-x angle-y) (translate-matrix (gpt/negate point)))) ([angle-x angle-y] (let [m1 (mth/tan (mth/radians angle-x)) @@ -121,3 +120,13 @@ ([m angle-x angle-y p] (multiply m (skew-matrix angle-x angle-y p)))) +(defn m-equal [m1 m2 threshold] + (let [th-eq (fn [a b] (<= (mth/abs (- a b)) threshold)) + {m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f} m1 + {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f} m2] + (and (th-eq m1a m2a) + (th-eq m1b m2b) + (th-eq m1c m2c) + (th-eq m1d m2d) + (th-eq m1e m2e) + (th-eq m1f m2f)))) diff --git a/common/app/common/geom/point.cljc b/common/app/common/geom/point.cljc index 65b453b56..7fd2fe621 100644 --- a/common/app/common/geom/point.cljc +++ b/common/app/common/geom/point.cljc @@ -12,7 +12,6 @@ (:require #?(:cljs [cljs.core :as c] :clj [clojure.core :as c]) - [cuerdas.core :as str] [app.common.math :as mth])) ;; --- Point Impl @@ -26,6 +25,14 @@ [v] (instance? Point v)) +(defn ^boolean point-like? + [{:keys [x y] :as v}] + (and (map? v) + (not (nil? x)) + (not (nil? y)) + (number? x) + (number? y))) + (defn point "Create a Point instance." ([] (Point. 0 0)) @@ -37,9 +44,20 @@ (number? v) (Point. v v) + (point-like? v) + (Point. (:x v) (:y v)) + :else (throw (ex-info "Invalid arguments" {:v v})))) - ([x y] (Point. x y))) + ([x y] + ;;(assert (not (nil? x))) + ;;(assert (not (nil? y))) + (Point. x y))) + +(defn angle->point [{:keys [x y]} angle distance] + (point + (+ x (* distance (mth/cos angle))) + (- y (* distance (mth/sin angle))))) (defn add "Returns the addition of the supplied value to both @@ -134,14 +152,18 @@ (assert (point? p)) (assert (point? other)) - (let [a (/ (+ (* x ox) - (* y oy)) - (* (length p) - (length other))) - a (mth/acos (if (< a -1) -1 (if (> a 1) 1 a))) - d (-> (mth/degrees a) - (mth/precision 6))] - (if (mth/nan? d) 0 d))) + (let [length-p (length p) + length-other (length other)] + (if (or (mth/almost-zero? length-p) + (mth/almost-zero? length-other)) + 0 + (let [a (/ (+ (* x ox) + (* y oy)) + (* length-p length-other)) + a (mth/acos (if (< a -1) -1 (if (> a 1) 1 a))) + d (-> (mth/degrees a) + (mth/precision 6))] + (if (mth/nan? d) 0 d))))) (defn update-angle @@ -173,7 +195,7 @@ (defn transform "Transform a point applying a matrix transfomation." - [{:keys [x y] :as p} {:keys [a b c d e f] :as m}] + [{:keys [x y] :as p} {:keys [a b c d e f]}] (assert (point? p)) (Point. (+ (* x a) (* y c) e) (+ (* x b) (* y d) f))) diff --git a/common/app/common/geom/proportions.cljc b/common/app/common/geom/proportions.cljc new file mode 100644 index 000000000..8fe1bf763 --- /dev/null +++ b/common/app/common/geom/proportions.cljc @@ -0,0 +1,52 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.proportions) + +;; --- Proportions + +(declare assign-proportions-path) +(declare assign-proportions-rect) + +(defn assign-proportions + [{:keys [type] :as shape}] + (case type + :path (assign-proportions-path shape) + (assign-proportions-rect shape))) + +(defn- assign-proportions-rect + [{:keys [width height] :as shape}] + (assoc shape :proportion (/ width height))) + + +;; --- Setup Proportions + +(declare setup-proportions-const) +(declare setup-proportions-image) + +(defn setup-proportions + [shape] + (case (:type shape) + :icon (setup-proportions-image shape) + :image (setup-proportions-image shape) + :text shape + (setup-proportions-const shape))) + +(defn setup-proportions-image + [{:keys [metadata] :as shape}] + (let [{:keys [width height]} metadata] + (assoc shape + :proportion (/ width height) + :proportion-lock false))) + +(defn setup-proportions-const + [shape] + (assoc shape + :proportion 1 + :proportion-lock false)) diff --git a/common/app/common/geom/shapes.cljc b/common/app/common/geom/shapes.cljc index b191ed5bf..1ddb96bb4 100644 --- a/common/app/common/geom/shapes.cljc +++ b/common/app/common/geom/shapes.cljc @@ -9,67 +9,26 @@ (ns app.common.geom.shapes (:require - [clojure.spec.alpha :as s] - [app.common.spec :as us] + [app.common.data :as d] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] - [app.common.math :as mth] - [app.common.data :as d])) - -(defn- nilf - "Returns a new function that if you pass nil as any argument will - return nil" - [f] - (fn [& args] - (if (some nil? args) - nil - (apply f args)))) + [app.common.geom.shapes.common :as gco] + [app.common.geom.shapes.path :as gsp] + [app.common.geom.shapes.rect :as gpr] + [app.common.geom.shapes.transforms :as gtr] + [app.common.spec :as us])) ;; --- Relative Movement -(declare move-rect) -(declare move-path) - -(defn -chk - "Function that checks if a number is nil or nan. Will return 0 when not - valid and the number otherwise." - [v] - (if (or (not v) (mth/nan? v)) 0 v)) - (defn move "Move the shape relativelly to its current position applying the provided delta." [shape {dx :x dy :y}] - (let [inc-x (nilf (fn [x] (+ (-chk x) (-chk dx)))) - inc-y (nilf (fn [y] (+ (-chk y) (-chk dy)))) - inc-point (nilf (fn [p] (-> p - (update :x inc-x) - (update :y inc-y))))] + (let [dx (d/check-num dx) + dy (d/check-num dy)] (-> shape - (update :x inc-x) - (update :y inc-y) - (update-in [:selrect :x] inc-x) - (update-in [:selrect :x1] inc-x) - (update-in [:selrect :x2] inc-x) - (update-in [:selrect :y] inc-y) - (update-in [:selrect :y1] inc-y) - (update-in [:selrect :y2] inc-y) - (update :points #(mapv inc-point %)) - (update :segments #(mapv inc-point %))))) - -;; Duplicated from pages-helpers to remove cyclic dependencies -(defn get-children [id objects] - (let [shapes (vec (get-in objects [id :shapes]))] - (if shapes - (d/concat shapes (mapcat #(get-children % objects) shapes)) - []))) - -(defn recursive-move - "Move the shape and all its recursive children." - [shape dpoint objects] - (let [children-ids (get-children (:id shape) objects) - children (map #(get objects %) children-ids)] - (map #(move % dpoint) (cons shape children)))) + (assoc-in [:modifiers :displacement] (gmt/translate-matrix (gpt/point dx dy))) + (gtr/transform-shape)))) ;; --- Absolute Movement @@ -77,116 +36,35 @@ (defn absolute-move "Move the shape to the exactly specified position." - [shape position] - (case (:type shape) - (:curve :path) shape - (absolute-move-rect shape position))) - -(defn- absolute-move-rect - "A specialized function for absolute moviment - for rect-like shapes." - [shape {:keys [x y] :as pos}] - (let [dx (if x (- (-chk x) (-chk (:x shape))) 0) - dy (if y (- (-chk y) (-chk (:y shape))) 0)] + [shape {:keys [x y]}] + (let [dx (- (d/check-num x) (-> shape :selrect :x)) + dy (- (d/check-num y) (-> shape :selrect :y))] (move shape (gpt/point dx dy)))) -;; --- Center - -(declare center-rect) -(declare center-path) - -(defn center - "Calculate the center of the shape." - [shape] - (case (:type shape) - :curve (center-path shape) - :path (center-path shape) - (center-rect shape))) - -(defn- center-rect - [{:keys [x y width height] :as shape}] - (gpt/point (+ x (/ width 2)) (+ y (/ height 2)))) - -(defn- center-path - [{:keys [segments] :as shape}] - (let [minx (apply min (map :x segments)) - miny (apply min (map :y segments)) - maxx (apply max (map :x segments)) - maxy (apply max (map :y segments))] - (gpt/point (/ (+ minx maxx) 2) (/ (+ miny maxy) 2)))) - -(defn center->rect - "Creates a rect given a center and a width and height" - [center width height] - {:x (- (:x center) (/ width 2)) - :y (- (:y center) (/ height 2)) - :width width - :height height}) - -;; --- Proportions - -(declare assign-proportions-path) -(declare assign-proportions-rect) - -(defn assign-proportions - [{:keys [type] :as shape}] - (case type - :path (assign-proportions-path shape) - (assign-proportions-rect shape))) - -(defn- assign-proportions-rect - [{:keys [width height] :as shape}] - (assoc shape :proportion (/ width height))) - -;; --- Paths - -(defn update-path-point - "Update a concrete point in the path. - - The point should exists before, this function - does not adds it automatically." - [shape index point] - (assoc-in shape [:segments index] point)) - -;; --- Setup Proportions - -(declare setup-proportions-const) -(declare setup-proportions-image) - -(defn setup-proportions - [shape] - (case (:type shape) - :icon (setup-proportions-image shape) - :image (setup-proportions-image shape) - :text shape - (setup-proportions-const shape))) - -(defn setup-proportions-image - [{:keys [metadata] :as shape}] - (let [{:keys [width height]} metadata] - (assoc shape - :proportion (/ width height) - :proportion-lock false))) - -(defn setup-proportions-const - [shape] - (assoc shape - :proportion 1 - :proportion-lock false)) - ;; --- Resize (Dimensions) - (defn resize [shape width height] (us/assert map? shape) (us/assert number? width) (us/assert number? height) - (-> shape - (assoc :width width :height height) - (update :selrect (fn [selrect] - (assoc selrect - :x2 (+ (:x1 selrect) width) - :y2 (+ (:y1 selrect) height)))))) + + (let [shape-transform (:transform shape (gmt/matrix)) + shape-transform-inv (:transform-inverse shape (gmt/matrix)) + shape-center (gco/center-shape shape) + {sr-width :width sr-height :height} (:selrect shape) + origin (-> (gpt/point (:selrect shape)) + (gtr/transform-point-center shape-center shape-transform)) + + scalev (gpt/divide (gpt/point width height) + (gpt/point sr-width sr-height))] + + (-> shape + (update :modifiers assoc + :resize-vector scalev + :resize-origin origin + :resize-transform shape-transform + :resize-transform-inverse shape-transform-inv) + (gtr/transform-shape)))) (defn resize-rect [shape attr value] @@ -207,9 +85,29 @@ (resize shape (:width new-size) (:height new-size)))) ;; --- Setup (Initialize) +;; FIXME: Is this the correct place for these functions? -(declare setup-rect) -(declare setup-image) +(defn- setup-rect + "A specialized function for setup rect-like shapes." + [shape {:keys [x y width height]}] + (let [rect {:x x :y y :width width :height height} + points (gpr/rect->points rect) + selrect (gpr/points->selrect points)] + (assoc shape + :x x + :y y + :width width + :height height + :points points + :selrect selrect))) + +(defn- setup-image + [{:keys [metadata] :as shape} props] + (-> (setup-rect shape props) + (assoc + :proportion (/ (:width metadata) + (:height metadata)) + :proportion-lock true))) (defn setup "A function that initializes the first coordinates for @@ -219,324 +117,45 @@ :image (setup-image shape props) (setup-rect shape props))) -(declare shape->points) -(declare points->selrect) - -(defn- setup-rect - "A specialized function for setup rect-like shapes." - [shape {:keys [x y width height]}] - (as-> shape $ - (assoc $ :x x - :y y - :width width - :height height) - (assoc $ :points (shape->points $)) - (assoc $ :selrect (points->selrect (:points $))))) - -(defn- setup-image - [{:keys [metadata] :as shape} {:keys [x y width height] :as props}] - (-> (setup-rect shape props) - (assoc - :proportion (/ (:width metadata) - (:height metadata)) - :proportion-lock true))) - -;; --- Coerce to Rect-like shape. - -(declare path->rect-shape) -(declare group->rect-shape) -(declare rect->rect-shape) - -;; TODO: completly remove - -(defn shape->rect-shape - "Coerce shape to rect like shape." - - [{:keys [type] :as shape}] - (case type - (:curve :path) (path->rect-shape shape) - (rect->rect-shape shape))) - -;; -- Points - -(declare transform-shape-point) - -(defn shape->points [shape] - (let [points (case (:type shape) - (:curve :path) (:segments shape) - (let [{:keys [x y width height]} shape] - [(gpt/point x y) - (gpt/point (+ x width) y) - (gpt/point (+ x width) (+ y height)) - (gpt/point x (+ y height))]))] - (->> points - (map #(transform-shape-point % shape (:transform shape (gmt/matrix)))) - (map gpt/round) - (vec)))) - -(defn points->selrect [points] - (let [minx (transduce (map :x) min ##Inf points) - miny (transduce (map :y) min ##Inf points) - maxx (transduce (map :x) max ##-Inf points) - maxy (transduce (map :y) max ##-Inf points)] - {:x1 minx - :y1 miny - :x2 maxx - :y2 maxy - :x minx - :y miny - :width (- maxx minx) - :height (- maxy miny) - :type :rect})) - -;; Shape->PATH - -(declare rect->path) - -(defn shape->path - [shape] - (case (:type shape) - (:curve :path) shape - (rect->path shape))) - -(defn rect->path - [{:keys [x y width height] :as shape}] - - (let [points [(gpt/point x y) - (gpt/point (+ x width) y) - (gpt/point (+ x width) (+ y height)) - (gpt/point x (+ y height)) - (gpt/point x y)]] - (-> shape - (assoc :type :path) - (assoc :segments points)))) - -;; --- SHAPE -> RECT - -(defn- rect->rect-shape - [{:keys [x y width height] :as shape}] - (assoc shape - :x1 x - :y1 y - :x2 (+ x width) - :y2 (+ y height))) - -(defn- path->rect-shape - [{:keys [segments] :as shape}] - (merge shape - {:type :rect} - (:selrect shape))) - -;; --- Resolve Shape - -(declare resolve-rect-shape) -(declare translate-from-frame) -(declare translate-to-frame) - -(defn resolve-shape - [objects shape] - (case (:type shape) - :rect (resolve-rect-shape objects shape) - :group (resolve-rect-shape objects shape) - :frame (resolve-rect-shape objects shape))) - -(defn- resolve-rect-shape - [objects {:keys [parent] :as shape}] - (loop [pobj (get objects parent)] - (if (= :frame (:type pobj)) - (translate-from-frame shape pobj) - (recur (get objects (:parent pobj)))))) - -;; --- Transform Shape - -(declare transform-rect) -(declare transform-path) - -(defn transform - "Apply the matrix transformation to shape." - [{:keys [type] :as shape} xfmt] - (if (gmt/matrix? xfmt) - (case type - :path (transform-path shape xfmt) - :curve (transform-path shape xfmt) - (transform-rect shape xfmt)) - shape)) - -(defn center-transform [shape matrix] - (let [shape-center (center shape)] - (-> shape - (transform - (-> (gmt/matrix) - (gmt/translate shape-center) - (gmt/multiply matrix) - (gmt/translate (gpt/negate shape-center))))))) - -(defn- transform-rect - [{:keys [x y width height] :as shape} mx] - (let [tl (gpt/transform (gpt/point x y) mx) - tr (gpt/transform (gpt/point (+ x width) y) mx) - bl (gpt/transform (gpt/point x (+ y height)) mx) - br (gpt/transform (gpt/point (+ x width) (+ y height)) mx) - ;; TODO: replace apply with transduce (performance) - minx (apply min (map :x [tl tr bl br])) - maxx (apply max (map :x [tl tr bl br])) - miny (apply min (map :y [tl tr bl br])) - maxy (apply max (map :y [tl tr bl br]))] - (assoc shape - :x minx - :y miny - :width (- maxx minx) - :height (- maxy miny)))) - -(defn- transform-path - [{:keys [segments] :as shape} xfmt] - (let [segments (mapv #(gpt/transform % xfmt) segments)] - (assoc shape :segments segments))) - ;; --- Outer Rect (defn selection-rect "Returns a rect that contains all the shapes and is aware of the rotation of each shape. Mainly used for multiple selection." [shapes] - (let [shapes (map :selrect shapes) - minx (transduce (map :x1) min ##Inf shapes) - miny (transduce (map :y1) min ##Inf shapes) - maxx (transduce (map :x2) max ##-Inf shapes) - maxy (transduce (map :y2) max ##-Inf shapes)] - {:x1 minx - :y1 miny - :x2 maxx - :y2 maxy - :x minx - :y miny - :width (- maxx minx) - :height (- maxy miny) - :points [(gpt/point minx miny) - (gpt/point maxx miny) - (gpt/point maxx maxy) - (gpt/point minx maxy)] - :type :rect})) + (->> shapes + (gtr/transform-shape) + (map (comp gpr/points->selrect :points)) + (gpr/join-selrects))) (defn translate-to-frame - [shape {:keys [x y] :as frame}] + [shape {:keys [x y]}] (move shape (gpt/point (- x) (- y)))) (defn translate-from-frame - [shape {:keys [x y] :as frame}] + [shape {:keys [x y]}] (move shape (gpt/point x y))) -;; --- Alignment - -(s/def ::align-axis #{:hleft :hcenter :hright :vtop :vcenter :vbottom}) - -(declare calc-align-pos) - -(defn align-to-rect - "Move the shape so that it is aligned with the given rectangle - in the given axis. Take account the form of the shape and the - possible rotation. What is aligned is the rectangle that wraps - the shape with the given rectangle. If the shape is a group, - move also all of its recursive children." - [shape rect axis objects] - (let [wrapper-rect (selection-rect [shape]) - align-pos (calc-align-pos wrapper-rect rect axis) - delta {:x (- (:x align-pos) (:x wrapper-rect)) - :y (- (:y align-pos) (:y wrapper-rect))}] - (recursive-move shape delta objects))) - -(defn calc-align-pos - [wrapper-rect rect axis] - (case axis - :hleft (let [left (:x rect)] - {:x left - :y (:y wrapper-rect)}) - - :hcenter (let [center (+ (:x rect) (/ (:width rect) 2))] - {:x (- center (/ (:width wrapper-rect) 2)) - :y (:y wrapper-rect)}) - - :hright (let [right (+ (:x rect) (:width rect))] - {:x (- right (:width wrapper-rect)) - :y (:y wrapper-rect)}) - - :vtop (let [top (:y rect)] - {:x (:x wrapper-rect) - :y top}) - - :vcenter (let [center (+ (:y rect) (/ (:height rect) 2))] - {:x (:x wrapper-rect) - :y (- center (/ (:height wrapper-rect) 2))}) - - :vbottom (let [bottom (+ (:y rect) (:height rect))] - {:x (:x wrapper-rect) - :y (- bottom (:height wrapper-rect))}))) - -;; --- Distribute - -(s/def ::dist-axis #{:horizontal :vertical}) - -(defn distribute-space - "Distribute equally the space between shapes in the given axis. If - there is no space enough, it does nothing. It takes into account - the form of the shape and the rotation, what is distributed is - the wrapping recangles of the shapes. If any shape is a group, - move also all of its recursive children." - [shapes axis objects] - (let [coord (if (= axis :horizontal) :x :y) - other-coord (if (= axis :horizontal) :y :x) - size (if (= axis :horizontal) :width :height) - ; The rectangle that wraps the whole selection - wrapper-rect (selection-rect shapes) - ; Sort shapes by the center point in the given axis - sorted-shapes (sort-by #(coord (center %)) shapes) - ; Each shape wrapped in its own rectangle - wrapped-shapes (map #(selection-rect [%]) sorted-shapes) - ; The total space between shapes - space (reduce - (size wrapper-rect) (map size wrapped-shapes))] - - (if (<= space 0) - shapes - (let [unit-space (/ space (- (count wrapped-shapes) 1)) - ; Calculate the distance we need to move each shape. - ; The new position of each one is the position of the - ; previous one plus its size plus the unit space. - deltas (loop [shapes' wrapped-shapes - start-pos (coord wrapper-rect) - deltas []] - - (let [first-shape (first shapes') - delta (- start-pos (coord first-shape)) - new-pos (+ start-pos (size first-shape) unit-space)] - - (if (= (count shapes') 1) - (conj deltas delta) - (recur (rest shapes') - new-pos - (conj deltas delta)))))] - - (mapcat #(recursive-move %1 {coord %2 other-coord 0} objects) - sorted-shapes deltas))))) - - ;; --- Helpers (defn contained-in? "Check if a shape is contained in the provided selection rect." [shape selrect] - (let [{sx1 :x1 sx2 :x2 sy1 :y1 sy2 :y2} (shape->rect-shape selrect) - {rx1 :x1 rx2 :x2 ry1 :y1 ry2 :y2} (shape->rect-shape shape)] + (let [{sx1 :x1 sx2 :x2 sy1 :y1 sy2 :y2} selrect + {rx1 :x1 rx2 :x2 ry1 :y1 ry2 :y2} (:selrect shape)] (and (neg? (- sy1 ry1)) (neg? (- sx1 rx1)) (pos? (- sy2 ry2)) (pos? (- sx2 rx2))))) +;; TODO: This not will work for rotated shapes (defn overlaps? "Check if a shape overlaps with provided selection rect." - [shape selrect] - (let [{sx1 :x1 sx2 :x2 sy1 :y1 sy2 :y2} (shape->rect-shape selrect) - {rx1 :x1 rx2 :x2 ry1 :y1 ry2 :y2} (shape->rect-shape shape)] + [shape rect] + (let [{sx1 :x1 sx2 :x2 sy1 :y1 sy2 :y2} (gpr/rect->selrect rect) + {rx1 :x1 rx2 :x2 ry1 :y1 ry2 :y2} (gpr/points->selrect (:points shape))] + (and (< rx1 sx2) (> rx2 sx1) (< ry1 sy2) @@ -564,43 +183,6 @@ :type :rect}] (overlaps? shape selrect))) -(defn calculate-rec-path-skew-angle - [path-shape] - (let [p1 (get-in path-shape [:segments 2]) - p2 (get-in path-shape [:segments 3]) - p3 (get-in path-shape [:segments 4]) - v1 (gpt/to-vec p1 p2) - v2 (gpt/to-vec p2 p3)] - (- 90 (gpt/angle-with-other v1 v2)))) - -(defn calculate-rec-path-height - "Calculates the height of a paralelogram given by the path" - [path-shape] - (let [p1 (get-in path-shape [:segments 2]) - p2 (get-in path-shape [:segments 3]) - p3 (get-in path-shape [:segments 4]) - v1 (gpt/to-vec p1 p2) - v2 (gpt/to-vec p2 p3) - angle (gpt/angle-with-other v1 v2)] - (* (gpt/length v2) (mth/sin (mth/radians angle))))) - -(defn calculate-rec-path-rotation - [path-shape1 path-shape2 resize-vector] - - (let [idx-1 0 - idx-2 (cond (and (neg? (:x resize-vector)) (pos? (:y resize-vector))) 1 - (and (neg? (:x resize-vector)) (neg? (:y resize-vector))) 2 - (and (pos? (:x resize-vector)) (neg? (:y resize-vector))) 3 - :else 0) - p1 (get-in path-shape1 [:segments idx-1]) - p2 (get-in path-shape2 [:segments idx-2]) - v1 (gpt/to-vec (center path-shape1) p1) - v2 (gpt/to-vec (center path-shape2) p2) - - rot-angle (gpt/angle-with-other v1 v2) - rot-sign (if (> (* (:y v1) (:x v2)) (* (:x v1) (:y v2))) -1 1)] - (* rot-sign rot-angle))) - (defn pad-selrec ([selrect] (pad-selrec selrect 1)) ([selrect size] @@ -619,16 +201,22 @@ (defn selrect->areas [bounds selrect] (let [make-selrect (fn [x1 y1 x2 y2] - {:x1 x1 :y1 y1 :x2 x2 :y2 y2 :x x1 :y y1 - :width (- x2 x1) :height (- y2 y1) :type :rect}) - {frame-x1 :x1 frame-x2 :x2 frame-y1 :y1 frame-y2 :y2 - frame-width :width frame-height :height} bounds - {sr-x1 :x1 sr-x2 :x2 sr-y1 :y1 sr-y2 :y2 - sr-width :width sr-height :height} selrect] - {:left (make-selrect frame-x1 sr-y1 sr-x1 sr-y2) - :top (make-selrect sr-x1 frame-y1 sr-x2 sr-y1) - :right (make-selrect sr-x2 sr-y1 frame-x2 sr-y2) - :bottom (make-selrect sr-x1 sr-y2 sr-x2 frame-y2)})) + (let [x1 (min x1 x2) + x2 (max x1 x2) + y1 (min y1 y2) + y2 (max y1 y2)] + {:x1 x1 :y1 y1 + :x2 x2 :y2 y2 + :x x1 :y y1 + :width (- x2 x1) + :height (- y2 y1) + :type :rect})) + {frame-x1 :x1 frame-x2 :x2 frame-y1 :y1 frame-y2 :y2} bounds + {sr-x1 :x1 sr-x2 :x2 sr-y1 :y1 sr-y2 :y2} selrect] + {:left (make-selrect frame-x1 sr-y1 (- sr-x1 2) sr-y2) + :top (make-selrect sr-x1 frame-y1 sr-x2 (- sr-y1 2)) + :right (make-selrect (+ sr-x2 2) sr-y1 frame-x2 sr-y2) + :bottom (make-selrect sr-x1 (+ sr-y2 2) sr-x2 frame-y2)})) (defn distance-selrect [selrect other] (let [{:keys [x1 y1]} other @@ -658,310 +246,37 @@ (and (>= s1c1 s2c1) (<= s1c1 s2c2)) (and (>= s1c2 s2c1) (<= s1c2 s2c2))))) -(defn transform-shape-point - "Transform a point around the shape center" - [point shape transform] - (let [shape-center (center shape)] - (gpt/transform - point - (-> (gmt/multiply - (gmt/translate-matrix shape-center) - transform - (gmt/translate-matrix (gpt/negate shape-center))))))) -(defn transform-apply-modifiers - [shape] - (let [modifiers (:modifiers shape) - ds-modifier (:displacement modifiers (gmt/matrix)) - {res-x :x res-y :y} (:resize-vector modifiers (gpt/point 1 1)) +(defn setup-selrect [shape] + (let [selrect (gpr/rect->selrect shape) + points (gpr/rect->points shape)] + (-> shape + (assoc :selrect selrect + :points points)))) - ;; Normalize x/y vector coordinates because scale by 0 is infinite - res-x (cond - (and (< res-x 0) (> res-x -0.01)) -0.01 - (and (>= res-x 0) (< res-x 0.01)) 0.01 - :else res-x) - - res-y (cond - (and (< res-y 0) (> res-y -0.01)) -0.01 - (and (>= res-y 0) (< res-y 0.01)) 0.01 - :else res-y) - - resize (gpt/point res-x res-y) - - origin (:resize-origin modifiers (gpt/point 0 0)) - - resize-transform (:resize-transform modifiers (gmt/matrix)) - resize-transform-inverse (:resize-transform-inverse modifiers (gmt/matrix)) - rt-modif (or (:rotation modifiers) 0) - - shape (-> shape - (transform ds-modifier)) - - shape-center (center shape)] - - (-> (shape->path shape) - (transform (-> (gmt/matrix) - - ;; Applies the current resize transformation - (gmt/translate origin) - (gmt/multiply resize-transform) - (gmt/scale resize) - (gmt/multiply resize-transform-inverse) - (gmt/translate (gpt/negate origin)) - - ;; Applies the stacked transformations - (gmt/translate shape-center) - (gmt/multiply (gmt/rotate-matrix rt-modif)) - (gmt/multiply (:transform shape (gmt/matrix))) - (gmt/translate (gpt/negate shape-center))))))) - -(defn rect-path-dimensions [rect-path] - (let [seg (:segments rect-path) - [width height] (mapv (fn [[c1 c2]] (gpt/distance c1 c2)) (take 2 (d/zip seg (rest seg))))] - {:width width - :height height})) - -(defn calculate-stretch [shape-path transform-inverse] - (let [shape-center (center shape-path) - shape-path-temp (transform - shape-path - (-> (gmt/matrix) - (gmt/translate shape-center) - (gmt/multiply transform-inverse) - (gmt/translate (gpt/negate shape-center)))) - - shape-path-temp-rec (shape->rect-shape shape-path-temp) - shape-path-temp-dim (rect-path-dimensions shape-path-temp)] - (gpt/divide (gpt/point (:width shape-path-temp-rec) (:height shape-path-temp-rec)) - (gpt/point (:width shape-path-temp-dim) (:height shape-path-temp-dim))))) - -(defn fix-invalid-rect-values - [rect-shape] - (letfn [(check [num] - (if (or (nil? num) (mth/nan? num) (= ##Inf num) (= ##-Inf num)) 0 num)) - (to-positive [num] (if (< num 1) 1 num))] - (-> rect-shape - (update :x check) - (update :y check) - (update :width (comp to-positive check)) - (update :height (comp to-positive check))))) - -(defn transform-rect-shape - [shape] - (let [;; Apply modifiers to the rect as a path so we have the end shape expected - shape-path (transform-apply-modifiers shape) - shape-center (center shape-path) - resize-vector (-> (get-in shape [:modifiers :resize-vector] (gpt/point 1 1)) - (update :x #(if (zero? %) 1 %)) - (update :y #(if (zero? %) 1 %))) - - ;; Reverse the current transformation stack to get the base rectangle - shape-path-temp (center-transform shape-path (:transform-inverse shape (gmt/matrix))) - shape-path-temp-dim (rect-path-dimensions shape-path-temp) - shape-path-temp-rec (shape->rect-shape shape-path-temp) - - ;; This rectangle is the new data for the current rectangle. We want to change our rectangle - ;; to have this width, height, x, y - rec (center->rect shape-center (:width shape-path-temp-dim) (:height shape-path-temp-dim)) - rec (fix-invalid-rect-values rec) - rec-path (rect->path rec) - - ;; The next matrix is a series of transformations we have to do to the previous rec so that - ;; after applying them the end result is the `shape-path-temp` - ;; This is compose of three transformations: skew, resize and rotation - stretch-matrix (gmt/matrix) - - skew-angle (calculate-rec-path-skew-angle shape-path-temp) - - ;; When one of the axis is flipped we have to reverse the skew - skew-angle (if (neg? (* (:x resize-vector) (:y resize-vector))) (- skew-angle) skew-angle ) - skew-angle (if (mth/nan? skew-angle) 0 skew-angle) +(defn rotation-modifiers + [center shape angle] + (let [displacement (let [shape-center (gco/center-shape shape)] + (-> (gmt/matrix) + (gmt/rotate angle center) + (gmt/rotate (- angle) shape-center)))] + {:rotation angle + :displacement displacement})) - stretch-matrix (gmt/multiply stretch-matrix (gmt/skew-matrix skew-angle 0)) - - h1 (calculate-rec-path-height shape-path-temp) - h2 (calculate-rec-path-height (center-transform rec-path stretch-matrix)) - h3 (/ h1 h2) - h3 (if (mth/nan? h3) 1 h3) - - stretch-matrix (gmt/multiply stretch-matrix (gmt/scale-matrix (gpt/point 1 h3))) - - rotation-angle (calculate-rec-path-rotation (center-transform rec-path stretch-matrix) - shape-path-temp resize-vector) - - stretch-matrix (gmt/multiply (gmt/rotate-matrix rotation-angle) stretch-matrix) - - ;; This is the inverse to be able to remove the transformation - stretch-matrix-inverse (-> (gmt/matrix) - (gmt/scale (gpt/point 1 h3)) - (gmt/skew (- skew-angle) 0) - (gmt/rotate (- rotation-angle))) - - - new-shape (as-> shape $ - (merge $ rec) - (update $ :x #(mth/precision % 0)) - (update $ :y #(mth/precision % 0)) - (update $ :width #(mth/precision % 0)) - (update $ :height #(mth/precision % 0)) - (update $ :transform #(gmt/multiply (or % (gmt/matrix)) stretch-matrix)) - (update $ :transform-inverse #(gmt/multiply stretch-matrix-inverse (or % (gmt/matrix)))) - (assoc $ :points (shape->points $)) - (assoc $ :selrect (points->selrect (:points $))) - (update $ :selrect fix-invalid-rect-values) - (update $ :rotation #(mod (+ (or % 0) - (or (get-in $ [:modifiers :rotation]) 0)) 360)))] - new-shape)) - -(declare update-path-selrect) -(defn transform-path-shape - [shape] - (-> shape - transform-apply-modifiers - update-path-selrect) - ;; TODO: Addapt for paths is not working - #_(let [shape-path (transform-apply-modifiers shape) - shape-path-center (center shape-path) - - shape-transform-inverse' (-> (gmt/matrix) - (gmt/translate shape-path-center) - (gmt/multiply (:transform-inverse shape (gmt/matrix))) - (gmt/multiply (gmt/rotate-matrix (- (:rotation-modifier shape 0)))) - (gmt/translate (gpt/negate shape-path-center)))] - (-> shape-path - (transform shape-transform-inverse') - (add-rotate-transform (:rotation-modifier shape 0))))) - -(defn transform-shape - "Transform the shape properties given the modifiers" - ([shape] (transform-shape nil shape)) - ([frame shape] - (let [new-shape - (if (:modifiers shape) - (-> (case (:type shape) - (:curve :path) (transform-path-shape shape) - (transform-rect-shape shape)) - (dissoc :modifiers)) - shape)] - (cond-> new-shape - frame (translate-to-frame frame))))) - - -(defn transform-matrix - "Returns a transformation matrix without changing the shape properties. - The result should be used in a `transform` attribute in svg" - ([{:keys [x y] :as shape}] - (let [shape-center (center shape)] - (-> (gmt/matrix) - (gmt/translate shape-center) - (gmt/multiply (:transform shape (gmt/matrix))) - (gmt/translate (gpt/negate shape-center)))))) - -(defn update-path-selrect [shape] - (as-> shape $ - (assoc $ :points (shape->points $)) - (assoc $ :selrect (points->selrect (:points $))) - (assoc $ :x (get-in $ [:selrect :x])) - (assoc $ :y (get-in $ [:selrect :y])) - (assoc $ :width (get-in $ [:selrect :width])) - (assoc $ :height (get-in $ [:selrect :height])))) - -(defn adjust-to-viewport - ([viewport srect] (adjust-to-viewport viewport srect nil)) - ([viewport srect {:keys [padding] :or {padding 0}}] - (let [gprop (/ (:width viewport) (:height viewport)) - srect (-> srect - (update :x #(- % padding)) - (update :y #(- % padding)) - (update :width #(+ % padding padding)) - (update :height #(+ % padding padding))) - width (:width srect) - height (:height srect) - lprop (/ width height)] - (cond - (> gprop lprop) - (let [width' (* (/ width lprop) gprop) - padding (/ (- width' width) 2)] - (-> srect - (update :x #(- % padding)) - (assoc :width width'))) - - (< gprop lprop) - (let [height' (/ (* height lprop) gprop) - padding (/ (- height' height) 2)] - (-> srect - (update :y #(- % padding)) - (assoc :height height'))) - - :else srect)))) - -(defn get-attrs-multi - [shapes attrs] - ;; Extract some attributes of a list of shapes. - ;; For each attribute, if the value is the same in all shapes, - ;; wll take this value. If there is any shape that is different, - ;; the value of the attribute will be the keyword :multiple. - ;; - ;; If some shape has the value nil in any attribute, it's - ;; considered a different value. If the shape does not contain - ;; the attribute, it's ignored in the final result. - ;; - ;; Example: - ;; (def shapes [{:stroke-color "#ff0000" - ;; :stroke-width 3 - ;; :fill-color "#0000ff" - ;; :x 1000 :y 2000 :rx nil} - ;; {:stroke-width "#ff0000" - ;; :stroke-width 5 - ;; :x 1500 :y 2000}]) - ;; - ;; (get-attrs-multi shapes [:stroke-color - ;; :stroke-width - ;; :fill-color - ;; :rx - ;; :ry]) - ;; >>> {:stroke-color "#ff0000" - ;; :stroke-width :multiple - ;; :fill-color "#0000ff" - ;; :rx nil - ;; :ry nil} - ;; - (let [defined-shapes (filter some? shapes) - - combine-value (fn [v1 v2] (cond - (= v1 v2) v1 - (= v1 :undefined) v2 - (= v2 :undefined) v1 - :else :multiple)) - - combine-values (fn [attrs shape values] - (map #(combine-value (get shape % :undefined) - (get values % :undefined)) attrs)) - - select-attrs (fn [shape attrs] - (zipmap attrs (map #(get shape % :undefined) attrs))) - - reducer (fn [result shape] - (zipmap attrs (combine-values attrs shape result))) - - combined (reduce reducer - (select-attrs (first defined-shapes) attrs) - (rest defined-shapes)) - - cleanup-value (fn [value] - (if (= value :undefined) nil value)) - - cleanup (fn [result] - (zipmap attrs (map #(cleanup-value (get result %)) attrs)))] - - (cleanup combined))) - - -(defn setup-selrect [{:keys [x y width height] :as shape}] - (-> shape - (assoc :selrect {:x x :y y - :width width :height height - :x1 x :y1 y - :x2 (+ x width) :y2 (+ y height)}))) +;; EXPORTS +(d/export gco/center-shape) +(d/export gco/center-selrect) +(d/export gco/center-rect) +(d/export gpr/rect->selrect) +(d/export gpr/rect->points) +(d/export gpr/points->selrect) +(d/export gtr/transform-shape) +(d/export gtr/transform-matrix) +(d/export gtr/transform-point-center) +(d/export gtr/transform-rect) +(d/export gtr/update-group-selrect) +;; PATHS +(d/export gsp/content->points) +(d/export gsp/content->selrect) diff --git a/common/app/common/geom/shapes/common.cljc b/common/app/common/geom/shapes/common.cljc new file mode 100644 index 000000000..49abc5943 --- /dev/null +++ b/common/app/common/geom/shapes/common.cljc @@ -0,0 +1,53 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.shapes.common + (:require + [app.common.geom.point :as gpt] + [app.common.math :as mth])) + +(defn center-rect + [{:keys [x y width height]}] + (when (and (mth/finite? x) + (mth/finite? y) + (mth/finite? width) + (mth/finite? height)) + (gpt/point (+ x (/ width 2)) + (+ y (/ height 2))))) + +(defn center-selrect + "Calculate the center of the shape." + [selrect] + (center-rect selrect)) + +(def map-x-xf (comp (map :x) (remove nil?))) +(def map-y-xf (comp (map :y) (remove nil?))) + +(defn center-points [points] + (let [ptx (into [] map-x-xf points) + pty (into [] map-y-xf points) + minx (reduce min ##Inf ptx) + miny (reduce min ##Inf pty) + maxx (reduce max ##-Inf ptx) + maxy (reduce max ##-Inf pty)] + (gpt/point (/ (+ minx maxx) 2) + (/ (+ miny maxy) 2)))) + +(defn center-shape + "Calculate the center of the shape." + [shape] + (center-rect (:selrect shape))) + +(defn make-centered-rect + "Creates a rect given a center and a width and height" + [center width height] + {:x (- (:x center) (/ width 2)) + :y (- (:y center) (/ height 2)) + :width width + :height height}) diff --git a/common/app/common/geom/shapes/path.cljc b/common/app/common/geom/shapes/path.cljc new file mode 100644 index 000000000..08378b220 --- /dev/null +++ b/common/app/common/geom/shapes/path.cljc @@ -0,0 +1,159 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.shapes.path + (:require + [app.common.geom.point :as gpt] + [app.common.geom.shapes.rect :as gpr] + [app.common.math :as mth] + [app.common.data :as d])) + +(defn content->points [content] + (->> content + (map #(when (-> % :params :x) (gpt/point (-> % :params :x) (-> % :params :y)))) + (remove nil?) + (into []))) + +;; https://medium.com/@Acegikmo/the-ever-so-lovely-b%C3%A9zier-curve-eb27514da3bf +;; https://en.wikipedia.org/wiki/Bernstein_polynomial +(defn curve-values + "Parametric equation for cubic beziers. Given a start and end and + two intermediate points returns points for values of t. + If you draw t on a plane you got the bezier cube" + [start end h1 h2 t] + + (let [t2 (* t t) ;; t square + t3 (* t2 t) ;; t cube + + start-v (+ (- t3) (* 3 t2) (* -3 t) 1) + h1-v (+ (* 3 t3) (* -6 t2) (* 3 t)) + h2-v (+ (* -3 t3) (* 3 t2)) + end-v t3 + + coord-v (fn [coord] + (+ (* (coord start) start-v) + (* (coord h1) h1-v) + (* (coord h2) h2-v) + (* (coord end) end-v)))] + + (gpt/point (coord-v :x) (coord-v :y)))) + +;; https://pomax.github.io/bezierinfo/#extremities +(defn curve-extremities + "Given a cubic bezier cube finds its roots in t. This are the extremities + if we calculate its values for x, y we can find a bounding box for the curve." + [start end h1 h2] + + (let [coords [[(:x start) (:x h1) (:x h2) (:x end)] + [(:y start) (:y h1) (:y h2) (:y end)]] + + coord->tvalue + (fn [[c0 c1 c2 c3]] + + (let [a (+ (* -3 c0) (* 9 c1) (* -9 c2) (* 3 c3)) + b (+ (* 6 c0) (* -12 c1) (* 6 c2)) + c (+ (* 3 c1) (* -3 c0)) + + sqrt-b2-4ac (mth/sqrt (- (* b b) (* 4 a c)))] + + (cond + (and (mth/almost-zero? a) + (not (mth/almost-zero? b))) + ;; When the term a is close to zero we have a linear equation + [(/ (- c) b)] + + ;; If a is not close to zero return the two roots for a cuadratic + (not (mth/almost-zero? a)) + [(/ (+ (- b) sqrt-b2-4ac) + (* 2 a)) + (/ (- (- b) sqrt-b2-4ac) + (* 2 a))] + + ;; If a and b close to zero we can't find a root for a constant term + :else + [])))] + (->> coords + (mapcat coord->tvalue) + + ;; Only values in the range [0, 1] are valid + (filter #(and (>= % 0) (<= % 1))) + + ;; Pass t-values to actual points + (map #(curve-values start end h1 h2 %))) + )) + +(defn command->point + ([command] (command->point command nil)) + ([{params :params} coord] + (let [prefix (if coord (name coord) "") + xkey (keyword (str prefix "x")) + ykey (keyword (str prefix "y")) + x (get params xkey) + y (get params ykey)] + (gpt/point x y)))) + +(defn content->selrect [content] + (let [calc-extremities + (fn [command prev] + (case (:command command) + :move-to [(command->point command)] + + ;; If it's a line we add the beginning point and endpoint + :line-to [(command->point prev) + (command->point command)] + + ;; We return the bezier extremities + :curve-to (d/concat + [(command->point prev) + (command->point command)] + (curve-extremities (command->point prev) + (command->point command) + (command->point command :c1) + (command->point command :c2))) + [])) + + extremities (mapcat calc-extremities + content + (d/concat [nil] content))] + + (gpr/points->selrect extremities))) + +(defn transform-content [content transform] + (let [set-tr (fn [params px py] + (let [tr-point (-> (gpt/point (get params px) (get params py)) + (gpt/transform transform))] + (assoc params + px (:x tr-point) + py (:y tr-point)))) + + transform-params + (fn [{:keys [x c1x c2x] :as params}] + (cond-> params + (not (nil? x)) (set-tr :x :y) + (not (nil? c1x)) (set-tr :c1x :c1y) + (not (nil? c2x)) (set-tr :c2x :c2y)))] + + (mapv #(update % :params transform-params) content))) + +(defn segments->content + ([segments] + (segments->content segments false)) + + ([segments closed?] + (let [initial (first segments) + lines (rest segments)] + + (d/concat [{:command :move-to + :params (select-keys initial [:x :y])}] + (->> lines + (mapv #(hash-map :command :line-to + :params (select-keys % [:x :y])))) + + (when closed? + [{:command :close-path}]))))) diff --git a/common/app/common/geom/shapes/rect.cljc b/common/app/common/geom/shapes/rect.cljc new file mode 100644 index 000000000..b7a56e6fa --- /dev/null +++ b/common/app/common/geom/shapes/rect.cljc @@ -0,0 +1,60 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.shapes.rect + (:require + [app.common.geom.point :as gpt] + [app.common.geom.shapes.common :as gco])) + +(defn rect->points [{:keys [x y width height]}] + ;; (assert (number? x)) + ;; (assert (number? y)) + ;; (assert (and (number? width) (> width 0))) + ;; (assert (and (number? height) (> height 0))) + [(gpt/point x y) + (gpt/point (+ x width) y) + (gpt/point (+ x width) (+ y height)) + (gpt/point x (+ y height))]) + +(defn points->rect + [points] + (let [minx (transduce gco/map-x-xf min ##Inf points) + miny (transduce gco/map-y-xf min ##Inf points) + maxx (transduce gco/map-x-xf max ##-Inf points) + maxy (transduce gco/map-y-xf max ##-Inf points)] + {:x minx + :y miny + :width (- maxx minx) + :height (- maxy miny)})) + +(defn points->selrect [points] + (let [{:keys [x y width height] :as rect} (points->rect points)] + (assoc rect + :x1 x + :x2 (+ x width) + :y1 y + :y2 (+ y height)))) + +(defn rect->selrect [rect] + (-> rect rect->points points->selrect)) + +(defn join-selrects [selrects] + (let [minx (transduce (comp (map :x1) (remove nil?)) min ##Inf selrects) + miny (transduce (comp (map :y1) (remove nil?)) min ##Inf selrects) + maxx (transduce (comp (map :x2) (remove nil?)) max ##-Inf selrects) + maxy (transduce (comp (map :y2) (remove nil?)) max ##-Inf selrects)] + {:x minx + :y miny + :x1 minx + :y1 miny + :x2 maxx + :y2 maxy + :width (- maxx minx) + :height (- maxy miny)})) + diff --git a/common/app/common/geom/shapes/transforms.cljc b/common/app/common/geom/shapes/transforms.cljc new file mode 100644 index 000000000..0430f0ea2 --- /dev/null +++ b/common/app/common/geom/shapes/transforms.cljc @@ -0,0 +1,287 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.geom.shapes.transforms + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes.common :as gco] + [app.common.geom.shapes.path :as gpa] + [app.common.geom.shapes.rect :as gpr] + [app.common.math :as mth])) + +(defn transform-matrix + "Returns a transformation matrix without changing the shape properties. + The result should be used in a `transform` attribute in svg" + ([shape] (transform-matrix shape nil)) + ([{:keys [flip-x flip-y] :as shape} {:keys [no-flip]}] + (let [shape-center (or (gco/center-shape shape) + (gpt/point 0 0))] + (-> (gmt/matrix) + (gmt/translate shape-center) + + (gmt/multiply (:transform shape (gmt/matrix))) + (cond-> + (and (not no-flip) flip-x) (gmt/scale (gpt/point -1 1)) + (and (not no-flip) flip-y) (gmt/scale (gpt/point 1 -1))) + (gmt/translate (gpt/negate shape-center)))))) + +(defn transform-point-center + "Transform a point around the shape center" + [point center matrix] + (gpt/transform + point + (gmt/multiply (gmt/translate-matrix center) + matrix + (gmt/translate-matrix (gpt/negate center))))) + +(defn transform-points + ([points matrix] + (transform-points points nil matrix)) + + ([points center matrix] + + (let [prev (if center (gmt/translate-matrix center) (gmt/matrix)) + post (if center (gmt/translate-matrix (gpt/negate center)) (gmt/matrix)) + + tr-point (fn [point] + (gpt/transform point (gmt/multiply prev matrix post)))] + (mapv tr-point points)))) + +(defn transform-rect + "Transform a rectangles and changes its attributes" + [rect matrix] + + (let [points (-> (gpr/rect->points rect) + (transform-points matrix))] + (gpr/points->rect points))) + +(defn normalize-scale + "We normalize the scale so it's not too close to 0" + [scale] + (cond + (and (< scale 0) (> scale -0.01)) -0.01 + (and (>= scale 0) (< scale 0.01)) 0.01 + :else scale)) + +(defn modifiers->transform + [center modifiers] + (let [ds-modifier (:displacement modifiers (gmt/matrix)) + {res-x :x res-y :y} (:resize-vector modifiers (gpt/point 1 1)) + + ;; Normalize x/y vector coordinates because scale by 0 is infinite + res-x (normalize-scale res-x) + res-y (normalize-scale res-y) + resize (gpt/point res-x res-y) + + origin (:resize-origin modifiers (gpt/point 0 0)) + + resize-transform (:resize-transform modifiers (gmt/matrix)) + resize-transform-inverse (:resize-transform-inverse modifiers (gmt/matrix)) + rt-modif (or (:rotation modifiers) 0) + + center (gpt/transform center ds-modifier) + + transform (-> (gmt/matrix) + + ;; Applies the current resize transformation + (gmt/translate origin) + (gmt/multiply resize-transform) + (gmt/scale resize) + (gmt/multiply resize-transform-inverse) + (gmt/translate (gpt/negate origin)) + + ;; Applies the stacked transformations + (gmt/translate center) + (gmt/multiply (gmt/rotate-matrix rt-modif)) + (gmt/translate (gpt/negate center)) + + ;; Displacement + (gmt/multiply ds-modifier))] + transform)) + +(defn- calculate-skew-angle + "Calculates the skew angle of the paralelogram given by the points" + [[p1 _ p3 p4]] + (let [v1 (gpt/to-vec p3 p4) + v2 (gpt/to-vec p4 p1)] + (- 90 (gpt/angle-with-other v1 v2)))) + +(defn- calculate-height + "Calculates the height of a paralelogram given by the points" + [[p1 _ p3 p4]] + (let [v1 (gpt/to-vec p3 p4) + v2 (gpt/to-vec p4 p1) + angle (gpt/angle-with-other v1 v2)] + (* (gpt/length v2) (mth/sin (mth/radians angle))))) + +(defn- calculate-rotation + "Calculates the rotation between two shapes given the resize vector direction" + [points-shape1 points-shape2 flip-x flip-y] + + (let [idx-1 0 + idx-2 (cond (and flip-x (not flip-y)) 1 + (and flip-x flip-y) 2 + (and (not flip-x) flip-y) 3 + :else 0) + p1 (nth points-shape1 idx-1) + p2 (nth points-shape2 idx-2) + v1 (gpt/to-vec (gco/center-points points-shape1) p1) + v2 (gpt/to-vec (gco/center-points points-shape2) p2) + + rot-angle (gpt/angle-with-other v1 v2) + rot-sign (if (> (* (:y v1) (:x v2)) (* (:x v1) (:y v2))) -1 1)] + (* rot-sign rot-angle))) + +(defn- calculate-dimensions + [[p1 p2 p3 _]] + (let [width (gpt/distance p1 p2) + height (gpt/distance p2 p3)] + {:width width :height height})) + +(defn calculate-adjust-matrix + "Calculates a matrix that is a series of transformations we have to do to the transformed rectangle so that + after applying them the end result is the `shape-pathn-temp`. + This is compose of three transformations: skew, resize and rotation" + [points-temp points-rec flip-x flip-y] + (let [center (gco/center-points points-temp) + + stretch-matrix (gmt/matrix) + + skew-angle (calculate-skew-angle points-temp) + + ;; When one of the axis is flipped we have to reverse the skew + ;; skew-angle (if (neg? (* (:x resize-vector) (:y resize-vector))) (- skew-angle) skew-angle ) + skew-angle (if (and (or flip-x flip-y) + (not (and flip-x flip-y))) (- skew-angle) skew-angle ) + skew-angle (if (mth/nan? skew-angle) 0 skew-angle) + + stretch-matrix (gmt/multiply stretch-matrix (gmt/skew-matrix skew-angle 0)) + + h1 (calculate-height points-temp) + h2 (calculate-height (transform-points points-rec center stretch-matrix)) + h3 (if-not (mth/almost-zero? h2) (/ h1 h2) 1) + h3 (if (mth/nan? h3) 1 h3) + + stretch-matrix (gmt/multiply stretch-matrix (gmt/scale-matrix (gpt/point 1 h3))) + + rotation-angle (calculate-rotation + (transform-points points-rec (gco/center-points points-rec) stretch-matrix) + points-temp + flip-x + flip-y) + + stretch-matrix (gmt/multiply (gmt/rotate-matrix rotation-angle) stretch-matrix) + + + ;; This is the inverse to be able to remove the transformation + stretch-matrix-inverse (-> (gmt/matrix) + (gmt/scale (gpt/point 1 (/ 1 h3))) + (gmt/skew (- skew-angle) 0) + (gmt/rotate (- rotation-angle)))] + [stretch-matrix stretch-matrix-inverse])) + + +(defn apply-transform-path + [shape transform] + (let [content (gpa/transform-content (:content shape) transform) + selrect (gpa/content->selrect content) + points (gpr/rect->points selrect) + ;;rotation (mod (+ (:rotation shape 0) + ;; (or (get-in shape [:modifiers :rotation]) 0)) + ;; 360) + ] + (assoc shape + :content content + :points points + :selrect selrect + ;;:rotation rotation + ))) + +(defn apply-transform-rect + "Given a new set of points transformed, set up the rectangle so it keeps + its properties. We adjust de x,y,width,height and create a custom transform" + [shape transform] + ;; + (let [points (-> shape :points (transform-points transform)) + center (gco/center-points points) + + ;; Reverse the current transformation stack to get the base rectangle + tr-inverse (:transform-inverse shape (gmt/matrix)) + + points-temp (transform-points points center tr-inverse) + points-temp-dim (calculate-dimensions points-temp) + + ;; This rectangle is the new data for the current rectangle. We want to change our rectangle + ;; to have this width, height, x, y + rect-shape (gco/make-centered-rect center + (:width points-temp-dim) + (:height points-temp-dim)) + rect-points (gpr/rect->points rect-shape) + + [matrix matrix-inverse] (calculate-adjust-matrix points-temp rect-points (:flip-x shape) (:flip-y shape))] + (as-> shape $ + (merge $ rect-shape) + (update $ :x #(mth/precision % 0)) + (update $ :y #(mth/precision % 0)) + (update $ :width #(mth/precision % 0)) + (update $ :height #(mth/precision % 0)) + (update $ :transform #(gmt/multiply (or % (gmt/matrix)) matrix)) + (update $ :transform-inverse #(gmt/multiply matrix-inverse (or % (gmt/matrix)))) + (assoc $ :points (into [] points)) + (assoc $ :selrect (gpr/rect->selrect rect-shape)) + (update $ :rotation #(mod (+ (or % 0) + (or (get-in $ [:modifiers :rotation]) 0)) 360))))) + +(defn apply-transform [shape transform] + (let [apply-transform-fn + (case (:type shape) + :path apply-transform-path + apply-transform-rect)] + (apply-transform-fn shape transform))) + +(defn set-flip [shape modifiers] + (let [rx (get-in modifiers [:resize-vector :x]) + ry (get-in modifiers [:resize-vector :y])] + (cond-> shape + (and rx (< rx 0)) (update :flip-x not) + (and ry (< ry 0)) (update :flip-y not)))) + +(defn transform-shape [shape] + (let [center (gco/center-shape shape)] + (if (and (:modifiers shape) center) + (let [transform (modifiers->transform center (:modifiers shape))] + (-> shape + (set-flip (:modifiers shape)) + (apply-transform transform) + (dissoc :modifiers))) + shape))) + +(defn update-group-selrect [group children] + (let [shape-center (gco/center-shape group) + + ;; Points for every shape inside the group + points (->> children (mapcat :points)) + + ;; Invert to get the points minus the transforms applied to the group + base-points (transform-points points shape-center (:transform-inverse group (gmt/matrix))) + + ;; Defines the new selection rect with its transformations + new-points (-> (gpr/points->selrect base-points) + (gpr/rect->points) + (transform-points shape-center (:transform group (gmt/matrix)))) + + ;; Calculte the new selrect + new-selrect (gpr/points->selrect base-points)] + + ;; Updates the shape and the applytransform-rect will update the other properties + (-> group + (assoc :selrect new-selrect) + (assoc :points new-points) + (apply-transform-rect (gmt/matrix))))) diff --git a/common/app/common/math.cljc b/common/app/common/math.cljc index 9125c7c35..dd16c402e 100644 --- a/common/app/common/math.cljc +++ b/common/app/common/math.cljc @@ -23,8 +23,8 @@ (defn finite? [v] - #?(:cljs (js/isFinite v) - :clj (Double/isFinite v))) + #?(:cljs (and (not (nil? v)) (js/isFinite v)) + :clj (and (not (nil? v)) (Double/isFinite v)))) (defn abs [v] @@ -135,3 +135,6 @@ (if (< num from) from (if (> num to) to num))) + +(defn almost-zero? [num] + (< (abs num) 1e-8)) diff --git a/common/app/common/pages.cljc b/common/app/common/pages.cljc index c5ad442da..c2ad88592 100644 --- a/common/app/common/pages.cljc +++ b/common/app/common/pages.cljc @@ -10,1017 +10,73 @@ (ns app.common.pages "A common (clj/cljs) functions and specs for pages." (:require - [clojure.spec.alpha :as s] [app.common.data :as d] - [app.common.pages-helpers :as cph] - [app.common.exceptions :as ex] - [app.common.geom.shapes :as geom] - [app.common.geom.matrix :as gmt] - [app.common.geom.point :as gpt] - [app.common.spec :as us] - [app.common.uuid :as uuid])) - -(def file-version 1) -(def max-safe-int 9007199254740991) -(def min-safe-int -9007199254740991) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Page Transformation Changes -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -;; --- Specs - -(s/def ::frame-id uuid?) -(s/def ::id uuid?) -(s/def ::name string?) -(s/def ::page-id uuid?) -(s/def ::parent-id uuid?) -(s/def ::string string?) -(s/def ::type keyword?) -(s/def ::uuid uuid?) - -(s/def ::safe-integer - #(and - (integer? %) - (>= % min-safe-int) - (<= % max-safe-int))) -(s/def ::component-id uuid?) -(s/def ::component-file uuid?) -(s/def ::component-root? boolean?) -(s/def ::shape-ref uuid?) - -(s/def ::safe-number - #(and - (number? %) - (>= % min-safe-int) - (<= % max-safe-int))) - -;; GRADIENTS - -(s/def :internal.gradient.stop/color ::string) -(s/def :internal.gradient.stop/opacity ::safe-number) -(s/def :internal.gradient.stop/offset ::safe-number) - -(s/def :internal.gradient/type #{:linear :radial}) -(s/def :internal.gradient/start-x ::safe-number) -(s/def :internal.gradient/start-y ::safe-number) -(s/def :internal.gradient/end-x ::safe-number) -(s/def :internal.gradient/end-y ::safe-number) -(s/def :internal.gradient/width ::safe-number) - -(s/def :internal.gradient/stop - (s/keys :req-un [:internal.gradient.stop/color - :internal.gradient.stop/opacity - :internal.gradient.stop/offset])) - -(s/def :internal.gradient/stops - (s/coll-of :internal.gradient/stop :kind vector?)) - -(s/def ::gradient - (s/keys :req-un [:internal.gradient/type - :internal.gradient/start-x - :internal.gradient/start-y - :internal.gradient/end-x - :internal.gradient/end-y - :internal.gradient/width - :internal.gradient/stops])) - - -;;; COLORS - -(s/def :internal.color/name ::string) -(s/def :internal.color/value (s/nilable ::string)) -(s/def :internal.color/color (s/nilable ::string)) -(s/def :internal.color/opacity (s/nilable ::safe-number)) -(s/def :internal.color/gradient (s/nilable ::gradient)) - -(s/def ::color - (s/keys :opt-un [::id - :internal.color/name - :internal.color/value - :internal.color/color - :internal.color/opacity - :internal.color/gradient])) - - - -;;; SHADOW EFFECT - -(s/def :internal.shadow/id uuid?) -(s/def :internal.shadow/style #{:drop-shadow :inner-shadow}) -(s/def :internal.shadow/color ::color) -(s/def :internal.shadow/offset-x ::safe-number) -(s/def :internal.shadow/offset-y ::safe-number) -(s/def :internal.shadow/blur ::safe-number) -(s/def :internal.shadow/spread ::safe-number) -(s/def :internal.shadow/hidden boolean?) - -(s/def :internal.shadow/shadow - (s/keys :req-un [:internal.shadow/id - :internal.shadow/style - :internal.shadow/color - :internal.shadow/offset-x - :internal.shadow/offset-y - :internal.shadow/blur - :internal.shadow/spread - :internal.shadow/hidden])) - -(s/def ::shadow - (s/coll-of :internal.shadow/shadow :kind vector?)) - - -;;; BLUR EFFECT - -(s/def :internal.blur/id uuid?) -(s/def :internal.blur/type #{:layer-blur}) -(s/def :internal.blur/value ::safe-number) -(s/def :internal.blur/hidden boolean?) - -(s/def ::blur - (s/keys :req-un [:internal.blur/id - :internal.blur/type - :internal.blur/value - :internal.blur/hidden])) - -;; Page Options -(s/def :internal.page.grid.color/value string?) -(s/def :internal.page.grid.color/opacity ::safe-number) - -(s/def :internal.page.grid/size ::safe-integer) -(s/def :internal.page.grid/color - (s/keys :req-un [:internal.page.grid.color/value - :internal.page.grid.color/opacity])) - -(s/def :internal.page.grid/type #{:stretch :left :center :right}) -(s/def :internal.page.grid/item-length (s/nilable ::safe-integer)) -(s/def :internal.page.grid/gutter (s/nilable ::safe-integer)) -(s/def :internal.page.grid/margin (s/nilable ::safe-integer)) - -(s/def :internal.page.grid/square - (s/keys :req-un [:internal.page.grid/size - :internal.page.grid/color])) - -(s/def :internal.page.grid/column - (s/keys :req-un [:internal.page.grid/size - :internal.page.grid/color - :internal.page.grid/type - :internal.page.grid/item-length - :internal.page.grid/gutter - :internal.page.grid/margin])) - -(s/def :internal.page.grid/row :internal.page.grid/column) - -(s/def :internal.page.options/background string?) -(s/def :internal.page.options/saved-grids - (s/keys :req-un [:internal.page.grid/square - :internal.page.grid/row - :internal.page.grid/column])) - -(s/def :internal.page/options - (s/keys :opt-un [:internal.page.options/background])) - -;; Interactions - -(s/def :internal.shape.interaction/event-type #{:click}) ; In the future we will have more options -(s/def :internal.shape.interaction/action-type #{:navigate}) -(s/def :internal.shape.interaction/destination ::uuid) - -(s/def :internal.shape/interaction - (s/keys :req-un [:internal.shape.interaction/event-type - :internal.shape.interaction/action-type - :internal.shape.interaction/destination])) - -(s/def :internal.shape/interactions - (s/coll-of :internal.shape/interaction :kind vector?)) - -;; Page Data related -(s/def :internal.shape/blocked boolean?) -(s/def :internal.shape/collapsed boolean?) -(s/def :internal.shape/content any?) - -(s/def :internal.shape/fill-color string?) -(s/def :internal.shape/fill-opacity ::safe-number) -(s/def :internal.shape/fill-gradient (s/nilable ::gradient)) -(s/def :internal.shape/fill-color-ref-file (s/nilable uuid?)) -(s/def :internal.shape/fill-color-ref-id (s/nilable uuid?)) - -(s/def :internal.shape/font-family string?) -(s/def :internal.shape/font-size ::safe-integer) -(s/def :internal.shape/font-style string?) -(s/def :internal.shape/font-weight string?) -(s/def :internal.shape/hidden boolean?) -(s/def :internal.shape/letter-spacing ::safe-number) -(s/def :internal.shape/line-height ::safe-number) -(s/def :internal.shape/locked boolean?) -(s/def :internal.shape/page-id uuid?) -(s/def :internal.shape/proportion ::safe-number) -(s/def :internal.shape/proportion-lock boolean?) -(s/def :internal.shape/rx ::safe-number) -(s/def :internal.shape/ry ::safe-number) -(s/def :internal.shape/stroke-color string?) -(s/def :internal.shape/stroke-color-ref-file (s/nilable uuid?)) -(s/def :internal.shape/stroke-color-ref-id (s/nilable uuid?)) -(s/def :internal.shape/stroke-opacity ::safe-number) -(s/def :internal.shape/stroke-style #{:solid :dotted :dashed :mixed :none}) -(s/def :internal.shape/stroke-width ::safe-number) -(s/def :internal.shape/stroke-alignment #{:center :inner :outer}) -(s/def :internal.shape/text-align #{"left" "right" "center" "justify"}) -(s/def :internal.shape/x ::safe-number) -(s/def :internal.shape/y ::safe-number) -(s/def :internal.shape/cx ::safe-number) -(s/def :internal.shape/cy ::safe-number) -(s/def :internal.shape/width ::safe-number) -(s/def :internal.shape/height ::safe-number) -(s/def :internal.shape/index integer?) -(s/def :internal.shape/shadow ::shadow) -(s/def :internal.shape/blur ::blur) - -(s/def :internal.shape/x1 ::safe-number) -(s/def :internal.shape/y1 ::safe-number) -(s/def :internal.shape/x2 ::safe-number) -(s/def :internal.shape/y2 ::safe-number) - -(s/def :internal.shape.export/suffix string?) -(s/def :internal.shape.export/scale ::safe-number) -(s/def :internal.shape/export - (s/keys :req-un [::type - :internal.shape.export/suffix - :internal.shape.export/scale])) - -(s/def :internal.shape/exports - (s/coll-of :internal.shape/export :kind vector?)) - - -(s/def :internal.shape/selrect - (s/keys :req-un [:internal.shape/x - :internal.shape/y - :internal.shape/x1 - :internal.shape/y1 - :internal.shape/x2 - :internal.shape/y2 - :internal.shape/width - :internal.shape/height])) - -(s/def :internal.shape/point - (s/keys :req-un [:internal.shape/x :internal.shape/y])) - -(s/def :internal.shape/points - (s/coll-of :internal.shape/point :kind vector?)) - -(s/def ::shape-attrs - (s/keys :opt-un [:internal.shape/blocked - :internal.shape/collapsed - :internal.shape/content - :internal.shape/fill-color - :internal.shape/fill-color-ref-file - :internal.shape/fill-color-ref-id - :internal.shape/fill-opacity - :internal.shape/font-family - :internal.shape/font-size - :internal.shape/font-style - :internal.shape/font-weight - :internal.shape/hidden - :internal.shape/letter-spacing - :internal.shape/line-height - :internal.shape/locked - :internal.shape/proportion - :internal.shape/proportion-lock - :internal.shape/rx - :internal.shape/ry - :internal.shape/cx - :internal.shape/cy - :internal.shape/x - :internal.shape/y - :internal.shape/exports - :internal.shape/stroke-color - :internal.shape/stroke-color-ref-file - :internal.shape/stroke-color-ref-id - :internal.shape/stroke-opacity - :internal.shape/stroke-style - :internal.shape/stroke-width - :internal.shape/stroke-alignment - :internal.shape/text-align - :internal.shape/width - :internal.shape/height - :internal.shape/interactions - :internal.shape/selrect - :internal.shape/points - :internal.shape/masked-group? - :internal.shape/shadow - :internal.shape/blur])) - -(def component-sync-attrs {:fill-color :fill-group - :fill-color-ref-file :fill-group - :fill-color-ref-id :fill-group - :fill-opacity :fill-group - :content :text-content-group - :font-family :text-font-group - :font-size :text-font-group - :font-style :text-font-group - :font-weight :text-font-group - :letter-spacing :text-display-group - :line-height :text-display-group - :text-align :text-display-group - :stroke-color :stroke-group - :stroke-color-ref-file :stroke-group - :stroke-color-ref-id :stroke-group - :stroke-opacity :stroke-group - :stroke-style :stroke-group - :stroke-width :stroke-group - :stroke-alignment :stroke-group - :width :size-group - :height :size-group - :proportion :size-group - :rx :radius-group - :ry :radius-group - :masked-group? :mask-group}) - -(s/def ::minimal-shape - (s/keys :req-un [::type ::name] - :opt-un [::id])) - -(s/def ::shape - (s/and ::minimal-shape ::shape-attrs - (s/keys :opt-un [::id - ::component-id - ::component-file - ::component-root? - ::shape-ref]))) - -(s/def :internal.page/objects (s/map-of uuid? ::shape)) - -(s/def ::page - (s/keys :req-un [::id - ::name - :internal.page/options - :internal.page/objects])) - - -(s/def ::recent-color - (s/keys :opt-un [:internal.color/value - :internal.color/color - :internal.color/opacity - :internal.color/gradient])) - -(s/def :internal.media-object/name ::string) -(s/def :internal.media-object/path ::string) -(s/def :internal.media-object/width ::safe-integer) -(s/def :internal.media-object/height ::safe-integer) -(s/def :internal.media-object/mtype ::string) -(s/def :internal.media-object/thumb-path ::string) -(s/def :internal.media-object/thumb-width ::safe-integer) -(s/def :internal.media-object/thumb-height ::safe-integer) -(s/def :internal.media-object/thumb-mtype ::string) - -(s/def ::media-object - (s/keys :req-un [::id ::name - :internal.media-object/name - :internal.media-object/path - :internal.media-object/width - :internal.media-object/height - :internal.media-object/mtype - :internal.media-object/thumb-path])) - - -(s/def :internal.file/colors - (s/map-of ::uuid ::color)) - -(s/def :internal.file/recent-colors - (s/coll-of ::recent-color :kind vector?)) - -(s/def :internal.typography/id ::id) -(s/def :internal.typography/name ::string) -(s/def :internal.typography/font-id ::string) -(s/def :internal.typography/font-family ::string) -(s/def :internal.typography/font-variant-id ::string) -(s/def :internal.typography/font-size ::string) -(s/def :internal.typography/font-weight ::string) -(s/def :internal.typography/font-style ::string) -(s/def :internal.typography/line-height ::string) -(s/def :internal.typography/letter-spacing ::string) -(s/def :internal.typography/text-transform ::string) - -(s/def ::typography - (s/keys :req-un [:internal.typography/id - :internal.typography/name - :internal.typography/font-id - :internal.typography/font-family - :internal.typography/font-variant-id - :internal.typography/font-size - :internal.typography/font-weight - :internal.typography/font-style - :internal.typography/line-height - :internal.typography/letter-spacing - :internal.typography/text-transform])) - -(s/def :internal.file/pages - (s/coll-of ::uuid :kind vector?)) - -(s/def :internal.file/media - (s/map-of ::uuid ::media-object)) - -(s/def :internal.file/pages-index - (s/map-of ::uuid ::page)) - -(s/def ::data - (s/keys :req-un [:internal.file/pages-index - :internal.file/pages] - :opt-un [:internal.file/colors - :internal.file/recent-colors - :internal.file/media])) - -(defmulti operation-spec :type) - -(s/def :internal.operations.set/attr keyword?) -(s/def :internal.operations.set/val any?) -(s/def :internal.operations.set/touched - (s/nilable (s/every keyword? :kind set?))) - -(defmethod operation-spec :set [_] - (s/keys :req-un [:internal.operations.set/attr - :internal.operations.set/val])) - -(defmethod operation-spec :set-touched [_] - (s/keys :req-un [:internal.operations.set/touched])) - -(defmulti change-spec :type) - -(s/def :internal.changes.set-option/option any?) -(s/def :internal.changes.set-option/value any?) - -(defmethod change-spec :set-option [_] - (s/keys :req-un [:internal.changes.set-option/option - :internal.changes.set-option/value])) - -(s/def :internal.changes.add-obj/obj ::shape) - -(defmethod change-spec :add-obj [_] - (s/keys :req-un [::id ::page-id ::frame-id - :internal.changes.add-obj/obj] - :opt-un [::parent-id])) - -(s/def ::operation (s/multi-spec operation-spec :type)) -(s/def ::operations (s/coll-of ::operation)) - -(defmethod change-spec :mod-obj [_] - (s/keys :req-un [::id (or ::page-id ::component-id) ::operations])) - -(defmethod change-spec :del-obj [_] - (s/keys :req-un [::id ::page-id])) - -(s/def :internal.changes.reg-objects/shapes - (s/coll-of uuid? :kind vector?)) - -(defmethod change-spec :reg-objects [_] - (s/keys :req-un [::page-id :internal.changes.reg-objects/shapes])) - -(defmethod change-spec :mov-objects [_] - (s/keys :req-un [::page-id ::parent-id ::shapes] - :opt-un [::index])) - -(defmethod change-spec :add-page [_] - (s/or :empty (s/keys :req-un [::id ::name]) - :complete (s/keys :req-un [::page]))) - -(defmethod change-spec :mod-page [_] - (s/keys :req-un [::id ::name])) - -(defmethod change-spec :del-page [_] - (s/keys :req-un [::id])) - -(defmethod change-spec :mov-page [_] - (s/keys :req-un [::id ::index])) - -(defmethod change-spec :add-color [_] - (s/keys :req-un [::color])) - -(defmethod change-spec :mod-color [_] - (s/keys :req-un [::color])) - -(defmethod change-spec :del-color [_] - (s/keys :req-un [::id])) - -(s/def :internal.changes.add-recent-color/color ::recent-color) - -(defmethod change-spec :add-recent-color [_] - (s/keys :req-un [:internal.changes.add-recent-color/color])) - -(s/def :internal.changes.media/object ::media-object) - -(defmethod change-spec :add-media [_] - (s/keys :req-un [:internal.changes.media/object])) - -(defmethod change-spec :mod-media [_] - (s/keys :req-un [:internal.changes.media/object])) - -(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 :mod-component [_] - (s/keys :req-un [::id] - :opt-un [::name :internal.changes.add-component/shapes])) - -(defmethod change-spec :del-component [_] - (s/keys :req-un [::id])) - -(s/def :internal.changes.typography/typography ::typography) - -(defmethod change-spec :add-typography [_] - (s/keys :req-un [:internal.changes.typography/typography])) - -(defmethod change-spec :mod-typography [_] - (s/keys :req-un [:internal.changes.typography/typography])) - -(defmethod change-spec :del-typography [_] - (s/keys :req-un [:internal.typography/id])) - -(s/def ::change (s/multi-spec change-spec :type)) -(s/def ::changes (s/coll-of ::change)) - -(def root uuid/zero) - -(def empty-page-data - {:options {} - :name "Page" - :objects - {root - {:id root - :type :frame - :name "Root Frame"}}}) - -(def empty-file-data - {:version file-version - :pages [] - :pages-index {}}) - -(def default-color "#b1b2b5") ;; $color-gray-20 -(def default-shape-attrs - {:fill-color default-color - :fill-opacity 1}) - -(def default-frame-attrs - {:frame-id uuid/zero - :fill-color "#ffffff" - :fill-opacity 1 - :shapes []}) - -(def ^:private minimal-shapes - [{:type :rect - :name "Rect" - :fill-color default-color - :fill-opacity 1 - :stroke-style :none - :stroke-alignment :center - :stroke-width 0 - :stroke-color "#000000" - :stroke-opacity 0 - :rx 0 - :ry 0} - - {:type :image} - - {:type :icon} - - {:type :circle - :name "Circle" - :fill-color default-color - :fill-opacity 1 - :stroke-style :none - :stroke-alignment :center - :stroke-width 0 - :stroke-color "#000000" - :stroke-opacity 0} - - {:type :path - :name "Path" - :fill-color "#000000" - :fill-opacity 0 - :stroke-style :solid - :stroke-alignment :center - :stroke-width 2 - :stroke-color "#000000" - :stroke-opacity 1 - :segments []} - - {:type :frame - :name "Artboard" - :fill-color "#ffffff" - :fill-opacity 1 - :stroke-style :none - :stroke-alignment :center - :stroke-width 0 - :stroke-color "#000000" - :stroke-opacity 0} - - {:type :curve - :name "Path" - :fill-color "#000000" - :fill-opacity 0 - :stroke-style :solid - :stroke-alignment :center - :stroke-width 2 - :stroke-color "#000000" - :stroke-opacity 1 - :segments []} - - {:type :text - :name "Text" - :content nil}]) - -(defn make-minimal-shape - [type] - (let [shape (d/seek #(= type (:type %)) minimal-shapes)] - (when-not shape - (ex/raise :type :assertion - :code :shape-type-not-implemented - :context {:type type})) - (assoc shape - :id (uuid/next) - :x 0 - :y 0 - :width 1 - :height 1 - :selrect {:x 0 - :x1 0 - :x2 1 - :y 0 - :y1 0 - :y2 1 - :width 1 - :height 1} - :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] - (let [ - pd (assoc empty-page-data - :id id - :name "Page-1")] - (-> empty-file-data - (update :pages conj id) - (update :pages-index assoc id pd))))) - -;; --- Changes Processing Impl - -(defmulti process-change (fn [data change] (:type change))) -(defmulti process-operation (fn [_ op] (:type op))) - -(defn process-changes - [data items] - (->> (us/verify ::changes items) - (reduce #(do - ;; (prn "process-change" (:type %2) (:id %2)) - (or (process-change %1 %2) %1)) - data))) - -(defmethod process-change :set-option - [data {:keys [page-id option value]}] - (d/update-in-when data [:pages-index page-id] - (fn [data] - (let [path (if (seqable? option) option [option])] - (if value - (assoc-in data (into [:options] path) value) - (assoc data :options (d/dissoc-in (:options data) path))))))) - -(defmethod process-change :add-obj - [data {:keys [id obj page-id frame-id parent-id index] :as change}] - (d/update-in-when data [:pages-index page-id] - (fn [data] - (let [parent-id (or parent-id frame-id) - objects (:objects data)] - (when (and (contains? objects parent-id) - (contains? objects frame-id)) - (let [obj (assoc obj - :frame-id frame-id - :parent-id parent-id - :id id)] - (-> data - (update :objects assoc id obj) - (update-in [:objects parent-id :shapes] - (fn [shapes] - (let [shapes (or shapes [])] - (cond - (some #{id} shapes) shapes - (nil? index) (conj shapes id) - :else (cph/insert-at-index shapes index [id])))))))))))) - -(defmethod process-change :mod-obj - [data {:keys [id page-id component-id operations] :as change}] - (let [update-fn (fn [objects] - (if-let [obj (get objects id)] - (assoc objects id (reduce process-operation obj operations)) - objects))] - (if page-id - (d/update-in-when data [:pages-index page-id :objects] update-fn) - (d/update-in-when data [:components component-id :objects] update-fn)))) - -(defmethod process-change :del-obj - [data {:keys [page-id id] :as change}] - (letfn [(delete-object [objects id] - (if-let [target (get objects id)] - (let [parent-id (cph/get-parent id objects) - frame-id (:frame-id target) - parent (get objects parent-id) - objects (dissoc objects id)] - (cond-> objects - (and (not= parent-id frame-id) - (= :group (:type parent))) - (update-in [parent-id :shapes] (fn [s] (filterv #(not= % id) s))) - - (contains? objects frame-id) - (update-in [frame-id :shapes] (fn [s] (filterv #(not= % id) s))) - - (seq (:shapes target)) ; Recursive delete all - ; dependend objects - (as-> $ (reduce delete-object $ (:shapes target))))) - objects))] - (d/update-in-when data [:pages-index page-id :objects] delete-object id))) - -(defn rotation-modifiers - [center shape angle] - (let [displacement (let [shape-center (geom/center shape)] - (-> (gmt/matrix) - (gmt/rotate angle center) - (gmt/rotate (- angle) shape-center)))] - {:rotation angle - :displacement displacement})) - -(defmethod process-change :reg-objects - [data {:keys [page-id shapes]}] - (letfn [(reg-objects [objects] - (reduce #(update %1 %2 update-group %1) objects - (sequence (comp - (mapcat #(cons % (cph/get-parents % objects))) - (map #(get objects %)) - (filter #(= (:type %) :group)) - (map :id) - (distinct)) - shapes))) - (update-group [group objects] - (let [gcenter (geom/center group) - gxfm (comp - (map #(get objects %)) - (map #(-> % - (assoc :modifiers - (rotation-modifiers gcenter % (- (:rotation group 0)))) - (geom/transform-shape)))) - inner-shapes (if (:masked-group? group) - [(first (:shapes group))] - (:shapes group)) - selrect (-> (into [] gxfm inner-shapes) - (geom/selection-rect))] - - ;; Rotate the group shape change the data and rotate back again - (-> group - (assoc-in [:modifiers :rotation] (- (:rotation group 0))) - (geom/transform-shape) - (merge (select-keys selrect [:x :y :width :height])) - (assoc-in [:modifiers :rotation] (:rotation group)) - (geom/transform-shape))))] - - (d/update-in-when data [:pages-index page-id :objects] reg-objects))) - -(defmethod process-change :mov-objects - [data {:keys [parent-id shapes index page-id] :as change}] - (letfn [(is-valid-move? [objects shape-id] - (let [invalid-targets (cph/calculate-invalid-targets shape-id objects)] - (and (not (invalid-targets parent-id)) - (cph/valid-frame-target shape-id parent-id objects)))) - - (insert-items [prev-shapes index shapes] - (let [prev-shapes (or prev-shapes [])] - (if index - (cph/insert-at-index prev-shapes index shapes) - (cph/append-at-the-end prev-shapes shapes)))) - - (check-insert-items [prev-shapes parent index shapes] - (if-not (:masked-group? parent) - (insert-items prev-shapes index shapes) - ;; For masked groups, the first shape is the mask - ;; and it cannot be moved. - (let [mask-id (first prev-shapes) - other-ids (rest prev-shapes) - not-mask-shapes (strip-id shapes mask-id) - new-index (if (nil? index) nil (max (dec index) 0)) - new-shapes (insert-items other-ids new-index not-mask-shapes)] - (d/concat [mask-id] new-shapes)))) - - (strip-id [coll id] - (filterv #(not= % id) coll)) - - (remove-from-old-parent [cpindex objects shape-id] - (let [prev-parent-id (get cpindex shape-id)] - ;; Do nothing if the parent id of the shape is the same as - ;; the new destination target parent id. - (if (= prev-parent-id parent-id) - objects - (loop [sid shape-id - pid prev-parent-id - objects objects] - (let [obj (get objects pid)] - (if (and (= 1 (count (:shapes obj))) - (= sid (first (:shapes obj))) - (= :group (:type obj))) - (recur pid - (:parent-id obj) - (dissoc objects pid)) - (update-in objects [pid :shapes] strip-id sid))))))) - - (update-parent-id [objects id] - (update objects id assoc :parent-id parent-id)) - - ;; Updates the frame-id references that might be outdated - (update-frame-ids [frame-id objects id] - (let [objects (assoc-in objects [id :frame-id] frame-id) - obj (get objects id)] - (cond-> objects - (not= :frame (:type obj)) - (as-> $$ (reduce (partial update-frame-ids frame-id) $$ (:shapes obj)))))) - - (move-objects [objects] - (let [valid? (every? (partial is-valid-move? objects) shapes) - cpindex (reduce (fn [index id] - (let [obj (get objects id)] - (assoc! index id (:parent-id obj)))) - (transient {}) - (keys objects)) - cpindex (persistent! cpindex) - - parent (get-in data [:objects parent-id]) - parent (get objects parent-id) - frame (if (= :frame (:type parent)) - parent - (get objects (:frame-id parent))) - - frm-id (:id frame)] - - (if valid? - (as-> objects $ - (update-in $ [parent-id :shapes] check-insert-items parent index shapes) - (reduce update-parent-id $ shapes) - (reduce (partial remove-from-old-parent cpindex) $ shapes) - (reduce (partial update-frame-ids frm-id) $ (get-in $ [parent-id :shapes]))) - objects)))] - - (d/update-in-when data [:pages-index page-id :objects] move-objects))) - -(defmethod process-change :add-page - [data {:keys [id name page]}] - (cond - (and (string? name) (uuid? id)) - (let [page (assoc empty-page-data - :id id - :name name)] - (-> data - (update :pages conj id) - (update :pages-index assoc id page))) - - (map? page) - (->> data - (update :pages conj (:id page) - (update :pages-index assoc (:id page) page))) - - :else - (ex/raise :type :conflict - :hint "name or page should be provided, never both"))) - -(defmethod process-change :mod-page - [data {:keys [id name]}] - (d/update-in-when data [:pages-index id] assoc :name name)) - -(defmethod process-change :del-page - [data {:keys [id]}] - (-> data - (update :pages (fn [pages] (filterv #(not= % id) pages))) - (update :pages-index dissoc id))) - -(defmethod process-change :mov-page - [data {:keys [id index]}] - (update data :pages cph/insert-at-index index [id])) - -(defmethod process-change :add-color - [data {:keys [color]}] - (update data :colors assoc (:id color) color)) - -(defmethod process-change :mod-color - [data {:keys [color]}] - (d/assoc-in-when data [:colors (:id color)] color)) - -(defmethod process-change :del-color - [data {:keys [id]}] - (update data :colors dissoc id)) - -(defmethod process-change :add-recent-color - [data {:keys [color]}] - ;; Moves the color to the top of the list and then truncates up to 15 - (update data :recent-colors (fn [rc] - (let [rc (conj (filterv (comp not #{color}) (or rc [])) color)] - (if (> (count rc) 15) - (subvec rc 1) - rc))))) - -;; -- Media - -(defmethod process-change :add-media - [data {:keys [object]}] - (update data :media assoc (:id object) object)) - -(defmethod process-change :mod-media - [data {:keys [object]}] - (d/update-in-when data [:media (:id object)] merge object)) - -(defmethod process-change :del-media - [data {:keys [id]}] - (update data :media dissoc id)) - -;; -- Components - -(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 :mod-component - [data {:keys [id name objects]}] - (update-in data [:components id] - #(cond-> % - (some? name) - (assoc :name name) - - (some? objects) - (assoc :objects objects)))) - -(defmethod process-change :del-component - [data {:keys [id]}] - (d/dissoc-in data [:components id])) - -;; -- Typography - -(defmethod process-change :add-typography - [data {:keys [typography]}] - (update data :typographies assoc (:id typography) typography)) - -(defmethod process-change :mod-typography - [data {:keys [typography]}] - (d/update-in-when data [:typographies (:id typography)] merge typography)) - -(defmethod process-change :del-typography - [data {:keys [id]}] - (update data :typographies dissoc id)) - -;; -- Operations - -(defmethod process-operation :set - [shape op] - (let [attr (:attr op) - val (:val op) - ignore (:ignore-touched op) - shape-ref (:shape-ref shape) - group (get component-sync-attrs attr)] - - (cond-> shape - (and shape-ref group (not ignore) (not= val (get shape attr))) - (update :touched #(conj (or % #{}) group)) - - (nil? val) - (dissoc attr) - - (some? val) - (assoc attr val)))) - -(defmethod process-operation :set-touched - [shape op] - (let [touched (:touched op) - shape-ref (:shape-ref shape)] - (if (or (nil? shape-ref) (nil? touched) (empty? touched)) - (dissoc shape :touched) - (assoc shape :touched touched)))) - -(defmethod process-operation :default - [shape op] - (ex/raise :type :not-implemented - :code :operation-not-implemented - :context {:type (:type op)})) - + [app.common.pages.changes :as changes] + [app.common.pages.common :as common] + [app.common.pages.helpers :as helpers] + [app.common.pages.init :as init] + [app.common.pages.spec :as spec] + [clojure.spec.alpha :as s])) + +;; Common +(d/export common/root) +(d/export common/file-version) +(d/export common/default-color) +(d/export common/component-sync-attrs) + +;; Helpers + +(d/export helpers/walk-pages) +(d/export helpers/select-objects) +(d/export helpers/update-object-list) +(d/export helpers/get-root-shape) +(d/export helpers/make-container) +(d/export helpers/page?) +(d/export helpers/component?) +(d/export helpers/get-container) +(d/export helpers/get-shape) +(d/export helpers/get-component) +(d/export helpers/is-master-of) +(d/export helpers/get-component-root) +(d/export helpers/get-children) +(d/export helpers/get-children-objects) +(d/export helpers/get-object-with-children) +(d/export helpers/is-shape-grouped) +(d/export helpers/get-parent) +(d/export helpers/get-parents) +(d/export helpers/generate-child-parent-index) +(d/export helpers/calculate-invalid-targets) +(d/export helpers/valid-frame-target) +(d/export helpers/position-on-parent) +(d/export helpers/insert-at-index) +(d/export helpers/append-at-the-end) +(d/export helpers/select-toplevel-shapes) +(d/export helpers/select-frames) +(d/export helpers/clone-object) +(d/export helpers/indexed-shapes) +(d/export helpers/expand-region-selection) +(d/export helpers/frame-id-by-position) +(d/export helpers/set-touched-group) +(d/export helpers/touched-group?) + +;; Process changes +(d/export changes/process-changes) + +;; Initialization +(d/export init/default-frame-attrs) +(d/export init/default-shape-attrs) +(d/export init/make-file-data) +(d/export init/make-minimal-shape) +(d/export init/make-minimal-group) + +;; Specs + +(s/def ::changes ::spec/changes) +(s/def ::color ::spec/color) +(s/def ::data ::spec/data) +(s/def ::media-object ::spec/media-object) +(s/def ::minimal-shape ::spec/minimal-shape) +(s/def ::page ::spec/page) +(s/def ::recent-color ::spec/recent-color) +(s/def ::shape-attrs ::spec/shape-attrs) +(s/def ::typography ::spec/typography) diff --git a/common/app/common/pages/changes.cljc b/common/app/common/pages/changes.cljc new file mode 100644 index 000000000..c327e0286 --- /dev/null +++ b/common/app/common/pages/changes.cljc @@ -0,0 +1,415 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.pages.changes + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.geom.shapes :as gsh] + [app.common.pages.helpers :as cph] + [app.common.pages.spec :as ps] + [app.common.spec :as us] + [app.common.pages.common :refer [component-sync-attrs]] + [app.common.pages.init :as init] + [app.common.pages.spec :as spec])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Page Transformation Changes +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; --- Changes Processing Impl + +(defmulti process-change (fn [_ change] (:type change))) +(defmulti process-operation (fn [_ op] (:type op))) + +(defn process-changes + [data items] + (->> (us/verify ::spec/changes items) + (reduce #(do + ;; (prn "process-change" (:type %2) (:id %2)) + (or (process-change %1 %2) %1)) + data))) + +(defmethod process-change :set-option + [data {:keys [page-id option value]}] + (d/update-in-when data [:pages-index page-id] + (fn [data] + (let [path (if (seqable? option) option [option])] + (if value + (assoc-in data (into [:options] path) value) + (assoc data :options (d/dissoc-in (:options data) path))))))) + +(defmethod process-change :add-obj + [data {:keys [id obj page-id component-id frame-id parent-id + index ignore-touched]}] + (letfn [(update-fn [data] + (let [parent-id (or parent-id frame-id) + objects (:objects data) + obj (assoc obj + :frame-id frame-id + :parent-id parent-id + :id id)] + (if (and (or (nil? parent-id) (contains? objects parent-id)) + (or (nil? frame-id) (contains? objects frame-id))) + (-> data + (update :objects assoc id obj) + (update-in [:objects parent-id :shapes] + (fn [shapes] + (let [shapes (or shapes [])] + (cond + (some #{id} shapes) + shapes + + (nil? index) + (if (= :frame (:type obj)) + (d/concat [id] shapes) + (conj shapes id)) + + :else + (cph/insert-at-index shapes index [id]))))) + + (cond-> (and (:shape-ref (get-in data [:objects parent-id])) + (not= parent-id frame-id) + (not ignore-touched)) + (update-in [:objects parent-id :touched] + cph/set-touched-group :shapes-group))) + data)))] + (if page-id + (d/update-in-when data [:pages-index page-id] update-fn) + (d/update-in-when data [:components component-id] update-fn)))) + +(defmethod process-change :mod-obj + [data {:keys [id page-id component-id operations]}] + (let [update-fn (fn [objects] + (if-let [obj (get objects id)] + (let [result (reduce process-operation obj operations)] + (us/verify ::spec/shape result) + (assoc objects id result)) + objects))] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] update-fn) + (d/update-in-when data [:components component-id :objects] update-fn)))) + +(defmethod process-change :del-obj + [data {:keys [page-id component-id id ignore-touched]}] + (letfn [(delete-object [objects id] + (if-let [target (get objects id)] + (let [parent-id (cph/get-parent id objects) + frame-id (:frame-id target) + parent (get objects parent-id) + objects (dissoc objects id)] + (cond-> objects + (and (not= parent-id frame-id) + (= :group (:type parent))) + (update-in [parent-id :shapes] (fn [s] (filterv #(not= % id) s))) + + (and (:shape-ref parent) (not ignore-touched)) + (update-in [parent-id :touched] cph/set-touched-group :shapes-group) + + (contains? objects frame-id) + (update-in [frame-id :shapes] (fn [s] (filterv #(not= % id) s))) + + (seq (:shapes target)) ; Recursive delete all + ; dependend objects + (as-> $ (reduce delete-object $ (:shapes target))))) + objects))] + (if page-id + (d/update-in-when data [:pages-index page-id :objects] delete-object id) + (d/update-in-when data [:components component-id :objects] delete-object id)))) + +;; reg-objects operation "regenerates" the values for the parent groups +(defmethod process-change :reg-objects + [data {:keys [page-id component-id shapes]}] + (letfn [(reg-objects [objects] + (reduce #(update %1 %2 update-group %1) objects + (sequence (comp + (mapcat #(cons % (cph/get-parents % objects))) + (map #(get objects %)) + (filter #(= (:type %) :group)) + (map :id) + (distinct)) + shapes))) + (update-group [group objects] + (let [children (->> group :shapes (map #(get objects %)))] + (if (:masked-group? group) + (let [mask (first children)] + (-> group + (merge (select-keys mask [:selrect :points])) + (assoc :x (-> mask :selrect :x) + :y (-> mask :selrect :y) + :width (-> mask :selrect :width) + :height (-> mask :selrect :height)))) + (gsh/update-group-selrect group children))))] + + (if page-id + (d/update-in-when data [:pages-index page-id :objects] reg-objects) + (d/update-in-when data [:components component-id :objects] reg-objects)))) + +(defmethod process-change :mov-objects + [data {:keys [parent-id shapes index page-id component-id ignore-touched]}] + (letfn [(is-valid-move? [objects shape-id] + (let [invalid-targets (cph/calculate-invalid-targets shape-id objects)] + (and (not (invalid-targets parent-id)) + (cph/valid-frame-target shape-id parent-id objects)))) + + (insert-items [prev-shapes index shapes] + (let [prev-shapes (or prev-shapes [])] + (if index + (cph/insert-at-index prev-shapes index shapes) + (cph/append-at-the-end prev-shapes shapes)))) + + (check-insert-items [prev-shapes parent index shapes] + (if-not (:masked-group? parent) + (insert-items prev-shapes index shapes) + ;; For masked groups, the first shape is the mask + ;; and it cannot be moved. + (let [mask-id (first prev-shapes) + other-ids (rest prev-shapes) + not-mask-shapes (strip-id shapes mask-id) + new-index (if (nil? index) nil (max (dec index) 0)) + new-shapes (insert-items other-ids new-index not-mask-shapes)] + (d/concat [mask-id] new-shapes)))) + + (strip-id [coll id] + (filterv #(not= % id) coll)) + + (add-to-parent [parent index shapes] + (cond-> parent + true + (update :shapes check-insert-items parent index shapes) + + (and (:shape-ref parent) (= (:type parent) :group) (not ignore-touched)) + (update :touched cph/set-touched-group :shapes-group))) + + (remove-from-old-parent [cpindex objects shape-id] + (let [prev-parent-id (get cpindex shape-id)] + ;; Do nothing if the parent id of the shape is the same as + ;; the new destination target parent id. + (if (= prev-parent-id parent-id) + objects + (loop [sid shape-id + pid prev-parent-id + objects objects] + (let [obj (get objects pid)] + (if (and (= 1 (count (:shapes obj))) + (= sid (first (:shapes obj))) + (= :group (:type obj))) + (recur pid + (:parent-id obj) + (dissoc objects pid)) + (cond-> objects + true + (update-in [pid :shapes] strip-id sid) + + (and (:shape-ref obj) + (= (:type obj) :group) + (not ignore-touched)) + (update-in [pid :touched] + cph/set-touched-group :shapes-group)))))))) + + (update-parent-id [objects id] + (update objects id assoc :parent-id parent-id)) + + ;; Updates the frame-id references that might be outdated + (assign-frame-id [frame-id objects id] + (let [objects (update objects id assoc :frame-id frame-id) + obj (get objects id)] + (cond-> objects + ;; If we moving frame, the parent frame is the root + ;; and we DO NOT NEED update children because the + ;; children will point correctly to the frame what we + ;; are currently moving + (not= :frame (:type obj)) + (as-> $$ (reduce (partial assign-frame-id frame-id) $$ (:shapes obj)))))) + + (move-objects [objects] + (let [valid? (every? (partial is-valid-move? objects) shapes) + + ;; Create a index of shape ids pointing to the + ;; corresponding parents; used mainly for update old + ;; parents after move operation. + cpindex (reduce (fn [index id] + (let [obj (get objects id)] + (assoc! index id (:parent-id obj)))) + (transient {}) + (keys objects)) + cpindex (persistent! cpindex) + + parent (get objects parent-id) + frame-id (if (= :frame (:type parent)) + (:id parent) + (:frame-id parent))] + + (if (and valid? (seq shapes)) + (as-> objects $ + ;; Add the new shapes to the parent object. + (update $ parent-id #(add-to-parent % index shapes)) + + ;; Update each individual shapre link to the new parent + (reduce update-parent-id $ shapes) + + ;; Analyze the old parents and clear the old links + ;; only if the new parrent is different form old + ;; parent. + (reduce (partial remove-from-old-parent cpindex) $ shapes) + + ;; Ensure that all shapes of the new parent has a + ;; correct link to the topside frame. + (reduce (partial assign-frame-id frame-id) $ shapes)) + objects)))] + + (if page-id + (d/update-in-when data [:pages-index page-id :objects] move-objects) + (d/update-in-when data [:components component-id :objects] move-objects)))) + +(defmethod process-change :add-page + [data {:keys [id name page]}] + (cond + (and (string? name) (uuid? id)) + (let [page (assoc init/empty-page-data + :id id + :name name)] + (-> data + (update :pages conj id) + (update :pages-index assoc id page))) + + (map? page) + (-> data + (update :pages conj (:id page)) + (update :pages-index assoc (:id page) page)) + + :else + (ex/raise :type :conflict + :hint "name or page should be provided, never both"))) + +(defmethod process-change :mod-page + [data {:keys [id name]}] + (d/update-in-when data [:pages-index id] assoc :name name)) + +(defmethod process-change :del-page + [data {:keys [id]}] + (-> data + (update :pages (fn [pages] (filterv #(not= % id) pages))) + (update :pages-index dissoc id))) + +(defmethod process-change :mov-page + [data {:keys [id index]}] + (update data :pages cph/insert-at-index index [id])) + +(defmethod process-change :add-color + [data {:keys [color]}] + (update data :colors assoc (:id color) color)) + +(defmethod process-change :mod-color + [data {:keys [color]}] + (d/assoc-in-when data [:colors (:id color)] color)) + +(defmethod process-change :del-color + [data {:keys [id]}] + (update data :colors dissoc id)) + +(defmethod process-change :add-recent-color + [data {:keys [color]}] + ;; Moves the color to the top of the list and then truncates up to 15 + (update data :recent-colors (fn [rc] + (let [rc (conj (filterv (comp not #{color}) (or rc [])) color)] + (if (> (count rc) 15) + (subvec rc 1) + rc))))) + +;; -- Media + +(defmethod process-change :add-media + [data {:keys [object]}] + (update data :media assoc (:id object) object)) + +(defmethod process-change :mod-media + [data {:keys [object]}] + (d/update-in-when data [:media (:id object)] merge object)) + +(defmethod process-change :del-media + [data {:keys [id]}] + (update data :media dissoc id)) + +;; -- Components + +(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 :mod-component + [data {:keys [id name objects]}] + (update-in data [:components id] + #(cond-> % + (some? name) + (assoc :name name) + + (some? objects) + (assoc :objects objects)))) + +(defmethod process-change :del-component + [data {:keys [id]}] + (d/dissoc-in data [:components id])) + +;; -- Typography + +(defmethod process-change :add-typography + [data {:keys [typography]}] + (update data :typographies assoc (:id typography) typography)) + +(defmethod process-change :mod-typography + [data {:keys [typography]}] + (d/update-in-when data [:typographies (:id typography)] merge typography)) + +(defmethod process-change :del-typography + [data {:keys [id]}] + (update data :typographies dissoc id)) + +;; -- Operations + +(defmethod process-operation :set + [shape op] + (let [attr (:attr op) + val (:val op) + ignore (:ignore-touched op) + shape-ref (:shape-ref shape) + group (get component-sync-attrs attr)] + + (cond-> shape + (and shape-ref group (not ignore) (not= val (get shape attr)) + ;; FIXME: it's difficult to tell if the geometry changes affect + ;; an individual shape inside the component, or are for + ;; the whole component (in which case we shouldn't set + ;; touched). For the moment we disable geometry touched. + (not= group :geometry-group)) + (update :touched cph/set-touched-group group) + + (nil? val) + (dissoc attr) + + (some? val) + (assoc attr val)))) + +(defmethod process-operation :set-touched + [shape op] + (let [touched (:touched op) + shape-ref (:shape-ref shape)] + (if (or (nil? shape-ref) (nil? touched) (empty? touched)) + (dissoc shape :touched) + (assoc shape :touched touched)))) + +(defmethod process-operation :default + [_ op] + (ex/raise :type :not-implemented + :code :operation-not-implemented + :context {:type (:type op)})) + diff --git a/common/app/common/pages/common.cljc b/common/app/common/pages/common.cljc new file mode 100644 index 000000000..2d58b6bbe --- /dev/null +++ b/common/app/common/pages/common.cljc @@ -0,0 +1,56 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.pages.common + (:require + [app.common.uuid :as uuid])) + +(def file-version 4) +(def default-color "#b1b2b5") ;; $color-gray-20 +(def root uuid/zero) + +(def component-sync-attrs + {:fill-color :fill-group + :fill-opacity :fill-group + :fill-color-gradient :fill-group + :fill-color-ref-file :fill-group + :fill-color-ref-id :fill-group + :content :content-group + :font-family :text-font-group + :font-size :text-font-group + :font-style :text-font-group + :font-weight :text-font-group + :letter-spacing :text-display-group + :line-height :text-display-group + :text-align :text-display-group + :stroke-color :stroke-group + :stroke-color-gradient :stroke-group + :stroke-color-ref-file :stroke-group + :stroke-color-ref-id :stroke-group + :stroke-opacity :stroke-group + :stroke-style :stroke-group + :stroke-width :stroke-group + :stroke-alignment :stroke-group + :rx :radius-group + :ry :radius-group + :selrect :geometry-group + :points :geometry-group + :locked :geometry-group + :proportion :geometry-group + :proportion-lock :geometry-group + :x :geometry-group + :y :geometry-group + :width :geometry-group + :height :geometry-group + :transform :geometry-group + :transform-inverse :geometry-group + :shadow :shadow-group + :blur :blur-group + :masked-group? :mask-group}) + diff --git a/common/app/common/pages_helpers.cljc b/common/app/common/pages/helpers.cljc similarity index 78% rename from common/app/common/pages_helpers.cljc rename to common/app/common/pages/helpers.cljc index e9722ec2d..f1435b33e 100644 --- a/common/app/common/pages_helpers.cljc +++ b/common/app/common/pages/helpers.cljc @@ -7,11 +7,12 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.common.pages-helpers +(ns app.common.pages.helpers (:require [app.common.data :as d] - [app.common.uuid :as uuid] - [app.common.geom.shapes :as gsh])) + [app.common.geom.shapes :as gsh] + [app.common.spec :as us] + [app.common.uuid :as uuid])) (defn walk-pages "Go through all pages of a file and apply a function to each one" @@ -38,15 +39,29 @@ (if (:component-id shape) shape (if-let [parent-id (:parent-id shape)] - (get-root-shape (get objects (:parent-id shape)) - objects) + (get-root-shape (get objects parent-id) objects) nil))) +(defn make-container + [page-or-component type] + (assoc page-or-component + :type type)) + +(defn page? + [container] + (us/assert some? (:type container)) + (= (:type container) :page)) + +(defn component? + [container] + (= (:type container) :component)) + (defn get-container - [page-id component-id local-file] - (if (some? page-id) - (get-in local-file [:pages-index page-id]) - (get-in local-file [:components component-id]))) + [id type local-file] + (-> (if (= type :page) + (get-in local-file [:pages-index id]) + (get-in local-file [:components id])) + (assoc :type type))) (defn get-shape [container shape-id] @@ -59,6 +74,12 @@ (get-in libraries [file-id :data]))] (get-in file [:components component-id]))) +(defn is-master-of + [shape-master shape-inst] + (and (:shape-ref shape-inst) + (or (= (:shape-ref shape-inst) (:id shape-master)) + (= (:shape-ref shape-inst) (:shape-ref shape-master))))) + (defn get-component-root [component] (get-in component [:objects (:id component)])) @@ -75,12 +96,12 @@ (defn get-children-objects "Retrieve all children objects recursively for a given object" [id objects] - (map #(get objects %) (get-children id objects))) + (mapv #(get objects %) (get-children id objects))) (defn get-object-with-children - "Retrieve a list with an object and all of its children" + "Retrieve a vector with an object and all of its children" [id objects] - (map #(get objects %) (cons id (get-children id objects)))) + (mapv #(get objects %) (cons id (get-children id objects)))) (defn is-shape-grouped "Checks if a shape is inside a group" @@ -97,7 +118,7 @@ (defn get-parents [shape-id objects] - (let [{:keys [parent-id] :as obj} (get objects shape-id)] + (let [{:keys [parent-id]} (get objects shape-id)] (when parent-id (lazy-seq (cons parent-id (get-parents parent-id objects)))))) @@ -210,50 +231,48 @@ :parent-id parent-id) (some? (:shapes object)) - (assoc :shapes (map :id new-direct-children))) + (assoc :shapes (mapv :id new-direct-children))) new-object (update-new-object new-object object) - new-objects (concat [new-object] new-children) + new-objects (d/concat [new-object] new-children) updated-object (update-original-object object new-object) updated-objects (if (identical? object updated-object) updated-children - (concat [updated-object] updated-children))] + (d/concat [updated-object] updated-children))] [new-object new-objects updated-objects]) (let [child-id (first child-ids) child (get objects child-id) + _ (us/assert some? child) [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)))))))) + (next child-ids) + (d/concat new-direct-children [new-child]) + (d/concat new-children new-child-objects) + (d/concat updated-children updated-child-objects)))))))) (defn indexed-shapes "Retrieves a list with the indexes for each element in the layer tree. This will be used for shift+selection." [objects] - (let [rec-index - (fn rec-index [cur-idx id] - (let [object (get objects id) - red-fn - (fn [cur-idx id] - (let [[prev-idx _] (first cur-idx) - prev-idx (or prev-idx 0) - cur-idx (conj cur-idx [(inc prev-idx) id])] - (rec-index cur-idx id)))] - (reduce red-fn cur-idx (reverse (:shapes object)))))] + (letfn [(red-fn [cur-idx id] + (let [[prev-idx _] (first cur-idx) + prev-idx (or prev-idx 0) + cur-idx (conj cur-idx [(inc prev-idx) id])] + (rec-index cur-idx id))) + (rec-index [cur-idx id] + (let [object (get objects id)] + (reduce red-fn cur-idx (reverse (:shapes object)))))] (into {} (rec-index '() uuid/zero)))) - (defn expand-region-selection "Given a selection selects all the shapes between the first and last in an indexed manner (shift selection)" @@ -264,7 +283,7 @@ (map first)) from (apply min filter-indexes) - to (apply max filter-indexes)] + to (apply max filter-indexes)] (->> indexed-shapes (filter (fn [[idx _]] (and (>= idx from) (<= idx to)))) (map second) @@ -277,3 +296,12 @@ (d/seek #(gsh/has-point? % position)) :id) uuid/zero))) + +(defn set-touched-group + [touched group] + (conj (or touched #{}) group)) + +(defn touched-group? + [shape group] + ((or (:touched shape) #{}) group)) + diff --git a/common/app/common/pages/init.cljc b/common/app/common/pages/init.cljc new file mode 100644 index 000000000..d39ecf1fe --- /dev/null +++ b/common/app/common/pages/init.cljc @@ -0,0 +1,143 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.pages.init + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [app.common.exceptions :as ex] + [app.common.pages.common :refer [file-version default-color]])) + +(def root uuid/zero) + +(def empty-page-data + {:options {} + :name "Page" + :objects + {root + {:id root + :type :frame + :name "Root Frame"}}}) + +(def empty-file-data + {:version file-version + :pages [] + :pages-index {}}) + +(def default-shape-attrs + {:fill-color default-color + :fill-opacity 1}) + +(def default-frame-attrs + {:frame-id uuid/zero + :fill-color "#ffffff" + :fill-opacity 1 + :shapes []}) + +(def ^:private minimal-shapes + [{:type :rect + :name "Rect" + :fill-color default-color + :fill-opacity 1 + :stroke-style :none + :stroke-alignment :center + :stroke-width 0 + :stroke-color "#000000" + :stroke-opacity 0 + :rx 0 + :ry 0} + + {:type :image} + + {:type :icon} + + {:type :circle + :name "Circle" + :fill-color default-color + :fill-opacity 1 + :stroke-style :none + :stroke-alignment :center + :stroke-width 0 + :stroke-color "#000000" + :stroke-opacity 0} + + {:type :path + :name "Path" + :fill-color "#000000" + :fill-opacity 0 + :stroke-style :solid + :stroke-alignment :center + :stroke-width 2 + :stroke-color "#000000" + :stroke-opacity 1} + + {:type :frame + :name "Artboard" + :fill-color "#ffffff" + :fill-opacity 1 + :stroke-style :none + :stroke-alignment :center + :stroke-width 0 + :stroke-color "#000000" + :stroke-opacity 0} + + {:type :text + :name "Text" + :content nil}]) + +(defn make-minimal-shape + [type] + (let [type (cond (= type :curve) :path + :else type) + shape (d/seek #(= type (:type %)) minimal-shapes)] + (when-not shape + (ex/raise :type :assertion + :code :shape-type-not-implemented + :context {:type type})) + + (cond-> shape + :always + (assoc :id (uuid/next)) + + (not= :path (:type shape)) + (assoc :x 0 + :y 0 + :width 1 + :height 1 + :selrect {:x 0 + :y 0 + :x1 0 + :y1 0 + :x2 1 + :y2 1 + :width 1 + :height 1})))) + +(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] + (let [ + pd (assoc empty-page-data + :id id + :name "Page-1")] + (-> empty-file-data + (update :pages conj id) + (update :pages-index assoc id pd))))) diff --git a/common/app/common/pages/migrations.cljc b/common/app/common/pages/migrations.cljc new file mode 100644 index 000000000..5c65aa9ed --- /dev/null +++ b/common/app/common/pages/migrations.cljc @@ -0,0 +1,122 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.pages.migrations + (:require + [app.common.pages :as cp] + [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.path :as gsp] + [app.common.geom.matrix :as gmt] + [app.common.uuid :as uuid] + [app.common.data :as d])) + +;; TODO: revisit this and rename to file-migrations + +(defmulti migrate :version) + +(defn migrate-data + ([data] + (if (= (:version data) cp/file-version) + data + (reduce #(migrate-data %1 %2 (inc %2)) + data + (range (:version data 0) cp/file-version)))) + + ([data _ to-version] + (-> data + (assoc :version to-version) + (migrate)))) + +(defn migrate-file + [file] + (update file :data migrate-data)) + +;; Default handler, noop +(defmethod migrate :default [data] data) + +;; -- MIGRATIONS -- + +;; Ensure that all :shape attributes on shapes are vectors. +(defmethod migrate 2 + [data] + (letfn [(update-object [_ object] + (d/update-when object :shapes + (fn [shapes] + (if (seq? shapes) + (into [] shapes) + shapes)))) + + (update-page [_ page] + (update page :objects #(d/mapm update-object %)))] + + (update data :pages-index #(d/mapm update-page %)))) + +;; Changes paths formats +(defmethod migrate 3 + [data] + (letfn [(migrate-path [shape] + (if-not (contains? shape :content) + (let [content (gsp/segments->content (:segments shape) (:close? shape)) + selrect (gsh/content->selrect content) + points (gsh/rect->points selrect)] + (-> shape + (dissoc :segments) + (dissoc :close?) + (assoc :content content) + (assoc :selrect selrect) + (assoc :points points))) + ;; If the shape contains :content is already in the new format + shape)) + + (fix-frames-selrects [frame] + (if (= (:id frame) uuid/zero) + frame + (let [frame-rect (select-keys frame [:x :y :width :height])] + (-> frame + (assoc :selrect (gsh/rect->selrect frame-rect)) + (assoc :points (gsh/rect->points frame-rect)))))) + + (fix-empty-points [shape] + (let [shape (cond-> shape + (empty? (:selrect shape)) (gsh/setup-selrect))] + (cond-> shape + (empty? (:points shape)) + (assoc :points (gsh/rect->points (:selrect shape)))))) + + (update-object [_ object] + (cond-> object + (= :curve (:type object)) + (assoc :type :path) + + (or (#{:curve :path} (:type object))) + (migrate-path) + + (= :frame (:type object)) + (fix-frames-selrects) + + (and (empty? (:points object)) (not= (:id object) uuid/zero)) + (fix-empty-points) + + :always + (-> + ;; Setup an empty transformation to re-calculate selrects + ;; and points data + (assoc :modifiers {:displacement (gmt/matrix)}) + (gsh/transform-shape)) + + )) + + (update-page [_ page] + (update page :objects #(d/mapm update-object %)))] + + (update data :pages-index #(d/mapm update-page %)))) + +;; We did rollback version 4 migration. +;; Keep this in order to remember the next version to be 5 +(defmethod migrate 4 [data] data) diff --git a/common/app/common/pages/spec.cljc b/common/app/common/pages/spec.cljc new file mode 100644 index 000000000..1cd0a4dfa --- /dev/null +++ b/common/app/common/pages/spec.cljc @@ -0,0 +1,571 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.common.pages.spec + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.spec :as us] + [clojure.spec.alpha :as s])) + +;; --- Specs + +(s/def ::frame-id uuid?) +(s/def ::id uuid?) +(s/def ::name string?) +(s/def ::page-id uuid?) +(s/def ::parent-id uuid?) +(s/def ::string string?) +(s/def ::type keyword?) +(s/def ::uuid uuid?) + +(s/def ::component-id uuid?) +(s/def ::component-file uuid?) +(s/def ::component-root? boolean?) +(s/def ::shape-ref uuid?) + +(s/def ::safe-integer ::us/safe-integer) +(s/def ::safe-number ::us/safe-number) + +(s/def :internal.matrix/a ::us/safe-number) +(s/def :internal.matrix/b ::us/safe-number) +(s/def :internal.matrix/c ::us/safe-number) +(s/def :internal.matrix/d ::us/safe-number) +(s/def :internal.matrix/e ::us/safe-number) +(s/def :internal.matrix/f ::us/safe-number) + +(s/def ::matrix + (s/and (s/keys :req-un [:internal.matrix/a + :internal.matrix/b + :internal.matrix/c + :internal.matrix/d + :internal.matrix/e + :internal.matrix/f]) + gmt/matrix?)) + + +(s/def :internal.point/x ::us/safe-number) +(s/def :internal.point/y ::us/safe-number) + +(s/def ::point + (s/and (s/keys :req-un [:internal.point/x + :internal.point/y]) + gpt/point?)) + +;; GRADIENTS + +(s/def :internal.gradient.stop/color ::string) +(s/def :internal.gradient.stop/opacity ::safe-number) +(s/def :internal.gradient.stop/offset ::safe-number) + +(s/def :internal.gradient/type #{:linear :radial}) +(s/def :internal.gradient/start-x ::safe-number) +(s/def :internal.gradient/start-y ::safe-number) +(s/def :internal.gradient/end-x ::safe-number) +(s/def :internal.gradient/end-y ::safe-number) +(s/def :internal.gradient/width ::safe-number) + +(s/def :internal.gradient/stop + (s/keys :req-un [:internal.gradient.stop/color + :internal.gradient.stop/opacity + :internal.gradient.stop/offset])) + +(s/def :internal.gradient/stops + (s/coll-of :internal.gradient/stop :kind vector?)) + +(s/def ::gradient + (s/keys :req-un [:internal.gradient/type + :internal.gradient/start-x + :internal.gradient/start-y + :internal.gradient/end-x + :internal.gradient/end-y + :internal.gradient/width + :internal.gradient/stops])) + + +;;; COLORS + +(s/def :internal.color/name ::string) +(s/def :internal.color/value (s/nilable ::string)) +(s/def :internal.color/color (s/nilable ::string)) +(s/def :internal.color/opacity (s/nilable ::safe-number)) +(s/def :internal.color/gradient (s/nilable ::gradient)) + +(s/def ::color + (s/keys :opt-un [::id + :internal.color/name + :internal.color/value + :internal.color/color + :internal.color/opacity + :internal.color/gradient])) + + + +;;; SHADOW EFFECT + +(s/def :internal.shadow/id uuid?) +(s/def :internal.shadow/style #{:drop-shadow :inner-shadow}) +(s/def :internal.shadow/color ::color) +(s/def :internal.shadow/offset-x ::safe-number) +(s/def :internal.shadow/offset-y ::safe-number) +(s/def :internal.shadow/blur ::safe-number) +(s/def :internal.shadow/spread ::safe-number) +(s/def :internal.shadow/hidden boolean?) + +(s/def :internal.shadow/shadow + (s/keys :req-un [:internal.shadow/id + :internal.shadow/style + :internal.shadow/color + :internal.shadow/offset-x + :internal.shadow/offset-y + :internal.shadow/blur + :internal.shadow/spread + :internal.shadow/hidden])) + +(s/def ::shadow + (s/coll-of :internal.shadow/shadow :kind vector?)) + + +;;; BLUR EFFECT + +(s/def :internal.blur/id uuid?) +(s/def :internal.blur/type #{:layer-blur}) +(s/def :internal.blur/value ::safe-number) +(s/def :internal.blur/hidden boolean?) + +(s/def ::blur + (s/keys :req-un [:internal.blur/id + :internal.blur/type + :internal.blur/value + :internal.blur/hidden])) + +;; Page Options +(s/def :internal.page.grid.color/value string?) +(s/def :internal.page.grid.color/opacity ::safe-number) + +(s/def :internal.page.grid/size ::safe-integer) +(s/def :internal.page.grid/color + (s/keys :req-un [:internal.page.grid.color/value + :internal.page.grid.color/opacity])) + +(s/def :internal.page.grid/type #{:stretch :left :center :right}) +(s/def :internal.page.grid/item-length (s/nilable ::safe-integer)) +(s/def :internal.page.grid/gutter (s/nilable ::safe-integer)) +(s/def :internal.page.grid/margin (s/nilable ::safe-integer)) + +(s/def :internal.page.grid/square + (s/keys :req-un [:internal.page.grid/size + :internal.page.grid/color])) + +(s/def :internal.page.grid/column + (s/keys :req-un [:internal.page.grid/size + :internal.page.grid/color + :internal.page.grid/type + :internal.page.grid/item-length + :internal.page.grid/gutter + :internal.page.grid/margin])) + +(s/def :internal.page.grid/row :internal.page.grid/column) + +(s/def :internal.page.options/background string?) +(s/def :internal.page.options/saved-grids + (s/keys :req-un [:internal.page.grid/square + :internal.page.grid/row + :internal.page.grid/column])) + +(s/def :internal.page/options + (s/keys :opt-un [:internal.page.options/background])) + +;; Interactions + +(s/def :internal.shape.interaction/event-type #{:click}) ; In the future we will have more options +(s/def :internal.shape.interaction/action-type #{:navigate}) +(s/def :internal.shape.interaction/destination ::uuid) + +(s/def :internal.shape/interaction + (s/keys :req-un [:internal.shape.interaction/event-type + :internal.shape.interaction/action-type + :internal.shape.interaction/destination])) + +(s/def :internal.shape/interactions + (s/coll-of :internal.shape/interaction :kind vector?)) + +;; Page Data related +(s/def :internal.shape/blocked boolean?) +(s/def :internal.shape/collapsed boolean?) +(s/def :internal.shape/content any?) + +(s/def :internal.shape/fill-color string?) +(s/def :internal.shape/fill-opacity ::safe-number) +(s/def :internal.shape/fill-color-gradient (s/nilable ::gradient)) +(s/def :internal.shape/fill-color-ref-file (s/nilable uuid?)) +(s/def :internal.shape/fill-color-ref-id (s/nilable uuid?)) + +(s/def :internal.shape/font-family string?) +(s/def :internal.shape/font-size ::safe-integer) +(s/def :internal.shape/font-style string?) +(s/def :internal.shape/font-weight string?) +(s/def :internal.shape/hidden boolean?) +(s/def :internal.shape/letter-spacing ::safe-number) +(s/def :internal.shape/line-height ::safe-number) +(s/def :internal.shape/locked boolean?) +(s/def :internal.shape/page-id uuid?) +(s/def :internal.shape/proportion ::safe-number) +(s/def :internal.shape/proportion-lock boolean?) +(s/def :internal.shape/rx ::safe-number) +(s/def :internal.shape/ry ::safe-number) +(s/def :internal.shape/stroke-color string?) +(s/def :internal.shape/stroke-color-gradient (s/nilable ::gradient)) +(s/def :internal.shape/stroke-color-ref-file (s/nilable uuid?)) +(s/def :internal.shape/stroke-color-ref-id (s/nilable uuid?)) +(s/def :internal.shape/stroke-opacity ::safe-number) +(s/def :internal.shape/stroke-style #{:solid :dotted :dashed :mixed :none}) +(s/def :internal.shape/stroke-width ::safe-number) +(s/def :internal.shape/stroke-alignment #{:center :inner :outer}) +(s/def :internal.shape/text-align #{"left" "right" "center" "justify"}) +(s/def :internal.shape/x ::safe-number) +(s/def :internal.shape/y ::safe-number) +(s/def :internal.shape/cx ::safe-number) +(s/def :internal.shape/cy ::safe-number) +(s/def :internal.shape/width ::safe-number) +(s/def :internal.shape/height ::safe-number) +(s/def :internal.shape/index integer?) +(s/def :internal.shape/shadow ::shadow) +(s/def :internal.shape/blur ::blur) + +(s/def :internal.shape/x1 ::safe-number) +(s/def :internal.shape/y1 ::safe-number) +(s/def :internal.shape/x2 ::safe-number) +(s/def :internal.shape/y2 ::safe-number) + +(s/def :internal.shape.export/suffix string?) +(s/def :internal.shape.export/scale ::safe-number) +(s/def :internal.shape/export + (s/keys :req-un [::type + :internal.shape.export/suffix + :internal.shape.export/scale])) + +(s/def :internal.shape/exports + (s/coll-of :internal.shape/export :kind vector?)) + +(s/def :internal.shape/selrect + (s/keys :req-un [:internal.shape/x + :internal.shape/y + :internal.shape/x1 + :internal.shape/y1 + :internal.shape/x2 + :internal.shape/y2 + :internal.shape/width + :internal.shape/height])) + +(s/def :internal.shape/points + (s/every ::point :kind vector?)) + +(s/def :internal.shape/shapes + (s/every uuid? :kind vector?)) + +(s/def :internal.shape/transform ::matrix) +(s/def :internal.shape/transform-inverse ::matrix) + +(s/def ::shape-attrs + (s/keys :opt-un [:internal.shape/selrect + :internal.shape/points + :internal.shape/blocked + :internal.shape/collapsed + :internal.shape/content + :internal.shape/fill-color + :internal.shape/fill-opacity + :internal.shape/fill-color-gradient + :internal.shape/fill-color-ref-file + :internal.shape/fill-color-ref-id + :internal.shape/font-family + :internal.shape/font-size + :internal.shape/font-style + :internal.shape/font-weight + :internal.shape/hidden + :internal.shape/letter-spacing + :internal.shape/line-height + :internal.shape/locked + :internal.shape/proportion + :internal.shape/proportion-lock + :internal.shape/rx + :internal.shape/ry + :internal.shape/x + :internal.shape/y + :internal.shape/exports + :internal.shape/shapes + :internal.shape/stroke-color + :internal.shape/stroke-color-ref-file + :internal.shape/stroke-color-ref-id + :internal.shape/stroke-opacity + :internal.shape/stroke-style + :internal.shape/stroke-width + :internal.shape/stroke-alignment + :internal.shape/text-align + :internal.shape/transform + :internal.shape/transform-inverse + :internal.shape/width + :internal.shape/height + :internal.shape/interactions + :internal.shape/masked-group? + :internal.shape/shadow + :internal.shape/blur])) + + + ;; shapes-group is handled differently + +(s/def ::minimal-shape + (s/keys :req-un [::type ::name] + :opt-un [::id])) + +(s/def ::shape + (s/and ::minimal-shape ::shape-attrs + (s/keys :opt-un [::id + ::component-id + ::component-file + ::component-root? + ::shape-ref]))) + +(s/def :internal.page/objects (s/map-of uuid? ::shape)) + +(s/def ::page + (s/keys :req-un [::id + ::name + :internal.page/options + :internal.page/objects])) + + +(s/def ::recent-color + (s/keys :opt-un [:internal.color/value + :internal.color/color + :internal.color/opacity + :internal.color/gradient])) + +(s/def :internal.media-object/name ::string) +(s/def :internal.media-object/path ::string) +(s/def :internal.media-object/width ::safe-integer) +(s/def :internal.media-object/height ::safe-integer) +(s/def :internal.media-object/mtype ::string) +(s/def :internal.media-object/thumb-path ::string) +(s/def :internal.media-object/thumb-width ::safe-integer) +(s/def :internal.media-object/thumb-height ::safe-integer) +(s/def :internal.media-object/thumb-mtype ::string) + +(s/def ::media-object + (s/keys :req-un [::id ::name + :internal.media-object/name + :internal.media-object/path + :internal.media-object/width + :internal.media-object/height + :internal.media-object/mtype + :internal.media-object/thumb-path])) + +(s/def ::media-object-update + (s/keys :req-un [::id] + :req-opt [::name + :internal.media-object/name + :internal.media-object/path + :internal.media-object/width + :internal.media-object/height + :internal.media-object/mtype + :internal.media-object/thumb-path])) + +(s/def :internal.file/colors + (s/map-of ::uuid ::color)) + +(s/def :internal.file/recent-colors + (s/coll-of ::recent-color :kind vector?)) + +(s/def :internal.typography/id ::id) +(s/def :internal.typography/name ::string) +(s/def :internal.typography/font-id ::string) +(s/def :internal.typography/font-family ::string) +(s/def :internal.typography/font-variant-id ::string) +(s/def :internal.typography/font-size ::string) +(s/def :internal.typography/font-weight ::string) +(s/def :internal.typography/font-style ::string) +(s/def :internal.typography/line-height ::string) +(s/def :internal.typography/letter-spacing ::string) +(s/def :internal.typography/text-transform ::string) + +(s/def ::typography + (s/keys :req-un [:internal.typography/id + :internal.typography/name + :internal.typography/font-id + :internal.typography/font-family + :internal.typography/font-variant-id + :internal.typography/font-size + :internal.typography/font-weight + :internal.typography/font-style + :internal.typography/line-height + :internal.typography/letter-spacing + :internal.typography/text-transform])) + +(s/def :internal.file/pages + (s/coll-of ::uuid :kind vector?)) + +(s/def :internal.file/media + (s/map-of ::uuid ::media-object)) + +(s/def :internal.file/pages-index + (s/map-of ::uuid ::page)) + +(s/def ::data + (s/keys :req-un [:internal.file/pages-index + :internal.file/pages] + :opt-un [:internal.file/colors + :internal.file/recent-colors + :internal.file/media])) + +(s/def :internal.container/type #{:page :component}) + +(s/def ::container + (s/keys :req-un [:internal.container/type + ::id + ::name + :internal.page/objects])) + +(defmulti operation-spec :type) + +(s/def :internal.operations.set/attr keyword?) +(s/def :internal.operations.set/val any?) +(s/def :internal.operations.set/touched + (s/nilable (s/every keyword? :kind set?))) + +(defmethod operation-spec :set [_] + (s/keys :req-un [:internal.operations.set/attr + :internal.operations.set/val])) + +(defmethod operation-spec :set-touched [_] + (s/keys :req-un [:internal.operations.set/touched])) + +(defmulti change-spec :type) + +(s/def :internal.changes.set-option/option any?) +(s/def :internal.changes.set-option/value any?) + +(defmethod change-spec :set-option [_] + (s/keys :req-un [:internal.changes.set-option/option + :internal.changes.set-option/value])) + +(s/def :internal.changes.add-obj/obj ::shape) + +(defn- valid-container-id-frame? + [o] + (or (and (contains? o :page-id) + (not (contains? o :component-id)) + (some? (:frame-id o))) + (and (contains? o :component-id) + (not (contains? o :page-id)) + (nil? (:frame-id o))))) + +(defn- valid-container-id? + [o] + (or (and (contains? o :page-id) + (not (contains? o :component-id))) + (and (contains? o :component-id) + (not (contains? o :page-id))))) + +(defmethod change-spec :add-obj [_] + (s/and (s/keys :req-un [::id :internal.changes.add-obj/obj] + :opt-un [::page-id ::component-id ::parent-id ::frame-id]) + valid-container-id-frame?)) + +(s/def ::operation (s/multi-spec operation-spec :type)) +(s/def ::operations (s/coll-of ::operation)) + +(defmethod change-spec :mod-obj [_] + (s/and (s/keys :req-un [::id ::operations] + :opt-un [::page-id ::component-id]) + valid-container-id?)) + +(defmethod change-spec :del-obj [_] + (s/and (s/keys :req-un [::id] + :opt-un [::page-id ::component-id]) + valid-container-id?)) + +(s/def :internal.changes.reg-objects/shapes + (s/coll-of uuid? :kind vector?)) + +(defmethod change-spec :reg-objects [_] + (s/and (s/keys :req-un [:internal.changes.reg-objects/shapes] + :opt-un [::page-id ::component-id]) + valid-container-id?)) + +(defmethod change-spec :mov-objects [_] + (s/and (s/keys :req-un [::parent-id :internal.shape/shapes] + :opt-un [::page-id ::component-id ::index]) + valid-container-id?)) + +(defmethod change-spec :add-page [_] + (s/or :empty (s/keys :req-un [::id ::name]) + :complete (s/keys :req-un [::page]))) + +(defmethod change-spec :mod-page [_] + (s/keys :req-un [::id ::name])) + +(defmethod change-spec :del-page [_] + (s/keys :req-un [::id])) + +(defmethod change-spec :mov-page [_] + (s/keys :req-un [::id ::index])) + +(defmethod change-spec :add-color [_] + (s/keys :req-un [::color])) + +(defmethod change-spec :mod-color [_] + (s/keys :req-un [::color])) + +(defmethod change-spec :del-color [_] + (s/keys :req-un [::id])) + +(s/def :internal.changes.add-recent-color/color ::recent-color) + +(defmethod change-spec :add-recent-color [_] + (s/keys :req-un [:internal.changes.add-recent-color/color])) + +(s/def :internal.changes.media/object ::media-object) + +(defmethod change-spec :add-media [_] + (s/keys :req-un [:internal.changes.media/object])) + +(s/def :internal.changes.media.mod/object ::media-object-update) + +(defmethod change-spec :mod-media [_] + (s/keys :req-un [:internal.changes.media.mod/object])) + +(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 :mod-component [_] + (s/keys :req-un [::id] + :opt-un [::name :internal.changes.add-component/shapes])) + +(defmethod change-spec :del-component [_] + (s/keys :req-un [::id])) + +(s/def :internal.changes.typography/typography ::typography) + +(defmethod change-spec :add-typography [_] + (s/keys :req-un [:internal.changes.typography/typography])) + +(defmethod change-spec :mod-typography [_] + (s/keys :req-un [:internal.changes.typography/typography])) + +(defmethod change-spec :del-typography [_] + (s/keys :req-un [:internal.typography/id])) + +(s/def ::change (s/multi-spec change-spec :type)) +(s/def ::changes (s/coll-of ::change)) diff --git a/common/app/common/pages_migrations.cljc b/common/app/common/pages_migrations.cljc deleted file mode 100644 index 33171c9fb..000000000 --- a/common/app/common/pages_migrations.cljc +++ /dev/null @@ -1,54 +0,0 @@ -(ns app.common.pages-migrations - (:require - [app.common.pages :as cp] - [app.common.geom.shapes :as gsh] - [app.common.geom.point :as gpt] - [app.common.geom.matrix :as gmt] - [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.common.data :as d])) - -;; TODO: revisit this and rename to file-migrations - -(defmulti migrate :version) - -(defn migrate-data - ([data] - (if (= (:version data) cp/file-version) - data - (reduce #(migrate-data %1 %2 (inc %2)) - data - (range (:version data 0) cp/file-version)))) - - ([data from-version to-version] - (-> data - (assoc :version to-version) - (migrate)))) - -(defn migrate-file - [file] - (update file :data migrate-data)) - -;; Default handler, noop -(defmethod migrate :default [data] data) - -;; -- MIGRATIONS -- - -(defn- generate-child-parent-index - [objects] - (reduce-kv - (fn [index id obj] - (into index (map #(vector % id) (:shapes obj [])))) - {} objects)) - -;; (defmethod migrate 5 -;; [data] -;; (update data :objects -;; (fn [objects] -;; (let [index (generate-child-parent-index objects)] -;; (d/mapm -;; (fn [id obj] -;; (let [parent-id (get index id)] -;; (assoc obj :parent-id parent-id))) -;; objects))))) - diff --git a/common/app/common/spec.cljc b/common/app/common/spec.cljc index b9ab80199..10a174deb 100644 --- a/common/app/common/spec.cljc +++ b/common/app/common/spec.cljc @@ -15,9 +15,6 @@ #?(:clj [clojure.spec.alpha :as s] :cljs [cljs.spec.alpha :as s]) - #?(:clj [clojure.spec.test.alpha :as stest] - :cljs [cljs.spec.test.alpha :as stest]) - [expound.alpha :as expound] [app.common.uuid :as uuid] [app.common.exceptions :as ex] @@ -34,6 +31,9 @@ (def uuid-rx #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +(def max-safe-int (int 1e6)) +(def min-safe-int (int -1e6)) + ;; --- Conformers (defn- uuid-conformer @@ -119,6 +119,19 @@ (s/def ::url string?) (s/def ::fn fn?) (s/def ::point gpt/point?) +(s/def ::id ::uuid) + +(s/def ::safe-integer + #(and + (integer? %) + (>= % min-safe-int) + (<= % max-safe-int))) + +(s/def ::safe-number + #(and + (number? %) + (>= % min-safe-int) + (<= % max-safe-int))) ;; --- Macros @@ -176,9 +189,7 @@ (let [edata (s/explain-data spec data)] (throw (ex/error :type :validation :code :spec-validation - :explain (with-out-str - (expound/printer edata)) - :data (::s/problems edata))))) + :data data)))) result)) (defmacro instrument! diff --git a/common/app/common/version.cljc b/common/app/common/version.cljc new file mode 100644 index 000000000..d02fe513e --- /dev/null +++ b/common/app/common/version.cljc @@ -0,0 +1,27 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 Andrey Antukh + +(ns app.common.version + "A version parsing helper." + (:require + [app.common.data :as d] + [cuerdas.core :as str])) + +(defn parse + [version] + (if (= version "%version%") + {:full "develop" + :base "develop" + :build 0 + :commit nil} + (let [[base build commit] (str/split version #"-" 3)] + {:full version + :base base + :build (d/parse-integer build) + :commit commit}))) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 75e04f06f..6d345686d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -2,10 +2,9 @@ FROM debian:buster LABEL maintainer="Andrey Antukh " ARG DEBIAN_FRONTEND=noninteractive -ARG EXTERNAL_UID=1000 -ENV NODE_VERSION=v14.15.0 \ - CLOJURE_VERSION=1.10.1.727 \ +ENV NODE_VERSION=v14.15.1 \ + CLOJURE_VERSION=1.10.1.739 \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 @@ -25,13 +24,14 @@ RUN set -ex; \ bash \ git \ rlwrap \ + unzip \ ; \ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ locale-gen; \ rm -rf /var/lib/apt/lists/*; RUN set -ex; \ - useradd -m -g users -s /bin/bash -u $EXTERNAL_UID penpot; \ + useradd -m -g users -s /bin/bash penpot; \ passwd penpot -d; \ echo "penpot ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers @@ -120,19 +120,26 @@ COPY files/phantomjs-mock /usr/bin/phantomjs COPY files/bashrc /root/.bashrc COPY files/vimrc /root/.vimrc COPY files/tmux.conf /root/.tmux.conf -COPY files/start-tmux.sh /home/start-tmux.sh -COPY files/entrypoint.sh /home/entrypoint.sh -COPY files/init.sh /home/init.sh -USER penpot -WORKDIR /home/penpot +WORKDIR /home RUN set -ex; \ - git clone https://github.com/creationix/nvm.git .nvm; \ - bash -c "source .nvm/nvm.sh && nvm install $NODE_VERSION"; \ - bash -c "source .nvm/nvm.sh && nvm alias default $NODE_VERSION"; \ - bash -c "source .nvm/nvm.sh && nvm use default"; \ - bash -c "source .nvm/nvm.sh && npm install -g yarn"; + mkdir -p /tmp/node; \ + cd /tmp/node; \ + export PATH="$PATH:/usr/local/nodejs/bin"; \ + wget https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.xz; \ + tar xvf node-$NODE_VERSION-linux-x64.tar.xz; \ + mv /tmp/node/node-$NODE_VERSION-linux-x64 /usr/local/nodejs; \ + chown -R root /usr/local/nodejs; \ + /usr/local/nodejs/bin/npm install -g yarn; \ + rm -rf /tmp/node; + +RUN set -ex; \ + cd /tmp; \ + wget https://github.com/borkdude/clj-kondo/releases/download/v2020.11.07/clj-kondo-2020.11.07-linux-amd64.zip; \ + unzip clj-kondo-2020.11.07-linux-amd64.zip; \ + mv clj-kondo /usr/local/bin/; \ + rm clj-kondo-2020.11.07-linux-amd64.zip; EXPOSE 3447 EXPOSE 3448 @@ -140,5 +147,10 @@ EXPOSE 3449 EXPOSE 6060 EXPOSE 9090 +COPY files/start-tmux.sh /home/start-tmux.sh +COPY files/entrypoint.sh /home/entrypoint.sh +COPY files/init.sh /home/init.sh +COPY files/bashrc /home/penpot/.bashrc + ENTRYPOINT ["/home/entrypoint.sh"] CMD ["/home/init.sh"] diff --git a/docker/devenv/docker-compose.yaml b/docker/devenv/docker-compose.yaml index 21978864f..286f2e103 100644 --- a/docker/devenv/docker-compose.yaml +++ b/docker/devenv/docker-compose.yaml @@ -14,7 +14,7 @@ volumes: services: main: privileged: true - image: "penpot-devenv" + image: "penpotapp/devenv:latest" build: context: "." container_name: "penpot-devenv-main" @@ -42,6 +42,7 @@ services: - APP_DATABASE_USERNAME=penpot - APP_DATABASE_PASSWORD=penpot - APP_REDIS_URI=redis://redis/0 + - EXTERNAL_UID=${CURRENT_USER_ID} postgres: image: postgres:13 diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 246700b75..3f1ddd20b 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -1,12 +1,9 @@ -export PATH=$HOME/.local/bin:$PATH +#!/usr/bin/env bash + +export PATH=/usr/local/nodejs/bin:$PATH alias l='ls --color -GFlh' alias rm='rm -r' alias ls='ls --color -F' alias lsd='ls -d *(/)' alias lsf='ls -h *(.)' - -export LEIN_FAST_TRAMPOLINE=y - -export NVM_DIR="$HOME/.nvm" -[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # This loads nvm diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index 87f47077f..975494219 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -1,12 +1,6 @@ #!/usr/bin/env bash set -e - -sudo cp /root/.bashrc /home/penpot/.bashrc -sudo cp /root/.vimrc /home/penpot/.vimrc -sudo cp /root/.tmux.conf /home/penpot/.tmux.conf - -source /home/penpot/.bashrc -sudo chown penpot:users /home/penpot +usermod -u ${EXTERNAL_UID:-1000} penpot exec "$@" diff --git a/docker/devenv/files/init.sh b/docker/devenv/files/init.sh index 0a5a754fb..2f809ebbb 100755 --- a/docker/devenv/files/init.sh +++ b/docker/devenv/files/init.sh @@ -1,10 +1,5 @@ #!/usr/bin/env bash -set -e; -source ~/.bashrc - -echo "[init.sh] Start nginx." -sudo nginx - -echo "[init.sh] Ready!" +set -e +nginx tail -f /dev/null diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 2ae400e69..cc33ec476 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -61,7 +61,6 @@ http { location / { root /home/penpot/penpot/frontend/resources/public; - try_files $uri /index.html; add_header Cache-Control "no-cache, max-age=0"; } diff --git a/docker/devenv/files/start-tmux.sh b/docker/devenv/files/start-tmux.sh index e65806965..1c0202a66 100755 --- a/docker/devenv/files/start-tmux.sh +++ b/docker/devenv/files/start-tmux.sh @@ -1,5 +1,12 @@ #!/usr/bin/env bash +sudo cp /root/.bashrc /home/penpot/.bashrc +sudo cp /root/.vimrc /home/penpot/.vimrc +sudo cp /root/.tmux.conf /home/penpot/.tmux.conf + +source /home/penpot/.bashrc +sudo chown penpot:users /home/penpot + cd ~; set -e; diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend new file mode 100644 index 000000000..b528e497a --- /dev/null +++ b/docker/images/Dockerfile.backend @@ -0,0 +1,21 @@ +FROM debian:buster-slim +LABEL maintainer="Andrey Antukh " + +ENV LANG='en_US.UTF-8' LC_ALL='en_US.UTF-8' + +RUN set -ex; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install wget locales ca-certificates imagemagick webp gnupg2; \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ + locale-gen; \ + mkdir -p /usr/share/man/man1; \ + mkdir -p /usr/share/man/man7; \ + wget -qO - https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public | apt-key add -; \ + echo "deb https://adoptopenjdk.jfrog.io/adoptopenjdk/deb/ buster main" >> /etc/apt/sources.list.d/adoptopenjdk.list; \ + apt-get -qq update; \ + apt-get -qqy install adoptopenjdk-15-hotspot; \ + rm -rf /var/lib/apt/lists/*; + +ADD ./bundle/backend/ /opt/bundle/ +WORKDIR /opt/bundle +CMD ["/bin/bash", "run.sh"] diff --git a/docker/testenv/Dockerfile-exporter b/docker/images/Dockerfile.exporter similarity index 98% rename from docker/testenv/Dockerfile-exporter rename to docker/images/Dockerfile.exporter index eaeee4a9f..2697d1155 100644 --- a/docker/testenv/Dockerfile-exporter +++ b/docker/images/Dockerfile.exporter @@ -5,7 +5,7 @@ ARG DEBIAN_FRONTEND=noninteractive ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v12.18.4 + NODE_VERSION=v14.15.1 RUN set -ex; \ mkdir -p /etc/resolvconf/resolv.conf.d; \ diff --git a/docker/testenv/Dockerfile-nginx b/docker/images/Dockerfile.frontend similarity index 100% rename from docker/testenv/Dockerfile-nginx rename to docker/images/Dockerfile.frontend diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml new file mode 100644 index 000000000..689b13f09 --- /dev/null +++ b/docker/images/docker-compose.yaml @@ -0,0 +1,68 @@ +--- +version: "3" + +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.177.99.0/24 + +volumes: + postgres_data: + user_data: + backend_data: + +services: + penpot-frontend: + image: "penpotapp/frontend:develop" + ports: + - 8080:80 + + volumes: + - backend_data:/opt/data + + depends_on: + - penpot-backend + - penpot-exporter + + penpot-backend: + image: "penpotapp/backend:develop" + volumes: + - backend_data:/opt/data + + depends_on: + - penpot-postgres + - penpot-redis + + environment: + - APP_DATABASE_URI=postgresql://penpot-postgres/penpot + - APP_DATABASE_USERNAME=penpot + - APP_DATABASE_PASSWORD=penpot + - APP_SMTP_ENABLED=false + - APP_REDIS_URI=redis://penpot-redis/0 + - APP_MEDIA_DIRECTORY=/opt/data/media + + penpot-exporter: + image: "penpotapp/exporter:develop" + environment: + - APP_PUBLIC_URI=http://penpot-frontend + + penpot-postgres: + image: "postgres:13" + restart: always + stop_signal: SIGINT + + environment: + - POSTGRES_INITDB_ARGS=--data-checksums + - POSTGRES_DB=penpot + - POSTGRES_USER=penpot + - POSTGRES_PASSWORD=penpot + + volumes: + - postgres_data:/var/lib/postgresql/data + + penpot-redis: + image: redis:6 + restart: always diff --git a/docker/testenv/files/exporter-entrypoint.sh b/docker/images/files/exporter-entrypoint.sh similarity index 100% rename from docker/testenv/files/exporter-entrypoint.sh rename to docker/images/files/exporter-entrypoint.sh diff --git a/docker/testenv/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh similarity index 100% rename from docker/testenv/files/nginx-entrypoint.sh rename to docker/images/files/nginx-entrypoint.sh diff --git a/docker/testenv/files/nginx.conf b/docker/images/files/nginx.conf similarity index 89% rename from docker/testenv/files/nginx.conf rename to docker/images/files/nginx.conf index af58933d4..c8ceca0f1 100644 --- a/docker/testenv/files/nginx.conf +++ b/docker/images/files/nginx.conf @@ -26,9 +26,9 @@ http { access_log /dev/stdout; gzip on; - gzip_vary on; gzip_proxied any; + gzip_static on; gzip_comp_level 4; gzip_buffers 16 8k; gzip_http_version 1.1; @@ -70,21 +70,21 @@ http { } location /api { - proxy_pass http://172.177.99.3:6060/api; + proxy_pass http://penpot-backend:6060/api; } location /export { - proxy_pass http://172.177.99.4:6061; + proxy_pass http://penpot-exporter:6061; } location /ws/notifications { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_pass http://172.177.99.3:6060/ws/notifications; + proxy_pass http://penpot-backend:6060/ws/notifications; } location /media { - alias /mount/backend/media; + alias /opt/data/media; } } } diff --git a/docker/testenv/Dockerfile-backend b/docker/testenv/Dockerfile-backend deleted file mode 100644 index 03906855e..000000000 --- a/docker/testenv/Dockerfile-backend +++ /dev/null @@ -1,5 +0,0 @@ -FROM adoptopenjdk/openjdk15:debianslim-jre -LABEL maintainer="Andrey Antukh " -ADD ./bundle/backend/ /opt/bundle/ -WORKDIR /opt/bundle -CMD ["/bin/bash", "run.sh"] diff --git a/docker/testenv/docker-compose.yaml b/docker/testenv/docker-compose.yaml deleted file mode 100644 index e266d7d2a..000000000 --- a/docker/testenv/docker-compose.yaml +++ /dev/null @@ -1,112 +0,0 @@ ---- -version: "3" - -networks: - default: - driver: bridge - ipam: - driver: default - config: - - subnet: 172.177.99.0/24 - -volumes: - postgres_data: - user_data: - backend_data: - -services: - nginx: - image: "uxbox-testenv-nginx" - build: - context: "." - dockerfile: "Dockerfile-nginx" - - ports: - - 8080:80 - - networks: - default: - ipv4_address: 172.177.99.2 - - backend: - image: "uxbox-testenv-backend" - build: - context: "." - dockerfile: "Dockerfile-backend" - - volumes: - - backend_data:/opt/data - - depends_on: - - postgres - - smtp - - redis - - environment: - - APP_DATABASE_URI=postgresql://postgres/uxbox - - APP_DATABASE_USERNAME=uxbox - - APP_DATABASE_PASSWORD=uxbox - - APP_SENDMAIL_BACKEND=smtp - - APP_SMTP_HOST=smtp - - APP_SMTP_PORT=25 - - APP_MEDIA_DIRECTORY=/opt/data/media - - networks: - default: - ipv4_address: 172.177.99.3 - - - exporter: - image: "uxbox-testenv-exporter" - build: - context: "." - dockerfile: "Dockerfile-exporter" - - environment: - - APP_PUBLIC_URI=http://nginx - - depends_on: - - backend - - nginx - - networks: - default: - ipv4_address: 172.177.99.4 - - smtp: - image: mwader/postfix-relay:latest - restart: always - environment: - - POSTFIX_myhostname=smtp.testing.uxbox.io - - OPENDKIM_DOMAINS=smtp.testing.uxbox.io - - networks: - default: - ipv4_address: 172.177.99.5 - - postgres: - image: "postgres:12" - restart: always - stop_signal: SIGINT - - environment: - - POSTGRES_INITDB_ARGS=--data-checksums - - POSTGRES_DB=uxbox - - POSTGRES_USER=uxbox - - POSTGRES_PASSWORD=uxbox - - volumes: - - postgres_data:/var/lib/postgresql/data - - networks: - default: - ipv4_address: 172.177.99.6 - - redis: - image: redis:6 - restart: always - - networks: - default: - ipv4_address: 172.177.99.7 - diff --git a/docs/00-Getting-Started.md b/docs/00-Getting-Started.md new file mode 100644 index 000000000..5573e4c00 --- /dev/null +++ b/docs/00-Getting-Started.md @@ -0,0 +1,56 @@ +# Getting Started ## + +This documentation intends to explain how to get penpot application and run it locally. + +The simplest approach is using docker and docker-compose. + +## Install Docker ## + +Skip this section if you alreasdy have docker installed, up and running. + +You can install docker and its dependencies from your distribution +repositores with: + +```bash +sudo apt-get install docker docker-compose +``` + +Or follow installation instructions from docker.com; (for debian +https://docs.docker.com/engine/install/debian/). + +Ensure that the docker is started and optionally enable it to start +with the system: + +```bash +sudo systemctl start docker +sudo systemctl enable docker +``` + +And finally, add your user to the docker group: + +```basb +sudo usermod -aG docker $USER +``` + +This will make use the docker without `sudo` command all the time. + +NOTE: probably you will need to relogin again to make this change +take effect. + + +## Start penpot application ## + +You can create it from scratch or take a base from the [penpot +repository][1] + +[1]: https://raw.githubusercontent.com/penpot/penpot/develop/docker/images/docker-compose.yaml + +```bash +wget https://raw.githubusercontent.com/penpot/penpot/develop/docker/images/docker-compose.yaml +``` + +And then: + +```bash +docker-compose -p penpotest -f docker-compose.yaml up +``` diff --git a/docs/01-Development-Environment.md b/docs/01-Development-Environment.md index bb5771ebf..4115112f2 100644 --- a/docs/01-Development-Environment.md +++ b/docs/01-Development-Environment.md @@ -54,12 +54,13 @@ development environment.** For start it, staying in this repository, execute: ```bash +./manage.sh pull-devenv ./manage.sh run-devenv ``` This will do the following: -- Build the images if it is not done before. +- Pulls the latest devenv image. - Starts all the containers in the background. - Attaches to the **devenv** container and executes the tmux session. - The tmux session automatically starts all the necessary services. @@ -67,7 +68,7 @@ This will do the following: You can execute the individual steps manully if you want: ```bash -./manage.sh build-devenv # builds the devenv docker image +./manage.sh build-devenv # builds the devenv docker image (not necessary in normal sircumstances) ./manage.sh start-devenv # starts background running containers ./manage.sh run-devenv # enters to new tmux session inside of one of the running containers ./manage.sh stop-devenv # stops background running containers @@ -143,47 +144,3 @@ If some exception is raised when code is reloaded, just use later use `(restart)` again. For more information, please refer to: `03-Backend-Guide.md`. - - -## Start the testenv ## - -The purpose of the testenv (Test Environment) is provide an easy way -to get Penpot running in local pc without getting into the full -development environment. - -As first step we still need to build devenv image because that image -is used to produce the production-like bundle of the application: - -```bash -./manage.sh build-devenv -``` - -Once the image is build, you no longer need to rebuilt it until the -devenv image is changed and this happens we make some structural -changes or upgrading some dependencies. - -Them, let's proceed to build the bundle (a directory that contains all -the sources and dependencies of the platform ready to be deployed): - -```bash -./manage.sh build-bundle -``` - -This will generate on current directory one file and one -directory. The most important is the file like -`uxbox-2020.09.09-1343.tar.xz`. - -Then, let's proceed to build the docker images with the bundle -generated from the previous step. - -```bash -./manage.sh build-testenv ./uxbox-2020.09.09-1343.tar.xz -``` - -This will generate the necessary docker images ready to be executed. - -And finally, start the docker-compose: - -```bash -./manage.sh start-testenv -``` diff --git a/docs/05-Management-Guide.md b/docs/05-Management-Guide.md index 485f86cde..f63800a97 100644 --- a/docs/05-Management-Guide.md +++ b/docs/05-Management-Guide.md @@ -13,7 +13,6 @@ Backend accepts a bunch of configuration parameters (detailed above), that can be passed in different ways. The preferred one is using environment variables. - This is a probably incomplete list of available options (with respective defaults): @@ -21,20 +20,19 @@ respective defaults): - `APP_PUBLIC_URI=http://localhost:3449` - `APP_DATABASE_USERNAME=` (default undefined, used from uri) - `APP_DATABASE_PASSWORD=` (default undefined, used from uri) -- `APP_DATABASE_URI=postgresql://127.0.0.1/app` +- `APP_DATABASE_URI=postgresql://127.0.0.1/penpot` - `APP_MEDIA_DIRECTORY=resources/public/media` - `APP_MEDIA_URI=http://localhost:6060/media/` -- `APP_ASSETS_DIRECTORY=resources/public/static` -- `APP_ASSETS_URI=ehttp://localhost:6060/static/` -- `APP_SENDMAIL_BACKEND=console` -- `APP_SENDMAIL_REPLY_TO=no-reply@nodomain.com` -- `APP_SENDMAIL_FROM=no-reply@nodomain.com` +- `APP_SMTP_DEFAULT_REPLY_TO=no-reply@example.com` +- `APP_SMTP_DEFAULT_FROM=no-reply@example.com` +- `APP_SMTP_ENABLED=` (default false, prints to console) - `APP_SMTP_HOST=` (default undefined) - `APP_SMTP_PORT=` (default undefined) - `APP_SMTP_USER=` (default undefined) - `APP_SMTP_PASSWORD=` (default undefined) - `APP_SMTP_SSL=` (default to `false`) - `APP_SMTP_TLS=` (default to `false`) +- `APP_REDIS_URI=redis://localhost/0` - `APP_REGISTRATION_ENABLED=true` - `APP_REGISTRATION_DOMAIN_WHITELIST=""` (comma-separated domains, defaults to `""` which means that all domains are allowed) - `APP_DEBUG_HUMANIZE_TRANSIT=true` @@ -57,6 +55,7 @@ respective defaults): - `APP_GITLAB_CLIENT_SECRET=` (default undefined) - `APP_GITLAB_BASE_URI=` (default https://gitlab.com) + ## REPL ## The production environment by default starts a server REPL where you @@ -64,43 +63,6 @@ can connect and perform diagnosis operations. For this you will need `netcat` or `telnet` installed in the server. ```bash -$ rlwrap netcat localhost 5555 +$ rlwrap netcat localhost 6062 user=> ``` - - -## Import collections ## - -This is the way we can preload default collections of images and icons to the -running platform. - -First of that, you need to have a configuration file (edn format) like -this: - -```clojure -{:icons - [{:name "Generic Icons 1" - :path "./icons/my-icons-collection/" - :regex #"^.*_48px\.svg$"} - ] - :images - [{:name "Generic Images 1" - :path "./images/my-images-collection/" - :regex #"^.*\.(png|jpg|webp)$"}]} -``` - -You can found a real example in `sample_media/config.edn` (that also -has all the material design icon collections). - -Then, you need to execute: - -```bash -clojure -Adev -X:fn-media-loader :path ../path/to/config.edn -``` - -If you have a REPL access to the running process, you can execute it from there: - -```clojure -(require 'app.cli.media-loader) -(uxbox.media-loader/run* "/path/to/config.edn") -``` diff --git a/docs/06-Testing-Guide.md b/docs/06-Testing-Guide.md new file mode 100644 index 000000000..5dc10cf70 --- /dev/null +++ b/docs/06-Testing-Guide.md @@ -0,0 +1,45 @@ +# Testing guide # + +## Backend / Common + +You can run the tests directly with: + +```bash +~/penpot/backend$ clojure -M:dev:tests +``` + +Alternatively, you can run them from a REPL. First starting a REPL. + +```bash +~/penpot/backend$ scripts/repl +``` + +And then: + +```bash +user=> (run-tests) +user=> (run-tests 'namespace) +user=> (run-tests 'namespace/test) +``` + +## Frontend + +Frontend tests have to be compiled first, and then run with node. + +```bash +npx shadow-cljs compile tests && node target/tests.js +``` + +Or run the watch (that automatically runs the test): + +```bash +npx shadow-cljs watch tests +``` + +## Linter + +We can execute the linter for the whole codebase with the following command: + +```bash +clj-kondo --lint common:backend/src:frontend/src +``` diff --git a/exporter/package.json b/exporter/package.json index 7f67fc45f..bb3640b55 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -12,15 +12,15 @@ "inflation": "^2.0.0", "jszip": "^3.5.0", "koa": "^2.13.0", - "puppeteer": "^4.0.1", - "puppeteer-cluster": "^0.21.0", + "puppeteer": "^5.5.0", + "puppeteer-cluster": "^0.22.0", "raw-body": "^2.4.1", "svgo": "^1.3.2", "xml-js": "^1.6.11", - "xregexp": "^4.3.0" + "xregexp": "^4.4.0" }, "devDependencies": { - "shadow-cljs": "^2.10.19", + "shadow-cljs": "^2.11.8", "source-map-support": "^0.5.19" } } diff --git a/exporter/yarn.lock b/exporter/yarn.lock index f60524e9a..7e9c4c2af 100644 --- a/exporter/yarn.lock +++ b/exporter/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@babel/runtime-corejs3@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.10.4.tgz#f29fc1990307c4c57b10dbd6ce667b27159d9e0d" - integrity sha512-BFlgP2SoLO9HJX9WBwN67gHWMBhDX/eDz64Jajd6mR/UAUzqrNMm99d4qHnVaKscAElZoFiPv+JpR/Siud5lXw== +"@babel/runtime-corejs3@^7.12.1": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" + integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== dependencies: core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" @@ -478,6 +478,11 @@ destroy@^1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +devtools-protocol@0.0.818844: + version "0.0.818844" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.818844.tgz#d1947278ec85b53e4c8ca598f607a28fa785ba9e" + integrity sha512-AD1hi7iVJ8OD0aMLQU5VK0XH9LDlA1+BcPIgrAxPfaibx2DbWucuyOhc4oyQCbnvDDO68nN6/LcKfqTP343Jjg== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -625,6 +630,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -915,6 +928,13 @@ lie@~3.3.0: dependencies: immediate "~3.0.5" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -959,11 +979,6 @@ mime-types@^2.1.18, mime-types@~2.1.24: dependencies: mime-db "1.44.0" -mime@^2.0.3: - version "2.4.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== - minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -986,11 +1001,6 @@ minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mitt@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mitt/-/mitt-2.0.1.tgz#9e8a075b4daae82dd91aac155a0ece40ca7cb393" - integrity sha512-FhuJY+tYHLnPcBHQhbUFzscD5512HumCPE4URXZUgPi3IvOJi4Xva5IIgy3xX56GqCmw++MAm5UURG6kDBYTdg== - mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -1018,7 +1028,12 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -node-libs-browser@^2.0.0: +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + +node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== @@ -1121,6 +1136,25 @@ os-browserify@^0.3.0: resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + pako@~1.0.2, pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -1147,6 +1181,11 @@ path-browserify@0.0.1: resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -1168,6 +1207,13 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -1218,23 +1264,24 @@ punycode@^1.2.4: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -puppeteer-cluster@^0.21.0: - version "0.21.0" - resolved "https://registry.yarnpkg.com/puppeteer-cluster/-/puppeteer-cluster-0.21.0.tgz#79071aa9830312446b18ec9d99fe41f6d80ee103" - integrity sha512-/x5mei0vXxFPpJ7iUS+xJ3rOcxxYUa2YeEyuWI9m0M5e8ammPiCXjvOsTcni+4ZAop3L2gpZFkxafPvXWOoRfg== +puppeteer-cluster@^0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/puppeteer-cluster/-/puppeteer-cluster-0.22.0.tgz#4ab214671f414f15ad6a94a4b61ed0b4172e86e6" + integrity sha512-hmydtMwfVM+idFIDzS8OXetnujHGre7RY3BGL+3njy9+r8Dcu3VALkZHfuBEPf6byKssTCgzxU1BvLczifXd5w== dependencies: debug "^4.1.1" -puppeteer@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-4.0.1.tgz#ebc2ee61157ed1aa25be3843fda97807df1d51f5" - integrity sha512-LIiSWTRqpTnnm3R2yAoMBx1inSeKwVZy66RFSkgSTDINzheJZPd5z5mMbPM0FkvwWAZ27a+69j5nZf+Fpyhn3Q== +puppeteer@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.5.0.tgz#331a7edd212ca06b4a556156435f58cbae08af00" + integrity sha512-OM8ZvTXAhfgFA7wBIIGlPQzvyEETzDjeRa4mZRCRHxYL+GNH5WAuYUQdja3rpWZvkX/JKqmuVgbsxDNsDFjMEg== dependencies: debug "^4.1.0" + devtools-protocol "0.0.818844" extract-zip "^2.0.0" https-proxy-agent "^4.0.0" - mime "^2.0.3" - mitt "^2.0.1" + node-fetch "^2.6.1" + pkg-dir "^4.2.0" progress "^2.0.1" proxy-from-env "^1.0.0" rimraf "^3.0.2" @@ -1382,12 +1429,12 @@ shadow-cljs-jar@1.3.2: resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.2.tgz#97273afe1747b6a2311917c1c88d9e243c81957b" integrity sha512-XmeffAZHv8z7451kzeq9oKh8fh278Ak+UIOGGrapyqrFBB773xN8vMQ3O7J7TYLnb9BUwcqadKkmgaq7q6fhZg== -shadow-cljs@^2.10.19: - version "2.11.1" - resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.11.1.tgz#1658278e2fdc7e0239f9573c505d3fbcfd741a31" - integrity sha512-3V+mtrGQwFJcb7DIreKwmCtwLKi/a7r8++mdmSTq2z1HRmcQV9DqIY4y+TLS6HkF/GNSIH7+hyHSH8uLdvsPlQ== +shadow-cljs@^2.11.8: + version "2.11.8" + resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.11.8.tgz#34f579a96f90f79f6fac46ff901d81695c2ea0c0" + integrity sha512-8k2t6lLHDseWTcqizkIyJNVInYTYcd7v8uEE3CWYrlqlNZ+U3TQ4FsUS2pRXUfosNgvdkM7hw61pvwRk+KB5TA== dependencies: - node-libs-browser "^2.0.0" + node-libs-browser "^2.2.1" readline-sync "^1.4.7" shadow-cljs-jar "1.3.2" source-map-support "^0.4.15" @@ -1673,12 +1720,12 @@ xml-js@^1.6.11: dependencies: sax "^1.2.4" -xregexp@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.3.0.tgz#7e92e73d9174a99a59743f67a4ce879a04b5ae50" - integrity sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g== +xregexp@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.4.0.tgz#29660f5d6567cd2ef981dd4a50cb05d22c10719d" + integrity sha512-83y4aa8o8o4NZe+L+46wpa+F1cWR/wCGOWI3tzqUso0w3/KAvXy0+Di7Oe/cbNMixDR4Jmi7NEybWU6ps25Wkg== dependencies: - "@babel/runtime-corejs3" "^7.8.3" + "@babel/runtime-corejs3" "^7.12.1" xtend@^4.0.0: version "4.0.2" diff --git a/frontend/deps.edn b/frontend/deps.edn index 072351097..2e79e6972 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -16,7 +16,7 @@ funcool/okulary {:mvn/version "2020.04.14-0"} funcool/potok {:mvn/version "2020.08.10-2"} funcool/promesa {:mvn/version "6.0.0"} - funcool/rumext {:mvn/version "2020.10.14-1"} + funcool/rumext {:mvn/version "2020.11.27-0"} lambdaisland/uri {:mvn/version "1.4.54" :exclusions [org.clojure/data.json]} diff --git a/frontend/gulpfile.js b/frontend/gulpfile.js index 4e12b670d..c962a72da 100644 --- a/frontend/gulpfile.js +++ b/frontend/gulpfile.js @@ -1,94 +1,33 @@ const fs = require("fs"); -const path = require("path"); const l = require("lodash"); +const path = require("path"); -const CleanCSS = require("clean-css"); const gulp = require("gulp"); -const gulpif = require("gulp-if"); -const gzip = require("gulp-gzip"); - -const mustache = require("gulp-mustache"); -const rename = require("gulp-rename"); +const gulpConcat = require("gulp-concat"); +const gulpGzip = require("gulp-gzip"); +const gulpMustache = require("gulp-mustache"); +const gulpPostcss = require("gulp-postcss"); +const gulpRename = require("gulp-rename"); +const gulpSass = require("gulp-sass"); const svgSprite = require("gulp-svg-sprite"); +const autoprefixer = require("autoprefixer") +const clean = require("postcss-clean"); const mkdirp = require("mkdirp"); const rimraf = require("rimraf"); const sass = require("sass"); -const autoprefixer = require("autoprefixer") -const postcss = require("postcss") const mapStream = require("map-stream"); - - const paths = {}; paths.resources = "./resources/"; paths.output = "./resources/public/"; paths.dist = "./target/dist/"; -paths.scss = "./resources/styles/**/*.scss"; + /*********************************************** * Helpers ***********************************************/ -function isProduction() { - return (process.env.NODE_ENV === "production"); -} - -function scssPipeline(options) { - const write = (_path, data) => { - return new Promise((resolve, reject) => { - fs.writeFile(_path, data, function(err) { - if (err) { reject(err); } - else { resolve(); } - }); - }); - }; - - const touch = (_path) => { - return new Promise((resolve, reject) => { - return fs.utimes(_path, new Date(), new Date(), () => { - resolve(_path); - }); - }) - }; - - const render = (input) => { - return new Promise((resolve, reject) => { - sass.render({file: input}, async function(err, result) { - if (err) { - console.log(err.formatted); - reject(err); - } else { - resolve(result.css); - } - }); - }); - }; - - const postprocess = (data, input, output) => { - return postcss([autoprefixer]) - .process(data, {map: false, from: input, to: output}) - }; - - return function(next) { - const input = options.input; - const output = options.output; - - return mkdirp(path.dirname(output)) - .then(() => render(input)) - .then((res) => postprocess(res, input, output)) - .then(async (res) => { - await write(output, res.css); - await touch(output); - return res; - }) - .catch((err) => null) - .then(() => { - next(); - }); - }; -} - // Templates function readLocales() { @@ -114,7 +53,8 @@ function readManifest() { const content = JSON.parse(fs.readFileSync(path, {encoding: "utf8"})); const index = { - "config": "/js/config.js?ts=" + Date.now() + "config": "/js/config.js?ts=" + Date.now(), + "polyfills": "js/polyfills.js?ts=" + Date.now(), }; for (let item of content) { @@ -126,6 +66,7 @@ function readManifest() { console.error("Error on reading manifest, using default."); return { "config": "/js/config.js", + "polyfills": "js/polyfills.js", "main": "/js/main.js", "shared": "/js/shared.js", "worker": "/js/worker.js" @@ -158,7 +99,7 @@ function templatePipeline(options) { const locales = readLocales(); const manifest = readManifest(); - const tmpl = mustache({ + const tmpl = gulpMustache({ ts: ts, th: th, manifest: manifest, @@ -168,7 +109,7 @@ function templatePipeline(options) { return gulp.src(input) .pipe(tmpl) - .pipe(rename("index.html")) + .pipe(gulpRename("index.html")) .pipe(gulp.dest(output)) .pipe(touch()); }; @@ -178,18 +119,23 @@ function templatePipeline(options) { * Generic ***********************************************/ -gulp.task("scss:main-default", scssPipeline({ - input: paths.resources + "styles/main-default.scss", - output: paths.output + "css/main-default.css" -})); +gulpSass.compiler = sass; -gulp.task("scss", gulp.parallel("scss:main-default")); +gulp.task("scss", function() { + return gulp.src(paths.resources + "styles/main-default.scss") + .pipe(gulpSass().on('error', gulpSass.logError)) + .pipe(gulpPostcss([ + autoprefixer, + // clean({format: "keep-breaks", level: 1}) + ])) + .pipe(gulp.dest(paths.output + "css/")); +}); gulp.task("svg:sprite", function() { return gulp.src(paths.resources + "images/icons/*.svg") - .pipe(rename({prefix: "icon-"})) - .pipe(svgSprite({mode:{symbol: {inline: false}}})) - .pipe(gulp.dest(paths.output + "images/svg-sprite/")); + .pipe(gulpRename({prefix: "icon-"})) + .pipe(svgSprite({mode:{symbol: {inline: true}}})) + .pipe(gulp.dest(paths.output + "images/sprites/")); }); gulp.task("template:main", templatePipeline({ @@ -197,7 +143,13 @@ gulp.task("template:main", templatePipeline({ output: paths.output })); -gulp.task("templates", gulp.series("template:main")); +gulp.task("templates", gulp.series("svg:sprite", "template:main")); + +gulp.task("polyfills", function() { + return gulp.src(paths.resources + "polyfills/*.js") + .pipe(gulpConcat("polyfills.js")) + .pipe(gulp.dest(paths.output + "js/")); +}); /*********************************************** * Development @@ -226,22 +178,16 @@ gulp.task("dev:dirs", async function(next) { }); gulp.task("watch:main", function() { - gulp.watch(paths.scss, gulp.series("scss")); - gulp.watch(paths.resources + "images/**/*", - gulp.series("svg:sprite", "copy:assets:images")); + gulp.watch(paths.resources + "styles/**/**.scss", gulp.series("scss")); + gulp.watch(paths.resources + "images/**/*", gulp.series("copy:assets:images")); gulp.watch([paths.resources + "templates/*.mustache", paths.resources + "locales.json"], gulp.series("templates")); }); -gulp.task("build", gulp.parallel("scss", "svg:sprite", "templates", "copy:assets")); - -gulp.task("watch", gulp.series( - "dev:dirs", - "build", - "watch:main" -)); +gulp.task("build", gulp.parallel("polyfills", "scss", "templates", "copy:assets")); +gulp.task("watch", gulp.series("dev:dirs", "build", "watch:main")); /*********************************************** * Production @@ -258,6 +204,6 @@ gulp.task("dist:copy", function() { gulp.task("dist:gzip", function() { return gulp.src(`${paths.dist}**/!(*.gz|*.br|*.jpg|*.png)`) - .pipe(gzip({gzipOptions: {level: 9}})) + .pipe(gulpGzip({gzipOptions: {level: 9}})) .pipe(gulp.dest(paths.dist)); }); diff --git a/frontend/package.json b/frontend/package.json index c7d4f4b48..4c52e371c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,24 +14,27 @@ "scripts": {}, "devDependencies": { "autoprefixer": "^10.0.1", - "clean-css": "^4.2.3", "gulp": "4.0.2", + "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", - "gulp-if": "^3.0.0", "gulp-mustache": "^5.0.0", + "gulp-postcss": "^9.0.0", "gulp-rename": "^2.0.0", + "gulp-sass": "^4.1.0", + "gulp-sourcemaps": "^3.0.0", "gulp-svg-sprite": "^1.5.0", + "map-stream": "0.0.7", "mkdirp": "^1.0.4", - "postcss": "^8.1.2", + "postcss": "^8.1.7", + "postcss-clean": "^1.1.0", "rimraf": "^3.0.0", "sass": "^1.26.10", "shadow-cljs": "2.11.5" }, "dependencies": { "date-fns": "^2.15.0", - "highlight.js": "^10.3.1", + "highlight.js": "^10.4.1", "js-beautify": "^1.13.0", - "map-stream": "0.0.7", "mousetrap": "^1.6.5", "randomcolor": "^0.6.2", "react": "17.0.1", diff --git a/frontend/resources/images/cursors/comments.svg b/frontend/resources/images/cursors/comments.svg new file mode 100644 index 000000000..4683f3a2e --- /dev/null +++ b/frontend/resources/images/cursors/comments.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/cursors/pen-node.svg b/frontend/resources/images/cursors/pen-node.svg new file mode 100644 index 000000000..ba03c12c6 --- /dev/null +++ b/frontend/resources/images/cursors/pen-node.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/cursors/pointer-move.svg b/frontend/resources/images/cursors/pointer-move.svg new file mode 100644 index 000000000..895bbd8ee --- /dev/null +++ b/frontend/resources/images/cursors/pointer-move.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/pointer-node.svg b/frontend/resources/images/cursors/pointer-node.svg new file mode 100644 index 000000000..185862c1d --- /dev/null +++ b/frontend/resources/images/cursors/pointer-node.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/images/dashboard-img.svg b/frontend/resources/images/dashboard-img.svg deleted file mode 100644 index 00544e925..000000000 --- a/frontend/resources/images/dashboard-img.svg +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - Octoface - - - - Mark Github - - - - Twitter - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/frontend/resources/images/deco-left.png b/frontend/resources/images/deco-left.png new file mode 100644 index 000000000..bd14661c7 Binary files /dev/null and b/frontend/resources/images/deco-left.png differ diff --git a/frontend/resources/images/deco-right.png b/frontend/resources/images/deco-right.png new file mode 100644 index 000000000..cc108924e Binary files /dev/null and b/frontend/resources/images/deco-right.png differ diff --git a/frontend/resources/images/icons/comment.svg b/frontend/resources/images/icons/comment.svg new file mode 100644 index 000000000..f6c098e08 --- /dev/null +++ b/frontend/resources/images/icons/comment.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/icons/icon-list.svg b/frontend/resources/images/icons/icon-list.svg new file mode 100644 index 000000000..eb47d32f2 --- /dev/null +++ b/frontend/resources/images/icons/icon-list.svg @@ -0,0 +1 @@ + diff --git a/frontend/resources/images/icons/nodes-add.svg b/frontend/resources/images/icons/nodes-add.svg new file mode 100644 index 000000000..9c5ecf93a --- /dev/null +++ b/frontend/resources/images/icons/nodes-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-corner.svg b/frontend/resources/images/icons/nodes-corner.svg new file mode 100644 index 000000000..295e316ab --- /dev/null +++ b/frontend/resources/images/icons/nodes-corner.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-curve.svg b/frontend/resources/images/icons/nodes-curve.svg new file mode 100644 index 000000000..b12913fc5 --- /dev/null +++ b/frontend/resources/images/icons/nodes-curve.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-join.svg b/frontend/resources/images/icons/nodes-join.svg new file mode 100644 index 000000000..551451cb9 --- /dev/null +++ b/frontend/resources/images/icons/nodes-join.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-merge.svg b/frontend/resources/images/icons/nodes-merge.svg new file mode 100644 index 000000000..5e0d9c336 --- /dev/null +++ b/frontend/resources/images/icons/nodes-merge.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-remove.svg b/frontend/resources/images/icons/nodes-remove.svg new file mode 100644 index 000000000..e00ecd534 --- /dev/null +++ b/frontend/resources/images/icons/nodes-remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-separate.svg b/frontend/resources/images/icons/nodes-separate.svg new file mode 100644 index 000000000..4e188e3cb --- /dev/null +++ b/frontend/resources/images/icons/nodes-separate.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/nodes-snap.svg b/frontend/resources/images/icons/nodes-snap.svg new file mode 100644 index 000000000..1bd5edac4 --- /dev/null +++ b/frontend/resources/images/icons/nodes-snap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/pen.svg b/frontend/resources/images/icons/pen.svg new file mode 100644 index 000000000..cc3c91147 --- /dev/null +++ b/frontend/resources/images/icons/pen.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/pointer-inner.svg b/frontend/resources/images/icons/pointer-inner.svg new file mode 100644 index 000000000..50798578b --- /dev/null +++ b/frontend/resources/images/icons/pointer-inner.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/login-bg.jpg b/frontend/resources/images/login-bg.jpg deleted file mode 100644 index e670a377f..000000000 Binary files a/frontend/resources/images/login-bg.jpg and /dev/null differ diff --git a/frontend/resources/images/on-design.gif b/frontend/resources/images/on-design.gif new file mode 100644 index 000000000..94a3925b5 Binary files /dev/null and b/frontend/resources/images/on-design.gif differ diff --git a/frontend/resources/images/on-feed.gif b/frontend/resources/images/on-feed.gif new file mode 100644 index 000000000..a850edc58 Binary files /dev/null and b/frontend/resources/images/on-feed.gif differ diff --git a/frontend/resources/images/on-handoff.gif b/frontend/resources/images/on-handoff.gif new file mode 100644 index 000000000..e5feb0af9 Binary files /dev/null and b/frontend/resources/images/on-handoff.gif differ diff --git a/frontend/resources/images/on-proto.gif b/frontend/resources/images/on-proto.gif new file mode 100644 index 000000000..9ccb7fbf8 Binary files /dev/null and b/frontend/resources/images/on-proto.gif differ diff --git a/frontend/resources/images/onboarding-start.jpg b/frontend/resources/images/onboarding-start.jpg new file mode 100644 index 000000000..f089ba4a1 Binary files /dev/null and b/frontend/resources/images/onboarding-start.jpg differ diff --git a/frontend/resources/images/onboarding-team.jpg b/frontend/resources/images/onboarding-team.jpg new file mode 100644 index 000000000..dbd28f9d4 Binary files /dev/null and b/frontend/resources/images/onboarding-team.jpg differ diff --git a/frontend/resources/images/open-source.svg b/frontend/resources/images/open-source.svg new file mode 100644 index 000000000..7bc4f583e --- /dev/null +++ b/frontend/resources/images/open-source.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/resources/images/penpot-login2.jpg b/frontend/resources/images/penpot-login2.jpg deleted file mode 100644 index 3c9409fb5..000000000 Binary files a/frontend/resources/images/penpot-login2.jpg and /dev/null differ diff --git a/frontend/resources/images/pot.png b/frontend/resources/images/pot.png new file mode 100644 index 000000000..ab8a96da4 Binary files /dev/null and b/frontend/resources/images/pot.png differ diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 111dad521..9176932b8 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -18,7 +18,7 @@ } }, "auth.create-demo-account" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:147" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:136", "src/app/main/ui/auth/login.cljs:147" ], "translations" : { "en" : "Create demo account", "fr" : "Vous voulez juste essayer?", @@ -27,7 +27,7 @@ } }, "auth.create-demo-profile" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:144", "src/app/main/ui/auth/register.cljs:133", "src/app/main/ui/auth/register.cljs:136" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:133", "src/app/main/ui/auth/login.cljs:144" ], "translations" : { "en" : "Just wanna try it?", "fr" : "Vous voulez juste essayer?", @@ -45,7 +45,7 @@ } }, "auth.email" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:92", "src/app/main/ui/auth/register.cljs:101", "src/app/main/ui/auth/recovery_request.cljs:47" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:101", "src/app/main/ui/auth/recovery_request.cljs:47", "src/app/main/ui/auth/login.cljs:92" ], "translations" : { "en" : "Email", "fr" : "Adresse email", @@ -56,7 +56,7 @@ "auth.forgot-password" : { "used-in" : [ "src/app/main/ui/auth/login.cljs:122" ], "translations" : { - "en" : "Forgot your password?", + "en" : "Forgot password?", "fr" : "Mot de passe oublié?", "ru" : "Забыли пароль?", "es" : "¿Olvidaste tu contraseña?" @@ -186,7 +186,7 @@ } }, "auth.password" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:99", "src/app/main/ui/auth/register.cljs:106" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:106", "src/app/main/ui/auth/login.cljs:99" ], "translations" : { "en" : "Password", "fr" : "Mot de passe", @@ -224,7 +224,7 @@ "auth.recovery-request-title" : { "used-in" : [ "src/app/main/ui/auth/recovery_request.cljs:61" ], "translations" : { - "en" : "Forgot your password?", + "en" : "Forgot password?", "fr" : "Vous avez oublié votre mot de passe?", "ru" : "Забыли пароль?", "es" : "¿Olvidaste tu contraseña?" @@ -249,7 +249,7 @@ } }, "auth.register-submit" : { - "used-in" : [ "src/app/main/ui/auth/login.cljs:128", "src/app/main/ui/auth/register.cljs:110" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:110", "src/app/main/ui/auth/login.cljs:128" ], "translations" : { "en" : "Create an account", "fr" : "Créer un compte", @@ -303,20 +303,20 @@ } }, "dashboard.create-new-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:155" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:159" ], "translations" : { "en" : "+ Create new team", "es" : "+ Crear nuevo equipo" } }, "dashboard.default-team-name" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:325" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:329" ], "translations" : { "en" : "Your penpot" } }, "dashboard.delete-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:309" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:313" ], "translations" : { "en" : "Delete team" } @@ -340,14 +340,14 @@ } }, "dashboard.invite-profile" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:69" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:72" ], "translations" : { "en" : "Invite to team", "es" : "Invitar al equipo" } }, "dashboard.leave-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:302", "src/app/main/ui/dashboard/sidebar.cljs:305" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:306", "src/app/main/ui/dashboard/sidebar.cljs:309" ], "translations" : { "en" : "Leave team" } @@ -460,6 +460,19 @@ "es" : "+ Nuevo proyecto" } }, + + "labels.num-of-projects" : { + "translations" : { + "en" : ["1 project", "%s projects"] + } + }, + + "labels.num-of-files" : { + "translations" : { + "en" : ["1 file", "%s files"] + } + }, + "dashboard.no-matches-for" : { "used-in" : [ "src/app/main/ui/dashboard/search.cljs:48" ], "translations" : { @@ -470,7 +483,7 @@ } }, "dashboard.no-projects-placeholder" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:423" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:424" ], "translations" : { "en" : "Pinned projects will appear here" } @@ -503,7 +516,7 @@ } }, "dashboard.num-of-members" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:291" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:294" ], "translations" : { "en" : "%s members" } @@ -527,7 +540,7 @@ } }, "dashboard.promote-to-owner" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:193" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:196" ], "translations" : { "en" : "Promote to owner" } @@ -551,7 +564,7 @@ } }, "dashboard.search-placeholder" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:110" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:113" ], "translations" : { "en" : "Search...", "fr" : "Rechercher...", @@ -603,25 +616,25 @@ "unused" : true }, "dashboard.switch-team" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:140" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:144" ], "translations" : { "en" : "Switch Team" } }, "dashboard.team-info" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:274" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:277" ], "translations" : { "en" : "Team info" } }, "dashboard.team-members" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:285" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:288" ], "translations" : { "en" : "Team members" } }, "dashboard.team-projects" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:294" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:297" ], "translations" : { "en" : "Team projects" } @@ -645,7 +658,7 @@ } }, "dashboard.update-settings" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:72", "src/app/main/ui/settings/profile.cljs:80", "src/app/main/ui/settings/password.cljs:96" ], + "used-in" : [ "src/app/main/ui/settings/profile.cljs:80", "src/app/main/ui/settings/password.cljs:96", "src/app/main/ui/settings/options.cljs:72" ], "translations" : { "en" : "Update settings", "fr" : "Mettre à jour les paramètres", @@ -679,7 +692,7 @@ } }, "dashboard.your-penpot" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:144" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:148" ], "translations" : { "en" : "Your penpot" } @@ -756,6 +769,14 @@ "es" : "Actualizado: %s" } }, + "errors.clipboard-not-implemented" : { + "translations" : { + "en" : "Your browser cannot do this operation, please use Ctrl-V", + "fr" : "", + "ru" : "", + "es" : "Tu navegador no puede realizar esta operación, por favor usa Ctrl-V." + } + }, "errors.auth.unauthorized" : { "used-in" : [ "src/app/main/ui/auth/login.cljs:82" ], "translations" : { @@ -766,7 +787,7 @@ } }, "errors.email-already-exists" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:47", "src/app/main/ui/auth/verify_token.cljs:80" ], + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:80", "src/app/main/ui/settings/change_email.cljs:47" ], "translations" : { "en" : "Email already used", "fr" : "Adresse e-mail déjà utilisée", @@ -793,7 +814,7 @@ } }, "errors.generic" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:32", "src/app/main/ui/settings/profile.cljs:40", "src/app/main/ui/auth/verify_token.cljs:89" ], + "used-in" : [ "src/app/main/ui/auth/verify_token.cljs:89", "src/app/main/ui/settings/profile.cljs:40", "src/app/main/ui/settings/options.cljs:32" ], "translations" : { "en" : "Something wrong has happened.", "fr" : "Quelque chose c'est mal passé.", @@ -820,7 +841,7 @@ } }, "errors.media-type-mismatch" : { - "used-in" : [ "src/app/main/data/media.cljs:61", "src/app/main/data/workspace/persistence.cljs:421" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:390", "src/app/main/data/media.cljs:61" ], "translations" : { "en" : "Seems that the contents of the image does not match the file extension.", "fr" : "", @@ -829,7 +850,7 @@ } }, "errors.media-type-not-allowed" : { - "used-in" : [ "src/app/main/data/media.cljs:58", "src/app/main/data/workspace/persistence.cljs:418" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:387", "src/app/main/data/media.cljs:58" ], "translations" : { "en" : "Seems that this is not a valid image.", "fr" : "", @@ -874,7 +895,7 @@ } }, "errors.unexpected-error" : { - "used-in" : [ "src/app/main/data/media.cljs:64", "src/app/main/ui/auth/register.cljs:45", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66" ], + "used-in" : [ "src/app/main/data/media.cljs:64", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66", "src/app/main/ui/auth/register.cljs:45", "src/app/main/ui/handoff/exports.cljs:41" ], "translations" : { "en" : "An unexpected error occurred.", "fr" : "Une erreur inattendue c'est produite", @@ -901,31 +922,31 @@ } }, "handoff.attributes.blur" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/blur.cljs:33" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs:34" ], "translations" : { "en" : "Blur" } }, "handoff.attributes.blur.value" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/blur.cljs:39" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/blur.cljs:40" ], "translations" : { "en" : "Value" } }, "handoff.attributes.color.hex" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/common.cljs:72" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:70" ], "translations" : { "en" : "HEX" } }, "handoff.attributes.color.hsla" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/common.cljs:78" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:76" ], "translations" : { "en" : "HSLA" } }, "handoff.attributes.color.rgba" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/common.cljs:75" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/common.cljs:73" ], "translations" : { "en" : "RGBA" } @@ -937,97 +958,97 @@ "unused" : true }, "handoff.attributes.fill" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/fill.cljs:58" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/fill.cljs:57" ], "translations" : { "en" : "Fill" } }, "handoff.attributes.image.download" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/image.cljs:44" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:45" ], "translations" : { "en" : "Dowload source image" } }, "handoff.attributes.image.height" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/image.cljs:36" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:37" ], "translations" : { "en" : "Height" } }, "handoff.attributes.image.width" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/image.cljs:31" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/image.cljs:32" ], "translations" : { "en" : "Width" } }, "handoff.attributes.layout" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:76" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:76" ], "translations" : { "en" : "Layout" } }, "handoff.attributes.layout.height" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:36" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:43" ], "translations" : { "en" : "Height" } }, "handoff.attributes.layout.left" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:44" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:49" ], "translations" : { "en" : "Left" } }, "handoff.attributes.layout.radius" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:60" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:61" ], "translations" : { "en" : "Radius" } }, "handoff.attributes.layout.rotation" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:60" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:67" ], "translations" : { "en" : "Rotation" } }, "handoff.attributes.layout.top" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:52" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:55" ], "translations" : { "en" : "Top" } }, "handoff.attributes.layout.width" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/layout.cljs:29" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/layout.cljs:38" ], "translations" : { "en" : "Width" } }, "handoff.attributes.shadow" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/shadow.cljs:71" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:71" ], "translations" : { "en" : "Shadow" } }, "handoff.attributes.shadow.shorthand.blur" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/shadow.cljs:44" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:53" ], "translations" : { "en" : "B" } }, "handoff.attributes.shadow.shorthand.offset-x" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/shadow.cljs:36" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:45" ], "translations" : { "en" : "X" } }, "handoff.attributes.shadow.shorthand.offset-y" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/shadow.cljs:40" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:49" ], "translations" : { "en" : "Y" } }, "handoff.attributes.shadow.shorthand.spread" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/shadow.cljs:48" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/shadow.cljs:57" ], "translations" : { "en" : "S" } @@ -1045,7 +1066,7 @@ "unused" : true }, "handoff.attributes.stroke" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/stroke.cljs:75" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs:75" ], "translations" : { "en" : "Stroke" } @@ -1099,49 +1120,49 @@ "unused" : true }, "handoff.attributes.stroke.width" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/stroke.cljs:57" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/stroke.cljs:63" ], "translations" : { "en" : "Width" } }, "handoff.attributes.typography" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:159" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:190" ], "translations" : { "en" : "Typography" } }, "handoff.attributes.typography.font-family" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:89" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:121" ], "translations" : { "en" : "Font Family" } }, "handoff.attributes.typography.font-size" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:101" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:133" ], "translations" : { "en" : "Font Size" } }, "handoff.attributes.typography.font-style" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:95" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:127" ], "translations" : { "en" : "Font Style" } }, "handoff.attributes.typography.letter-spacing" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:113" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:145" ], "translations" : { "en" : "Letter Spacing" } }, "handoff.attributes.typography.line-height" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:107" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:139" ], "translations" : { "en" : "Line Height" } }, "handoff.attributes.typography.text-decoration" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:119" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:151" ], "translations" : { "en" : "Text Decoration" } @@ -1165,7 +1186,7 @@ "unused" : true }, "handoff.attributes.typography.text-transform" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:125" ], + "used-in" : [ "src/app/main/ui/handoff/attributes/text.cljs:157" ], "translations" : { "en" : "Text Transform" } @@ -1195,7 +1216,7 @@ "unused" : true }, "handoff.tabs.code" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/right_sidebar.cljs:78" ], + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:65" ], "translations" : { "en" : "Code" } @@ -1231,7 +1252,7 @@ "unused" : true }, "handoff.tabs.code.selected.multiple" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/right_sidebar.cljs:65" ], + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:48" ], "translations" : { "en" : "%s Selected" } @@ -1255,7 +1276,7 @@ "unused" : true }, "handoff.tabs.info" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/right_sidebar.cljs:74" ], + "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:59" ], "translations" : { "en" : "Info" } @@ -1270,13 +1291,19 @@ "unused" : true }, "labels.admin" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:82", "src/app/main/ui/dashboard/team.cljs:171", "src/app/main/ui/dashboard/team.cljs:187" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:85", "src/app/main/ui/dashboard/team.cljs:174", "src/app/main/ui/dashboard/team.cljs:190" ], "translations" : { "en" : "Admin" } }, + "labels.all" : { + "used-in" : [ "src/app/main/ui/workspace/comments.cljs:161" ], + "translations" : { + "en" : "All" + } + }, "labels.cancel" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:199" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:203" ], "translations" : { "en" : "Cancel", "fr" : "Annuler", @@ -1284,6 +1311,12 @@ "es" : "Cancelar" } }, + "labels.comments" : { + "used-in" : [ "src/app/main/ui/dashboard/comments.cljs:80" ], + "translations" : { + "en" : "Comments" + } + }, "labels.confirm-password" : { "used-in" : [ "src/app/main/ui/settings/password.cljs:93" ], "translations" : { @@ -1300,7 +1333,7 @@ } }, "labels.delete" : { - "used-in" : [ "src/app/main/ui/dashboard/files.cljs:85", "src/app/main/ui/dashboard/grid.cljs:177" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:177", "src/app/main/ui/dashboard/files.cljs:85" ], "translations" : { "en" : "Delete", "fr" : "Supprimer", @@ -1308,8 +1341,20 @@ "es" : "Borrar" } }, + "labels.delete-comment" : { + "used-in" : [ "src/app/main/ui/comments.cljs:274" ], + "translations" : { + "en" : "Delete comment" + } + }, + "labels.delete-comment-thread" : { + "used-in" : [ "src/app/main/ui/comments.cljs:273" ], + "translations" : { + "en" : "Delete thread" + } + }, "labels.drafts" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:402" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:404" ], "translations" : { "en" : "Drafts", "fr" : "Brouillons", @@ -1317,14 +1362,20 @@ "es" : "Borradores" } }, + "labels.edit" : { + "used-in" : [ "src/app/main/ui/comments.cljs:271" ], + "translations" : { + "en" : "Edit" + } + }, "labels.editor" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:83", "src/app/main/ui/dashboard/team.cljs:174", "src/app/main/ui/dashboard/team.cljs:188" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:86", "src/app/main/ui/dashboard/team.cljs:177", "src/app/main/ui/dashboard/team.cljs:191" ], "translations" : { "en" : "Editor" } }, "labels.email" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:109", "src/app/main/ui/dashboard/team.cljs:212" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:112", "src/app/main/ui/dashboard/team.cljs:215" ], "translations" : { "en" : "Email", "fr" : "Adresse email", @@ -1332,6 +1383,12 @@ "es" : "Correo electrónico" } }, + "labels.hide-resolved-comments" : { + "used-in" : [ "src/app/main/ui/workspace/comments.cljs:129", "src/app/main/ui/viewer/header.cljs:175" ], + "translations" : { + "en" : "Hide resolved comments" + } + }, "labels.language" : { "used-in" : [ "src/app/main/ui/settings/options.cljs:54" ], "translations" : { @@ -1342,7 +1399,7 @@ } }, "labels.logout" : { - "used-in" : [ "src/app/main/ui/settings.cljs:31", "src/app/main/ui/dashboard/sidebar.cljs:457" ], + "used-in" : [ "src/app/main/ui/settings.cljs:31", "src/app/main/ui/dashboard/sidebar.cljs:456" ], "translations" : { "en" : "Logout", "fr" : "Quitter", @@ -1351,13 +1408,13 @@ } }, "labels.members" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:295", "src/app/main/ui/dashboard/team.cljs:59", "src/app/main/ui/dashboard/team.cljs:63" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:60", "src/app/main/ui/dashboard/team.cljs:66", "src/app/main/ui/dashboard/sidebar.cljs:299" ], "translations" : { "en" : "Members" } }, "labels.name" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:211" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:214" ], "translations" : { "en" : "Name", "fr" : "Nom", @@ -1374,6 +1431,12 @@ "es" : "Nueva contraseña" } }, + "labels.no-comments-available" : { + "used-in" : [ "src/app/main/ui/dashboard/comments.cljs:104" ], + "translations" : { + "en" : "You have no pending comment notifications" + } + }, "labels.old-password" : { "used-in" : [ "src/app/main/ui/settings/password.cljs:81" ], "translations" : { @@ -1383,14 +1446,20 @@ "es" : "Contraseña anterior" } }, + "labels.only-yours" : { + "used-in" : [ "src/app/main/ui/workspace/comments.cljs:162" ], + "translations" : { + "en" : "Only yours" + } + }, "labels.owner" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:168" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:171", "src/app/main/ui/dashboard/team.cljs:291" ], "translations" : { "en" : "Owner" } }, "labels.password" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:75", "src/app/main/ui/dashboard/sidebar.cljs:454" ], + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:75", "src/app/main/ui/dashboard/sidebar.cljs:453" ], "translations" : { "en" : "Password", "fr" : "Mot de passe", @@ -1399,14 +1468,14 @@ } }, "labels.permissions" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:213" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:216" ], "translations" : { "en" : "Permissions", "es" : "Permisos" } }, "labels.profile" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:70", "src/app/main/ui/dashboard/sidebar.cljs:451" ], + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:70", "src/app/main/ui/dashboard/sidebar.cljs:450" ], "translations" : { "en" : "Profile", "fr" : "Profil", @@ -1415,7 +1484,7 @@ } }, "labels.projects" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:397" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:400" ], "translations" : { "en" : "Projects", "fr" : "Projetes", @@ -1424,7 +1493,7 @@ } }, "labels.remove" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:91", "src/app/main/ui/dashboard/team.cljs:199" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:92", "src/app/main/ui/dashboard/team.cljs:202" ], "translations" : { "en" : "Remove", "fr" : "", @@ -1433,20 +1502,20 @@ } }, "labels.rename" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:298", "src/app/main/ui/dashboard/files.cljs:84", "src/app/main/ui/dashboard/grid.cljs:176" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:176", "src/app/main/ui/dashboard/sidebar.cljs:302", "src/app/main/ui/dashboard/files.cljs:84" ], "translations" : { "en" : "Rename", "es" : "Renombrar" } }, "labels.role" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:81" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:84" ], "translations" : { "en" : "Role" } }, "labels.settings" : { - "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:80", "src/app/main/ui/dashboard/sidebar.cljs:296", "src/app/main/ui/dashboard/team.cljs:65" ], + "used-in" : [ "src/app/main/ui/settings/sidebar.cljs:80", "src/app/main/ui/dashboard/team.cljs:61", "src/app/main/ui/dashboard/team.cljs:68", "src/app/main/ui/dashboard/sidebar.cljs:300" ], "translations" : { "en" : "Settings", "fr" : "Settings", @@ -1455,7 +1524,7 @@ } }, "labels.shared-libraries" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:408" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:409" ], "translations" : { "en" : "Shared Libraries", "fr" : "", @@ -1463,6 +1532,18 @@ "es" : "Bibliotecas Compartidas" } }, + "labels.show-all-comments" : { + "used-in" : [ "src/app/main/ui/workspace/comments.cljs:117", "src/app/main/ui/viewer/header.cljs:163" ], + "translations" : { + "en" : "Show all comments" + } + }, + "labels.show-your-comments" : { + "used-in" : [ "src/app/main/ui/workspace/comments.cljs:122", "src/app/main/ui/viewer/header.cljs:168" ], + "translations" : { + "en" : "Show only yours comments" + } + }, "labels.update" : { "used-in" : [ "src/app/main/ui/settings/profile.cljs:106" ], "translations" : { @@ -1473,14 +1554,20 @@ } }, "labels.viewer" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:84", "src/app/main/ui/dashboard/team.cljs:177", "src/app/main/ui/dashboard/team.cljs:189" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:87", "src/app/main/ui/dashboard/team.cljs:180", "src/app/main/ui/dashboard/team.cljs:192" ], "translations" : { "en" : "Viewer", "es" : "Visualizador" } }, + "labels.write-new-comment" : { + "used-in" : [ "src/app/main/ui/comments.cljs:151" ], + "translations" : { + "en" : "Write new comment" + } + }, "media.loading" : { - "used-in" : [ "src/app/main/data/media.cljs:43", "src/app/main/data/workspace/persistence.cljs:402" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:371", "src/app/main/data/media.cljs:43" ], "translations" : { "en" : "Loading image...", "fr" : "Chargement de l'image...", @@ -1606,30 +1693,21 @@ } }, "modals.delete-comment-thread.accept" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:236" ], + "used-in" : [ "src/app/main/ui/comments.cljs:223" ], "translations" : { - "en" : null, - "fr" : null, - "ru" : null, - "es" : null + "en" : "Delete conversation" } }, "modals.delete-comment-thread.message" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:235" ], + "used-in" : [ "src/app/main/ui/comments.cljs:222" ], "translations" : { - "en" : null, - "fr" : null, - "ru" : null, - "es" : null + "en" : "Are you sure you want to delete this conversation? All comments in this thread will be deleted." } }, "modals.delete-comment-thread.title" : { - "used-in" : [ "src/app/main/ui/workspace/comments.cljs:234" ], + "used-in" : [ "src/app/main/ui/comments.cljs:221" ], "translations" : { - "en" : null, - "fr" : null, - "ru" : null, - "es" : null + "en" : "Delete conversation" } }, "modals.delete-file-confirm.accept" : { @@ -1669,109 +1747,109 @@ } }, "modals.delete-team-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:285" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:289" ], "translations" : { "en" : "Delete team" } }, "modals.delete-team-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:284" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:288" ], "translations" : { "en" : "Are you sure you want to delete this team? All projects and files associated with team will be permanently deleted." } }, "modals.delete-team-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:283" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:287" ], "translations" : { "en" : "Deleting team" } }, "modals.delete-team-member-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:157" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:160" ], "translations" : { "en" : "Delete member" } }, "modals.delete-team-member-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:156" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:159" ], "translations" : { "en" : "Are you sure wan't to delete this user from team?" } }, "modals.delete-team-member-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:155" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:158" ], "translations" : { "en" : "Delete team member" } }, "modals.invite-member.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:105" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:108" ], "translations" : { "en" : "Invite a new team member" } }, "modals.leave-and-reassign.hint1" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:188" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:192" ], "translations" : { "en" : "You are %s owner." } }, "modals.leave-and-reassign.hint2" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:189" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:193" ], "translations" : { "en" : "Select an other member to promote before leave" } }, "modals.leave-and-reassign.promote-and-leave" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:206" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:210" ], "translations" : { "en" : "Promote and leave" } }, "modals.leave-and-reassign.select-memeber-to-promote" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:166" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:170" ], "translations" : { "en" : "Select a member to promote" } }, "modals.leave-and-reassign.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:183" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:187" ], "translations" : { "en" : "Select a member to promote" } }, "modals.leave-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:260" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:264" ], "translations" : { "en" : "Leave team" } }, "modals.leave-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:259" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:263" ], "translations" : { "en" : "Are you sure you want to leave this team?" } }, "modals.leave-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:258" ], + "used-in" : [ "src/app/main/ui/dashboard/sidebar.cljs:262" ], "translations" : { "en" : "Leaving team" } }, "modals.promote-owner-confirm.accept" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:144" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:147" ], "translations" : { "en" : "Promote" } }, "modals.promote-owner-confirm.message" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:143" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:146" ], "translations" : { "en" : "Are you sure you wan't to promote this user to owner?" } }, "modals.promote-owner-confirm.title" : { - "used-in" : [ "src/app/main/ui/dashboard/team.cljs:142" ], + "used-in" : [ "src/app/main/ui/dashboard/team.cljs:145" ], "translations" : { "en" : "Promote to owner" } @@ -1813,7 +1891,7 @@ } }, "notifications.profile-saved" : { - "used-in" : [ "src/app/main/ui/settings/options.cljs:36", "src/app/main/ui/settings/profile.cljs:36" ], + "used-in" : [ "src/app/main/ui/settings/profile.cljs:36", "src/app/main/ui/settings/options.cljs:36" ], "translations" : { "en" : "Profile saved successfully!", "fr" : "Profil enregistré avec succès!", @@ -1822,7 +1900,7 @@ } }, "notifications.validation-email-sent" : { - "used-in" : [ "src/app/main/ui/settings/change_email.cljs:56", "src/app/main/ui/auth/register.cljs:54" ], + "used-in" : [ "src/app/main/ui/auth/register.cljs:54", "src/app/main/ui/settings/change_email.cljs:56" ], "translations" : { "en" : "Verification email sent to %s; check your email!" } @@ -1837,7 +1915,7 @@ } }, "settings.multiple" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:153", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:163", "src/app/main/ui/workspace/sidebar/options/typography.cljs:99", "src/app/main/ui/workspace/sidebar/options/typography.cljs:149", "src/app/main/ui/workspace/sidebar/options/typography.cljs:162", "src/app/main/ui/workspace/sidebar/options/stroke.cljs:147" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/stroke.cljs:147", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:153", "src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:163", "src/app/main/ui/workspace/sidebar/options/typography.cljs:99", "src/app/main/ui/workspace/sidebar/options/typography.cljs:149", "src/app/main/ui/workspace/sidebar/options/typography.cljs:162" ], "translations" : { "en" : "Mixed", "fr" : null, @@ -1864,7 +1942,7 @@ "unused" : true }, "viewer.empty-state" : { - "used-in" : [ "src/app/main/ui/viewer/handoff.cljs:56", "src/app/main/ui/viewer.cljs:42" ], + "used-in" : [ "src/app/main/ui/handoff.cljs:55", "src/app/main/ui/viewer.cljs:193" ], "translations" : { "en" : "No frames found on the page.", "fr" : "Aucun cadre trouvé sur la page.", @@ -1873,7 +1951,7 @@ } }, "viewer.frame-not-found" : { - "used-in" : [ "src/app/main/ui/viewer/handoff.cljs:60", "src/app/main/ui/viewer.cljs:46" ], + "used-in" : [ "src/app/main/ui/handoff.cljs:59", "src/app/main/ui/viewer.cljs:197" ], "translations" : { "en" : "Frame not found.", "fr" : "Cadre introuvable.", @@ -1882,7 +1960,7 @@ } }, "viewer.header.dont-show-interactions" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:68" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:124" ], "translations" : { "en" : "Don't show interactions", "fr" : "Ne pas afficher les interactions", @@ -1891,7 +1969,7 @@ } }, "viewer.header.edit-page" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:183" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:264" ], "translations" : { "en" : "Edit page", "fr" : "Editer la page", @@ -1900,7 +1978,7 @@ } }, "viewer.header.fullscreen" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:194" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:275" ], "translations" : { "en" : "Full Screen", "fr" : "Plein écran", @@ -1909,7 +1987,7 @@ } }, "viewer.header.share.copy-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:113" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:92" ], "translations" : { "en" : "Copy link", "fr" : "Copier lien", @@ -1918,7 +1996,7 @@ } }, "viewer.header.share.create-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:122" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:101" ], "translations" : { "en" : "Create link", "fr" : "Créer lien", @@ -1927,7 +2005,7 @@ } }, "viewer.header.share.placeholder" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:114" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:93" ], "translations" : { "en" : "Share link will appear here", "fr" : "Le lien de partage apparaîtra ici", @@ -1936,7 +2014,7 @@ } }, "viewer.header.share.remove-link" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:120" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:99" ], "translations" : { "en" : "Remove link", "fr" : "Supprimer le lien", @@ -1945,7 +2023,7 @@ } }, "viewer.header.share.subtitle" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:116" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:95" ], "translations" : { "en" : "Anyone with the link will have access", "fr" : "Toute personne disposant du lien aura accès", @@ -1954,7 +2032,7 @@ } }, "viewer.header.share.title" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:99", "src/app/main/ui/viewer/header.cljs:101", "src/app/main/ui/viewer/header.cljs:107" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:78", "src/app/main/ui/viewer/header.cljs:80", "src/app/main/ui/viewer/header.cljs:86" ], "translations" : { "en" : "Share link", "fr" : "Lien de partage", @@ -1963,7 +2041,7 @@ } }, "viewer.header.show-interactions" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:72" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:129" ], "translations" : { "en" : "Show interactions", "fr" : "Afficher les interactions", @@ -1972,7 +2050,7 @@ } }, "viewer.header.show-interactions-on-click" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:76" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:134" ], "translations" : { "en" : "Show interactions on click", "fr" : "Afficher les interactions au clic", @@ -1981,7 +2059,7 @@ } }, "viewer.header.sitemap" : { - "used-in" : [ "src/app/main/ui/viewer/header.cljs:156" ], + "used-in" : [ "src/app/main/ui/viewer/header.cljs:223" ], "translations" : { "en" : "Sitemap", "fr" : "Plan du site", @@ -2062,7 +2140,7 @@ } }, "workspace.assets.assets" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:630" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:668" ], "translations" : { "en" : "Assets", "fr" : "", @@ -2071,7 +2149,7 @@ } }, "workspace.assets.box-filter-all" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:650" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:688" ], "translations" : { "en" : "All assets", "fr" : "", @@ -2098,7 +2176,7 @@ "unused" : true }, "workspace.assets.colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:330", "src/app/main/ui/workspace/sidebar/assets.cljs:653" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:366", "src/app/main/ui/workspace/sidebar/assets.cljs:691" ], "translations" : { "en" : "Colors", "fr" : "", @@ -2107,7 +2185,7 @@ } }, "workspace.assets.components" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:84", "src/app/main/ui/workspace/sidebar/assets.cljs:651" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:109", "src/app/main/ui/workspace/sidebar/assets.cljs:689" ], "translations" : { "en" : "Components", "fr" : "", @@ -2116,7 +2194,7 @@ } }, "workspace.assets.delete" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:104", "src/app/main/ui/workspace/sidebar/assets.cljs:192", "src/app/main/ui/workspace/sidebar/assets.cljs:306", "src/app/main/ui/workspace/sidebar/assets.cljs:434" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:140", "src/app/main/ui/workspace/sidebar/assets.cljs:228", "src/app/main/ui/workspace/sidebar/assets.cljs:342", "src/app/main/ui/workspace/sidebar/assets.cljs:470" ], "translations" : { "en" : "Delete", "fr" : "", @@ -2125,6 +2203,7 @@ } }, "workspace.assets.duplicate" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:139" ], "translations" : { "en" : "Duplicate", "fr" : "", @@ -2133,7 +2212,7 @@ } }, "workspace.assets.edit" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:305", "src/app/main/ui/workspace/sidebar/assets.cljs:433" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:341", "src/app/main/ui/workspace/sidebar/assets.cljs:469" ], "translations" : { "en" : "Edit", "fr" : "", @@ -2142,7 +2221,7 @@ } }, "workspace.assets.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:532" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:568" ], "translations" : { "en" : "File library", "fr" : "", @@ -2151,7 +2230,7 @@ } }, "workspace.assets.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:165", "src/app/main/ui/workspace/sidebar/assets.cljs:652" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:201", "src/app/main/ui/workspace/sidebar/assets.cljs:690" ], "translations" : { "en" : "Graphics", "fr" : "", @@ -2160,7 +2239,7 @@ } }, "workspace.assets.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:633" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:671" ], "translations" : { "en" : "Libraries", "fr" : "", @@ -2169,7 +2248,7 @@ } }, "workspace.assets.not-found" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:593" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:629" ], "translations" : { "en" : "No assets found", "fr" : "", @@ -2178,7 +2257,7 @@ } }, "workspace.assets.rename" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:304", "src/app/main/ui/workspace/sidebar/assets.cljs:432" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:138", "src/app/main/ui/workspace/sidebar/assets.cljs:340", "src/app/main/ui/workspace/sidebar/assets.cljs:468" ], "translations" : { "en" : "Rename", "fr" : "", @@ -2187,7 +2266,7 @@ } }, "workspace.assets.search" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:637" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:675" ], "translations" : { "en" : "Search assets", "fr" : "", @@ -2196,7 +2275,7 @@ } }, "workspace.assets.shared" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:534" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:570" ], "translations" : { "en" : "SHARED", "fr" : "", @@ -2205,7 +2284,7 @@ } }, "workspace.assets.typography" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:421", "src/app/main/ui/workspace/sidebar/assets.cljs:654" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:457", "src/app/main/ui/workspace/sidebar/assets.cljs:692" ], "translations" : { "en" : "Typographies" } @@ -2247,7 +2326,7 @@ } }, "workspace.assets.typography.sample" : { - "used-in" : [ "src/app/main/ui/viewer/handoff/attributes/text.cljs:65", "src/app/main/ui/workspace/sidebar/options/typography.cljs:255" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:255", "src/app/main/ui/handoff/attributes/text.cljs:97", "src/app/main/ui/handoff/attributes/text.cljs:106" ], "translations" : { "en" : "Ag" } @@ -2259,13 +2338,13 @@ } }, "workspace.gradients.linear" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:39", "src/app/main/ui/components/color_bullet.cljs:31" ], + "used-in" : [ "src/app/main/data/workspace/libraries.cljs:42", "src/app/main/ui/components/color_bullet.cljs:31" ], "translations" : { "en" : "Linear gradient" } }, "workspace.gradients.radial" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:40", "src/app/main/ui/components/color_bullet.cljs:32" ], + "used-in" : [ "src/app/main/data/workspace/libraries.cljs:43", "src/app/main/ui/components/color_bullet.cljs:32" ], "translations" : { "en" : "Radial gradient" } @@ -2351,6 +2430,14 @@ "es" : "Ocultar reglas" } }, + "workspace.header.menu.select-all" : { + "translations" : { + "en" : "Select all", + "fr" : "", + "ru" : "", + "es" : "Seleccionar todo" + } + }, "workspace.header.menu.show-assets" : { "used-in" : [ "src/app/main/ui/workspace/header.cljs:210" ], "translations" : { @@ -2430,7 +2517,7 @@ } }, "workspace.libraries.add" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:115" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:116" ], "translations" : { "en" : "Add", "fr" : "", @@ -2439,7 +2526,7 @@ } }, "workspace.libraries.colors" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:43" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:44" ], "translations" : { "en" : "%s colors", "fr" : "", @@ -2478,7 +2565,7 @@ } }, "workspace.libraries.components" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:37" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:38" ], "translations" : { "en" : "%s components", "fr" : "", @@ -2487,7 +2574,7 @@ } }, "workspace.libraries.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:84" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:85" ], "translations" : { "en" : "File library", "fr" : "", @@ -2496,7 +2583,7 @@ } }, "workspace.libraries.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:40" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:41" ], "translations" : { "en" : "%s graphics", "fr" : "", @@ -2505,7 +2592,7 @@ } }, "workspace.libraries.in-this-file" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:81" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:82" ], "translations" : { "en" : "LIBRARIES IN THIS FILE", "fr" : "", @@ -2514,7 +2601,7 @@ } }, "workspace.libraries.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:175" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:177" ], "translations" : { "en" : "LIBRARIES", "fr" : "", @@ -2523,7 +2610,7 @@ } }, "workspace.libraries.library" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:135" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:136" ], "translations" : { "en" : "LIBRARY", "fr" : "", @@ -2532,7 +2619,7 @@ } }, "workspace.libraries.no-libraries-need-sync" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:133" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:134" ], "translations" : { "en" : "There are no Shared Libraries that need update", "fr" : "", @@ -2541,7 +2628,7 @@ } }, "workspace.libraries.no-matches-for" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:121" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:122" ], "translations" : { "en" : "No matches found for “%s“", "fr" : "Aucune correspondance pour “%s“", @@ -2550,7 +2637,7 @@ } }, "workspace.libraries.no-shared-libraries-available" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:120" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:121" ], "translations" : { "en" : "There are no Shared Libraries available", "fr" : "", @@ -2559,7 +2646,7 @@ } }, "workspace.libraries.search-shared-libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:98" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:99" ], "translations" : { "en" : "Search shared libraries", "fr" : "", @@ -2568,7 +2655,7 @@ } }, "workspace.libraries.shared-libraries" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:95" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:96" ], "translations" : { "en" : "SHARED LIBRARIES", "fr" : "", @@ -2589,13 +2676,13 @@ } }, "workspace.libraries.typography" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:46" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:47" ], "translations" : { "en" : "%s typographies" } }, "workspace.libraries.update" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:142" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:143" ], "translations" : { "en" : "Update", "fr" : "", @@ -2604,7 +2691,7 @@ } }, "workspace.libraries.updates" : { - "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:179" ], + "used-in" : [ "src/app/main/ui/workspace/libraries.cljs:181" ], "translations" : { "en" : "UPDATES", "fr" : "", @@ -2694,6 +2781,7 @@ } }, "workspace.options.component" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:62" ], "translations" : { "en" : "Component", "es" : "Componente" @@ -2709,21 +2797,21 @@ } }, "workspace.options.export" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:123" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:123", "src/app/main/ui/handoff/exports.cljs:96" ], "translations" : { "en" : "Export", "ru" : "Экспорт" } }, "workspace.options.export-object" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:156" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:156", "src/app/main/ui/handoff/exports.cljs:131" ], "translations" : { "en" : "Export shape", "ru" : "Экспорт фигуры" } }, "workspace.options.exporting-object" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:155" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/exports.cljs:155", "src/app/main/ui/handoff/exports.cljs:130" ], "translations" : { "en" : "Exporting...", "ru" : "Экспортирую ..." @@ -2964,7 +3052,7 @@ } }, "workspace.options.position" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs:127", "src/app/main/ui/workspace/sidebar/options/measures.cljs:146" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs:146", "src/app/main/ui/workspace/sidebar/options/frame.cljs:127" ], "translations" : { "en" : "Position", "fr" : "Position", @@ -3078,7 +3166,7 @@ } }, "workspace.options.size" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/frame.cljs:100", "src/app/main/ui/workspace/sidebar/options/measures.cljs:116" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/measures.cljs:116", "src/app/main/ui/workspace/sidebar/options/frame.cljs:100" ], "translations" : { "en" : "Size", "fr" : "Taille", @@ -3285,7 +3373,7 @@ } }, "workspace.options.text-options.none" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/options/typography.cljs:178", "src/app/main/ui/workspace/sidebar/options/text.cljs:154" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/text.cljs:154", "src/app/main/ui/workspace/sidebar/options/typography.cljs:178" ], "translations" : { "en" : "None", "fr" : "Aucune", @@ -3382,138 +3470,139 @@ } }, "workspace.shape.menu.back" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:103" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:104" ], "translations" : { "en" : "Send to back" } }, "workspace.shape.menu.backward" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:100" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:101" ], "translations" : { "en" : "Send backward" } }, "workspace.shape.menu.copy" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:81" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:82" ], "translations" : { "en" : "Copy" } }, "workspace.shape.menu.create-component" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:145" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:147" ], "translations" : { "en" : "Create component" } }, "workspace.shape.menu.cut" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:84" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:85" ], "translations" : { "en" : "Cut" } }, "workspace.shape.menu.delete" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:163" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:177" ], "translations" : { "en" : "Delete" } }, "workspace.shape.menu.detach-instance" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:152" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:76", "src/app/main/ui/workspace/sidebar/options/component.cljs:81", "src/app/main/ui/workspace/context_menu.cljs:159", "src/app/main/ui/workspace/context_menu.cljs:169" ], "translations" : { "en" : "Detach instance" } }, "workspace.shape.menu.duplicate" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:90" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:91" ], "translations" : { "en" : "Duplicate" } }, "workspace.shape.menu.forward" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:94" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:95" ], "translations" : { "en" : "Bring forward" } }, "workspace.shape.menu.front" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:97" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:98" ], "translations" : { "en" : "Bring to front" } }, "workspace.shape.menu.go-master" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:159" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:83", "src/app/main/ui/workspace/context_menu.cljs:173" ], "translations" : { "en" : "Go to master component file" } }, - "workspace.shape.menu.show-master" : { - "translations" : { - "en" : "Show master component" - } - }, "workspace.shape.menu.group" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:110" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:111" ], "translations" : { "en" : "Group" } }, "workspace.shape.menu.hide" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:133" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:134" ], "translations" : { "en" : "Hide" } }, "workspace.shape.menu.lock" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:139" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:140" ], "translations" : { "en" : "Lock" } }, "workspace.shape.menu.mask" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:113" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:114" ], "translations" : { "en" : "Mask" } }, "workspace.shape.menu.paste" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:87", "src/app/main/ui/workspace/context_menu.cljs:172" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:88", "src/app/main/ui/workspace/context_menu.cljs:186" ], "translations" : { "en" : "Paste" } }, "workspace.shape.menu.reset-overrides" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:154" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:77", "src/app/main/ui/workspace/sidebar/options/component.cljs:82", "src/app/main/ui/workspace/context_menu.cljs:161", "src/app/main/ui/workspace/context_menu.cljs:171" ], "translations" : { "en" : "Reset overrides" } }, "workspace.shape.menu.show" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:131" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:132" ], "translations" : { "en" : "Show" } }, + "workspace.shape.menu.show-master" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:79", "src/app/main/ui/workspace/context_menu.cljs:165" ], + "translations" : { + "en" : "Show master component" + } + }, "workspace.shape.menu.ungroup" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:119" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:120" ], "translations" : { "en" : "Ungroup" } }, "workspace.shape.menu.unlock" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:137" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:138" ], "translations" : { "en" : "Unlock" } }, "workspace.shape.menu.unmask" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:123" ], + "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:124" ], "translations" : { "en" : "Unmask" } }, "workspace.shape.menu.update-master" : { - "used-in" : [ "src/app/main/ui/workspace/context_menu.cljs:157" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/options/component.cljs:78", "src/app/main/ui/workspace/context_menu.cljs:163" ], "translations" : { "en" : "Update master component" } @@ -3554,13 +3643,13 @@ "es" : "Recursos (Ctrl + I)" } }, - "workspace.toolbar.circle" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:69" ], + "workspace.toolbar.ellipse" : { + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:74" ], "translations" : { - "en" : "Circle (E)", - "fr" : "Cercle (E)", - "ru" : "Круг (E)", - "es" : "Círculo (E)" + "en" : "Ellipse (E)", + "fr" : "", + "ru" : "", + "es" : "Elipse (E)" } }, "workspace.toolbar.color-palette" : { @@ -3573,7 +3662,7 @@ } }, "workspace.toolbar.comments" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:99" ], + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:104" ], "translations" : { "en" : "Comments", "es" : "Comentarios" @@ -3588,8 +3677,17 @@ "es" : "Curva" } }, - "workspace.toolbar.frame" : { + "workspace.toolbar.move" : { "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:59" ], + "translations" : { + "en" : "Move", + "fr" : "Déplacer", + "ru" : "Вытеснить", + "es" : "Mover" + } + }, + "workspace.toolbar.frame" : { + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:64" ], "translations" : { "en" : "Artboard (A)", "fr" : "Plan de travail (A)", @@ -3598,12 +3696,12 @@ } }, "workspace.toolbar.image" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:79" ], + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:93" ], "translations" : { - "en" : "Image (I)", - "fr" : "Image (I)", - "ru" : "Изображение (I)", - "es" : "Imagen (I)" + "en" : "Image (K)", + "fr" : "Image (K)", + "ru" : "Изображение (K)", + "es" : "Imagen (K)" } }, "workspace.toolbar.libraries" : { @@ -3616,7 +3714,7 @@ "unused" : true }, "workspace.toolbar.path" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:93" ], + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:98" ], "translations" : { "en" : "Path", "fr" : "Chemin", @@ -3625,16 +3723,16 @@ } }, "workspace.toolbar.rect" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:64" ], + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:69" ], "translations" : { - "en" : "Box (B)", - "fr" : "Boîte (B)", - "ru" : "Прямоугольник (B)", - "es" : "Recuadro (B)" + "en" : "Rectangle (R)", + "fr" : "", + "ru" : "Прямоугольник (R)", + "es" : "Rectángulo (R)" } }, "workspace.toolbar.text" : { - "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:74" ], + "used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:79" ], "translations" : { "en" : "Text (T)", "fr" : "Texte (T)", @@ -3865,7 +3963,7 @@ } }, "workspace.updates.dismiss" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:541" ], + "used-in" : [ "src/app/main/data/workspace/libraries.cljs:638" ], "translations" : { "en" : "Dismiss", "fr" : "", @@ -3874,7 +3972,7 @@ } }, "workspace.updates.there-are-updates" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:537" ], + "used-in" : [ "src/app/main/data/workspace/libraries.cljs:634" ], "translations" : { "en" : "There are updates in shared libraries", "fr" : "", @@ -3883,7 +3981,7 @@ } }, "workspace.updates.update" : { - "used-in" : [ "src/app/main/data/workspace/libraries.cljs:539" ], + "used-in" : [ "src/app/main/data/workspace/libraries.cljs:636" ], "translations" : { "en" : "Update", "fr" : "", diff --git a/frontend/resources/polyfills/createImageBitmap.js b/frontend/resources/polyfills/createImageBitmap.js new file mode 100644 index 000000000..0ddf17c32 --- /dev/null +++ b/frontend/resources/polyfills/createImageBitmap.js @@ -0,0 +1,33 @@ +/* + * Safari and Edge polyfill for createImageBitmap + * https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap + * + * Support source image types Blob and ImageData. + * + * From: https://dev.to/nektro/createimagebitmap-polyfill-for-safari-and-edge-228 + * Updated by Yoan Tournade + */ +if (!('createImageBitmap' in window)) { + window.createImageBitmap = async function (data) { + return new Promise((resolve,reject) => { + let dataURL; + if (data instanceof Blob) { + dataURL = URL.createObjectURL(data); + } else if (data instanceof ImageData) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = data.width; + canvas.height = data.height; + ctx.putImageData(data,0,0); + dataURL = canvas.toDataURL(); + } else { + throw new Error('createImageBitmap does not handle the provided image source type'); + } + const img = document.createElement('img'); + img.addEventListener('load',function () { + resolve(this); + }); + img.src = dataURL; + }); + }; +} diff --git a/frontend/resources/polyfills/scrollIntoViewIfNeeded.js b/frontend/resources/polyfills/scrollIntoViewIfNeeded.js new file mode 100644 index 000000000..a341b3e40 --- /dev/null +++ b/frontend/resources/polyfills/scrollIntoViewIfNeeded.js @@ -0,0 +1,27 @@ +;(function() { + if (!Element.prototype.scrollIntoViewIfNeeded) { + Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded) { + centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded; + + var parent = this.parentNode, + parentComputedStyle = window.getComputedStyle(parent, null), + parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')), + parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')), + overTop = this.offsetTop - parent.offsetTop < parent.scrollTop, + overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight), + overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft, + overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth), + alignWithTop = overTop && !overBottom; + + if ((overTop || overBottom) && centerIfNeeded) { + parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2; + } + if ((overLeft || overRight) && centerIfNeeded) { + parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2; + } + if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) { + this.scrollIntoView(alignWithTop); + } + }; + } +})() diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index a9fcacdee..ddd623792 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -224,3 +224,8 @@ input[type=number]::-webkit-inner-spin-button, input[type=number] { -moz-appearance: textfield; } + +[contenteditable] { + -webkit-user-select: text; + user-select: text; +} diff --git a/frontend/resources/styles/common/framework.scss b/frontend/resources/styles/common/framework.scss index 815343e43..6c1923b92 100644 --- a/frontend/resources/styles/common/framework.scss +++ b/frontend/resources/styles/common/framework.scss @@ -373,9 +373,10 @@ ul.slider-dots { font-size: $fs12; height: 20px; position: absolute; - right: 3px; + right: $small; + text-align: right; top: 26%; - width: 20px; + width: 18px; } .after { @@ -539,7 +540,7 @@ input.element-name { @extend .input-text; background-image: url("/images/icons/arrow-down-white.svg"); background-repeat: no-repeat; - background-position: 95% 48%; + background-position: calc(100% - 4px) 48%; background-size: 10px; cursor: pointer; @@ -1001,6 +1002,16 @@ input[type=range]:focus::-ms-fill-upper { } } + &.tooltip-left { + &:hover { + &::after { + left: unset; + right: 130%; + top: 15%; + } + } + } + &.tooltip-hover { &:hover { &::after { diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss index b8860a4b4..a18f57a44 100644 --- a/frontend/resources/styles/main-default.scss +++ b/frontend/resources/styles/main-default.scss @@ -80,6 +80,6 @@ @import 'main/partials/user-settings'; @import 'main/partials/workspace'; @import 'main/partials/workspace-header'; -@import 'main/partials/workspace-comments'; +@import 'main/partials/comments'; @import 'main/partials/color-bullet'; @import "main/partials/handoff"; diff --git a/frontend/resources/styles/main/layouts/main-layout.scss b/frontend/resources/styles/main/layouts/main-layout.scss index 22a1cd54c..b7045cd39 100644 --- a/frontend/resources/styles/main/layouts/main-layout.scss +++ b/frontend/resources/styles/main/layouts/main-layout.scss @@ -23,7 +23,7 @@ .dashboard-sidebar { grid-row: 1 / span 2; grid-column: 1 / span 2; - overflow: hidden; + // overflow: hidden; } .dashboard-content { diff --git a/frontend/resources/styles/main/partials/color-bullet.scss b/frontend/resources/styles/main/partials/color-bullet.scss index b4ee15997..a8041e58b 100644 --- a/frontend/resources/styles/main/partials/color-bullet.scss +++ b/frontend/resources/styles/main/partials/color-bullet.scss @@ -9,11 +9,15 @@ .color-cell { .color-bullet { - background-color: $color-white; + background-color: transparent; // Creates strange artifacts border: 2px solid $color-gray-60; // box-shadow: 0 0 0 2px $color-gray-60; border-radius: 50%; + + &:hover { + border-color: $color-primary; + } } &.cell-big .color-bullet { @@ -74,10 +78,11 @@ ul.palette-menu .color-bullet { } .asset-group .group-list-item .color-bullet { - width: 20px; - height: 20px; + border: 1px solid $color-gray-20; border-radius: 10px; + height: 20px; margin-right: $x-small; + width: 20px; } .color-cell.add-color:hover .color-bullet { diff --git a/frontend/resources/styles/main/partials/colorpicker.scss b/frontend/resources/styles/main/partials/colorpicker.scss index 90323f677..dd88e6b85 100644 --- a/frontend/resources/styles/main/partials/colorpicker.scss +++ b/frontend/resources/styles/main/partials/colorpicker.scss @@ -309,7 +309,7 @@ font-size: 0.75rem; color: $color-gray-40; cursor: pointer; - border-color: $color-gray-10; + border: 1px solid $color-gray-10; border-radius: 2px; option { diff --git a/frontend/resources/styles/main/partials/workspace-comments.scss b/frontend/resources/styles/main/partials/comments.scss similarity index 60% rename from frontend/resources/styles/main/partials/workspace-comments.scss rename to frontend/resources/styles/main/partials/comments.scss index 6c5174774..161c109bb 100644 --- a/frontend/resources/styles/main/partials/workspace-comments.scss +++ b/frontend/resources/styles/main/partials/comments.scss @@ -1,16 +1,4 @@ -.workspace-comments { - width: 100%; - height: 100%; - grid-column: 1/span 2; - grid-row: 1/span 2; - z-index: 1000; - pointer-events: none; - overflow: hidden; - - .threads { - position: relative; - } - +.comments-section { .thread-bubble { position: absolute; display: flex; @@ -52,18 +40,19 @@ box-sizing: border-box; box-shadow: 0px 2px 8px rgba($color-black, 0.25); border-radius: 2px; - min-width: 250px; - max-width: 250px; + min-width: 280px; + max-width: 280px; .comments { - max-height: 320px; + max-height: 420px; + min-height: 105px; overflow-y: auto; } hr { border: 0; height: 1px; - background-color: #e3e3e3; + background-color: $color-gray-20; margin: 0px 10px; } } @@ -86,6 +75,8 @@ padding: $small; resize: none; width: 100%; + border-radius: 2px; + border: 1px solid $color-gray-10; } .buttons { @@ -95,7 +86,7 @@ input { margin: 0px; - font-size: $fs12; + font-size: $fs14; &:not(:last-child) { margin-right: 6px; @@ -104,8 +95,6 @@ } } - - .comment-container { position: relative; } @@ -113,7 +102,7 @@ .comment { display: flex; flex-direction: column; - padding: 10px; + padding: $medium $small; .author { display: flex; @@ -132,12 +121,12 @@ font-size: $fs13; @include text-ellipsis; - width: 110px; + width: 174px; } .timeago { margin-top: -2px; - font-size: $fs11; + font-size: $fs12; color: $color-gray-30; } } @@ -173,8 +162,8 @@ .options { position: absolute; - right: 0px; - top: 0px; + right: -2px; + top: 2px; height: 16px; display: flex; align-items: center; @@ -182,8 +171,8 @@ .options-icon { svg { - width: 10px; - height: 10px; + width: 14px; + height: 14px; fill: $color-black; } } @@ -193,11 +182,10 @@ .content { margin: $medium 0; - // margin-left: 26px; - font-size: $fs13; + font-size: $fs14; color: $color-black; .text { - margin-left: 26px; + margin: 0 $small 0 26px; white-space: pre-wrap; display: inline-block; } @@ -212,60 +200,60 @@ border: 1px solid #B1B2B5; } - } -.workspace-comments-sidebar { - pointer-events: auto; +.workspace-comment-threads-sidebar-header { + display: flex; + background-color: $color-black; + height: 34px; + align-items: center; + padding: 0px 9px; + color: $color-gray-10; + font-size: $fs12; + justify-content: space-between; - .sidebar-title { + .options { display: flex; - background-color: $color-black; - height: 34px; - align-items: center; - padding: 0px 9px; - color: $color-gray-10; - font-size: $fs12; - justify-content: space-between; + margin-right: 3px; + cursor: pointer; - .options { + .label { + padding-right: 8px; + } + + .icon { display: flex; - margin-right: 3px; - cursor: pointer; + align-items: center; + } - .label { - padding-right: 8px; - } - - .icon { - display: flex; - align-items: center; - } - - svg { - fill: $color-gray-10; - width: 10px; - height: 10px; - } + svg { + fill: $color-gray-10; + width: 10px; + height: 10px; } } - .sidebar-options-dropdown { + .dropdown { top: 80px; right: 7px; } +} - .threads { + +.comment-threads-section { + pointer-events: auto; + + .thread-groups { hr { border: 0; height: 1px; - background-color: #1f1f2f; + background-color: $color-gray-30; margin: 0px 0px; } } - .page-section { + .thread-group { display: flex; flex-direction: column; font-size: $fs12; @@ -279,6 +267,9 @@ } .label { + &.filename { + font-weight: 700; + } } svg { @@ -292,23 +283,22 @@ .thread-bubble { position: unset; transform: unset; - width: 20px; - height: 20px; + width: 24px; + height: 24px; margin-right: 6px; box-shadow: unset; } .comment { + cursor: pointer; .author { - margin-bottom: 10px; + margin-bottom: $medium; .name { display: flex; - flex-direction: row; - align-items: center; .fullname { width: unset; - max-width: 100px; + max-width: 170px; color: $color-gray-20; padding-right: 3px; } @@ -324,7 +314,7 @@ color: $color-white; &.replies { - margin-left: 26px; + margin: 0 $small 0 26px; display: flex; .total-replies { margin-right: 9px; @@ -338,3 +328,130 @@ } } } + + +.viewer-comments-container { + width: 100%; + height: 100%; + z-index: 1000; + position: absolute; + top: 0px; + left: 0px; +} + +.workspace-comments-container { + width: 100%; + height: 100%; + grid-column: 1/span 2; + grid-row: 1/span 2; + z-index: 1000; + pointer-events: none; + overflow: hidden; + + .threads { + position: absolute; + top: 0px; + left: 0px; + } +} + +.dashboard-comments-section { + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + background-color: $color-dashboard; + border-radius: 3px; + position: relative; + + .button { + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + background-color: $color-dashboard; + border-radius: 3px; + + svg { + width: 15px; + height: 15px; + } + + &.unread { + background-color: $color-warning; + } + + &.open { + background-color: $color-black; + svg { fill: $color-primary; } + } + } + + .dropdown { + width: 280px; + bottom: 35px; + left: 0px; + border-radius: 3px; + } + + .header { + display: flex; + height: 40px; + align-items: center; + padding: 0px 11px; + + h3 { + font-weight: 400; + color: $color-black; + font-size: $fs14; + line-height: $fs18; + flex-grow: 1; + } + + .close { + display: flex; + align-items: center; + } + + + svg { + width: 15px; + height: 15px; + transform: rotate(45deg); + } + } + + .thread-group { + .section-title { + color: $color-black; + } + } + + .comment { + .author .name .fullname { + color: $color-gray-40; + } + .content { + color: $color-black; + } + } +} + +.thread-groups-placeholder { + align-items: center; + display: flex; + flex-direction: column; + font-size: $fs12; + padding: $big; + text-align: center; + + svg { + fill: $color-gray-20; + height: 24px; + margin-bottom: $big; + width: 24px; + } +} + diff --git a/frontend/resources/styles/main/partials/dashboard-grid.scss b/frontend/resources/styles/main/partials/dashboard-grid.scss index 76cda8a6b..79b5da8ad 100644 --- a/frontend/resources/styles/main/partials/dashboard-grid.scss +++ b/frontend/resources/styles/main/partials/dashboard-grid.scss @@ -180,7 +180,7 @@ .project-th-actions { align-items: center; - bottom: 0; + bottom: 4px; display: none; left: 0; justify-content: flex-end; @@ -190,9 +190,9 @@ svg { fill: $color-gray-20; - height: 14px; + height: 18px; margin-right: $x-small; - width: 14px; + width: 18px; } span { @@ -206,15 +206,19 @@ &.menu { margin-right: 0; + width: 2rem; + height: 2rem; + display: flex; + justify-content: flex-end; + align-items: flex-end; + flex-direction: column; svg { - fill: $color-gray-30; + fill: $color-gray-60; margin-right: 0; } &:hover { - transform: scale(1.4); - svg { fill: $color-primary-dark; } @@ -223,43 +227,6 @@ } - &.delete { - margin-right: 0; - - svg { - fill: $color-gray-30; - margin-right: 0; - } - - &:hover { - transform: scale(1.4); - - svg { - fill: $color-danger; - } - - } - - } - - &.edit { - margin-right: 0; - - svg { - fill: $color-gray-30; - } - - &:hover { - transform: scale(1.4); - - svg { - fill: $color-gray-60; - } - - } - - } - } } diff --git a/frontend/resources/styles/main/partials/dashboard-sidebar.scss b/frontend/resources/styles/main/partials/dashboard-sidebar.scss index 04f1daf17..1b649be29 100644 --- a/frontend/resources/styles/main/partials/dashboard-sidebar.scss +++ b/frontend/resources/styles/main/partials/dashboard-sidebar.scss @@ -77,6 +77,7 @@ } .current-team { + cursor: pointer; display: flex; flex-grow: 1; font-size: $fs14; @@ -118,7 +119,6 @@ .switch-icon { display: flex; align-items: center; - cursor: pointer; svg { width: 10px; @@ -363,26 +363,32 @@ padding: 10px 15px; position: relative; - span { - @include text-ellipsis; - color: $color-black; - margin: 10px 5px; - font-size: $fs14; - max-width: 160px; - } + .profile { + align-items: center; + cursor: pointer; + display: flex; + flex-grow: 1; - img { - border-radius: 50%; - flex-shrink: 0; - height: 25px; - width: 25px; - } + span { + @include text-ellipsis; + color: $color-black; + margin: 10px 5px; + font-size: $fs14; + max-width: 160px; + } - svg { - height: 10px; - margin-left: auto; - margin-right: $small; - width: 10px; + img { + border-radius: 50%; + flex-shrink: 0; + height: 25px; + width: 25px; + } + svg { + height: 10px; + margin-left: auto; + margin-right: $small; + width: 10px; + } } .dropdown { @@ -400,6 +406,8 @@ svg { fill: $color-gray-20; + margin-right: $small; + height: 12px; width: 12px; } diff --git a/frontend/resources/styles/main/partials/dashboard.scss b/frontend/resources/styles/main/partials/dashboard.scss index 2aed67220..38b467b49 100644 --- a/frontend/resources/styles/main/partials/dashboard.scss +++ b/frontend/resources/styles/main/partials/dashboard.scss @@ -135,6 +135,7 @@ border: 1px solid $color-gray-10; border-radius: $br-small; display: flex; + padding-right: $big; position: relative; input.element-title { diff --git a/frontend/resources/styles/main/partials/debug-icons-preview.scss b/frontend/resources/styles/main/partials/debug-icons-preview.scss index 5e56fc0a4..7a4940eec 100644 --- a/frontend/resources/styles/main/partials/debug-icons-preview.scss +++ b/frontend/resources/styles/main/partials/debug-icons-preview.scss @@ -1,5 +1,4 @@ .debug-preview { - max-height: 100vh; display: flex; flex-direction: column; overflow: scroll; diff --git a/frontend/resources/styles/main/partials/dropdown.scss b/frontend/resources/styles/main/partials/dropdown.scss index 496cc78fd..0802e04d2 100644 --- a/frontend/resources/styles/main/partials/dropdown.scss +++ b/frontend/resources/styles/main/partials/dropdown.scss @@ -11,7 +11,7 @@ border-color: $color-gray-10; } - li { + > li { display: flex; align-items: center; color: $color-gray-60; @@ -20,6 +20,12 @@ height: 40px; padding: 5px 16px; + svg { + fill: $color-gray-20; + height: 12px; + width: 12px; + } + &.title { font-weight: 600; cursor: default; @@ -29,4 +35,27 @@ background-color: $color-primary-lighter; } } + + + &.with-check { + > li { + padding: 5px 10px; + } + + > li:not(.selected) { + svg { display: none; } + } + + svg { + fill: $color-gray-50; + } + + .icon { + display: flex; + align-items: center; + width: 25px; + height: 25px; + margin-right: 7px; + } + } } diff --git a/frontend/resources/styles/main/partials/modal.scss b/frontend/resources/styles/main/partials/modal.scss index 92100c35e..8638e05ca 100644 --- a/frontend/resources/styles/main/partials/modal.scss +++ b/frontend/resources/styles/main/partials/modal.scss @@ -59,7 +59,7 @@ // NEW GEN MODALS .modal-container { - border-radius: 8px; + border-radius: $br-medium; display: flex; flex-direction: column; width: 448px; @@ -78,10 +78,12 @@ .modal-header-title { display: flex; align-items: center; - font-size: $fs24; + font-size: $fs18; padding-left: 16px; h2 { + font-size: $fs18; + font-weight: 400; margin: 0; } } @@ -204,8 +206,9 @@ } .libraries-dialog { - width: 920px; + border-radius: $br-medium; height: 664px; + width: 920px; .modal-content { display: flex; @@ -257,8 +260,10 @@ } .section-title { + color: $color-black; font-size: $fs15; padding: 0 $size-4; + font-weight: 500; } .section-list { @@ -319,9 +324,9 @@ } .libraries-search { - border: 1px solid $color-gray-30; + border: 1px solid $color-gray-20; margin: $size-4; - padding: $x-small; + padding: $x-small $small; display: flex; align-items: center; @@ -357,3 +362,190 @@ } } +//- ONBOARDING +.onboarding { + background-color: $color-white; + box-shadow: 0 10px 10px rgba(0,0,0,.2); + display: flex; + height: 350px; + flex-direction: row; + font-family: "sourcesanspro", sans-serif; + min-width: 620px; + position: relative; + + .modal-left { + align-items: center; + background-color: $color-primary; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + display: flex; + flex-shrink: 0; + justify-content: center; + padding: $x-big; + width: 230px; + } + + .modal-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + display: flex; + flex-direction: column; + padding: $x-big; + + .modal-title h2 { + color: $color-black; + font-size: $fs28; + font-weight: 900; + } + + .release { + background-color: $color-primary; + color: $color-black; + font-size: $fs12; + font-weight: bold; + margin-top: $small; + padding: 2px $x-small; + width: max-content; + } + + .modal-content { + border: none; + padding: $big 0; + + p { + color: $color-black; + font-size: 16px; + margin-top: $small; + } + } + + .modal-navigation { + align-items: center; + display: flex; + margin-top: auto; + + .skip { + cursor: pointer; + font-family: "worksans", sans-serif; + font-size: $fs13; + margin-left: $big; + + &:hover { + color: $color-black; + } + } + } + + .step-dots { + align-items: center; + display: flex; + margin-bottom: 0; + margin-left: auto; + + li { + background-color: $color-gray-10; + border-radius: 50%; + height: $small; + margin-left: $small; + width: $small; + + &.current { + background-color: $color-primary; + } + } + } + } + + &.black { + .modal-left { + background-color: $color-black; + } + } + + button { + font-family: "worksans", sans-serif; + } + + &.feature { + .modal-left { + padding: 0; + + img { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + } + } + + &.final { + padding: $big 0 0 0; + + .modal-left, + .modal-right { + align-items: center; + background-color: $color-white; + color: $color-black; + flex: 1; + flex-direction: column; + padding: $x-big 40px; + text-align: center; + + h2 { + font-weight: 900; + margin-bottom: $big; + font-size: $fs24; + } + + p { + font-size: $fs14; + } + + .btn-primary { + margin-bottom: 0; + margin-top: auto; + width: 200px; + } + + img { + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25); + border-radius: $br-medium; + margin-bottom: $x-big; + margin-top: -90px; + width: 150px; + } + } + + .modal-left { + border-right: 1px solid $color-gray-10; + + form { + align-items: center; + display: flex; + flex-direction: column; + margin-top: auto; + + .custom-input { + margin-bottom: $medium; + + input { + width: 200px; + } + } + } + } + + } +} + +.deco { + left: -10px; + position: absolute; + top: -18px; + width: 60px; + + &.right { + left: 590px; + top: 0; + } +} + diff --git a/frontend/resources/styles/main/partials/sidebar-assets.scss b/frontend/resources/styles/main/partials/sidebar-assets.scss index 29938b238..b2915448e 100644 --- a/frontend/resources/styles/main/partials/sidebar-assets.scss +++ b/frontend/resources/styles/main/partials/sidebar-assets.scss @@ -26,9 +26,9 @@ svg { fill: $color-gray-30; - height: 20px; - width: 20px; - padding-right: $x-small; + height: 16px; + width: 16px; + padding-right: 6px; } } @@ -44,7 +44,7 @@ .search-block { border: 1px solid $color-gray-30; margin: $small $small 0 $small; - padding: $x-small; + padding: $x-small $small; display: flex; align-items: center; @@ -108,15 +108,19 @@ .collapse-library { margin-right: $small; - cursor: pointer; &.open svg { transform: rotate(90deg); } } + .library-bar { + cursor: pointer; + } + .asset-group { background-color: $color-gray-60; + border-top: 1px solid $color-gray-50; padding: $small; font-size: $fs12; color: $color-gray-20; @@ -164,10 +168,10 @@ } .group-grid { - margin-top: $small; + margin-top: $medium; display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-auto-rows: 7vh; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-auto-rows: 6vh; column-gap: 0.5rem; row-gap: 0.5rem; @@ -253,6 +257,10 @@ // overflow-y: scroll; // } + .group-list { + margin-top: $medium; + } + .group-list-item { display: flex; align-items: center; diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss index b24ef1c5e..ed324a469 100644 --- a/frontend/resources/styles/main/partials/sidebar-element-options.scss +++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss @@ -194,6 +194,7 @@ color: $color-white; font-size: $fs12; margin: $x-small; + min-width: 0; padding: $x-small; width: 100%; @@ -225,17 +226,17 @@ .element-set-subtitle { color: $color-gray-20; font-size: $fs11; - width: 12rem; + width: 64px; } .lock-size { cursor: pointer; - width: 20%; + margin: auto; svg { fill: $color-gray-20; - height: 15px; - width: 15px; + height: 14px; + width: 14px; &:hover { fill: $color-primary; @@ -517,16 +518,15 @@ } .align-icons { - border: 1px solid $color-gray-60; - border-radius: $br-small; cursor: pointer; display: flex; flex: 1; - justify-content: space-between; - margin-left: $small; - padding: $small; + justify-content: flex-end; + margin: $small 0 $small $small; + padding: 0 $x-small; &:first-child { + justify-content: space-between; margin-left: 0; } @@ -541,8 +541,8 @@ svg { fill: $color-gray-30; - height: 15px; - width: 15px; + height: 14px; + width: 14px; } &:hover, @@ -650,12 +650,12 @@ display: flex; height: 18px; position: relative; - width: 18px; + width: 14px; svg { fill: $color-gray-30; - height: 16px; - width: 16px; + height: 14px; + width: 14px; } } } @@ -760,30 +760,23 @@ left: 0; position: absolute; top: 0; - width: 100%; + width: calc(100% - 8px); opacity: 0.4; z-index: 10; } .advanced-options-wrapper { - position: absolute; width: 100%; - padding-right: 1.5rem; padding-left: 0.25rem; } -.element-options .advanced-options-wrapper { - padding-right: 1rem; -} - .advanced-options { background-color: #303236; border-radius: 4px; - left: -8px; padding: 0.5rem; position: relative; top: 2px; - width: calc(100% + 16px); + width: 100%; z-index: 20; } @@ -918,6 +911,7 @@ flex-grow: 1; font-size: 11px; margin-top: 4px; + white-space: nowrap; } .element-set-actions-button svg { @@ -926,6 +920,10 @@ } } +.spacing-options { + display: flex; +} + .asset-group { .typography-entry { margin: 0.25rem 0; diff --git a/frontend/resources/styles/main/partials/sidebar-layers.scss b/frontend/resources/styles/main/partials/sidebar-layers.scss index 25e42fe07..d31e5c7b7 100644 --- a/frontend/resources/styles/main/partials/sidebar-layers.scss +++ b/frontend/resources/styles/main/partials/sidebar-layers.scss @@ -144,38 +144,36 @@ } } -.element-list li.masked { - .element-children { - li:first-child { - position: relative; +.element-list li.masked > .element-children > li { + &:first-child { + position: relative; - &::before { - content: " "; - border-right: 1px solid $color-gray-40; - border-top: 1px solid $color-gray-40; - position: absolute; - width: 6px; - height: 6px; - transform: rotate(-45deg); - top: -1px; - left: -4px; - } + &::before { + content: " "; + border-right: 1px solid $color-gray-40; + border-top: 1px solid $color-gray-40; + position: absolute; + width: 6px; + height: 6px; + transform: rotate(-45deg); + top: -1px; + left: -4px; } + } - li:last-child { - border-left: none; - position: relative; + &:last-child { + border-left: none; + position: relative; - &::before { - content: " "; - border-left: 1px solid $color-gray-40; - border-bottom: 1px solid $color-gray-40; - height: 1rem; - width: 0.3rem; - position: absolute; - top: 0; - left: 0; - } + &::after { + content: " "; + border-left: 1px solid $color-gray-40; + border-bottom: 1px solid $color-gray-40; + height: 1rem; + width: 0.3rem; + position: absolute; + top: 0; + left: 0; } } } diff --git a/frontend/resources/styles/main/partials/sidebar.scss b/frontend/resources/styles/main/partials/sidebar.scss index e03353aec..f474c2364 100644 --- a/frontend/resources/styles/main/partials/sidebar.scss +++ b/frontend/resources/styles/main/partials/sidebar.scss @@ -194,9 +194,8 @@ $width-settings-bar: 16rem; display: flex; flex-direction: column; width: 100%; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - + padding-top: 1px; + padding-bottom: 1px; &.open { diff --git a/frontend/resources/styles/main/partials/viewer-header.scss b/frontend/resources/styles/main/partials/viewer-header.scss index 97e076b1b..1edb9a0ac 100644 --- a/frontend/resources/styles/main/partials/viewer-header.scss +++ b/frontend/resources/styles/main/partials/viewer-header.scss @@ -42,28 +42,31 @@ } } - .header-icon { - align-items: center; - cursor: pointer; - display: flex; - justify-content: center; - - a { - height: 16px; - width: 16px; - - svg { - fill: $color-gray-30; - height: 16px; - width: 16px; - } + .view-options { + .icon { + align-items: center; + cursor: pointer; + display: flex; + justify-content: center; &:hover { - svg { + > svg { fill: $color-primary; } } } + + svg { + fill: $color-gray-30; + height: 16px; + width: 16px; + } + + .dropdown { + top: 40px; + left: 0px; + width: 260px; + } } .sitemap-zone { @@ -92,12 +95,10 @@ } } - .dropdown-button { - svg { - fill: $color-white; - height: 10px; - width: 10px; - } + .show-thumbnails-button svg { + fill: $color-white; + height: 10px; + width: 10px; } .page-name { @@ -126,8 +127,8 @@ svg { fill: $color-gray-20; - width: 24px; - height: 24px; + width: 20px; + height: 20px; } &.active { @@ -172,7 +173,7 @@ box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.25); display: flex; flex-direction: column; - left: -180px; + left: -135px; position: absolute; padding: 1rem; top: 45px; @@ -243,42 +244,9 @@ } - .custom-select-dropdown { - position: absolute; - left: 0; - z-index: 12; - max-height: 31rem; - min-width: 7rem; - overflow-y: auto; - - background-color: $color-white; - border-radius: $br-small; - box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.25); - - li { - color: $color-gray-60; - cursor: pointer; - font-size: $fs14; - display: flex; - padding: $small $medium $small 25px; - - &.selected { - background-image: url(/images/icons/tick.svg); - background-repeat: no-repeat; - background-position: 5% 48%; - background-size: 10px; - font-weight: bold; - } - - &:hover { - background-color: $color-primary-lighter; - } - } - } - .zoom-dropdown { - left : 116px; - top: 45px; + left: 180px; + top: 40px; } .users-zone { diff --git a/frontend/resources/styles/main/partials/viewer.scss b/frontend/resources/styles/main/partials/viewer.scss index 5ac5b7611..346db1c83 100644 --- a/frontend/resources/styles/main/partials/viewer.scss +++ b/frontend/resources/styles/main/partials/viewer.scss @@ -7,19 +7,18 @@ } .viewer-preview { - height: 100vh; + height: calc(100vh - 40px); grid-row: 1 / span 2; grid-column: 1 / span 1; - overflow: scroll; + overflow: auto; display: flex; justify-content: center; align-items: center; flex-flow: wrap; - .empty-state { justify-content: center; align-items: center; diff --git a/frontend/resources/styles/main/partials/workspace-header.scss b/frontend/resources/styles/main/partials/workspace-header.scss index 060688ff9..5506421c5 100644 --- a/frontend/resources/styles/main/partials/workspace-header.scss +++ b/frontend/resources/styles/main/partials/workspace-header.scss @@ -209,6 +209,11 @@ .label { color: $color-danger; } .icon svg { fill: $color-danger; } } + + &.pending { + .label { color: $color-warning; } + .icon svg { fill: $color-warning; } + } } .icon { diff --git a/frontend/resources/styles/main/partials/workspace.scss b/frontend/resources/styles/main/partials/workspace.scss index add203878..3861a5d33 100644 --- a/frontend/resources/styles/main/partials/workspace.scss +++ b/frontend/resources/styles/main/partials/workspace.scss @@ -99,7 +99,7 @@ position: fixed; right: calc(#{$width-settings-bar} + 10px); text-align: center; - width: 100px; + width: 110px; padding-bottom: 2px; span { @@ -225,3 +225,89 @@ padding: $x-small; } } + +.viewport-actions { + position: absolute; + margin-left: auto; + width: 100%; + margin-top: 2rem; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + .path-actions { + display: flex; + flex-direction: row; + background: white; + border-radius: 3px; + padding: 0.5rem; + border: 1px solid $color-gray-20; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } + + .viewport-actions-group { + display: flex; + flex-direction: row; + border-right: 1px solid $color-gray-20; + } + + .viewport-actions-entry { + width: 28px; + height: 28px; + margin: 0 0.25rem; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + border-radius: 3px; + + svg { + pointer-events: none; + width: 20px; + height: 20px; + } + + &:hover svg { + fill: $color-primary; + } + + &.is-disabled { + opacity: 0.3; + + &:hover svg { + fill: initial; + } + } + + &.is-toggled { + background: $color-black; + + svg { + fill: $color-primary; + } + } + } + + .viewport-actions-entry-wide { + width: 27px; + height: 20px; + + svg { + width: 27px; + height: 20px; + } + } + + .path-actions > :first-child .viewport-actions-entry { + margin-left: 0; + } + + .path-actions > :last-child { + border: none; + } + + .path-actions > :last-child .viewport-actions-entry { + margin-right: 0; + } +} diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index def3b53d1..7011dce99 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -6,19 +6,27 @@ PENPOT - The Open-Source prototyping tool - - - -
- - - {{# manifest}} + + + + + {{# manifest}} + + {{/manifest}} + + + + {{>../public/images/sprites/symbol/svg/sprite.symbol.svg}} +
+ + {{# manifest}} {{/manifest}} diff --git a/frontend/scripts/build.sh b/frontend/scripts/build.sh index bfbadbcf3..4587970b3 100755 --- a/frontend/scripts/build.sh +++ b/frontend/scripts/build.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash source ~/.bashrc + set -ex if [ -z "${TAG}" ]; then - export TAG=`git log -n 1 --pretty=format:%H -- ./` + export TAG=$(git log -n 1 --pretty=format:%H -- ./); fi yarn install @@ -14,7 +15,7 @@ export NODE_ENV=production; # Clean the output directory npx gulp clean || exit 1; -npx shadow-cljs release main --config-merge "{:release-version \"${TAG}\"}" +npx shadow-cljs release main --config-merge "{:release-version \"${TAG}\"}" $SHADOWCLJS_EXTRA_PARAMS npx gulp build || exit 1; npx gulp dist:clean || exit 1; npx gulp dist:copy || exit 1; diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index 408003d67..88068a45d 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -28,8 +28,7 @@ {:compiler-options {:fn-invoke-direct true :source-map true - ;; :pseudo-names true - ;; :pretty-print true + :elide-asserts true :anon-fn-naming-policy :off :source-map-detail-level :all}}} diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 6281a10f5..0d0626995 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -9,9 +9,49 @@ (ns app.config (:require + [clojure.spec.alpha :as s] + [app.common.data :as d] + [app.common.spec :as us] + [app.common.version :as v] [app.util.object :as obj] + [app.util.dom :as dom] [cuerdas.core :as str])) +;; --- Auxiliar Functions + +(s/def ::platform #{:windows :linux :macos :other}) +(s/def ::browser #{:chrome :mozilla :safari :edge :other}) + +(defn- parse-browser + [] + (let [user-agent (-> (dom/get-user-agent) str/lower) + check-chrome? (fn [] (str/includes? user-agent "chrom")) + check-firefox? (fn [] (str/includes? user-agent "firefox")) + check-edge? (fn [] (str/includes? user-agent "edg")) + check-safari? (fn [] (str/includes? user-agent "safari"))] + (cond + (check-edge?) :edge + (check-chrome?) :chrome + (check-firefox?) :firefox + (check-safari?) :safari + :else :other))) + +(defn- parse-platform + [] + (let [user-agent (-> (dom/get-user-agent) str/lower) + check-windows? (fn [] (str/includes? user-agent "windows")) + check-linux? (fn [] (str/includes? user-agent "linux")) + check-macos? (fn [] (str/includes? user-agent "mac os"))] + (cond + (check-windows?) :windows + (check-linux?) :linux + (check-macos?) :macos + :else :other))) + +;; --- Globar Config Vars + +(def default-theme "default") + (this-as global (def default-language "en") (def demo-warning (obj/get global "appDemoWarning" false)) @@ -21,8 +61,26 @@ (def worker-uri (obj/get global "appWorkerURI" "/js/worker.js")) (def public-uri (or (obj/get global "appPublicURI") (.-origin ^js js/location))) - (def media-uri (str public-uri "/media")) - (def default-theme "default")) + (def version (v/parse (obj/get global "appVersion")))) + + +(def media-uri (str public-uri "/media")) +(def browser (parse-browser)) +(def platform (parse-platform)) + +(js/console.log + (str/format "Welcome to pentpot! Version: '%s'" (:full version))) + +;; --- Helper Functions + + +(defn ^boolean check-browser? [candidate] + (us/verify ::browser candidate) + (= candidate browser)) + +(defn ^boolean check-platform? [candidate] + (us/verify ::platform candidate) + (= candidate platform)) (defn resolve-media-path [path] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 70053ffc4..787c60de3 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -10,6 +10,7 @@ (ns app.main (:require [app.common.uuid :as uuid] + [app.common.spec :as us] [app.main.data.auth :refer [logout]] [app.main.data.users :as udu] [app.main.store :as st] @@ -35,12 +36,26 @@ (declare reinit) +(s/def ::any any?) + +(defn match-path + [router path] + (when-let [match (rt/match router path)] + (if-let [conform (get-in match [:data :conform])] + (let [spath (get conform :path-params ::any) + squery (get conform :query-params ::any)] + (-> (dissoc match :params) + (assoc :path-params (us/conform spath (get match :path-params)) + :query-params (us/conform squery (get match :query-params))))) + match))) + (defn on-navigate [router path] - (let [match (rt/match router path) + (let [match (match-path router path) profile (:profile storage) authed? (and (not (nil? profile)) (not= (:id profile) uuid/zero))] + (cond (and (or (= path "") (nil? match)) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index 2209cdd99..ad0b31e1b 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -120,7 +120,8 @@ (reset! storage {}) (i18n/set-default-locale!)))) -(def logout +(defn logout + [] (ptk/reify ::logout ptk/WatchEvent (watch [_ state stream] diff --git a/frontend/src/app/main/data/colors.cljs b/frontend/src/app/main/data/colors.cljs index ae866dd50..c84529a2b 100644 --- a/frontend/src/app/main/data/colors.cljs +++ b/frontend/src/app/main/data/colors.cljs @@ -6,24 +6,24 @@ (ns app.main.data.colors (:require - [cljs.spec.alpha :as s] - [beicon.core :as rx] - [clojure.set :as set] - [potok.core :as ptk] - [app.main.streams :as ms] [app.common.data :as d] + [app.common.pages :as cp] [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.main.data.modal :as md] + [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.texts :as dwt] [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] [app.util.time :as dt] - [app.common.uuid :as uuid] - [app.main.data.workspace.common :as dwc] - [app.main.data.workspace.texts :as dwt] - [app.main.data.modal :as md] - [app.common.pages-helpers :as cph])) + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [clojure.set :as set] + [potok.core :as ptk])) (def clear-color-for-rename (ptk/reify ::clear-color-for-rename @@ -112,7 +112,7 @@ (let [pid (:current-page-id state) objects (get-in state [:workspace-data :pages-index pid :objects]) not-frame (fn [shape-id] (not= (get-in objects [shape-id :type]) :frame)) - children (->> ids (filter not-frame) (mapcat #(cph/get-children % objects))) + children (->> ids (filter not-frame) (mapcat #(cp/get-children % objects))) ids (into ids children) is-text? #(= :text (:type (get objects %))) @@ -141,7 +141,7 @@ (let [pid (:current-page-id state) objects (get-in state [:workspace-data :pages-index pid :objects]) not-frame (fn [shape-id] (not= (get-in objects [shape-id :type]) :frame)) - children (->> ids (filter not-frame) (mapcat #(cph/get-children % objects))) + children (->> ids (filter not-frame) (mapcat #(cp/get-children % objects))) ids (into ids children) update-fn (fn [s] diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs new file mode 100644 index 000000000..dd8107898 --- /dev/null +++ b/frontend/src/app/main/data/comments.cljs @@ -0,0 +1,335 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.data.comments + (:require + [cuerdas.core :as str] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as geom] + [app.common.math :as mth] + [app.common.pages :as cp] + [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.config :as cfg] + [app.main.constants :as c] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.streams :as ms] + [app.main.worker :as uw] + [app.util.router :as rt] + [app.util.timers :as ts] + [app.util.transit :as t] + [app.util.webapi :as wapi] + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [clojure.set :as set] + [potok.core :as ptk])) + +(s/def ::content ::us/string) +(s/def ::count-comments ::us/integer) +(s/def ::count-unread-comments ::us/integer) +(s/def ::created-at ::us/inst) +(s/def ::file-id ::us/uuid) +(s/def ::file-name ::us/string) +(s/def ::modified-at ::us/inst) +(s/def ::owner-id ::us/uuid) +(s/def ::page-id ::us/uuid) +(s/def ::page-name ::us/string) +(s/def ::participants (s/every ::us/uuid :kind set?)) +(s/def ::position ::us/point) +(s/def ::project-id ::us/uuid) +(s/def ::seqn ::us/integer) +(s/def ::thread-id ::us/uuid) + +(s/def ::comment-thread + (s/keys :req-un [::us/id + ::page-id + ::file-id + ::project-id + ::page-name + ::file-name + ::seqn + ::content + ::participants + ::created-at + ::modified-at + ::owner-id + ::position] + :opt-un [::count-unread-comments + ::count-comments])) + +(s/def ::comment + (s/keys :req-un [::us/id + ::thread-id + ::owner-id + ::created-at + ::modified-at + ::content])) + +(declare create-draft-thread) +(declare retrieve-comment-threads) +(declare refresh-comment-thread) + +(s/def ::create-thread-params + (s/keys :req-un [::page-id ::file-id ::position ::content])) + +(defn create-thread + [params] + (us/assert ::create-thread-params params) + (letfn [(created [{:keys [id comment] :as thread} state] + (-> state + (update :comment-threads assoc id (dissoc thread :comment)) + (update :comments-local assoc :open id) + (update :comments-local dissoc :draft) + (update :workspace-drawing dissoc :comment) + (update-in [:comments id] assoc (:id comment) comment)))] + + (ptk/reify ::create-thread + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :create-comment-thread params) + (rx/mapcat #(rp/query :comment-thread {:file-id (:file-id %) :id (:id %)})) + (rx/map #(partial created %))))))) + +(defn update-comment-thread-status + [{:keys [id] :as thread}] + (us/assert ::comment-thread thread) + (ptk/reify ::update-comment-thread-status + ptk/WatchEvent + (watch [_ state stream] + (let [done #(d/update-in-when % [:comment-threads id] assoc :count-unread-comments 0)] + (->> (rp/mutation :update-comment-thread-status {:id id}) + (rx/map (constantly done))))))) + + +(defn update-comment-thread + [{:keys [id is-resolved] :as thread}] + (us/assert ::comment-thread thread) + (ptk/reify ::update-comment-thread + + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved)) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :update-comment-thread {:id id :is-resolved is-resolved}) + (rx/ignore))))) + + +(defn add-comment + [thread content] + (us/assert ::comment-thread thread) + (us/assert ::us/string content) + (letfn [(created [comment state] + (update-in state [:comments (:id thread)] assoc (:id comment) comment))] + (ptk/reify ::create-comment + ptk/WatchEvent + (watch [_ state stream] + (rx/concat + (->> (rp/mutation :add-comment {:thread-id (:id thread) :content content}) + (rx/map #(partial created %))) + (rx/of (refresh-comment-thread thread))))))) + +(defn update-comment + [{:keys [id content thread-id] :as comment}] + (us/assert ::comment comment) + (ptk/reify :update-comment + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:comments thread-id id] assoc :content content)) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :update-comment {:id id :content content}) + (rx/ignore))))) + +(defn delete-comment-thread + [{:keys [id] :as thread}] + (us/assert ::comment-thread thread) + (ptk/reify :delete-comment-thread + ptk/UpdateEvent + (update [_ state] + (-> state + (update :comments dissoc id) + (update :comment-threads dissoc id))) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :delete-comment-thread {:id id}) + (rx/ignore))))) + +(defn delete-comment + [{:keys [id thread-id] :as comment}] + (us/assert ::comment comment) + (ptk/reify :delete-comment + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:comments thread-id] dissoc id)) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :delete-comment {:id id}) + (rx/ignore))))) + +(defn refresh-comment-thread + [{:keys [id file-id] :as thread}] + (us/assert ::comment-thread thread) + (letfn [(fetched [thread state] + (assoc-in state [:comment-threads id] thread))] + (ptk/reify ::refresh-comment-thread + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comment-thread {:file-id file-id :id id}) + (rx/map #(partial fetched %))))))) + +(defn retrieve-comment-threads + [file-id] + (us/assert ::us/uuid file-id) + (letfn [(fetched [data state] + (assoc state :comment-threads (d/index-by :id data)))] + (ptk/reify ::retrieve-comment-threads + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comment-threads {:file-id file-id}) + (rx/map #(partial fetched %))))))) + +(defn retrieve-comments + [thread-id] + (us/assert ::us/uuid thread-id) + (letfn [(fetched [comments state] + (update state :comments assoc thread-id (d/index-by :id comments)))] + (ptk/reify ::retrieve-comments + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comments {:thread-id thread-id}) + (rx/map #(partial fetched %))))))) + +(defn retrieve-unread-comment-threads + "A event used mainly in dashboard for retrieve all unread threads of a team." + [team-id] + (us/assert ::us/uuid team-id) + (ptk/reify ::retrieve-unread-comment-threads + ptk/WatchEvent + (watch [_ state stream] + (let [fetched #(assoc %2 :comment-threads (d/index-by :id %1))] + (->> (rp/query :unread-comment-threads {:team-id team-id}) + (rx/map #(partial fetched %))))))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Local State +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn open-thread + [{:keys [id] :as thread}] + (us/assert ::comment-thread thread) + (ptk/reify ::open-thread + ptk/UpdateEvent + (update [_ state] + (-> state + (update :comments-local assoc :open id) + (update :workspace-drawing dissoc :comment))))) + +(defn close-thread + [] + (ptk/reify ::close-thread + ptk/UpdateEvent + (update [_ state] + (-> state + (update :comments-local dissoc :open :draft) + (update :workspace-drawing dissoc :comment))))) + +(defn update-filters + [{:keys [mode show] :as params}] + (ptk/reify ::update-filters + ptk/UpdateEvent + (update [_ state] + (update state :comments-local + (fn [local] + (cond-> local + (some? mode) + (assoc :mode mode) + + (some? show) + (assoc :show show))))))) + +(s/def ::create-draft-params + (s/keys :req-un [::page-id ::file-id ::position])) + +(defn create-draft + [params] + (us/assert ::create-draft-params params) + (ptk/reify ::create-draft + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-drawing assoc :comment params) + (update :comments-local assoc :draft params))))) + +(defn update-draft-thread + [data] + (ptk/reify ::update-draft-thread + ptk/UpdateEvent + (update [_ state] + (-> state + (d/update-in-when [:workspace-drawing :comment] merge data) + (d/update-in-when [:comments-local :draft] merge data))))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn group-threads-by-page + [threads] + (letfn [(group-by-page [result thread] + (let [current (first result)] + (if (= (:page-id current) (:page-id thread)) + (cons (update current :items conj thread) + (rest result)) + (cons {:page-id (:page-id thread) + :page-name (:page-name thread) + :items [thread]} + result))))] + (reverse + (reduce group-by-page nil threads)))) + + +(defn group-threads-by-file-and-page + [threads] + (letfn [(group-by-file-and-page [result thread] + (let [current (first result)] + (if (and (= (:page-id current) (:page-id thread)) + (= (:file-id current) (:file-id thread))) + (cons (update current :items conj thread) + (rest result)) + (cons {:page-id (:page-id thread) + :page-name (:page-name thread) + :file-id (:file-id thread) + :file-name (:file-name thread) + :items [thread]} + result))))] + (reverse + (reduce group-by-file-and-page nil threads)))) + +(defn apply-filters + [cstate profile threads] + (let [{:keys [show mode open]} cstate] + (cond->> threads + (= :pending show) + (filter (fn [item] + (or (not (:is-resolved item)) + (= (:id item) open)))) + + (= :yours mode) + (filter #(contains? (:participants %) (:id profile)))))) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 52ae6f79a..395d03563 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -11,6 +11,7 @@ [app.common.spec :as us] [app.common.uuid :as uuid] [app.main.repo :as rp] + [app.main.data.users :as du] [app.util.router :as rt] [app.util.time :as dt] [app.util.timers :as ts] @@ -64,13 +65,6 @@ ;; --- Fetch Team -(defn assoc-team-avatar - [{:keys [photo name] :as team}] - (us/assert ::team team) - (cond-> team - (or (nil? photo) (empty? photo)) - (assoc :photo (avatars/generate {:name name})))) - (defn fetch-team [{:keys [id] :as params}] (letfn [(fetched [team state] @@ -80,20 +74,32 @@ (watch [_ state stream] (let [profile (:profile state)] (->> (rp/query :team params) - (rx/map assoc-team-avatar) + (rx/map #(avatars/assoc-avatar % :name)) (rx/map #(partial fetched %)))))))) (defn fetch-team-members [{:keys [id] :as params}] (us/assert ::us/uuid id) (letfn [(fetched [members state] - (assoc-in state [:team-members id] (d/index-by :id members)))] + (->> (map #(avatars/assoc-avatar % :name) members) + (d/index-by :id) + (assoc-in state [:team-members id])))] (ptk/reify ::fetch-team-members ptk/WatchEvent (watch [_ state stream] (->> (rp/query :team-members {:team-id id}) (rx/map #(partial fetched %))))))) +(defn fetch-team-stats + [{:keys [id] :as team}] + (us/assert ::us/uuid id) + (ptk/reify ::fetch-team-members + ptk/WatchEvent + (watch [_ state stream] + (let [fetched #(assoc-in %2 [:team-stats id] %1)] + (->> (rp/query :team-stats {:team-id id}) + (rx/map #(partial fetched %))))))) + ;; --- Fetch Projects (defn fetch-projects @@ -115,7 +121,8 @@ (watch [_ state stream] (let [profile (:profile state)] (->> (rx/merge (ptk/watch (fetch-team params) state stream) - (ptk/watch (fetch-projects {:team-id id}) state stream)) + (ptk/watch (fetch-projects {:team-id id}) state stream) + (ptk/watch (du/fetch-users {:team-id id}) state stream)) (rx/catch (fn [{:keys [type code] :as error}] (cond (and (= :not-found type) @@ -387,7 +394,9 @@ (ptk/reify ::rename-project ptk/UpdateEvent (update [_ state] - (assoc-in state [:projects team-id id :name] name)) + (-> state + (assoc-in [:projects team-id id :name] name) + (update :dashboard-local dissoc :project-for-edit))) ptk/WatchEvent (watch [_ state stream] @@ -412,6 +421,8 @@ ;; --- Delete File (by id) +(declare delete-file-result) + (defn delete-file [{:keys [id project-id] :as params}] (us/assert ::file params) @@ -424,8 +435,18 @@ ptk/WatchEvent (watch [_ state s] - (->> (rp/mutation :delete-file {:id id}) - (rx/ignore))))) + (let [team-id (uuid/uuid (get-in state [:route :path-params :team-id]))] + (->> (rp/mutation :delete-file {:id id}) + (rx/map #(delete-file-result team-id project-id))))))) + +(defn delete-file-result + [team-id project-id] + + (ptk/reify ::delete-file + ptk/UpdateEvent + (update [_ state] + (-> state + (update-in [:projects team-id project-id :count] dec))))) ;; --- Rename File diff --git a/frontend/src/app/main/data/media.cljs b/frontend/src/app/main/data/media.cljs index bdab5699b..0f32ada2b 100644 --- a/frontend/src/app/main/data/media.cljs +++ b/frontend/src/app/main/data/media.cljs @@ -2,25 +2,28 @@ ;; 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) 2016 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.data.media (:require - [cljs.spec.alpha :as s] - [cuerdas.core :as str] - [beicon.core :as rx] - [potok.core :as ptk] - [app.common.spec :as us] [app.common.data :as d] [app.common.media :as cm] - [app.main.data.messages :as dm] - [app.main.store :as st] - [app.main.repo :as rp] - [app.util.i18n :refer [tr]] - [app.util.router :as rt] + [app.common.spec :as us] [app.common.uuid :as uuid] + [app.main.data.messages :as dm] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.i18n :refer [tr]] + [app.util.router :as r] + [app.util.router :as rt] [app.util.time :as ts] - [app.util.router :as r])) + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [cuerdas.core :as str] + [potok.core :as ptk])) ;; --- Specs diff --git a/frontend/src/app/main/data/messages.cljs b/frontend/src/app/main/data/messages.cljs index c4837c0fb..7eb90707c 100644 --- a/frontend/src/app/main/data/messages.cljs +++ b/frontend/src/app/main/data/messages.cljs @@ -9,32 +9,47 @@ (ns app.main.data.messages (:require - [beicon.core :as rx] - [cljs.spec.alpha :as s] - [potok.core :as ptk] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.pages :as cp] [app.common.spec :as us] - [app.config :as cfg])) + [app.config :as cfg] + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [potok.core :as ptk])) (declare hide) (declare show) -(def +animation-timeout+ 600) +(def default-animation-timeout 600) +(def default-timeout 2000) -(s/def ::message-type #{:success :error :info :warning}) -(s/def ::message-position #{:fixed :floating :inline}) -(s/def ::message-status #{:visible :hide}) -(s/def ::message-controls #{:none :close :inline-actions :bottom-actions}) -(s/def ::message-tag string?) -(s/def ::label string?) +(s/def ::type #{:success :error :info :warning}) +(s/def ::position #{:fixed :floating :inline}) +(s/def ::status #{:visible :hide}) +(s/def ::controls #{:none :close :inline-actions :bottom-actions}) + +(s/def ::tag (s/or :str ::us/string :kw ::us/keyword)) +(s/def ::label ::us/string) (s/def ::callback fn?) -(s/def ::message-action (s/keys :req-un [::label ::callback])) -(s/def ::message-actions (s/nilable (s/coll-of ::message-action :kind vector?))) +(s/def ::action (s/keys :req-un [::label ::callback])) +(s/def ::actions (s/every ::action :kind vector?)) +(s/def ::timeout (s/nilable ::us/integer)) +(s/def ::content ::us/string) + +(s/def ::message + (s/keys :req-un [::type] + :opt-un [::status + ::position + ::controls + ::tag + ::timeout + ::actions + ::status])) (defn show [data] + (us/verify ::message data) (ptk/reify ::show ptk/UpdateEvent (update [_ state] @@ -59,7 +74,7 @@ (watch [_ state stream] (let [stoper (rx/filter (ptk/type? ::show) stream)] (->> (rx/of #(dissoc % :message)) - (rx/delay +animation-timeout+) + (rx/delay default-animation-timeout) (rx/take-until stoper)))))) (defn hide-tag @@ -73,7 +88,7 @@ (defn error ([content] (error content {})) - ([content {:keys [timeout] :or {timeout 3000}}] + ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content :type :error :position :fixed @@ -81,7 +96,7 @@ (defn info ([content] (info content {})) - ([content {:keys [timeout] :or {timeout 3000}}] + ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content :type :info :position :fixed @@ -89,7 +104,7 @@ (defn success ([content] (success content {})) - ([content {:keys [timeout] :or {timeout 3000}}] + ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content :type :success :position :fixed @@ -97,7 +112,7 @@ (defn warn ([content] (warn content {})) - ([content {:keys [timeout] :or {timeout 3000}}] + ([content {:keys [timeout] :or {timeout default-timeout}}] (show {:content content :type :warning :position :fixed diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 2e990f7d0..a3f5904bc 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -2,25 +2,29 @@ ;; 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) 2016-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.data.users (:require + [app.config :as cfg] + [app.common.data :as d] + [app.common.spec :as us] + [app.main.data.media :as di] + [app.main.data.messages :as dm] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.avatars :as avatars] + [app.util.i18n :as i18n :refer [tr]] + [app.util.router :as rt] + [app.util.storage :refer [storage]] + [app.util.theme :as theme] [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] - [potok.core :as ptk] - [app.common.spec :as us] - [app.config :as cfg] - [app.main.store :as st] - [app.main.repo :as rp] - [app.main.data.messages :as dm] - [app.main.data.media :as di] - [app.util.router :as rt] - [app.util.i18n :as i18n :refer [tr]] - [app.util.storage :refer [storage]] - [app.util.avatars :as avatars] - [app.util.theme :as theme])) + [potok.core :as ptk])) ;; --- Common Specs @@ -55,8 +59,8 @@ (update [_ state] (assoc state :profile (cond-> data - (nil? (:photo-uri data)) - (assoc :photo-uri (avatars/generate {:name fullname})) + (empty? (:photo data)) + (assoc :photo (avatars/generate {:name fullname})) (nil? (:lang data)) (assoc :lang cfg/default-language) @@ -152,6 +156,16 @@ (rx/ignore)))))) +(defn mark-onboarding-as-viewed + [] + (ptk/reify ::mark-oboarding-as-viewed + ptk/WatchEvent + (watch [_ state stream] + (let [{:keys [id] :as profile} (:profile state)] + (->> (rp/mutation :update-profile-props {:props {:onboarding-viewed true}}) + (rx/map (constantly fetch-profile))))))) + + ;; --- Update Photo (defn update-photo @@ -161,7 +175,6 @@ ptk/WatchEvent (watch [_ state stream] (let [on-success di/notify-finished-loading - on-error #(do (di/notify-finished-loading) (di/process-error %)) @@ -179,3 +192,18 @@ (rx/map (constantly fetch-profile)) (rx/catch on-error)))))) + +(defn fetch-users + [{:keys [team-id] :as params}] + (us/assert ::us/uuid team-id) + (letfn [(fetched [users state] + (->> (map #(avatars/assoc-avatar % :fullname) users) + (d/index-by :id) + (assoc state :users)))] + (ptk/reify ::fetch-team-users + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :team-users {:team-id team-id}) + (rx/map #(partial fetched %))))))) + + diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 67c1310ab..9f1c4ecd5 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -9,21 +9,22 @@ (ns app.main.data.viewer (:require - [cljs.spec.alpha :as s] - [beicon.core :as rx] - [potok.core :as ptk] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.pages :as cp] + [app.common.spec :as us] + [app.common.uuid :as uuid] [app.main.constants :as c] [app.main.repo :as rp] [app.main.store :as st] - [app.common.spec :as us] - [app.common.pages :as cp] - [app.common.data :as d] - [app.common.exceptions :as ex] + [app.main.data.comments :as dcm] + [app.util.avatars :as avatars] [app.util.router :as rt] - [app.common.uuid :as uuid] - [app.common.pages-helpers :as cph])) + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [potok.core :as ptk])) -;; --- Specs +;; --- General Specs (s/def ::id ::us/uuid) (s/def ::name ::us/string) @@ -32,101 +33,167 @@ (s/def ::file (s/keys :req-un [::id ::name])) (s/def ::page ::cp/page) -(s/def ::interactions-mode #{:hide :show :show-on-click}) - (s/def ::bundle (s/keys :req-un [::project ::file ::page])) -;; --- Initialization +;; --- Local State Initialization +(def ^:private + default-local-state + {:zoom 1 + :interactions-mode :hide + :interactions-show? false + :comments-mode :all + :comments-show :unresolved + :selected #{} + :collapsed #{} + :hover nil}) + +(declare fetch-comment-threads) (declare fetch-bundle) (declare bundle-fetched) +(s/def ::page-id ::us/uuid) +(s/def ::file-id ::us/uuid) +(s/def ::index ::us/integer) +(s/def ::token (s/nilable ::us/string)) +(s/def ::section ::us/string) + +(s/def ::initialize-params + (s/keys :req-un [::page-id ::file-id] + :opt-in [::token])) + (defn initialize - [{:keys [page-id file-id] :as params}] + [{:keys [page-id file-id token] :as params}] + (us/assert ::initialize-params params) (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (assoc state :viewer-local {:zoom 1 - :page-id page-id - :file-id file-id - :interactions-mode :hide - :show-interactions? false - - :selected #{} - :collapsed #{} - :hover nil})) + (-> state + (assoc :current-file-id file-id) + (assoc :current-page-id page-id) + (update :viewer-local + (fn [lstate] + (if (nil? lstate) + default-local-state + lstate))))) ptk/WatchEvent (watch [_ state stream] - (rx/of (fetch-bundle params))))) + (rx/of (fetch-bundle params) + (fetch-comment-threads params))))) ;; --- Data Fetching +(s/def ::fetch-bundle-params + (s/keys :req-un [::page-id ::file-id] + :opt-in [::token])) + (defn fetch-bundle - [{:keys [page-id file-id token]}] + [{:keys [page-id file-id token] :as params}] + (us/assert ::fetch-bundle-params params) (ptk/reify ::fetch-file ptk/WatchEvent (watch [_ state stream] (let [params (cond-> {:page-id page-id :file-id file-id} - (string? token) (assoc :share-token token))] - (->> (rx/zip (rp/query :viewer-bundle params) - (rp/query :file-libraries {:file-id file-id})) - (rx/first) - (rx/map #(apply bundle-fetched %)) - #_(rx/catch (fn [error-data] - (rx/of (rt/nav :not-found))))))))) + (string? token) (assoc :token token))] + (->> (rp/query :viewer-bundle params) + (rx/map bundle-fetched)))))) (defn- extract-frames [objects] (let [root (get objects uuid/zero)] - (->> (:shapes root) - (map #(get objects %)) - (filter #(= :frame (:type %))) - (reverse) - (vec)))) + (into [] (comp (map #(get objects %)) + (filter #(= :frame (:type %)))) + (reverse (:shapes root))))) (defn bundle-fetched - [{:keys [project file page share-token] :as bundle} libraries] + [{:keys [project file page share-token token libraries users] :as bundle}] (us/verify ::bundle bundle) (ptk/reify ::file-fetched ptk/UpdateEvent (update [_ state] (let [objects (:objects page) - frames (extract-frames objects)] - (-> state - (assoc :viewer-libraries (into {} (map #(vector (:id %) %) libraries)) - :viewer-data {:project project - :objects objects - :file file - :page page - :frames frames - :share-token share-token})))))) + frames (extract-frames objects) + users (map #(avatars/assoc-avatar % :fullname) users)] + (assoc state + :viewer-libraries (d/index-by :id libraries) + :viewer-data {:project project + :objects objects + :users (d/index-by :id users) + :file file + :page page + :frames frames + :token token + :share-token share-token}))))) -(def create-share-link +(defn fetch-comment-threads + [{:keys [file-id page-id] :as params}] + (letfn [(fetched [data state] + (->> data + (filter #(= page-id (:page-id %))) + (d/index-by :id) + (assoc state :comment-threads))) + (on-error [err] + (if (= :not-authorized (:code err)) + (rx/empty) + (rx/throw err)))] + + (ptk/reify ::fetch-comment-threads + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comment-threads {:file-id file-id}) + (rx/map #(partial fetched %)) + (rx/catch on-error)))))) + +(defn refresh-comment-thread + [{:keys [id file-id] :as thread}] + (letfn [(fetched [thread state] + (assoc-in state [:comment-threads id] thread))] + (ptk/reify ::refresh-comment-thread + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comment-thread {:file-id file-id :id id}) + (rx/map #(partial fetched %))))))) + +(defn fetch-comments + [{:keys [thread-id]}] + (us/assert ::us/uuid thread-id) + (letfn [(fetched [comments state] + (update state :comments assoc thread-id (d/index-by :id comments)))] + (ptk/reify ::retrieve-comments + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/query :comments {:thread-id thread-id}) + (rx/map #(partial fetched %))))))) + +(defn create-share-link + [] (ptk/reify ::create-share-link ptk/WatchEvent (watch [_ state stream] - (let [file-id (get-in state [:viewer-local :file-id]) - page-id (get-in state [:viewer-local :page-id])] + (let [file-id (:current-file-id state) + page-id (:current-page-id state)] (->> (rp/mutation! :create-file-share-token {:file-id file-id :page-id page-id}) (rx/map (fn [{:keys [token]}] - #(assoc-in % [:viewer-data :share-token] token)))))))) + #(assoc-in % [:viewer-data :token] token)))))))) -(def delete-share-link +(defn delete-share-link + [] (ptk/reify ::delete-share-link ptk/WatchEvent (watch [_ state stream] - (let [file-id (get-in state [:viewer-local :file-id]) - page-id (get-in state [:viewer-local :page-id]) - token (get-in state [:viewer-data :share-token])] - (->> (rp/mutation :delete-file-share-token {:file-id file-id - :page-id page-id - :token token}) - (rx/map (fn [_] #(update % :viewer-data dissoc :share-token)))))))) + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + token (get-in state [:viewer-data :token]) + params {:file-id file-id + :page-id page-id + :token token}] + (->> (rp/mutation :delete-file-share-token params) + (rx/map (fn [_] #(update % :viewer-data dissoc :token)))))))) ;; --- Zoom Management @@ -178,26 +245,32 @@ (ptk/reify ::select-prev-frame ptk/WatchEvent (watch [_ state stream] - (let [route (:route state) - screen (-> route :data :name keyword) - qparams (get-in route [:params :query]) - pparams (get-in route [:params :path]) - index (d/parse-integer (:index qparams))] + (let [route (:route state) + screen (-> route :data :name keyword) + qparams (:query-params route) + pparams (:path-params route) + index (:index qparams)] (when (pos? index) - (rx/of (rt/nav screen pparams (assoc qparams :index (dec index))))))))) + (rx/of + (dcm/close-thread) + (rt/nav screen pparams (assoc qparams :index (dec index))))))))) (def select-next-frame (ptk/reify ::select-prev-frame ptk/WatchEvent (watch [_ state stream] - (let [route (:route state) - screen (-> route :data :name keyword) - qparams (get-in route [:params :query]) - pparams (get-in route [:params :path]) - index (d/parse-integer (:index qparams)) + (let [route (:route state) + screen (-> route :data :name keyword) + qparams (:query-params route) + pparams (:path-params route) + index (:index qparams) total (count (get-in state [:viewer-data :frames]))] (when (< index (dec total)) - (rx/of (rt/nav screen pparams (assoc qparams :index (inc index))))))))) + (rx/of + (dcm/close-thread) + (rt/nav screen pparams (assoc qparams :index (inc index))))))))) + +(s/def ::interactions-mode #{:hide :show :show-on-click}) (defn set-interactions-mode [mode] @@ -207,7 +280,7 @@ (update [_ state] (-> state (assoc-in [:viewer-local :interactions-mode] mode) - (assoc-in [:viewer-local :show-interactions?] (case mode + (assoc-in [:viewer-local :interactions-show?] (case mode :hide false :show true :show-on-click false)))))) @@ -218,7 +291,7 @@ (ptk/reify ::flash-interactions ptk/UpdateEvent (update [_ state] - (assoc-in state [:viewer-local :show-interactions?] true)) + (assoc-in state [:viewer-local :interactions-show?] true)) ptk/WatchEvent (watch [_ state stream] @@ -231,26 +304,47 @@ (ptk/reify ::flash-done ptk/UpdateEvent (update [_ state] - (assoc-in state [:viewer-local :show-interactions?] false)))) + (assoc-in state [:viewer-local :interactions-show?] false)))) ;; --- Navigation +(defn go-to-frame-by-index + [index] + (ptk/reify ::go-to-frame + ptk/WatchEvent + (watch [_ state stream] + (let [route (:route state) + screen (-> route :data :name keyword) + qparams (:query-params route) + pparams (:path-params route)] + (rx/of (rt/nav screen pparams (assoc qparams :index index))))))) + (defn go-to-frame [frame-id] (us/verify ::us/uuid frame-id) (ptk/reify ::go-to-frame ptk/WatchEvent (watch [_ state stream] - (let [page-id (get-in state [:viewer-local :page-id]) - file-id (get-in state [:viewer-local :file-id]) - frames (get-in state [:viewer-data :frames]) - share-token (get-in state [:viewer-data :share-token]) + (let [frames (get-in state [:viewer-data :frames]) index (d/index-of-pred frames #(= (:id %) frame-id))] - (rx/of (rt/nav :viewer - {:page-id page-id - :file-id file-id} - {:token share-token - :index index})))))) + (when index + (rx/of (go-to-frame-by-index index))))))) + + +(defn go-to-section + [section] + (ptk/reify ::go-to-section + ptk/WatchEvent + (watch [_ state stream] + (let [route (:route state) + screen (-> route :data :name keyword) + pparams (:path-params route) + qparams (:query-params route)] + (rx/of + (if (= :handoff section) + (rt/nav :handoff pparams qparams) + (rt/nav :viewer pparams (assoc qparams :section section)))))))) + (defn set-current-frame [frame-id] (ptk/reify ::current-frame @@ -293,7 +387,7 @@ (conj id))] (-> state (assoc-in [:viewer-local :selected] - (cph/expand-region-selection objects selection))))))) + (cp/expand-region-selection objects selection))))))) (defn select-all [] @@ -325,12 +419,12 @@ ;; --- Shortcuts (def shortcuts - {"+" #(st/emit! increase-zoom) - "-" #(st/emit! decrease-zoom) - "ctrl+a" #(st/emit! (select-all)) - "shift+0" #(st/emit! zoom-to-50) - "shift+1" #(st/emit! reset-zoom) - "shift+2" #(st/emit! zoom-to-200) - "left" #(st/emit! select-prev-frame) - "right" #(st/emit! select-next-frame)}) + {"+" (st/emitf increase-zoom) + "-" (st/emitf decrease-zoom) + "ctrl+a" (st/emitf (select-all)) + "shift+0" (st/emitf zoom-to-50) + "shift+1" (st/emitf reset-zoom) + "shift+2" (st/emitf zoom-to-200) + "left" (st/emitf select-prev-frame) + "right" (st/emitf select-next-frame)}) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index ef4031831..f42a029a0 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -13,15 +13,17 @@ [app.common.exceptions :as ex] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] - [app.common.geom.shapes :as geom] + [app.common.geom.shapes :as gsh] + [app.common.geom.proportions :as gpr] + [app.common.geom.align :as gal] [app.common.math :as mth] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] [app.common.uuid :as uuid] [app.config :as cfg] [app.main.constants :as c] [app.main.data.colors :as mdc] + [app.main.data.messages :as dm] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.notifications :as dwn] @@ -29,6 +31,9 @@ [app.main.data.workspace.selection :as dws] [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.transforms :as dwt] + [app.main.data.workspace.groups :as dwg] + [app.main.data.workspace.drawing :as dwd] + [app.main.data.workspace.drawing.path :as dwdp] [app.main.repo :as rp] [app.main.store :as st] [app.main.streams :as ms] @@ -38,10 +43,11 @@ [app.util.timers :as ts] [app.util.transit :as t] [app.util.webapi :as wapi] + [app.util.i18n :refer [tr] :as i18n] + [app.util.dom :as dom] [beicon.core :as rx] [cljs.spec.alpha :as s] [clojure.set :as set] - [clojure.set :as set] [cuerdas.core :as str] ;; [cljs.pprint :refer [pprint]] [potok.core :as ptk])) @@ -144,6 +150,8 @@ ptk/UpdateEvent (update [_ state] (assoc state + :current-file-id file-id + :current-project-id project-id :workspace-presence {})) ptk/WatchEvent @@ -202,7 +210,6 @@ :workspace-file :workspace-project :workspace-media-objects - :workspace-users :workspace-persistence)) ptk/WatchEvent @@ -338,8 +345,8 @@ (initialize [state local] (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) - shapes (cph/select-toplevel-shapes objects {:include-frames? true}) - srect (geom/selection-rect shapes) + shapes (cp/select-toplevel-shapes objects {:include-frames? true}) + srect (gsh/selection-rect shapes) local (assoc local :vport size :zoom 1)] (cond (or (not (mth/finite? (:width srect))) @@ -348,7 +355,7 @@ (or (> (:width srect) width) (> (:height srect) height)) - (let [srect (geom/adjust-to-viewport size srect {:padding 40}) + (let [srect (gal/adjust-to-viewport size srect {:padding 40}) zoom (/ (:width size) (:width srect))] (-> local (assoc :zoom zoom) @@ -471,10 +478,10 @@ (let [vbox (update vbox :x + (:left-offset vbox)) new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom) old-zoom (:zoom local) - center (if center center (geom/center vbox)) + center (if center center (gsh/center-rect vbox)) scale (/ old-zoom new-zoom) mtx (gmt/scale-matrix (gpt/point scale) center) - vbox' (geom/transform vbox mtx) + vbox' (gsh/transform-rect vbox mtx) vbox' (update vbox' :x - (:left-offset vbox))] (-> local (assoc :zoom new-zoom) @@ -509,15 +516,15 @@ (update [_ state] (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) - shapes (cph/select-toplevel-shapes objects {:include-frames? true}) - srect (geom/selection-rect shapes)] + shapes (cp/select-toplevel-shapes objects {:include-frames? true}) + srect (gsh/selection-rect shapes)] (if (or (mth/nan? (:width srect)) (mth/nan? (:height srect))) state (update state :workspace-local (fn [{:keys [vbox vport] :as local}] - (let [srect (geom/adjust-to-viewport vport srect {:padding 40}) + (let [srect (gal/adjust-to-viewport vport srect {:padding 40}) zoom (/ (:width vport) (:width srect))] (-> local (assoc :zoom zoom) @@ -534,10 +541,10 @@ objects (dwc/lookup-page-objects state page-id) srect (->> selected (map #(get objects %)) - (geom/selection-rect))] + (gsh/selection-rect))] (update state :workspace-local (fn [{:keys [vbox vport] :as local}] - (let [srect (geom/adjust-to-viewport vport srect {:padding 40}) + (let [srect (gal/adjust-to-viewport vport srect {:padding 40}) zoom (/ (:width vport) (:width srect))] (-> local (assoc :zoom zoom) @@ -545,50 +552,6 @@ ;; --- Add shape to Workspace -(declare start-edition-mode) - -(defn add-shape - [attrs] - (us/verify ::shape-attrs attrs) - (ptk/reify ::add-shape - ptk/WatchEvent - (watch [_ state stream] - (let [page-id (:current-page-id state) - objects (dwc/lookup-page-objects state page-id) - - id (uuid/next) - shape (geom/setup-proportions attrs) - - unames (dwc/retrieve-used-names objects) - name (dwc/generate-unique-name unames (:name shape)) - - frame-id (or (:frame-id attrs) - (cph/frame-id-by-position objects attrs)) - - shape (merge - (if (= :frame (:type shape)) - cp/default-frame-attrs - cp/default-shape-attrs) - (assoc shape - :id id - :name name)) - - rchange {:type :add-obj - :id id - :page-id page-id - :frame-id frame-id - :obj shape} - uchange {:type :del-obj - :page-id page-id - :id id}] - - (rx/concat - (rx/of (dwc/commit-changes [rchange] [uchange] {:commit-local? true}) - (dws/select-shapes (d/ordered-set id))) - (when (= :text (:type attrs)) - (->> (rx/of (start-edition-mode id)) - (rx/observe-on :async)))))))) - (defn- viewport-center [state] (let [{:keys [x y width height]} (get-in state [:workspace-local :vbox])] @@ -608,14 +571,14 @@ page-id (:current-page-id state) frame-id (-> (dwc/lookup-page-objects state page-id) - (cph/frame-id-by-position {:x frame-x :y frame-y})) + (cp/frame-id-by-position {:x frame-x :y frame-y})) shape (-> (cp/make-minimal-shape type) (merge data) (merge {:x x :y y}) (assoc :frame-id frame-id) - (geom/setup-selrect))] - (rx/of (add-shape shape)))))) + (gsh/setup-selrect))] + (rx/of (dwc/add-shape shape)))))) ;; --- Update Shape Attrs @@ -722,8 +685,8 @@ rchanges (d/concat (reduce (fn [res id] - (let [children (cph/get-children id objects) - parents (cph/get-parents id objects) + (let [children (cp/get-children id objects) + parents (cp/get-parents id objects) del-change #(array-map :type :del-obj :page-id page-id @@ -749,14 +712,15 @@ uchanges (d/concat (reduce (fn [res id] - (let [children (cph/get-children id objects) - parents (cph/get-parents id objects) + (let [children (cp/get-children id objects) + parents (cp/get-parents id objects) + parent (get objects (first parents)) add-change (fn [id] (let [item (get objects id)] {:type :add-obj :id (:id item) :page-id page-id - :index (cph/position-on-parent id objects) + :index (cp/position-on-parent id objects) :frame-id (:frame-id item) :parent-id (:parent-id item) :obj item}))] @@ -766,7 +730,13 @@ (map add-change children) [{:type :reg-objects :page-id page-id - :shapes (vec parents)}]))) + :shapes (vec parents)}] + (when (some? parent) + [{:type :mod-obj + :page-id page-id + :id (:id parent) + :operations [{:type :set-touched + :touched (:touched parent)}]}])))) [] ids) (map #(array-map @@ -831,7 +801,7 @@ :frame-id (:frame-id obj) :page-id page-id :shapes [id] - :index (cph/position-on-parent id objects)})) + :index (cp/position-on-parent id objects)})) selected)] ;; TODO: maybe missing the :reg-objects event? (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) @@ -855,7 +825,7 @@ (if (nil? ids) (vec res) (recur - (conj res (cph/get-parent (first ids) objects)) + (conj res (cp/get-parent (first ids) objects)) (next ids)))) groups-to-unmask @@ -898,7 +868,7 @@ {:type :mov-objects :parent-id (:parent-id obj) :page-id page-id - :index (cph/position-on-parent id objects) + :index (cp/position-on-parent id objects) :shapes [id]}))) [] (reverse ids)) [{:type :reg-objects @@ -953,7 +923,7 @@ (defn align-objects [axis] - (us/verify ::geom/align-axis axis) + (us/verify ::gal/align-axis axis) (ptk/reify :align-objects ptk/WatchEvent (watch [_ state stream] @@ -991,17 +961,17 @@ [objects object-id axis] (let [object (get objects object-id) frame (get objects (:frame-id object))] - (geom/align-to-rect object frame axis objects))) + (gal/align-to-rect object frame axis objects))) (defn align-objects-list [objects selected axis] (let [selected-objs (map #(get objects %) selected) - rect (geom/selection-rect selected-objs)] - (mapcat #(geom/align-to-rect % rect axis objects) selected-objs))) + rect (gsh/selection-rect selected-objs)] + (mapcat #(gal/align-to-rect % rect axis objects) selected-objs))) (defn distribute-objects [axis] - (us/verify ::geom/dist-axis axis) + (us/verify ::gal/dist-axis axis) (ptk/reify :align-objects ptk/WatchEvent (watch [_ state stream] @@ -1009,7 +979,7 @@ objects (dwc/lookup-page-objects state page-id) selected (get-in state [:workspace-local :selected]) moved (-> (map #(get objects %) selected) - (geom/distribute-space axis objects))] + (gal/distribute-space axis objects))] (loop [moved (seq moved) rchanges [] uchanges []] @@ -1034,78 +1004,6 @@ :operations ops2 :id (:id curr)}))))))))) -;; --- Start shape "edition mode" - -(declare clear-edition-mode) - -(defn start-edition-mode - [id] - (us/assert ::us/uuid id) - (ptk/reify ::start-edition-mode - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:workspace-local :edition] id)) - - ptk/WatchEvent - (watch [_ state stream] - (->> stream - (rx/filter dwc/interrupt?) - (rx/take 1) - (rx/map (constantly clear-edition-mode)))))) - -(def clear-edition-mode - (ptk/reify ::clear-edition-mode - ptk/UpdateEvent - (update [_ state] - (update state :workspace-local dissoc :edition)))) - -;; --- Select for Drawing - -(def clear-drawing - (ptk/reify ::clear-drawing - ptk/UpdateEvent - (update [_ state] - (update state :workspace-drawing dissoc :tool :object)))) - -(defn select-for-drawing - ([tool] (select-for-drawing tool nil)) - ([tool data] - (ptk/reify ::select-for-drawing - ptk/UpdateEvent - (update [_ state] - (update state :workspace-drawing assoc :tool tool :object data)) - - ptk/WatchEvent - (watch [_ state stream] - (let [stoper (rx/filter (ptk/type? ::clear-drawing) stream)] - (rx/merge - (rx/of (dws/deselect-all)) - - ;; NOTE: comments are a special case and they manage they - ;; own interrupt cycle. - (when (not= tool :comments) - (->> stream - (rx/filter dwc/interrupt?) - (rx/take 1) - (rx/map (constantly clear-drawing)) - (rx/take-until stoper))))))))) - -;; --- Update Dimensions - -;; Event mainly used for handling user modification of the size of the -;; object from workspace sidebar options inputs. - -(defn update-dimensions - [ids attr value] - (us/verify (s/coll-of ::us/uuid) ids) - (us/verify #{:width :height} attr) - (us/verify ::us/number value) - (ptk/reify ::update-dimensions - ptk/WatchEvent - (watch [_ state stream] - (rx/of (dwc/update-shapes ids #(geom/resize-rect % attr value)))))) - - ;; --- Shape Proportions (defn set-shape-proportion-lock @@ -1113,11 +1011,13 @@ (ptk/reify ::set-shape-proportion-lock ptk/WatchEvent (watch [_ state stream] - (rx/of (dwc/update-shapes [id] (fn [shape] - (if-not lock - (assoc shape :proportion-lock false) - (-> (assoc shape :proportion-lock true) - (geom/assign-proportions))))))))) + (letfn [(assign-proportions [shape] + (if-not lock + (assoc shape :proportion-lock false) + (-> (assoc shape :proportion-lock true) + (gpr/assign-proportions))))] + (rx/of (dwc/update-shapes [id] assign-proportions)))))) + ;; --- Update Shape Position (s/def ::x number?) @@ -1142,37 +1042,20 @@ (rx/of (dwt/set-modifiers [id] {:displacement displ}) (dwt/apply-modifiers [id])))))) -;; --- Path Modifications +;; --- Update Shape Flags -(defn update-path - "Update a concrete point in the path shape." - [id index delta] - (us/verify ::us/uuid id) - (us/verify ::us/integer index) - (us/verify gpt/point? delta) - (js/alert "TODO: broken") - #_(ptk/reify ::update-path - ptk/UpdateEvent - (update [_ state] - (let [page-id (:current-page-id state)] - (-> state - (update-in [:workspace-data page-id :objects id :segments index] gpt/add delta) - (update-in [:workspace-data page-id :objects id] geom/update-path-selrect)))))) - -;; --- Shape attrs (Layers Sidebar) - -(defn toggle-collapse - [id] - (ptk/reify ::toggle-collapse - ptk/UpdateEvent - (update [_ state] - (update-in state [:workspace-local :expanded id] not)))) - -(def collapse-all - (ptk/reify ::collapse-all - ptk/UpdateEvent - (update [_ state] - (update state :workspace-local dissoc :expanded)))) +(defn update-shape-flags + [id {:keys [blocked hidden] :as flags}] + (s/assert ::us/uuid id) + (s/assert ::shape-attrs flags) + (ptk/reify ::update-shape-flags + ptk/WatchEvent + (watch [_ state stream] + (letfn [(update-fn [obj] + (cond-> obj + (boolean? blocked) (assoc :blocked blocked) + (boolean? hidden) (assoc :hidden hidden)))] + (rx/of (dwc/update-shapes-recursive [id] update-fn)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1223,6 +1106,15 @@ qparams {:page-id page-id}] (rx/of (rt/nav :workspace pparams qparams)))))) + +(defn go-to-viewer + [{:keys [file-id page-id] :as params}] + (ptk/reify ::go-to-viewer + ptk/WatchEvent + (watch [_ state stream] + (rx/of ::dwp/force-persist + (rt/nav :viewer params {:index 0}))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Context Menu ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1255,7 +1147,11 @@ ptk/WatchEvent (watch [_ state stream] - (rx/of (dws/select-shape (:id shape)))))) + (let [selected (get-in state [:workspace-local :selected])] + (if (selected (:id shape)) + (rx/empty) + (rx/of (dws/deselect-all) + (dws/select-shape (:id shape)))))))) (def hide-context-menu (ptk/reify ::hide-context-menu @@ -1281,7 +1177,7 @@ ;; When the parent frame is not selected we change to relative ;; coordinates (let [frame (get objects (:frame-id shape))] - (geom/translate-to-frame shape frame)) + (gsh/translate-to-frame shape frame)) shape)) (prepare [result objects selected id] @@ -1307,6 +1203,66 @@ (rx/catch on-copy-error) (rx/ignore))))))) +(declare paste-shape) +(declare paste-text) +(declare paste-image) + +(def paste + (ptk/reify ::paste + ptk/WatchEvent + (watch [_ state stream] + (try + (let [clipboard-str (wapi/read-from-clipboard) + + paste-transit-str + (->> clipboard-str + (rx/filter t/transit?) + (rx/map t/decode) + (rx/filter #(= :copied-shapes (:type %))) + (rx/map #(select-keys % [:selected :objects])) + (rx/map paste-shape)) + + paste-plain-text-str + (->> clipboard-str + (rx/filter (comp not empty?)) + (rx/map paste-text)) + + paste-image-str + (->> (wapi/read-image-from-clipboard) + (rx/map paste-image))] + + (->> (rx/concat paste-transit-str + paste-plain-text-str + paste-image-str) + (rx/first) + (rx/catch + (fn [err] + (js/console.error "Clipboard error:" err) + (rx/empty))))) + (catch :default e + (let [data (ex-data e)] + (if (:not-implemented data) + (rx/of (dm/warn (tr "errors.clipboard-not-implemented"))) + (js/console.error "ERROR" e)))))))) + +(defn paste-from-event + [event] + (ptk/reify ::paste-from-event + ptk/WatchEvent + (watch [_ state stream] + (try + (let [paste-data (wapi/read-from-paste-event event) + image-data (wapi/extract-images paste-data) + text-data (wapi/extract-text paste-data) + decoded-data (and (t/transit? text-data) (t/decode text-data))] + (cond + (seq image-data) (rx/from (map paste-image image-data)) + decoded-data (rx/of (paste-shape decoded-data)) + (string? text-data) (rx/of (paste-text text-data)) + :else (rx/empty))) + (catch :default err + (js/console.error "Clipboard error:" err)))))) + (defn selected-frame? [state] (let [selected (get-in state [:workspace-local :selected]) page-id (:current-page-id state) @@ -1314,13 +1270,13 @@ (and (and (= 1 (count selected)) (= :frame (get-in objects [(first selected) :type])))))) -(defn- paste-impl +(defn- paste-shape [{:keys [selected objects] :as data}] - (ptk/reify ::paste-impl + (ptk/reify ::paste-shape ptk/WatchEvent (watch [_ state stream] (let [selected-objs (map #(get objects %) selected) - wrapper (geom/selection-rect selected-objs) + wrapper (gsh/selection-rect selected-objs) orig-pos (gpt/point (:x1 wrapper) (:y1 wrapper)) mouse-pos @ms/mouse-position @@ -1332,7 +1288,7 @@ [frame-id delta] (if (selected-frame? state) [(first page-selected) (get page-objects (first page-selected))] - [(cph/frame-id-by-position page-objects mouse-pos) + [(cp/frame-id-by-position page-objects mouse-pos) (gpt/subtract mouse-pos orig-pos)]) objects (d/mapm (fn [_ v] (assoc v :frame-id frame-id :parent-id frame-id)) objects) @@ -1350,72 +1306,7 @@ (map #(get-in % [:obj :id])) (into (d/ordered-set)))] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes selected)))))) - -(defn- image-uploaded - [image] - (let [{:keys [x y]} @ms/mouse-position - {:keys [width height]} image - shape {:name (:name image) - :width width - :height height - :x (- x (/ width 2)) - :y (- y (/ height 2)) - :metadata {:width width - :height height - :id (:id image) - :path (:path image)}}] - (st/emit! (create-and-add-shape :image x y shape)))) - -(defn- paste-image-impl - [image] - (ptk/reify ::paste-bin-impl - ptk/WatchEvent - (watch [_ state stream] - (let [file-id (get-in state [:workspace-file :id]) - params {:file-id file-id - :local? true - :js-files [image]}] - (rx/of (dwp/upload-media-objects - (with-meta params - {:on-success image-uploaded}))))))) - -(declare paste-text) - -(def paste - (ptk/reify ::paste - ptk/WatchEvent - (watch [_ state stream] - (try - (let [clipboard-str (wapi/read-from-clipboard) - - paste-transit-str - (->> clipboard-str - (rx/filter t/transit?) - (rx/map t/decode) - (rx/filter #(= :copied-shapes (:type %))) - (rx/map #(select-keys % [:selected :objects])) - (rx/map paste-impl)) - - paste-plain-text-str - (->> clipboard-str - (rx/filter (comp not empty?)) - (rx/map paste-text)) - - paste-image-str - (->> (wapi/read-image-from-clipboard) - (rx/map paste-image-impl))] - - (->> (rx/concat paste-transit-str - paste-plain-text-str - paste-image-str) - (rx/first) - (rx/catch - (fn [err] - (js/console.error "Clipboard error:" err) - (rx/empty))))) - (catch :default e - (.error js/console "ERROR" e)))))) + (dwc/select-shapes selected)))))) (defn as-content [text] (let [paragraphs (->> (str/lines text) @@ -1436,8 +1327,8 @@ height 16 page-id (:current-page-id state) frame-id (-> (dwc/lookup-page-objects state page-id) - (cph/frame-id-by-position @ms/mouse-position)) - shape (geom/setup-selrect + (cp/frame-id-by-position @ms/mouse-position)) + shape (gsh/setup-selrect {:id id :type :text :name "Text" @@ -1448,147 +1339,38 @@ :height height :grow-type (if (> (count text) 100) :auto-height :auto-width) :content (as-content text)})] - (rx/of dwc/start-undo-transaction + (rx/of (dwc/start-undo-transaction) (dws/deselect-all) - (add-shape shape) - dwc/commit-undo-transaction))))) + (dwc/add-shape shape) + (dwc/commit-undo-transaction)))))) -(defn update-shape-flags - [id {:keys [blocked hidden] :as flags}] - (s/assert ::us/uuid id) - (s/assert ::shape-attrs flags) - (ptk/reify ::update-shape-flags +(defn- image-uploaded + [image] + (let [{:keys [x y]} @ms/mouse-position + {:keys [width height]} image + shape {:name (:name image) + :width width + :height height + :x (- x (/ width 2)) + :y (- y (/ height 2)) + :metadata {:width width + :height height + :id (:id image) + :path (:path image)}}] + (st/emit! (create-and-add-shape :image x y shape)))) + +(defn- paste-image + [image] + (ptk/reify ::paste-bin-impl ptk/WatchEvent (watch [_ state stream] - (letfn [(update-fn [obj] - (cond-> obj - (boolean? blocked) (assoc :blocked blocked) - (boolean? hidden) (assoc :hidden hidden)))] - (rx/of (dwc/update-shapes-recursive [id] update-fn)))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; GROUPS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def group-selected - (ptk/reify ::group-selected - 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 [[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 group)))))))))) - -(def ungroup-selected - (ptk/reify ::ungroup-selected - 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]) - group-id (first selected) - group (get objects group-id)] - (when (and (= 1 (count selected)) - (= (:type group) :group)) - (let [[rchanges uchanges] - (dws/prepare-remove-group page-id group objects)] - (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))))) - -(def mask-group - (ptk/reify ::mask-group - 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, - ;; create a new group and set it as masked. - [group rchanges uchanges] - (if (and (= (count shapes) 1) - (= (:type (first shapes)) :group)) - [(first shapes) [] []] - (dws/prepare-create-group page-id shapes "Group-" true)) - - rchanges (d/concat rchanges - [{:type :mod-obj - :page-id page-id - :id (:id group) - :operations [{:type :set - :attr :masked-group? - :val true}]} - {:type :reg-objects - :page-id page-id - :shapes [(:id group)]}]) - - uchanges (conj rchanges - {:type :mod-obj - :page-id page-id - :id (:id group) - :operations [{:type :set - :attr :masked-group? - :val nil}]}) - - ;; If the mask has the default color, change it automatically - ;; to white, to have an opaque mask by default (user may change - ;; it later to have different degrees of transparency). - mask (first shapes) - rchanges (if (not= (:fill-color mask) cp/default-color) - rchanges - (conj rchanges - {:type :mod-obj - :page-id page-id - :id (:id mask) - :operations [{:type :set - :attr :fill-color - :val "#ffffff"}]})) - - uchanges (if (not= (:fill-color mask) cp/default-color) - uchanges - (conj uchanges - {:type :mod-obj - :page-id page-id - :id (:id mask) - :operations [{:type :set - :attr :fill-color - :val (:fill-color mask)}]}))] - - (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set (:id group)))))))))) - -(def unmask-group - (ptk/reify ::unmask-group - 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])] - (when (= (count selected) 1) - (let [group (get objects (first selected)) - - rchanges [{:type :mod-obj - :page-id page-id - :id (:id group) - :operations [{:type :set - :attr :masked-group? - :val nil}]}] - - uchanges [{:type :mod-obj - :page-id page-id - :id (:id group) - :operations [{:type :set - :attr :masked-group? - :val (:masked-group? group)}]}]] - - (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set (:id group)))))))))) - + (let [file-id (get-in state [:workspace-file :id]) + params {:file-id file-id + :local? true + :js-files [image]}] + (rx/of (dwp/upload-media-objects + (with-meta params + {:on-success image-uploaded}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Interactions @@ -1689,33 +1471,44 @@ ;; Transform -(def start-rotate dwt/start-rotate) -(def start-resize dwt/start-resize) -(def start-move-selected dwt/start-move-selected) -(def move-selected dwt/move-selected) - -(def set-rotation dwt/set-rotation) -(def set-modifiers dwt/set-modifiers) -(def apply-modifiers dwt/apply-modifiers) +(d/export dwt/start-rotate) +(d/export dwt/start-resize) +(d/export dwt/start-move-selected) +(d/export dwt/move-selected) +(d/export dwt/set-rotation) +(d/export dwt/set-modifiers) +(d/export dwt/apply-modifiers) +(d/export dwt/update-dimensions) ;; Persistence -(def set-file-shared dwp/set-file-shared) -(def fetch-shared-files dwp/fetch-shared-files) -(def link-file-to-library dwp/link-file-to-library) -(def unlink-file-from-library dwp/unlink-file-from-library) -(def upload-media-objects dwp/upload-media-objects) -(def delete-media-object dwp/delete-media-object) +(d/export dwp/set-file-shared) +(d/export dwp/fetch-shared-files) +(d/export dwp/link-file-to-library) +(d/export dwp/unlink-file-from-library) +(d/export dwp/upload-media-objects) ;; Selection -(def select-shape dws/select-shape) -(def deselect-all dws/deselect-all) -(def select-shapes dws/select-shapes) -(def duplicate-selected dws/duplicate-selected) -(def handle-selection dws/handle-selection) -(def select-inside-group dws/select-inside-group) +(d/export dws/select-shape) +(d/export dws/select-all) +(d/export dws/deselect-all) +(d/export dwc/select-shapes) +(d/export dws/duplicate-selected) +(d/export dws/handle-selection) +(d/export dws/select-inside-group) +(d/export dwd/select-for-drawing) +(d/export dwc/clear-edition-mode) +(d/export dwc/add-shape) +(d/export dwc/start-edition-mode) +(d/export dwdp/start-path-edit) +;; Groups + +(d/export dwg/mask-group) +(d/export dwg/unmask-group) +(d/export dwg/group-selected) +(d/export dwg/ungroup-selected) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Shortcuts @@ -1723,44 +1516,66 @@ ;; Shortcuts impl https://github.com/ccampbell/mousetrap +(defn esc-pressed [] + (ptk/reify :esc-pressed + ptk/WatchEvent + (watch [_ state stream] + ;; Not interrupt when we're editing a path + (let [edition-id (get-in state [:workspace-local :edition]) + path-edit-mode (get-in state [:workspace-local :edit-path edition-id :edit-mode])] + (if-not (= :draw path-edit-mode) + (rx/of :interrupt (deselect-all true)) + (rx/empty)))))) + +(defn c-mod + "Adds the control/command modifier to a shortcuts depending on the + operating system for the user" + [shortcut] + (if (cfg/check-platform? :macos) + (str "command+" shortcut) + (str "ctrl+" shortcut))) + (def shortcuts - {"ctrl+i" #(st/emit! (toggle-layout-flags :assets)) - "ctrl+l" #(st/emit! (toggle-layout-flags :sitemap :layers)) - "ctrl+shift+r" #(st/emit! (toggle-layout-flags :rules)) - "ctrl+a" #(st/emit! (toggle-layout-flags :dynamic-alignment)) - "ctrl+p" #(st/emit! (toggle-layout-flags :colorpalette)) - "ctrl+'" #(st/emit! (toggle-layout-flags :display-grid)) - "ctrl+shift+'" #(st/emit! (toggle-layout-flags :snap-grid)) + {(c-mod "i") #(st/emit! (toggle-layout-flags :assets)) + (c-mod "l") #(st/emit! (toggle-layout-flags :sitemap :layers)) + (c-mod "shift+r") #(st/emit! (toggle-layout-flags :rules)) + (c-mod "a") #(st/emit! (select-all)) + (c-mod "p") #(st/emit! (toggle-layout-flags :colorpalette)) + (c-mod "'") #(st/emit! (toggle-layout-flags :display-grid)) + (c-mod "shift+'") #(st/emit! (toggle-layout-flags :snap-grid)) "+" #(st/emit! (increase-zoom nil)) "-" #(st/emit! (decrease-zoom nil)) - "ctrl+g" #(st/emit! group-selected) + (c-mod "g") #(st/emit! group-selected) "shift+g" #(st/emit! ungroup-selected) - "ctrl+m" #(st/emit! mask-group) + (c-mod "m") #(st/emit! mask-group) "shift+m" #(st/emit! unmask-group) - "ctrl+k" #(st/emit! dwl/add-component) + (c-mod "k") #(st/emit! dwl/add-component) "shift+0" #(st/emit! reset-zoom) "shift+1" #(st/emit! zoom-to-fit-all) "shift+2" #(st/emit! zoom-to-selected-shape) - "ctrl+d" #(st/emit! duplicate-selected) - "ctrl+z" #(st/emit! dwc/undo) - "ctrl+shift+z" #(st/emit! dwc/redo) - "ctrl+y" #(st/emit! dwc/redo) - "ctrl+q" #(st/emit! dwc/reinitialize-undo) - "a" #(st/emit! (select-for-drawing :frame)) - "b" #(st/emit! (select-for-drawing :rect)) - "e" #(st/emit! (select-for-drawing :circle)) + (c-mod "d") #(st/emit! duplicate-selected) + (c-mod "z") #(st/emit! dwc/undo) + (c-mod "shift+z") #(st/emit! dwc/redo) + (c-mod "y") #(st/emit! dwc/redo) + (c-mod "q") #(st/emit! dwc/reinitialize-undo) + "a" #(st/emit! (dwd/select-for-drawing :frame)) + "r" #(st/emit! (dwd/select-for-drawing :rect)) + "e" #(st/emit! (dwd/select-for-drawing :circle)) "t" #(st/emit! dwtxt/start-edit-if-selected - (select-for-drawing :text)) - "ctrl+c" #(st/emit! copy-selected) - "ctrl+v" #(st/emit! paste) - "ctrl+x" #(st/emit! copy-selected delete-selected) - "escape" #(st/emit! :interrupt (deselect-all true)) + (dwd/select-for-drawing :text)) + "p" #(st/emit! (dwd/select-for-drawing :path)) + "k" (fn [event] + (let [image-upload (dom/get-element "image-upload")] + (dom/click image-upload))) + (c-mod "c") #(st/emit! copy-selected) + (c-mod "x") #(st/emit! copy-selected delete-selected) + "escape" #(st/emit! (esc-pressed)) "del" #(st/emit! delete-selected) "backspace" #(st/emit! delete-selected) - "ctrl+up" #(st/emit! (vertical-order-selected :up)) - "ctrl+down" #(st/emit! (vertical-order-selected :down)) - "ctrl+shift+up" #(st/emit! (vertical-order-selected :top)) - "ctrl+shift+down" #(st/emit! (vertical-order-selected :bottom)) + (c-mod "up") #(st/emit! (vertical-order-selected :up)) + (c-mod "down") #(st/emit! (vertical-order-selected :down)) + (c-mod "shift+up") #(st/emit! (vertical-order-selected :top)) + (c-mod "shift+down") #(st/emit! (vertical-order-selected :bottom)) "shift+up" #(st/emit! (dwt/move-selected :up true)) "shift+down" #(st/emit! (dwt/move-selected :down true)) "shift+right" #(st/emit! (dwt/move-selected :right true)) @@ -1769,6 +1584,4 @@ "down" #(st/emit! (dwt/move-selected :down false)) "right" #(st/emit! (dwt/move-selected :right false)) "left" #(st/emit! (dwt/move-selected :left false)) - "i" #(st/emit! (mdc/picker-for-selected-shape ))}) - diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index b355be375..ccbbd8c30 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -9,59 +9,36 @@ (ns app.main.data.workspace.comments (:require - [cuerdas.core :as str] [app.common.data :as d] [app.common.exceptions :as ex] - [app.common.geom.matrix :as gmt] - [app.common.geom.point :as gpt] - [app.common.geom.shapes :as geom] [app.common.math :as mth] - [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.config :as cfg] [app.main.constants :as c] + [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] - [app.main.repo :as rp] + [app.main.data.comments :as dcm] [app.main.store :as st] [app.main.streams :as ms] - [app.main.worker :as uw] [app.util.router :as rt] - [app.util.timers :as ts] - [app.util.transit :as t] - [app.util.webapi :as wapi] [beicon.core :as rx] [cljs.spec.alpha :as s] - [clojure.set :as set] [potok.core :as ptk])) -(s/def ::comment-thread any?) -(s/def ::comment any?) - -(declare create-draft-thread) -(declare retrieve-comment-threads) -(declare refresh-comment-thread) (declare handle-interrupt) (declare handle-comment-layer-click) (defn initialize-comments [file-id] (us/assert ::us/uuid file-id) - (ptk/reify ::start-commenting - ptk/UpdateEvent - (update [_ state] - (update state :workspace-local assoc :commenting true)) - + (ptk/reify ::initialize-comments ptk/WatchEvent (watch [_ state stream] (let [stoper (rx/filter #(= ::finalize %) stream)] (rx/merge - (rx/of (retrieve-comment-threads file-id)) + (rx/of (dcm/retrieve-comment-threads file-id)) (->> stream (rx/filter ms/mouse-click?) (rx/switch-map #(rx/take 1 ms/mouse-position)) - (rx/mapcat #(rx/take 1 ms/mouse-position)) (rx/map handle-comment-layer-click) (rx/take-until stoper)) (->> stream @@ -72,19 +49,13 @@ (defn- handle-interrupt [] (ptk/reify ::handle-interrupt - ptk/UpdateEvent - (update [_ state] - (let [local (:workspace-comments state) - drawing (:workspace-drawing state)] + ptk/WatchEvent + (watch [_ state stream] + (let [local (:comments-local state)] (cond - (:comment drawing) - (update state :workspace-drawing dissoc :comment) - - (:open local) - (update state :workspace-comments dissoc :open) - - :else - (dissoc state :workspace-drawing)))))) + (:draft local) (rx/of (dcm/close-thread)) + (:open local) (rx/of (dcm/close-thread)) + :else (rx/of #(dissoc % :workspace-drawing))))))) ;; Event responsible of the what should be executed when user clicked ;; on the comments layer. An option can be create a new draft thread, @@ -93,215 +64,51 @@ (defn- handle-comment-layer-click [position] (ptk/reify ::handle-comment-layer-click - ptk/UpdateEvent - (update [_ state] - (let [local (:workspace-comments state)] - (if (:open local) - (update state :workspace-comments dissoc :open) - (update state :workspace-drawing assoc - :comment {:position position :content ""})))))) - -(defn create-thread - [data] - (letfn [(created [{:keys [id comment] :as thread} state] - (-> state - (update :comment-threads assoc id (dissoc thread :comment)) - (update :workspace-comments assoc :open id) - (update :workspace-drawing dissoc :comment) - (update-in [:comments id] assoc (:id comment) comment)))] - - (ptk/reify ::create-thread - ptk/WatchEvent - (watch [_ state stream] - (let [file-id (get-in state [:workspace-file :id]) - page-id (:current-page-id state) - params (assoc data - :page-id page-id - :file-id file-id)] - (->> (rp/mutation :create-comment-thread params) - (rx/map #(partial created %)))))))) - -(defn update-comment-thread-status - [{:keys [id] :as thread}] - (us/assert ::comment-thread thread) - (ptk/reify ::update-comment-thread-status - ptk/UpdateEvent - (update [_ state] - (d/update-in-when state [:comment-threads id] assoc :count-unread-comments 0)) - ptk/WatchEvent (watch [_ state stream] - (->> (rp/mutation :update-comment-thread-status {:id id}) - (rx/ignore))))) - - -(defn update-comment-thread - [{:keys [id is-resolved] :as thread}] - (us/assert ::comment-thread thread) - (ptk/reify ::update-comment-thread - - ptk/UpdateEvent - (update [_ state] - (d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved)) - - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/mutation :update-comment-thread {:id id :is-resolved is-resolved}) - (rx/ignore))))) - - -(defn add-comment - [thread content] - (us/assert ::comment-thread thread) - (us/assert ::us/string content) - (letfn [(created [comment state] - (update-in state [:comments (:id thread)] assoc (:id comment) comment))] - (ptk/reify ::create-comment - ptk/WatchEvent - (watch [_ state stream] - (rx/concat - (->> (rp/mutation :add-comment {:thread-id (:id thread) :content content}) - (rx/map #(partial created %))) - (rx/of (refresh-comment-thread thread))))))) - -(defn update-comment - [{:keys [id content thread-id] :as comment}] - (us/assert ::comment comment) - (ptk/reify :update-comment - ptk/UpdateEvent - (update [_ state] - (d/update-in-when state [:comments thread-id id] assoc :content content)) - - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/mutation :update-comment {:id id :content content}) - (rx/ignore))))) - -(defn delete-comment-thread - [{:keys [id] :as thread}] - (us/assert ::comment-thread thread) - (ptk/reify :delete-comment-thread - ptk/UpdateEvent - (update [_ state] - (-> state - (update :comments dissoc id) - (update :comment-threads dissoc id))) - - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/mutation :delete-comment-thread {:id id}) - (rx/ignore))))) - -(defn delete-comment - [{:keys [id thread-id] :as comment}] - (us/assert ::comment comment) - (ptk/reify :delete-comment - ptk/UpdateEvent - (update [_ state] - (d/update-in-when state [:comments thread-id] dissoc id)) - - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/mutation :delete-comment {:id id}) - (rx/ignore))))) - -(defn refresh-comment-thread - [{:keys [id file-id] :as thread}] - (us/assert ::comment-thread thread) - (letfn [(fetched [thread state] - (assoc-in state [:comment-threads id] thread))] - (ptk/reify ::refresh-comment-thread - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/query :comment-thread {:file-id file-id :id id}) - (rx/map #(partial fetched %))))))) - -(defn retrieve-comment-threads - [file-id] - (us/assert ::us/uuid file-id) - (letfn [(fetched [data state] - (assoc state :comment-threads (d/index-by :id data)))] - (ptk/reify ::retrieve-comment-threads - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/query :comment-threads {:file-id file-id}) - (rx/map #(partial fetched %))))))) - -(defn retrieve-comments - [thread-id] - (us/assert ::us/uuid thread-id) - (letfn [(fetched [comments state] - (update state :comments assoc thread-id (d/index-by :id comments)))] - (ptk/reify ::retrieve-comments - ptk/WatchEvent - (watch [_ state stream] - (->> (rp/query :comments {:thread-id thread-id}) - (rx/map #(partial fetched %))))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Workspace (local) events -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn open-thread - [{:keys [id] :as thread}] - (us/assert ::comment-thread thread) - (ptk/reify ::open-thread - ptk/UpdateEvent - (update [_ state] - (-> state - (update :workspace-comments assoc :open id) - (update :workspace-drawing dissoc :comment))))) - -(defn close-thread - [] - (ptk/reify ::open-thread - ptk/UpdateEvent - (update [_ state] - (-> state - (update :workspace-comments dissoc :open) - (update :workspace-drawing dissoc :comment))))) - -(defn update-draft-thread - [data] - (ptk/reify ::update-draft-thread - ptk/UpdateEvent - (update [_ state] - (update state :workspace-drawing assoc :comment data)))) - -(defn update-filters - [{:keys [main resolved]}] - (ptk/reify ::update-filters - ptk/UpdateEvent - (update [_ state] - (update state :workspace-comments - (fn [local] - (cond-> local - (some? main) - (assoc :filter main) - - (some? resolved) - (assoc :filter-resolved resolved))))))) - + (let [local (:comments-local state)] + (if (some? (:open local)) + (rx/of (dcm/close-thread)) + (let [page-id (:current-page-id state) + file-id (:current-file-id state) + params {:position position + :page-id page-id + :file-id file-id}] + (rx/of (dcm/create-draft params)))))))) (defn center-to-comment-thread [{:keys [id position] :as thread}] - (us/assert ::comment-thread thread) + (us/assert ::dcm/comment-thread thread) (ptk/reify :center-to-comment-thread ptk/UpdateEvent (update [_ state] (update state :workspace-local (fn [{:keys [vbox vport zoom] :as local}] - ;; (prn "position=" position) - ;; (prn "vbox=" vbox) - ;; (prn "vport=" vport) + (prn "center-to-comment-thread" vbox) (let [pw (/ 50 zoom) ph (/ 200 zoom) nw (mth/round (- (/ (:width vbox) 2) pw)) nh (mth/round (- (/ (:height vbox) 2) ph)) nx (- (:x position) nw) ny (- (:y position) nh)] - (update local :vbox assoc :x nx :y ny)))) - - ))) + (update local :vbox assoc :x nx :y ny))))))) + +(defn navigate + [{:keys [project-id file-id page-id] :as thread}] + (us/assert ::dcm/comment-thread thread) + (ptk/reify ::navigate + ptk/WatchEvent + (watch [_ state stream] + (let [pparams {:project-id (:project-id thread) + :file-id (:file-id thread)} + qparams {:page-id (:page-id thread)}] + (rx/merge + (rx/of (rt/nav :workspace pparams qparams) + (dw/select-for-drawing :comments)) + (->> stream + (rx/filter (ptk/type? ::dw/initialize-viewport)) + (rx/take 1) + (rx/mapcat #(rx/of (center-to-comment-thread thread) + (dcm/open-thread thread))))))))) diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index 282efc500..037fa483e 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -15,13 +15,16 @@ [potok.core :as ptk] [app.common.data :as d] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] [app.common.uuid :as uuid] [app.main.worker :as uw] [app.util.timers :as ts] - [app.common.geom.shapes :as geom])) + [app.common.geom.proportions :as gpr] + [app.common.geom.shapes :as gsh])) +(s/def ::shape-attrs ::cp/shape-attrs) +(s/def ::set-of-string (s/every string? :kind set?)) +(s/def ::ordered-set-of-uuid (s/every uuid? :kind d/ordered-set?)) ;; --- Protocols (declare setup-selection-index) @@ -62,28 +65,35 @@ commit-local? false} :as opts}] (us/verify ::cp/changes changes) - (us/verify ::cp/changes undo-changes) - (ptk/reify ::commit-changes - cljs.core/IDeref - (-deref [_] changes) + ;; (us/verify ::cp/changes undo-changes) - ptk/UpdateEvent - (update [_ state] - (let [state (update-in state [:workspace-file :data] cp/process-changes changes)] - (cond-> state - commit-local? (update :workspace-data cp/process-changes changes)))) + (let [error (volatile! nil)] + (ptk/reify ::commit-changes + cljs.core/IDeref + (-deref [_] changes) - ptk/WatchEvent - (watch [_ state stream] - (let [page-id (:current-page-id state)] - (rx/concat - (when (some :page-id changes) - (rx/of (update-indices page-id))) + ptk/UpdateEvent + (update [_ state] + (try + (let [state (update-in state [:workspace-file :data] cp/process-changes changes)] + (cond-> state + commit-local? (update :workspace-data cp/process-changes changes))) + (catch :default e + (vreset! error e) + state))) - (when (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes changes}] - (rx/of (append-undo entry)))))))))) + ptk/WatchEvent + (watch [_ state stream] + (when-not @error + (let [page-id (:current-page-id state)] + (rx/concat + (when (some :page-id changes) + (rx/of (update-indices page-id))) + + (when (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes changes}] + (rx/of (append-undo entry)))))))))))) (defn generate-operations ([ma mb] (generate-operations ma mb false)) @@ -157,8 +167,8 @@ (defn get-frame-at-point [objects point] - (let [frames (cph/select-frames objects)] - (d/seek #(geom/has-point? % point) frames))) + (let [frames (cp/select-frames objects)] + (d/seek #(gsh/has-point? % point) frames))) (defn- extract-numeric-suffix @@ -171,8 +181,6 @@ [objects] (into #{} (map :name) (vals objects))) -(s/def ::set-of-string - (s/every string? :kind set?)) (defn generate-unique-name "A unique name generator" @@ -186,6 +194,29 @@ (recur (inc counter)) candidate))))) +;; --- Shape attrs (Layers Sidebar) + +(defn toggle-collapse + [id] + (ptk/reify ::toggle-collapse + ptk/UpdateEvent + (update [_ state] + (update-in state [:workspace-local :expanded id] not)))) + +(defn expand-collapse + [id] + (ptk/reify ::expand-collapse + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :expanded id] true)))) + +(def collapse-all + (ptk/reify ::collapse-all + ptk/UpdateEvent + (update [_ state] + (update state :workspace-local dissoc :expanded)))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Undo / Redo ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -256,7 +287,7 @@ (defonce empty-tx {:undo-changes [] :redo-changes []}) -(def start-undo-transaction +(defn start-undo-transaction [] (ptk/reify ::start-undo-transaction ptk/UpdateEvent (update [_ state] @@ -265,13 +296,13 @@ (cond-> state (nil? current-tx) (assoc-in [:workspace-undo :transaction] empty-tx)))))) -(def discard-undo-transaction +(defn discard-undo-transaction [] (ptk/reify ::discard-undo-transaction ptk/UpdateEvent (update [_ state] (update state :workspace-undo dissoc :transaction)))) -(def commit-undo-transaction +(defn commit-undo-transaction [] (ptk/reify ::commit-undo-transaction ptk/UpdateEvent (update [_ state] @@ -334,7 +365,7 @@ (let [expand-fn (fn [expanded] (merge expanded (->> ids - (map #(cph/get-parents % objects)) + (map #(cp/get-parents % objects)) flatten (filter #(not= % uuid/zero)) (map (fn [id] {id true})) @@ -357,33 +388,33 @@ (ptk/reify ::update-shapes ptk/WatchEvent (watch [_ state stream] - (let [page-id (:current-page-id state) - objects (lookup-page-objects state page-id)] - (loop [ids (seq ids) - rch [] - uch []] - (if (nil? ids) - (rx/of (commit-changes - (cond-> rch reg-objects? (conj {:type :reg-objects :page-id page-id :shapes (vec ids)})) - (cond-> uch reg-objects? (conj {:type :reg-objects :page-id page-id :shapes (vec ids)})) - {:commit-local? true})) + (let [page-id (:current-page-id state) + objects (lookup-page-objects state page-id)] + (loop [ids (seq ids) + rch [] + uch []] + (if (nil? ids) + (rx/of (commit-changes + (cond-> rch reg-objects? (conj {:type :reg-objects :page-id page-id :shapes (vec ids)})) + (cond-> uch reg-objects? (conj {:type :reg-objects :page-id page-id :shapes (vec ids)})) + {:commit-local? true})) - (let [id (first ids) - obj1 (get objects id) - obj2 (f obj1) - rch-operations (generate-operations obj1 obj2) - uch-operations (generate-operations obj2 obj1 true) - rchg {:type :mod-obj - :page-id page-id - :operations rch-operations - :id id} - uchg {:type :mod-obj - :page-id page-id - :operations uch-operations - :id id}] - (recur (next ids) - (if (empty? rch-operations) rch (conj rch rchg)) - (if (empty? uch-operations) uch (conj uch uchg))))))))))) + (let [id (first ids) + obj1 (get objects id) + obj2 (f obj1) + rch-operations (generate-operations obj1 obj2) + uch-operations (generate-operations obj2 obj1 true) + rchg {:type :mod-obj + :page-id page-id + :operations rch-operations + :id id} + uchg {:type :mod-obj + :page-id page-id + :operations uch-operations + :id id}] + (recur (next ids) + (if (empty? rch-operations) rch (conj rch rchg)) + (if (empty? uch-operations) uch (conj uch uchg))))))))))) (defn update-shapes-recursive @@ -391,7 +422,7 @@ (us/assert ::coll-of-uuid ids) (us/assert fn? f) (letfn [(impl-get-children [objects id] - (cons id (cph/get-children id objects))) + (cons id (cp/get-children id objects))) (impl-gen-changes [objects page-id ids] (loop [sids (seq ids) @@ -434,3 +465,99 @@ [rchanges uchanges] (impl-gen-changes objects page-id (seq ids))] (rx/of (commit-changes rchanges uchanges {:commit-local? true}))))))) + +(defn select-shapes + [ids] + (us/verify ::ordered-set-of-uuid ids) + (ptk/reify ::select-shapes + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :selected] ids)) + + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (lookup-page-objects state page-id)] + (rx/of (expand-all-parents ids objects)))))) + +;; --- Start shape "edition mode" + +(declare clear-edition-mode) + +(defn start-edition-mode + [id] + (us/assert ::us/uuid id) + (ptk/reify ::start-edition-mode + ptk/UpdateEvent + (update [_ state] + (let [page-id (:current-page-id state) + objects (get-in state [:workspace-data :pages-index page-id :objects])] + ;; Can only edit objects that exist + (if (contains? objects id) + (-> state + (assoc-in [:workspace-local :selected] #{id}) + (assoc-in [:workspace-local :edition] id)) + state))) + + ptk/WatchEvent + (watch [_ state stream] + (->> stream + (rx/filter interrupt?) + (rx/take 1) + (rx/map (constantly clear-edition-mode)))))) + +(def clear-edition-mode + (ptk/reify ::clear-edition-mode + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (-> state + (update-in [:workspace-local :hover] disj id) + (update :workspace-local dissoc :edition)))))) + + +(defn add-shape + [attrs] + (us/verify ::shape-attrs attrs) + (ptk/reify ::add-shape + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (lookup-page-objects state page-id) + + id (or (:id attrs) (uuid/next)) + shape (gpr/setup-proportions attrs) + + unames (retrieve-used-names objects) + name (generate-unique-name unames (:name shape)) + + + + frame-id (if (= :frame (:type attrs)) + uuid/zero + (or (:frame-id attrs) + (cp/frame-id-by-position objects attrs))) + + shape (merge + (if (= :frame (:type shape)) + cp/default-frame-attrs + cp/default-shape-attrs) + (assoc shape + :id id + :name name)) + + rchange {:type :add-obj + :id id + :page-id page-id + :frame-id frame-id + :obj shape} + uchange {:type :del-obj + :page-id page-id + :id id}] + + (rx/concat + (rx/of (commit-changes [rchange] [uchange] {:commit-local? true}) + (select-shapes (d/ordered-set id))) + (when (= :text (:type attrs)) + (->> (rx/of (start-edition-mode id)) + (rx/observe-on :async)))))))) diff --git a/frontend/src/app/main/data/workspace/drawing.cljs b/frontend/src/app/main/data/workspace/drawing.cljs index bfe114418..ece9fffba 100644 --- a/frontend/src/app/main/data/workspace/drawing.cljs +++ b/frontend/src/app/main/data/workspace/drawing.cljs @@ -12,24 +12,46 @@ (:require [beicon.core :as rx] [potok.core :as ptk] - [app.common.geom.point :as gpt] - [app.common.geom.shapes :as geom] + [app.common.spec :as us] [app.common.pages :as cp] [app.common.uuid :as uuid] - [app.common.pages-helpers :as cph] - [app.common.uuid :as uuid] - [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] - [app.main.snap :as snap] - [app.main.streams :as ms] - [app.util.geom.path :as path])) + [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.drawing.common :as common] + [app.main.data.workspace.drawing.path :as path] + [app.main.data.workspace.drawing.curve :as curve] + [app.main.data.workspace.drawing.box :as box])) +(declare start-drawing) (declare handle-drawing) -(declare handle-drawing-generic) -(declare handle-drawing-path) -(declare handle-drawing-curve) -(declare handle-finish-drawing) -(declare conditional-align) + +;; --- Select for Drawing + +(defn select-for-drawing + ([tool] (select-for-drawing tool nil)) + ([tool data] + (ptk/reify ::select-for-drawing + ptk/UpdateEvent + (update [_ state] + (update state :workspace-drawing assoc :tool tool :object data)) + + ptk/WatchEvent + (watch [_ state stream] + (let [stoper (rx/filter (ptk/type? ::clear-drawing) stream)] + (rx/merge + (when (= tool :path) + (rx/of (start-drawing :path))) + + ;; NOTE: comments are a special case and they manage they + ;; own interrupt cycle.q + (when (and (not= tool :comments) + (not= tool :path)) + (->> stream + (rx/filter dwc/interrupt?) + (rx/take 1) + (rx/map (constantly common/clear-drawing)) + (rx/take-until stoper))))))))) + ;; NOTE/TODO: when an exception is raised in some point of drawing the ;; draw lock is not released so the user need to refresh in order to @@ -38,20 +60,22 @@ (defn start-drawing [type] {:pre [(keyword? type)]} - (let [id (uuid/next)] + (let [lock-id (uuid/next)] (ptk/reify ::start-drawing ptk/UpdateEvent (update [_ state] - (update-in state [:workspace-drawing :lock] #(if (nil? %) id %))) + (update-in state [:workspace-drawing :lock] #(if (nil? %) lock-id %))) ptk/WatchEvent (watch [_ state stream] (let [lock (get-in state [:workspace-drawing :lock])] - (when (= lock id) - (rx/merge (->> (rx/filter #(= % handle-finish-drawing) stream) - (rx/take 1) - (rx/map (fn [_] #(update % :workspace-drawing dissoc :lock)))) - (rx/of (handle-drawing type))))))))) + (when (= lock lock-id) + (rx/merge + (rx/of (handle-drawing type)) + (->> stream + (rx/filter (ptk/type? ::common/handle-finish-drawing) ) + (rx/first) + (rx/map #(fn [state] (update state :workspace-drawing dissoc :lock))))))))))) (defn handle-drawing [type] @@ -63,248 +87,15 @@ ptk/WatchEvent (watch [_ state stream] - (case type - :path (rx/of handle-drawing-path) - :curve (rx/of handle-drawing-curve) - (rx/of handle-drawing-generic))))) + (rx/of (case type + :path + (path/handle-new-shape) -(def handle-drawing-generic - (letfn [(resize-shape [{:keys [x y width height] :as shape} point lock? point-snap] - (let [;; The new shape behaves like a resize on the bottom-right corner - initial (gpt/point (+ x width) (+ y height)) - shapev (gpt/point width height) - deltav (gpt/to-vec initial point-snap) - scalev (gpt/divide (gpt/add shapev deltav) shapev) - scalev (if lock? - (let [v (max (:x scalev) (:y scalev))] - (gpt/point v v)) - scalev)] - (-> shape - (assoc ::click-draw? false) - (assoc-in [:modifiers :resize-vector] scalev) - (assoc-in [:modifiers :resize-origin] (gpt/point x y)) - (assoc-in [:modifiers :resize-rotation] 0)))) + :curve + (curve/handle-drawing-curve) - (update-drawing [state point lock? point-snap] - (update-in state [:workspace-drawing :object] resize-shape point lock? point-snap))] - - (ptk/reify ::handle-drawing-generic - ptk/WatchEvent - (watch [_ state stream] - (let [{:keys [flags]} (:workspace-local state) - - stoper? #(or (ms/mouse-up? %) (= % :interrupt)) - stoper (rx/filter stoper? stream) - initial @ms/mouse-position + ;; default + (box/handle-drawing-box)))))) - page-id (:current-page-id state) - objects (dwc/lookup-page-objects state page-id) - layout (get state :workspace-layout) - - frames (cph/select-frames objects) - fid (or (->> frames - (filter #(geom/has-point? % initial)) - first - :id) - uuid/zero) - - shape (-> state - (get-in [:workspace-drawing :object]) - (geom/setup {:x (:x initial) :y (:y initial) :width 1 :height 1}) - (assoc :frame-id fid) - (assoc ::initialized? true) - (assoc ::click-draw? true))] - (rx/concat - ;; Add shape to drawing state - (rx/of #(assoc-in state [:workspace-drawing :object] shape)) - - ;; Initial SNAP - (->> (snap/closest-snap-point page-id [shape] layout initial) - (rx/map (fn [{:keys [x y]}] - #(update-in % [:workspace-drawing :object] assoc :x x :y y)))) - - (->> ms/mouse-position - (rx/filter #(> (gpt/distance % initial) 2)) - (rx/with-latest vector ms/mouse-position-ctrl) - (rx/switch-map - (fn [[point :as current]] - (->> (snap/closest-snap-point page-id [shape] layout point) - (rx/map #(conj current %))))) - (rx/map - (fn [[pt ctrl? point-snap]] - #(update-drawing % pt ctrl? point-snap))) - - (rx/take-until stoper)) - (rx/of handle-finish-drawing))))))) - -(def handle-drawing-path - (letfn [(stoper-event? [{:keys [type shift] :as event}] - (or (= event :path/end-path-drawing) - (= event :interrupt) - (and (ms/mouse-event? event) - (or (= type :double-click) - (= type :context-menu))) - (and (ms/keyboard-event? event) - (= type :down) - (= 13 (:key event))))) - - (initialize-drawing [state point] - (-> state - (assoc-in [:workspace-drawing :object :segments] [point point]) - (assoc-in [:workspace-drawing :object ::initialized?] true))) - - (insert-point-segment [state point] - (-> state - (update-in [:workspace-drawing :object :segments] (fnil conj []) point))) - - (update-point-segment [state index point] - (let [segments (count (get-in state [:workspace-drawing :object :segments])) - exists? (< -1 index segments)] - (cond-> state - exists? (assoc-in [:workspace-drawing :object :segments index] point)))) - - (finish-drawing-path [state] - (update-in - state [:workspace-drawing :object] - (fn [shape] (-> shape - (update :segments #(vec (butlast %))) - (geom/update-path-selrect)))))] - - (ptk/reify ::handle-drawing-path - ptk/WatchEvent - (watch [_ state stream] - (let [{:keys [flags]} (:workspace-local state) - - last-point (volatile! @ms/mouse-position) - - stoper (->> (rx/filter stoper-event? stream) - (rx/share)) - - mouse (rx/sample 10 ms/mouse-position) - - points (->> stream - (rx/filter ms/mouse-click?) - (rx/filter #(false? (:shift %))) - (rx/with-latest vector mouse) - (rx/map second)) - - counter (rx/merge (rx/scan #(inc %) 1 points) (rx/of 1)) - - stream' (->> mouse - (rx/with-latest vector ms/mouse-position-ctrl) - (rx/with-latest vector counter) - (rx/map flatten)) - - imm-transform #(vector (- % 7) (+ % 7) %) - immanted-zones (vec (concat - (map imm-transform (range 0 181 15)) - (map (comp imm-transform -) (range 0 181 15)))) - - align-position (fn [angle pos] - (reduce (fn [pos [a1 a2 v]] - (if (< a1 angle a2) - (reduced (gpt/update-angle pos v)) - pos)) - pos - immanted-zones))] - - (rx/merge - (rx/of #(initialize-drawing % @last-point)) - - (->> points - (rx/take-until stoper) - (rx/map (fn [pt] #(insert-point-segment % pt)))) - - (rx/concat - (->> stream' - (rx/take-until stoper) - (rx/map (fn [[point ctrl? index :as xxx]] - (let [point (if ctrl? - (as-> point $ - (gpt/subtract $ @last-point) - (align-position (gpt/angle $) $) - (gpt/add $ @last-point)) - point)] - #(update-point-segment % index point))))) - (rx/of finish-drawing-path - handle-finish-drawing)))))))) - -(def simplify-tolerance 0.3) - -(def handle-drawing-curve - (letfn [(stoper-event? [{:keys [type shift] :as event}] - (ms/mouse-event? event) (= type :up)) - - (initialize-drawing [state] - (assoc-in state [:workspace-drawing :object ::initialized?] true)) - - (insert-point-segment [state point] - (update-in state [:workspace-drawing :object :segments] (fnil conj []) point)) - - (finish-drawing-curve [state] - (update-in - state [:workspace-drawing :object] - (fn [shape] - (-> shape - (update :segments #(path/simplify % simplify-tolerance)) - (geom/update-path-selrect)))))] - - (ptk/reify ::handle-drawing-curve - ptk/WatchEvent - (watch [_ state stream] - (let [{:keys [flags]} (:workspace-local state) - stoper (rx/filter stoper-event? stream) - mouse (rx/sample 10 ms/mouse-position)] - (rx/concat - (rx/of initialize-drawing) - (->> mouse - (rx/map (fn [pt] #(insert-point-segment % pt))) - (rx/take-until stoper)) - (rx/of finish-drawing-curve - handle-finish-drawing))))))) - -(def handle-finish-drawing - (ptk/reify ::handle-finish-drawing - ptk/WatchEvent - (watch [_ state stream] - (let [shape (get-in state [:workspace-drawing :object])] - (rx/concat - (rx/of dw/clear-drawing) - (when (::initialized? shape) - (let [shape-click-width (case (:type shape) - :text 3 - 20) - shape-click-height (case (:type shape) - :text 16 - 20) - shape (if (::click-draw? shape) - (-> shape - (assoc-in [:modifiers :resize-vector] - (gpt/point shape-click-width shape-click-height)) - (assoc-in [:modifiers :resize-origin] - (gpt/point (:x shape) (:y shape)))) - shape) - - shape (cond-> shape - (= (:type shape) :text) (assoc :grow-type - (if (::click-draw? shape) :auto-width :fixed))) - - shape (-> shape - geom/transform-shape - (dissoc ::initialized? ::click-draw?))] - ;; Add & select the created shape to the workspace - (rx/concat - (if (= :text (:type shape)) - (rx/of dwc/start-undo-transaction) - (rx/empty)) - - (rx/of (dw/deselect-all) - (dw/add-shape shape)))))))))) - -(def close-drawing-path - (ptk/reify ::close-drawing-path - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:workspace-drawing :object :close?] true)))) diff --git a/frontend/src/app/main/data/workspace/drawing/box.cljs b/frontend/src/app/main/data/workspace/drawing/box.cljs new file mode 100644 index 000000000..dc6091372 --- /dev/null +++ b/frontend/src/app/main/data/workspace/drawing/box.cljs @@ -0,0 +1,98 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.data.workspace.drawing.box + (:require + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.uuid :as uuid] + [app.common.pages :as cp] + [app.main.data.workspace.common :as dwc] + [app.main.snap :as snap] + [app.main.streams :as ms] + [app.main.data.workspace.drawing.common :as common] + [app.common.math :as mth])) + +(defn resize-shape [{:keys [x y width height transform transform-inverse] :as shape} point lock?] + (let [;; The new shape behaves like a resize on the bottom-right corner + initial (gpt/point (+ x width) (+ y height)) + shapev (gpt/point width height) + deltav (gpt/to-vec initial point) + scalev (gpt/divide (gpt/add shapev deltav) shapev) + scalev (if lock? + (let [v (max (:x scalev) (:y scalev))] + (gpt/point v v)) + scalev)] + (-> shape + (assoc :click-draw? false) + (assoc-in [:modifiers :resize-vector] scalev) + (assoc-in [:modifiers :resize-origin] (gpt/point x y)) + (assoc-in [:modifiers :resize-rotation] 0)))) + +(defn update-drawing [state point lock?] + (update-in state [:workspace-drawing :object] resize-shape point lock?)) + +(defn move-drawing + [{:keys [x y]}] + (fn [state] + (let [x (mth/precision x 0) + y (mth/precision y 0)] + (update-in state [:workspace-drawing :object] gsh/absolute-move (gpt/point x y))))) + +(defn handle-drawing-box [] + (ptk/reify ::handle-drawing-box + ptk/WatchEvent + (watch [_ state stream] + (let [{:keys [flags]} (:workspace-local state) + + stoper? #(or (ms/mouse-up? %) (= % :interrupt)) + stoper (rx/filter stoper? stream) + initial @ms/mouse-position + + + page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + layout (get state :workspace-layout) + + frames (cp/select-frames objects) + fid (or (->> frames + (filter #(gsh/has-point? % initial)) + first + :id) + uuid/zero) + + shape (-> state + (get-in [:workspace-drawing :object]) + (gsh/setup {:x (:x initial) :y (:y initial) :width 1 :height 1}) + (assoc :frame-id fid) + (assoc :initialized? true) + (assoc :click-draw? true))] + (rx/concat + ;; Add shape to drawing state + (rx/of #(assoc-in state [:workspace-drawing :object] shape)) + + ;; Initial SNAP + (->> (snap/closest-snap-point page-id [shape] layout initial) + (rx/map move-drawing)) + + (->> ms/mouse-position + (rx/filter #(> (gpt/distance % initial) 2)) + (rx/with-latest vector ms/mouse-position-ctrl) + (rx/switch-map + (fn [[point :as current]] + (->> (snap/closest-snap-point page-id [shape] layout point) + (rx/map #(conj current %))))) + (rx/map + (fn [[_ ctrl? point]] + #(update-drawing % point ctrl?))) + + (rx/take-until stoper)) + (rx/of common/handle-finish-drawing)))))) diff --git a/frontend/src/app/main/data/workspace/drawing/common.cljs b/frontend/src/app/main/data/workspace/drawing/common.cljs new file mode 100644 index 000000000..c5a19c32c --- /dev/null +++ b/frontend/src/app/main/data/workspace/drawing/common.cljs @@ -0,0 +1,62 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.data.workspace.drawing.common + (:require + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.selection :as dws] + [app.main.streams :as ms])) + +(def clear-drawing + (ptk/reify ::clear-drawing + ptk/UpdateEvent + (update [_ state] + (update state :workspace-drawing dissoc :tool :object)))) + +(def handle-finish-drawing + (ptk/reify ::handle-finish-drawing + ptk/WatchEvent + (watch [_ state stream] + (let [shape (get-in state [:workspace-drawing :object])] + (rx/concat + (rx/of clear-drawing) + (when (:initialized? shape) + (let [shape-click-width (case (:type shape) + :text 3 + 20) + shape-click-height (case (:type shape) + :text 16 + 20) + shape (if (:click-draw? shape) + (-> shape + (assoc-in [:modifiers :resize-vector] + (gpt/point shape-click-width shape-click-height)) + (assoc-in [:modifiers :resize-origin] + (gpt/point (:x shape) (:y shape)))) + shape) + + shape (cond-> shape + (= (:type shape) :text) (assoc :grow-type + (if (:click-draw? shape) :auto-width :fixed))) + + shape (-> shape + (gsh/transform-shape) + (dissoc :initialized? :click-draw?))] + ;; Add & select the created shape to the workspace + (rx/concat + (if (= :text (:type shape)) + (rx/of (dwc/start-undo-transaction)) + (rx/empty)) + + (rx/of (dws/deselect-all) + (dwc/add-shape shape)))))))))) diff --git a/frontend/src/app/main/data/workspace/drawing/curve.cljs b/frontend/src/app/main/data/workspace/drawing/curve.cljs new file mode 100644 index 000000000..6edb5390d --- /dev/null +++ b/frontend/src/app/main/data/workspace/drawing/curve.cljs @@ -0,0 +1,78 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.data.workspace.drawing.curve + (:require + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.path :as gsp] + [app.main.streams :as ms] + [app.util.geom.path :as path] + [app.main.data.workspace.drawing.common :as common])) + +(def simplify-tolerance 0.3) + +(defn stoper-event? [{:keys [type shift] :as event}] + (ms/mouse-event? event) (= type :up)) + +(defn initialize-drawing [state] + (assoc-in state [:workspace-drawing :object :initialized?] true)) + +(defn insert-point-segment [state point] + + (let [segments (-> state + (get-in [:workspace-drawing :object :segments]) + (or []) + (conj point)) + content (gsp/segments->content segments) + selrect (gsh/content->selrect content) + points (gsh/rect->points selrect)] + (-> state + (update-in [:workspace-drawing :object] assoc + :segments segments + :content content + :selrect selrect + :points points)))) + + + +(defn curve-to-path [{:keys [segments] :as shape}] + (let [content (gsp/segments->content segments) + selrect (gsh/content->selrect content) + points (gsh/rect->points selrect)] + (-> shape + (dissoc :segments) + (assoc :content content) + (assoc :selrect selrect) + (assoc :points points)))) + +(defn finish-drawing-curve [state] + (update-in + state [:workspace-drawing :object] + (fn [shape] + (-> shape + (update :segments #(path/simplify % simplify-tolerance)) + (curve-to-path))))) + +(defn handle-drawing-curve [] + (ptk/reify ::handle-drawing-curve + ptk/WatchEvent + (watch [_ state stream] + (let [{:keys [flags]} (:workspace-local state) + stoper (rx/filter stoper-event? stream) + mouse (rx/sample 10 ms/mouse-position)] + (rx/concat + (rx/of initialize-drawing) + (->> mouse + (rx/map (fn [pt] #(insert-point-segment % pt))) + (rx/take-until stoper)) + (rx/of finish-drawing-curve + common/handle-finish-drawing)))))) diff --git a/frontend/src/app/main/data/workspace/drawing/path.cljs b/frontend/src/app/main/data/workspace/drawing/path.cljs new file mode 100644 index 000000000..4599e903b --- /dev/null +++ b/frontend/src/app/main/data/workspace/drawing/path.cljs @@ -0,0 +1,817 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.data.workspace.drawing.path + (:require + [clojure.spec.alpha :as s] + [app.common.spec :as us] + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.math :as mth] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.util.data :as ud] + [app.common.data :as cd] + [app.util.geom.path :as ugp] + [app.main.streams :as ms] + [app.main.store :as st] + [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.drawing.common :as common] + [app.common.geom.shapes.path :as gsp])) + +;; SCHEMAS + +(s/def ::command #{:move-to + :line-to + :line-to-horizontal + :line-to-vertical + :curve-to + :smooth-curve-to + :quadratic-bezier-curve-to + :smooth-quadratic-bezier-curve-to + :elliptical-arc + :close-path}) + +(s/def :paths.params/x number?) +(s/def :paths.params/y number?) +(s/def :paths.params/c1x number?) +(s/def :paths.params/c1y number?) +(s/def :paths.params/c2x number?) +(s/def :paths.params/c2y number?) + +(s/def ::relative? boolean?) + +(s/def ::params + (s/keys :req-un [:path.params/x + :path.params/y] + :opt-un [:path.params/c1x + :path.params/c1y + :path.params/c2x + :path.params/c2y])) + +(s/def ::content-entry + (s/keys :req-un [::command] + :req-opt [::params + ::relative?])) +(s/def ::content + (s/coll-of ::content-entry :kind vector?)) + + +;; CONSTANTS +(defonce enter-keycode 13) +(defonce drag-threshold 5) + +;; PRIVATE METHODS + +(defn get-path-id + "Retrieves the currently editing path id" + [state] + (or (get-in state [:workspace-local :edition]) + (get-in state [:workspace-drawing :object :id]))) + +(defn get-path + "Retrieves the location of the path object and additionaly can pass + the arguments. This location can be used in get-in, assoc-in... functions" + [state & path] + (let [edit-id (get-in state [:workspace-local :edition]) + page-id (:current-page-id state)] + (cd/concat + (if edit-id + [:workspace-data :pages-index page-id :objects edit-id] + [:workspace-drawing :object]) + path))) + +(defn update-selrect + "Updates the selrect and points for a path" + [shape] + (let [selrect (gsh/content->selrect (:content shape)) + points (gsh/rect->points selrect)] + (assoc shape :points points :selrect selrect))) + +(defn closest-angle [angle] + (cond + (or (> angle 337.5) (<= angle 22.5)) 0 + (and (> angle 22.5) (<= angle 67.5)) 45 + (and (> angle 67.5) (<= angle 112.5)) 90 + (and (> angle 112.5) (<= angle 157.5)) 135 + (and (> angle 157.5) (<= angle 202.5)) 180 + (and (> angle 202.5) (<= angle 247.5)) 225 + (and (> angle 247.5) (<= angle 292.5)) 270 + (and (> angle 292.5) (<= angle 337.5)) 315)) + +(defn position-fixed-angle [point from-point] + (if (and from-point point) + (let [angle (mod (+ 360 (- (gpt/angle point from-point))) 360) + to-angle (closest-angle angle) + distance (gpt/distance point from-point)] + (gpt/angle->point from-point (mth/radians to-angle) distance)) + point)) + +(defn next-node + "Calculates the next-node to be inserted." + [shape position prev-point prev-handler] + (let [last-command (-> shape :content last :command) + add-line? (and prev-point (not prev-handler) (not= last-command :close-path)) + add-curve? (and prev-point prev-handler (not= last-command :close-path))] + (cond + add-line? {:command :line-to + :params position} + add-curve? {:command :curve-to + :params (ugp/make-curve-params position prev-handler)} + :else {:command :move-to + :params position}))) + +(defn append-node + "Creates a new node in the path. Usualy used when drawing." + [shape position prev-point prev-handler] + (let [command (next-node shape position prev-point prev-handler)] + (-> shape + (update :content (fnil conj []) command) + (update-selrect)))) + +(defn move-handler-modifiers [content index prefix match-opposite? dx dy] + (let [[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y]) + [ocx ocy] (if (= prefix :c1) [:c2x :c2y] [:c1x :c1y]) + opposite-index (ugp/opposite-index content index prefix)] + + (cond-> {} + :always + (update index assoc cx dx cy dy) + + (and match-opposite? opposite-index) + (update opposite-index assoc ocx (- dx) ocy (- dy))))) + +(defn end-path-event? [{:keys [type shift] :as event}] + (or (= (ptk/type event) ::finish-path) + (= (ptk/type event) :esc-pressed) + (= event :interrupt) ;; ESC + (and (ms/mouse-double-click? event)) + (and (ms/keyboard-event? event) + (= type :down) + ;; TODO: Enter now finish path but can finish drawing/editing as well + (= enter-keycode (:key event))))) + +(defn generate-path-changes [page-id shape-id old-content new-content] + (us/verify ::content old-content) + (us/verify ::content new-content) + (let [old-selrect (gsh/content->selrect old-content) + old-points (gsh/rect->points old-selrect) + new-selrect (gsh/content->selrect new-content) + new-points (gsh/rect->points new-selrect) + + rch [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr :content :val new-content} + {:type :set :attr :selrect :val new-selrect} + {:type :set :attr :points :val new-points}]} + {:type :reg-objects + :page-id page-id + :shapes [shape-id]}] + + uch [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr :content :val old-content} + {:type :set :attr :selrect :val old-selrect} + {:type :set :attr :points :val old-points}]} + {:type :reg-objects + :page-id page-id + :shapes [shape-id]}]] + [rch uch])) + +(defn clean-edit-state + [state] + (dissoc state :last-point :prev-handler :drag-handler :preview)) + +(defn dragging? [start zoom] + (fn [current] + (>= (gpt/distance start current) (/ drag-threshold zoom)))) + +(defn drag-stream [to-stream] + (let [start @ms/mouse-position + zoom (get-in @st/state [:workspace-local :zoom] 1) + mouse-up (->> st/stream (rx/filter #(ms/mouse-up? %)))] + (->> ms/mouse-position + (rx/take-until mouse-up) + (rx/filter (dragging? start zoom)) + (rx/take 1) + (rx/merge-map (fn [] to-stream))))) + +(defn position-stream [] + (->> ms/mouse-position + (rx/with-latest merge (->> ms/mouse-position-shift (rx/map #(hash-map :shift? %)))) + (rx/with-latest merge (->> ms/mouse-position-alt (rx/map #(hash-map :alt? %)))))) + +;; EVENTS + +(defn init-path [] + (ptk/reify ::init-path)) + +(defn finish-path [source] + (ptk/reify ::finish-path + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (-> state + (update-in [:workspace-local :edit-path id] clean-edit-state)))))) + +(defn preview-next-point [{:keys [x y shift?]}] + (ptk/reify ::preview-next-point + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state) + fix-angle? shift? + last-point (get-in state [:workspace-local :edit-path id :last-point]) + position (cond-> (gpt/point x y) + fix-angle? (position-fixed-angle last-point)) + shape (get-in state (get-path state)) + {:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id]) + command (next-node shape position last-point prev-handler)] + (assoc-in state [:workspace-local :edit-path id :preview] command))))) + +(defn add-node [{:keys [x y shift?]}] + (ptk/reify ::add-node + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state) + fix-angle? shift? + {:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id]) + position (cond-> (gpt/point x y) + fix-angle? (position-fixed-angle last-point)) + ] + (if-not (= last-point position) + (-> state + (assoc-in [:workspace-local :edit-path id :last-point] position) + (update-in [:workspace-local :edit-path id] dissoc :prev-handler) + (update-in [:workspace-local :edit-path id] dissoc :preview) + (update-in (get-path state) append-node position last-point prev-handler)) + state))))) + +(defn start-drag-handler [] + (ptk/reify ::start-drag-handler + ptk/UpdateEvent + (update [_ state] + (let [content (get-in state (get-path state :content)) + index (dec (count content)) + command (get-in state (get-path state :content index :command)) + + make-curve + (fn [command] + (let [params (ugp/make-curve-params + (get-in content [index :params]) + (get-in content [(dec index) :params]))] + (-> command + (assoc :command :curve-to :params params))))] + + (cond-> state + (= command :line-to) + (update-in (get-path state :content index) make-curve)))))) + +(defn drag-handler [{:keys [x y alt? shift?]}] + (ptk/reify ::drag-handler + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state) + shape (get-in state (get-path state)) + content (:content shape) + index (dec (count content)) + node-position (ugp/command->point (nth content index)) + handler-position (cond-> (gpt/point x y) + shift? (position-fixed-angle node-position)) + {dx :x dy :y} (gpt/subtract handler-position node-position) + match-opposite? (not alt?) + modifiers (move-handler-modifiers content (inc index) :c1 match-opposite? dx dy)] + (-> state + (update-in [:workspace-local :edit-path id :content-modifiers] merge modifiers) + (assoc-in [:workspace-local :edit-path id :prev-handler] handler-position) + (assoc-in [:workspace-local :edit-path id :drag-handler] handler-position)))))) + +(defn finish-drag [] + (ptk/reify ::finish-drag + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state) + modifiers (get-in state [:workspace-local :edit-path id :content-modifiers]) + handler (get-in state [:workspace-local :edit-path id :drag-handler])] + (-> state + (update-in (get-path state :content) ugp/apply-content-modifiers modifiers) + (update-in [:workspace-local :edit-path id] dissoc :drag-handler) + (update-in [:workspace-local :edit-path id] dissoc :content-modifiers) + (assoc-in [:workspace-local :edit-path id :prev-handler] handler) + (update-in (get-path state) update-selrect)))) + + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + handler (get-in state [:workspace-local :edit-path id :prev-handler])] + ;; Update the preview because can be outdated after the dragging + (rx/of (preview-next-point handler)))))) + +(declare close-path-drag-end) + +(defn close-path-drag-start [position] + (ptk/reify ::close-path-drag-start + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + zoom (get-in state [:workspace-local :zoom]) + start-position @ms/mouse-position + + stop-stream + (->> stream (rx/filter #(or (end-path-event? %) + (ms/mouse-up? %)))) + + drag-events-stream + (->> (position-stream) + (rx/take-until stop-stream) + (rx/map #(drag-handler %)))] + + (rx/concat + (rx/of (add-node position)) + (drag-stream + (rx/concat + (rx/of (start-drag-handler)) + drag-events-stream + (rx/of (finish-drag)) + (rx/of (close-path-drag-end)))) + (rx/of (finish-path "close-path"))))))) + +(defn close-path-drag-end [] + (ptk/reify ::close-path-drag-end + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (update-in state [:workspace-local :edit-path id] dissoc :prev-handler))))) + +(defn path-pointer-enter [position] + (ptk/reify ::path-pointer-enter + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover-points] (fnil conj #{}) position))))) + +(defn path-pointer-leave [position] + (ptk/reify ::path-pointer-leave + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover-points] disj position))))) + +(defn start-path-from-point [position] + (ptk/reify ::start-path-from-point + ptk/WatchEvent + (watch [_ state stream] + (let [start-point @ms/mouse-position + zoom (get-in state [:workspace-local :zoom]) + mouse-up (->> stream (rx/filter #(or (end-path-event? %) + (ms/mouse-up? %)))) + drag-events (->> ms/mouse-position + (rx/take-until mouse-up) + (rx/map #(drag-handler %)))] + + (rx/concat + (rx/of (add-node position)) + (drag-stream + (rx/concat + (rx/of (start-drag-handler)) + drag-events + (rx/of (finish-drag))))))))) + +(defn make-corner [] + (ptk/reify ::make-corner + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + page-id (:current-page-id state) + shape (get-in state (get-path state)) + selected-points (get-in state [:workspace-local :edit-path id :selected-points] #{}) + new-content (reduce ugp/make-corner-point (:content shape) selected-points) + [rch uch] (generate-path-changes page-id id (:content shape) new-content)] + (rx/of (dwc/commit-changes rch uch {:commit-local? true})))))) + +(defn make-curve [] + (ptk/reify ::make-curve + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + page-id (:current-page-id state) + shape (get-in state (get-path state)) + selected-points (get-in state [:workspace-local :edit-path id :selected-points] #{}) + new-content (reduce ugp/make-curve-point (:content shape) selected-points) + [rch uch] (generate-path-changes page-id id (:content shape) new-content)] + (rx/of (dwc/commit-changes rch uch {:commit-local? true})))))) + +(defn path-handler-enter [index prefix] + (ptk/reify ::path-handler-enter + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover-handlers] (fnil conj #{}) [index prefix]))))) + +(defn path-handler-leave [index prefix] + (ptk/reify ::path-handler-leave + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover-handlers] disj [index prefix]))))) + +;; EVENT STREAMS + +(defn make-drag-stream + [stream down-event zoom] + (let [mouse-up (->> stream (rx/filter #(or (end-path-event? %) + (ms/mouse-up? %)))) + drag-events (->> (position-stream) + (rx/take-until mouse-up) + (rx/map #(drag-handler %)))] + + (rx/concat + (rx/of (add-node down-event)) + (drag-stream + (rx/concat + (rx/of (start-drag-handler)) + drag-events + (rx/of (finish-drag))))))) + +(defn make-node-events-stream + [stream] + (->> stream + (rx/filter (ptk/type? ::close-path-drag-start)) + (rx/take 1) + (rx/merge-map #(rx/empty)))) + +;; MAIN ENTRIES + +(defn handle-drawing-path + [id] + (ptk/reify ::handle-drawing-path + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (-> state + (assoc-in [:workspace-local :edit-path id :edit-mode] :draw)))) + + ptk/WatchEvent + (watch [_ state stream] + (let [zoom (get-in state [:workspace-local :zoom]) + mouse-down (->> stream (rx/filter ms/mouse-down?)) + end-path-events (->> stream (rx/filter end-path-event?)) + + ;; Mouse move preview + mousemove-events + (->> (position-stream) + (rx/take-until end-path-events) + (rx/map #(preview-next-point %))) + + ;; From mouse down we can have: click, drag and double click + mousedown-events + (->> mouse-down + (rx/take-until end-path-events) + (rx/with-latest merge (position-stream)) + + ;; We change to the stream that emits the first event + (rx/switch-map + #(rx/race (make-node-events-stream stream) + (make-drag-stream stream % zoom))))] + + (rx/concat + (rx/of (init-path)) + (rx/merge mousemove-events + mousedown-events) + (rx/of (finish-path "after-events"))))))) + +(defn stop-path-edit [] + (ptk/reify ::stop-path-edit + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (update state :workspace-local dissoc :edit-path id))))) + +(defn start-path-edit + [id] + (ptk/reify ::start-path-edit + ptk/UpdateEvent + (update [_ state] + ;; Only edit if the object has been created + (if-let [id (get-in state [:workspace-local :edition])] + (assoc-in state [:workspace-local :edit-path id] {:edit-mode :move + :selected #{} + :snap-toggled true}) + state)) + + ptk/WatchEvent + (watch [_ state stream] + (->> stream + (rx/filter #(= % :interrupt)) + (rx/take 1) + (rx/map #(stop-path-edit)))))) + +(defn modify-point [index prefix dx dy] + (ptk/reify ::modify-point + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition]) + [cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])] + (-> state + (update-in [:workspace-local :edit-path id :content-modifiers (inc index)] assoc + :c1x dx :c1y dy) + (update-in [:workspace-local :edit-path id :content-modifiers index] assoc + :x dx :y dy :c2x dx :c2y dy) + ))))) + +(defn modify-handler [id index prefix dx dy match-opposite?] + (ptk/reify ::modify-point + ptk/UpdateEvent + (update [_ state] + (let [content (get-in state (get-path state :content)) + [cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y]) + [ocx ocy] (if (= prefix :c1) [:c2x :c2y] [:c1x :c1y]) + opposite-index (ugp/opposite-index content index prefix)] + (cond-> state + :always + (update-in [:workspace-local :edit-path id :content-modifiers index] assoc + cx dx cy dy) + + (and match-opposite? opposite-index) + (update-in [:workspace-local :edit-path id :content-modifiers opposite-index] assoc + ocx (- dx) ocy (- dy))))))) + +(defn apply-content-modifiers [] + (ptk/reify ::apply-content-modifiers + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + page-id (:current-page-id state) + shape (get-in state (get-path state)) + content-modifiers (get-in state [:workspace-local :edit-path id :content-modifiers]) + new-content (ugp/apply-content-modifiers (:content shape) content-modifiers) + [rch uch] (generate-path-changes page-id (:id shape) (:content shape) new-content)] + + (rx/of (dwc/commit-changes rch uch {:commit-local? true}) + (fn [state] (update-in state [:workspace-local :edit-path id] dissoc :content-modifiers))))))) + +(defn save-path-content [] + (ptk/reify ::save-path-content + ptk/UpdateEvent + (update [_ state] + (let [content (get-in state (get-path state :content)) + content (if (= (-> content last :command) :move-to) + (into [] (take (dec (count content)) content)) + content)] + (assoc-in state (get-path state :content) content))) + + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-in state [:workspace-local :edition]) + shape (get-in state (get-path state)) + page-id (:current-page-id state) + old-content (get-in state [:workspace-local :edit-path id :old-content]) + [rch uch] (generate-path-changes page-id id old-content (:content shape))] + (rx/of (dwc/commit-changes rch uch {:commit-local? true})))))) + +(declare start-draw-mode) +(defn check-changed-content [] + (ptk/reify ::check-changed-content + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state) + content (get-in state (get-path state :content)) + old-content (get-in state [:workspace-local :edit-path id :old-content]) + mode (get-in state [:workspace-local :edit-path id :edit-mode])] + + (cond + (not= content old-content) (rx/of (save-path-content) + (start-draw-mode)) + (= mode :draw) (rx/of :interrupt) + :else (rx/of (finish-path "changed-content"))))))) + +(defn move-path-point [start-point end-point] + (ptk/reify ::move-point + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state) + content (get-in state (get-path state :content)) + + {dx :x dy :y} (gpt/subtract end-point start-point) + + handler-indices (-> (ugp/content->handlers content) + (get start-point)) + + command-for-point (fn [[index command]] + (let [point (ugp/command->point command)] + (= point start-point))) + + point-indices (->> (cd/enumerate content) + (filter command-for-point) + (map first)) + + + point-reducer (fn [modifiers index] + (-> modifiers + (assoc-in [index :x] dx) + (assoc-in [index :y] dy))) + + handler-reducer (fn [modifiers [index prefix]] + (let [cx (ud/prefix-keyword prefix :x) + cy (ud/prefix-keyword prefix :y)] + (-> modifiers + (assoc-in [index cx] dx) + (assoc-in [index cy] dy)))) + + modifiers (as-> (get-in state [:workspace-local :edit-path id :content-modifiers] {}) $ + (reduce point-reducer $ point-indices) + (reduce handler-reducer $ handler-indices))] + + (assoc-in state [:workspace-local :edit-path id :content-modifiers] modifiers))))) + +(defn start-move-path-point + [position] + (ptk/reify ::start-move-path-point + ptk/WatchEvent + (watch [_ state stream] + (let [start-position @ms/mouse-position + stopper (->> stream (rx/filter ms/mouse-up?)) + zoom (get-in state [:workspace-local :zoom])] + + (drag-stream + (rx/concat + (->> ms/mouse-position + (rx/take-until stopper) + (rx/map #(move-path-point position %))) + (rx/of (apply-content-modifiers)))))))) + +(defn start-move-handler + [index prefix] + (ptk/reify ::start-move-handler + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-in state [:workspace-local :edition]) + cx (ud/prefix-keyword prefix :x) + cy (ud/prefix-keyword prefix :y) + start-point @ms/mouse-position + modifiers (get-in state [:workspace-local :edit-path id :content-modifiers]) + start-delta-x (get-in modifiers [index cx] 0) + start-delta-y (get-in modifiers [index cy] 0) + + content (get-in state (get-path state :content)) + opposite-index (ugp/opposite-index content index prefix) + opposite-prefix (if (= prefix :c1) :c2 :c1) + opposite-handler (-> content (get opposite-index) (ugp/get-handler opposite-prefix)) + + point (-> content (get (if (= prefix :c1) (dec index) index)) (ugp/command->point)) + handler (-> content (get index) (ugp/get-handler prefix)) + + current-distance (gpt/distance (ugp/opposite-handler point handler) opposite-handler) + match-opposite? (mth/almost-zero? current-distance)] + + (drag-stream + (rx/concat + (->> (position-stream) + (rx/take-until (->> stream (rx/filter ms/mouse-up?))) + (rx/map + (fn [{:keys [x y alt? shift?]}] + (let [pos (cond-> (gpt/point x y) + shift? (position-fixed-angle point))] + (modify-handler + id + index + prefix + (+ start-delta-x (- (:x pos) (:x start-point))) + (+ start-delta-y (- (:y pos) (:y start-point))) + (and (not alt?) match-opposite?)))))) + (rx/concat (rx/of (apply-content-modifiers))))))))) + +(defn start-draw-mode [] + (ptk/reify ::start-draw-mode + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition]) + page-id (:current-page-id state) + old-content (get-in state [:workspace-data :pages-index page-id :objects id :content])] + (-> state + (assoc-in [:workspace-local :edit-path id :old-content] old-content)))) + + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-in state [:workspace-local :edition]) + edit-mode (get-in state [:workspace-local :edit-path id :edit-mode])] + (if (= :draw edit-mode) + (rx/concat + (rx/of (handle-drawing-path id)) + (->> stream + (rx/filter (ptk/type? ::finish-path)) + (rx/take 1) + (rx/merge-map #(rx/of (check-changed-content))))) + (rx/empty)))))) + +(defn change-edit-mode [mode] + (ptk/reify ::change-edit-mode + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (cond-> state + id (assoc-in [:workspace-local :edit-path id :edit-mode] mode)))) + + ptk/WatchEvent + (watch [_ state stream] + (let [id (get-path-id state)] + (cond + (and id (= :move mode)) (rx/of (finish-path "change-edit-mode")) + (and id (= :draw mode)) (rx/of (start-draw-mode)) + :else (rx/empty)))))) + +(defn select-handler [index type] + (ptk/reify ::select-handler + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (-> state + (update-in [:workspace-local :edit-path id :selected-handlers] (fnil conj #{}) [index type])))))) + +(defn select-node [position] + (ptk/reify ::select-node + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (-> state + (assoc-in [:workspace-local :edit-path id :selected-points] #{position})))))) + +(defn deselect-node [position] + (ptk/reify ::deselect-node + ptk/UpdateEvent + (update [_ state] + (let [id (get-in state [:workspace-local :edition])] + (-> state + (update-in [:workspace-local :edit-path id :selected-points] (fnil disj #{}) position)))))) + +(defn add-to-selection-handler [index type] + (ptk/reify ::add-to-selection-handler + ptk/UpdateEvent + (update [_ state] + state))) + +(defn add-to-selection-node [index] + (ptk/reify ::add-to-selection-node + ptk/UpdateEvent + (update [_ state] + state))) + +(defn remove-from-selection-handler [index] + (ptk/reify ::remove-from-selection-handler + ptk/UpdateEvent + (update [_ state] + state))) + +(defn remove-from-selection-node [index] + (ptk/reify ::remove-from-selection-handler + ptk/UpdateEvent + (update [_ state] + state))) + +(defn deselect-all [] + (ptk/reify ::deselect-all + ptk/UpdateEvent + (update [_ state] + (let [id (get-path-id state)] + (-> state + (assoc-in [:workspace-local :edit-path id :selected-handlers] #{}) + (assoc-in [:workspace-local :edit-path id :selected-points] #{})))))) + +(defn handle-new-shape-result [shape-id] + (ptk/reify ::handle-new-shape-result + ptk/UpdateEvent + (update [_ state] + (let [content (get-in state [:workspace-drawing :object :content] [])] + (us/verify ::content content) + (if (> (count content) 1) + (assoc-in state [:workspace-drawing :object :initialized?] true) + state))) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rx/of common/handle-finish-drawing + (dwc/start-edition-mode shape-id) + (start-path-edit shape-id) + (change-edit-mode :draw)))))) + +(defn handle-new-shape + "Creates a new path shape" + [] + (ptk/reify ::handle-new-shape + ptk/WatchEvent + (watch [_ state stream] + (let [shape-id (get-in state [:workspace-drawing :object :id])] + (rx/concat + (rx/of (handle-drawing-path shape-id)) + (->> stream + (rx/filter (ptk/type? ::finish-path)) + (rx/take 1) + (rx/observe-on :async) + (rx/map #(handle-new-shape-result shape-id)))))))) diff --git a/frontend/src/app/main/data/workspace/groups.cljs b/frontend/src/app/main/data/workspace/groups.cljs new file mode 100644 index 000000000..fe79a6c07 --- /dev/null +++ b/frontend/src/app/main/data/workspace/groups.cljs @@ -0,0 +1,213 @@ +(ns app.main.data.workspace.groups + (:require + [app.common.data :as d] + [app.common.geom.shapes :as gsh] + [app.common.pages :as cp] + [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.selection :as dws] + [beicon.core :as rx] + [potok.core :as ptk])) + +(defn shapes-for-grouping + [objects selected] + (->> selected + (map #(get objects %)) + (filter #(not= :frame (:type %))) + (map #(assoc % ::index (cp/position-on-parent (:id %) objects))) + (sort-by ::index))) + +(defn- make-group + [shapes prefix keep-name] + (let [selrect (gsh/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) + (gsh/setup selrect) + (assoc :shapes (mapv :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 (mapv :id shapes)}] + + uchanges (conj + (mapv (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 (cp/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} + {:type :del-obj + :page-id page-id + :id (:id group)}] + 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])) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GROUPS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def group-selected + (ptk/reify ::group-selected + 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 (shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [[group rchanges uchanges] (prepare-create-group page-id shapes "Group-" false)] + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dwc/select-shapes (d/ordered-set (:id group)))))))))) + +(def ungroup-selected + (ptk/reify ::ungroup-selected + 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]) + group-id (first selected) + group (get objects group-id)] + (when (and (= 1 (count selected)) + (= (:type group) :group)) + (let [[rchanges uchanges] + (prepare-remove-group page-id group objects)] + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))))) + +(def mask-group + (ptk/reify ::mask-group + 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 (shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [;; If the selected shape is a group, we can use it. If not, + ;; create a new group and set it as masked. + [group rchanges uchanges] + (if (and (= (count shapes) 1) + (= (:type (first shapes)) :group)) + [(first shapes) [] []] + (prepare-create-group page-id shapes "Group-" true)) + + rchanges (d/concat rchanges + [{:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val true} + {:type :set + :attr :selrect + :val (-> shapes first :selrect)} + {:type :set + :attr :points + :val (-> shapes first :points)} + {:type :set + :attr :transform + :val (-> shapes first :transform)} + {:type :set + :attr :transform-inverse + :val (-> shapes first :transform-inverse)}]} + {:type :reg-objects + :page-id page-id + :shapes [(:id group)]}]) + + uchanges (conj uchanges + {:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val nil}]} + {:type :reg-objects + :page-id page-id + :shapes [(:id group)]})] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dwc/select-shapes (d/ordered-set (:id group)))))))))) + +(def unmask-group + (ptk/reify ::unmask-group + 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])] + (when (= (count selected) 1) + (let [group (get objects (first selected)) + + rchanges [{:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val nil}]} + {:type :reg-objects + :page-id page-id + :shapes [(:id group)]}] + + uchanges [{:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val (:masked-group? group)}]} + {:type :reg-objects + :page-id page-id + :shapes [(:id group)]}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dwc/select-shapes (d/ordered-set (:id group)))))))))) + + diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 9fb6ef150..855154136 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -12,12 +12,11 @@ [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.messages :as dm] [app.main.data.workspace.common :as dwc] - [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.libraries-helpers :as dwlh] [app.common.pages :as cp] [app.main.repo :as rp] @@ -32,6 +31,7 @@ [cljs.spec.alpha :as s] [potok.core :as ptk])) +;; Change this to :info :debug or :trace to debug this module (log/set-level! :warn) (declare sync-file) @@ -114,6 +114,24 @@ :id id}] (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) +(defn rename-media + [id new-name] + (us/assert ::us/uuid id) + (us/assert ::us/string new-name) + (ptk/reify ::rename-media + ptk/WatchEvent + (watch [_ state stream] + (let [object (get-in state [:workspace-data :media id]) + + rchanges [{:type :mod-media + :object {:id id + :name new-name}}] + + uchanges [{:type :mod-media + :object {:id id + :name (:name object)}}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) (defn delete-media [{:keys [id] :as params}] @@ -181,7 +199,7 @@ (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)] + shapes (dwg/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. @@ -189,7 +207,7 @@ (if (and (= (count shapes) 1) (= (:type (first shapes)) :group)) [(first shapes) [] []] - (dws/prepare-create-group page-id shapes "Component-" true)) + (dwg/prepare-create-group page-id shapes "Component-" true)) [new-shape new-shapes updated-shapes] (dwlh/make-component-shape group objects) @@ -251,7 +269,7 @@ (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set (:id group)))))))))) + (dwc/select-shapes (d/ordered-set (:id group)))))))))) (defn rename-component [id new-name] @@ -284,7 +302,7 @@ (ptk/reify ::duplicate-component ptk/WatchEvent (watch [_ state stream] - (let [component (cph/get-component id + (let [component (cp/get-component id nil (get state :workspace-data) nil) @@ -346,7 +364,7 @@ objects (dwc/lookup-page-objects state page-id) unames (atom (dwc/retrieve-used-names objects)) - frame-id (cph/frame-id-by-position objects (gpt/add orig-pos delta)) + frame-id (cp/frame-id-by-position objects (gpt/add orig-pos delta)) update-new-shape (fn [new-shape original-shape] @@ -386,7 +404,7 @@ (dissoc :component-root?)))) [new-shape new-shapes _] - (cph/clone-object component-shape + (cp/clone-object component-shape nil (get component :objects) update-new-shape) @@ -397,17 +415,19 @@ :page-id page-id :frame-id (:frame-id obj) :parent-id (:parent-id obj) + :ignore-touched true :obj obj}) new-shapes) uchanges (map (fn [obj] {:type :del-obj :id (:id obj) - :page-id page-id}) + :page-id page-id + :ignore-touched true}) new-shapes)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set (:id new-shape)))))))) + (dwc/select-shapes (d/ordered-set (:id new-shape)))))))) (defn detach-component "Remove all references to components in the shape with the given id, @@ -419,7 +439,7 @@ (watch [_ state stream] (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) - shapes (cph/get-object-with-children id objects) + shapes (cp/get-object-with-children id objects) rchanges (map (fn [obj] {:type :mod-obj @@ -493,16 +513,18 @@ (ptk/reify ::reset-component ptk/WatchEvent (watch [_ state stream] - ;; ===== Uncomment this to debug ===== (log/info :msg "RESET-COMPONENT of shape" :id (str id)) - (let [[rchanges uchanges] - (dwlh/generate-sync-shape-and-children-components (get state :current-page-id) - nil - id - (get state :workspace-data) - (get state :workspace-libraries) - true)] - ;; ===== Uncomment this to debug ===== + (let [local-file (get state :workspace-data) + libraries (get state :workspace-libraries) + container (cp/get-container (get state :current-page-id) + :page + local-file) + [rchanges uchanges] + (dwlh/generate-sync-shape-direct container + id + local-file + libraries + true)] (log/debug :msg "RESET-COMPONENT finished" :js/rchanges rchanges) (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) @@ -516,7 +538,6 @@ (ptk/reify ::update-component ptk/WatchEvent (watch [_ state stream] - ;; ===== Uncomment this to debug ===== (log/info :msg "UPDATE-COMPONENT of shape" :id (str id)) (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) @@ -529,7 +550,6 @@ (get state :workspace-data) (get state :workspace-libraries))] - ;; ===== Uncomment this to debug ===== (log/debug :msg "UPDATE-COMPONENT finished" :js/rchanges rchanges) (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) @@ -552,7 +572,6 @@ ptk/WatchEvent (watch [_ state stream] - ;; ===== Uncomment this to debug ===== (log/info :msg "SYNC-FILE" :file (str (or file-id "local"))) (let [library-changes [(dwlh/generate-sync-library :components file-id state) (dwlh/generate-sync-library :colors file-id state) @@ -566,7 +585,6 @@ uchanges (d/concat [] (->> library-changes (remove nil?) (map second) (flatten)) (->> file-changes (remove nil?) (map second) (flatten)))] - ;; ===== Uncomment this to debug ===== (log/debug :msg "SYNC-FILE finished" :js/rchanges rchanges) (rx/concat (rx/of (dm/hide-tag :sync-dialog)) @@ -593,14 +611,12 @@ (ptk/reify ::sync-file-2nd-stage ptk/WatchEvent (watch [_ state stream] - ;; ===== Uncomment this to debug ===== (log/info :msg "SYNC-FILE (2nd stage)" :file (str (or file-id "local"))) (let [[rchanges1 uchanges1] (dwlh/generate-sync-file :components nil state) [rchanges2 uchanges2] (dwlh/generate-sync-library :components file-id state) rchanges (d/concat rchanges1 rchanges2) uchanges (d/concat uchanges1 uchanges2)] (when rchanges - ;; ===== Uncomment this to debug ===== (log/debug :msg "SYNC-FILE (2nd stage) finished" :js/rchanges rchanges) (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}))))))) diff --git a/frontend/src/app/main/data/workspace/libraries_helpers.cljs b/frontend/src/app/main/data/workspace/libraries_helpers.cljs index 929839973..1cd2461e4 100644 --- a/frontend/src/app/main/data/workspace/libraries_helpers.cljs +++ b/frontend/src/app/main/data/workspace/libraries_helpers.cljs @@ -12,12 +12,14 @@ [cljs.spec.alpha :as s] [app.common.spec :as us] [app.common.data :as d] - [app.common.pages-helpers :as cph] + [app.common.pages :as cph] ;; TODO: remove this namespace [app.common.geom.point :as gpt] + [app.common.geom.shapes :as geom] [app.common.pages :as cp] [app.util.logging :as log] [app.util.text :as ut])) +;; Change this to :info :debug or :trace to debug this module (log/set-level! :warn) (defonce empty-changes [[] []]) @@ -36,19 +38,20 @@ (declare has-asset-reference-fn) (declare get-assets) -(declare generate-sync-shape-and-children-components) -(declare generate-sync-shape-and-children-normal) -(declare generate-sync-shape-and-children-nested) +(declare generate-sync-shape-direct) +(declare generate-sync-shape-direct-recursive) (declare generate-sync-shape-inverse) -(declare generate-sync-shape-inverse-normal) -(declare generate-sync-shape-inverse-nested) -(declare generate-sync-shape<-component) -(declare generate-sync-shape->component) -(declare remove-component-and-ref) -(declare remove-ref) -(declare reset-touched) +(declare generate-sync-shape-inverse-recursive) + +(declare compare-children) +(declare concat-changes) +(declare add-shape-to-instance) +(declare add-shape-to-master) +(declare remove-shape) +(declare move-shape) +(declare change-touched) (declare update-attrs) -(declare calc-new-pos) +(declare reposition-shape) ;; ---- Create a new component ---- @@ -133,9 +136,7 @@ (generate-sync-container asset-type library-id state - page - (:id page) - nil)] + (cph/make-container page :page))] (recur (next pages) (d/concat rchanges page-rchanges) (d/concat uchanges page-uchanges))) @@ -165,9 +166,8 @@ (generate-sync-container asset-type library-id state - local-component - nil - (:id local-component))] + (cph/make-container local-component + :component))] (recur (next local-components) (d/concat rchanges comp-rchanges) (d/concat uchanges comp-uchanges))) @@ -176,11 +176,11 @@ (defn- generate-sync-container "Generate changes to synchronize all shapes in a particular container (a page or a component) that are linked to the given library." - [asset-type library-id state container page-id component-id] + [asset-type library-id state container] - (if page-id - (log/debug :msg "Sync page in local file" :page-id page-id) - (log/debug :msg "Sync component in local library" :component-id component-id)) + (if (cph/page? container) + (log/debug :msg "Sync page in local file" :page-id (:id container)) + (log/debug :msg "Sync component in local library" :component-id (:id container))) (let [has-asset-reference? (has-asset-reference-fn asset-type library-id) linked-shapes (cph/select-objects has-asset-reference? container)] @@ -192,9 +192,7 @@ (generate-sync-shape asset-type library-id state - (get container :objects) - page-id - component-id + container shape)] (recur (next shapes) (d/concat rchanges shape-rchanges) @@ -241,45 +239,46 @@ (defmulti generate-sync-shape "Generate changes to synchronize one shape, that use the given type of asset of the given library." - (fn [type _ _ _ _ _ _ _] type)) + (fn [type _ _ _ _] type)) (defmethod generate-sync-shape :components - [_ library-id state objects page-id component-id shape] - (generate-sync-shape-and-children-components page-id - component-id - (:id shape) - (get state :workspace-data) - (get state :workspace-libraries) - false)) + [_ library-id state container shape] + (generate-sync-shape-direct container + (:id shape) + (get state :workspace-data) + (get state :workspace-libraries) + false)) -(defn- generate-sync-text-shape [shape page-id component-id update-node] +(defn- generate-sync-text-shape [shape container update-node] (let [old-content (:content shape) new-content (ut/map-node update-node old-content) - rchanges [(d/without-nils {:type :mod-obj - :page-id page-id - :component-id component-id - :id (:id shape) - :operations [{:type :set - :attr :content - :val new-content}]})] - lchanges [(d/without-nils {:type :mod-obj - :page-id page-id - :component-id component-id - :id (:id shape) - :operations [{:type :set - :attr :content - :val old-content}]})]] + rchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations [{:type :set + :attr :content + :val new-content}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + uchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations [{:type :set + :attr :content + :val old-content}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] + (if (= new-content old-content) empty-changes - [rchanges lchanges]))) - + [rchanges uchanges]))) (defmethod generate-sync-shape :colors - [_ library-id state _ page-id component-id shape] + [_ library-id state container shape] ;; Synchronize a shape that uses some colors of the library. The value of the ;; color in the library is copied to the shape. - (let [colors (get-assets library-id :colors state)] + (let [colors (get-assets library-id :colors state)] (if (= :text (:type shape)) (let [update-node (fn [node] (if-let [color (get colors (:fill-color-ref-id node))] @@ -288,7 +287,7 @@ :fill-opacity (:opacity color) :fill-color-gradient (:gradient color)) node))] - (generate-sync-text-shape shape page-id component-id update-node)) + (generate-sync-text-shape shape container update-node)) (loop [attrs (seq color-sync-attrs) roperations [] uoperations []] @@ -296,16 +295,18 @@ (if (nil? attr) (if (empty? roperations) empty-changes - (let [rchanges [(d/without-nils {:type :mod-obj - :page-id page-id - :component-id component-id - :id (:id shape) - :operations roperations})] - uchanges [(d/without-nils {:type :mod-obj - :page-id page-id - :component-id component-id - :id (:id shape) - :operations uoperations})]] + (let [rchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations roperations} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + uchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations uoperations} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] [rchanges uchanges])) (if-not (contains? shape attr-ref-id) (recur (next attrs) @@ -325,7 +326,7 @@ (conj uoperations uoperation)))))))))) (defmethod generate-sync-shape :typographies - [_ library-id state _ page-id component-id shape] + [_ library-id state container shape] ;; Synchronize a shape that uses some typographies of the library. The attributes ;; of the typography are copied to the shape." @@ -334,7 +335,7 @@ (if-let [typography (get typographies (:typography-ref-id node))] (merge node (d/without-keys typography [:name :id])) node))] - (generate-sync-text-shape shape page-id component-id update-node))) + (generate-sync-text-shape shape container update-node))) ;; ---- Component synchronization helpers ---- @@ -345,7 +346,7 @@ (get-in state [:workspace-data asset-type]) (get-in state [:workspace-libraries library-id :data asset-type]))) -(defn generate-sync-shape-and-children-components +(defn generate-sync-shape-direct "Generate changes to synchronize one shape that the root of a component instance, and all its children, from the given component. If reset? is false, all atributes of each component shape that have @@ -353,121 +354,111 @@ be copied to this one. If reset? is true, all changed attributes will be copied and the 'touched' flags in the instance shape will be cleared." - [page-id component-id shape-id local-file libraries reset?] - (log/debug :msg "Sync shape and children" :shape (str shape-id) :reset? reset?) - (let [container (cph/get-container page-id component-id local-file) - shape (cph/get-shape container shape-id) - component (cph/get-component (:component-id shape) - (:component-file shape) - local-file - libraries) - root-shape shape - root-component (cph/get-component-root component)] + [container shape-id local-file libraries reset?] + (log/debug :msg "Sync shape direct" :shape (str shape-id) :reset? reset?) + (let [shape-inst (cph/get-shape container shape-id) + component (cph/get-component (:component-id shape-inst) + (:component-file shape-inst) + local-file + libraries) + shape-master (cph/get-shape component (:shape-ref shape-inst)) - (generate-sync-shape-and-children-normal page-id - component-id - container - shape - component - root-shape - root-component - reset?))) + root-inst shape-inst + root-master (cph/get-component-root component)] -(defn- generate-sync-shape-and-children-normal - [page-id component-id container shape component root-shape root-component reset?] - (log/trace :msg "Sync shape (normal)" - :shape (str (:name shape)) - :component (:name component)) - (let [[rchanges uchanges] - (generate-sync-shape<-component shape - root-shape - root-component - component - page-id - component-id - reset?) + (generate-sync-shape-direct-recursive container + shape-inst + component + shape-master + root-inst + root-master + {:omit-touched? (not reset?) + :reset-touched? reset? + :copy-touched? false}))) - children-ids (get shape :shapes [])] +(defn- generate-sync-shape-direct-recursive + [container shape-inst component shape-master root-inst root-master + {:keys [omit-touched? reset-touched? copy-touched?] + :as options :or {omit-touched? false + reset-touched? false + copy-touched? false}}] + (log/trace :msg "Sync shape direct recursive" + :shape (str (:name shape-inst)) + :component (:name component) + :options options) - (loop [children-ids (seq children-ids) - rchanges rchanges - uchanges uchanges] - (let [child-id (first children-ids)] - (if (nil? child-id) - [rchanges uchanges] - (let [child-shape (cph/get-shape container child-id) - - [child-rchanges child-uchanges] - (if (nil? (:component-id child-shape)) - (generate-sync-shape-and-children-normal page-id - component-id - container - child-shape - component - root-shape - root-component - reset?) - (generate-sync-shape-and-children-nested page-id - component-id - container - child-shape - component - root-shape - root-component - reset?))] - (recur (next children-ids) - (d/concat rchanges child-rchanges) - (d/concat uchanges child-uchanges)))))))) - -(defn- generate-sync-shape-and-children-nested - [page-id component-id container shape component root-shape root-component reset?] - (log/trace :msg "Sync shape (nested)" - :shape (str (:name shape)) - :component (:name component)) - (let [component-shape (d/seek #(= (:shape-ref %) - (:shape-ref shape)) - (vals (:objects component))) - root-shape (if (:component-id shape) - shape - root-shape) - root-component (if (:component-id shape) - component-shape - root-component) + (let [root-inst (if (:component-id shape-inst) + shape-inst + root-inst) + root-master (if (:component-id shape-inst) + shape-master + root-master) [rchanges uchanges] - (update-attrs shape - component-shape - root-shape - root-component - page-id - component-id - {:omit-touched? false - :reset-touched? false - :set-touched? false - :copy-touched? true}) + (concat-changes + (update-attrs shape-inst + shape-master + root-inst + root-master + container + options) + (change-touched shape-inst + shape-master + container + options)) - children-ids (get shape :shapes [])] + children-inst (mapv #(cph/get-shape container %) + (:shapes shape-inst)) + children-master (mapv #(cph/get-shape component %) + (:shapes shape-master)) - (loop [children-ids (seq children-ids) - rchanges rchanges - uchanges uchanges] - (let [child-id (first children-ids)] - (if (nil? child-id) - [rchanges uchanges] - (let [child-shape (cph/get-shape container child-id) + only-inst (fn [shape-inst] + (remove-shape shape-inst + container + omit-touched?)) - [child-rchanges child-uchanges] - (generate-sync-shape-and-children-nested page-id - component-id - container - child-shape - component - root-shape - root-component - reset?)] - (recur (next children-ids) - (d/concat rchanges child-rchanges) - (d/concat uchanges child-uchanges)))))))) + only-master (fn [shape-master] + (add-shape-to-instance shape-master + component + container + root-inst + root-master + omit-touched?)) + + both (fn [shape-inst shape-master] + (let [options (if-not (:component-id shape-inst) + options + {:omit-touched? false + :reset-touched? false + :copy-touched? true})] + + (generate-sync-shape-direct-recursive container + shape-inst + component + shape-master + root-inst + root-master + options))) + + moved (fn [shape-inst shape-master] + (move-shape + shape-inst + (d/index-of children-inst shape-inst) + (d/index-of children-master shape-master) + container + omit-touched?)) + + [child-rchanges child-uchanges] + (compare-children children-inst + children-master + only-inst + only-master + both + moved + false)] + + [(d/concat rchanges child-rchanges) + (d/concat uchanges child-uchanges)])) (defn- generate-sync-shape-inverse "Generate changes to update the component a shape is linked to, from @@ -478,229 +469,477 @@ And if the component shapes are, in turn, instances of a second component, their 'touched' flags will be set accordingly." [page-id shape-id local-file libraries] - (log/debug :msg "Sync inverse shape and children" :shape (str shape-id)) - (let [page (cph/get-container page-id nil local-file) - shape (cph/get-shape page shape-id) - component (cph/get-component (:component-id shape) - (:component-file shape) + (log/debug :msg "Sync shape inverse" :shape (str shape-id)) + (let [container (cph/get-container page-id :page local-file) + shape-inst (cph/get-shape container shape-id) + component (cph/get-component (:component-id shape-inst) + (:component-file shape-inst) local-file libraries) - root-shape shape - root-component (cph/get-component-root component)] + shape-master (cph/get-shape component (:shape-ref shape-inst)) - (generate-sync-shape-inverse-normal page - shape - component - root-shape - root-component))) + root-inst shape-inst + root-master (cph/get-component-root component)] -(defn- generate-sync-shape-inverse-normal - [page shape component root-shape root-component] - (log/trace :msg "Sync shape inverse (normal)" - :shape (str (:name shape)) - :component (:name component)) - (let [[rchanges uchanges] - (generate-sync-shape->component shape - root-shape - root-component - component - (:id page)) + (generate-sync-shape-inverse-recursive container + shape-inst + component + shape-master + root-inst + root-master + {:reset-touched? false + :set-touched? true + :copy-touched? false}))) - children-ids (get shape :shapes [])] +(defn- generate-sync-shape-inverse-recursive + [container shape-inst component shape-master root-inst root-master + {:keys [reset-touched? set-touched? copy-touched?] + :as options :or {reset-touched? false + set-touched? false + copy-touched? false}}] + (log/trace :msg "Sync shape inverse recursive" + :shape (str (:name shape-inst)) + :component (:name component) + :options options) - (loop [children-ids (seq children-ids) - rchanges rchanges - uchanges uchanges] - (let [child-id (first children-ids)] - (if (nil? child-id) - [rchanges uchanges] - (let [child-shape (cph/get-shape page child-id) + (let [root-inst (if (:component-id shape-inst) + shape-inst + root-inst) + root-master (if (:component-id shape-inst) + shape-master + root-master) - [child-rchanges child-uchanges] - (if (nil? (:component-id child-shape)) - (generate-sync-shape-inverse-normal page - child-shape - component - root-shape - root-component) - (generate-sync-shape-inverse-nested page - child-shape - component - root-shape - root-component))] - (recur (next children-ids) - (d/concat rchanges child-rchanges) - (d/concat uchanges child-uchanges)))))))) - -(defn- generate-sync-shape-inverse-nested - [page shape component root-shape root-component] - (log/trace :msg "Sync shape inverse (nested)" - :shape (str (:name shape)) - :component (:name component)) - (let [component-shape (d/seek #(= (:shape-ref %) - (:shape-ref shape)) - (vals (:objects component))) - root-shape (if (:component-id shape) - shape - root-shape) - root-component (if (:component-id shape) - component-shape - root-component) + component-container (cph/make-container component :component) [rchanges uchanges] - (update-attrs component-shape - shape - root-component - root-shape - nil - (:id component) - {:omit-touched? false - :reset-touched? false - :set-touched? false - :copy-touched? true}) + (concat-changes + (update-attrs shape-master + shape-inst + root-master + root-inst + component-container + options) + (concat-changes + (change-touched shape-master + shape-inst + component-container + options) + (if (:set-touched? options) + (change-touched shape-inst nil container {:reset-touched? true}) + empty-changes))) - children-ids (get shape :shapes [])] + children-inst (mapv #(cph/get-shape container %) + (:shapes shape-inst)) + children-master (mapv #(cph/get-shape component %) + (:shapes shape-master)) - (loop [children-ids (seq children-ids) - rchanges rchanges - uchanges uchanges] - (let [child-id (first children-ids)] - (if (nil? child-id) - [rchanges uchanges] - (let [child-shape (cph/get-shape page child-id) + only-inst (fn [shape-inst] + (add-shape-to-master shape-inst + component + container + root-inst + root-master)) - [child-rchanges child-uchanges] - (generate-sync-shape-inverse-nested page - child-shape - component - root-shape - root-component)] - (recur (next children-ids) - (d/concat rchanges child-rchanges) - (d/concat uchanges child-uchanges)))))))) + only-master (fn [shape-master] + (remove-shape shape-master + component-container + false)) + + both (fn [shape-inst shape-master] + (let [options (if-not (:component-id shape-inst) + options + {:reset-touched? false + :set-touched? false + :copy-touched? true})] -(defn- generate-sync-shape<-component - "Generate changes to synchronize one shape that is linked to other shape - inside a component. Same considerations as above about reset-touched?" - [shape root-shape root-component component page-id component-id reset?] - (if (nil? component) - (remove-component-and-ref shape page-id component-id) - (let [component-shape (get (:objects component) (:shape-ref shape))] - (if (nil? component-shape) - (remove-ref shape page-id component-id) - (update-attrs shape - component-shape - root-shape - root-component - page-id - component-id - {:omit-touched? (not reset?) - :reset-touched? reset? - :set-touched? false}))))) + (generate-sync-shape-inverse-recursive container + shape-inst + component + shape-master + root-inst + root-master + options))) -(defn- generate-sync-shape->component - "Generate changes to synchronize one shape inside a component, with other - shape that is linked to it." - [shape root-shape root-component component page-id] - (if (nil? component) - empty-changes - (let [component-shape (get (:objects component) (:shape-ref shape))] - (if (nil? component-shape) - empty-changes - (let [[rchanges1 uchanges1] - (update-attrs component-shape - shape - root-component - root-shape - nil - (:id root-component) - {:omit-touched? false - :reset-touched? false - :set-touched? true}) - [rchanges2 uchanges2] - (reset-touched shape - page-id - nil)] - [(d/concat rchanges1 rchanges2) - (d/concat uchanges2 uchanges2)]))))) + moved (fn [shape-inst shape-master] + (move-shape + shape-master + (d/index-of children-master shape-master) + (d/index-of children-inst shape-inst) + component-container + false)) + + [child-rchanges child-uchanges] + (compare-children children-inst + children-master + only-inst + only-master + both + moved + true)] + + [(d/concat rchanges child-rchanges) + (d/concat uchanges child-uchanges)])) ; ---- Operation generation helpers ---- -(defn- remove-component-and-ref - [shape page-id component-id] - [[(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set - :attr :component-root? - :val nil} - {:type :set - :attr :component-id - :val nil} - {:type :set - :attr :component-file - :val nil} - {:type :set - :attr :shape-ref - :val nil} - {:type :set-touched - :touched nil}]})] - [(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set - :attr :component-root? - :val (:component-root? shape)} - {: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)} - {:type :set-touched - :touched (:touched shape)}]})]]) +(defn- compare-children + [children-inst children-master only-inst-cb only-master-cb both-cb moved-cb inverse?] + (loop [children-inst (seq (or children-inst [])) + children-master (seq (or children-master [])) + [rchanges uchanges] [[] []]] + (let [child-inst (first children-inst) + child-master (first children-master)] + (cond + (and (nil? child-inst) (nil? child-master)) + [rchanges uchanges] -(defn- -remove-ref - [shape page-id component-id] - [[(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set - :attr :shape-ref - :val nil} - {:type :set-touched - :touched nil}]})] - [(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set - :attr :shape-ref - :val (:shape-ref shape)} - {:type :set-touched - :touched (:touched shape)}]})]]) + (nil? child-inst) + (reduce (fn [changes child] + (concat-changes changes (only-master-cb child))) + [rchanges uchanges] + children-master) -(defn- reset-touched - [shape page-id component-id] - [[(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set-touched - :touched nil}]})] - [(d/without-nils {:type :mod-obj - :id (:id shape) - :page-id page-id - :component-id component-id - :operations [{:type :set-touched - :touched (:touched shape)}]})]]) + (nil? child-master) + (reduce (fn [changes child] + (concat-changes changes (only-inst-cb child))) + [rchanges uchanges] + children-inst) + + :else + (if (cph/is-master-of child-master child-inst) + (recur (next children-inst) + (next children-master) + (concat-changes [rchanges uchanges] + (both-cb child-inst child-master))) + + (let [child-inst' (d/seek #(cph/is-master-of child-master %) + children-inst) + child-master' (d/seek #(cph/is-master-of % child-inst) + children-master)] + (cond + (nil? child-inst') + (recur children-inst + (next children-master) + (concat-changes [rchanges uchanges] + (only-master-cb child-master))) + + (nil? child-master') + (recur (next children-inst) + children-master + (concat-changes [rchanges uchanges] + (only-inst-cb child-inst))) + + :else + (if inverse? + (recur (next children-inst) + (remove #(= (:id %) (:id child-master')) children-master) + (-> [rchanges uchanges] + (concat-changes (both-cb child-inst' child-master)) + (concat-changes (moved-cb child-inst child-master')))) + (recur (remove #(= (:id %) (:id child-inst')) children-inst) + (next children-master) + (-> [rchanges uchanges] + (concat-changes (both-cb child-inst child-master')) + (concat-changes (moved-cb child-inst' child-master)))))))))))) + +(defn concat-changes + [[rchanges1 uchanges1] [rchanges2 uchanges2]] + [(d/concat rchanges1 rchanges2) + (d/concat uchanges1 uchanges2)]) + +(defn- add-shape-to-instance + [component-shape component container root-instance root-master omit-touched?] + (log/info :msg (str "ADD [P] " (:name component-shape))) + (let [component-parent-shape (cph/get-shape component (:parent-id component-shape)) + parent-shape (d/seek #(cph/is-master-of component-parent-shape %) + (cph/get-object-with-children (:id root-instance) + (:objects container))) + all-parents (vec (cons (:id parent-shape) + (cph/get-parents parent-shape (:objects container)))) + + update-new-shape (fn [new-shape original-shape] + (let [new-shape (reposition-shape new-shape + root-master + root-instance)] + (cond-> new-shape + true + (assoc :frame-id (:frame-id parent-shape)) + + (nil? (:shape-ref original-shape)) + (assoc :shape-ref (:id original-shape)) + + (some? (:shape-ref original-shape)) + (assoc :shape-ref (:shape-ref original-shape)) + + (:component-id original-shape) + (assoc :component-id (:component-id original-shape)) + + (:component-file original-shape) + (assoc :component-file (:component-file original-shape)) + + (:component-root original-shape) + (assoc :component-root (:component-root original-shape)) + + (:touched original-shape) + (assoc :touched (:touched original-shape))))) + + update-original-shape (fn [original-shape new-shape] + original-shape) + + [new-shape new-shapes _] + (cph/clone-object component-shape + (:id parent-shape) + (get component :objects) + update-new-shape + update-original-shape) + + rchanges (d/concat + (mapv (fn [shape'] + (as-> {:type :add-obj + :id (:id shape') + :parent-id (:parent-id shape') + :ignore-touched true + :obj shape'} $ + (cond-> $ + (:frame-id shape') + (assoc :frame-id (:frame-id shape'))) + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))) + new-shapes) + [(as-> {:type :reg-objects + :shapes all-parents} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]) + + uchanges (d/concat + (mapv (fn [shape'] + (as-> {:type :del-obj + :id (:id shape') + :ignore-touched true} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))) + new-shapes))] + + (if (and (cph/touched-group? parent-shape :shapes-group) omit-touched?) + empty-changes + [rchanges uchanges]))) + +(defn- add-shape-to-master + [shape component page root-instance root-master] + (log/info :msg (str "ADD [C] " (:name shape))) + (let [parent-shape (cph/get-shape page (:parent-id shape)) + component-parent-shape (d/seek #(cph/is-master-of % parent-shape) + (cph/get-object-with-children (:id root-master) + (:objects component))) + all-parents (vec (cons (:id component-parent-shape) + (cph/get-parents component-parent-shape (:objects component)))) + + update-new-shape (fn [new-shape original-shape] + (reposition-shape new-shape + root-instance + root-master)) + + update-original-shape (fn [original-shape new-shape] + (if-not (:shape-ref original-shape) + (assoc original-shape + :shape-ref (:id new-shape)) + original-shape)) + + [new-shape new-shapes updated-shapes] + (cph/clone-object shape + (:id component-parent-shape) + (get page :objects) + update-new-shape + update-original-shape) + + rchanges (d/concat + (mapv (fn [shape'] + {:type :add-obj + :id (:id shape') + :component-id (:id component) + :parent-id (:parent-id shape') + :ignore-touched true + :obj shape'}) + new-shapes) + [{:type :reg-objects + :component-id (:id component) + :shapes all-parents}] + (mapv (fn [shape'] + {: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 :component-root? + :val (:component-root? shape')} + {:type :set + :attr :shape-ref + :val (:shape-ref shape')} + {:type :set + :attr :touched + :val (:touched shape')}]}) + updated-shapes)) + + uchanges (d/concat + (mapv (fn [shape'] + {:type :del-obj + :id (:id shape') + :page-id (:id page) + :ignore-touched true}) + new-shapes))] + + [rchanges uchanges])) + +(defn- remove-shape + [shape container omit-touched?] + (log/info :msg (str "REMOVE-SHAPE " + (if (cph/page? container) "[P] " "[C] ") + (:name shape))) + (let [objects (get container :objects) + parents (cph/get-parents (:id shape) objects) + parent (first parents) + children (cph/get-children (:id shape) objects) + + rchanges [(as-> {:type :del-obj + :id (:id shape) + :ignore-touched true} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + + add-change (fn [id] + (let [shape' (get objects id)] + (as-> {:type :add-obj + :id id + :index (cph/position-on-parent id objects) + :parent-id (:parent-id shape') + :ignore-touched true + :obj shape'} $ + (cond-> $ + (:frame-id shape') + (assoc :frame-id (:frame-id shape'))) + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container)))))) + + uchanges (d/concat + [(add-change (:id shape))] + (map add-change children) + [(as-> {:type :reg-objects + :shapes (vec parents)} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))])] + + (if (and (cph/touched-group? parent :shapes-group) omit-touched?) + empty-changes + [rchanges uchanges]))) + +(defn- move-shape + [shape index-before index-after container omit-touched?] + (log/info :msg (str "MOVE " + (if (cph/page? container) "[P] " "[C] ") + (:name shape) + " " + index-before + " -> " + index-after)) + (let [parent (cph/get-shape container (:parent-id shape)) + + rchanges [(as-> {:type :mov-objects + :parent-id (:parent-id shape) + :shapes [(:id shape)] + :index index-after + :ignore-touched true} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + uchanges [(as-> {:type :mov-objects + :parent-id (:parent-id shape) + :shapes [(:id shape)] + :index index-before + :ignore-touched true} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] + + (if (and (cph/touched-group? parent :shapes-group) omit-touched?) + empty-changes + [rchanges uchanges]))) + +(defn- change-touched + [dest-shape orig-shape container + {:keys [reset-touched? copy-touched?] + :as options :or {reset-touched? false + copy-touched? false}}] + (if (or (nil? (:shape-ref dest-shape)) + (not (or reset-touched? copy-touched?))) + empty-changes + (do + (log/info :msg (str "CHANGE-TOUCHED " + (if (cph/page? container) "[P] " "[C] ") + (:name dest-shape)) + :options options) + (let [rchanges [(as-> {:type :mod-obj + :id (:id dest-shape) + :operations + [{:type :set-touched + :touched + (cond reset-touched? + nil + copy-touched? + (:touched orig-shape))}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + + uchanges [(as-> {:type :mod-obj + :id (:id dest-shape) + :operations + [{:type :set-touched + :touched (:touched dest-shape)}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] + [rchanges uchanges])))) + +(defn- set-touched-shapes-group + [shape container] + (if-not (:shape-ref shape) + empty-changes + (do + (log/info :msg (str "SET-TOUCHED-SHAPES-GROUP " + (if (cph/page? container) "[P] " "[C] ") + (:name shape))) + (let [rchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations + [{:type :set-touched + :touched (cph/set-touched-group + (:touched shape) + :shapes-group)}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + + uchanges [(as-> {:type :mod-obj + :id (:id shape) + :operations + [{:type :set-touched + :touched (:touched shape)}]} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] + [rchanges uchanges])))) (defn- update-attrs "The main function that implements the sync algorithm. Copy @@ -710,8 +949,10 @@ If reset-touched? is true, the 'touched' flags will be cleared in the dest shape. If set-touched? is true, the corresponding 'touched' flags will be - set in dest shape if they are different than their current values." - [dest-shape origin-shape dest-root origin-root page-id component-id + set in dest shape if they are different than their current values. + If copy-touched? is true, the value of 'touched' flags in the + origin shape will be copied as is to the dest shape." + [dest-shape origin-shape dest-root origin-root container {:keys [omit-touched? reset-touched? set-touched? copy-touched?] :as options :or {omit-touched? false reset-touched? false @@ -721,26 +962,20 @@ (log/info :msg (str "SYNC " (:name origin-shape) " -> " - (if page-id "[W] " "[C] ") + (if (cph/page? container) "[P] " "[C] ") (:name dest-shape))) - (let [; The position attributes need a special sync algorith, because we do - ; not synchronize the absolute position, but the position relative of - ; the container shape of the component. - new-pos (calc-new-pos dest-shape origin-shape dest-root origin-root) - touched (get dest-shape :touched #{})] + (let [; To synchronize geometry attributes we need to make a prior + ; operation, because coordinates are absolute, but we need to + ; sync only the position relative to the origin of the component. + ; We solve this by moving the origin shape so it is aligned with + ; the dest root before syncing. + origin-shape (reposition-shape origin-shape origin-root dest-root) + touched (get dest-shape :touched #{})] - (loop [attrs (seq (keys (dissoc cp/component-sync-attrs :x :y))) - roperations (if (or (not= (:x new-pos) (:x dest-shape)) - (not= (:y new-pos) (:y dest-shape))) - [{:type :set :attr :x :val (:x new-pos)} - {:type :set :attr :y :val (:y new-pos)}] - []) - uoperations (if (or (not= (:x new-pos) (:x dest-shape)) - (not= (:y new-pos) (:y dest-shape))) - [{:type :set :attr :x :val (:x dest-shape)} - {:type :set :attr :y :val (:y dest-shape)}] - [])] + (loop [attrs (seq (keys cp/component-sync-attrs)) + roperations [] + uoperations []] (let [attr (first attrs)] (if (nil? attr) @@ -764,47 +999,45 @@ :else uoperations) - rchanges [(d/without-nils {:type :mod-obj - :id (:id dest-shape) - :page-id page-id - :component-id component-id - :operations roperations})] - uchanges [(d/without-nils {:type :mod-obj - :id (:id dest-shape) - :page-id page-id - :component-id component-id - :operations uoperations})]] + rchanges [(as-> {:type :mod-obj + :id (:id dest-shape) + :operations roperations} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))] + uchanges [(as-> {:type :mod-obj + :id (:id dest-shape) + :operations uoperations} $ + (if (cph/page? container) + (assoc $ :page-id (:id container)) + (assoc $ :component-id (:id container))))]] [rchanges uchanges]) - (if-not (contains? dest-shape attr) - (recur (next attrs) - roperations - uoperations) - (let [roperation {:type :set - :attr attr - :val (get origin-shape attr) - :ignore-touched (not set-touched?)} - uoperation {:type :set - :attr attr - :val (get dest-shape attr) - :ignore-touched (not set-touched?)} + (let [roperation {:type :set + :attr attr + :val (get origin-shape attr) + :ignore-touched (not set-touched?)} + uoperation {:type :set + :attr attr + :val (get dest-shape attr) + :ignore-touched (not set-touched?)} - attr-group (get cp/component-sync-attrs attr)] - (if (and (touched attr-group) omit-touched?) - (recur (next attrs) - roperations - uoperations) - (recur (next attrs) - (conj roperations roperation) - (conj uoperations uoperation)))))))))) + attr-group (get cp/component-sync-attrs attr)] + (if (and (touched attr-group) omit-touched?) + (recur (next attrs) + roperations + uoperations) + (recur (next attrs) + (conj roperations roperation) + (conj uoperations uoperation))))))))) -(defn- calc-new-pos - [dest-shape origin-shape dest-root origin-root] - (let [root-pos (gpt/point (:x dest-root) (:y dest-root)) - origin-root-pos (gpt/point (:x origin-root) (:y origin-root)) - origin-pos (gpt/point (:x origin-shape) (:y origin-shape)) - delta (gpt/subtract origin-pos origin-root-pos) - shape-pos (gpt/point (:x dest-shape) (:y dest-shape)) - new-pos (gpt/add root-pos delta)] - new-pos)) +(defn- reposition-shape + [shape origin-root dest-root] + (let [shape-pos (fn [shape] + (gpt/point (get-in shape [:selrect :x]) + (get-in shape [:selrect :y]))) + origin-root-pos (shape-pos origin-root) + dest-root-pos (shape-pos dest-root) + delta (gpt/subtract dest-root-pos origin-root-pos)] + (geom/move shape delta))) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index a0ae74a6b..28008bdfc 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -163,7 +163,7 @@ (ptk/reify ::handle-presence ptk/UpdateEvent (update [_ state] - (let [profiles (:workspace-users state)] + (let [profiles (:users state)] (update state :workspace-presence update-sessions profiles)))))) (defn handle-pointer-update diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs index a35c0ae67..a4db5cce0 100644 --- a/frontend/src/app/main/data/workspace/persistence.cljs +++ b/frontend/src/app/main/data/workspace/persistence.cljs @@ -37,18 +37,17 @@ ;; --- Persistence - - (defn initialize-file-persistence [file-id] (ptk/reify ::initialize-persistence ptk/EffectEvent (effect [_ state stream] (let [stoper (rx/filter #(= ::finalize %) stream) + forcer (rx/filter #(= ::force-persist %) stream) notifier (->> stream (rx/filter (ptk/type? ::dwc/commit-changes)) (rx/debounce 2000) - (rx/merge stoper)) + (rx/merge stoper forcer)) on-dirty (fn [] @@ -126,9 +125,13 @@ (rx/map #(shapes-changes-persisted file-id %)))))) on-error - (fn [error] - (rx/of (update-persistence-status {:status :error - :reason (:type error)})))] + (fn [{:keys [type status] :as error}] + (if (and (= :server-error type) + (= 502 status)) + (rx/of (update-persistence-status {:status :error :reason type})) + (rx/of update-persistence-queue + (update-persistence-status {:status :error :reason type}))))] + (when (= file-id (:id file)) (->> (rp/mutation :update-file params) @@ -184,7 +187,6 @@ (s/def ::version ::us/integer) (s/def ::revn ::us/integer) (s/def ::ordering ::us/integer) -(s/def ::metadata (s/nilable ::cp/metadata)) (s/def ::data ::cp/data) (s/def ::file ::dd/file) @@ -208,7 +210,7 @@ ptk/WatchEvent (watch [_ state stream] (->> (rx/zip (rp/query :file {:id file-id}) - (rp/query :file-users {:id file-id}) + (rp/query :team-users {:file-id file-id}) (rp/query :project {:id project-id}) (rp/query :file-libraries {:file-id file-id})) (rx/first) @@ -225,12 +227,6 @@ :else (throw error)))))))) -(defn assoc-profile-avatar - [{:keys [photo fullname] :as profile}] - (cond-> profile - (or (nil? photo) (empty? photo)) - (assoc :photo (avatars/generate {:name fullname})))) - (defn- bundle-fetched [file users project libraries] (ptk/reify ::bundle-fetched @@ -243,13 +239,13 @@ ptk/UpdateEvent (update [_ state] - (let [users (map assoc-profile-avatar users)] + (let [users (map avatars/assoc-profile-avatar users)] (assoc state + :users (d/index-by :id users) :workspace-undo {} :workspace-project project :workspace-file file :workspace-data (:data file) - :workspace-users (d/index-by :id users) :workspace-libraries (d/index-by :id libraries)))))) @@ -295,51 +291,28 @@ ;; --- Link and unlink Files -(declare file-linked) - (defn link-file-to-library [file-id library-id] (ptk/reify ::link-file-to-library ptk/WatchEvent (watch [_ state stream] - (let [params {:file-id file-id - :library-id library-id}] - (->> (->> (rp/mutation :link-file-to-library params) - (rx/mapcat - #(rx/zip (rp/query :file-library {:file-id library-id}) - (rp/query :media-objects {:file-id library-id - :is-local false})))) - (rx/map file-linked)))))) - -(defn file-linked - [[library media-objects colors]] - (ptk/reify ::file-linked - ptk/UpdateEvent - (update [_ state] - (assoc-in state [:workspace-libraries (:id library)] - (assoc library - :media-objects media-objects - :colors colors))))) - -(declare file-unlinked) + (let [fetched #(assoc-in %2 [:workspace-libraries (:id %1)] %1) + params {:file-id file-id + :library-id library-id}] + (->> (rp/mutation :link-file-to-library params) + (rx/mapcat #(rp/query :file {:id library-id})) + (rx/map #(partial fetched %))))))) (defn unlink-file-from-library [file-id library-id] (ptk/reify ::unlink-file-from-library ptk/WatchEvent (watch [_ state stream] - (let [params {:file-id file-id - :library-id library-id}] + (let [unlinked #(d/dissoc-in % [:workspace-libraries library-id]) + params {:file-id file-id + :library-id library-id}] (->> (rp/mutation :unlink-file-from-library params) - (rx/map #(file-unlinked file-id library-id))))))) - -(defn file-unlinked - [file-id library-id] - (ptk/reify ::file-unlinked - ptk/UpdateEvent - (update [_ state] - (d/dissoc-in state [:workspace-libraries library-id])))) - + (rx/map (constantly unlinked))))))) ;; --- Fetch Pages @@ -376,7 +349,7 @@ :opt-un [::uri ::di/js-files])) (defn upload-media-objects - [{:keys [file-id local? js-files uri] :as params}] + [{:keys [file-id local? js-files uri name] :as params}] (us/assert ::upload-media-objects-params params) (ptk/reify ::upload-media-objects ptk/WatchEvent @@ -396,7 +369,8 @@ (fn [uri] {:file-id file-id :is-local local? - :url uri})] + :url uri + :name name})] (rx/concat (rx/of (dm/show {:content (tr "media.loading") @@ -430,18 +404,6 @@ (rx/finalize (fn [] (st/emit! (dm/hide-tag :media-loading)))))))))) - -;; --- Delete media object - -(defn delete-media-object - [file-id id] - (ptk/reify ::delete-media-object - ptk/WatchEvent - (watch [_ state stream] - (let [params {:id id}] - (rp/mutation :delete-media-object params))))) - - ;; --- Helpers (defn purge-page diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 136333ef6..629b4bb83 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -18,7 +18,6 @@ [app.common.geom.shapes :as geom] [app.common.math :as mth] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] [app.common.uuid :as uuid] [app.main.data.workspace.common :as dwc] @@ -118,7 +117,24 @@ objects (dwc/lookup-page-objects state page-id)] (rx/of (dwc/expand-all-parents ids objects)))))) -(defn deselect-all +(defn select-all + [] + (ptk/reify ::select-all + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + is-not-blocked (fn [shape-id] (not (get-in state [:workspace-data + :pages-index page-id + :objects shape-id + :blocked] false)))] + (rx/of (->> (cp/select-toplevel-shapes objects) + (map :id) + (filter is-not-blocked) + (into lks/empty-linked-set) + (select-shapes))))))) + +(defn deselect-all "Clear all possible state of drawing, edition or any similar action taken by the user. When `check-modal` the method will check if a modal is opened @@ -180,91 +196,6 @@ (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} - {:type :del-obj - :page-id page-id - :id (:id group)}] - 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) @@ -303,7 +234,7 @@ 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) + frames (cp/select-frames objects) parent-id (or parent-id frame-id) children-changes @@ -330,6 +261,7 @@ :old-id (:id obj) :frame-id frame-id :parent-id parent-id + :ignore-touched true :obj (dissoc reframed-obj :shapes)}] children-changes))) @@ -381,11 +313,8 @@ (defn change-hover-state [id value] - (letfn [(update-hover [items] - (if value - (conj items id) - (disj items id)))] - (ptk/reify ::change-hover-state - ptk/UpdateEvent - (update [_ state] - (update-in state [:workspace-local :hover] (fnil update-hover #{})))))) + (ptk/reify ::change-hover-state + ptk/UpdateEvent + (update [_ state] + (let [hover-value (if value #{id} #{})] + (assoc-in state [:workspace-local :hover] hover-value))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 34616e94c..3f8a34596 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -16,8 +16,10 @@ [clojure.walk :as walk] [goog.object :as gobj] [potok.core :as ptk] - [app.common.geom.shapes :as geom] + [app.common.geom.shapes :as gsh] + [app.common.attrs :as attrs] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.transforms :as dwt] [app.main.fonts :as fonts] [app.util.object :as obj] [app.util.text :as ut])) @@ -125,7 +127,7 @@ (map #(if (is-text-node? %) (merge ut/default-text-attrs %) %)))] - (geom/get-attrs-multi nodes attrs))) + (attrs/get-attrs-multi nodes attrs))) (defn current-text-values [{:keys [editor default attrs shape]}] @@ -209,3 +211,40 @@ (and (= 1 (count selected)) (= (-> selected first :type) :text)) (assoc-in [:workspace-local :edition] (-> selected first :id))))))) + +(defn resize-text [id new-width new-height] + (ptk/reify ::resize-text + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + shape (get-in state [:workspace-data :pages-index page-id :objects id]) + {:keys [selrect grow-type overflow-text]} (gsh/transform-shape shape) + {shape-width :width shape-height :height} selrect + undo-transaction (get-in state [:workspace-undo :transaction]) + + events + (cond-> [] + (and overflow-text (not= :fixed grow-type)) + (conj (update-overflow-text id false)) + + (and (= :fixed grow-type) (not overflow-text) (> new-height shape-height)) + (conj (update-overflow-text id true)) + + (and (= :fixed grow-type) overflow-text (<= new-height shape-height)) + (conj (update-overflow-text id false)) + + (and (or (not= shape-width new-width) (not= shape-height new-height)) + (= grow-type :auto-width)) + (conj (dwt/update-dimensions [id] :width new-width) + (dwt/update-dimensions [id] :height new-height)) + + (and (not= shape-height new-height) (= grow-type :auto-height)) + (conj (dwt/update-dimensions [id] :height new-height)))] + + (if (not (empty? events)) + (rx/concat + (when (not undo-transaction) + (rx/of (dwc/start-undo-transaction))) + (rx/from events) + (when (not undo-transaction) + (rx/of (dwc/discard-undo-transaction))))))))) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 56985d95c..f0c83a67b 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -15,10 +15,8 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] [app.main.data.workspace.common :as dwc] - [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.selection :as dws] [app.main.refs :as refs] [app.main.snap :as snap] @@ -80,10 +78,11 @@ (defn start-resize [handler initial ids shape] (letfn [(resize [shape initial resizing-shapes [point lock? point-snap]] - (let [{:keys [width height rotation]} shape + (let [{:keys [width height]} (:selrect shape) + {:keys [rotation]} shape shapev (-> (gpt/point width height)) - rotation (if (#{:curve :path} (:type shape)) 0 rotation) + rotation (if (= :path (:type shape)) 0 rotation) ;; Vector modifiers depending on the handler handler-modif (let [[x y] (handler-modifiers handler)] (gpt/point x y)) @@ -101,9 +100,11 @@ shape-transform (:transform shape (gmt/matrix)) shape-transform-inverse (:transform-inverse shape (gmt/matrix)) + shape-center (gsh/center-shape shape) + ;; Resize origin point given the selected handler - origin (-> (handler-resize-origin shape handler) - (gsh/transform-shape-point shape shape-transform))] + origin (-> (handler-resize-origin (:selrect shape) handler) + (gsh/transform-point-center shape-center shape-transform))] (rx/of (set-modifiers ids {:resize-vector scalev @@ -170,7 +171,7 @@ (watch [_ state stream] (let [stoper (rx/filter ms/mouse-up? stream) group (gsh/selection-rect shapes) - group-center (gsh/center group) + group-center (gsh/center-selrect group) initial-angle (gpt/angle @ms/mouse-position group-center) calculate-angle (fn [pos ctrl?] (let [angle (- (gpt/angle pos group-center) initial-angle) @@ -240,7 +241,7 @@ (let [position @ms/mouse-position page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) - frame-id (cph/frame-id-by-position objects position) + frame-id (cp/frame-id-by-position objects position) moving-shapes (->> ids (map #(get objects %)) @@ -263,7 +264,8 @@ (when-not (empty? rch) (rx/of dwc/pop-undo-into-transaction (dwc/commit-changes rch uch {:commit-local? true}) - dwc/commit-undo-transaction)))))) + (dwc/commit-undo-transaction) + (dwc/expand-collapse frame-id))))))) (defn start-move ([from-position] (start-move from-position nil)) @@ -396,38 +398,30 @@ (update-in objects [shape-id :modifiers] #(merge % modifiers))) ;; ID's + Children but remove frame children if the flag is set to false - ids-with-children (concat ids (mapcat #(cph/get-children % objects) + ids-with-children (concat ids (mapcat #(cp/get-children % objects) (filter not-frame-id? ids)))] (d/update-in-when state [:workspace-data :pages-index page-id :objects] #(reduce update-shape % ids-with-children))))))) -(defn rotation-modifiers [center shape angle] - (let [displacement (let [shape-center (gsh/center shape)] - (-> (gmt/matrix) - (gmt/rotate angle center) - (gmt/rotate (- angle) shape-center)))] - {:rotation angle - :displacement displacement})) - ;; Set-rotation is custom because applies different modifiers to each ;; shape adjusting their position. (defn set-rotation ([delta-rotation shapes] - (set-rotation delta-rotation shapes (-> shapes gsh/selection-rect gsh/center))) + (set-rotation delta-rotation shapes (-> shapes gsh/selection-rect gsh/center-selrect))) ([delta-rotation shapes center] (letfn [(rotate-shape [objects angle shape center] - (update-in objects [(:id shape) :modifiers] merge (rotation-modifiers center shape angle))) + (update-in objects [(:id shape) :modifiers] merge (gsh/rotation-modifiers center shape angle))) (rotate-around-center [objects angle center shapes] (reduce #(rotate-shape %1 angle %2 center) objects shapes)) (set-rotation [objects] (let [id->obj #(get objects %) - get-children (fn [shape] (map id->obj (cph/get-children (:id shape) objects))) + get-children (fn [shape] (map id->obj (cp/get-children (:id shape) objects))) shapes (concat shapes (mapcat get-children shapes))] (rotate-around-center objects delta-rotation center shapes)))] @@ -449,7 +443,7 @@ objects1 (get-in state [:workspace-data :pages-index page-id :objects]) ;; ID's + Children ID's - ids-with-children (d/concat [] (mapcat #(cph/get-children % objects1) ids) ids) + ids-with-children (d/concat [] (mapcat #(cp/get-children % objects1) ids) ids) ;; For each shape applies the modifiers by transforming the objects update-shape #(update %1 %2 gsh/transform-shape) @@ -468,6 +462,21 @@ rchanges (conj (dwc/generate-changes page-id objects1 objects2) regchg) uchanges (conj (dwc/generate-changes page-id objects2 objects0) regchg)] - (rx/of dwc/start-undo-transaction + (rx/of (dwc/start-undo-transaction) (dwc/commit-changes rchanges uchanges {:commit-local? true}) - dwc/commit-undo-transaction))))) + (dwc/commit-undo-transaction)))))) + +;; --- Update Dimensions + +;; Event mainly used for handling user modification of the size of the +;; object from workspace sidebar options inputs. + +(defn update-dimensions + [ids attr value] + (us/verify (s/coll-of ::us/uuid) ids) + (us/verify #{:width :height} attr) + (us/verify ::us/number value) + (ptk/reify ::update-dimensions + ptk/WatchEvent + (watch [_ state stream] + (rx/of (dwc/update-shapes ids #(gsh/resize-rect % attr value) {:reg-objects? true}))))) diff --git a/frontend/src/app/main/exports.cljs b/frontend/src/app/main/exports.cljs index cf8a129dd..8166ab334 100644 --- a/frontend/src/app/main/exports.cljs +++ b/frontend/src/app/main/exports.cljs @@ -11,11 +11,12 @@ "The main logic for SVG export functionality." (:require [rumext.alpha :as mf] + [cuerdas.core :as str] [app.common.uuid :as uuid] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.math :as mth] - [app.common.geom.shapes :as geom] + [app.common.geom.shapes :as gsh] + [app.common.geom.align :as gal] [app.common.geom.point :as gpt] [app.common.geom.matrix :as gmt] [app.main.ui.shapes.filters :as filters] @@ -41,10 +42,15 @@ (defn- calculate-dimensions [{:keys [objects] :as data} vport] - (let [shapes (cph/select-toplevel-shapes objects {:include-frames? true})] - (->> (geom/selection-rect shapes) - (geom/adjust-to-viewport vport) - (geom/fix-invalid-rect-values)))) + (let [shapes (cp/select-toplevel-shapes objects {:include-frames? true}) + to-finite (fn [val fallback] (if (not (mth/finite? val)) fallback val)) + rect (->> (gsh/selection-rect shapes) + (gal/adjust-to-viewport vport))] + (-> rect + (update :x to-finite 0) + (update :y to-finite 0) + (update :width to-finite 10000) + (update :height to-finite 10000)))) (declare shape-wrapper-factory) @@ -55,7 +61,7 @@ (mf/fnc frame-wrapper [{:keys [shape] :as props}] (let [childs (mapv #(get objects %) (:shapes shape)) - shape (geom/transform-shape shape)] + shape (gsh/transform-shape shape)] [:> shape-container {:shape shape} [:& frame-shape {:shape shape :childs childs}]])))) @@ -78,11 +84,11 @@ (let [group-wrapper (mf/use-memo (mf/deps objects) #(group-wrapper-factory objects)) frame-wrapper (mf/use-memo (mf/deps objects) #(frame-wrapper-factory objects))] (when (and shape (not (:hidden shape))) - (let [shape (geom/transform-shape frame shape) + (let [shape (-> (gsh/transform-shape shape) + (gsh/translate-to-frame frame)) opts #js {:shape shape}] [:> shape-container {:shape shape} (case (:type shape) - :curve [:> path/path-shape opts] :text [:> text/text-shape opts] :rect [:> rect/rect-shape opts] :path [:> path/path-shape opts] @@ -92,21 +98,20 @@ :group [:> group-wrapper {:shape shape :frame frame}] nil)]))))) +(defn get-viewbox [{:keys [x y width height] :or {x 0 y 0 width 100 height 100}}] + (str/fmt "%s %s %s %s" x y width height)) + (mf/defc page-svg {::mf/wrap [mf/memo]} [{:keys [data width height] :as props}] (let [objects (:objects data) - vport {:width width :height height} - - dim (calculate-dimensions data vport) root (get objects uuid/zero) shapes (->> (:shapes root) (map #(get objects %))) - vbox (str (:x dim 0) " " - (:y dim 0) " " - (:width dim 100) " " - (:height dim 100)) + vport {:width width :height height} + dim (calculate-dimensions data vport) + vbox (get-viewbox dim) background-color (get-in data [:options :background] default-color) frame-wrapper (mf/use-memo @@ -138,7 +143,7 @@ frame-id (:id frame) - modifier-ids (concat [frame-id] (cph/get-children frame-id objects)) + modifier-ids (concat [frame-id] (cp/get-children frame-id objects)) update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) objects (reduce update-fn objects modifier-ids) frame (assoc-in frame [:modifiers :displacement] modifier) @@ -168,7 +173,7 @@ group-id (:id group) - modifier-ids (concat [group-id] (cph/get-children group-id objects)) + modifier-ids (concat [group-id] (cp/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) diff --git a/frontend/src/app/main/fonts.clj b/frontend/src/app/main/fonts.clj index 4c5d35d94..2688bc743 100644 --- a/frontend/src/app/main/fonts.clj +++ b/frontend/src/app/main/fonts.clj @@ -18,10 +18,10 @@ [variant] (cond (= "regular" variant) - {:name "regular" :weight "400" :style "normal"} + {:id "regular" :name "regular" :weight "400" :style "normal"} (= "italic" variant) - {:name "italic" :weight "400" :style "italic"} + {:id "italic" :name "italic" :weight "400" :style "italic"} :else (when-let [[a b c] (re-find #"^(\d+)(.*)$" variant)] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index a20d94d6e..61e704537 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -170,3 +170,8 @@ (or (d/seek #(or (= (:id %) "regular") (= (:name %) "regular")) variants) (first variants))) + +(defn fetch-font [font-id font-variant-id] + (let [font-url (font-url font-id font-variant-id)] + (-> (js/fetch font-url) + (p/then (fn [res] (.text res)))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index fd3a2c2cd..e2b13677f 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -13,7 +13,6 @@ [beicon.core :as rx] [okulary.core :as l] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.uuid :as uuid] [app.main.constants :as c] [app.main.store :as st])) @@ -122,9 +121,6 @@ (def workspace-libraries (l/derived :workspace-libraries st/state)) -(def workspace-users - (l/derived :workspace-users st/state)) - (def workspace-presence (l/derived :workspace-presence st/state)) @@ -145,7 +141,7 @@ (l/derived :options workspace-page)) (def workspace-frames - (l/derived cph/select-frames workspace-page-objects)) + (l/derived cp/select-frames workspace-page-objects)) (defn object-by-id [id] @@ -155,8 +151,8 @@ [ids] (l/derived (fn [objects] (into [] (comp (map #(get objects %)) - (filter identity)) - (set ids))) + (remove nil?)) + ids)) workspace-page-objects =)) (defn is-child-selected? @@ -165,7 +161,7 @@ (let [page-id (:current-page-id state) objects (get-in state [:workspace-data :pages-index page-id :objects]) selected (get-in state [:workspace-local :selected]) - children (cph/get-children id objects)] + children (cp/get-children id objects)] (some selected children)))] (l/derived selector st/state))) @@ -184,7 +180,7 @@ (let [selected (get-in state [:workspace-local :selected]) page-id (:current-page-id state) objects (get-in state [:workspace-data :pages-index page-id :objects]) - children (mapcat #(cph/get-children % objects) selected)] + children (mapcat #(cp/get-children % objects) selected)] (into selected children)))] (l/derived selector st/state =))) @@ -195,7 +191,7 @@ (let [selected (get-in state [:workspace-local :selected]) page-id (:current-page-id state) objects (get-in state [:workspace-data :pages-index page-id :objects]) - children (mapcat #(cph/get-children % objects) selected) + children (mapcat #(cp/get-children % objects) selected) shapes (into selected children)] (mapv #(get objects %) shapes)))] (l/derived selector st/state =))) @@ -207,3 +203,13 @@ (def viewer-local (l/derived :viewer-local st/state)) + +(def comment-threads + (l/derived :comment-threads st/state)) + +(def comments-local + (l/derived :comments-local st/state)) + +(def users + (l/derived :users st/state)) + diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 823d1d373..9221f9ad4 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -31,11 +31,14 @@ (rx/throw {:type :authorization :code :not-authorized}) + (= (:status response) 404) + (rx/throw (:body response)) + (= 0 (:status response)) (rx/throw {:type :offline}) :else - (rx/throw {:type :internal-error + (rx/throw {:type :server-error :status (:status response) :body (:body response)}))) diff --git a/frontend/src/app/main/snap.cljs b/frontend/src/app/main/snap.cljs index 384302179..e20db5f63 100644 --- a/frontend/src/app/main/snap.cljs +++ b/frontend/src/app/main/snap.cljs @@ -166,7 +166,7 @@ (rx/merge-map (fn [[frame selrect]] (let [areas (->> (gsh/selrect->areas (or (:selrect frame) - (gsh/rect->rect-shape @refs/vbox)) selrect) + (gsh/rect->selrect @refs/vbox)) selrect) (d/mapm #(select-shapes-area page-id shapes objects %2))) snap-x (search-snap-distance selrect :x (:left areas) (:right areas)) snap-y (search-snap-distance selrect :y (:top areas) (:bottom areas))] @@ -195,7 +195,7 @@ (or (filter-shapes id) (not (contains? layout :dynamic-alignment))))) shape (if (> (count shapes) 1) - (->> shapes (map gsh/transform-shape) gsh/selection-rect) + (->> shapes (map gsh/transform-shape) gsh/selection-rect (gsh/setup {:type :rect})) (->> shapes (first))) shapes-points (->> shape diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index 28936dd5f..333756e36 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -12,10 +12,10 @@ [potok.core :as ptk] [cuerdas.core :as str] [app.common.data :as d] - [app.common.pages-helpers :as cph] + [app.common.pages :as cp] [app.common.uuid :as uuid] [app.util.storage :refer [storage]] - [app.util.debug :refer [debug? logjs]])) + [app.util.debug :refer [debug? debug-exclude-events logjs]])) (enable-console-print!) @@ -41,11 +41,11 @@ (when *assert* (defonce debug-subscription - (as-> stream $ - #_(rx/filter ptk/event? $) - (rx/filter (fn [s] (debug? :events)) $) - (rx/subscribe $ (fn [event] - (println "[stream]: " (repr-event event))))))) + (->> stream + (rx/filter ptk/event?) + (rx/filter (fn [s] (and (debug? :events) + (not (debug-exclude-events (ptk/type s)))))) + (rx/subs #(println "[stream]: " (repr-event %)))))) (defn emit! ([] nil) ([event] @@ -73,6 +73,11 @@ (defn ^:export dump-state [] (logjs "state" @state)) +(defn ^:export get-state [str-path] + (let [path (->> (str/split str-path " ") + (map d/read-string))] + (clj->js (get-in @state path)))) + (defn ^:export dump-objects [] (let [page-id (get @state :current-page-id)] (logjs "state" (get-in @state [:workspace-data :pages-index page-id :objects])))) @@ -114,7 +119,7 @@ (show-component [shape objects] (if (nil? (:shape-ref shape)) "" - (let [root-shape (cph/get-root-shape shape objects) + (let [root-shape (cp/get-root-shape shape objects) component-id (when root-shape (:component-id root-shape)) component-file-id (when root-shape (:component-file root-shape)) component-file (when component-file-id (get libraries component-file-id)) @@ -144,7 +149,7 @@ (when component-file (str/format "<%s> " (:name component-file))) (:name component))))))))] - (println "[Workspace]") + (println "[Page]") (show-shape (:id root) 0 objects) (dorun (for [component (vals components)] diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index cc5c720c9..54b633d0e 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -26,6 +26,11 @@ [v] (instance? MouseEvent v)) +(defn mouse-down? + [v] + (and (mouse-event? v) + (= :down (:type v)))) + (defn mouse-up? [v] (and (mouse-event? v) @@ -36,6 +41,11 @@ (and (mouse-event? v) (= :click (:type v)))) +(defn mouse-double-click? + [v] + (and (mouse-event? v) + (= :double-click (:type v)))) + (defrecord PointerEvent [source pt ctrl shift alt]) (defn pointer-event? @@ -91,13 +101,24 @@ (rx/subscribe-with ob sub) sub)) + +(defonce window-blur + (->> (rx/from-event js/window "blur") + (rx/share))) + (defonce keyboard-alt (let [sub (rx/behavior-subject nil) - ob (->> st/stream - (rx/filter keyboard-event?) - (rx/map :alt) + ob (->> (rx/merge + (->> st/stream + (rx/filter keyboard-event?) + (rx/map :alt)) + ;; Fix a situation caused by using `ctrl+alt` kind of shortcuts, + ;; that makes keyboard-alt stream registring the key pressed but + ;; on bluring the window (unfocus) the key down is never arrived. + (->> window-blur + (rx/map (constantly false)))) (rx/dedupe))] - (rx/subscribe-with ob sub) + (rx/subscribe-with ob sub) sub)) (defn mouse-position-deltas diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 611ba44f1..635d2afa6 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -13,6 +13,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.uuid :as uuid] + [app.common.spec :as us] [app.main.data.auth :refer [logout]] [app.main.data.messages :as dm] [app.main.refs :as refs] @@ -20,6 +21,8 @@ [app.main.ui.auth :refer [auth]] [app.main.ui.auth.verify-token :refer [verify-token]] [app.main.ui.cursors :as c] + [app.main.ui.context :as ctx] + [app.main.ui.onboarding] [app.main.ui.dashboard :refer [dashboard]] [app.main.ui.icons :as i] [app.main.ui.messages :as msgs] @@ -27,10 +30,11 @@ [app.main.ui.settings :as settings] [app.main.ui.static :refer [not-found-page not-authorized-page]] [app.main.ui.viewer :refer [viewer-page]] - [app.main.ui.viewer.handoff :refer [handoff]] + [app.main.ui.handoff :refer [handoff]] [app.main.ui.workspace :as workspace] [app.util.i18n :as i18n :refer [tr t]] [app.util.timers :as ts] + [app.util.router :as rt] [cuerdas.core :as str] [cljs.spec.alpha :as s] [expound.alpha :as expound] @@ -39,6 +43,19 @@ ;; --- Routes +(s/def ::page-id ::us/uuid) +(s/def ::file-id ::us/uuid) +(s/def ::viewer-path-params + (s/keys :req-un [::file-id ::page-id])) + +(s/def ::section ::us/keyword) +(s/def ::index ::us/integer) +(s/def ::token (s/nilable ::us/string)) + +(s/def ::viewer-query-params + (s/keys :req-un [::index] + :opt-un [::token ::section])) + (def routes [["/auth" ["/login" :auth-login] @@ -53,8 +70,17 @@ ["/password" :settings-password] ["/options" :settings-options]] - ["/view/:file-id/:page-id" :viewer] - ["/handoff/:file-id/:page-id" :handoff] + ["/view/:file-id/:page-id" + {:name :viewer + :conform + {:path-params ::viewer-path-params + :query-params ::viewer-query-params}}] + + ["/handoff/:file-id/:page-id" + {:name :handoff + :conform {:path-params ::viewer-path-params + :query-params ::viewer-query-params}}] + ["/not-found" :not-found] ["/not-authorized" :not-authorized] @@ -86,84 +112,91 @@ (mf/defc app {::mf/wrap [#(mf/catch % {:fallback app-error})]} [{:keys [route] :as props}] - (case (get-in route [:data :name]) - (:auth-login - :auth-register - :auth-goodbye - :auth-recovery-request - :auth-recovery) - [:& auth {:route route}] - :auth-verify-token - [:& verify-token {:route route}] + [:& (mf/provider ctx/current-route) {:value route} + (case (get-in route [:data :name]) + (:auth-login + :auth-register + :auth-goodbye + :auth-recovery-request + :auth-recovery) + [:& auth {:route route}] - (:settings-profile - :settings-password - :settings-options) - [:& settings/settings {:route route}] + :auth-verify-token + [:& verify-token {:route route}] - :debug-icons-preview - (when *assert* - [:div.debug-preview - [:h1 "Cursors"] - [:& c/debug-preview] - [:h1 "Icons"] - [:& i/debug-icons-preview] - ]) + (:settings-profile + :settings-password + :settings-options) + [:& settings/settings {:route route}] - (:dashboard-search - :dashboard-projects - :dashboard-files - :dashboard-libraries - :dashboard-team-members - :dashboard-team-settings) - [:& dashboard {:route route}] + :debug-icons-preview + (when *assert* + [:div.debug-preview + [:h1 "Cursors"] + [:& c/debug-preview] + [:h1 "Icons"] + [:& i/debug-icons-preview] + ]) - :viewer - (let [index (d/parse-integer (get-in route [:params :query :index])) - token (get-in route [:params :query :token]) - file-id (uuid (get-in route [:params :path :file-id])) - page-id (uuid (get-in route [:params :path :page-id]))] - [:& viewer-page {:page-id page-id - :file-id file-id - :index index - :token token}]) + (:dashboard-search + :dashboard-projects + :dashboard-files + :dashboard-libraries + :dashboard-team-members + :dashboard-team-settings) + [:& dashboard {:route route}] - :handoff - (let [index (d/parse-integer (get-in route [:params :query :index])) - file-id (uuid (get-in route [:params :path :file-id])) - page-id (uuid (get-in route [:params :path :page-id]))] - [:& handoff {:page-id page-id - :file-id file-id - :index index}]) + :viewer + (let [index (get-in route [:query-params :index]) + token (get-in route [:query-params :token]) + section (get-in route [:query-params :section] :interactions) + file-id (get-in route [:path-params :file-id]) + page-id (get-in route [:path-params :page-id])] + [:& viewer-page {:page-id page-id + :file-id file-id + :section section + :index index + :token token}]) - :render-object - (do - (let [file-id (uuid (get-in route [:params :path :file-id])) - page-id (uuid (get-in route [:params :path :page-id])) - object-id (uuid (get-in route [:params :path :object-id]))] - [:& render/render-object {:file-id file-id - :page-id page-id - :object-id object-id}])) + :handoff + (let [file-id (get-in route [:path-params :file-id]) + page-id (get-in route [:path-params :page-id]) + index (get-in route [:query-params :index]) + token (get-in route [:query-params :token])] - :workspace - (let [project-id (uuid (get-in route [:params :path :project-id])) - file-id (uuid (get-in route [:params :path :file-id])) - page-id (uuid (get-in route [:params :query :page-id])) - layout-name (get-in route [:params :query :layout])] - [:& workspace/workspace {:project-id project-id - :file-id file-id - :page-id page-id - :layout-name (keyword layout-name) - :key file-id}]) + [:& handoff {:page-id page-id + :file-id file-id + :index index + :token token}]) - :not-authorized - [:& not-authorized-page] + :render-object + (do + (let [file-id (uuid (get-in route [:path-params :file-id])) + page-id (uuid (get-in route [:path-params :page-id])) + object-id (uuid (get-in route [:path-params :object-id]))] + [:& render/render-object {:file-id file-id + :page-id page-id + :object-id object-id}])) - :not-found - [:& not-found-page] + :workspace + (let [project-id (uuid (get-in route [:params :path :project-id])) + file-id (uuid (get-in route [:params :path :file-id])) + page-id (uuid (get-in route [:params :query :page-id])) + layout-name (get-in route [:params :query :layout])] + [:& workspace/workspace {:project-id project-id + :file-id file-id + :page-id page-id + :layout-name (keyword layout-name) + :key file-id}]) - nil)) + :not-authorized + [:& not-authorized-page] + + :not-found + [:& not-found-page] + + nil)]) (mf/defc app-wrapper [] @@ -178,7 +211,7 @@ (defmethod ptk/handle-error :validation [error] (ts/schedule - (st/emitf (dm/show {:content "Unexpected validation error." + (st/emitf (dm/show {:content "Unexpected validation error (server side)." :type :error :timeout 5000}))) (when-let [explain (:explain error)] @@ -190,11 +223,15 @@ (defmethod ptk/handle-error :authentication [error] - (ts/schedule 0 #(st/emit! logout))) + (ts/schedule 0 #(st/emit! (logout)))) (defmethod ptk/handle-error :authorization [error] - (ts/schedule 0 #(st/emit! logout))) + (st/emit! (rt/nav :login)) + (ts/schedule + (st/emitf (dm/show {:content "Not authorized to see this content." + :timeout 5000 + :type :error})))) (defmethod ptk/handle-error :assertion [{:keys [data stack message context] :as error}] @@ -227,7 +264,7 @@ :type :error :timeout 5000})))))) -(defmethod ptk/handle-error :internal-error +(defmethod ptk/handle-error :server-error [{:keys [status] :as error}] (cond (= status 429) @@ -241,6 +278,13 @@ (st/emitf (dm/show {:content "Unable to connect to backend, wait a little bit and refresh." :type :error}))))) + +(defmethod ptk/handle-error :not-found + [{:keys [status] :as error}] + (ts/schedule + (st/emitf (dm/show {:content "Resource not found." + :type :warning})))) + (defonce uncaught-error-handler (letfn [(on-error [event] (ptk/handle-error (unchecked-get event "error")) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 81674ebf1..756cd3b5b 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -133,4 +133,4 @@ [:span (t locale "auth.create-demo-profile") " "] [:a {:on-click #(st/emit! da/create-demo-profile) :tab-index "5"} - (t locale "auth.create-demo-profile")]]]]) + (t locale "auth.create-demo-account")]]]]) \ No newline at end of file diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs new file mode 100644 index 000000000..3d989b091 --- /dev/null +++ b/frontend/src/app/main/ui/comments.cljs @@ -0,0 +1,405 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.comments + (:require + [app.config :as cfg] + [app.main.data.comments :as dcm] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.streams :as ms] + [app.main.ui.context :as ctx] + [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.data.modal :as modal] + [app.main.ui.icons :as i] + [app.main.ui.keyboard :as kbd] + [app.util.time :as dt] + [app.util.dom :as dom] + [app.util.object :as obj] + [app.util.i18n :as i18n :refer [t tr]] + [cuerdas.core :as str] + [okulary.core :as l] + [rumext.alpha :as mf])) + +(mf/defc resizing-textarea + {::mf/wrap-props false} + [props] + (let [value (obj/get props "value" "") + on-focus (obj/get props "on-focus") + on-blur (obj/get props "on-blur") + placeholder (obj/get props "placeholder") + on-change (obj/get props "on-change") + on-esc (obj/get props "on-esc") + autofocus? (obj/get props "autofocus") + + ref (mf/use-ref) + + on-key-down + (mf/use-callback + (fn [event] + (when (and (kbd/esc? event) + (fn? on-esc)) + (on-esc event)))) + + on-change* + (mf/use-callback + (mf/deps on-change) + (fn [event] + (let [content (dom/get-target-val event)] + (on-change content))))] + + + (mf/use-layout-effect + nil + (fn [] + (let [node (mf/ref-val ref)] + (set! (.-height (.-style node)) "0") + (set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px"))))) + + [:textarea + {:ref ref + :auto-focus autofocus? + :on-key-down on-key-down + :on-focus on-focus + :on-blur on-blur + :value value + :placeholder placeholder + :on-change on-change*}])) + +(mf/defc reply-form + [{:keys [thread] :as props}] + (let [show-buttons? (mf/use-state false) + content (mf/use-state "") + + on-focus + (mf/use-callback + #(reset! show-buttons? true)) + + on-blur + (mf/use-callback + #(reset! show-buttons? false)) + + on-change + (mf/use-callback + #(reset! content %)) + + on-cancel + (mf/use-callback + #(do (reset! content "") + (reset! show-buttons? false))) + + on-submit + (mf/use-callback + (mf/deps thread @content) + (fn [] + (st/emit! (dcm/add-comment thread @content)) + (on-cancel)))] + + [:div.reply-form + [:& resizing-textarea {:value @content + :placeholder "Reply" + :on-blur on-blur + :on-focus on-focus + :on-change on-change}] + (when (or @show-buttons? + (not (empty? @content))) + [:div.buttons + [:input.btn-primary {:type "button" :value "Post" :on-click on-submit}] + [:input.btn-secondary {:type "button" :value "Cancel" :on-click on-cancel}]])])) + +(mf/defc draft-thread + [{:keys [draft zoom on-cancel on-submit] :as props}] + (let [position (:position draft) + content (:content draft) + pos-x (* (:x position) zoom) + pos-y (* (:y position) zoom) + + on-esc + (mf/use-callback + (mf/deps draft) + (fn [event] + (dom/stop-propagation event) + (if (fn? on-cancel) + (on-cancel) + (st/emit! :interrupt)))) + + on-change + (mf/use-callback + (mf/deps draft) + (fn [content] + (st/emit! (dcm/update-draft-thread {:content content})))) + + on-submit + (mf/use-callback + (mf/deps draft) + (partial on-submit draft))] + + [:* + [:div.thread-bubble + {:style {:top (str pos-y "px") + :left (str pos-x "px")} + :on-click dom/stop-propagation} + [:span "?"]] + [:div.thread-content + {:style {:top (str (- pos-y 14) "px") + :left (str (+ pos-x 14) "px")} + :on-click dom/stop-propagation} + [:div.reply-form + [:& resizing-textarea {:placeholder (tr "labels.write-new-comment") + :value (or content "") + :autofocus true + :on-esc on-esc + :on-change on-change}] + [:div.buttons + [:input.btn-primary + {:on-click on-submit + :type "button" + :value "Post"}] + [:input.btn-secondary + {:on-click on-esc + :type "button" + :value "Cancel"}]]]]])) + +(mf/defc edit-form + [{:keys [content on-submit on-cancel] :as props}] + (let [content (mf/use-state content) + + on-change + (mf/use-callback + #(reset! content %)) + + on-submit* + (mf/use-callback + (mf/deps @content) + (fn [] (on-submit @content)))] + + [:div.reply-form.edit-form + [:& resizing-textarea {:value @content + :on-change on-change}] + [:div.buttons + [:input.btn-primary {:type "button" :value "Post" :on-click on-submit*}] + [:input.btn-secondary {:type "button" :value "Cancel" :on-click on-cancel}]]])) + +(mf/defc comment-item + [{:keys [comment thread users] :as props}] + (let [owner (get users (:owner-id comment)) + profile (mf/deref refs/profile) + options (mf/use-state false) + edition? (mf/use-state false) + + on-show-options + (mf/use-callback #(reset! options true)) + + on-hide-options + (mf/use-callback #(reset! options false)) + + on-edit-clicked + (mf/use-callback + (fn [] + (reset! options false) + (reset! edition? true))) + + on-delete-comment + (mf/use-callback + (mf/deps comment) + (st/emitf (dcm/delete-comment comment))) + + delete-thread + (mf/use-callback + (mf/deps thread) + (st/emitf (dcm/close-thread) + (dcm/delete-comment-thread thread))) + + + on-delete-thread + (mf/use-callback + (mf/deps thread) + (st/emitf (modal/show + {:type :confirm + :title (tr "modals.delete-comment-thread.title") + :message (tr "modals.delete-comment-thread.message") + :accept-label (tr "modals.delete-comment-thread.accept") + :on-accept delete-thread}))) + + on-submit + (mf/use-callback + (mf/deps comment thread) + (fn [content] + (reset! edition? false) + (st/emit! (dcm/update-comment (assoc comment :content content))))) + + on-cancel + (mf/use-callback #(reset! edition? false)) + + toggle-resolved + (mf/use-callback + (mf/deps thread) + (fn [event] + (dom/stop-propagation event) + (st/emit! (dcm/update-comment-thread (update thread :is-resolved not)))))] + + [:div.comment-container + [:div.comment + [:div.author + [:div.avatar + [:img {:src (cfg/resolve-media-path (:photo owner))}]] + [:div.name + [:div.fullname (:fullname owner)] + [:div.timeago (dt/timeago (:modified-at comment))]] + + (when (some? thread) + [:div.options-resolve {:on-click toggle-resolved} + (if (:is-resolved thread) + [:span i/checkbox-checked] + [:span i/checkbox-unchecked])]) + (when (= (:id profile) (:id owner)) + [:div.options + [:div.options-icon {:on-click on-show-options} i/actions]])] + + [:div.content + (if @edition? + [:& edit-form {:content (:content comment) + :on-submit on-submit + :on-cancel on-cancel}] + [:span.text (:content comment)])]] + + [:& dropdown {:show @options + :on-close on-hide-options} + [:ul.dropdown.comment-options-dropdown + [:li {:on-click on-edit-clicked} (tr "labels.edit")] + (if thread + [:li {:on-click on-delete-thread} (tr "labels.delete-comment-thread")] + [:li {:on-click on-delete-comment} (tr "labels.delete-comment")])]]])) + +(defn comments-ref + [{:keys [id] :as thread}] + (l/derived (l/in [:comments id]) st/state)) + +(mf/defc thread-comments + [{:keys [thread zoom users]}] + (let [ref (mf/use-ref) + pos (:position thread) + pos-x (+ (* (:x pos) zoom) 14) + pos-y (- (* (:y pos) zoom) 14) + + comments-ref (mf/use-memo (mf/deps thread) #(comments-ref thread)) + comments-map (mf/deref comments-ref) + comments (->> (vals comments-map) + (sort-by :created-at)) + comment (first comments)] + + (mf/use-layout-effect + (mf/deps thread) + (st/emitf (dcm/retrieve-comments (:id thread)))) + + (mf/use-effect + (mf/deps thread) + (st/emitf (dcm/update-comment-thread-status thread))) + + (mf/use-layout-effect + (mf/deps thread comments-map) + (fn [] + (when-let [node (mf/ref-val ref)] + (.scrollIntoViewIfNeeded ^js node)))) + + [:div.thread-content + {:style {:top (str pos-y "px") + :left (str pos-x "px")} + :on-click dom/stop-propagation} + + [:div.comments + [:& comment-item {:comment comment + :users users + :thread thread}] + (for [item (rest comments)] + [:* + [:hr] + [:& comment-item {:comment item :users users}]]) + [:div {:ref ref}]] + [:& reply-form {:thread thread}]])) + +(mf/defc thread-bubble + {::mf/wrap [mf/memo]} + [{:keys [thread zoom open? on-click] :as params}] + (let [pos (:position thread) + pos-x (* (:x pos) zoom) + pos-y (* (:y pos) zoom) + on-click* (fn [event] + (dom/stop-propagation event) + (on-click thread))] + + [:div.thread-bubble + {:style {:top (str pos-y "px") + :left (str pos-x "px")} + :on-mouse-down (fn [event] + (dom/prevent-default event)) + :class (dom/classnames + :resolved (:is-resolved thread) + :unread (pos? (:count-unread-comments thread))) + :on-click on-click*} + [:span (:seqn thread)]])) + +(mf/defc comment-thread + [{:keys [item users on-click] :as props}] + (let [owner (get users (:owner-id item)) + + on-click* + (mf/use-callback + (mf/deps item) + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (when (fn? on-click) + (on-click item))))] + + [:div.comment {:on-click on-click*} + [:div.author + [:div.thread-bubble + {:class (dom/classnames + :resolved (:is-resolved item) + :unread (pos? (:count-unread-comments item)))} + (:seqn item)] + [:div.avatar + [:img {:src (cfg/resolve-media-path (:photo owner))}]] + [:div.name + [:div.fullname (:fullname owner) ", "] + [:div.timeago (dt/timeago (:modified-at item))]]] + [:div.content + [:span.text (:content item)]] + [:div.content.replies + (let [unread (:count-unread-comments item ::none) + total (:count-comments item 1)] + [:* + (when (> total 1) + (if (= total 2) + [:span.total-replies "1 reply"] + [:span.total-replies (str (dec total) " replies")])) + + (when (and (> total 1) (> unread 0)) + (if (= unread 1) + [:span.new-replies "1 new reply"] + [:span.new-replies (str unread " new replies")]))])]])) + +(mf/defc comment-thread-group + [{:keys [group users on-thread-click]}] + [:div.thread-group + (if (:file-name group) + [:div.section-title + [:span.label.filename (:file-name group) ", "] + [:span.label (:page-name group)]] + [:div.section-title + [:span.icon i/file-html] + [:span.label (:page-name group)]]) + [:div.threads + (for [item (:items group)] + [:& comment-thread + {:item item + :on-click on-thread-click + :users users + :key (:id item)}])]]) diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index 9903c528e..db9013df3 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -31,11 +31,11 @@ on-mount (fn [] - (let [lkey1 (events/listen (dom/get-root) EventType.CLICK on-click) - lkey2 (events/listen (dom/get-root) EventType.KEYUP on-keyup)] - #(do - (events/unlistenByKey lkey1) - (events/unlistenByKey lkey2))))] + (let [keys [(events/listen js/document EventType.CLICK on-click) + (events/listen js/document EventType.CONTEXTMENU on-click) + (events/listen js/document EventType.KEYUP on-keyup)]] + #(doseq [key keys] + (events/unlistenByKey key))))] (mf/use-effect on-mount) children)) diff --git a/frontend/src/app/main/ui/components/fullscreen.cljs b/frontend/src/app/main/ui/components/fullscreen.cljs new file mode 100644 index 000000000..c7cf120d2 --- /dev/null +++ b/frontend/src/app/main/ui/components/fullscreen.cljs @@ -0,0 +1,57 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.components.fullscreen + (:require + [app.util.dom :as dom] + [app.util.webapi :as wapi] + [beicon.core :as rx] + [rumext.alpha :as mf])) + +(def fullscreen-context + (mf/create-context)) + +(mf/defc fullscreen-wrapper + [{:keys [children] :as props}] + (let [container (mf/use-ref) + state (mf/use-state (dom/fullscreen?)) + + change + (mf/use-callback + (fn [event] + (let [val (dom/fullscreen?)] + (reset! state val)))) + + manager + (mf/use-memo + (mf/deps @state) + (fn [] + (specify! state + cljs.core/IFn + (-invoke + ([it val] + (if val + (wapi/request-fullscreen (mf/ref-val container)) + (wapi/exit-fullscreen)))))))] + + ;; NOTE: the user interaction with F11 keyboard hot-key does not + ;; emits the `fullscreenchange` event; that event is emmited only + ;; when API is used. There are no way to detect the F11 behavior + ;; in a uniform cross browser way. + + (mf/use-effect + (fn [] + (.addEventListener js/document "fullscreenchange" change) + (fn [] + (.removeEventListener js/document "fullscreenchange" change)))) + + [:div.fulllscreen-wrapper {:ref container :class (dom/classnames :fullscreen @state)} + [:& (mf/provider fullscreen-context) {:value manager} + children]])) + diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs new file mode 100644 index 000000000..d2c005729 --- /dev/null +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -0,0 +1,48 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.components.numeric-input + (:require + [rumext.alpha :as mf] + [app.main.ui.keyboard :as kbd] + [app.common.data :as d] + [app.util.dom :as dom] + [app.util.object :as obj])) + +(mf/defc numeric-input + {::mf/wrap-props false + ::mf/forward-ref true} + [props ref] + (let [on-key-down + (mf/use-callback + (fn [event] + (when (and (or (kbd/up-arrow? event) (kbd/down-arrow? event)) + (kbd/shift? event)) + (let [increment (if (kbd/up-arrow? event) 9 -9) ; this is added to the + target (dom/get-target event) ; default 1 or -1 step + min-value (-> (dom/get-attribute target "min") + (d/parse-integer ##-Inf)) + max-value (-> (dom/get-attribute target "max") + (d/parse-integer ##Inf)) + new-value (-> target + (dom/get-value) + (d/parse-integer 0) + (+ increment) + (cljs.core/min max-value) + (cljs.core/max min-value))] + (dom/set-value! target new-value))))) + + props (-> props + (obj/set! "className" "input-text") + (obj/set! "type" "number") + (obj/set! "ref" ref) + (obj/set! "onKeyDown" on-key-down))] + + [:> :input props])) + diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index d791de0f4..73bf41bcc 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -64,7 +64,7 @@ (dom/stop-propagation event) (st/emit! (modal/hide)) (on-accept props)))) - key (events/listen (dom/get-root) EventType.KEYDOWN on-keydown)] + key (events/listen js/document EventType.KEYDOWN on-keydown)] #(events/unlistenByKey key)))) [:div.modal-overlay diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 11abc5fdb..7e063282b 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -14,6 +14,7 @@ (def embed-ctx (mf/create-context false)) (def render-ctx (mf/create-context nil)) +(def current-route (mf/create-context nil)) (def current-team-id (mf/create-context nil)) (def current-project-id (mf/create-context nil)) (def current-page-id (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/cursors.clj b/frontend/src/app/main/ui/cursors.clj index 414297d11..5360356e2 100644 --- a/frontend/src/app/main/ui/cursors.clj +++ b/frontend/src/app/main/ui/cursors.clj @@ -8,17 +8,17 @@ ;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.ui.cursors - (:import java.net.URLEncoder) - (:require [rumext.alpha] - [clojure.java.io :as io] - [lambdaisland.uri.normalize :as uri] - [cuerdas.core :as str])) + (:require + [clojure.java.io :as io] + [cuerdas.core :as str] + [lambdaisland.uri.normalize :as uri])) (def cursor-folder "images/cursors") (def default-hotspot-x 12) (def default-hotspot-y 12) (def default-rotation 0) +(def default-height 20) (defn parse-svg [svg-data] (-> svg-data @@ -53,25 +53,27 @@ (str/replace #"\s+$" ""))) (defn encode-svg-cursor - [id rotation x y] - (let [svg-path (str cursor-folder "/" (name id) ".svg") - data (-> svg-path io/resource slurp parse-svg uri/percent-encode) - transform (if rotation (str " transform='rotate(" rotation ")'") "") - data (clojure.pprint/cl-format - nil - "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20px' height='20px'~A%3E~A%3C/svg%3E\") ~A ~A, auto" - transform data x y)] - data)) + [id rotation x y height] + (let [svg-path (str cursor-folder "/" (name id) ".svg") + data (-> svg-path io/resource slurp parse-svg) + data (uri/percent-encode data) + + data (if rotation + (str/fmt "%3Cg transform='rotate(%s 8,8)'%3E%s%3C/g%3E" rotation data) + data)] + (str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20px' " + "height='" height "px' %3E" data "%3C/svg%3E\") " x " " y ", auto"))) (defmacro cursor-ref "Creates a static cursor given its name, rotation and x/y hotspot" - ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y)) - ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y)) - ([id rotation x y] (encode-svg-cursor id rotation x y))) + ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height)) + ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height)) + ([id rotation x y] (encode-svg-cursor id rotation x y default-height)) + ([id rotation x y height] (encode-svg-cursor id rotation x y height))) (defmacro cursor-fn "Creates a dynamic cursor that can be rotated in runtime" [id initial] - (let [cursor (encode-svg-cursor id "{{rotation}}" default-hotspot-x default-hotspot-y)] + (let [cursor (encode-svg-cursor id "{{rotation}}" default-hotspot-x default-hotspot-y default-height)] `(fn [rot#] (str/replace ~cursor "{{rotation}}" (+ ~initial rot#))))) diff --git a/frontend/src/app/main/ui/cursors.cljs b/frontend/src/app/main/ui/cursors.cljs index f7dd21fed..46a1b5d2e 100644 --- a/frontend/src/app/main/ui/cursors.cljs +++ b/frontend/src/app/main/ui/cursors.cljs @@ -8,12 +8,13 @@ ;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.ui.cursors - (:require-macros [app.main.ui.cursors :refer [cursor-ref - cursor-fn]]) + (:require-macros [app.main.ui.cursors :refer [cursor-ref cursor-fn]]) (:require [rumext.alpha :as mf] [cuerdas.core :as str] [app.util.timers :as ts])) +;; Static cursors +(def comments (cursor-ref :comments 0 2 20)) (def create-artboard (cursor-ref :create-artboard)) (def create-ellipse (cursor-ref :create-ellipse)) (def create-polygon (cursor-ref :create-polygon)) @@ -22,17 +23,23 @@ (def duplicate (cursor-ref :duplicate 0 0 0)) (def hand (cursor-ref :hand)) (def move-pointer (cursor-ref :move-pointer)) -(def pencil (cursor-ref :pencil 0 0 24)) (def pen (cursor-ref :pen 0 0 0)) -(def pointer-inner (cursor-ref :pointer-inner 0 0 0)) -(def resize-alt (cursor-ref :resize-alt)) -(def resize-nesw (cursor-fn :resize-h 45)) -(def resize-nwse (cursor-fn :resize-h 135)) -(def resize-ew (cursor-fn :resize-h 0)) -(def resize-ns (cursor-fn :resize-h 90)) -(def rotate (cursor-fn :rotate 90)) -(def text (cursor-ref :text)) +(def pen-node (cursor-ref :pen-node 0 0 10 36)) +(def pencil (cursor-ref :pencil 0 0 24)) (def picker (cursor-ref :picker 0 0 24)) +(def pointer-inner (cursor-ref :pointer-inner 0 0 0)) +(def pointer-move (cursor-ref :pointer-move 0 0 10 42)) +(def pointer-node (cursor-ref :pointer-node 0 0 10 32)) +(def resize-alt (cursor-ref :resize-alt)) +(def text (cursor-ref :text)) + +;; Dynamic cursors +(def resize-ew (cursor-fn :resize-h 0)) +(def resize-nesw (cursor-fn :resize-h 45)) +(def resize-ns (cursor-fn :resize-h 90)) +(def resize-nwse (cursor-fn :resize-h 135)) +(def rotate (cursor-fn :rotate 90)) + (mf/defc debug-preview {::mf/wrap-props false} @@ -49,7 +56,9 @@ [:div {:style {:width "100px" :height "100px" :background-image (-> value (str/replace #"(url\(.*\)).*" "$1")) - :background-size "cover" + :background-size "contain" + :background-repeat "no-repeat" + :background-position "center" :cursor value}}] [:span {:style {:white-space "nowrap" diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index d1abccf4e..6c5350f0c 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -13,6 +13,7 @@ [app.common.spec :as us] [app.common.uuid :as uuid] [app.main.data.dashboard :as dd] + [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.files :refer [files-section]] @@ -24,6 +25,7 @@ [app.main.ui.icons :as i] [app.util.i18n :as i18n :refer [t]] [app.util.router :as rt] + [app.util.storage :refer [storage]] [cuerdas.core :as str] [okulary.core :as l] [rumext.alpha :as mf])) @@ -101,6 +103,11 @@ projects (mf/deref projects-ref) project (get projects project-id)] + (mf/use-effect + (fn [] + (when (and profile (not (get-in profile [:props :onboarding-viewed]))) + (st/emit! (modal/show {:type :onboarding}))))) + (mf/use-effect (mf/deps team-id) (st/emitf (dd/fetch-bundle {:id team-id}))) diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs new file mode 100644 index 000000000..dfc404556 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -0,0 +1,96 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.dashboard.comments + (:require + [okulary.core :as l] + [app.common.data :as d] + [app.common.spec :as us] + [app.config :as cfg] + [app.main.data.auth :as da] + [app.main.data.dashboard :as dd] + [app.main.data.workspace :as dw] + [app.main.data.workspace.comments :as dwcm] + [app.main.data.comments :as dcm] + [app.main.refs :as refs] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.comments :as cmt] + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [t tr]] + [app.util.object :as obj] + [app.util.router :as rt] + [app.util.time :as dt] + [app.util.timers :as tm] + [beicon.core :as rx] + [cljs.spec.alpha :as s] + [cuerdas.core :as str] + [rumext.alpha :as mf])) + +(mf/defc comments-section + [{:keys [profile team]}] + (mf/use-effect + (mf/deps team) + (st/emitf (dcm/retrieve-unread-comment-threads (:id team)))) + + (let [show-dropdown? (mf/use-state false) + show-dropdown (mf/use-fn #(reset! show-dropdown? true)) + hide-dropdown (mf/use-fn #(reset! show-dropdown? false)) + threads-map (mf/deref refs/comment-threads) + users (mf/deref refs/users) + + tgroups (->> (vals threads-map) + (sort-by :modified-at) + (reverse) + (dcm/apply-filters {} profile) + (dcm/group-threads-by-file-and-page)) + + on-navigate + (mf/use-callback + (fn [thread] + (st/emit! (dwcm/navigate thread))))] + + [:div.dashboard-comments-section + [:div.button + {:on-click show-dropdown + :class (dom/classnames :open @show-dropdown? + :unread (boolean (seq tgroups)))} + i/chat] + + [:& dropdown {:show @show-dropdown? :on-close hide-dropdown} + [:div.dropdown.comments-section.comment-threads-section. + [:div.header + [:h3 (tr "labels.comments")] + [:span.close {:on-click hide-dropdown} i/close]] + + [:hr] + + (if (seq tgroups) + [:div.thread-groups + [:& cmt/comment-thread-group + {:group (first tgroups) + :on-thread-click on-navigate + :show-file-name true + :users users}] + (for [tgroup (rest tgroups)] + [:* + [:hr] + + [:& cmt/comment-thread-group + {:group tgroup + :on-thread-click on-navigate + :show-file-name true + :users users + :key (:page-id tgroup)}]])] + + [:div.thread-groups-placeholder + i/chat + (tr "labels.no-comments-available")])]]])) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index c4ceed6da..193c72b3b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -15,21 +15,24 @@ [app.main.data.auth :as da] [app.main.data.dashboard :as dd] [app.main.data.messages :as dm] + [app.main.data.modal :as modal] + [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.components.forms :as fm] + [app.main.ui.dashboard.comments :refer [comments-section]] + [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.team-form] [app.main.ui.icons :as i] [app.main.ui.keyboard :as kbd] - [app.main.data.modal :as modal] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] [app.util.object :as obj] [app.util.router :as rt] [app.util.time :as dt] + [app.util.avatars :as avatars] [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] @@ -133,7 +136,8 @@ (mf/deps (:id team)) (fn [] (->> (rp/query! :teams) - (rx/map #(mapv dd/assoc-team-avatar %)) + (rx/map (fn [teams] + (mapv #(avatars/assoc-avatar % :name) teams))) (rx/subs #(reset! teams %))))) [:ul.dropdown.teams-dropdown @@ -318,7 +322,7 @@ [:div.sidebar-team-switch [:div.switch-content - [:div.current-team + [:div.current-team {:on-click #(reset! show-teams-ddwn? true)} (if (:is-default team) [:div.team-name [:span.team-icon i/logo-icon] @@ -328,7 +332,7 @@ [:img {:src (cfg/resolve-media-path (:photo team))}]] [:span.team-text {:title (:name team)} (:name team)]]) - [:span.switch-icon {:on-click #(reset! show-teams-ddwn? true)} + [:span.switch-icon i/arrow-down]] (when-not (:is-default team) @@ -421,12 +425,9 @@ (mf/defc profile-section - [{:keys [profile locale] :as props}] + [{:keys [profile locale team] :as props}] (let [show (mf/use-state false) - photo (:photo-uri profile "") - photo (if (str/empty? photo) - "/images/avatar.jpg" - photo) + photo (cfg/resolve-media-path (:photo profile)) on-click (mf/use-callback @@ -436,10 +437,10 @@ (st/emit! (rt/nav section)) (st/emit! section))))] - [:div.profile-section {:on-click #(reset! show true)} - [:img {:src photo}] - [:span (:fullname profile)] - i/arrow-down + [:div.profile-section + [:div.profile {:on-click #(reset! show true)} + [:img {:src photo}] + [:span (:fullname profile)] [:& dropdown {:on-close #(reset! show false) :show @show} @@ -450,19 +451,27 @@ [:li {:on-click (partial on-click :settings-password)} [:span.icon i/lock] [:span.text (t locale "labels.password")]] - [:li {:on-click (partial on-click da/logout)} + [:li {:on-click (partial on-click (da/logout))} [:span.icon i/exit] - [:span.text (t locale "labels.logout")]]]]])) + [:span.text (t locale "labels.logout")]]]]] + + (when (and team profile) + [:& comments-section {:profile profile + :team team}])])) (mf/defc sidebar {::mf/wrap-props false ::mf/wrap [mf/memo]} [props] (let [locale (mf/deref i18n/locale) + team (obj/get props "team") profile (obj/get props "profile") props (-> (obj/clone props) (obj/set! "locale" locale))] [:div.dashboard-sidebar [:div.sidebar-inside [:> sidebar-content props] - [:& profile-section {:profile profile :locale locale}]]])) + [:& profile-section + {:profile profile + :team team + :locale locale}]]])) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 02cee2800..fc3039e5f 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -25,7 +25,7 @@ [app.main.ui.dashboard.team-form] [app.main.ui.icons :as i] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [t tr]] + [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] [app.util.time :as dt] [cljs.spec.alpha :as s] @@ -34,7 +34,7 @@ (mf/defc header {::mf/wrap [mf/memo]} - [{:keys [section locale team] :as props}] + [{:keys [section team] :as props}] (let [go-members (mf/use-callback (mf/deps team) @@ -57,19 +57,19 @@ [:header.dashboard-header [:div.dashboard-title [:h1 (cond - members-section? (t locale "labels.members") - settings-section? (t locale "labels.settings") + members-section? (tr "labels.members") + settings-section? (tr "labels.settings") nil)]] [:nav [:ul [:li {:class (when members-section? "active")} - [:a {:on-click go-members} (t locale "labels.members")]] + [:a {:on-click go-members} (tr "labels.members")]] [:li {:class (when settings-section? "active")} - [:a {:on-click go-settings} (t locale "labels.settings")]]]] + [:a {:on-click go-settings} (tr "labels.settings")]]]] (if members-section? [:a.btn-secondary.btn-small {:on-click invite-member} - (t locale "dashboard.invite-profile")] + (tr "dashboard.invite-profile")] [:div])])) (s/def ::email ::us/email) @@ -220,13 +220,12 @@ [:& team-member {:member item :team team :profile profile :key (:id item)}])]])) (defn- members-ref - [team-id] - (l/derived (l/in [:team-members team-id]) st/state)) + [{:keys [id] :as team}] + (l/derived (l/in [:team-members id]) st/state)) (mf/defc team-members-page [{:keys [team profile] :as props}] - (let [locale (mf/deref i18n/locale) - members-ref (mf/use-memo (mf/deps team) #(members-ref (:id team))) + (let [members-ref (mf/use-memo (mf/deps team) #(members-ref team)) members-map (mf/deref members-ref)] (mf/use-effect @@ -234,24 +233,30 @@ (st/emitf (dd/fetch-team-members team))) [:* - [:& header {:locale locale - :section :dashboard-team-members + [:& header {:section :dashboard-team-members :team team}] [:section.dashboard-container.dashboard-team-members - [:& team-members {:locale locale - :profile profile + [:& team-members {:profile profile :team team :members-map members-map}]]])) +(defn- stats-ref + [{:keys [id] :as team}] + (l/derived (l/in [:team-stats id]) st/state)) (mf/defc team-settings-page [{:keys [team profile] :as props}] - (let [locale (mf/deref i18n/locale) - finput (mf/use-ref) + (let [finput (mf/use-ref) - members-ref (mf/use-memo (mf/deps team) #(members-ref (:id team))) + members-ref (mf/use-memo (mf/deps team) #(members-ref team)) members-map (mf/deref members-ref) + owner (->> (vals members-map) + (d/seek :is-owner)) + + stats-ref (mf/use-memo (mf/deps team) #(stats-ref team)) + stats (mf/deref stats-ref) + on-image-click (mf/use-callback #(dom/click (mf/ref-val finput))) @@ -264,17 +269,17 @@ (mf/use-effect (mf/deps team) - (st/emitf (dd/fetch-team-members team))) + (st/emitf (dd/fetch-team-members team) + (dd/fetch-team-stats team))) [:* - [:& header {:locale locale - :section :dashboard-team-settings + [:& header {:section :dashboard-team-settings :team team}] [:section.dashboard-container.dashboard-team-settings [:div.team-settings [:div.horizontal-blocks [:div.block.info-block - [:div.label (t locale "dashboard.team-info")] + [:div.label (tr "dashboard.team-info")] [:div.name (:name team)] [:div.icon [:span.update-overlay {:on-click on-image-click} i/exit] @@ -285,19 +290,19 @@ :on-selected on-file-selected}]]] [:div.block.owner-block - [:div.label (t locale "dashboard.team-members")] + [:div.label (tr "dashboard.team-members")] [:div.owner - [:span.icon [:img {:src (cfg/resolve-media-path (:photo-uri profile))}]] - [:span.text (str (:fullname profile) " (" (t locale "labels.owner") ")") ]] + [:span.icon [:img {:src (cfg/resolve-media-path (:photo owner))}]] + [:span.text (str (:name owner) " (" (tr "labels.owner") ")") ]] [:div.summary [:span.icon i/user] - [:span.text (t locale "dashboard.num-of-members" (count members-map))]]] + [:span.text (tr "dashboard.num-of-members" (count members-map))]]] [:div.block.stats-block - [:div.label (t locale "dashboard.team-projects")] + [:div.label (tr "dashboard.team-projects")] [:div.projects [:span.icon i/folder] - [:span.text "4 projects"]] + [:span.text (tr "labels.num-of-projects" (i18n/c (dec (:projects stats))))]] [:div.files [:span.icon i/file-html] - [:span.text "4 files"]]]]]]])) + [:span.text (tr "labels.num-of-files" (i18n/c (:files stats)))]]]]]]])) diff --git a/frontend/src/app/main/ui/viewer/handoff.cljs b/frontend/src/app/main/ui/handoff.cljs similarity index 55% rename from frontend/src/app/main/ui/viewer/handoff.cljs rename to frontend/src/app/main/ui/handoff.cljs index fc06143f4..f2d2ebb44 100644 --- a/frontend/src/app/main/ui/viewer/handoff.cljs +++ b/frontend/src/app/main/ui/handoff.cljs @@ -7,28 +7,27 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff +(ns app.main.ui.handoff (:require - [rumext.alpha :as mf] - [beicon.core :as rx] - [goog.events :as events] - [okulary.core :as l] [app.common.exceptions :as ex] - [app.util.data :refer [classnames]] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [t tr]] [app.main.data.viewer :as dv] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.fullscreen :as fs] + [app.main.ui.handoff.left-sidebar :refer [left-sidebar]] + [app.main.ui.handoff.render :refer [render-frame-svg]] + [app.main.ui.handoff.right-sidebar :refer [right-sidebar]] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] [app.main.ui.keyboard :as kbd] [app.main.ui.viewer.header :refer [header]] [app.main.ui.viewer.thumbnails :refer [thumbnails-panel]] - [app.main.ui.viewer.handoff.render :refer [render-frame-svg]] - [app.main.ui.viewer.handoff.left-sidebar :refer [left-sidebar]] - [app.main.ui.viewer.handoff.right-sidebar :refer [right-sidebar]]) + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [t tr]] + [beicon.core :as rx] + [goog.events :as events] + [okulary.core :as l] + [rumext.alpha :as mf]) (:import goog.events.EventType)) (defn handle-select-frame [frame] @@ -37,7 +36,7 @@ (st/emit! (dv/select-shape (:id frame))))) (mf/defc render-panel - [{:keys [data local index page-id file-id]}] + [{:keys [data state index page-id file-id]}] (let [locale (mf/deref i18n/locale) frames (:frames data []) objects (:objects data) @@ -65,26 +64,23 @@ [:div.handoff-svg-wrapper {:on-click (handle-select-frame frame)} [:div.handoff-svg-container [:& render-frame-svg {:frame-id (:id frame) - :zoom (:zoom local) + :zoom (:zoom state) :objects objects}]]] [:& right-sidebar {:frame frame :page-id page-id :file-id file-id}]])])) (mf/defc handoff-content - [{:keys [data local index page-id file-id] :as props}] - - (let [container (mf/use-ref) - [toggle-fullscreen fullscreen?] (hooks/use-fullscreen container) - - on-mouse-wheel - (fn [event] - (when (kbd/ctrl? event) - (dom/prevent-default event) - (let [event (.getBrowserEvent ^js event)] - (if (pos? (.-deltaY ^js event)) - (st/emit! dv/decrease-zoom) - (st/emit! dv/increase-zoom))))) + [{:keys [data state index page-id file-id] :as props}] + (let [on-mouse-wheel + (mf/use-callback + (fn [event] + (when (kbd/ctrl? event) + (dom/prevent-default event) + (let [event (.getBrowserEvent ^js event)] + (if (pos? (.-deltaY ^js event)) + (st/emit! dv/decrease-zoom) + (st/emit! dv/increase-zoom)))))) on-mount (fn [] @@ -98,37 +94,39 @@ (mf/use-effect on-mount) (hooks/use-shortcuts dv/shortcuts) - [:div.handoff-layout {:class (classnames :fullscreen fullscreen?) - :ref container} - [:& header {:data data - :toggle-fullscreen toggle-fullscreen - :fullscreen? fullscreen? - :local local - :index index - :screen :handoff}] - [:div.viewer-content - (when (:show-thumbnails local) - [:& thumbnails-panel {:index index - :data data - :screen :handoff}]) - [:& render-panel {:data data - :local local - :index index - :page-id page-id - :file-id file-id}]]])) + [:& fs/fullscreen-wrapper {} + [:div.handoff-layout + [:& header + {:data data + :state state + :index index + :section :handoff}] + [:div.viewer-content + (when (:show-thumbnails state) + [:& thumbnails-panel {:index index + :data data + :screen :handoff}]) + [:& render-panel {:data data + :state state + :index index + :page-id page-id + :file-id file-id}]]]])) (mf/defc handoff - [{:keys [file-id page-id index] :as props}] + [{:keys [file-id page-id index token] :as props}] + (mf/use-effect - (mf/deps file-id page-id) + (mf/deps file-id page-id token) (fn [] (st/emit! (dv/initialize props)))) - (let [data (mf/deref refs/viewer-data) - local (mf/deref refs/viewer-local)] - (when data - [:& handoff-content {:file-id file-id - :page-id page-id - :index index - :local local - :data data}]))) + (let [data (mf/deref refs/viewer-data) + state (mf/deref refs/viewer-local)] + + (when (and data state) + [:& handoff-content + {:file-id file-id + :page-id page-id + :index index + :state state + :data data}]))) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs b/frontend/src/app/main/ui/handoff/attributes.cljs similarity index 71% rename from frontend/src/app/main/ui/viewer/handoff/attributes.cljs rename to frontend/src/app/main/ui/handoff/attributes.cljs index 4b29485eb..4cf91cafb 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs +++ b/frontend/src/app/main/ui/handoff/attributes.cljs @@ -7,19 +7,19 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes +(ns app.main.ui.handoff.attributes (:require [rumext.alpha :as mf] [app.util.i18n :as i18n] [app.common.geom.shapes :as gsh] - [app.main.ui.viewer.handoff.exports :refer [exports]] - [app.main.ui.viewer.handoff.attributes.layout :refer [layout-panel]] - [app.main.ui.viewer.handoff.attributes.fill :refer [fill-panel]] - [app.main.ui.viewer.handoff.attributes.stroke :refer [stroke-panel]] - [app.main.ui.viewer.handoff.attributes.shadow :refer [shadow-panel]] - [app.main.ui.viewer.handoff.attributes.blur :refer [blur-panel]] - [app.main.ui.viewer.handoff.attributes.image :refer [image-panel]] - [app.main.ui.viewer.handoff.attributes.text :refer [text-panel]])) + [app.main.ui.handoff.exports :refer [exports]] + [app.main.ui.handoff.attributes.layout :refer [layout-panel]] + [app.main.ui.handoff.attributes.fill :refer [fill-panel]] + [app.main.ui.handoff.attributes.stroke :refer [stroke-panel]] + [app.main.ui.handoff.attributes.shadow :refer [shadow-panel]] + [app.main.ui.handoff.attributes.blur :refer [blur-panel]] + [app.main.ui.handoff.attributes.image :refer [image-panel]] + [app.main.ui.handoff.attributes.text :refer [text-panel]])) (def type->options {:multiple [:fill :stroke :image :text :shadow :blur] @@ -28,7 +28,6 @@ :rect [:layout :fill :stroke :shadow :blur] :circle [:layout :fill :stroke :shadow :blur] :path [:layout :fill :stroke :shadow :blur] - :curve [:layout :fill :stroke :shadow :blur] :image [:image :layout :shadow :blur] :text [:layout :text :shadow :blur]}) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs b/frontend/src/app/main/ui/handoff/attributes/blur.cljs similarity index 96% rename from frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs rename to frontend/src/app/main/ui/handoff/attributes/blur.cljs index 203f5658c..166779332 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/blur.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.blur +(ns app.main.ui.handoff.attributes.blur (:require [rumext.alpha :as mf] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs b/frontend/src/app/main/ui/handoff/attributes/common.cljs similarity index 98% rename from frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs rename to frontend/src/app/main/ui/handoff/attributes/common.cljs index 8fe89b976..f2bcce9a4 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/common.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.common +(ns app.main.ui.handoff.attributes.common (:require [rumext.alpha :as mf] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/fill.cljs b/frontend/src/app/main/ui/handoff/attributes/fill.cljs similarity index 94% rename from frontend/src/app/main/ui/viewer/handoff/attributes/fill.cljs rename to frontend/src/app/main/ui/handoff/attributes/fill.cljs index 8afb6d5ba..cf6a8d48a 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/fill.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/fill.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.fill +(ns app.main.ui.handoff.attributes.fill (:require [rumext.alpha :as mf] [app.util.i18n :refer [t]] @@ -15,7 +15,7 @@ [app.main.ui.icons :as i] [app.util.code-gen :as cg] [app.main.ui.components.copy-button :refer [copy-button]] - [app.main.ui.viewer.handoff.attributes.common :refer [color-row]])) + [app.main.ui.handoff.attributes.common :refer [color-row]])) (def fill-attributes [:fill-color :fill-color-gradient]) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/image.cljs b/frontend/src/app/main/ui/handoff/attributes/image.cljs similarity index 97% rename from frontend/src/app/main/ui/viewer/handoff/attributes/image.cljs rename to frontend/src/app/main/ui/handoff/attributes/image.cljs index c4fd0c639..5b63e839e 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/image.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/image.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.image +(ns app.main.ui.handoff.attributes.image (:require [rumext.alpha :as mf] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs b/frontend/src/app/main/ui/handoff/attributes/layout.cljs similarity index 98% rename from frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs rename to frontend/src/app/main/ui/handoff/attributes/layout.cljs index 3aa984b7b..02175ece7 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/layout.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.layout +(ns app.main.ui.handoff.attributes.layout (:require [rumext.alpha :as mf] [cuerdas.core :as str] @@ -49,7 +49,7 @@ [:div.attributes-label (t locale "handoff.attributes.layout.left")] [:div.attributes-value (mth/precision (:x shape) 2) "px"] [:& copy-button {:data (copy-data shape :x)}]]) - + (when (not= (:y shape) 0) [:div.attributes-unit-row [:div.attributes-label (t locale "handoff.attributes.layout.top")] diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/shadow.cljs b/frontend/src/app/main/ui/handoff/attributes/shadow.cljs similarity index 95% rename from frontend/src/app/main/ui/viewer/handoff/attributes/shadow.cljs rename to frontend/src/app/main/ui/handoff/attributes/shadow.cljs index 44089cc46..1ddb7b86f 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/shadow.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/shadow.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.shadow +(ns app.main.ui.handoff.attributes.shadow (:require [rumext.alpha :as mf] [cuerdas.core :as str] @@ -16,7 +16,7 @@ [app.main.ui.icons :as i] [app.util.code-gen :as cg] [app.main.ui.components.copy-button :refer [copy-button]] - [app.main.ui.viewer.handoff.attributes.common :refer [color-row]])) + [app.main.ui.handoff.attributes.common :refer [color-row]])) (defn has-shadow? [shape] (:shadow shape)) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs b/frontend/src/app/main/ui/handoff/attributes/stroke.cljs similarity index 95% rename from frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs rename to frontend/src/app/main/ui/handoff/attributes/stroke.cljs index fe26f8be5..14faeb5ec 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/stroke.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.stroke +(ns app.main.ui.handoff.attributes.stroke (:require [rumext.alpha :as mf] [cuerdas.core :as str] @@ -16,7 +16,7 @@ [app.main.ui.icons :as i] [app.util.code-gen :as cg] [app.main.ui.components.copy-button :refer [copy-button]] - [app.main.ui.viewer.handoff.attributes.common :refer [color-row]])) + [app.main.ui.handoff.attributes.common :refer [color-row]])) (defn shape->color [shape] {:color (:stroke-color shape) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/text.cljs b/frontend/src/app/main/ui/handoff/attributes/text.cljs similarity index 98% rename from frontend/src/app/main/ui/viewer/handoff/attributes/text.cljs rename to frontend/src/app/main/ui/handoff/attributes/text.cljs index 9c1090f41..81c75388a 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/text.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/text.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.attributes.text +(ns app.main.ui.handoff.attributes.text (:require [rumext.alpha :as mf] [cuerdas.core :as str] @@ -19,7 +19,7 @@ [app.main.fonts :as fonts] [app.main.ui.icons :as i] [app.util.webapi :as wapi] - [app.main.ui.viewer.handoff.attributes.common :refer [color-row]] + [app.main.ui.handoff.attributes.common :refer [color-row]] [app.util.code-gen :as cg] [app.main.store :as st] [app.main.ui.components.copy-button :refer [copy-button]])) diff --git a/frontend/src/app/main/ui/viewer/handoff/code.cljs b/frontend/src/app/main/ui/handoff/code.cljs similarity index 98% rename from frontend/src/app/main/ui/viewer/handoff/code.cljs rename to frontend/src/app/main/ui/handoff/code.cljs index 19ff75c57..eecdb80f7 100644 --- a/frontend/src/app/main/ui/viewer/handoff/code.cljs +++ b/frontend/src/app/main/ui/handoff/code.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.code +(ns app.main.ui.handoff.code (:require ["js-beautify" :as beautify] [cuerdas.core :as str] diff --git a/frontend/src/app/main/ui/viewer/handoff/exports.cljs b/frontend/src/app/main/ui/handoff/exports.cljs similarity index 98% rename from frontend/src/app/main/ui/viewer/handoff/exports.cljs rename to frontend/src/app/main/ui/handoff/exports.cljs index 4dbc303c3..a992b6980 100644 --- a/frontend/src/app/main/ui/viewer/handoff/exports.cljs +++ b/frontend/src/app/main/ui/handoff/exports.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.exports +(ns app.main.ui.handoff.exports (:require [rumext.alpha :as mf] [beicon.core :as rx] @@ -59,7 +59,7 @@ (swap! exports (fn [exports] (let [[before after] (split-at index exports)] (d/concat [] before (rest after))))))) - + on-scale-change (mf/use-callback (mf/deps shape) @@ -68,7 +68,7 @@ value (dom/get-value target) value (d/parse-double value)] (swap! exports assoc-in [index :scale] value)))) - + on-suffix-change (mf/use-callback (mf/deps shape) @@ -76,7 +76,7 @@ (let [target (dom/get-target event) value (dom/get-value target)] (swap! exports assoc-in [index :suffix] value)))) - + on-type-change (mf/use-callback (mf/deps shape) diff --git a/frontend/src/app/main/ui/viewer/handoff/left_sidebar.cljs b/frontend/src/app/main/ui/handoff/left_sidebar.cljs similarity index 96% rename from frontend/src/app/main/ui/viewer/handoff/left_sidebar.cljs rename to frontend/src/app/main/ui/handoff/left_sidebar.cljs index 0c2a4ef82..d44ce15c6 100644 --- a/frontend/src/app/main/ui/viewer/handoff/left_sidebar.cljs +++ b/frontend/src/app/main/ui/handoff/left_sidebar.cljs @@ -7,18 +7,18 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.left-sidebar +(ns app.main.ui.handoff.left-sidebar (:require - [rumext.alpha :as mf] - [okulary.core :as l] [app.common.data :as d] [app.common.uuid :as uuid] - [app.main.store :as st] - [app.util.dom :as dom] [app.main.data.viewer :as dv] + [app.main.store :as st] [app.main.ui.icons :as i] [app.main.ui.keyboard :as kbd] - [app.main.ui.workspace.sidebar.layers :refer [element-icon layer-name frame-wrapper]])) + [app.main.ui.workspace.sidebar.layers :refer [element-icon layer-name frame-wrapper]] + [app.util.dom :as dom] + [okulary.core :as l] + [rumext.alpha :as mf])) (def selected-shapes (l/derived (comp :selected :viewer-local) st/state)) @@ -29,7 +29,7 @@ (defn- make-collapsed-iref [id] #(-> (l/in [:viewer-local :collapsed id]) - (l/derived st/state) )) + (l/derived st/state))) (mf/defc layer-item [{:keys [index item selected objects disable-collapse?] :as props}] diff --git a/frontend/src/app/main/ui/viewer/handoff/render.cljs b/frontend/src/app/main/ui/handoff/render.cljs similarity index 94% rename from frontend/src/app/main/ui/viewer/handoff/render.cljs rename to frontend/src/app/main/ui/handoff/render.cljs index 7f5c238dc..7af944e29 100644 --- a/frontend/src/app/main/ui/viewer/handoff/render.cljs +++ b/frontend/src/app/main/ui/handoff/render.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.render +(ns app.main.ui.handoff.render "The main container for a frame in handoff mode" (:require [rumext.alpha :as mf] @@ -15,7 +15,6 @@ [app.util.dom :as dom] [app.common.data :as d] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as geom] @@ -30,7 +29,7 @@ [app.main.ui.shapes.path :as path] [app.main.ui.shapes.rect :as rect] [app.main.ui.shapes.text :as text] - [app.main.ui.viewer.handoff.selection-feedback :refer [selection-feedback]] + [app.main.ui.handoff.selection-feedback :refer [selection-feedback]] [app.main.ui.shapes.shape :refer [shape-container]])) (declare shape-container-factory) @@ -122,11 +121,11 @@ (mf/deps objects) #(group-container-factory objects))] (when (and shape (not (:hidden shape))) - (let [shape (geom/transform-shape frame shape) + (let [shape (-> (geom/transform-shape shape) + (geom/translate-to-frame frame)) opts #js {:shape shape :frame frame}] (case (:type shape) - :curve [:> path-wrapper opts] :text [:> text-wrapper opts] :rect [:> rect-wrapper opts] :path [:> path-wrapper opts] @@ -141,7 +140,7 @@ (gmt/translate-matrix)) update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) - modifier-ids (d/concat [frame-id] (cph/get-children frame-id objects))] + modifier-ids (d/concat [frame-id] (cp/get-children frame-id objects))] (reduce update-fn objects modifier-ids))) (defn make-vbox [frame] diff --git a/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs b/frontend/src/app/main/ui/handoff/right_sidebar.cljs similarity index 94% rename from frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs rename to frontend/src/app/main/ui/handoff/right_sidebar.cljs index 210503d6c..ce9abf05c 100644 --- a/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs +++ b/frontend/src/app/main/ui/handoff/right_sidebar.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.right-sidebar +(ns app.main.ui.handoff.right-sidebar (:require [rumext.alpha :as mf] [okulary.core :as l] @@ -16,8 +16,8 @@ [app.main.ui.icons :as i] [app.main.ui.components.tab-container :refer [tab-container tab-element]] [app.main.ui.workspace.sidebar.layers :refer [element-icon]] - [app.main.ui.viewer.handoff.attributes :refer [attributes]] - [app.main.ui.viewer.handoff.code :refer [code]])) + [app.main.ui.handoff.attributes :refer [attributes]] + [app.main.ui.handoff.code :refer [code]])) (defn make-selected-shapes-iref [] diff --git a/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs b/frontend/src/app/main/ui/handoff/selection_feedback.cljs similarity index 90% rename from frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs rename to frontend/src/app/main/ui/handoff/selection_feedback.cljs index 01bbae2a6..371134d56 100644 --- a/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs +++ b/frontend/src/app/main/ui/handoff/selection_feedback.cljs @@ -7,7 +7,7 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.main.ui.viewer.handoff.selection-feedback +(ns app.main.ui.handoff.selection-feedback (:require [rumext.alpha :as mf] [cuerdas.core :as str] @@ -60,11 +60,9 @@ ;; HELPERS ;; ------------------------------------------------ -(defn frame->selrect [frame] - {:x1 0 - :y1 0 - :x2 (:width frame) - :y2 (:height frame) +(defn frame->bounds [frame] + {:x 0 + :y 0 :width (:width frame) :height (:height frame)}) @@ -88,23 +86,24 @@ (let [zoom (mf/deref selected-zoom) hover-shapes-ref (mf/use-memo (make-hover-shapes-iref)) - hover-shape (-> (mf/deref hover-shapes-ref) + hover-shape (-> (or (mf/deref hover-shapes-ref) frame) (gsh/translate-to-frame frame)) selected-shapes-ref (mf/use-memo (make-selected-shapes-iref)) selected-shapes (->> (mf/deref selected-shapes-ref) (map #(gsh/translate-to-frame % frame))) - selrect (gsh/selection-rect selected-shapes)] + selrect (gsh/selection-rect selected-shapes) + bounds (frame->bounds frame)] (when (seq selected-shapes) [:g.selection-feedback {:pointer-events "none"} [:g.selected-shapes - [:& selection-guides {:selrect selrect :frame frame :zoom zoom}] + [:& selection-guides {:bounds bounds :selrect selrect :zoom zoom}] [:& selection-rect {:selrect selrect :zoom zoom}] [:& size-display {:selrect selrect :zoom zoom}]] - [:& measurement {:bounds frame + [:& measurement {:bounds bounds :selected-shapes selected-shapes :hover-shape hover-shape :zoom zoom}]]))) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 178360b68..ba72e96c7 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -50,23 +50,6 @@ (fn [] (mousetrap/reset)))) nil) -(defn use-fullscreen - [ref] - (let [state (mf/use-state (dom/fullscreen?)) - change (mf/use-callback #(reset! state (dom/fullscreen?))) - toggle (mf/use-callback (mf/deps @state) - #(let [el (mf/ref-val ref)] - (swap! state not) - (if @state - (wapi/exit-fullscreen) - (wapi/request-fullscreen el))))] - (mf/use-effect - (fn [] - (.addEventListener js/document "fullscreenchange" change) - #(.removeEventListener js/document "fullscreenchange" change))) - - [toggle @state])) - (defn invisible-image [] (let [img (js/Image.) @@ -231,3 +214,10 @@ (mf/use-effect (fn [] (let [sub (->> stream (rx/subs on-subscribe))] #(rx/dispose! sub))))) + +;; https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state +(defn use-previous [value] + (let [ref (mf/use-ref)] + (mf/use-effect + #(mf/set-ref-val! ref value)) + (mf/ref-val ref))) diff --git a/frontend/src/app/main/ui/icons.clj b/frontend/src/app/main/ui/icons.clj index 393e3a974..c6fcb056a 100644 --- a/frontend/src/app/main/ui/icons.clj +++ b/frontend/src/app/main/ui/icons.clj @@ -10,11 +10,9 @@ (ns app.main.ui.icons (:require [rumext.alpha])) -(def base-uri "/images/svg-sprite/symbol/svg/sprite.symbol.svg#icon-") - (defmacro icon-xref [id] - (let [href (str base-uri (name id))] + (let [href (str "#icon-" (name id))] `(rumext.alpha/html [:svg {:width 500 :height 500} [:use {:xlinkHref ~href}]]))) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 08ac0679d..f620afd84 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -49,6 +49,7 @@ (def grid (icon-xref :grid)) (def grid-snap (icon-xref :grid-snap)) (def icon-empty (icon-xref :icon-empty)) +(def icon-list (icon-xref :icon-list)) (def icon-lock (icon-xref :icon-lock)) (def icon-set (icon-xref :icon-set)) (def image (icon-xref :image)) @@ -127,6 +128,16 @@ (def checkbox-checked (icon-xref :checkbox-checked)) (def checkbox-unchecked (icon-xref :checkbox-unchecked)) (def code (icon-xref :code)) +(def nodes-add (icon-xref :nodes-add)) +(def nodes-corner (icon-xref :nodes-corner)) +(def nodes-curve (icon-xref :nodes-curve)) +(def nodes-join (icon-xref :nodes-join)) +(def nodes-merge (icon-xref :nodes-merge)) +(def nodes-remove (icon-xref :nodes-remove)) +(def nodes-separate (icon-xref :nodes-separate)) +(def nodes-snap (icon-xref :nodes-snap)) +(def pen (icon-xref :pen)) +(def pointer-inner (icon-xref :pointer-inner)) (def loader-pencil (mf/html diff --git a/frontend/src/app/main/ui/keyboard.cljs b/frontend/src/app/main/ui/keyboard.cljs index 9bc6d8499..ecc03d63b 100644 --- a/frontend/src/app/main/ui/keyboard.cljs +++ b/frontend/src/app/main/ui/keyboard.cljs @@ -20,3 +20,5 @@ (def esc? (is-keycode? 27)) (def enter? (is-keycode? 13)) (def space? (is-keycode? 32)) +(def up-arrow? (is-keycode? 38)) +(def down-arrow? (is-keycode? 40)) diff --git a/frontend/src/app/main/ui/messages.cljs b/frontend/src/app/main/ui/messages.cljs index 0b41989bd..fc6f092bd 100644 --- a/frontend/src/app/main/ui/messages.cljs +++ b/frontend/src/app/main/ui/messages.cljs @@ -9,27 +9,17 @@ (ns app.main.ui.messages (:require - [rumext.alpha :as mf] - [clojure.spec.alpha :as s] - [app.common.uuid :as uuid] [app.common.spec :as us] - [app.main.ui.icons :as i] + [app.common.uuid :as uuid] [app.main.data.messages :as dm] [app.main.refs :as refs] [app.main.store :as st] - [app.util.data :refer [classnames]] + [app.main.ui.icons :as i] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [t]] - [app.util.timers :as ts])) + [rumext.alpha :as mf])) (mf/defc banner [{:keys [type position status controls content actions on-close] :as props}] - (us/assert ::dm/message-type type) - (us/assert ::dm/message-position position) - (us/assert ::dm/message-status status) - (us/assert ::dm/message-controls controls) - (us/assert ::dm/message-actions actions) - (us/assert (s/nilable ::us/fn) on-close) [:div.banner {:class (dom/classnames :warning (= type :warning) :error (= type :error) @@ -62,7 +52,7 @@ (mf/defc notifications [] (let [message (mf/deref refs/message) - on-close #(st/emit! dm/hide)] + on-close (st/emitf dm/hide)] (when message [:& banner (assoc message :position (or (:position message) :fixed) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 908ebced8..e9fa25c7a 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -61,6 +61,7 @@ [props] (let [data (unchecked-get props "data") wrapper-ref (mf/use-ref nil) + components (mf/deref dm/components) allow-click-outside (:allow-click-outside data) @@ -75,16 +76,18 @@ (mf/use-layout-effect (mf/deps allow-click-outside) (fn [] - (let [keys [(events/listen js/window EventType.POPSTATE on-pop-state) - (events/listen (dom/get-root) EventType.KEYDOWN handle-keydown) - (events/listen (dom/get-root) EventType.CLICK handle-click-outside)]] + (let [keys [(events/listen js/window EventType.POPSTATE on-pop-state) + (events/listen js/document EventType.KEYDOWN handle-keydown) + + ;; Changing to js/document breaks the color picker + (events/listen (dom/get-root) EventType.CLICK handle-click-outside) + + (events/listen js/document EventType.CONTEXTMENU handle-click-outside)]] #(doseq [key keys] (events/unlistenByKey key))))) [:div.modal-wrapper {:ref wrapper-ref} - (mf/element - (get @dm/components (:type data)) - (:props data))])) + (mf/element (get components (:type data)) (:props data))])) (def modal-ref @@ -93,5 +96,6 @@ (mf/defc modal [] (let [modal (mf/deref modal-ref)] - (when modal [:& modal-wrapper {:data modal - :key (:id modal)}]))) + (when modal + [:& modal-wrapper {:data modal + :key (:id modal)}]))) diff --git a/frontend/src/app/main/ui/onboarding.cljs b/frontend/src/app/main/ui/onboarding.cljs new file mode 100644 index 000000000..77b88823a --- /dev/null +++ b/frontend/src/app/main/ui/onboarding.cljs @@ -0,0 +1,230 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.onboarding + (:require + [app.common.spec :as us] + [app.main.data.dashboard :as dd] + [app.main.data.messages :as dm] + [app.main.data.modal :as modal] + [app.main.data.users :as du] + [app.main.store :as st] + [app.main.ui.components.forms :as fm :refer [input submit-button form]] + [app.util.router :as rt] + [app.util.timers :as tm] + [cljs.spec.alpha :as s] + [rumext.alpha :as mf])) + +(defmulti render-slide :slide) + +(defmethod render-slide :start + [{:keys [navigate] :as props}] + (mf/html + [:div.modal-container.onboarding + [:div.modal-left + [:img {:src "images/pot.png" :border "0" :alt "Penpot"}]] + [:div.modal-right + [:div.modal-title + [:h2 "Welcome to Penpot!"]] + [:span.release "Alpha version 1.0"] + [:div.modal-content + [:p "We are very happy to introduce you to the very first Alpha 1.0 release."] + [:p "Penpot is still at development stage and there will be constant updates. We hope you enjoy the first stable version."]] + [:div.modal-navigation + [:button.btn-secondary {:on-click #(navigate :opensource)} "Continue"]]] + [:img.deco {:src "images/deco-left.png" :border "0"}] + [:img.deco.right {:src "images/deco-right.png" :border "0"}]])) + + +(defmethod render-slide :opensource + [{:keys [navigate] :as props}] + (mf/html + [:div.modal-container.onboarding.black + [:div.modal-left + [:img {:src "images/open-source.svg" :border "0" :alt "Open Source"}]] + [:div.modal-right + [:div.modal-title + [:h2 "Open Source Contributor?"]] + [:div.modal-content + [:p "Penpot is Open Source, made by and for the community. If you want to collaborate, you are more than welcome!"] + [:p "You can access the " [:a {:href "https://github.com/penpot" :target "_blank"} "project on github"] " and follow the contribution instructions :)"]] + [:div.modal-navigation + [:button.btn-secondary {:on-click #(navigate :feature1)} "Continue"]]]])) + +(defmethod render-slide :feature1 + [{:keys [navigate skip] :as props}] + (mf/html + [:div.modal-container.onboarding.feature + [:div.modal-left + [:img {:src "images/on-design.gif" :border "0" :alt "Create designs"}]] + [:div.modal-right + [:div.modal-title + [:h2 "Design libraries, styles and components"]] + [:div.modal-content + [:p "Create beautiful user interfaces in collaboration with all team members."] + [:p "Maintain consistency at scale with components, libraries and design systems."]] + [:div.modal-navigation + [:button.btn-secondary {:on-click #(navigate :feature2)} "Continue"] + [:span.skip {:on-click skip} "Skip"] + [:ul.step-dots + [:li.current] + [:li] + [:li] + [:li]]]]])) + +(defmethod render-slide :feature2 + [{:keys [navigate skip] :as props}] + (mf/html + [:div.modal-container.onboarding.feature + [:div.modal-left + [:img {:src "images/on-proto.gif" :border "0" :alt "Interactive prototypes"}]] + [:div.modal-right + [:div.modal-title + [:h2 "Bring your designs to life with interactions"]] + [:div.modal-content + [:p "Create rich interactions to mimic the product behaviour."] + [:p "Share to stakeholders, present proposals to your team and start user testing with your designs, all in one place."]] + [:div.modal-navigation + [:button.btn-secondary {:on-click #(navigate :feature3)} "Continue"] + [:span.skip {:on-click skip} "Skip"] + [:ul.step-dots + [:li] + [:li.current] + [:li] + [:li]]]]])) + +(defmethod render-slide :feature3 + [{:keys [navigate skip] :as props}] + (mf/html + [:div.modal-container.onboarding.feature + [:div.modal-left + [:img {:src "images/on-feed.gif" :border "0" :alt "Get feedback"}]] + [:div.modal-right + [:div.modal-title + [:h2 "Get feedback, present and share your work"]] + [:div.modal-content + [:p "All team members working simultaneously with real time design multiplayer and centralised comments, ideas and feedback right over the designs."]] + [:div.modal-navigation + [:button.btn-secondary {:on-click #(navigate :feature4)} "Continue"] + [:span.skip {:on-click skip} "Skip"] + [:ul.step-dots + [:li] + [:li] + [:li.current] + [:li]]]]])) + +(defmethod render-slide :feature4 + [{:keys [navigate skip] :as props}] + (mf/html + [:div.modal-container.onboarding.feature + [:div.modal-left + [:img {:src "images/on-handoff.gif" :border "0" :alt "Handoff and lowcode"}]] + [:div.modal-right + [:div.modal-title + [:h2 "One shared source of truth"]] + [:div.modal-content + [:p "Sync the design and code of all your components and styles and get code snippets."] + [:p "Get and provide code specifications like markup (SVG, HTML) or styles (CSS, Less, Stylus…)."]] + [:div.modal-navigation + [:button.btn-secondary {:on-click skip} "Continue"] + [:span.skip {:on-click skip} "Skip"] + [:ul.step-dots + [:li] + [:li] + [:li] + [:li.current]]]]])) + +(mf/defc onboarding-modal + {::mf/register modal/components + ::mf/register-as :onboarding} + [props] + (let [slide (mf/use-state :start) + klass (mf/use-state "fadeInDown") + + navigate + (mf/use-callback #(reset! slide %)) + + skip + (mf/use-callback + (st/emitf (modal/hide) + (modal/show {:type :onboarding-team}) + (du/mark-onboarding-as-viewed)))] + + (mf/use-layout-effect + (mf/deps @slide) + (fn [] + (when (not= :start @slide) + (reset! klass "fadeIn")) + (let [sem (tm/schedule 300 #(reset! klass nil))] + (fn [] + (reset! klass nil) + (tm/dispose! sem))))) + + [:div.modal-overlay + [:div.animated {:class @klass} + (render-slide + (assoc props + :slide @slide + :navigate navigate + :skip skip))]])) + +(s/def ::name ::us/not-empty-string) +(s/def ::team-form + (s/keys :req-un [::name])) + +(defn- on-success + [form response] + (st/emit! (modal/hide) + (rt/nav :dashboard-projects {:team-id (:id response)}))) + +(defn- on-error + [form response] + (st/emit! (dm/error "Error on creating team."))) + +(defn- on-submit + [form event] + (let [mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + params {:name (get-in @form [:clean-data :name])}] + (st/emit! (dd/create-team (with-meta params mdata))))) + +(mf/defc onboarding-team-modal + {::mf/register modal/components + ::mf/register-as :onboarding-team} + [props] + (let [close (mf/use-fn (st/emitf (modal/hide))) + form (fm/use-form :spec ::team-form + :initial {}) + + on-submit + (mf/use-callback (partial on-submit form))] + [:div.modal-overlay + [:div.modal-container.onboarding.final.animated.fadeInUp + [:div.modal-left + [:img {:src "images/onboarding-team.jpg" :border "0" :alt "Create a team"}] + [:h2 "Create a team"] + [:p "Are you working with someone? Create a team to work together on projects and share design assets."] + + [:& fm/form {:form form + :on-submit on-submit} + [:& fm/input {:type "text" + :name :name + :label "Enter new team name"}] + [:& fm/submit-button + {:label "Create team"}]]] + [:div.modal-right + [:img {:src "images/onboarding-start.jpg" :border "0" :alt "Start designing"}] + [:h2 "Start designing"] + [:p "Jump right away into Penpot and start designing by your own. You will still have the chance to create teams later."] + [:button.btn-primary.btn-large {:on-click close} "Start right away"]] + + + [:img.deco {:src "images/deco-left.png" :border "0"}] + [:img.deco.right {:src "images/deco-right.png" :border "0"}]]])) + diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index ec4b00d1f..35612bdab 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -14,7 +14,6 @@ [rumext.alpha :as mf] [app.common.uuid :as uuid] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.math :as mth] [app.common.geom.shapes :as geom] [app.common.geom.point :as gpt] @@ -34,7 +33,7 @@ (gpt/negate) (gmt/translate-matrix)) - mod-ids (cons frame-id (cph/get-children frame-id objects)) + mod-ids (cons frame-id (cp/get-children frame-id objects)) updt-fn #(-> %1 (assoc-in [%2 :modifiers :displacement] modifier) (update %2 geom/transform-shape)) @@ -82,7 +81,7 @@ [objects object-id] (if (uuid/zero? object-id) (let [object (get objects object-id) - shapes (cph/select-toplevel-shapes objects {:include-frames? true}) + shapes (cp/select-toplevel-shapes objects {:include-frames? true}) srect (geom/selection-rect shapes) object (merge object (select-keys srect [:x :y :width :height])) object (geom/transform-shape object) diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index e509150dd..bd6c4df4f 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -9,7 +9,6 @@ [rumext.alpha :as mf] [app.common.uuid :as uuid] [app.common.geom.shapes :as geom] - [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.util.object :as obj])) ; The SVG standard does not implement yet the 'stroke-alignment' @@ -23,17 +22,15 @@ (let [shape (unchecked-get props "shape") base-props (unchecked-get props "base-props") elem-name (unchecked-get props "elem-name") - {:keys [x y width height]} (geom/shape->rect-shape shape) - mask-id (mf/use-ctx mask-id-ctx) + ;; {:keys [x y width height]} (geom/shape->rect-shape shape) + {:keys [x y width height]} (:selrect shape) stroke-id (mf/use-var (uuid/next)) stroke-style (:stroke-style shape :none) stroke-position (:stroke-alignment shape :center)] (cond ;; Center alignment (or no stroke): the default in SVG (or (= stroke-style :none) (= stroke-position :center)) - [:> elem-name (cond-> (obj/merge! #js {} base-props) - (some? mask-id) - (obj/merge! #js {:mask mask-id}))] + [:> elem-name (obj/merge! #js {} base-props)] ;; Inner alignment: display the shape with double width stroke, ;; and clip the result with the original shape without stroke. @@ -53,15 +50,10 @@ shape-props (-> (obj/merge! #js {} base-props) (obj/merge! #js {:strokeWidth (* stroke-width 2) :clipPath (str "url('#" clip-id "')")}))] - (if (nil? mask-id) - [:* - [:> "clipPath" #js {:id clip-id} - [:> elem-name clip-props]] - [:> elem-name shape-props]] - [:g {:mask mask-id} - [:> "clipPath" #js {:id clip-id} - [:> elem-name clip-props]] - [:> elem-name shape-props]])) + [:* + [:> "clipPath" #js {:id clip-id} + [:> elem-name clip-props]] + [:> elem-name shape-props]]) ;; Outer alingmnent: display the shape in two layers. One ;; without stroke (only fill), and another one only with stroke @@ -99,17 +91,10 @@ :fill "none" :fillOpacity 0 :mask (str "url('#" stroke-mask-id "')")}))] - (if (nil? mask-id) - [:* - [:mask {:id mask-id} - [:> elem-name mask-props1] - [:> elem-name mask-props2]] - [:> elem-name shape-props1] - [:> elem-name shape-props2]] - [:g {:mask mask-id} - [:mask {:id stroke-mask-id} - [:> elem-name mask-props1] - [:> elem-name mask-props2]] - [:> elem-name shape-props1] - [:> elem-name shape-props2]]))))) + [:* + [:mask {:id stroke-mask-id} + [:> elem-name mask-props1] + [:> elem-name mask-props2]] + [:> elem-name shape-props1] + [:> elem-name shape-props2]])))) diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index 120e623dd..7d60bb88d 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -18,56 +18,54 @@ (mf/defc linear-gradient [{:keys [id gradient shape]}] (let [{:keys [x y width height]} shape] - [:defs - [:linearGradient {:id id - :x1 (:start-x gradient) - :y1 (:start-y gradient) - :x2 (:end-x gradient) - :y2 (:end-y gradient)} - (for [{:keys [offset color opacity]} (:stops gradient)] - [:stop {:key (str id "-stop-" offset) - :offset (or offset 0) - :stop-color color - :stop-opacity opacity}])]])) + [:linearGradient {:id id + :x1 (:start-x gradient) + :y1 (:start-y gradient) + :x2 (:end-x gradient) + :y2 (:end-y gradient)} + (for [{:keys [offset color opacity]} (:stops gradient)] + [:stop {:key (str id "-stop-" offset) + :offset (or offset 0) + :stop-color color + :stop-opacity opacity}])])) (mf/defc radial-gradient [{:keys [id gradient shape]}] (let [{:keys [x y width height]} shape] - [:defs - (let [[x y] (if (= (:type shape) :frame) [0 0] [x y]) - translate-vec (gpt/point (+ x (* width (:start-x gradient))) - (+ y (* height (:start-y gradient)))) - - gradient-vec (gpt/to-vec (gpt/point (* width (:start-x gradient)) - (* height (:start-y gradient))) - (gpt/point (* width (:end-x gradient)) - (* height (:end-y gradient)))) + (let [[x y] (if (= (:type shape) :frame) [0 0] [x y]) + translate-vec (gpt/point (+ x (* width (:start-x gradient))) + (+ y (* height (:start-y gradient)))) - angle (gpt/angle gradient-vec - (gpt/point 1 0)) + gradient-vec (gpt/to-vec (gpt/point (* width (:start-x gradient)) + (* height (:start-y gradient))) + (gpt/point (* width (:end-x gradient)) + (* height (:end-y gradient)))) - shape-height-vec (gpt/point 0 (/ height 2)) + angle (gpt/angle gradient-vec + (gpt/point 1 0)) - scale-factor-y (/ (gpt/length gradient-vec) (/ height 2)) - scale-factor-x (* scale-factor-y (:width gradient)) + shape-height-vec (gpt/point 0 (/ height 2)) - scale-vec (gpt/point (* scale-factor-y (/ height 2)) - (* scale-factor-x (/ width 2))) + scale-factor-y (/ (gpt/length gradient-vec) (/ height 2)) + scale-factor-x (* scale-factor-y (:width gradient)) - tr-translate (str/fmt "translate(%s, %s)" (:x translate-vec) (:y translate-vec)) - tr-rotate (str/fmt "rotate(%s)" angle) - tr-scale (str/fmt "scale(%s, %s)" (:x scale-vec) (:y scale-vec)) - transform (str/fmt "%s %s %s" tr-translate tr-rotate tr-scale)] - [:radialGradient {:id id - :cx 0 - :cy 0 - :r 1 - :gradientUnits "userSpaceOnUse" - :gradientTransform transform} - (for [{:keys [offset color opacity]} (:stops gradient)] - [:stop {:key (str id "-stop-" offset) - :offset (or offset 0) - :stop-color color - :stop-opacity opacity}])])])) + scale-vec (gpt/point (* scale-factor-y (/ height 2)) + (* scale-factor-x (/ width 2))) + + tr-translate (str/fmt "translate(%s, %s)" (:x translate-vec) (:y translate-vec)) + tr-rotate (str/fmt "rotate(%s)" angle) + tr-scale (str/fmt "scale(%s, %s)" (:x scale-vec) (:y scale-vec)) + transform (str/fmt "%s %s %s" tr-translate tr-rotate tr-scale)] + [:radialGradient {:id id + :cx 0 + :cy 0 + :r 1 + :gradientUnits "userSpaceOnUse" + :gradientTransform transform} + (for [{:keys [offset color opacity]} (:stops gradient)] + [:stop {:key (str id "-stop-" offset) + :offset (or offset 0) + :stop-color color + :stop-opacity opacity}])]))) (mf/defc gradient {::mf/wrap-props false} diff --git a/frontend/src/app/main/ui/shapes/group.cljs b/frontend/src/app/main/ui/shapes/group.cljs index 03bea3db6..5e00ea9e3 100644 --- a/frontend/src/app/main/ui/shapes/group.cljs +++ b/frontend/src/app/main/ui/shapes/group.cljs @@ -10,51 +10,37 @@ (ns app.main.ui.shapes.group (:require [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.main.ui.shapes.attrs :as attrs] - [app.util.debug :refer [debug?]] - [app.common.geom.shapes :as geom])) - -(def mask-id-ctx (mf/create-context nil)) + [app.main.ui.shapes.mask :refer [mask-str mask-factory]])) (defn group-shape [shape-wrapper] - (mf/fnc group-shape - {::mf/wrap-props false} - [props] - (let [frame (unchecked-get props "frame") - shape (unchecked-get props "shape") - childs (unchecked-get props "childs") - expand-mask (unchecked-get props "expand-mask") - mask (if (and (:masked-group? shape) (not expand-mask)) - (first childs) - nil) - childs (if (and (:masked-group? shape) (not expand-mask)) - (rest childs) - childs) - is-child-selected? (unchecked-get props "is-child-selected?") - {:keys [id x y width height]} shape - transform (geom/transform-matrix shape)] - [:g - (when mask - [:defs - [:mask {:id (:id mask) - :width width - :height height} + (let [render-mask (mask-factory shape-wrapper)] + (mf/fnc group-shape + {::mf/wrap-props false} + [props] + (let [frame (unchecked-get props "frame") + shape (unchecked-get props "shape") + childs (unchecked-get props "childs") + expand-mask (unchecked-get props "expand-mask") + pointer-events (unchecked-get props "pointer-events") + + {:keys [id x y width height]} shape + + show-mask? (and (:masked-group? shape) (not expand-mask)) + mask (when show-mask? (first childs)) + childs (if show-mask? (rest childs) childs)] + + [:g.group + {:pointer-events pointer-events + :mask (when (and mask (not expand-mask)) (mask-str mask))} + + (when mask + [:> render-mask #js {:frame frame :mask mask}]) + + (for [item childs] [:& shape-wrapper {:frame frame - :shape mask}]]]) - [:& (mf/provider mask-id-ctx) {:value (str/fmt "url(#%s)" (:id mask))} - (for [item childs] - [:& shape-wrapper {:frame frame - :shape item - :key (:id item)}])] - (when (not is-child-selected?) - [:rect {:transform transform - :x x - :y y - :fill (if (debug? :group) "red" "transparent") - :opacity 0.5 - :width width - :height height}])]))) + :shape item + :key (:id item)}])])))) + diff --git a/frontend/src/app/main/ui/shapes/image.cljs b/frontend/src/app/main/ui/shapes/image.cljs index 61ef85c79..c7640bbc3 100644 --- a/frontend/src/app/main/ui/shapes/image.cljs +++ b/frontend/src/app/main/ui/shapes/image.cljs @@ -13,7 +13,6 @@ [app.config :as cfg] [app.common.geom.shapes :as geom] [app.main.ui.shapes.attrs :as attrs] - [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.util.object :as obj] [app.main.ui.context :as muc] [app.main.data.fetch :as df] @@ -27,7 +26,6 @@ {:keys [id x y width height rotation metadata]} shape uri (cfg/resolve-media-path (:path metadata)) embed-resources? (mf/use-ctx muc/embed-ctx) - mask-id (mf/use-ctx mask-id-ctx) data-uri (mf/use-state (when (not embed-resources?) uri))] (mf/use-effect @@ -45,8 +43,7 @@ :transform transform :width width :height height - :preserveAspectRatio "none" - :mask mask-id}))] + :preserveAspectRatio "none"}))] (if (nil? @data-uri) [:> "rect" (obj/merge! props diff --git a/frontend/src/app/main/ui/shapes/mask.cljs b/frontend/src/app/main/ui/shapes/mask.cljs new file mode 100644 index 000000000..be9684793 --- /dev/null +++ b/frontend/src/app/main/ui/shapes/mask.cljs @@ -0,0 +1,35 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.shapes.mask + (:require + [rumext.alpha :as mf] + [cuerdas.core :as str])) + +(defn mask-str [mask] + (str/fmt "url(#%s)" (str (:id mask) "-mask"))) + +(defn mask-factory + [shape-wrapper] + (mf/fnc mask-shape + {::mf/wrap-props false} + [props] + (let [frame (unchecked-get props "frame") + mask (unchecked-get props "mask")] + [:defs + [:filter {:id (str (:id mask) "-filter")} + [:feFlood {:flood-color "white"}] + [:feComposite {:in "BackgroundImage" + :in2 "SourceGraphic" + :operator "in" + :result "comp"}]] + [:mask {:id (str (:id mask) "-mask")} + [:g {:filter (str/fmt "url(#%s)" (str (:id mask) "-filter"))} + [:& shape-wrapper {:frame frame :shape mask}]]]]))) + diff --git a/frontend/src/app/main/ui/shapes/path.cljs b/frontend/src/app/main/ui/shapes/path.cljs index 677bc1649..90f485a0a 100644 --- a/frontend/src/app/main/ui/shapes/path.cljs +++ b/frontend/src/app/main/ui/shapes/path.cljs @@ -13,48 +13,27 @@ [rumext.alpha :as mf] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]] - [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.common.geom.shapes :as geom] - [app.util.object :as obj])) + [app.util.object :as obj] + [app.util.geom.path :as ugp])) ;; --- Path Shape -(defn- render-path - [{:keys [segments close?] :as shape}] - (let [numsegs (count segments)] - (loop [buffer [] - index 0] - (cond - (>= index numsegs) - (if close? - (str/join " " (conj buffer "Z")) - (str/join " " buffer)) - - (zero? index) - (let [{:keys [x y] :as segment} (nth segments index) - buffer (conj buffer (str/istr "M~{x},~{y}"))] - (recur buffer (inc index))) - - :else - (let [{:keys [x y] :as segment} (nth segments index) - buffer (conj buffer (str/istr "L~{x},~{y}"))] - (recur buffer (inc index))))))) - (mf/defc path-shape {::mf/wrap-props false} [props] (let [shape (unchecked-get props "shape") background? (unchecked-get props "background?") - {:keys [id x y width height]} (geom/shape->rect-shape shape) - mask-id (mf/use-ctx mask-id-ctx) + ;; {:keys [id x y width height]} (geom/shape->rect-shape shape) + {:keys [id x y width height]} (:selrect shape) transform (geom/transform-matrix shape) - pdata (render-path shape) + pdata (ugp/content->path (:content shape)) props (-> (attrs/extract-style-attrs shape) (obj/merge! #js {:transform transform :d pdata}))] (if background? - [:g {:mask mask-id} + [:g [:path {:stroke "transparent" :fill "transparent" :stroke-width "20px" @@ -64,6 +43,5 @@ :elem-name "path"}]] [:& shape-custom-stroke {:shape shape :base-props props - :mask mask-id :elem-name "path"}]))) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index 830a7057b..62760374e 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -9,34 +9,34 @@ (ns app.main.ui.shapes.shape (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.util.object :as obj] - [app.common.uuid :as uuid] [app.common.geom.shapes :as geom] + [app.common.uuid :as uuid] + [app.main.ui.context :as muc] [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.gradients :as grad] - [app.main.ui.context :as muc])) + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.alpha :as mf])) (mf/defc shape-container {::mf/wrap-props false} [props] - - (let [shape (unchecked-get props "shape") - children (unchecked-get props "children") + (let [shape (obj/get props "shape") + children (obj/get props "children") render-id (mf/use-memo #(str (uuid/next))) filter-id (str "filter_" render-id) - group-props (-> props - (obj/clone) + styles (cond-> (obj/new) + (:blocked shape) (obj/set! "pointerEvents" "none")) + group-props (-> (obj/clone props) (obj/without ["shape" "children"]) (obj/set! "id" (str "shape-" (:id shape))) - (obj/set! "className" "shape") - (obj/set! "filter" (filters/filter-str filter-id shape)))] + (obj/set! "className" (str "shape " (:type shape))) + (obj/set! "filter" (filters/filter-str filter-id shape)) + (obj/set! "style" styles))] [:& (mf/provider muc/render-ctx) {:value render-id} [:> :g group-props [:defs [:& filters/filters {:shape shape :filter-id filter-id}] [:& grad/gradient {:shape shape :attr :fill-color-gradient}] [:& grad/gradient {:shape shape :attr :stroke-color-gradient}]] - children]])) diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index 3fbc1f972..05c0afda4 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -2,205 +2,64 @@ ;; 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) 2016-2019 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.ui.shapes.text (:require - [clojure.set :as set] - [promesa.core :as p] [cuerdas.core :as str] [rumext.alpha :as mf] - [app.main.data.fetch :as df] - [app.main.fonts :as fonts] [app.main.ui.context :as muc] - [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.common.data :as d] [app.common.geom.shapes :as geom] [app.common.geom.matrix :as gmt] [app.util.object :as obj] [app.util.color :as uc] - [app.util.text :as ut])) - -;; --- Text Editor Rendering - -(defn- generate-root-styles - [data] - (let [valign (obj/get data "vertical-align" "top") - talign (obj/get data "text-align" "flex-start") - base #js {:height "100%" - :width "100%" - :display "flex"}] - (cond-> base - (= valign "top") (obj/set! "alignItems" "flex-start") - (= valign "center") (obj/set! "alignItems" "center") - (= valign "bottom") (obj/set! "alignItems" "flex-end") - (= talign "left") (obj/set! "justifyContent" "flex-start") - (= talign "center") (obj/set! "justifyContent" "center") - (= talign "right") (obj/set! "justifyContent" "flex-end") - (= talign "justify") (obj/set! "justifyContent" "stretch")))) - -(defn- generate-paragraph-styles - [data] - (let [base #js {:fontSize "14px" - :margin "inherit" - :lineHeight "1.2"} - lh (obj/get data "line-height") - ta (obj/get data "text-align")] - (cond-> base - ta (obj/set! "textAlign" ta) - lh (obj/set! "lineHeight" lh)))) - -(defn- generate-text-styles - [data] - (let [letter-spacing (obj/get data "letter-spacing") - text-decoration (obj/get data "text-decoration") - text-transform (obj/get data "text-transform") - line-height (obj/get data "line-height") - - font-id (obj/get data "font-id" (:font-id ut/default-text-attrs)) - font-variant-id (obj/get data "font-variant-id") - - font-family (obj/get data "font-family") - font-size (obj/get data "font-size") - - ;; Old properties for backwards compatibility - fill (obj/get data "fill") - opacity (obj/get data "opacity" 1) - - fill-color (obj/get data "fill-color" fill) - fill-opacity (obj/get data "fill-opacity" opacity) - fill-color-gradient (obj/get data "fill-color-gradient" nil) - fill-color-gradient (when fill-color-gradient - (-> (js->clj fill-color-gradient :keywordize-keys true) - (update :type keyword))) - - fill-color-ref-id (obj/get data "fill-color-ref-id") - fill-color-ref-file (obj/get data "fill-color-ref-file") - - [r g b a] (uc/hex->rgba fill-color fill-opacity) - background (if fill-color-gradient - (uc/gradient->css (js->clj fill-color-gradient)) - (str/format "rgba(%s, %s, %s, %s)" r g b a)) - - fontsdb (deref fonts/fontsdb) - - base #js {:textDecoration text-decoration - :textTransform text-transform - :lineHeight (or line-height "inherit") - "--text-color" background}] - - (when (and (string? letter-spacing) - (pos? (alength letter-spacing))) - (obj/set! base "letterSpacing" (str letter-spacing "px"))) - - (when (and (string? font-size) - (pos? (alength font-size))) - (obj/set! base "fontSize" (str font-size "px"))) - - (when (and (string? font-id) - (pos? (alength font-id))) - (let [font (get fontsdb font-id)] - (fonts/ensure-loaded! font-id) - (let [font-family (or (:family font) - (obj/get data "fontFamily")) - font-variant (d/seek #(= font-variant-id (:id %)) - (:variants font)) - font-style (or (:style font-variant) - (obj/get data "fontStyle")) - font-weight (or (:weight font-variant) - (obj/get data "fontWeight"))] - (obj/set! base "fontFamily" font-family) - (obj/set! base "fontStyle" font-style) - (obj/set! base "fontWeight" font-weight)))) - - base)) - -(defn get-all-fonts [node] - (let [current-font (if (not (nil? (:font-id node))) - #{(select-keys node [:font-id :font-variant-id])} - #{}) - children-font (map get-all-fonts (:children node))] - (reduce set/union (conj children-font current-font)))) - - -(defn fetch-font [font-id font-variant-id] - (let [font-url (fonts/font-url font-id font-variant-id)] - (-> (js/fetch font-url) - (p/then (fn [res] (.text res)))))) - -(defonce font-face-template " -/* latin */ -@font-face { - font-family: '$0'; - font-style: $3; - font-weight: $2; - font-display: block; - src: url(/fonts/%(0)s-$1.woff) format('woff'); -} -") - -(defn get-local-font-css [font-id font-variant-id] - (let [{:keys [family variants]} (get @fonts/fontsdb font-id) - {:keys [name weight style]} (->> variants (filter #(= (:id %) font-variant-id)) first) - css-str (str/format font-face-template [family name weight style])] - (p/resolved css-str))) - -(defn embed-font [{:keys [font-id font-variant-id] :or {font-variant-id "regular"}}] - (let [{:keys [backend]} (get @fonts/fontsdb font-id)] - (p/let [font-text (case backend - :google (fetch-font font-id font-variant-id) - (get-local-font-css font-id font-variant-id)) - url-to-data (->> font-text - (re-seq #"url\(([^)]+)\)") - (map second) - (map df/fetch-as-data-uri) - (p/all))] - (reduce (fn [text [url data]] (str/replace text url data)) font-text url-to-data)) - )) + [app.main.ui.shapes.text.styles :as sts] + [app.main.ui.shapes.text.embed :as ste])) +;; -- Text nodes (mf/defc text-node - [{:keys [node index] :as props}] + [{:keys [node index shape] :as props}] (let [embed-resources? (mf/use-ctx muc/embed-ctx) - embeded-fonts (mf/use-state nil) - {:keys [type text children]} node] - - (mf/use-effect - (mf/deps node) - (fn [] - (when (and embed-resources? (= type "root")) - (let [font-to-embed (get-all-fonts node) - font-to-embed (if (empty? font-to-embed) #{ut/default-text-attrs} font-to-embed) - embeded (map embed-font font-to-embed)] - (-> (p/all embeded) - (p/then (fn [result] (reset! embeded-fonts (str/join "\n" result))))))))) + {:keys [type text children]} node + props #js {:shape shape} + render-node + (fn [index node] + (mf/element text-node {:index index + :node node + :key index + :shape shape}))] (if (string? text) - (let [style (generate-text-styles (clj->js node))] - [:span.text-node {:style style} (if (= text "") "\u00A0" text)]) - (let [children (map-indexed (fn [index node] - (mf/element text-node {:index index :node node :key index})) - children)] + (let [style (sts/generate-text-styles (clj->js node) props)] + [:span {:style style + :className (when (:fill-color-gradient node) "gradient")} + (if (= text "") "\u00A0" text)]) + + (let [children (map-indexed render-node children)] (case type "root" - (let [style (generate-root-styles (clj->js node))] - + (let [style (sts/generate-root-styles (clj->js node) props)] [:div.root.rich-text {:key index :style style :xmlns "http://www.w3.org/1999/xhtml"} [:* - [:style ".text-node { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] - (when (not (nil? @embeded-fonts)) - [:style @embeded-fonts])] + [:style ".gradient { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] + (when embed-resources? + [ste/embed-fontfaces-style {:node node}])] children]) "paragraph-set" - (let [style #js {:display "inline-block"}] - [:div.paragraphs {:key index :style style} children]) + (let [style (sts/generate-paragraph-set-styles (clj->js node) props)] + [:div.paragraph-set {:key index :style style} children]) "paragraph" - (let [style (generate-paragraph-styles (clj->js node))] - [:p {:key index :style style} children]) + (let [style (sts/generate-paragraph-styles (clj->js node) props)] + [:p.paragraph {:key index :style style} children]) nil))))) @@ -208,31 +67,37 @@ {::mf/wrap-props false ::mf/wrap [mf/memo]} [props] - (let [root (obj/get props "content")] - [:& text-node {:index 0 :node root}])) + (let [root (obj/get props "content") + shape (obj/get props "shape")] + [:& text-node {:index 0 + :node root + :shape shape}])) (defn- retrieve-colors [shape] - (let [colors (into #{} (comp (map :fill) - (filter string?)) - (tree-seq map? :children (:content shape)))] + (let [colors (->> shape :content + (tree-seq map? :children) + (into #{} (comp (map :fill-color) (filter string?))))] (if (empty? colors) "#000000" (apply str (interpose "," colors))))) (mf/defc text-shape - {::mf/wrap-props false} - [props] + {::mf/wrap-props false + ::mf/forward-ref true} + [props ref] (let [shape (unchecked-get props "shape") selected? (unchecked-get props "selected?") - mask-id (mf/use-ctx mask-id-ctx) - {:keys [id x y width height rotation content]} shape] + grow-type (:grow-type shape) + {:keys [id x y width height content]} shape] [:foreignObject {:x x :y y + :id (:id shape) :data-colors (retrieve-colors shape) :transform (geom/transform-matrix shape) - :width width - :height height - :mask mask-id} - [:& text-content {:content (:content shape)}]])) - + :width (if (#{:auto-width} grow-type) 10000 width) + :height (if (#{:auto-height :auto-width} grow-type) 10000 height) + :ref ref + :pointer-events "none"} + [:& text-content {:shape shape + :content (:content shape)}]])) diff --git a/frontend/src/app/main/ui/shapes/text/embed.cljs b/frontend/src/app/main/ui/shapes/text/embed.cljs new file mode 100644 index 000000000..9d41810f0 --- /dev/null +++ b/frontend/src/app/main/ui/shapes/text/embed.cljs @@ -0,0 +1,75 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.shapes.text.embed + (:require + [clojure.set :as set] + [promesa.core :as p] + [cuerdas.core :as str] + [rumext.alpha :as mf] + [app.main.data.fetch :as df] + [app.main.fonts :as fonts] + [app.util.text :as ut])) + +(defonce font-face-template " +/* latin */ +@font-face { + font-family: '$0'; + font-style: $3; + font-weight: $2; + font-display: block; + src: url(/fonts/%(0)s-$1.woff) format('woff'); +} +") + +;; -- Embed fonts into styles +(defn get-node-fonts [node] + (let [current-font (if (not (nil? (:font-id node))) + #{(select-keys node [:font-id :font-variant-id])} + #{}) + children-font (map get-node-fonts (:children node))] + (reduce set/union (conj children-font current-font)))) + + +(defn get-local-font-css [font-id font-variant-id] + (let [{:keys [family variants]} (get @fonts/fontsdb font-id) + {:keys [name weight style]} (->> variants (filter #(= (:id %) font-variant-id)) first) + css-str (str/format font-face-template [family name weight style])] + (p/resolved css-str))) + +(defn get-text-font-data [text] + (->> text + (re-seq #"url\(([^)]+)\)") + (map second) + (map df/fetch-as-data-uri) + (p/all))) + +(defn embed-font [{:keys [font-id font-variant-id] :or {font-variant-id "regular"}}] + (let [{:keys [backend]} (get @fonts/fontsdb font-id)] + (p/let [font-text (case backend + :google (fonts/fetch-font font-id font-variant-id) + (get-local-font-css font-id font-variant-id)) + url-to-data (get-text-font-data font-text) + replace-text (fn [text [url data]] (str/replace text url data))] + (reduce replace-text font-text url-to-data)))) + +(mf/defc embed-fontfaces-style [{:keys [node]}] + (let [embeded-fonts (mf/use-state nil)] + (mf/use-effect + (mf/deps node) + (fn [] + (let [font-to-embed (get-node-fonts node) + font-to-embed (if (empty? font-to-embed) #{ut/default-text-attrs} font-to-embed) + embeded (map embed-font font-to-embed)] + (-> (p/all embeded) + (p/then (fn [result] (reset! embeded-fonts (str/join "\n" result)))))))) + + + (when (not (nil? @embeded-fonts)) + [:style @embeded-fonts]))) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs new file mode 100644 index 000000000..fa230f1af --- /dev/null +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -0,0 +1,130 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.shapes.text.styles + (:require + [cuerdas.core :as str] + [app.main.fonts :as fonts] + [app.common.data :as d] + [app.util.object :as obj] + [app.util.color :as uc] + [app.util.text :as ut])) + +(defn generate-root-styles + [data props] + (let [valign (obj/get data "vertical-align" "top") + talign (obj/get data "text-align" "flex-start") + shape (obj/get props "shape") + base #js {:height (or (:height shape) "100%") + :width (or (:width shape) "100%") + :display "flex"}] + (cond-> base + (= valign "top") (obj/set! "alignItems" "flex-start") + (= valign "center") (obj/set! "alignItems" "center") + (= valign "bottom") (obj/set! "alignItems" "flex-end") + + (= talign "left") (obj/set! "justifyContent" "flex-start") + (= talign "center") (obj/set! "justifyContent" "center") + (= talign "right") (obj/set! "justifyContent" "flex-end") + (= talign "justify") (obj/set! "justifyContent" "stretch")))) + +(defn generate-paragraph-set-styles + [data props] + ;; The position absolute is used so the paragraph is "outside" + ;; the normal layout and can grow outside its parent + ;; We use this element to measure the size of the text + (let [base #js {:display "inline-block"}] + base)) + +(defn generate-paragraph-styles + [data props] + (let [shape (obj/get props "shape") + grow-type (:grow-type shape) + base #js {:fontSize "14px" + :margin "inherit" + :lineHeight "1.2"} + lh (obj/get data "line-height") + ta (obj/get data "text-align")] + (cond-> base + ta (obj/set! "textAlign" ta) + lh (obj/set! "lineHeight" lh) + (= grow-type :auto-width) (obj/set! "whiteSpace" "pre")))) + +(defn generate-text-styles + [data props] + (let [letter-spacing (obj/get data "letter-spacing") + text-decoration (obj/get data "text-decoration") + text-transform (obj/get data "text-transform") + line-height (obj/get data "line-height") + + font-id (obj/get data "font-id" (:font-id ut/default-text-attrs)) + font-variant-id (obj/get data "font-variant-id") + + font-family (obj/get data "font-family") + font-size (obj/get data "font-size") + + ;; Old properties for backwards compatibility + fill (obj/get data "fill") + opacity (obj/get data "opacity" 1) + + fill-color (obj/get data "fill-color" fill) + fill-opacity (obj/get data "fill-opacity" opacity) + fill-color-gradient (obj/get data "fill-color-gradient" nil) + fill-color-gradient (when fill-color-gradient + (-> (js->clj fill-color-gradient :keywordize-keys true) + (update :type keyword))) + + fill-color-ref-id (obj/get data "fill-color-ref-id") + fill-color-ref-file (obj/get data "fill-color-ref-file") + + ;; Uncomment this to allow to remove text colors. This could break the texts that already exist + ;;[r g b a] (if (nil? fill-color) + ;; [0 0 0 0] ;; Transparent color + ;; (uc/hex->rgba fill-color fill-opacity)) + + [r g b a] (uc/hex->rgba fill-color fill-opacity) + + text-color (if fill-color-gradient + (uc/gradient->css (js->clj fill-color-gradient)) + (str/format "rgba(%s, %s, %s, %s)" r g b a)) + + fontsdb (deref fonts/fontsdb) + + base #js {:textDecoration text-decoration + :textTransform text-transform + :lineHeight (or line-height "inherit") + :color text-color + "--text-color" text-color}] + + (when (and (string? letter-spacing) + (pos? (alength letter-spacing))) + (obj/set! base "letterSpacing" (str letter-spacing "px"))) + + (when (and (string? font-size) + (pos? (alength font-size))) + (obj/set! base "fontSize" (str font-size "px"))) + + (when (and (string? font-id) + (pos? (alength font-id))) + (fonts/ensure-loaded! font-id) + (let [font (get fontsdb font-id)] + (let [font-family (or (:family font) + (obj/get data "fontFamily")) + font-variant (d/seek #(= font-variant-id (:id %)) + (:variants font)) + font-style (or (:style font-variant) + (obj/get data "fontStyle")) + font-weight (or (:weight font-variant) + (obj/get data "fontWeight"))] + (obj/set! base "fontFamily" font-family) + (obj/set! base "fontStyle" font-style) + (obj/set! base "fontWeight" font-weight)))) + + + base)) diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 21774edd9..94cefd316 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -9,31 +9,180 @@ (ns app.main.ui.viewer (:require + [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as geom] + [app.common.pages :as cp] [app.main.data.viewer :as dv] + [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.fullscreen :as fs] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] [app.main.ui.keyboard :as kbd] [app.main.ui.viewer.header :refer [header]] - [app.main.ui.viewer.shapes :refer [frame-svg]] + [app.main.ui.viewer.shapes :as shapes :refer [frame-svg]] [app.main.ui.viewer.thumbnails :refer [thumbnails-panel]] - [app.util.data :refer [classnames]] + [app.main.ui.comments :as cmt] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t tr]] - [beicon.core :as rx] [goog.events :as events] [okulary.core :as l] - [rumext.alpha :as mf]) - (:import goog.events.EventType)) + [rumext.alpha :as mf])) + +(defn- frame-contains? + [{:keys [x y width height]} {px :x py :y}] + (let [x2 (+ x width) + y2 (+ y height)] + (and (<= x px x2) + (<= y py y2)))) + +(def threads-ref + (l/derived :comment-threads st/state)) + +(def comments-local-ref + (l/derived :comments-local st/state)) + +(mf/defc comments-layer + [{:keys [width height zoom frame data] :as props}] + (let [profile (mf/deref refs/profile) + + modifier1 (-> (gpt/point (:x frame) (:y frame)) + (gpt/negate) + (gmt/translate-matrix)) + + modifier2 (-> (gpt/point (:x frame) (:y frame)) + (gmt/translate-matrix)) + + threads-map (->> (mf/deref threads-ref) + (d/mapm #(update %2 :position gpt/transform modifier1))) + + cstate (mf/deref refs/comments-local) + + mframe (geom/transform-shape frame) + threads (->> (vals threads-map) + (dcm/apply-filters cstate profile) + (filter (fn [{:keys [seqn position]}] + (frame-contains? mframe position)))) + + on-bubble-click + (mf/use-callback + (mf/deps cstate) + (fn [thread] + (if (= (:open cstate) (:id thread)) + (st/emit! (dcm/close-thread)) + (st/emit! (dcm/open-thread thread))))) + + on-click + (mf/use-callback + (mf/deps cstate data frame) + (fn [event] + (dom/stop-propagation event) + (if (some? (:open cstate)) + (st/emit! (dcm/close-thread)) + (let [event (.-nativeEvent ^js event) + position (-> (dom/get-offset-position event) + (gpt/transform modifier2)) + params {:position position + :page-id (get-in data [:page :id]) + :file-id (get-in data [:file :id])}] + (st/emit! (dcm/create-draft params)))))) + + on-draft-cancel + (mf/use-callback + (mf/deps cstate) + (st/emitf (dcm/close-thread))) + + on-draft-submit + (mf/use-callback + (mf/deps frame) + (fn [draft] + (let [params (update draft :position gpt/transform modifier2)] + (st/emit! (dcm/create-thread params) + (dcm/close-thread)))))] + + [:div.comments-section {:on-click on-click} + [:div.viewer-comments-container + [:div.threads + (for [item threads] + [:& cmt/thread-bubble {:thread item + :zoom zoom + :on-click on-bubble-click + :open? (= (:id item) (:open cstate)) + :key (:seqn item)}]) + + (when-let [id (:open cstate)] + (when-let [thread (get threads-map id)] + [:& cmt/thread-comments {:thread thread + :users (:users data) + :zoom zoom}])) + + (when-let [draft (:draft cstate)] + [:& cmt/draft-thread {:draft (update draft :position gpt/transform modifier1) + :on-cancel on-draft-cancel + :on-submit on-draft-submit + :zoom zoom}])]]])) + + + +(mf/defc viewport + {::mf/wrap [mf/memo]} + [{:keys [state data index section] :or {zoom 1} :as props}] + (let [zoom (:zoom state) + objects (:objects data) + + frame (get-in data [:frames index]) + frame-id (:id frame) + + modifier (-> (gpt/point (:x frame) (:y frame)) + (gpt/negate) + (gmt/translate-matrix)) + + update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) + + objects (->> (d/concat [frame-id] (cp/get-children frame-id objects)) + (reduce update-fn objects)) + + interactions? (:interactions-show? state) + wrapper (mf/use-memo (mf/deps objects) #(shapes/frame-container-factory objects interactions?)) + + ;; Retrieve frame again with correct modifier + frame (get objects frame-id) + + width (* (:width frame) zoom) + height (* (:height frame) zoom) + vbox (str "0 0 " (:width frame 0) " " (:height frame 0))] + + [:div.viewport-container + {:style {:width width + :height height + :state state + :position "relative"}} + + (when (= section :comments) + [:& comments-layer {:width width + :height height + :frame frame + :data data + :zoom zoom}]) + + [: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 frame + :show-interactions? interactions? + :view-box vbox}]]])) (mf/defc main-panel - [{:keys [data local index]}] + [{:keys [data state index section]}] (let [locale (mf/deref i18n/locale) - frames (:frames data []) - objects (:objects data) + frames (:frames data) frame (get frames index)] [:section.viewer-preview (cond @@ -45,22 +194,20 @@ [:section.empty-state [:span (t locale "viewer.frame-not-found")]] - :else - [:& frame-svg {:frame frame - :show-interactions? (:show-interactions? local) - :zoom (:zoom local) - :objects objects}])])) + (some? state) + [:& viewport + {:data data + :section section + :index index + :state state + }])])) (mf/defc viewer-content - [{:keys [data local index] :as props}] - (let [container (mf/use-ref) - - [toggle-fullscreen fullscreen?] (hooks/use-fullscreen container) - - on-click + [{:keys [data state index section] :as props}] + (let [on-click (fn [event] (dom/stop-propagation event) - (let [mode (get local :interactions-mode)] + (let [mode (get state :interactions-mode)] (when (= mode :show-on-click) (st/emit! dv/flash-interactions)))) @@ -73,49 +220,63 @@ (st/emit! dv/decrease-zoom) (st/emit! dv/increase-zoom))))) + on-click + (fn [event] + (st/emit! (dcm/close-thread))) + + on-key-down + (fn [event] + (when (kbd/esc? event) + (st/emit! (dcm/close-thread)))) + on-mount (fn [] ;; bind with passive=false to allow the event to be cancelled ;; https://stackoverflow.com/a/57582286/3219895 - (let [key1 (events/listen goog/global EventType.WHEEL - on-mouse-wheel #js {"passive" false})] + (let [key1 (events/listen goog/global "wheel" on-mouse-wheel #js {"passive" false}) + key2 (events/listen js/document "keydown" on-key-down) + key3 (events/listen js/document "click" on-click)] (fn [] - (events/unlistenByKey key1))))] + (events/unlistenByKey key1) + (events/unlistenByKey key2) + (events/unlistenByKey key3))))] (mf/use-effect on-mount) (hooks/use-shortcuts dv/shortcuts) - [:div.viewer-layout {:class (classnames :fullscreen fullscreen?) - :ref container} + [:& fs/fullscreen-wrapper {} + [:div.viewer-layout + [:& header + {:data data + :state state + :section section + :index index}] - [:& header {:data data - :toggle-fullscreen toggle-fullscreen - :fullscreen? fullscreen? - :local local - :index index - :screen :viewer}] - [:div.viewer-content {:on-click on-click} - (when (:show-thumbnails local) - [:& thumbnails-panel {:screen :viewer - :index index - :data data}]) - [:& main-panel {:data data - :local local - :index index}]]])) + [:div.viewer-content {:on-click on-click} + (when (:show-thumbnails state) + [:& thumbnails-panel {:screen :viewer + :index index + :data data}]) + [:& main-panel {:data data + :section section + :state state + :index index}]]]])) ;; --- Component: Viewer Page (mf/defc viewer-page - [{:keys [file-id page-id index token] :as props}] + [{:keys [file-id page-id index token section] :as props}] + (mf/use-effect (mf/deps file-id page-id token) - (fn [] - (st/emit! (dv/initialize props)))) + (st/emitf (dv/initialize props))) - (let [data (mf/deref refs/viewer-data) - local (mf/deref refs/viewer-local)] - (when data - [:& viewer-content {:index index - :local local - :data data}]))) + (let [data (mf/deref refs/viewer-data) + state (mf/deref refs/viewer-local)] + (when (and data state) + [:& viewer-content + {:index index + :section section + :state state + :data data}]))) diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 4992e4b95..21e15787d 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -9,21 +9,23 @@ (ns app.main.ui.viewer.header (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.main.ui.icons :as i] + [app.common.math :as mth] + [app.common.uuid :as uuid] [app.main.data.messages :as dm] [app.main.data.viewer :as dv] + [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.fullscreen :as fs] + [app.main.ui.icons :as i] [app.util.data :refer [classnames]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [t]] [app.util.router :as rt] - [app.common.math :as mth] - [app.common.uuid :as uuid] - [app.util.webapi :as wapi])) + [app.util.webapi :as wapi] + [cuerdas.core :as str] + [rumext.alpha :as mf])) (mf/defc zoom-widget {:wrap [mf/memo]} @@ -40,7 +42,7 @@ [:span.dropdown-button i/arrow-down] [:& dropdown {:show @show-dropdown? :on-close #(reset! show-dropdown? false)} - [:ul.zoom-dropdown + [:ul.dropdown.zoom-dropdown [:li {:on-click on-increase} "Zoom in" [:span "+"]] [:li {:on-click on-decrease} @@ -52,37 +54,14 @@ [:li {:on-click on-zoom-to-200} "Zoom to 200%" [:span "Shift + 2"]]]]])) -(mf/defc interactions-menu - [{:keys [interactions-mode] :as props}] - (let [show-dropdown? (mf/use-state false) - locale (i18n/use-locale) - on-select-mode #(st/emit! (dv/set-interactions-mode %))] - [:div.header-icon - [:a {:on-click #(swap! show-dropdown? not)} i/eye - [:& dropdown {:show @show-dropdown? - :on-close #(swap! show-dropdown? not)} - [:ul.custom-select-dropdown - [:li {:key :hide - :class (classnames :selected (= interactions-mode :hide)) - :on-click #(on-select-mode :hide)} - (t locale "viewer.header.dont-show-interactions")] - [:li {:key :show - :class (classnames :selected (= interactions-mode :show)) - :on-click #(on-select-mode :show)} - (t locale "viewer.header.show-interactions")] - [:li {:key :show-on-click - :class (classnames :selected (= interactions-mode :show-on-click)) - :on-click #(on-select-mode :show-on-click)} - (t locale "viewer.header.show-interactions-on-click")]]]]])) - (mf/defc share-link [{:keys [page token] :as props}] (let [show-dropdown? (mf/use-state false) dropdown-ref (mf/use-ref) locale (mf/deref i18n/locale) - create #(st/emit! dv/create-share-link) - delete #(st/emit! dv/delete-share-link) + create (st/emitf (dv/create-share-link)) + delete (st/emitf (dv/delete-share-link)) href (.-href js/location) href (subs href 0 (str/index-of href "?")) @@ -103,7 +82,7 @@ [:& dropdown {:show @show-dropdown? :on-close #(swap! show-dropdown? not) :container dropdown-ref} - [:div.share-link-dropdown {:ref dropdown-ref} + [:div.dropdown.share-link-dropdown {:ref dropdown-ref} [:span.share-link-title (t locale "viewer.header.share.title")] [:div.share-link-input (if (string? token) @@ -121,37 +100,125 @@ [:button.btn-primary {:on-click create} (t locale "viewer.header.share.create-link")])]]]])) +(mf/defc interactions-menu + [{:keys [state locale] :as props}] + (let [imode (:interactions-mode state) + + show-dropdown? (mf/use-state false) + show-dropdown (mf/use-fn #(reset! show-dropdown? true)) + hide-dropdown (mf/use-fn #(reset! show-dropdown? false)) + + select-mode + (mf/use-callback + (fn [mode] + (st/emit! (dv/set-interactions-mode mode))))] + + [:div.view-options + [:div.icon {:on-click #(swap! show-dropdown? not)} i/eye] + [:& dropdown {:show @show-dropdown? + :on-close hide-dropdown} + [:ul.dropdown.with-check + [:li {:class (dom/classnames :selected (= imode :hide)) + :on-click #(select-mode :hide)} + [:span.icon i/tick] + [:span.label (t locale "viewer.header.dont-show-interactions")]] + + [:li {:class (dom/classnames :selected (= imode :show)) + :on-click #(select-mode :show)} + [:span.icon i/tick] + [:span.label (t locale "viewer.header.show-interactions")]] + + [:li {:class (dom/classnames :selected (= imode :show-on-click)) + :on-click #(select-mode :show-on-click)} + [:span.icon i/tick] + [:span.label (t locale "viewer.header.show-interactions-on-click")]]]]])) + + +(mf/defc comments-menu + [{:keys [locale] :as props}] + (let [{cmode :mode cshow :show} (mf/deref refs/comments-local) + + show-dropdown? (mf/use-state false) + show-dropdown (mf/use-fn #(reset! show-dropdown? true)) + hide-dropdown (mf/use-fn #(reset! show-dropdown? false)) + + update-mode + (mf/use-callback + (fn [mode] + (st/emit! (dcm/update-filters {:mode mode})))) + + update-show + (mf/use-callback + (fn [mode] + (st/emit! (dcm/update-filters {:show mode}))))] + + [:div.view-options + [:div.icon {:on-click #(swap! show-dropdown? not)} i/eye] + [:& dropdown {:show @show-dropdown? + :on-close hide-dropdown} + [:ul.dropdown.with-check + [:li {:class (dom/classnames :selected (= :all cmode)) + :on-click #(update-mode :all)} + [:span.icon i/tick] + [:span.label (t locale "labels.show-all-comments")]] + + [:li {:class (dom/classnames :selected (= :yours cmode)) + :on-click #(update-mode :yours)} + [:span.icon i/tick] + [:span.label (t locale "labels.show-your-comments")]] + + [:hr] + + [:li {:class (dom/classnames :selected (= :pending cshow)) + :on-click #(update-show (if (= :pending cshow) :all :pending))} + [:span.icon i/tick] + [:span.label (t locale "labels.hide-resolved-comments")]]]]])) + (mf/defc header - [{:keys [data index local fullscreen? toggle-fullscreen screen] :as props}] + [{:keys [data index section state] :as props}] (let [{:keys [project file page frames]} data - total (count frames) - on-click #(st/emit! dv/toggle-thumbnails-panel) - interactions-mode (:interactions-mode local) + fullscreen (mf/use-ctx fs/fullscreen-context) - locale (i18n/use-locale) - - profile (mf/deref refs/profile) + total (count frames) + locale (mf/deref i18n/locale) + profile (mf/deref refs/profile) anonymous? (= uuid/zero (:id profile)) project-id (get-in data [:project :id]) - file-id (get-in data [:file :id]) - page-id (get-in data [:page :id]) + file-id (get-in data [:file :id]) + page-id (get-in data [:page :id]) - on-edit #(st/emit! (rt/nav :workspace - {:project-id project-id - :file-id file-id} - {:page-id page-id})) + on-click + (mf/use-callback + (st/emitf dv/toggle-thumbnails-panel)) + + on-goback + (mf/use-callback + (mf/deps project-id file-id page-id anonymous?) + (fn [] + (if anonymous? + (st/emit! (rt/nav :login)) + (st/emit! (rt/nav :workspace + {:project-id project-id + :file-id file-id} + {:page-id page-id}))))) + on-edit + (mf/use-callback + (mf/deps project-id file-id page-id) + (st/emitf (rt/nav :workspace + {:project-id project-id + :file-id file-id} + {:page-id page-id}))) + navigate + (mf/use-callback + (mf/deps file-id page-id) + (fn [section] + (st/emit! (dv/go-to-section section))))] - change-screen - (fn [screen] - (st/emit! - (rt/nav screen - {:file-id file-id :page-id page-id} - {:index index})))] [:header.viewer-header [:div.main-icon - [:a {:on-click on-edit} i/logo-icon]] + [:a {:on-click on-goback} i/logo-icon]] [:div.sitemap-zone {:alt (t locale "viewer.header.sitemap") :on-click on-click} @@ -160,22 +227,37 @@ [:span.file-name (:name file)] [:span "/"] [:span.page-name (:name page)] - [:span.dropdown-button i/arrow-down] + [:span.show-thumbnails-button i/arrow-down] [:span.counters (str (inc index) " / " total)]] [:div.mode-zone - [:button.mode-zone-button {:on-click #(when (not= screen :viewer) - (change-screen :viewer)) - :class (when (= screen :viewer) "active")} i/play] - [:button.mode-zone-button {:on-click #(when (not= screen :handoff) - (change-screen :handoff)) - :class (when (= screen :handoff) "active")} i/code]] - - [:div.options-zone - [:& interactions-menu {:interactions-mode interactions-mode}] + [:button.mode-zone-button.tooltip.tooltip-bottom + {:on-click #(navigate :interactions) + :class (dom/classnames :active (= section :interactions)) + :alt "View mode"} + i/play] (when-not anonymous? - [:& share-link {:token (:share-token data) + [:button.mode-zone-button.tooltip.tooltip-bottom + {:on-click #(navigate :comments) + :class (dom/classnames :active (= section :comments)) + :alt "Comments"} + i/chat]) + + [:button.mode-zone-button.tooltip.tooltip-bottom + {:on-click #(navigate :handoff) + :class (dom/classnames :active (= section :handoff)) + :alt "Code mode"} + i/code]] + + [:div.options-zone + (case section + :interactions [:& interactions-menu {:state state :locale locale}] + :comments [:& comments-menu {:locale locale}] + nil) + + (when-not anonymous? + [:& share-link {:token (:token data) :page (:page data)}]) (when-not anonymous? @@ -183,17 +265,17 @@ (t locale "viewer.header.edit-page")]) [:& zoom-widget - {:zoom (:zoom local) - :on-increase #(st/emit! dv/increase-zoom) - :on-decrease #(st/emit! dv/decrease-zoom) - :on-zoom-to-50 #(st/emit! dv/zoom-to-50) - :on-zoom-to-100 #(st/emit! dv/reset-zoom) - :on-zoom-to-200 #(st/emit! dv/zoom-to-200)}] + {:zoom (:zoom state) + :on-increase (st/emitf dv/increase-zoom) + :on-decrease (st/emitf dv/decrease-zoom) + :on-zoom-to-50 (st/emitf dv/zoom-to-50) + :on-zoom-to-100 (st/emitf dv/reset-zoom) + :on-zoom-to-200 (st/emitf dv/zoom-to-200)}] [:span.btn-icon-dark.btn-small.tooltip.tooltip-bottom {:alt (t locale "viewer.header.fullscreen") - :on-click toggle-fullscreen} - (if fullscreen? + :on-click #(if @fullscreen (fullscreen false) (fullscreen true))} + (if @fullscreen i/full-screen-off i/full-screen)] ]])) diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index 8cca3ce1e..d27dfe525 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -13,7 +13,6 @@ [rumext.alpha :as mf] [app.common.data :as d] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.main.data.viewer :as dv] [app.main.refs :as refs] [app.main.store :as st] @@ -38,6 +37,7 @@ :navigate (let [frame-id (:destination interaction)] (st/emit! (dv/go-to-frame frame-id))) + nil))) (defn generic-wrapper-factory @@ -58,7 +58,7 @@ [:> shape-container {:shape shape :on-mouse-down on-mouse-down - :cursor (when (:interactions shape) "pointer")} + :cursor (when (seq (:interactions shape)) "pointer")} [:& component {:shape shape :frame frame :childs childs @@ -110,7 +110,7 @@ (mf/fnc frame-container {::mf/wrap-props false} [props] - (let [shape (unchecked-get props "shape") + (let [shape (obj/get props "shape") childs (mapv #(get objects %) (:shapes shape)) shape (geom/transform-shape shape) props (obj/merge! #js {} props @@ -149,10 +149,11 @@ shape (unchecked-get props "shape") frame (unchecked-get props "frame")] (when (and shape (not (:hidden shape))) - (let [shape (geom/transform-shape frame shape) + (let [shape (-> (geom/transform-shape shape) + (geom/translate-to-frame frame)) opts #js {:shape shape}] (case (:type shape) - :curve [:> path-wrapper opts] + :frame [:g.empty] :text [:> text-wrapper opts] :rect [:> rect-wrapper opts] :path [:> path-wrapper opts] @@ -172,7 +173,7 @@ update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) frame-id (:id frame) - modifier-ids (d/concat [frame-id] (cph/get-children frame-id objects)) + modifier-ids (d/concat [frame-id] (cp/get-children frame-id objects)) objects (reduce update-fn objects modifier-ids) frame (assoc-in frame [:modifiers :displacement] modifier) diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index 57fb01152..b2af4a515 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -111,8 +111,7 @@ on-item-click (fn [event index] (compare-and-set! selected false true) - (st/emit! (rt/nav screen {:file-id file-id - :page-id page-id} {:index index})) + (st/emit! (dv/go-to-frame-by-index index)) (when @expanded? (on-close)))] [:& dropdown' {:on-close on-close diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index ea77a68b9..3995887af 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -27,9 +27,8 @@ [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.scroll :as scroll] [app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]] - [app.main.ui.workspace.viewport :refer [viewport coordinates]] + [app.main.ui.workspace.viewport :refer [viewport viewport-actions coordinates]] [app.util.dom :as dom] [beicon.core :as rx] [cuerdas.core :as str] @@ -65,6 +64,7 @@ (when (contains? layout :rules) [:& workspace-rules {:local local}]) + [:& viewport-actions] [:& viewport {:file file :local local :layout layout}]]] diff --git a/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs index 3599660a0..84a9a5dbf 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/pixel_overlay.cljs @@ -54,106 +54,124 @@ [:& shape-wrapper {:shape item :key (:id item)}]))]])) -(defn draw-picker-canvas [svg-node canvas-node] - (let [canvas-context (.getContext canvas-node "2d") - xml (.serializeToString (js/XMLSerializer.) svg-node) - img-src (str "data:image/svg+xml;base64," - (-> xml js/encodeURIComponent js/unescape js/btoa)) - img (js/Image.) - - on-error (fn [err] (.error js/console "ERROR" err)) - on-load (fn [] (.drawImage canvas-context img 0 0))] - (.addEventListener img "error" on-error) - (.addEventListener img "load" on-load) - (obj/set! img "src" img-src))) - (mf/defc pixel-overlay {::mf/wrap-props false} [props] - (let [vport (unchecked-get props "vport") - vbox (unchecked-get props "vbox") + (let [vport (unchecked-get props "vport") + vbox (unchecked-get props "vbox") viewport-ref (unchecked-get props "viewport-ref") - options (unchecked-get props "options") - svg-ref (mf/use-ref nil) - canvas-ref (mf/use-ref nil) - fetch-pending (mf/deref (mdf/pending-ref)) + options (unchecked-get props "options") + svg-ref (mf/use-ref nil) + canvas-ref (mf/use-ref nil) + img-ref (mf/use-ref nil) - update-canvas-stream (rx/subject) + update-str (rx/subject) handle-keydown - (fn [event] - (when (and (kbd/esc? event)) - (do (dom/stop-propagation event) - (dom/prevent-default event) - (st/emit! (dwc/stop-picker)) - (modal/disallow-click-outside!)))) + (mf/use-callback + (fn [event] + (when (and (kbd/esc? event)) + (do (dom/stop-propagation event) + (dom/prevent-default event) + (st/emit! (dwc/stop-picker)) + (modal/disallow-click-outside!))))) - on-mouse-move-picker - (fn [event] - (when-let [zoom-view-node (.getElementById js/document "picker-detail")] - (let [{brx :left bry :top} (dom/get-bounding-rect (mf/ref-val viewport-ref)) - x (- (.-clientX event) brx) - y (- (.-clientY event) bry) + handle-mouse-move-picker + (mf/use-callback + (mf/deps viewport-ref) + (fn [event] + (when-let [zoom-view-node (.getElementById js/document "picker-detail")] + (let [viewport-node (mf/ref-val viewport-ref) + canvas-node (mf/ref-val canvas-ref) - zoom-context (.getContext zoom-view-node "2d") - canvas-node (mf/ref-val canvas-ref) - canvas-context (.getContext canvas-node "2d") - pixel-data (.getImageData canvas-context x y 1 1) - rgba (.-data pixel-data) - r (obj/get rgba 0) - g (obj/get rgba 1) - b (obj/get rgba 2) - a (obj/get rgba 3) + {brx :left bry :top} (dom/get-bounding-rect viewport-node) + x (- (.-clientX event) brx) + y (- (.-clientY event) bry) - area-data (.getImageData canvas-context (- x 25) (- y 20) 50 40)] + zoom-context (.getContext zoom-view-node "2d") + canvas-context (.getContext canvas-node "2d") + pixel-data (.getImageData canvas-context x y 1 1) + rgba (.-data pixel-data) + r (obj/get rgba 0) + g (obj/get rgba 1) + b (obj/get rgba 2) + a (obj/get rgba 3) + area-data (.getImageData canvas-context (- x 25) (- y 20) 50 40)] + (-> (js/createImageBitmap area-data) + (p/then + (fn [image] + ;; Draw area + (obj/set! zoom-context "imageSmoothingEnabled" false) + (.drawImage zoom-context image 0 0 200 160)))) + (st/emit! (dwc/pick-color [r g b a])))))) - (-> (js/createImageBitmap area-data) - (p/then (fn [image] - ;; Draw area - (obj/set! zoom-context "imageSmoothingEnabled" false) - (.drawImage zoom-context image 0 0 200 160)))) - (st/emit! (dwc/pick-color [r g b a]))))) + handle-mouse-down-picker + (mf/use-callback + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dwc/pick-color-select true (kbd/shift? event))))) - on-mouse-down-picker - (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - (st/emit! (dwc/pick-color-select true (kbd/shift? event)))) + handle-mouse-up-picker + (mf/use-callback + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dwc/stop-picker)) + (modal/disallow-click-outside!))) - on-mouse-up-picker - (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - (st/emit! (dwc/stop-picker)) - (modal/disallow-click-outside!))] + handle-image-load + (mf/use-callback + (mf/deps img-ref) + (fn [] + (let [canvas-node (mf/ref-val canvas-ref) + img-node (mf/ref-val img-ref) + canvas-context (.getContext canvas-node "2d")] + (.drawImage canvas-context img-node 0 0)))) + + handle-draw-picker-canvas + (mf/use-callback + (mf/deps img-ref) + (fn [] + (let [img-node (mf/ref-val img-ref) + svg-node (mf/ref-val svg-ref) + xml (-> (js/XMLSerializer.) + (.serializeToString svg-node) + js/encodeURIComponent + js/unescape + js/btoa) + img-src (str "data:image/svg+xml;base64," xml)] + (obj/set! img-node "src" img-src)))) + + handle-svg-change + (mf/use-callback + (fn [] + (rx/push! update-str :update)))] (mf/use-effect (fn [] - (let [listener (events/listen (dom/get-root) EventType.KEYDOWN handle-keydown)] + (let [listener (events/listen js/document EventType.KEYDOWN handle-keydown)] #(events/unlistenByKey listener)))) (mf/use-effect (fn [] - (let [sub (->> update-canvas-stream + (let [sub (->> update-str (rx/debounce 10) - (rx/subs #(draw-picker-canvas (mf/ref-val svg-ref) - (mf/ref-val canvas-ref))))] - + (rx/subs handle-draw-picker-canvas))] #(rx/dispose! sub)))) (mf/use-effect - (mf/deps svg-ref canvas-ref) + (mf/deps svg-ref) (fn [] - (when (and svg-ref canvas-ref) - - (let [config (clj->js {:attributes true - :childList true - :subtree true - :characterData true}) - on-svg-change (fn [mutation-list] (rx/push! update-canvas-stream :update)) - observer (js/MutationObserver. on-svg-change)] - - (.observe observer (mf/ref-val svg-ref) config) + (when svg-ref + (let [config #js {:attributes true + :childList true + :subtree true + :characterData true} + svg-node (mf/ref-val svg-ref) + observer (js/MutationObserver. handle-svg-change)] + (.observe observer svg-node config) + (handle-svg-change) ;; Disconnect on unmount #(.disconnect observer))))) @@ -167,21 +185,31 @@ :width "100%" :height "100%" :cursor cur/picker} - :on-mouse-down on-mouse-down-picker - :on-mouse-up on-mouse-up-picker - :on-mouse-move on-mouse-move-picker}] - [:canvas {:ref canvas-ref - :width (:width vport 0) - :height (:height vport 0) - :style {:display "none"}}] + :on-mouse-down handle-mouse-down-picker + :on-mouse-up handle-mouse-up-picker + :on-mouse-move handle-mouse-move-picker} + [:div {:style {:display "none"}} + [:img {:ref img-ref + :on-load handle-image-load + :style {:position "absolute" + :width "100%" + :height "100%"}}] + [:canvas {:ref canvas-ref + :width (:width vport 0) + :height (:height vport 0) + :style {:position "absolute" + :width "100%" + :height "100%"}}] - [:& (mf/provider muc/embed-ctx) {:value true} - [:svg.viewport - {:ref svg-ref - :preserveAspectRatio "xMidYMid meet" - :width (:width vport 0) - :height (:height vport 0) - :view-box (format-viewbox vbox) - :style {:display "none" - :background-color (get options :background "#E8E9EA")}} - [:& overlay-frames]]]])) + [:& (mf/provider muc/embed-ctx) {:value true} + [:svg.viewport + {:ref svg-ref + :preserveAspectRatio "xMidYMid meet" + :width (:width vport 0) + :height (:height vport 0) + :view-box (format-viewbox vbox) + :style {:position "absolute" + :width "100%" + :height "100%" + :background-color (get options :background "#E8E9EA")}} + [:& overlay-frames]]]]]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/pixel_picker.cljs b/frontend/src/app/main/ui/workspace/colorpicker/pixel_picker.cljs deleted file mode 100644 index 21a35a0fc..000000000 --- a/frontend/src/app/main/ui/workspace/colorpicker/pixel_picker.cljs +++ /dev/null @@ -1,29 +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/. -;; -;; This Source Code Form is "Incompatible With Secondary Licenses", as -;; defined by the Mozilla Public License, v. 2.0. -;; -;; Copyright (c) 2020 UXBOX Labs SL - -(ns app.main.ui.workspace.colorpicker.pixel-picker - (:require - [rumext.alpha :as mf] - [okulary.core :as l] - [cuerdas.core :as str] - [app.common.geom.point :as gpt] - [app.common.math :as math] - [app.common.uuid :refer [uuid]] - [app.util.dom :as dom] - [app.util.color :as uc] - [app.util.object :as obj] - [app.main.store :as st] - [app.main.refs :as refs] - [app.main.data.workspace.libraries :as dwl] - [app.main.data.colors :as dc] - [app.main.data.modal :as modal] - [app.main.ui.icons :as i] - [app.util.i18n :as i18n :refer [t]])) - - diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index dd9860298..8475ab4d2 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -12,362 +12,52 @@ [app.config :as cfg] [app.main.data.workspace :as dw] [app.main.data.workspace.comments :as dwcm] - [app.main.data.workspace.common :as dwc] + [app.main.data.comments :as dcm] [app.main.refs :as refs] [app.main.store :as st] - [app.main.streams :as ms] [app.main.ui.context :as ctx] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.data.modal :as modal] - [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] - [app.main.ui.keyboard :as kbd] - [app.main.ui.workspace.colorpicker] - [app.main.ui.workspace.context-menu :refer [context-menu]] + [app.main.ui.comments :as cmt] [app.util.time :as dt] [app.util.timers :as tm] [app.util.dom :as dom] - [app.util.object :as obj] - [beicon.core :as rx] [app.util.i18n :as i18n :refer [t tr]] [cuerdas.core :as str] [okulary.core :as l] [rumext.alpha :as mf])) -(declare group-threads-by-page) -(declare apply-filters) - -(mf/defc resizing-textarea - {::mf/wrap-props false} - [props] - (let [value (obj/get props "value" "") - on-focus (obj/get props "on-focus") - on-blur (obj/get props "on-blur") - placeholder (obj/get props "placeholder") - on-change (obj/get props "on-change") - - on-esc (obj/get props "on-esc") - - ref (mf/use-ref) - ;; state (mf/use-state value) - - on-key-down - (mf/use-callback - (fn [event] - (when (and (kbd/esc? event) - (fn? on-esc)) - (on-esc event)))) - - on-change* - (mf/use-callback - (mf/deps on-change) - (fn [event] - (let [content (dom/get-target-val event)] - (on-change content))))] - - - (mf/use-layout-effect - nil - (fn [] - (let [node (mf/ref-val ref)] - (set! (.-height (.-style node)) "0") - (set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px"))))) - - [:textarea - {:ref ref - :on-key-down on-key-down - :on-focus on-focus - :on-blur on-blur - :value value - :placeholder placeholder - :on-change on-change*}])) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Workspace -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(mf/defc reply-form - [{:keys [thread] :as props}] - (let [show-buttons? (mf/use-state false) - content (mf/use-state "") - - on-focus - (mf/use-callback - #(reset! show-buttons? true)) - - on-blur - (mf/use-callback - #(reset! show-buttons? false)) - - on-change - (mf/use-callback - #(reset! content %)) - - on-cancel - (mf/use-callback - #(do (reset! content "") - (reset! show-buttons? false))) - - on-submit - (mf/use-callback - (mf/deps thread @content) - (fn [] - (st/emit! (dwcm/add-comment thread @content)) - (on-cancel)))] - - [:div.reply-form - [:& resizing-textarea {:value @content - :placeholder "Reply" - :on-blur on-blur - :on-focus on-focus - :on-change on-change}] - (when (or @show-buttons? - (not (empty? @content))) - [:div.buttons - [:input.btn-primary {:type "button" :value "Post" :on-click on-submit}] - [:input.btn-secondary {:type "button" :value "Cancel" :on-click on-cancel}]])])) - -(mf/defc draft-thread - [{:keys [draft zoom] :as props}] - (let [position (:position draft) - content (:content draft) - pos-x (* (:x position) zoom) - pos-y (* (:y position) zoom) - - on-esc - (mf/use-callback - (mf/deps draft) - (st/emitf :interrupt)) - - on-change - (mf/use-callback - (mf/deps draft) - (fn [content] - (st/emit! (dwcm/update-draft-thread (assoc draft :content content))))) - - on-submit - (mf/use-callback - (mf/deps draft) - (st/emitf (dwcm/create-thread draft)))] - - [:* - [:div.thread-bubble - {:style {:top (str pos-y "px") - :left (str pos-x "px")}} - [:span "?"]] - [:div.thread-content - {:style {:top (str (- pos-y 14) "px") - :left (str (+ pos-x 14) "px")}} - [:div.reply-form - [:& resizing-textarea {:placeholder "Write new comment" - :value content - :on-esc on-esc - :on-change on-change}] - [:div.buttons - [:input.btn-primary - {:on-click on-submit - :type "button" - :value "Post"}] - [:input.btn-secondary - {:on-click on-esc - :type "button" - :value "Cancel"}]]]]])) - - -(mf/defc edit-form - [{:keys [content on-submit on-cancel] :as props}] - (let [content (mf/use-state content) - - on-change - (mf/use-callback - #(reset! content %)) - - on-submit* - (mf/use-callback - (mf/deps @content) - (fn [] (on-submit @content)))] - - [:div.reply-form.edit-form - [:& resizing-textarea {:value @content - :on-change on-change}] - [:div.buttons - [:input.btn-primary {:type "button" :value "Post" :on-click on-submit*}] - [:input.btn-secondary {:type "button" :value "Cancel" :on-click on-cancel}]]])) - - -(mf/defc comment-item - [{:keys [comment thread] :as props}] - (let [profile (get @refs/workspace-users (:owner-id comment)) - - options (mf/use-state false) - edition? (mf/use-state false) - - on-show-options - (mf/use-callback #(reset! options true)) - - on-hide-options - (mf/use-callback #(reset! options false)) - - on-edit-clicked - (mf/use-callback - (fn [] - (reset! options false) - (reset! edition? true))) - - on-delete-comment - (mf/use-callback - (mf/deps comment) - (st/emitf (dwcm/delete-comment comment))) - - delete-thread - (mf/use-callback - (mf/deps thread) - (st/emitf (dwcm/close-thread) - (dwcm/delete-comment-thread thread))) - - - on-delete-thread - (mf/use-callback - (mf/deps thread) - (st/emitf (modal/show - {:type :confirm - :title (tr "modals.delete-comment-thread.title") - :message (tr "modals.delete-comment-thread.message") - :accept-label (tr "modals.delete-comment-thread.accept") - :on-accept delete-thread}))) - - on-submit - (mf/use-callback - (mf/deps comment thread) - (fn [content] - (reset! edition? false) - (st/emit! (dwcm/update-comment (assoc comment :content content))))) - - on-cancel - (mf/use-callback #(reset! edition? false)) - - toggle-resolved - (mf/use-callback - (mf/deps thread) - (st/emitf (dwcm/update-comment-thread (update thread :is-resolved not))))] - - [:div.comment-container - [:div.comment - [:div.author - [:div.avatar - [:img {:src (cfg/resolve-media-path (:photo profile))}]] - [:div.name - [:div.fullname (:fullname profile)] - [:div.timeago (dt/timeago (:modified-at comment))]] - - (when (some? thread) - [:div.options-resolve {:on-click toggle-resolved} - (if (:is-resolved thread) - [:span i/checkbox-checked] - [:span i/checkbox-unchecked])]) - - [:div.options - [:div.options-icon {:on-click on-show-options} i/actions]]] - - [:div.content - (if @edition? - [:& edit-form {:content (:content comment) - :on-submit on-submit - :on-cancel on-cancel}] - [:span.text (:content comment)])]] - - [:& dropdown {:show @options - :on-close on-hide-options} - [:ul.dropdown.comment-options-dropdown - [:li {:on-click on-edit-clicked} "Edit"] - (if thread - [:li {:on-click on-delete-thread} "Delete thread"] - [:li {:on-click on-delete-comment} "Delete comment"])]]])) - -(defn comments-ref - [{:keys [id] :as thread}] - (l/derived (l/in [:comments id]) st/state)) - -(mf/defc thread-comments - [{:keys [thread zoom]}] - (let [ref (mf/use-ref) - pos (:position thread) - pos-x (+ (* (:x pos) zoom) 14) - pos-y (- (* (:y pos) zoom) 14) - - comments-ref (mf/use-memo (mf/deps thread) #(comments-ref thread)) - comments-map (mf/deref comments-ref) - comments (->> (vals comments-map) - (sort-by :created-at)) - comment (first comments)] - - (mf/use-effect - (st/emitf (dwcm/update-comment-thread-status thread))) - - (mf/use-effect - (mf/deps thread) - (st/emitf (dwcm/retrieve-comments (:id thread)))) - - (mf/use-layout-effect - (mf/deps thread comments-map) - (fn [] - (when-let [node (mf/ref-val ref)] - (.scrollIntoView ^js node)))) - - [:div.thread-content - {:style {:top (str pos-y "px") - :left (str pos-x "px")}} - - [:div.comments - [:& comment-item {:comment comment - :thread thread}] - (for [item (rest comments)] - [:* - [:hr] - [:& comment-item {:comment item}]]) - [:div {:ref ref}]] - [:& reply-form {:thread thread}]])) - -(mf/defc thread-bubble - {::mf/wrap [mf/memo]} - [{:keys [thread zoom open?] :as params}] - (let [pos (:position thread) - pos-x (* (:x pos) zoom) - pos-y (* (:y pos) zoom) - - on-open-toggle - (mf/use-callback - (mf/deps thread open?) - (fn [] - (if open? - (st/emit! (dwcm/close-thread)) - (st/emit! (dwcm/open-thread thread)))))] - - [:div.thread-bubble - {:style {:top (str pos-y "px") - :left (str pos-x "px")} - :class (dom/classnames - :resolved (:is-resolved thread) - :unread (pos? (:count-unread-comments thread))) - :on-click on-open-toggle} - [:span (:seqn thread)]])) - (def threads-ref (l/derived :comment-threads st/state)) -(def workspace-comments-ref - (l/derived :workspace-comments st/state)) - (mf/defc comments-layer [{:keys [vbox vport zoom file-id page-id drawing] :as props}] (let [pos-x (* (- (:x vbox)) zoom) pos-y (* (- (:y vbox)) zoom) + profile (mf/deref refs/profile) - local (mf/deref workspace-comments-ref) + users (mf/deref refs/users) + local (mf/deref refs/comments-local) threads-map (mf/deref threads-ref) + threads (->> (vals threads-map) (filter #(= (:page-id %) page-id)) - (apply-filters local profile))] + (dcm/apply-filters local profile)) + + on-bubble-click + (fn [{:keys [id] :as thread}] + (if (= (:open local) id) + (st/emit! (dcm/close-thread)) + (st/emit! (dcm/open-thread thread)))) + + on-draft-cancel + (mf/use-callback + (st/emitf :interrupt)) + + on-draft-submit + (mf/use-callback + (fn [draft] + (st/emit! (dcm/create-thread draft))))] (mf/use-effect (mf/deps file-id) @@ -376,174 +66,123 @@ (fn [] (st/emit! ::dwcm/finalize)))) - [:div.workspace-comments - {:style {:width (str (:width vport) "px") - :height (str (:height vport) "px")}} - [:div.threads {:style {:transform (str/format "translate(%spx, %spx)" pos-x pos-y)}} - (for [item threads] - [:& thread-bubble {:thread item - :zoom zoom - :open? (= (:id item) (:open local)) - :key (:seqn item)}]) + [:div.comments-section + [:div.workspace-comments-container + {:style {:width (str (:width vport) "px") + :height (str (:height vport) "px")}} + [:div.threads {:style {:transform (str/format "translate(%spx, %spx)" pos-x pos-y)}} + (for [item threads] + [:& cmt/thread-bubble {:thread item + :zoom zoom + :on-click on-bubble-click + :open? (= (:id item) (:open local)) + :key (:seqn item)}]) - (when-let [id (:open local)] - (when-let [thread (get threads-map id)] - [:& thread-comments {:thread thread - :zoom zoom}])) + (when-let [id (:open local)] + (when-let [thread (get threads-map id)] + [:& cmt/thread-comments {:thread thread + :users users + :zoom zoom}])) - (when-let [draft (:comment drawing)] - [:& draft-thread {:draft draft :zoom zoom}])]])) + (when-let [draft (:comment drawing)] + [:& cmt/draft-thread {:draft draft + :on-cancel on-draft-cancel + :on-submit on-draft-submit + :zoom zoom}])]]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Sidebar ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(mf/defc sidebar-group-item - [{:keys [item] :as props}] - (let [profile (get @refs/workspace-users (:owner-id item)) - page-id (mf/use-ctx ctx/current-page-id) - file-id (mf/use-ctx ctx/current-file-id) - - on-click - (mf/use-callback - (mf/deps item page-id) - (fn [] - (when (not= page-id (:page-id item)) - (st/emit! (dw/go-to-page (:page-id item)))) - (tm/schedule - (st/emitf (dwcm/center-to-comment-thread item) - (dwcm/open-thread item)))))] - - [:div.comment {:on-click on-click} - [:div.author - [:div.thread-bubble - {:class (dom/classnames - :resolved (:is-resolved item) - :unread (pos? (:count-unread-comments item)))} - (:seqn item)] - [:div.avatar - [:img {:src (cfg/resolve-media-path (:photo profile))}]] - [:div.name - [:div.fullname (:fullname profile) ", "] - [:div.timeago (dt/timeago (:modified-at item))]]] - [:div.content - [:span.text (:content item)]] - [:div.content.replies - (let [unread (:count-unread-comments item ::none) - total (:count-comments item 1)] - [:* - (when (> total 1) - (if (= total 2) - [:span.total-replies "1 reply"] - [:span.total-replies (str (dec total) " replies")])) - - (when (and (> total 1) (> unread 0)) - (if (= unread 1) - [:span.new-replies "1 new reply"] - [:span.new-replies (str unread " new replies")]))])]])) - -(defn page-name-ref - [id] - (l/derived (l/in [:workspace-data :pages-index id :name]) st/state)) - -(mf/defc sidebar-item - [{:keys [group]}] - (let [page-name-ref (mf/use-memo (mf/deps (:page-id group)) #(page-name-ref (:page-id group))) - page-name (mf/deref page-name-ref)] - [:div.page-section - [:div.section-title - [:span.icon i/file-html] - [:span.label page-name]] - [:div.comments-container - (for [item (:items group)] - [:& sidebar-group-item {:item item :key (:id item)}])]])) - (mf/defc sidebar-options [{:keys [local] :as props}] - (let [filter-yours - (mf/use-callback - (mf/deps local) - (st/emitf (dwcm/update-filters {:main :yours}))) + (let [{cmode :mode cshow :show} (mf/deref refs/comments-local) + locale (mf/deref i18n/locale) - filter-all + update-mode (mf/use-callback - (mf/deps local) - (st/emitf (dwcm/update-filters {:main :all}))) + (fn [mode] + (st/emit! (dcm/update-filters {:mode mode})))) - toggle-resolved + update-show (mf/use-callback - (mf/deps local) - (st/emitf (dwcm/update-filters {:resolved (not (:filter-resolved local))})))] + (fn [mode] + (st/emit! (dcm/update-filters {:show mode}))))] - [:ul.dropdown.sidebar-options-dropdown - [:li {:on-click filter-all} "All"] - [:li {:on-click filter-yours} "Only yours"] - [:hr] - (if (:filter-resolved local) - [:li {:on-click toggle-resolved} "Show resolved comments"] - [:li {:on-click toggle-resolved} "Hide resolved comments"])])) + [:ul.dropdown.with-check + [:li {:class (dom/classnames :selected (or (= :all cmode) (nil? cmode))) + :on-click #(update-mode :all)} + [:span.icon i/tick] + [:span.label (t locale "labels.show-all-comments")]] + + [:li {:class (dom/classnames :selected (= :yours cmode)) + :on-click #(update-mode :yours)} + [:span.icon i/tick] + [:span.label (t locale "labels.show-your-comments")]] + + [:hr] + + [:li {:class (dom/classnames :selected (= :pending cshow)) + :on-click #(update-show (if (= :pending cshow) :all :pending))} + [:span.icon i/tick] + [:span.label (t locale "labels.hide-resolved-comments")]]])) (mf/defc comments-sidebar [] (let [threads-map (mf/deref threads-ref) profile (mf/deref refs/profile) - local (mf/deref workspace-comments-ref) + users (mf/deref refs/users) + local (mf/deref refs/comments-local) options? (mf/use-state false) tgroups (->> (vals threads-map) (sort-by :modified-at) (reverse) - (apply-filters local profile) - (group-threads-by-page))] + (dcm/apply-filters local profile) + (dcm/group-threads-by-page)) - [:div.workspace-comments.workspace-comments-sidebar - [:div.sidebar-title + page-id (mf/use-ctx ctx/current-page-id) + + on-thread-click + (mf/use-callback + (fn [thread] + (when (not= page-id (:page-id thread)) + (st/emit! (dw/go-to-page (:page-id thread)))) + (tm/schedule + (st/emitf (dwcm/center-to-comment-thread thread) + (dcm/open-thread thread)))))] + + [:div.comments-section.comment-threads-section + [:div.workspace-comment-threads-sidebar-header [:div.label "Comments"] [:div.options {:on-click #(reset! options? true)} - [:div.label (case (:filter local) - (nil :all) "All" - :yours "Only yours")] - [:div.icon i/arrow-down]]] + [:div.label (case (:mode local) + (nil :all) (tr "labels.all") + :yours (tr "labels.only-yours"))] + [:div.icon i/arrow-down]] - [:& dropdown {:show @options? - :on-close #(reset! options? false)} - [:& sidebar-options {:local local}]] + [:& dropdown {:show @options? + :on-close #(reset! options? false)} + [:& sidebar-options {:local local}]]] - (when (seq tgroups) - [:div.threads - [:& sidebar-item {:group (first tgroups)}] + (if (seq tgroups) + [:div.thread-groups + [:& cmt/comment-thread-group + {:group (first tgroups) + :on-thread-click on-thread-click + :users users}] (for [tgroup (rest tgroups)] [:* [:hr] - [:& sidebar-item {:group tgroup - :key (:page-id tgroup)}]])])])) + [:& cmt/comment-thread-group + {:group tgroup + :on-thread-click on-thread-click + :users users + :key (:page-id tgroup)}]])] + + [:div.thread-groups-placeholder + i/chat + (tr "labels.no-comments-available")])])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Helpers -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- group-threads-by-page - [threads] - (letfn [(group-by-page [result thread] - (let [current (first result)] - (if (= (:page-id current) (:page-id thread)) - (cons (update current :items conj thread) - (rest result)) - (cons {:page-id (:page-id thread) :items [thread]} - result))))] - (reverse - (reduce group-by-page nil threads)))) - -(defn- apply-filters - [local profile threads] - (cond->> threads - (true? (:filter-resolved local)) - (filter (fn [item] - (or (not (:is-resolved item)) - (= (:id item) (:open local))))) - - (= :yours (:filter local)) - (filter #(contains? (:participants %) (:id profile))))) - diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 5928fc178..de5180966 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -10,21 +10,21 @@ (ns app.main.ui.workspace.context-menu "A workspace specific context menu (mouse right click)." (:require - [beicon.core :as rx] - [okulary.core :as l] - [potok.core :as ptk] - [rumext.alpha :as mf] - [app.main.store :as st] - [app.main.refs :as refs] - [app.main.streams :as ms] - [app.main.ui.icons :as i] - [app.util.dom :as dom] - [app.util.i18n :refer [t] :as i18n] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.streams :as ms] + [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.hooks :refer [use-rxsub]] - [app.main.ui.components.dropdown :refer [dropdown]])) + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.i18n :refer [t] :as i18n] + [beicon.core :as rx] + [okulary.core :as l] + [potok.core :as ptk] + [rumext.alpha :as mf])) (def menu-ref (l/derived :context-menu refs/workspace-local)) @@ -71,10 +71,10 @@ do-detach-component #(st/emit! (dwl/detach-component id)) do-reset-component #(st/emit! (dwl/reset-component id)) do-update-component #(do - (st/emit! dwc/start-undo-transaction) + (st/emit! (dwc/start-undo-transaction)) (st/emit! (dwl/update-component id)) (st/emit! (dwl/sync-file nil)) - (st/emit! dwc/commit-undo-transaction)) + (st/emit! (dwc/commit-undo-transaction))) do-show-component #(st/emit! (dw/go-to-layout :assets)) do-navigate-component-file #(st/emit! (dwl/nav-to-component-file (:component-file shape)))] @@ -140,8 +140,9 @@ [:& menu-entry {:title (t locale "workspace.shape.menu.lock") :on-click do-lock-shape}]) - (when (or (nil? (:shape-ref shape)) - (> (count selected) 1)) + (when (and (or (nil? (:shape-ref shape)) + (> (count selected) 1)) + (not= (:type shape) :frame)) [:* [:& menu-separator] [:& menu-entry {:title (t locale "workspace.shape.menu.create-component") diff --git a/frontend/src/app/main/ui/workspace/drawarea.cljs b/frontend/src/app/main/ui/workspace/drawarea.cljs index 33143af7f..3c365dea0 100644 --- a/frontend/src/app/main/ui/workspace/drawarea.cljs +++ b/frontend/src/app/main/ui/workspace/drawarea.cljs @@ -12,6 +12,8 @@ [app.main.data.workspace.drawing :as dd] [app.main.store :as st] [app.main.ui.workspace.shapes :as shapes] + [app.main.ui.shapes.path :refer [path-shape]] + [app.main.ui.workspace.shapes.path.editor :refer [path-editor]] [app.common.geom.shapes :as gsh] [app.common.data :as d] [app.util.dom :as dom] @@ -21,11 +23,15 @@ (declare path-draw-area) (mf/defc draw-area - [{:keys [shape zoom] :as props}] - (when (:id shape) - (case (:type shape) - (:path :curve) [:& path-draw-area {:shape shape}] - [:& generic-draw-area {:shape shape :zoom zoom}]))) + [{:keys [shape zoom tool] :as props}] + + [:g.draw-area + [:& shapes/shape-wrapper {:shape shape}] + + (case tool + :path [:& path-editor {:shape shape :zoom zoom}] + :curve [:& path-shape {:shape shape :zoom zoom}] + #_:default [:& generic-draw-area {:shape shape :zoom zoom}])]) (mf/defc generic-draw-area [{:keys [shape zoom]}] @@ -34,43 +40,10 @@ (not (d/nan? x)) (not (d/nan? y))) - [:g - [:& shapes/shape-wrapper {:shape shape}] - [:rect.main {:x x :y y - :width width - :height height - :style {:stroke "#1FDEA7" - :fill "transparent" - :stroke-width (/ 1 zoom)}}]]))) + [:rect.main {:x x :y y + :width width + :height height + :style {:stroke "#1FDEA7" + :fill "transparent" + :stroke-width (/ 1 zoom)}}]))) -(mf/defc path-draw-area - [{:keys [shape] :as props}] - (let [locale (i18n/use-locale) - - on-click - (fn [event] - (dom/stop-propagation event) - (st/emit! (dw/assign-cursor-tooltip nil) - dd/close-drawing-path - :path/end-path-drawing)) - - on-mouse-enter - (fn [event] - (let [msg (t locale "workspace.viewport.click-to-close-path")] - (st/emit! (dw/assign-cursor-tooltip msg)))) - - on-mouse-leave - (fn [event] - (st/emit! (dw/assign-cursor-tooltip nil)))] - - (when-let [{:keys [x y] :as segment} (first (:segments shape))] - [:g - [:& shapes/shape-wrapper {:shape shape}] - (when (not= :curve (:type shape)) - [:circle.close-bezier - {:cx x - :cy y - :r 5 - :on-click on-click - :on-mouse-enter on-mouse-enter - :on-mouse-leave on-mouse-leave}])]))) diff --git a/frontend/src/app/main/ui/workspace/effects.cljs b/frontend/src/app/main/ui/workspace/effects.cljs new file mode 100644 index 000000000..8937e7513 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/effects.cljs @@ -0,0 +1,80 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.effects + (:require + [rumext.alpha :as mf] + [app.util.dom :as dom] + [app.main.data.workspace.selection :as dws] + [app.main.store :as st] + [app.main.data.workspace :as dw] + [app.main.refs :as refs] + [app.main.ui.keyboard :as kbd])) + +(defn use-pointer-enter + [{:keys [id]}] + (mf/use-callback + (mf/deps id) + (fn [] + (st/emit! (dws/change-hover-state id true))))) + +(defn use-pointer-leave + [{:keys [id]}] + (mf/use-callback + (mf/deps id) + (fn [] + (st/emit! (dws/change-hover-state id false))))) + +(defn use-context-menu + [shape] + (mf/use-callback + (mf/deps shape) + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (let [position (dom/get-client-position event)] + (st/emit! (dw/show-shape-context-menu {:position position :shape shape})))))) + +(defn use-mouse-down + [{:keys [id type blocked]}] + (mf/use-callback + (mf/deps id type blocked) + (fn [event] + (let [selected @refs/selected-shapes + edition @refs/selected-edition + selected? (contains? selected id) + drawing? @refs/selected-drawing-tool + button (.-which (.-nativeEvent event))] + (when-not blocked + (cond + (not= 1 button) + nil + + drawing? + nil + + (= type :frame) + (when selected? + (do + (dom/stop-propagation event) + (st/emit! (dw/start-move-selected)))) + + :else + (do + (dom/stop-propagation event) + (if selected? + (when (kbd/shift? event) + (st/emit! (dw/select-shape id true))) + (do + (when-not (or (empty? selected) (kbd/shift? event)) + (st/emit! (dw/deselect-all))) + (st/emit! (dw/select-shape id)))) + + (when (not= edition id) + (st/emit! (dw/start-move-selected)))))))))) diff --git a/frontend/src/app/main/ui/workspace/header.cljs b/frontend/src/app/main/ui/workspace/header.cljs index 3ceaa5aaf..d79f42ce7 100644 --- a/frontend/src/app/main/ui/workspace/header.cljs +++ b/frontend/src/app/main/ui/workspace/header.cljs @@ -9,23 +9,21 @@ (ns app.main.ui.workspace.header (:require - [okulary.core :as l] - [rumext.alpha :as mf] - [app.main.ui.icons :as i :include-macros true] + [app.common.math :as mth] [app.config :as cfg] - [app.main.data.history :as udh] + [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.data.modal :as modal] - [app.main.ui.workspace.presence :as presence] + [app.main.ui.icons :as i] [app.main.ui.keyboard :as kbd] - [app.util.i18n :as i18n :refer [t]] - [app.util.data :refer [classnames]] + [app.main.ui.workspace.presence :refer [active-sessions]] [app.util.dom :as dom] - [app.common.math :as mth] - [app.util.router :as rt])) + [app.util.i18n :as i18n :refer [tr]] + [app.util.router :as rt] + [okulary.core :as l] + [rumext.alpha :as mf])) ;; --- Zoom Widget @@ -34,28 +32,28 @@ (mf/defc persistence-state-widget {::mf/wrap [mf/memo]} - [{:keys [locale]}] + [] (let [data (mf/deref workspace-persistence-ref)] [:div.persistence-status-widget (cond (= :pending (:status data)) [:div.pending - [:span.label (t locale "workspace.header.unsaved")]] + [:span.label (tr "workspace.header.unsaved")]] (= :saving (:status data)) [:div.saving [:span.icon i/toggle] - [:span.label (t locale "workspace.header.saving")]] + [:span.label (tr "workspace.header.saving")]] (= :saved (:status data)) [:div.saved [:span.icon i/tick] - [:span.label (t locale "workspace.header.saved")]] + [:span.label (tr "workspace.header.saved")]] (= :error (:status data)) [:div.error {:title "There was an error saving the data. Please refresh if this persists."} [:span.icon i/msg-warning] - [:span.label (t locale "workspace.header.save-error")]])])) + [:span.label (tr "workspace.header.save-error")]])])) (mf/defc zoom-widget @@ -108,10 +106,10 @@ (st/emitf (modal/show {:type :confirm :message "" - :title (t locale "modals.add-shared-confirm.message" (:name file)) - :hint (t locale "modals.add-shared-confirm.hint") + :title (tr "modals.add-shared-confirm.message" (:name file)) + :hint (tr "modals.add-shared-confirm.hint") :cancel-label :omit - :accept-label (t locale "modals.add-shared-confirm.accept") + :accept-label (tr "modals.add-shared-confirm.accept") :accept-style :primary :on-accept add-shared-fn}))) @@ -121,10 +119,10 @@ (st/emitf (modal/show {:type :confirm :message "" - :title (t locale "modals.remove-shared-confirm.message" (:name file)) - :hint (t locale "modals.remove-shared-confirm.hint") + :title (tr "modals.remove-shared-confirm.message" (:name file)) + :hint (tr "modals.remove-shared-confirm.hint") :cancel-label :omit - :accept-label (t locale "modals.remove-shared-confirm.accept") + :accept-label (tr "modals.remove-shared-confirm.accept") :on-accept del-shared-fn}))) @@ -146,7 +144,7 @@ [:div.menu-section [:div.btn-icon-dark.btn-small {:on-click #(reset! show-menu? true)} i/actions] - [:div.project-tree {:alt (t locale "workspace.sitemap")} + [:div.project-tree {:alt (tr "workspace.sitemap")} [:span.project-name {:on-click #(st/emit! (rt/navigate :dashboard-project {:team-id team-id :project-id (:project-id file)}))} @@ -171,69 +169,82 @@ [:li {:on-click #(st/emit! (dw/toggle-layout-flags :rules))} [:span (if (contains? layout :rules) - (t locale "workspace.header.menu.hide-rules") - (t locale "workspace.header.menu.show-rules"))] + (tr "workspace.header.menu.hide-rules") + (tr "workspace.header.menu.show-rules"))] [:span.shortcut "Ctrl+shift+R"]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :display-grid))} [:span (if (contains? layout :display-grid) - (t locale "workspace.header.menu.hide-grid") - (t locale "workspace.header.menu.show-grid"))] + (tr "workspace.header.menu.hide-grid") + (tr "workspace.header.menu.show-grid"))] [:span.shortcut "Ctrl+'"]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :snap-grid))} [:span (if (contains? layout :snap-grid) - (t locale "workspace.header.menu.disable-snap-grid") - (t locale "workspace.header.menu.enable-snap-grid"))] + (tr "workspace.header.menu.disable-snap-grid") + (tr "workspace.header.menu.enable-snap-grid"))] [:span.shortcut "Ctrl+Shift+'"]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :sitemap :layers))} [:span (if (or (contains? layout :sitemap) (contains? layout :layers)) - (t locale "workspace.header.menu.hide-layers") - (t locale "workspace.header.menu.show-layers"))] + (tr "workspace.header.menu.hide-layers") + (tr "workspace.header.menu.show-layers"))] [:span.shortcut "Ctrl+l"]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :colorpalette))} [:span (if (contains? layout :colorpalette) - (t locale "workspace.header.menu.hide-palette") - (t locale "workspace.header.menu.show-palette"))] + (tr "workspace.header.menu.hide-palette") + (tr "workspace.header.menu.show-palette"))] [:span.shortcut "Ctrl+p"]] [:li {:on-click #(st/emit! (dw/toggle-layout-flags :assets))} [:span (if (contains? layout :assets) - (t locale "workspace.header.menu.hide-assets") - (t locale "workspace.header.menu.show-assets"))] + (tr "workspace.header.menu.hide-assets") + (tr "workspace.header.menu.show-assets"))] [:span.shortcut "Ctrl+i"]] + [:li {:on-click #(st/emit! (dw/select-all))} + [:span (tr "workspace.header.menu.select-all")] + [:span.shortcut "Ctrl+a"]] + [:li {:on-click #(st/emit! (dw/toggle-layout-flags :dynamic-alignment))} [:span (if (contains? layout :dynamic-alignment) - (t locale "workspace.header.menu.disable-dynamic-alignment") - (t locale "workspace.header.menu.enable-dynamic-alignment"))] - [:span.shortcut "Ctrl+a"]] + (tr "workspace.header.menu.disable-dynamic-alignment") + (tr "workspace.header.menu.enable-dynamic-alignment"))]] (if (:is-shared file) [:li {:on-click on-remove-shared} - [:span (t locale "dashboard.remove-shared")]] + [:span (tr "dashboard.remove-shared")]] [:li {:on-click on-add-shared} - [:span (t locale "dashboard.add-shared")]]) + [:span (tr "dashboard.add-shared")]]) ]]])) ;; --- Header Component (mf/defc header [{:keys [file layout project page-id] :as props}] - (let [team-id (:team-id project) - go-back #(st/emit! (rt/nav :dashboard-projects {:team-id team-id})) - zoom (mf/deref refs/selected-zoom) - locale (mf/deref i18n/locale) - router (mf/deref refs/router) - view-url (rt/resolve router :viewer {:page-id page-id :file-id (:id file)} {:index 0})] + (let [team-id (:team-id project) + zoom (mf/deref refs/selected-zoom) + router (mf/deref refs/router) + params {:page-id page-id :file-id (:id file)} + view-url (rt/resolve router :viewer params {:index 0}) + + go-back + (mf/use-callback + (mf/deps project) + (st/emitf (rt/nav :dashboard-projects {:team-id team-id}))) + + go-viewer + (mf/use-callback + (mf/deps file page-id) + (st/emitf (dw/go-to-viewer params)))] + [:header.workspace-header [:div.main-icon [:a {:on-click go-back} i/logo-icon]] @@ -244,11 +255,10 @@ :team-id team-id}] [:div.users-section - [:& presence/active-sessions]] + [:& active-sessions]] [:div.options-section - [:& persistence-state-widget - {:locale locale}] + [:& persistence-state-widget] [:& zoom-widget {:zoom zoom @@ -258,8 +268,9 @@ :on-zoom-fit #(st/emit! dw/zoom-to-fit-all) :on-zoom-selected #(st/emit! dw/zoom-to-selected-shape)}] - [:a.btn-icon-dark.btn-small - {;; :target "__blank" - :alt (t locale "workspace.header.viewer") - :href (str "#" view-url)} i/play]]])) + [:a.btn-icon-dark.btn-small.tooltip.tooltip-bottom + {:alt (tr "workspace.header.viewer") + :href (str "#" view-url) + :on-click go-viewer} + i/play]]])) diff --git a/frontend/src/app/main/ui/workspace/left_toolbar.cljs b/frontend/src/app/main/ui/workspace/left_toolbar.cljs index d67f40e14..192882c71 100644 --- a/frontend/src/app/main/ui/workspace/left_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/left_toolbar.cljs @@ -55,6 +55,11 @@ [:aside.left-toolbar [:div.left-toolbar-inside [:ul.left-toolbar-options + [:li.tooltip.tooltip-right + {:alt (t locale "workspace.toolbar.move") + :class (when (nil? selected-drawtool) "selected") + :on-click #(st/emit! :interrupt)} + i/pointer-inner] [:li.tooltip.tooltip-right {:alt (t locale "workspace.toolbar.frame") :class (when (= selected-drawtool :frame) "selected") @@ -66,7 +71,7 @@ :on-click (partial select-drawtool :rect)} i/box] [:li.tooltip.tooltip-right - {:alt (t locale "workspace.toolbar.circle") + {:alt (t locale "workspace.toolbar.ellipse") :class (when (= selected-drawtool :circle) "selected") :on-click (partial select-drawtool :circle)} i/circle] @@ -80,10 +85,11 @@ :on-click on-image} [:* i/image - [:& file-uploader {:accept cm/str-media-types - :multi true - :input-ref file-input - :on-selected on-files-selected}]]] + [:& file-uploader {:input-id "image-upload" + :accept cm/str-media-types + :multi true + :input-ref file-input + :on-selected on-files-selected}]]] [:li.tooltip.tooltip-right {:alt (t locale "workspace.toolbar.curve") :class (when (= selected-drawtool :curve) "selected") @@ -93,7 +99,7 @@ {:alt (t locale "workspace.toolbar.path") :class (when (= selected-drawtool :path) "selected") :on-click (partial select-drawtool :path)} - i/curve] + i/pen] [:li.tooltip.tooltip-right {:alt (t locale "workspace.toolbar.comments") diff --git a/frontend/src/app/main/ui/workspace/scroll.cljs b/frontend/src/app/main/ui/workspace/scroll.cljs deleted file mode 100644 index 29070de8c..000000000 --- a/frontend/src/app/main/ui/workspace/scroll.cljs +++ /dev/null @@ -1,73 +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) 2015-2017 Andrey Antukh -;; Copyright (c) 2015-2017 Juan de la Cruz - -(ns app.main.ui.workspace.scroll - "Workspace scroll events handling." - (:require [beicon.core :as rx] - [potok.core :as ptk] - [app.main.refs :as refs] - [app.util.dom :as dom] - [app.common.geom.point :as gpt])) - -;; FIXME: revisit this ns in order to find a better location for its functions -;; TODO: this need a good refactor (probably move to events with access to the state) - -(defn set-scroll-position - [dom position] - (set! (.-scrollLeft dom) (:x position)) - (set! (.-scrollTop dom) (:y position))) - -(defn set-scroll-center - [dom center] - (let [viewport-width (.-offsetWidth dom) - viewport-height (.-offsetHeight dom) - position-x (- (* (:x center) 1 #_@refs/selected-zoom) (/ viewport-width 2)) - position-y (- (* (:y center) 1 #_@refs/selected-zoom) (/ viewport-height 2)) - position (gpt/point position-x position-y)] - (set-scroll-position dom position))) - -(defn scroll-to-page-center - [dom page] - (let [page-width (get-in page [:metadata :width]) - page-height (get-in page [:metadata :height]) - center (gpt/point (+ 1200 (/ page-width 2)) (+ 1200 (/ page-height 2)))] - (set-scroll-center dom center))) - -(defn get-current-center - [dom] - (let [viewport-width (.-offsetWidth dom) - viewport-height (.-offsetHeight dom) - scroll-left (.-scrollLeft dom) - scroll-top (.-scrollTop dom)] - (gpt/point - (+ (/ viewport-width 2) scroll-left) - (+ (/ viewport-height 2) scroll-top)))) - -(defn get-current-center-absolute - [dom] - (gpt/divide (get-current-center dom) (gpt/point @refs/selected-zoom))) - -(defn get-current-position - "Get the coordinates of the currently visible point at top left of viewport" - [dom] - (let [scroll-left (.-scrollLeft dom) - scroll-top (.-scrollTop dom)] - (gpt/point scroll-left scroll-top))) - -(defn get-current-position-absolute - [dom] - (let [current-position (get-current-position dom)] - (gpt/divide (get-current-position dom) (gpt/point @refs/selected-zoom)))) - -(defn scroll-to-point - [dom point position] - (let [viewport-offset (gpt/subtract point position) - selected-zoom (gpt/point @refs/selected-zoom) - new-scroll-position (gpt/subtract - (gpt/multiply point selected-zoom) - (gpt/multiply viewport-offset selected-zoom))] - (set-scroll-position dom new-scroll-position))) diff --git a/frontend/src/app/main/ui/workspace/selection.cljs b/frontend/src/app/main/ui/workspace/selection.cljs index 4c149c3cb..4d53b6802 100644 --- a/frontend/src/app/main/ui/workspace/selection.cljs +++ b/frontend/src/app/main/ui/workspace/selection.cljs @@ -31,7 +31,8 @@ [app.common.geom.matrix :as gmt] [app.util.debug :refer [debug?]] [app.main.ui.workspace.shapes.outline :refer [outline]] - [app.main.ui.measurements :as msr])) + [app.main.ui.measurements :as msr] + [app.main.ui.workspace.shapes.path.editor :refer [path-editor]])) (def rotation-handler-size 25) (def resize-point-radius 4) @@ -64,52 +65,47 @@ :position :top-left :props {:cx x :cy y}} - ;; TOP + {:type :rotation + :position :top-right + :props {:cx (+ x width) :cy y}} + + {:type :resize-point + :position :top-right + :props {:cx (+ x width) :cy y}} + + {:type :rotation + :position :bottom-right + :props {:cx (+ x width) :cy (+ y height)}} + + {:type :resize-point + :position :bottom-right + :props {:cx (+ x width) :cy (+ y height)}} + + {:type :rotation + :position :bottom-left + :props {:cx x :cy (+ y height)}} + + {:type :resize-point + :position :bottom-left + :props {:cx x :cy (+ y height)}} + {:type :resize-side :position :top :props {:x x :y y :length width :angle 0 }} - ;; TOP-RIGHT - {:type :rotation - :position :top-right - :props {:cx (+ x width) :cy y}} - - {:type :resize-point - :position :top-right - :props {:cx (+ x width) :cy y}} - - ;; RIGHT {:type :resize-side :position :right :props {:x (+ x width) :y y :length height :angle 90 }} - ;; BOTTOM-RIGHT - {:type :rotation - :position :bottom-right - :props {:cx (+ x width) :cy (+ y height)}} - - {:type :resize-point - :position :bottom-right - :props {:cx (+ x width) :cy (+ y height)}} - - ;; BOTTOM {:type :resize-side :position :bottom :props {:x (+ x width) :y (+ y height) :length width :angle 180 }} - ;; BOTTOM-LEFT - {:type :rotation - :position :bottom-left - :props {:cx x :cy (+ y height)}} - - {:type :resize-point - :position :bottom-left - :props {:cx x :cy (+ y height)}} - - ;; LEFT {:type :resize-side :position :left - :props {:x x :y (+ y height) :length height :angle 270 }}]) + :props {:x x :y (+ y height) :length height :angle 270 }} + + ]) (mf/defc rotation-handler [{:keys [cx cy transform position rotation zoom on-rotate]}] (let [size (/ rotation-handler-size zoom) @@ -159,11 +155,14 @@ (mf/defc resize-side-handler [{:keys [x y length angle zoom position rotation transform on-resize]}] (let [res-point (if (#{:top :bottom} position) {:y y} - {:x x})] - [:rect {:x (+ x (/ resize-point-rect-size zoom)) - :y (- y (/ resize-side-height 2 zoom)) - :width (max 0 (- length (/ (* resize-point-rect-size 2) zoom))) - :height (/ resize-side-height zoom) + {:x x}) + target-length (max 0 (- length (/ (* resize-point-rect-size 2) zoom))) + width (if (< target-length 6) length target-length) + height (/ resize-side-height zoom)] + [:rect {:x (+ x (/ (- length width) 2)) + :y (- y (/ height 2)) + :width width + :height height :transform (gmt/multiply transform (gmt/rotate-matrix angle (gpt/point x y))) :on-mouse-down #(on-resize res-point %) @@ -181,8 +180,8 @@ on-rotate (obj/get props "on-rotate") current-transform (mf/deref refs/current-transform) - selrect (geom/shape->rect-shape shape) - transform (geom/transform-matrix shape) + selrect (:selrect shape) + transform (geom/transform-matrix shape {:no-flip true}) tr-shape (geom/transform-shape shape)] @@ -214,44 +213,6 @@ :resize-side [:> resize-side-handler props])))]))) ;; --- Selection Handlers (Component) -(mf/defc path-edition-selection-handlers - [{:keys [shape modifiers zoom color] :as props}] - (letfn [(on-mouse-down [event index] - (dom/stop-propagation event) - ;; TODO: this need code ux refactor - (let [stoper (get-edition-stream-stoper) - stream (->> (ms/mouse-position-deltas @ms/mouse-position) - (rx/take-until stoper))] - ;; (when @refs/selected-alignment - ;; (st/emit! (dw/initial-path-point-align (:id shape) index))) - (rx/subscribe stream #(on-handler-move % index)))) - - (get-edition-stream-stoper [] - (let [stoper? #(and (ms/mouse-event? %) (= (:type %) :up))] - (rx/merge - (rx/filter stoper? st/stream) - (->> st/stream - (rx/filter #(= % :interrupt)) - (rx/take 1))))) - - (on-handler-move [delta index] - (st/emit! (dw/update-path (:id shape) index delta)))] - - (let [transform (geom/transform-matrix shape) - displacement (:displacement modifiers) - segments (cond->> (:segments shape) - displacement (map #(gpt/transform % displacement)))] - [:g.controls - (for [[index {:keys [x y]}] (map-indexed vector segments)] - (let [{:keys [x y]} (gpt/transform (gpt/point x y) transform)] - [:circle {:cx x :cy y - :r (/ 6.0 zoom) - :key index - :on-mouse-down #(on-mouse-down % index) - :fill "#ffffff" - :stroke color - :style {:cursor cur/move-pointer}}]))]))) - ;; TODO: add specs for clarity (mf/defc text-edition-selection-handlers @@ -269,8 +230,8 @@ (mf/defc multiple-selection-handlers [{:keys [shapes selected zoom color show-distances] :as props}] - (let [shape (geom/selection-rect shapes) - shape-center (geom/center shape) + (let [shape (geom/setup {:type :rect} (geom/selection-rect (->> shapes (map geom/transform-shape)))) + shape-center (geom/center-shape shape) hover-id (-> (mf/deref refs/current-hover) first) hover-id (when-not (d/seek #(= hover-id (:id %)) shapes) hover-id) @@ -314,7 +275,7 @@ hover-id (when-not (= shape-id hover-id) hover-id) hover-shape (mf/deref (refs/object-by-id hover-id)) - shape' (if (debug? :simple-selection) (geom/selection-rect [shape]) shape) + shape' (if (debug? :simple-selection) (geom/setup {:type :rect} (geom/selection-rect [shape])) shape) on-resize (fn [current-position initial-position event] (dom/stop-propagation event) (st/emit! (dw/start-resize current-position initial-position #{shape-id} shape'))) @@ -322,7 +283,6 @@ on-rotate #(do (dom/stop-propagation %) (st/emit! (dw/start-rotate [shape])))] - [:* [:& controls {:shape shape' :zoom zoom @@ -366,12 +326,11 @@ [:& text-edition-selection-handlers {:shape shape :zoom zoom :color color}] - (and (or (= type :path) - (= type :curve)) + + (and (= type :path) (= edition (:id shape))) - [:& path-edition-selection-handlers {:shape shape - :zoom zoom - :color color}] + [:& path-editor {:zoom zoom + :shape shape}] :else [:& single-selection-handlers {:shape shape diff --git a/frontend/src/app/main/ui/workspace/shapes.cljs b/frontend/src/app/main/ui/workspace/shapes.cljs index f0b8bc2b5..0b561e58a 100644 --- a/frontend/src/app/main/ui/workspace/shapes.cljs +++ b/frontend/src/app/main/ui/workspace/shapes.cljs @@ -8,32 +8,32 @@ ;; Copyright (c) 2020 UXBOX Labs SL (ns app.main.ui.workspace.shapes - "A workspace specific shapes wrappers." + "A workspace specific shapes wrappers. + + Shapes that has some peculiarities are defined in its own + namespace under app.ui.workspace.shapes.* prefix, all the + others are defined using a generic wrapper implemented in + common." (:require - [rumext.alpha :as mf] - [okulary.core :as l] - [beicon.core :as rx] + [app.common.geom.shapes :as geom] + [app.main.refs :as refs] + [app.main.store :as st] [app.main.streams :as ms] - [app.main.ui.hooks :as hooks] [app.main.ui.cursors :as cur] - [app.main.ui.shapes.rect :as rect] + [app.main.ui.hooks :as hooks] [app.main.ui.shapes.circle :as circle] [app.main.ui.shapes.image :as image] - [app.main.data.workspace.selection :as dws] - [app.main.store :as st] - [app.main.refs :as refs] - - ;; Shapes that has some peculiarities are defined in its own - ;; namespace under app.ui.workspace.shapes.* prefix, all the - ;; others are defined using a generic wrapper implemented in - ;; common. + [app.main.ui.shapes.rect :as rect] [app.main.ui.workspace.shapes.bounding-box :refer [bounding-box]] [app.main.ui.workspace.shapes.common :as common] [app.main.ui.workspace.shapes.frame :as frame] [app.main.ui.workspace.shapes.group :as group] [app.main.ui.workspace.shapes.path :as path] [app.main.ui.workspace.shapes.text :as text] - [app.common.geom.shapes :as geom])) + [app.util.object :as obj] + [beicon.core :as rx] + [okulary.core :as l] + [rumext.alpha :as mf])) (declare group-wrapper) (declare frame-wrapper) @@ -44,70 +44,46 @@ (defn- shape-wrapper-memo-equals? [np op] - (let [n-shape (unchecked-get np "shape") - o-shape (unchecked-get op "shape") - n-frame (unchecked-get np "frame") - o-frame (unchecked-get op "frame")] + (let [n-shape (obj/get np "shape") + o-shape (obj/get op "shape") + n-frame (obj/get np "frame") + o-frame (obj/get op "frame")] ;; (prn "shape-wrapper-memo-equals?" (identical? n-frame o-frame)) (if (= (:type n-shape) :group) false (and (identical? n-shape o-shape) (identical? n-frame o-frame))))) -(defn use-mouse-enter - [{:keys [id] :as shape}] - (mf/use-callback - (mf/deps id) - (fn [] - (st/emit! (dws/change-hover-state id true))))) - -(defn use-mouse-leave - [{:keys [id] :as shape}] - (mf/use-callback - (mf/deps id) - (fn [] - (st/emit! (dws/change-hover-state id false))))) - (defn make-is-moving-ref [id] - (let [check-moving (fn [local] - (and (= :move (:transform local)) - (contains? (:selected local) id)))] - (l/derived check-moving refs/workspace-local))) + (fn [] + (let [check-moving (fn [local] + (and (= :move (:transform local)) + (contains? (:selected local) id)))] + (l/derived check-moving refs/workspace-local)))) (mf/defc shape-wrapper {::mf/wrap [#(mf/memo' % shape-wrapper-memo-equals?)] ::mf/wrap-props false} [props] - (let [shape (unchecked-get props "shape") - frame (unchecked-get props "frame") - ghost? (unchecked-get props "ghost?") - shape (geom/transform-shape frame shape) - opts #js {:shape shape - :frame frame} - alt? (mf/use-state false) - on-mouse-enter (use-mouse-enter shape) - on-mouse-leave (use-mouse-leave shape) + (let [shape (obj/get props "shape") + frame (obj/get props "frame") + ghost? (obj/get props "ghost?") + shape (-> (geom/transform-shape shape) + (geom/translate-to-frame frame)) + opts #js {:shape shape + :frame frame} - moving-iref (mf/use-memo (mf/deps (:id shape)) - #(make-is-moving-ref (:id shape))) - moving? (mf/deref moving-iref)] + alt? (hooks/use-rxsub ms/keyboard-alt) - (hooks/use-stream ms/keyboard-alt #(reset! alt? %)) - - (mf/use-effect - (fn [] - (fn [] - (on-mouse-leave)))) + moving-iref (mf/use-memo (mf/deps (:id shape)) (make-is-moving-ref (:id shape))) + moving? (mf/deref moving-iref)] (when (and shape (or ghost? (not moving?)) (not (:hidden shape))) - [:g.shape-wrapper {:on-mouse-enter on-mouse-enter - :on-mouse-leave on-mouse-leave - :style {:cursor (if @alt? cur/duplicate nil)}} + [:g.shape-wrapper {:style {:cursor (if alt? cur/duplicate nil)}} (case (:type shape) - :curve [:> path/path-wrapper opts] :path [:> path/path-wrapper opts] :text [:> text/text-wrapper opts] :group [:> group-wrapper opts] diff --git a/frontend/src/app/main/ui/workspace/shapes/bounding_box.cljs b/frontend/src/app/main/ui/workspace/shapes/bounding_box.cljs index b6b44c91f..10edd1b2b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/bounding_box.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/bounding_box.cljs @@ -9,7 +9,7 @@ [cuerdas.core :as str] [rumext.alpha :as mf] [app.util.debug :as debug] - [app.common.geom.shapes :as geom] + [app.common.geom.shapes :as gsh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.util.debug :refer [debug?]] @@ -35,16 +35,41 @@ :stroke-width "1px" :stroke-opacity 0.5}]])) +(mf/defc render-rect [{{:keys [x y width height]} :rect :keys [color]}] + [:rect {:x x + :y y + :width width + :height height + :style {:stroke color + :fill "transparent" + :stroke-width "1px" + :stroke-opacity 0.5 + :stroke-dasharray 4 + :pointer-events "none"}}]) + +(mf/defc render-rect-points [{:keys [points color]}] + (for [[p1 p2] (map vector points (concat (rest points) [(first points)]))] + [:line {:x1 (:x p1) + :y1 (:y p1) + :x2 (:x p2) + :y2 (:y p2) + :style {:stroke color + :stroke-width "1px"}}])) + (mf/defc bounding-box {::mf/wrap-props false} [props] (when (debug? :bounding-boxes) - (let [shape (unchecked-get props "shape") + (let [shape (-> (unchecked-get props "shape")) frame (unchecked-get props "frame") - selrect (-> shape :selrect) - shape-center (geom/center shape) + selrect (gsh/points->selrect (-> shape :points)) + shape-center (gsh/center-shape shape) line-color (rdcolor #js {:seed (str (:id shape))}) - zoom (mf/deref refs/selected-zoom)] + zoom (mf/deref refs/selected-zoom) + childs-ref (mf/use-memo (mf/deps shape) #(refs/objects-by-id (:shapes shape))) + childs (->> (mf/deref childs-ref) + (map gsh/transform-shape))] + [:g.bounding-box [:text {:x (:x selrect) :y (- (:y selrect) 5) @@ -63,12 +88,8 @@ :zoom zoom :color line-color}]) - [:rect {:x (:x selrect) - :y (:y selrect) - :width (:width selrect) - :height (:height selrect) - :style {:stroke line-color - :fill "transparent" - :stroke-width "1px" - :stroke-opacity 0.5 - :pointer-events "none"}}]]))) + [:& render-rect-points {:rect selrect + :color line-color}] + + [:& render-rect {:rect selrect + :color line-color}]]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/common.cljs b/frontend/src/app/main/ui/workspace/shapes/common.cljs index 302c0cf48..af4986035 100644 --- a/frontend/src/app/main/ui/workspace/shapes/common.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/common.cljs @@ -9,72 +9,19 @@ (ns app.main.ui.workspace.shapes.common (:require - [rumext.alpha :as mf] - [app.main.data.workspace :as dw] - [app.main.refs :as refs] - [app.main.store :as st] - [app.main.ui.keyboard :as kbd] - [app.util.dom :as dom] - [app.common.geom.matrix :as gmt] - [app.common.geom.point :as gpt] - [app.common.geom.shapes :as geom] - [app.main.ui.shapes.shape :refer [shape-container]])) - -(defn- on-mouse-down - [event {:keys [id type] :as shape}] - (let [selected @refs/selected-shapes - selected? (contains? selected id) - drawing? @refs/selected-drawing-tool - button (.-which (.-nativeEvent event))] - (when-not (:blocked shape) - (cond - (not= 1 button) - nil - - drawing? - nil - - (= type :frame) - (when selected? - (dom/stop-propagation event) - (st/emit! (dw/start-move-selected))) - - :else - (do - (dom/stop-propagation event) - (if selected? - (when (kbd/shift? event) - (st/emit! (dw/select-shape id true))) - (do - (when-not (or (empty? selected) (kbd/shift? event)) - (st/emit! (dw/deselect-all))) - (st/emit! (dw/select-shape id)))) - - (st/emit! (dw/start-move-selected))))))) - -(defn on-context-menu - [event shape] - (dom/prevent-default event) - (dom/stop-propagation event) - (let [position (dom/get-client-position event)] - (st/emit! (dw/show-shape-context-menu {:position position :shape shape})))) + [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.workspace.effects :as we] + [rumext.alpha :as mf])) (defn generic-wrapper-factory [component] (mf/fnc generic-wrapper {::mf/wrap-props false} [props] - (let [shape (unchecked-get props "shape") - on-mouse-down (mf/use-callback - (mf/deps shape) - #(on-mouse-down % shape)) - on-context-menu (mf/use-callback - (mf/deps shape) - #(on-context-menu % shape))] - + (let [shape (unchecked-get props "shape")] [:> shape-container {:shape shape - :on-mouse-down on-mouse-down - :on-context-menu on-context-menu} + :on-mouse-down (we/use-mouse-down shape) + :on-context-menu (we/use-context-menu shape) + :on-pointer-over (we/use-pointer-enter shape) + :on-pointer-out (we/use-pointer-leave shape)} [:& component {:shape shape}]]))) - - diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index 433b22f3f..1b6bef2cf 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -9,23 +9,18 @@ (ns app.main.ui.workspace.shapes.frame (:require - [okulary.core :as l] - [rumext.alpha :as mf] - [app.common.data :as d] - [app.main.constants :as c] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.workspace.shapes.common :as common] - [app.main.data.workspace.selection :as dws] [app.main.ui.shapes.frame :as frame] - [app.common.geom.matrix :as gmt] - [app.common.geom.point :as gpt] - [app.common.geom.shapes :as geom] + [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.workspace.effects :as we] [app.util.dom :as dom] - [app.main.streams :as ms] [app.util.timers :as ts] - [app.main.ui.shapes.shape :refer [shape-container]])) + [okulary.core :as l] + [rumext.alpha :as mf])) (defn- frame-wrapper-factory-equals? [np op] @@ -45,29 +40,41 @@ (recur (first ids) (rest ids)) false)))))) +(defn use-select-shape [{:keys [id]}] + (mf/use-callback + (mf/deps id) + (fn [event] + (dom/prevent-default event) + (st/emit! (dw/deselect-all) + (dw/select-shape id))))) + +;; Ensure that the label has always the same font +;; size, regardless of zoom +;; https://css-tricks.com/transforms-on-svg-elements/ +(defn text-transform + [{:keys [x y]} zoom] + (let [inv-zoom (/ 1 zoom)] + (str + "scale(" inv-zoom ", " inv-zoom ") " + "translate(" (* zoom x) ", " (* zoom y) ")"))) + (mf/defc frame-title - [{:keys [frame on-double-click on-mouse-over on-mouse-out]}] + [{:keys [frame]}] (let [zoom (mf/deref refs/selected-zoom) - inv-zoom (/ 1 zoom) {:keys [width x y]} frame - label-pos (gpt/point x (- y (/ 10 zoom)))] + label-pos (gpt/point x (- y (/ 10 zoom))) + handle-click (use-select-shape frame) + handle-pointer-enter (we/use-pointer-enter frame) + handle-pointer-leave (we/use-pointer-leave frame)] [:text {:x 0 :y 0 :width width :height 20 :class "workspace-frame-label" - ;; Ensure that the label has always the same font - ;; size, regardless of zoom - ;; https://css-tricks.com/transforms-on-svg-elements/ - :transform (str - "scale(" inv-zoom ", " inv-zoom ") " - "translate(" (* zoom (:x label-pos)) ", " - (* zoom (:y label-pos)) - ")") - ;; User may also select the frame with single click in the label - :on-click on-double-click - :on-mouse-over on-mouse-over - :on-mouse-out on-mouse-out} + :transform (text-transform label-pos zoom) + :on-click handle-click + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave} (:name frame)])) (defn make-is-moving-ref @@ -97,47 +104,23 @@ #(refs/make-selected-ref (:id shape))) selected? (mf/deref selected-iref) - on-mouse-down (mf/use-callback (mf/deps shape) - #(common/on-mouse-down % shape)) - on-context-menu (mf/use-callback (mf/deps shape) - #(common/on-context-menu % shape)) - - shape (geom/transform-shape shape) + shape (gsh/transform-shape shape) children (mapv #(get objects %) (:shapes shape)) ds-modifier (get-in shape [:modifiers :displacement]) - on-double-click - (mf/use-callback - (mf/deps (:id shape)) - (fn [event] - (dom/prevent-default event) - (st/emit! (dw/deselect-all) - (dw/select-shape (:id shape))))) - - on-mouse-over - (mf/use-callback - (mf/deps (:id shape)) - (fn [] - (st/emit! (dws/change-hover-state (:id shape) true)))) - - on-mouse-out - (mf/use-callback - (mf/deps (:id shape)) - (fn [] - (st/emit! (dws/change-hover-state (:id shape) false))))] + handle-context-menu (we/use-context-menu shape) + handle-double-click (use-select-shape shape) + handle-mouse-down (we/use-mouse-down shape)] (when (and shape (or ghost? (not moving?)) (not (:hidden shape))) [:g {:class (when selected? "selected") - :on-context-menu on-context-menu - :on-double-click on-double-click - :on-mouse-down on-mouse-down} + :on-context-menu handle-context-menu + :on-double-click handle-double-click + :on-mouse-down handle-mouse-down} - [:& frame-title {:frame shape - :on-context-menu on-context-menu - :on-double-click on-double-click - :on-mouse-down on-mouse-down}] + [:& frame-title {:frame shape}] [:> shape-container {:shape shape} [:& frame-shape diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index bb91982dd..7c6f464cd 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -9,18 +9,17 @@ (ns app.main.ui.workspace.shapes.group (:require - [rumext.alpha :as mf] - [app.common.data :as d] - [app.main.constants :as c] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.workspace.shapes.common :as common] - [app.main.ui.shapes.shape :refer [shape-container]] - [app.main.ui.shapes.group :as group] - [app.util.dom :as dom] [app.main.streams :as ms] - [app.util.timers :as ts])) + [app.main.ui.shapes.group :as group] + [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.workspace.effects :as we] + [app.util.dom :as dom] + [rumext.alpha :as mf] + [app.common.geom.shapes :as gsh] + [app.util.debug :refer [debug?]])) (defn- group-wrapper-factory-equals? [np op] @@ -31,6 +30,14 @@ (and (= n-frame o-frame) (= n-shape o-shape)))) +(defn use-double-click [{:keys [id]}] + (mf/use-callback + (mf/deps id) + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (st/emit! (dw/select-inside-group id @ms/mouse-position))))) + (defn group-wrapper-factory [shape-wrapper] (let [group-shape (group/group-shape shape-wrapper)] @@ -41,14 +48,11 @@ (let [shape (unchecked-get props "shape") frame (unchecked-get props "frame") - on-mouse-down - (mf/use-callback (mf/deps shape) #(common/on-mouse-down % shape)) + {:keys [id x y width height]} shape + transform (gsh/transform-matrix shape) - on-context-menu - (mf/use-callback (mf/deps shape) #(common/on-context-menu % shape)) - - childs-ref (mf/use-memo (mf/deps shape) #(refs/objects-by-id (:shapes shape))) - childs (mf/deref childs-ref) + childs-ref (mf/use-memo (mf/deps shape) #(refs/objects-by-id (:shapes shape))) + childs (mf/deref childs-ref) is-child-selected-ref (mf/use-memo (mf/deps (:id shape)) #(refs/is-child-selected? (:id shape))) @@ -59,28 +63,38 @@ mask-id (when (:masked-group? shape) (first (:shapes shape))) is-mask-selected-ref - (mf/use-memo (mf/deps mask-id) - #(refs/make-selected-ref mask-id)) + (mf/use-memo (mf/deps mask-id) #(refs/make-selected-ref mask-id)) is-mask-selected? (mf/deref is-mask-selected-ref) - on-double-click - (mf/use-callback - (mf/deps (:id shape)) - (fn [event] - (dom/stop-propagation event) - (dom/prevent-default event) - (st/emit! (dw/select-inside-group (:id shape) @ms/mouse-position))))] + handle-mouse-down (we/use-mouse-down shape) + handle-context-menu (we/use-context-menu shape) + handle-pointer-enter (we/use-pointer-enter shape) + handle-pointer-leave (we/use-pointer-leave shape) + handle-double-click (use-double-click shape)] - [:> shape-container {:shape shape - :on-mouse-down on-mouse-down - :on-context-menu on-context-menu - :on-double-click on-double-click} - [:& group-shape - {:frame frame - :shape shape - :childs childs - :is-child-selected? is-child-selected? - :expand-mask is-mask-selected?}]])))) + [:> shape-container {:shape shape} + [:g.group-shape + [:& group-shape + {:frame frame + :shape shape + :childs childs + :expand-mask is-mask-selected? + :pointer-events (when (not is-child-selected?) "none")}] + + (when-not is-child-selected? + [:rect.group-actions + {:x x + :y y + :fill (if (debug? :group) "red" "transparent") + :opacity 0.5 + :transform transform + :width width + :height height + :on-mouse-down handle-mouse-down + :on-context-menu handle-context-menu + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave + :on-double-click handle-double-click}])]])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/outline.cljs b/frontend/src/app/main/ui/workspace/shapes/outline.cljs index 361636e44..309bdc5ff 100644 --- a/frontend/src/app/main/ui/workspace/shapes/outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/outline.cljs @@ -13,8 +13,8 @@ [app.common.geom.shapes :as gsh] [app.util.object :as obj] [rumext.util :refer [map->obj]] - [app.main.ui.shapes.path :as path] - [app.main.refs :as refs])) + [app.main.refs :as refs] + [app.util.geom.path :as ugp])) (mf/defc outline @@ -28,7 +28,7 @@ outline-type (case (:type shape) :circle "ellipse" - (:curve :path) "path" + :path "path" "rect") common {:fill "transparent" @@ -44,8 +44,8 @@ :rx (/ width 2) :ry (/ height 2)} - (:curve :path) - {:d (path/render-path shape)} + :path + {:d (ugp/content->path (:content shape))} {:x x :y y diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 81fd43816..fd9fbe173 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -9,47 +9,48 @@ (ns app.main.ui.workspace.shapes.path (:require - [rumext.alpha :as mf] - [app.common.data :as d] - [app.util.dom :as dom] - [app.util.timers :as ts] - [app.main.streams :as ms] - [app.main.constants :as c] + [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] - [app.main.data.workspace :as dw] - [app.main.data.workspace.drawing :as dr] - [app.main.ui.keyboard :as kbd] [app.main.ui.shapes.path :as path] - [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.shape :refer [shape-container]] - [app.main.ui.workspace.shapes.common :as common])) + [app.main.ui.workspace.effects :as we] + [app.main.ui.workspace.shapes.path.common :as pc] + [app.util.dom :as dom] + [app.util.geom.path :as ugp] + [rumext.alpha :as mf])) + +(defn use-double-click [{:keys [id]}] + (mf/use-callback + (mf/deps id) + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (st/emit! (dw/start-edition-mode id) + (dw/start-path-edit id))))) (mf/defc path-wrapper {::mf/wrap-props false} [props] (let [shape (unchecked-get props "shape") hover? (or (mf/deref refs/current-hover) #{}) - on-mouse-down (mf/use-callback - (mf/deps shape) - #(common/on-mouse-down % shape)) - on-context-menu (mf/use-callback - (mf/deps shape) - #(common/on-context-menu % shape)) - on-double-click (mf/use-callback - (mf/deps shape) - (fn [event] - (when (and (not (::dr/initialized? shape)) (hover? (:id shape))) - (do - (dom/stop-propagation event) - (dom/prevent-default event) - (st/emit! (dw/start-edition-mode (:id shape)))))))] + content-modifiers-ref (pc/make-content-modifiers-ref (:id shape)) + content-modifiers (mf/deref content-modifiers-ref) + editing-id (mf/deref refs/selected-edition) + editing? (= editing-id (:id shape)) + shape (update shape :content ugp/apply-content-modifiers content-modifiers) + handle-mouse-down (we/use-mouse-down shape) + handle-context-menu (we/use-context-menu shape) + handle-pointer-enter (we/use-pointer-enter shape) + handle-pointer-leave (we/use-pointer-leave shape) + handle-double-click (use-double-click shape)] [:> shape-container {:shape shape - :on-double-click on-double-click - :on-mouse-down on-mouse-down - :on-context-menu on-context-menu} - + :pointer-events (when editing? "none") + :on-mouse-down handle-mouse-down + :on-context-menu handle-context-menu + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave + :on-double-click handle-double-click} [:& path/path-shape {:shape shape :background? true}]])) - diff --git a/frontend/src/app/main/ui/workspace/shapes/path/actions.cljs b/frontend/src/app/main/ui/workspace/shapes/path/actions.cljs new file mode 100644 index 000000000..4f41e9d57 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/path/actions.cljs @@ -0,0 +1,47 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.shapes.path.actions + (:require + [app.main.data.workspace.drawing.path :as drp] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.icons :as i] + [app.main.ui.workspace.shapes.path.common :as pc] + [rumext.alpha :as mf])) + +(mf/defc path-actions [{:keys [shape]}] + (let [id (mf/deref refs/selected-edition) + {:keys [edit-mode selected-points snap-toggled] :as all} (mf/deref pc/current-edit-path-ref)] + [:div.path-actions + [:div.viewport-actions-group + [:div.viewport-actions-entry {:class (when (= edit-mode :draw) "is-toggled") + :on-click #(st/emit! (drp/change-edit-mode :draw))} i/pen] + [:div.viewport-actions-entry {:class (when (= edit-mode :move) "is-toggled") + :on-click #(st/emit! (drp/change-edit-mode :move))} i/pointer-inner]] + + #_[:div.viewport-actions-group + [:div.viewport-actions-entry {:class "is-disabled"} i/nodes-add] + [:div.viewport-actions-entry {:class "is-disabled"} i/nodes-remove]] + + #_[:div.viewport-actions-group + [:div.viewport-actions-entry {:class "is-disabled"} i/nodes-merge] + [:div.viewport-actions-entry {:class "is-disabled"} i/nodes-join] + [:div.viewport-actions-entry {:class "is-disabled"} i/nodes-separate]] + + [:div.viewport-actions-group + [:div.viewport-actions-entry {:class (when (empty? selected-points) "is-disabled") + :on-click #(when-not (empty? selected-points) + (st/emit! (drp/make-corner)))} i/nodes-corner] + [:div.viewport-actions-entry {:class (when (empty? selected-points) "is-disabled") + :on-click #(when-not (empty? selected-points) + (st/emit! (drp/make-curve)))} i/nodes-curve]] + + #_[:div.viewport-actions-group + [:div.viewport-actions-entry {:class (when snap-toggled "is-toggled")} i/nodes-snap]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/path/common.cljs b/frontend/src/app/main/ui/workspace/shapes/path/common.cljs new file mode 100644 index 000000000..b5f408c92 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/path/common.cljs @@ -0,0 +1,39 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.shapes.path.common + (:require + [app.main.refs :as refs] + [okulary.core :as l] + [rumext.alpha :as mf])) + +(def primary-color "#1FDEA7") +(def secondary-color "#DB00FF") +(def black-color "#000000") +(def white-color "#FFFFFF") +(def gray-color "#B1B2B5") + +(def current-edit-path-ref + (let [selfn (fn [local] + (let [id (:edition local)] + (get-in local [:edit-path id])))] + (l/derived selfn refs/workspace-local))) + +(defn make-edit-path-ref [id] + (mf/use-memo + (mf/deps id) + (let [selfn #(get-in % [:edit-path id])] + #(l/derived selfn refs/workspace-local)))) + +(defn make-content-modifiers-ref [id] + (mf/use-memo + (mf/deps id) + (let [selfn #(get-in % [:edit-path id :content-modifiers])] + #(l/derived selfn refs/workspace-local)))) + diff --git a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs new file mode 100644 index 000000000..e8367bcd0 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs @@ -0,0 +1,235 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.shapes.path.editor + (:require + [app.common.geom.point :as gpt] + [app.main.data.workspace.drawing.path :as drp] + [app.main.store :as st] + [app.main.ui.cursors :as cur] + [app.main.ui.workspace.shapes.path.common :as pc] + [app.util.data :as d] + [app.util.dom :as dom] + [app.util.geom.path :as ugp] + [goog.events :as events] + [rumext.alpha :as mf]) + (:import goog.events.EventType)) + +(mf/defc path-point [{:keys [position zoom edit-mode hover? selected? preview? start-path? last-p?]}] + (let [{:keys [x y]} position + + on-enter + (fn [event] + (st/emit! (drp/path-pointer-enter position))) + + on-leave + (fn [event] + (st/emit! (drp/path-pointer-leave position))) + + on-click + (fn [event] + (when-not last-p? + (do (dom/stop-propagation event) + (dom/prevent-default event) + + (cond + (and (= edit-mode :move) (not selected?)) + (st/emit! (drp/select-node position)) + + (and (= edit-mode :move) selected?) + (st/emit! (drp/deselect-node position)))))) + + on-mouse-down + (fn [event] + (when-not last-p? + (do (dom/stop-propagation event) + (dom/prevent-default event) + + (cond + (= edit-mode :move) + (st/emit! (drp/start-move-path-point position)) + + (and (= edit-mode :draw) start-path?) + (st/emit! (drp/start-path-from-point position)) + + (and (= edit-mode :draw) (not start-path?)) + (st/emit! (drp/close-path-drag-start position))))))] + [:g.path-point + [:circle.path-point + {:cx x + :cy y + :r (if (or selected? hover?) (/ 3.5 zoom) (/ 3 zoom)) + :style {:stroke-width (/ 1 zoom) + :stroke (cond (or selected? hover?) pc/black-color + preview? pc/secondary-color + :else pc/primary-color) + :fill (cond selected? pc/primary-color + :else pc/white-color)}}] + [:circle {:cx x + :cy y + :r (/ 10 zoom) + :on-click on-click + :on-mouse-down on-mouse-down + :on-mouse-enter on-enter + :on-mouse-leave on-leave + :style {:cursor (cond + (and (not last-p?) (= edit-mode :draw)) cur/pen-node + (= edit-mode :move) cur/pointer-node) + :fill "transparent"}}]])) + +(mf/defc path-handler [{:keys [index prefix point handler zoom selected? hover? edit-mode]}] + (when (and point handler) + (let [{:keys [x y]} handler + on-enter + (fn [event] + (st/emit! (drp/path-handler-enter index prefix))) + + on-leave + (fn [event] + (st/emit! (drp/path-handler-leave index prefix))) + + on-click + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (cond + (= edit-mode :move) + (drp/select-handler index prefix))) + + on-mouse-down + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + + (cond + (= edit-mode :move) + (st/emit! (drp/start-move-handler index prefix))))] + + [:g.handler {:pointer-events (when (= edit-mode :draw))} + [:line + {:x1 (:x point) + :y1 (:y point) + :x2 x + :y2 y + :style {:stroke (if hover? pc/black-color pc/gray-color) + :stroke-width (/ 1 zoom)}}] + [:rect + {:x (- x (/ 3 zoom)) + :y (- y (/ 3 zoom)) + :width (/ 6 zoom) + :height (/ 6 zoom) + + :style {:stroke-width (/ 1 zoom) + :stroke (cond (or selected? hover?) pc/black-color + :else pc/primary-color) + :fill (cond selected? pc/primary-color + :else pc/white-color)}}] + [:circle {:cx x + :cy y + :r (/ 10 zoom) + :on-click on-click + :on-mouse-down on-mouse-down + :on-mouse-enter on-enter + :on-mouse-leave on-leave + :style {:cursor (when (= edit-mode :move) cur/pointer-move) + :fill "transparent"}}]]))) + +(mf/defc path-preview [{:keys [zoom command from]}] + [:g.preview {:style {:pointer-events "none"}} + (when (not= :move-to (:command command)) + [:path {:style {:fill "transparent" + :stroke pc/secondary-color + :stroke-width (/ 1 zoom)} + :d (ugp/content->path [{:command :move-to + :params {:x (:x from) + :y (:y from)}} + command])}]) + [:& path-point {:position (:params command) + :preview? true + :zoom zoom}]]) + +(mf/defc path-editor + [{:keys [shape zoom]}] + + (let [editor-ref (mf/use-ref nil) + edit-path-ref (pc/make-edit-path-ref (:id shape)) + {:keys [edit-mode + drag-handler + prev-handler + preview + content-modifiers + last-point + selected-handlers + selected-points + hover-handlers + hover-points]} (mf/deref edit-path-ref) + {:keys [content]} shape + content (ugp/apply-content-modifiers content content-modifiers) + points (->> content ugp/content->points (into #{})) + last-command (last content) + last-p (->> content last ugp/command->point) + handlers (ugp/content->handlers content) + + handle-click-outside + (fn [event] + (let [current (dom/get-target event) + editor-dom (mf/ref-val editor-ref)] + (when-not (or (.contains editor-dom current) + (dom/class? current "viewport-actions-entry")) + (st/emit! (drp/deselect-all)))))] + + (mf/use-layout-effect + (fn [] + (let [keys [(events/listen js/document EventType.CLICK handle-click-outside)]] + #(doseq [key keys] + (events/unlistenByKey key))))) + + [:g.path-editor {:ref editor-ref} + (when (and preview (not drag-handler)) + [:& path-preview {:command preview + :from last-p + :zoom zoom}]) + + (for [position points] + [:g.path-node + [:g.point-handlers {:pointer-events (when (= edit-mode :draw) "none")} + (for [[index prefix] (get handlers position)] + (let [command (get content index) + x (get-in command [:params (d/prefix-keyword prefix :x)]) + y (get-in command [:params (d/prefix-keyword prefix :y)]) + handler-position (gpt/point x y)] + (when (not= position handler-position) + [:& path-handler {:point position + :handler handler-position + :index index + :prefix prefix + :zoom zoom + :selected? (contains? selected-handlers [index prefix]) + :hover? (contains? hover-handlers [index prefix]) + :edit-mode edit-mode}])))] + [:& path-point {:position position + :zoom zoom + :edit-mode edit-mode + :selected? (contains? selected-points position) + :hover? (contains? hover-points position) + :last-p? (= last-point position) + :start-path? (nil? last-point)}]]) + + (when prev-handler + [:g.prev-handler {:pointer-events "none"} + [:& path-handler {:point last-p + :handler prev-handler + :zoom zoom}]]) + + (when drag-handler + [:g.drag-handler {:pointer-events "none"} + [:& path-handler {:point last-p + :handler drag-handler + :zoom zoom}]])])) + diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index c389583f5..d122c694d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -9,57 +9,46 @@ (ns app.main.ui.workspace.shapes.text (:require - ["slate" :as slate] - ["slate-react" :as rslate] - [goog.events :as events] - [goog.object :as gobj] - [cuerdas.core :as str] - [rumext.alpha :as mf] - [beicon.core :as rx] - [app.util.color :as color] - [app.util.dom :as dom] - [app.util.text :as ut] - [app.util.object :as obj] - [app.util.color :as uc] - [app.util.timers :as timers] - [app.common.data :as d] - [app.common.geom.shapes :as geom] - [app.main.refs :as refs] - [app.main.store :as st] - [app.main.fonts :as fonts] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.texts :as dwt] - [app.main.ui.cursors :as cur] - [app.main.ui.workspace.shapes.common :as common] - [app.main.ui.shapes.text :as text] - [app.main.ui.keyboard :as kbd] + [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.context :as muc] - [app.main.ui.shapes.filters :as filters] - [app.main.ui.shapes.shape :refer [shape-container]]) - (:import - goog.events.EventType - goog.events.KeyCodes)) + [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.shapes.text :as text] + [app.main.ui.workspace.effects :as we] + [app.main.ui.workspace.shapes.common :as common] + [app.main.ui.workspace.shapes.text.editor :as editor] + [app.util.dom :as dom] + [app.util.logging :as log] + [app.util.object :as obj] + [app.util.timers :as timers] + [beicon.core :as rx] + [rumext.alpha :as mf])) + +;; Change this to :info :debug or :trace to debug this module +(log/set-level! :warn) ;; --- Events -(defn handle-mouse-down - [event {:keys [id group] :as shape}] - (if (and (not (:blocked shape)) - (or @refs/selected-drawing-tool - @refs/selected-edition)) - (dom/stop-propagation event) - (common/on-mouse-down event shape))) +(defn use-double-click [{:keys [id]} selected?] + (mf/use-callback + (mf/deps id selected?) + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (when selected? + (st/emit! (dw/start-edition-mode id)))))) ;; --- Text Wrapper for workspace -(declare text-shape-edit) -(declare text-shape) - (mf/defc text-wrapper {::mf/wrap-props false} [props] - (let [{:keys [id x1 y1 content group grow-type width height ] :as shape} (unchecked-get props "shape") + (let [{:keys [id name x y width height grow-type] :as shape} (unchecked-get props "shape") selected-iref (mf/use-memo (mf/deps (:id shape)) #(refs/make-selected-ref (:id shape))) selected? (mf/deref selected-iref) @@ -72,390 +61,71 @@ embed-resources? (mf/use-ctx muc/embed-ctx) - on-mouse-down #(handle-mouse-down % shape) - on-context-menu #(common/on-context-menu % shape) + handle-mouse-down (we/use-mouse-down shape) + handle-context-menu (we/use-context-menu shape) + handle-pointer-enter (we/use-pointer-enter shape) + handle-pointer-leave (we/use-pointer-leave shape) + handle-double-click (use-double-click shape selected?) - on-double-click - (fn [event] - (dom/stop-propagation event) - (dom/prevent-default event) - (when selected? - (st/emit! (dw/start-edition-mode (:id shape)))))] + paragraph-ref (mf/use-state nil) - (mf/use-effect - (mf/deps shape edition selected? current-transform) - (fn [] (let [check? (and (#{:auto-width :auto-height} (:grow-type shape)) - selected? - (not edition?) - (not embed-resources?) - (nil? current-transform)) - result (timers/schedule #(reset! render-editor check?))] - #(rx/dispose! result)))) - - [:> shape-container {:shape shape - :on-double-click on-double-click - :on-mouse-down on-mouse-down - :on-context-menu on-context-menu} - (when @render-editor - [:g {:opacity 0 - :style {:pointer-events "none"}} - ;; We only render the component for its side-effect - [:& text-shape-edit {:shape shape - :read-only? true}]]) - - (if edition? - [:& text-shape-edit {:shape shape}] - [:& text/text-shape {:shape shape - :selected? selected?}])])) - -;; --- Text Editor Rendering - -(defn- generate-root-styles - [data props] - (let [valign (obj/get data "vertical-align" "top") - talign (obj/get data "text-align") - shape (obj/get props "shape") - base #js {:height "100%" - :width (:width shape) - :display "flex"}] - (cond-> base - (= valign "top") (obj/set! "alignItems" "flex-start") - (= valign "center") (obj/set! "alignItems" "center") - (= valign "bottom") (obj/set! "alignItems" "flex-end") - (= talign "left") (obj/set! "justifyContent" "flex-start") - (= talign "center") (obj/set! "justifyContent" "center") - (= talign "right") (obj/set! "justifyContent" "flex-end") - (= talign "justify") (obj/set! "justifyContent" "stretch")))) - -(defn- generate-paragraph-styles - [data] - (let [base #js {:fontSize "14px" - :margin "inherit" - :lineHeight "1.2"} - lh (obj/get data "line-height") - ta (obj/get data "text-align")] - (cond-> base - ta (obj/set! "textAlign" ta) - lh (obj/set! "lineHeight" lh)))) - -(defn- generate-text-styles - [data] - (let [letter-spacing (obj/get data "letter-spacing") - text-decoration (obj/get data "text-decoration") - text-transform (obj/get data "text-transform") - line-height (obj/get data "line-height") - - font-id (obj/get data "font-id" (:font-id ut/default-text-attrs)) - font-variant-id (obj/get data "font-variant-id") - - font-family (obj/get data "font-family") - font-size (obj/get data "font-size") - - ;; Old properties for backwards compatibility - fill (obj/get data "fill") - opacity (obj/get data "opacity" 1) - - fill-color (obj/get data "fill-color" fill) - fill-opacity (obj/get data "fill-opacity" opacity) - fill-color-gradient (obj/get data "fill-color-gradient" nil) - fill-color-gradient (when fill-color-gradient - (-> (js->clj fill-color-gradient :keywordize-keys true) - (update :type keyword))) - - fill-color-ref-id (obj/get data "fill-color-ref-id") - fill-color-ref-file (obj/get data "fill-color-ref-file") - - [r g b a] (uc/hex->rgba fill-color fill-opacity) - background (if fill-color-gradient - (uc/gradient->css (js->clj fill-color-gradient)) - (str/format "rgba(%s, %s, %s, %s)" r g b a)) - - fontsdb (deref fonts/fontsdb) - - base #js {:textDecoration text-decoration - :textTransform text-transform - :lineHeight (or line-height "inherit") - "--text-color" background}] - - (when (and (string? letter-spacing) - (pos? (alength letter-spacing))) - (obj/set! base "letterSpacing" (str letter-spacing "px"))) - - (when (and (string? font-size) - (pos? (alength font-size))) - (obj/set! base "fontSize" (str font-size "px"))) - - (when (and (string? font-id) - (pos? (alength font-id))) - (let [font (get fontsdb font-id)] - (let [font-family (or (:family font) - (obj/get data "fontFamily")) - font-variant (d/seek #(= font-variant-id (:id %)) - (:variants font)) - font-style (or (:style font-variant) - (obj/get data "fontStyle")) - font-weight (or (:weight font-variant) - (obj/get data "fontWeight"))] - (obj/set! base "fontFamily" font-family) - (obj/set! base "fontStyle" font-style) - (obj/set! base "fontWeight" font-weight)))) - - base)) - -(mf/defc editor-root-node - {::mf/wrap-props false - ::mf/wrap [mf/memo]} - [props] - (let [attrs (obj/get props "attributes") - childs (obj/get props "children") - data (obj/get props "element") - type (obj/get data "type") - style (generate-root-styles data props) - attrs (obj/set! attrs "style" style) - attrs (obj/set! attrs "className" type)] - [:> :div attrs childs])) - -(mf/defc editor-paragraph-set-node - {::mf/wrap-props false} - [props] - (let [attrs (obj/get props "attributes") - childs (obj/get props "children") - data (obj/get props "element") - type (obj/get data "type") - shape (obj/get props "shape") - - ;; The position absolute is used so the paragraph is "outside" - ;; the normal layout and can grow outside its parent - ;; We use this element to measure the size of the text - style #js {:display "inline-block" - :position "absolute"} - attrs (obj/set! attrs "style" style) - attrs (obj/set! attrs "className" type)] - [:> :div attrs childs])) - -(mf/defc editor-paragraph-node - {::mf/wrap-props false} - [props] - (let [attrs (obj/get props "attributes") - childs (obj/get props "children") - data (obj/get props "element") - style (generate-paragraph-styles data) - attrs (obj/set! attrs "style" style)] - [:> :p attrs childs])) - -(mf/defc editor-text-node - {::mf/wrap-props false} - [props] - (let [attrs (obj/get props "attributes") - childs (obj/get props "children") - data (obj/get props "leaf") - style (generate-text-styles data) - attrs (-> attrs - (obj/set! "style" style) - (obj/set! "className" "text-node"))] - [:> :span attrs childs])) - -(defn- render-element - [shape props] - (mf/html - (let [element (obj/get props "element") - props (obj/merge! props #js {:shape shape})] - (case (obj/get element "type") - "root" [:> editor-root-node props] - "paragraph-set" [:> editor-paragraph-set-node props] - "paragraph" [:> editor-paragraph-node props] - nil)))) - -(defn- render-text - [props] - (mf/html - [:> editor-text-node props])) - -;; --- Text Shape Edit - -(defn- initial-text - [text] - (clj->js - [{:type "root" - :children [{:type "paragraph-set" - :children [{:type "paragraph" - :children [{:text (or text "")}]}]}]}])) -(defn- parse-content - [content] - (cond - (string? content) (initial-text content) - (map? content) (clj->js [content]) - :else (initial-text ""))) - -(defn- content-size - [node] - (let [current (count (:text node)) - children-count (->> node :children (map content-size) (reduce +))] - (+ current children-count))) - -(defn fix-gradients - "Fix for the gradient types that need to be keywords" - [content] - (let [fix-node - (fn [node] - (d/update-in-when node [:fill-color-gradient :type] keyword))] - (ut/map-node fix-node content))) - -(mf/defc text-shape-edit - {::mf/wrap [mf/memo]} - [{:keys [shape read-only?] :or {read-only? false} :as props}] - (let [{:keys [id x y width height content grow-type]} shape - zoom (mf/deref refs/selected-zoom) - state (mf/use-state #(parse-content content)) - editor (mf/use-memo #(dwt/create-editor)) - self-ref (mf/use-ref) - selecting-ref (mf/use-ref) - measure-ref (mf/use-ref) - - content-var (mf/use-var content) - - on-close - (fn [] - (when (not read-only?) - (st/emit! dw/clear-edition-mode)) - (when (= 0 (content-size @content-var)) - (st/emit! (dw/delete-shapes [id])))) - - on-click-outside - (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - - - (let [sidebar (dom/get-element "settings-bar") - assets (dom/get-element-by-class "assets-bar") - cpicker (dom/get-element-by-class "colorpicker-tooltip") - self (mf/ref-val self-ref) - target (dom/get-target event) - selecting? (mf/ref-val selecting-ref)] - (when-not (or (and sidebar (.contains sidebar target)) - (and assets (.contains assets target)) - (and self (.contains self target)) - (and cpicker (.contains cpicker target))) - (if selecting? - (mf/set-ref-val! selecting-ref false) - (on-close))))) - - on-mouse-down - (fn [event] - (mf/set-ref-val! selecting-ref true)) - - on-mouse-up - (fn [event] - (mf/set-ref-val! selecting-ref false)) - - on-key-up - (fn [event] - (dom/stop-propagation event) - (when (= (.-keyCode event) 27) ; ESC - (do - (st/emit! :interrupt) - (on-close)))) - - on-mount - (fn [] - (when (not read-only?) - (let [lkey1 (events/listen (dom/get-root) EventType.CLICK on-click-outside) - lkey2 (events/listen (dom/get-root) EventType.KEYUP on-key-up)] - (st/emit! (dwt/assign-editor id editor) - dwc/start-undo-transaction) - - #(do - (st/emit! (dwt/assign-editor id nil) - dwc/commit-undo-transaction) - (events/unlistenByKey lkey1) - (events/unlistenByKey lkey2))))) - - on-focus - (fn [event] - (when (not read-only?) - (dwt/editor-select-all! editor))) - - on-change + handle-resize-text (mf/use-callback - (fn [val] - (when (not read-only?) - (let [content (js->clj val :keywordize-keys true) - content (first content) - content (fix-gradients content)] - ;; Append timestamp so we can react to cursor change events - (st/emit! (dw/update-shape id {:content (assoc content :ts (js->clj (.now js/Date)))})) - (reset! state val) - (reset! content-var content)))))] + (mf/deps id) + (fn [entries] + (when (seq entries) + ;; RequestAnimationFrame so the "loop limit error" error is not thrown + ;; https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded + (timers/raf + #(let [width (obj/get-in entries [0 "contentRect" "width"]) + height (obj/get-in entries [0 "contentRect" "height"])] + (log/debug :msg "Resize detected" :shape-id id :width width :height height) + (st/emit! (dwt/resize-text id (mth/ceil width) (mth/ceil height)))))))) - (mf/use-effect on-mount) + text-ref-cb + (mf/use-callback + (mf/deps handle-resize-text) + (fn [node] + (when node + (let [obs-ref (atom nil)] + (timers/schedule + (fn [] + (when-let [ps-node (dom/query node ".paragraph-set")] + (reset! paragraph-ref ps-node))))))))] (mf/use-effect - (mf/deps content) + (mf/deps @paragraph-ref handle-resize-text grow-type) (fn [] - (reset! state (parse-content content)) - (reset! content-var content))) + (when-let [paragraph-node @paragraph-ref] + (let [observer (js/ResizeObserver. handle-resize-text)] + (log/debug :msg "Attach resize observer" :shape-id id :shape-name name) + (.observe observer paragraph-node) + #(.disconnect observer))))) - ;; Checks the size of the wrapper to update if it were necesary - (mf/use-effect - (mf/deps shape) - (fn [] - (fonts/ready - #(let [self-node (mf/ref-val self-ref) - paragraph-node (when self-node (dom/query self-node ".paragraph-set"))] - (when paragraph-node - (let [ - {bb-w :width bb-h :height} (dom/get-bounding-rect paragraph-node) - width (max (/ bb-w zoom) 7) - height (max (/ bb-h zoom) 16) - undo-transaction (get-in @st/state [:workspace-undo :transaction])] - (when (not undo-transaction) (st/emit! dwc/start-undo-transaction)) - (when (or (not= (:width shape) width) - (not= (:height shape) height)) - (cond - (and (:overflow-text shape) (not= :fixed (:grow-type shape))) - (st/emit! (dwt/update-overflow-text id false)) + [:> shape-container {:shape shape} + ;; We keep hidden the shape when we're editing so it keeps track of the size + ;; and updates the selrect acordingly + [:g.text-shape {:opacity (when edition? 0)} + [:& text/text-shape {:key (str "text-shape" (:id shape)) + :ref text-ref-cb + :shape shape + :selected? selected?}]] + (when edition? + [:& editor/text-shape-edit {:key (str "editor" (:id shape)) + :shape shape}]) - (and (= :fixed (:grow-type shape)) (not (:overflow-text shape)) (> height (:height shape))) - (st/emit! (dwt/update-overflow-text id true)) + (when-not edition? + [:rect.text-actions + {:x x + :y y + :width width + :height height + :style {:fill "transparent"} + :on-mouse-down handle-mouse-down + :on-context-menu handle-context-menu + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave + :on-double-click handle-double-click + :transform (gsh/transform-matrix shape)}])])) - (and (= :fixed (:grow-type shape)) (:overflow-text shape) (<= height (:height shape))) - (st/emit! (dwt/update-overflow-text id false)) - - (= grow-type :auto-width) - (st/emit! (dw/update-dimensions [id] :width width) - (dw/update-dimensions [id] :height height)) - - (= grow-type :auto-height) - (st/emit! (dw/update-dimensions [id] :height height)) - )) - (when (not undo-transaction) (st/emit! dwc/discard-undo-transaction)))))))) - - [:foreignObject {:ref self-ref - :transform (geom/transform-matrix shape) - :x x :y y - :width (if (= :auto-width grow-type) 10000 width) - :height height} - [:style "span { line-height: inherit; } - .text-node { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] - [:> rslate/Slate {:editor editor - :value @state - :on-change on-change} - [:> rslate/Editable - {:auto-focus (when (not read-only?) "true") - :spell-check "false" - :on-focus on-focus - :class "rich-text" - :style {:cursor cur/text - :width (:width shape)} - :render-element #(render-element shape %) - :render-leaf render-text - :on-mouse-up on-mouse-up - :on-mouse-down on-mouse-down - :on-blur (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - ;; WARN: monky patch - (obj/set! slate/Transforms "deselect" (constantly nil))) - :placeholder (when (= :fixed grow-type) "Type some text here...")}]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs new file mode 100644 index 000000000..79b27658f --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -0,0 +1,276 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.shapes.text.editor + (:require + ["slate" :as slate] + ["slate-react" :as rslate] + [goog.events :as events] + [rumext.alpha :as mf] + [app.common.data :as d] + [app.common.geom.shapes :as gsh] + [app.util.dom :as dom] + [app.util.text :as ut] + [app.util.object :as obj] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.data.workspace :as dw] + [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.texts :as dwt] + [app.main.ui.cursors :as cur] + [app.main.ui.shapes.text.styles :as sts]) + (:import + goog.events.EventType + goog.events.KeyCodes)) + +;; --- Data functions + +(defn- initial-text + [text] + (clj->js + [{:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:fill-color "#000000" + :fill-opacity 1 + :text (or text "")}]}]}]}])) +(defn- parse-content + [content] + (cond + (string? content) (initial-text content) + (map? content) (clj->js [content]) + :else (initial-text ""))) + +(defn- content-size + [node] + (let [current (count (:text node)) + children-count (->> node :children (map content-size) (reduce +))] + (+ current children-count))) + +(defn- fix-gradients + "Fix for the gradient types that need to be keywords" + [content] + (let [fix-node + (fn [node] + (d/update-in-when node [:fill-color-gradient :type] keyword))] + (ut/map-node fix-node content))) + +;; --- Text Editor Rendering + +(mf/defc editor-root-node + {::mf/wrap-props false + ::mf/wrap [mf/memo]} + [props] + (let [ + childs (obj/get props "children") + data (obj/get props "element") + type (obj/get data "type") + style (sts/generate-root-styles data props) + attrs (-> (obj/get props "attributes") + (obj/set! "style" style) + (obj/set! "className" type))] + [:> :div attrs childs])) + +(mf/defc editor-paragraph-set-node + {::mf/wrap-props false} + [props] + (let [childs (obj/get props "children") + data (obj/get props "element") + type (obj/get data "type") + shape (obj/get props "shape") + style (sts/generate-paragraph-set-styles data props) + attrs (-> (obj/get props "attributes") + (obj/set! "style" style) + (obj/set! "className" type))] + [:> :div attrs childs])) + +(mf/defc editor-paragraph-node + {::mf/wrap-props false} + [props] + (let [ + childs (obj/get props "children") + data (obj/get props "element") + type (obj/get data "type") + style (sts/generate-paragraph-styles data props) + attrs (-> (obj/get props "attributes") + (obj/set! "style" style) + (obj/set! "className" type))] + [:> :p attrs childs])) + +(mf/defc editor-text-node + {::mf/wrap-props false} + [props] + (let [childs (obj/get props "children") + data (obj/get props "leaf") + type (obj/get data "type") + style (sts/generate-text-styles data props) + attrs (-> (obj/get props "attributes") + (obj/set! "style" style)) + gradient (obj/get data "fill-color-gradient" nil)] + (if gradient + (obj/set! attrs "className" (str type " gradient")) + (obj/set! attrs "className" type)) + [:> :span attrs childs])) + +(defn- render-element + [shape props] + (mf/html + (let [element (obj/get props "element") + type (obj/get element "type") + props (obj/merge! props #js {:shape shape}) + props (cond-> props + (= type "root") (obj/set! "key" "root") + (= type "paragraph-set") (obj/set! "key" "paragraph-set"))] + + (case type + "root" [:> editor-root-node props] + "paragraph-set" [:> editor-paragraph-set-node props] + "paragraph" [:> editor-paragraph-node props] + nil)))) + +(defn- render-text + [props] + (mf/html + [:> editor-text-node props])) + +;; --- Text Shape Edit + +(mf/defc text-shape-edit-html + {::mf/wrap [mf/memo] + ::mf/wrap-props false + ::mf/forward-ref true} + [props ref] + (let [shape (unchecked-get props "shape") + node-ref (unchecked-get props "node-ref") + + {:keys [id x y width height content grow-type]} shape + zoom (mf/deref refs/selected-zoom) + state (mf/use-state #(parse-content content)) + editor (mf/use-memo #(dwt/create-editor)) + self-ref (mf/use-ref) + selecting-ref (mf/use-ref) + measure-ref (mf/use-ref) + + content-var (mf/use-var content) + + on-close + (fn [] + (st/emit! dw/clear-edition-mode) + (when (= 0 (content-size @content-var)) + (st/emit! (dw/delete-shapes [id])))) + + on-click-outside + (fn [event] + (let [options (dom/get-element-by-class "element-options") + assets (dom/get-element-by-class "assets-bar") + cpicker (dom/get-element-by-class "colorpicker-tooltip") + self (mf/ref-val self-ref) + target (dom/get-target event) + selecting? (mf/ref-val selecting-ref)] + (when-not (or (and options (.contains options target)) + (and assets (.contains assets target)) + (and self (.contains self target)) + (and cpicker (.contains cpicker target))) + (do + (dom/prevent-default event) + (dom/stop-propagation event) + + (if selecting? + (mf/set-ref-val! selecting-ref false) + (on-close)))))) + + on-mouse-down + (fn [event] + (mf/set-ref-val! selecting-ref true)) + + on-mouse-up + (fn [event] + (mf/set-ref-val! selecting-ref false)) + + on-key-up + (fn [event] + (dom/stop-propagation event) + (when (= (.-keyCode event) 27) ; ESC + (do + (st/emit! :interrupt) + (on-close)))) + + on-mount + (fn [] + (let [lkey1 (events/listen js/document EventType.CLICK on-click-outside) + lkey2 (events/listen js/document EventType.KEYUP on-key-up)] + (st/emit! (dwt/assign-editor id editor) + (dwc/start-undo-transaction)) + + #(do + (st/emit! (dwt/assign-editor id nil) + (dwc/commit-undo-transaction)) + (events/unlistenByKey lkey1) + (events/unlistenByKey lkey2)))) + + on-focus + (fn [event] + (dwt/editor-select-all! editor)) + + on-change + (mf/use-callback + (fn [val] + (let [content (js->clj val :keywordize-keys true) + content (first content) + content (fix-gradients content)] + ;; Append timestamp so we can react to cursor change events + (st/emit! (dw/update-shape id {:content (assoc content :ts (js->clj (.now js/Date)))})) + (reset! state val) + (reset! content-var content))))] + + (mf/use-effect on-mount) + + (mf/use-effect + (mf/deps content) + (fn [] + (reset! state (parse-content content)) + (reset! content-var content))) + + [:div.text-editor {:ref self-ref} + [:style "span { line-height: inherit; } + .gradient { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] + [:> rslate/Slate {:editor editor + :value @state + :on-change on-change} + [:> rslate/Editable + {:auto-focus "true" + :spell-check "false" + :on-focus on-focus + :class "rich-text" + :style {:cursor cur/text + :width (:width shape)} + :render-element #(render-element shape %) + :render-leaf render-text + :on-mouse-up on-mouse-up + :on-mouse-down on-mouse-down + :on-blur (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + ;; WARN: monky patch + (obj/set! slate/Transforms "deselect" (constantly nil))) + :placeholder (when (= :fixed grow-type) "Type some text here...")}]]])) + +(mf/defc text-shape-edit + {::mf/wrap [mf/memo] + ::mf/wrap-props false + ::mf/forward-ref true} + [props ref] + (let [shape (unchecked-get props "shape") + {:keys [x y width height grow-type]} shape] + [:foreignObject {:transform (gsh/transform-matrix shape) + :x x :y y + :width (if (#{:auto-width} grow-type) 10000 width) + :height (if (#{:auto-height :auto-width} grow-type) 10000 height)} + + [:& text-shape-edit-html {:shape shape}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 42f467851..3b28d8daa 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -14,7 +14,6 @@ [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.colors :as dc] @@ -143,6 +142,7 @@ [{:keys [file-id local? objects open? on-open on-close] :as props}] (let [input-ref (mf/use-ref nil) state (mf/use-state {:menu-open false + :renaming nil :top nil :left nil :object-id nil}) @@ -176,6 +176,25 @@ (let [params {:id (:object-id @state)}] (st/emit! (dwl/delete-media params))))) + on-rename + (mf/use-callback + (mf/deps state) + (fn [] + (swap! state assoc :renaming (:object-id @state)))) + + cancel-rename + (mf/use-callback + (mf/deps state) + (fn [] + (swap! state assoc :renaming nil))) + + do-rename + (mf/use-callback + (mf/deps state) + (fn [new-name] + (st/emit! (dwl/rename-media (:renaming @state) new-name)) + (swap! state assoc :renaming nil))) + on-context-menu (mf/use-callback (fn [object-id] @@ -192,8 +211,9 @@ on-drag-start (mf/use-callback - (fn [path event] + (fn [path name event] (dnd/set-data! event "text/uri-list" (cfg/resolve-media-path path)) + (dnd/set-data! event "text/asset-name" name) (dnd/set-allowed-effect! event "move")))] [:div.asset-group @@ -213,10 +233,21 @@ [:div.grid-cell {:key (:id object) :draggable true :on-context-menu (on-context-menu (:id object)) - :on-drag-start (partial on-drag-start (:path object))} + :on-drag-start (partial on-drag-start (:path object) (:name object))} [:img {:src (cfg/resolve-media-path (:thumb-path object)) :draggable false}] ;; Also need to add css pointer-events: none - [:div.cell-name (:name object)]]) + + #_[:div.cell-name (:name object)] + (let [renaming? (= (:renaming @state) (:id object))] + [:& editable-label + {:class-name (dom/classnames + :cell-name true + :editing renaming?) + :value (:name object) + :editing? renaming? + :disable-dbl-click? true + :on-change do-rename + :on-cancel cancel-rename}])]) (when local? [:& context-menu @@ -225,7 +256,8 @@ :on-close #(swap! state assoc :menu-open false) :top (:top @state) :left (:left @state) - :options [[(tr "workspace.assets.delete") on-delete]]}])])])) + :options [[(tr "workspace.assets.rename") on-rename] + [(tr "workspace.assets.delete") on-delete]]}])])])) (mf/defc color-item [{:keys [color local? locale] :as props}] @@ -313,7 +345,8 @@ nil)) [:div.group-list-item {:on-context-menu on-context-menu} - [:& bc/color-bullet {:color color}] + [:& bc/color-bullet {:color color + :on-click click-color}] (if (:editing @state) [:input.element-name @@ -557,10 +590,10 @@ components (apply-filters (mf/deref components-ref) filters)] [:div.tool-window - [:div.tool-window-bar + [:div.tool-window-bar.library-bar + {:on-click toggle-open} [:div.collapse-library - {:class (dom/classnames :open @open?) - :on-click toggle-open} + {:class (dom/classnames :open @open?)} i/arrow-slide] (if local? @@ -570,8 +603,11 @@ [:span.tool-badge (t locale "workspace.assets.shared")])] [:* [:span (:name file)] - [:span.tool-link - [:a {:href (str "#" url) :target "_blank"} i/chain]]])] + [:span.tool-link.tooltip.tooltip-left {:alt "Open library file"} + [:a {:href (str "#" url) + :target "_blank" + :on-click dom/stop-propagation} + i/chain]]])] (when @open? (let [show-components? (and (or (= (:box filters) :all) @@ -667,7 +703,7 @@ [:div.assets-bar-title (t locale "workspace.assets.assets") [:div.libraries-button {:on-click #(modal/show! :libraries-dialog {})} - i/libraries + i/text-align-justify (t locale "workspace.assets.libraries")]] [:div.search-block diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index aebc62bb5..2ad04ab13 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -129,7 +129,6 @@ :rect i/box :circle i/circle :text i/text - :curve i/curve :path i/curve :frame i/artboard :group i/folder @@ -141,7 +140,7 @@ i/layers)) (defn is-shape? [type] - #{:shape :rect :circle :text :curve :path :frame :group}) + #{:shape :rect :circle :text :path :frame :group}) (defn parse-entry [{:keys [redo-changes]}] (->> redo-changes diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 2b83cb73f..9ac44acf4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -11,9 +11,9 @@ (:require [app.common.data :as d] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] + [app.main.data.workspace.common :as dwc] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.hooks :as hooks] @@ -39,7 +39,6 @@ :circle i/circle :path i/curve :rect i/box - :curve i/curve :text i/text :group (if (some? (:component-id shape)) i/component @@ -113,8 +112,8 @@ (fn [event] (dom/stop-propagation event) (if (and expanded? (kbd/shift? event)) - (st/emit! dw/collapse-all) - (st/emit! (dw/toggle-collapse id)))) + (st/emit! dwc/collapse-all) + (st/emit! (dwc/toggle-collapse id)))) toggle-blocking (fn [event] @@ -168,13 +167,13 @@ (if (= side :center) (st/emit! (dw/relocate-selected-shapes (:id item) 0)) (let [to-index (if (= side :top) (inc index) index) - parent-id (cph/get-parent (:id item) objects)] + parent-id (cp/get-parent (:id item) objects)] (st/emit! (dw/relocate-selected-shapes parent-id to-index))))) on-hold (fn [] (when-not expanded? - (st/emit! (dw/toggle-collapse (:id item))))) + (st/emit! (dwc/toggle-collapse (:id item))))) [dprops dref] (hooks/use-sortable :data-type "app/layer" @@ -245,7 +244,7 @@ old-obs (unchecked-get oprops "objects")] (and (= new-itm old-itm) (identical? new-idx old-idx) - (let [childs (cph/get-children (:id new-itm) new-obs) + (let [childs (cp/get-children (:id new-itm) new-obs) childs' (conj childs (:id new-itm))] (and (or (= new-sel old-sel) (not (or (boolean (some new-sel childs')) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 0a4020a8d..467d87f94 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -48,7 +48,6 @@ :icon [:& icon/options {:shape shape}] :circle [:& circle/options {:shape shape}] :path [:& path/options {:shape shape}] - :curve [:& path/options {:shape shape}] :image [:& image/options {:shape shape}] nil) [:& exports-menu diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index a37caa009..91f0eb438 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -13,13 +13,21 @@ [app.util.dom :as dom])) (mf/defc advanced-options [{:keys [visible? on-close children]}] - (let [handle-click (fn [event] (when on-close + (let [ref (mf/use-ref nil) + handle-click (fn [event] (when on-close (do (dom/stop-propagation event) (on-close))))] + (mf/use-effect + (mf/deps visible?) + (fn [] + (when-let [node (mf/ref-val ref)] + (when visible? + (.scrollIntoViewIfNeeded ^js node))))) + (when visible? [:* [:div.focus-overlay {:on-click handle-click}] - [:div.advanced-options-wrapper + [:div.advanced-options-wrapper {:ref ref} [:div.advanced-options {} children]]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/component.cljs index a88062f38..0d4a125b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/component.cljs @@ -10,7 +10,7 @@ (ns app.main.ui.workspace.sidebar.options.component (:require [rumext.alpha :as mf] - [app.common.pages-helpers :as cph] + [app.common.pages :as cp] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.icons :as i] @@ -32,10 +32,10 @@ show? (some? (:component-id values)) local-library (mf/deref refs/workspace-local-library) libraries (mf/deref refs/workspace-libraries) - component (cph/get-component (:component-id values) - (:component-file values) - local-library - libraries) + component (cp/get-component (:component-id values) + (:component-file values) + local-library + libraries) on-menu-click (mf/use-callback (fn [event] @@ -49,10 +49,10 @@ do-detach-component #(st/emit! (dwl/detach-component id)) do-reset-component #(st/emit! (dwl/reset-component id)) do-update-component #(do - (st/emit! dwc/start-undo-transaction) + (st/emit! (dwc/start-undo-transaction)) (st/emit! (dwl/update-component id)) (st/emit! (dwl/sync-file nil)) - (st/emit! dwc/commit-undo-transaction)) + (st/emit! (dwc/commit-undo-transaction))) do-show-component #(st/emit! (dw/go-to-layout :assets)) do-navigate-component-file #(st/emit! (dwl/nav-to-component-file (:component-file values)))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/fill.cljs index d469acee2..016dc9652 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/fill.cljs @@ -81,13 +81,13 @@ (mf/use-callback (mf/deps ids) (fn [value opacity id file-id] - (st/emit! dwc/start-undo-transaction))) + (st/emit! (dwc/start-undo-transaction)))) on-close-picker (mf/use-callback (mf/deps ids) (fn [value opacity id file-id] - (st/emit! dwc/commit-undo-transaction)))] + (st/emit! (dwc/commit-undo-transaction))))] (if show? [:div.element-set diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs index 5adaf5c97..90489cee2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/frame.cljs @@ -20,6 +20,7 @@ [app.main.data.workspace :as udw] [app.main.ui.icons :as i] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.components.numeric-input :refer [numeric-input]] [app.main.ui.workspace.sidebar.options.fill :refer [fill-attrs fill-menu]] [app.main.ui.workspace.sidebar.options.stroke :refer [stroke-attrs stroke-menu]] [app.main.ui.workspace.sidebar.options.frame-grid :refer [frame-grid]] @@ -51,7 +52,7 @@ (fn [event attr] (let [value (-> (dom/get-target event) (dom/get-value) - (d/parse-integer 0))] + (d/parse-integer 1))] (st/emit! (udw/update-dimensions [(:id shape)] attr value)))) on-proportion-lock-change @@ -104,43 +105,39 @@ i/lock i/unlock)] [:div.input-element.pixels - [:input.input-text {:type "number" - :min "0" - :on-click select-all - :on-change on-width-change - :value (-> (:width shape) - (math/precision 2) - (d/coalesce-str "0"))}]] + [:> numeric-input {:min "1" + :on-click select-all + :on-change on-width-change + :value (-> (:width shape) + (math/precision 2) + (d/coalesce-str "1"))}]] [:div.input-element.pixels - [:input.input-text {:type "number" - :min "0" - :on-click select-all - :on-change on-height-change - :value (-> (:height shape) - (math/precision 2) - (d/coalesce-str "0"))}]]] + [:> numeric-input {:min "1" + :on-click select-all + :on-change on-height-change + :value (-> (:height shape) + (math/precision 2) + (d/coalesce-str "1"))}]]] ;; POSITION [:div.row-flex [:span.element-set-subtitle (tr "workspace.options.position")] [:div.input-element.pixels - [:input.input-text {:placeholder "x" - :type "number" - :on-click select-all - :on-change on-pos-x-change - :value (-> (:x shape) - (math/precision 2) - (d/coalesce-str "0"))}]] + [:> numeric-input {:placeholder "x" + :on-click select-all + :on-change on-pos-x-change + :value (-> (:x shape) + (math/precision 2) + (d/coalesce-str "0"))}]] [:div.input-element.pixels - [:input.input-text {:placeholder "y" - :type "number" - :on-click select-all - :on-change on-pos-y-change - :value (-> (:y shape) - (math/precision 2) - (d/coalesce-str "0"))}]]]]])) + [:> numeric-input {:placeholder "y" + :on-click select-all + :on-change on-pos-y-change + :value (-> (:y shape) + (math/precision 2) + (d/coalesce-str "0"))}]]]]])) (def +size-presets+ [{:name "APPLE"} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/frame_grid.cljs index d1aba7a4f..ed4c96295 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/frame_grid.cljs @@ -23,6 +23,7 @@ [app.main.ui.workspace.sidebar.options.common :refer [advanced-options]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row]] [app.main.ui.workspace.sidebar.options.rows.input-row :refer [input-row]] + [app.main.ui.components.numeric-input :refer [numeric-input]] [app.main.ui.components.select :refer [select]] [app.main.ui.components.editable-select :refer [editable-select]] [app.main.ui.components.dropdown :refer [dropdown]] @@ -135,11 +136,10 @@ (if (= type :square) [:div.input-element.pixels - [:input.input-text {:type "number" - :min "1" - :no-validate true - :value (:size params) - :on-change (handle-change-event :params :size)}]] + [:> numeric-input {:min "1" + :no-validate true + :value (:size params) + :on-change (handle-change-event :params :size)}]] [:& editable-select {:value (:size params) :type (when (number? (:size params)) "number" ) :class "input-option" diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs index 43711deb7..4554a9530 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs @@ -11,8 +11,8 @@ (ns app.main.ui.workspace.sidebar.options.group (:require [rumext.alpha :as mf] + [app.common.attrs :as attrs] [app.common.geom.shapes :as geom] - [app.common.pages-helpers :as cph] [app.main.refs :as refs] [app.main.data.workspace.texts :as dwt] [app.main.ui.workspace.sidebar.options.multiple :refer [get-shape-attrs]] @@ -43,7 +43,7 @@ (merge ;; All values extracted from the group shape, except ;; border radius, that needs to be looked up from children - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % measure-attrs nil @@ -51,7 +51,7 @@ nil) [shape]) measure-attrs) - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % [:rx :ry] nil @@ -64,10 +64,10 @@ (select-keys shape component-attrs) fill-values - (geom/get-attrs-multi shape-with-children fill-attrs) + (attrs/get-attrs-multi shape-with-children fill-attrs) stroke-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % stroke-attrs nil @@ -77,7 +77,7 @@ stroke-attrs) font-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-font-attrs @@ -87,7 +87,7 @@ text-font-attrs) align-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-align-attrs @@ -97,7 +97,7 @@ text-align-attrs) spacing-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-spacing-attrs @@ -107,7 +107,7 @@ text-spacing-attrs) valign-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-valign-attrs @@ -117,7 +117,7 @@ text-valign-attrs) decoration-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-decoration-attrs @@ -127,7 +127,7 @@ text-decoration-attrs) transform-values - (geom/get-attrs-multi (map #(get-shape-attrs + (attrs/get-attrs-multi (map #(get-shape-attrs % nil text-transform-attrs diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/interactions.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/interactions.cljs index bfa6c576e..98f14837c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/interactions.cljs @@ -11,7 +11,7 @@ (:require [rumext.alpha :as mf] [app.common.data :as d] - [app.common.pages-helpers :as cph] + [app.common.pages :as cp] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] @@ -32,7 +32,7 @@ destination (get objects (:destination interaction)) frames (mf/use-memo (mf/deps objects) - #(cph/select-frames objects)) + #(cp/select-frames objects)) show-frames-dropdown? (mf/use-state false) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs index fae050ac3..c26a11a8c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/measures.cljs @@ -20,6 +20,7 @@ [app.common.geom.point :as gpt] [app.main.data.workspace :as udw] [app.main.data.workspace.common :as dwc] + [app.main.ui.components.numeric-input :refer [numeric-input]] [app.common.math :as math] [app.util.i18n :refer [t] :as i18n])) @@ -43,11 +44,15 @@ old-shapes (deref (refs/objects-by-id ids)) frames (map #(deref (refs/object-by-id (:frame-id %))) old-shapes) - shapes (map gsh/transform-shape frames old-shapes) - values (cond-> values - (not= (:x values) :multiple) (assoc :x (:x (:selrect (first shapes)))) - (not= (:y values) :multiple) (assoc :y (:y (:selrect (first shapes))))) + shapes (as-> old-shapes $ + (map gsh/transform-shape $) + (map gsh/translate-to-frame $ frames)) + + values (let [{:keys [x y]} (-> shapes first :points gsh/points->selrect)] + (cond-> values + (not= (:x values) :multiple) (assoc :x x) + (not= (:y values) :multiple) (assoc :y y))) proportion-lock (:proportion-lock values) @@ -55,7 +60,7 @@ (fn [event attr] (let [value (-> (dom/get-target event) (dom/get-value) - (d/parse-integer 0))] + (d/parse-integer 1))] (st/emit! (udw/update-dimensions ids attr value)))) on-proportion-lock-change @@ -65,7 +70,7 @@ do-position-change (fn [shape' frame' value attr] - (let [from (-> shape' :selrect attr) + (let [from (-> shape' :points gsh/points->selrect attr) to (+ value (attr frame')) target (+ (attr shape') (- to from))] (st/emit! (udw/update-position (:id shape') {attr target})))) @@ -114,66 +119,61 @@ (when (options :size) [:div.row-flex [:span.element-set-subtitle (t locale "workspace.options.size")] + [:div.input-element.width + [:> numeric-input {:min "1" + :no-validate true + :placeholder "--" + :on-click select-all + :on-change on-width-change + :value (attr->string :width values)}]] + + [:div.input-element.height + [:> numeric-input {:min "1" + :no-validate true + :placeholder "--" + :on-click select-all + :on-change on-height-change + :value (attr->string :height values)}]] + [:div.lock-size {:class (classnames :selected (true? proportion-lock) :disabled (= proportion-lock :multiple)) :on-click on-proportion-lock-change} (if proportion-lock i/lock - i/unlock)] - [:div.input-element.width - [:input.input-text {:type "number" - :min "0" - :no-validate true - :placeholder "--" - :on-click select-all - :on-change on-width-change - :value (attr->string :width values)}]] - - - [:div.input-element.height - [:input.input-text {:type "number" - :min "0" - :no-validate true - :placeholder "--" - :on-click select-all - :on-change on-height-change - :value (attr->string :height values)}]]]) + i/unlock)]]) ;; POSITION (when (options :position) [:div.row-flex [:span.element-set-subtitle (t locale "workspace.options.position")] [:div.input-element.Xaxis - [:input.input-text {:type "number" - :no-validate true - :placeholder "--" - :on-click select-all - :on-change on-pos-x-change - :value (attr->string :x values)}]] + [:> numeric-input {:no-validate true + :placeholder "--" + :on-click select-all + :on-change on-pos-x-change + :value (attr->string :x values)}]] [:div.input-element.Yaxis - [:input.input-text {:type "number" - :no-validate true - :placeholder "--" - :on-click select-all - :on-change on-pos-y-change - :value (attr->string :y values)}]]]) + [:> numeric-input {:no-validate true + :placeholder "--" + :on-click select-all + :on-change on-pos-y-change + :value (attr->string :y values)}]]]) ;; ROTATION (when (options :rotation) [:div.row-flex [:span.element-set-subtitle (t locale "workspace.options.rotation")] [:div.input-element.degrees - [:input.input-text - {:type "number" - :no-validate true + [:> numeric-input + {:no-validate true :min "0" :max "359" :placeholder "--" :on-click select-all :on-change on-rotation-change :value (attr->string :rotation values)}]] - [:input.slidebar + #_[:input.slidebar {:type "range" :min "0" :max "359" @@ -187,9 +187,9 @@ [:div.row-flex [:span.element-set-subtitle (t locale "workspace.options.radius")] [:div.input-element.pixels - [:input.input-text - {:type "number" - :placeholder "--" + [:> numeric-input + {:placeholder "--" + :min "0" :on-click select-all :on-change on-radius-change :value (attr->string :rx values)}]] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs index b13a69acb..03f45783b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs @@ -11,6 +11,7 @@ (:require [rumext.alpha :as mf] [app.common.geom.shapes :as geom] + [app.common.attrs :as attrs] [app.main.data.workspace.texts :as dwt] [app.main.ui.workspace.sidebar.options.measures :refer [measure-attrs measures-menu]] [app.main.ui.workspace.sidebar.options.fill :refer [fill-attrs fill-menu]] @@ -48,9 +49,9 @@ text-attrs convert-attrs extract-fn))] - (geom/get-attrs-multi (map mapfn shapes) (or attrs text-attrs)))) + (attrs/get-attrs-multi (map mapfn shapes) (or attrs text-attrs)))) - measure-values (geom/get-attrs-multi shapes measure-attrs) + measure-values (attrs/get-attrs-multi shapes measure-attrs) fill-values (extract {:attrs fill-attrs :text-attrs ot/text-fill-attrs diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index 31381c3a0..e0a0289ef 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -34,12 +34,12 @@ on-open (mf/use-callback (mf/deps page-id) - #(st/emit! dwc/start-undo-transaction)) + #(st/emit! (dwc/start-undo-transaction))) on-close (mf/use-callback (mf/deps page-id) - #(st/emit! dwc/commit-undo-transaction))] + #(st/emit! (dwc/commit-undo-transaction)))] [:div.element-set [:div.element-set-title (t locale "workspace.options.canvas-background")] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 2a6437fb5..c9fbbce61 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -20,7 +20,9 @@ [app.util.color :as uc] [app.main.refs :as refs] [app.main.data.modal :as modal] - [app.main.ui.components.color-bullet :as cb])) + [app.main.ui.hooks :as h] + [app.main.ui.components.color-bullet :as cb] + [app.main.ui.components.numeric-input :refer [numeric-input]])) (defn color-picker-callback [color disable-gradient disable-opacity handle-change-color handle-open handle-close] @@ -119,12 +121,15 @@ disable-opacity handle-pick-color handle-open - handle-close)))] + handle-close))) + + prev-color (h/use-previous color)] (mf/use-effect - (mf/deps color) + (mf/deps color prev-color) (fn [] - (modal/update-props! :colorpicker {:data (parse-color color)}))) + (when (not= prev-color color) + (modal/update-props! :colorpicker {:data (parse-color color)})))) [:div.row-flex.color-data [:& cb/color-bullet {:color color @@ -158,11 +163,10 @@ (not (:gradient color))) [:div.input-element {:class (classnames :percentail (not= (:opacity color) :multiple))} - [:input.input-text {:type "number" - :value (-> color :opacity opacity->string) - :placeholder (tr "settings.multiple") - :on-click select-all - :on-change handle-opacity-change - :min "0" - :max "100"}]])])])) + [:> numeric-input {:value (-> color :opacity opacity->string) + :placeholder (tr "settings.multiple") + :on-click select-all + :on-change handle-opacity-change + :min "0" + :max "100"}]])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs index 4c7933334..a04be184d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs @@ -11,6 +11,7 @@ (:require [rumext.alpha :as mf] [app.common.data :as d] + [app.main.ui.components.numeric-input :refer [numeric-input]] [app.main.ui.components.select :refer [select]] [app.main.ui.components.editable-select :refer [editable-select]] [app.util.dom :as dom])) @@ -42,10 +43,9 @@ (or (not min) (>= value min)) (or (not max) (<= value max))) (on-change value))))] - [:input.input-text - {:placeholder placeholder - :type "number" - :on-change handle-change - :value (or value "")}])) - - ]]) + [:> numeric-input {:placeholder placeholder + :min (when min (str min)) + :max (when max (str max)) + :on-change handle-change + :value (or value "")}]))]]) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shadow.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shadow.cljs index 54756d4da..6bf6ae899 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shadow.cljs @@ -15,6 +15,7 @@ [app.main.data.workspace.common :as dwc] [app.main.store :as st] [app.main.ui.icons :as i] + [app.main.ui.components.numeric-input :refer [numeric-input]] [app.main.ui.workspace.sidebar.options.common :refer [advanced-options]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row]] [app.util.dom :as dom] @@ -92,23 +93,20 @@ [:div.element-set-actions-button {:on-click #(reset! open-shadow true)} i/actions] - - [:input.input-text {:type "number" - :ref basic-offset-x-ref - :on-change (update-attr index :offset-x valid-number?) - :on-click (select-text basic-offset-x-ref) - :default-value (:offset-x value)}] - [:input.input-text {:type "number" - :ref basic-offset-y-ref - :on-change (update-attr index :offset-y valid-number?) - :on-click (select-text basic-offset-y-ref) - :default-value (:offset-y value)}] - [:input.input-text {:type "number" - :ref basic-blur-ref - :on-click (select-text basic-blur-ref) - :on-change (update-attr index :blur valid-number?) - :min 0 - :default-value (:blur value)}] + + [:> numeric-input {:ref basic-offset-x-ref + :on-change (update-attr index :offset-x valid-number?) + :on-click (select-text basic-offset-x-ref) + :default-value (:offset-x value)}] + [:> numeric-input {:ref basic-offset-y-ref + :on-change (update-attr index :offset-y valid-number?) + :on-click (select-text basic-offset-y-ref) + :default-value (:offset-y value)}] + [:> numeric-input {:ref basic-blur-ref + :on-click (select-text basic-blur-ref) + :on-change (update-attr index :blur valid-number?) + :min 0 + :default-value (:blur value)}] [:div.element-set-actions [:div.element-set-actions-button {:on-click (toggle-visibility index)} @@ -129,46 +127,42 @@ [:div.row-grid-2 [:div.input-element - [:input.input-text {:type "number" - :ref adv-offset-x-ref - :no-validate true - :placeholder "--" - :on-click (select-text adv-offset-x-ref) - :on-change (update-attr index :offset-x valid-number? basic-offset-x-ref) - :default-value (:offset-x value)}] + [:> numeric-input {:ref adv-offset-x-ref + :no-validate true + :placeholder "--" + :on-click (select-text adv-offset-x-ref) + :on-change (update-attr index :offset-x valid-number? basic-offset-x-ref) + :default-value (:offset-x value)}] [:span.after (t locale "workspace.options.shadow-options.offsetx")]] [:div.input-element - [:input.input-text {:type "number" - :ref adv-offset-y-ref - :no-validate true - :placeholder "--" - :on-click (select-text adv-offset-y-ref) - :on-change (update-attr index :offset-y valid-number? basic-offset-y-ref) - :default-value (:offset-y value)}] + [:> numeric-input {:ref adv-offset-y-ref + :no-validate true + :placeholder "--" + :on-click (select-text adv-offset-y-ref) + :on-change (update-attr index :offset-y valid-number? basic-offset-y-ref) + :default-value (:offset-y value)}] [:span.after (t locale "workspace.options.shadow-options.offsety")]]] [:div.row-grid-2 [:div.input-element - [:input.input-text {:type "number" - :ref adv-blur-ref - :no-validate true - :placeholder "--" - :on-click (select-text adv-blur-ref) - :on-change (update-attr index :blur valid-number? basic-blur-ref) - :min 0 - :default-value (:blur value)}] + [:> numeric-input {:ref adv-blur-ref + :no-validate true + :placeholder "--" + :on-click (select-text adv-blur-ref) + :on-change (update-attr index :blur valid-number? basic-blur-ref) + :min 0 + :default-value (:blur value)}] [:span.after (t locale "workspace.options.shadow-options.blur")]] [:div.input-element - [:input.input-text {:type "number" - :ref adv-spread-ref - :no-validate true - :placeholder "--" - :on-click (select-text adv-spread-ref) - :on-change (update-attr index :spread valid-number?) - :min 0 - :default-value (:spread value)}] + [:> numeric-input {:ref adv-spread-ref + :no-validate true + :placeholder "--" + :on-click (select-text adv-spread-ref) + :on-change (update-attr index :spread valid-number?) + :min 0 + :default-value (:spread value)}] [:span.after (t locale "workspace.options.shadow-options.spread")]]] [:div.color-row-wrap @@ -178,8 +172,8 @@ (:color value)) :disable-gradient true :on-change (update-color index) - :on-open #(st/emit! dwc/start-undo-transaction) - :on-close #(st/emit! dwc/commit-undo-transaction)}]]]])) + :on-open #(st/emit! (dwc/start-undo-transaction)) + :on-close #(st/emit! (dwc/commit-undo-transaction))}]]]])) (mf/defc shadow-menu [{:keys [ids values] :as props}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/stroke.cljs index 6419805d2..12903f28f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/stroke.cljs @@ -116,13 +116,13 @@ (mf/use-callback (mf/deps ids) (fn [value opacity id file-id] - (st/emit! dwc/start-undo-transaction))) + (st/emit! (dwc/start-undo-transaction)))) on-close-picker (mf/use-callback (mf/deps ids) (fn [value opacity id file-id] - (st/emit! dwc/commit-undo-transaction)))] + (st/emit! (dwc/commit-undo-transaction))))] (if show-options [:div.element-set diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/text.cljs index cde0071ed..25644aee7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/text.cljs @@ -62,7 +62,7 @@ (on-change {:text-align new-align}))] ;; --- Align - [:div.row-flex.align-icons + [:div.align-icons [:span.tooltip.tooltip-bottom {:alt (t locale "workspace.options.text-options.align-left") :class (dom/classnames :current (= "left" text-align)) @@ -85,58 +85,55 @@ i/text-align-justify]])) -(mf/defc additional-options +(mf/defc vertical-align [{:keys [shapes editor ids values locale on-change] :as props}] (let [{:keys [vertical-align]} values - - to-single-value (fn [coll] (if (> (count coll) 1) nil (first coll))) - - grow-type (->> shapes (map :grow-type) (remove nil?) (into #{}) to-single-value) - vertical-align (or vertical-align "top") - - handle-change-grow - (fn [event grow-type] - (st/emit! (dwc/update-shapes ids #(assoc % :grow-type grow-type)))) - handle-change (fn [event new-align] (on-change {:vertical-align new-align}))] - [:div.row-flex - [:div.align-icons - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.align-top") - :class (dom/classnames :current (= "top" vertical-align)) - :on-click #(handle-change % "top")} - i/align-top] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.align-middle") - :class (dom/classnames :current (= "center" vertical-align)) - :on-click #(handle-change % "center")} - i/align-middle] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.align-bottom") - :class (dom/classnames :current (= "bottom" vertical-align)) - :on-click #(handle-change % "bottom")} - i/align-bottom]] + [:div.align-icons + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.align-top") + :class (dom/classnames :current (= "top" vertical-align)) + :on-click #(handle-change % "top")} + i/align-top] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.align-middle") + :class (dom/classnames :current (= "center" vertical-align)) + :on-click #(handle-change % "center")} + i/align-middle] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.align-bottom") + :class (dom/classnames :current (= "bottom" vertical-align)) + :on-click #(handle-change % "bottom")} + i/align-bottom]])) - [:div.align-icons - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.grow-fixed") - :class (dom/classnames :current (= :fixed grow-type)) - :on-click #(handle-change-grow % :fixed)} - i/auto-fix] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.grow-auto-width") - :class (dom/classnames :current (= :auto-width grow-type)) - :on-click #(handle-change-grow % :auto-width)} - i/auto-width] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.grow-auto-height") - :class (dom/classnames :current (= :auto-height grow-type)) - :on-click #(handle-change-grow % :auto-height)} - i/auto-height]]])) +(mf/defc grow-options + [{:keys [shapes editor ids values locale on-change] :as props}] + (let [to-single-value (fn [coll] (if (> (count coll) 1) nil (first coll))) + grow-type (->> shapes (map :grow-type) (remove nil?) (into #{}) to-single-value) + handle-change-grow + (fn [event grow-type] + (st/emit! (dwc/update-shapes ids #(assoc % :grow-type grow-type))))] + + [:div.align-icons + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.grow-fixed") + :class (dom/classnames :current (= :fixed grow-type)) + :on-click #(handle-change-grow % :fixed)} + i/auto-fix] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.grow-auto-width") + :class (dom/classnames :current (= :auto-width grow-type)) + :on-click #(handle-change-grow % :auto-width)} + i/auto-width] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.grow-auto-height") + :class (dom/classnames :current (= :auto-height grow-type)) + :on-click #(handle-change-grow % :auto-height)} + i/auto-height]])) (mf/defc text-decoration-options [{:keys [editor ids values locale on-change] :as props}] @@ -147,26 +144,24 @@ handle-change (fn [event type] (on-change {:text-decoration type}))] - [:div.row-flex - [:span.element-set-subtitle (t locale "workspace.options.text-options.decoration")] - [:div.align-icons - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.none") - :class (dom/classnames :current (= "none" text-decoration)) - :on-click #(handle-change % "none")} - i/minus] + [:div.align-icons + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.none") + :class (dom/classnames :current (= "none" text-decoration)) + :on-click #(handle-change % "none")} + i/minus] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.underline") - :class (dom/classnames :current (= "underline" text-decoration)) - :on-click #(handle-change % "underline")} - i/underline] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.underline") + :class (dom/classnames :current (= "underline" text-decoration)) + :on-click #(handle-change % "underline")} + i/underline] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.strikethrough") - :class (dom/classnames :current (= "line-through" text-decoration)) - :on-click #(handle-change % "line-through")} - i/strikethrough]]])) + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.strikethrough") + :class (dom/classnames :current (= "line-through" text-decoration)) + :on-click #(handle-change % "line-through")} + i/strikethrough]])) (defn generate-typography-name [{:keys [font-id font-variant-id] :as typography}] (let [{:keys [name]} (fonts/get-font-data font-id)] @@ -273,9 +268,16 @@ [:> typography-options opts]) [:div.element-set-content - [:> text-align-options opts] - [:> additional-options opts] - [:> text-decoration-options opts]]])) + + [:div.row-flex + [:> text-align-options opts] + [:> vertical-align opts]] + + [:div.row-flex + [:> grow-options opts] + [:> text-decoration-options opts]] + + ]])) (mf/defc options [{:keys [shape] :as props}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs index 08f83a2c9..5af24e253 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/typography.cljs @@ -135,7 +135,7 @@ (let [new-spacing (dom/get-target-val event)] (on-change {attr new-spacing})))] - [:div.row-flex + [:div.spacing-options [:div.input-icon [:span.icon-before.tooltip.tooltip-bottom {:alt (t locale "workspace.options.text-options.line-height")} @@ -171,29 +171,27 @@ handle-change (fn [event type] (on-change {:text-transform type}))] - [:div.row-flex - [:span.element-set-subtitle (t locale "workspace.options.text-options.text-case")] - [:div.align-icons - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.none") - :class (dom/classnames :current (= "none" text-transform)) - :on-click #(handle-change % "none")} - i/minus] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.uppercase") - :class (dom/classnames :current (= "uppercase" text-transform)) - :on-click #(handle-change % "uppercase")} - i/uppercase] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.lowercase") - :class (dom/classnames :current (= "lowercase" text-transform)) - :on-click #(handle-change % "lowercase")} - i/lowercase] - [:span.tooltip.tooltip-bottom - {:alt (t locale "workspace.options.text-options.titlecase") - :class (dom/classnames :current (= "capitalize" text-transform)) - :on-click #(handle-change % "capitalize")} - i/titlecase]]])) + [:div.align-icons + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.none") + :class (dom/classnames :current (= "none" text-transform)) + :on-click #(handle-change % "none")} + i/minus] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.uppercase") + :class (dom/classnames :current (= "uppercase" text-transform)) + :on-click #(handle-change % "uppercase")} + i/uppercase] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.lowercase") + :class (dom/classnames :current (= "lowercase" text-transform)) + :on-click #(handle-change % "lowercase")} + i/lowercase] + [:span.tooltip.tooltip-bottom + {:alt (t locale "workspace.options.text-options.titlecase") + :class (dom/classnames :current (= "capitalize" text-transform)) + :on-click #(handle-change % "capitalize")} + i/titlecase]])) (mf/defc typography-options [{:keys [ids editor values on-change]}] @@ -206,8 +204,9 @@ [:div.element-set-content [:> font-options opts] - [:> spacing-options opts] - [:> text-transform-options opts]])) + [:div.row-flex + [:> spacing-options opts] + [:> text-transform-options opts]]])) (mf/defc typography-entry @@ -271,7 +270,7 @@ (if read-only? [:div.element-set-content.typography-read-only-data [:div.row-flex.typography-name - [:spang (:name typography)]] + [:span (:name typography)]] [:div.row-flex [:span.label (t locale "workspace.assets.typography.font-id")] diff --git a/frontend/src/app/main/ui/workspace/snap_distances.cljs b/frontend/src/app/main/ui/workspace/snap_distances.cljs index a2559c01b..139b9c289 100644 --- a/frontend/src/app/main/ui/workspace/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/snap_distances.cljs @@ -141,15 +141,19 @@ (fn [[selrect selected frame]] (let [lt-side (if (= coord :x) :left :top) gt-side (if (= coord :x) :right :bottom) - areas (gsh/selrect->areas (or (:selrect frame) - (gsh/rect->rect-shape @refs/vbox)) selrect) + container-selrec (or (:selrect frame) + (gsh/rect->selrect @refs/vbox)) + areas (gsh/selrect->areas container-selrec selrect) query-side (fn [side] - (->> (uw/ask! {:cmd :selection/query - :page-id page-id - :frame-id (:id frame) - :rect (gsh/pad-selrec (areas side))}) - (rx/map #(set/difference % selected)) - (rx/map #(->> % (map (partial get @refs/workspace-page-objects))))))] + (let [rect (gsh/pad-selrec (areas side))] + (if (and (> (:width rect) 0) (> (:height rect) 0)) + (->> (uw/ask! {:cmd :selection/query + :page-id page-id + :frame-id (:id frame) + :rect rect}) + (rx/map #(set/difference % selected)) + (rx/map #(->> % (map (partial get @refs/workspace-page-objects))))) + (rx/of nil))))] (->> (query-side lt-side) (rx/combine-latest vector (query-side gt-side))))) @@ -192,25 +196,29 @@ distance-coincidences (concat (get-shapes-match show-candidate? lt-shapes) (get-shapes-match show-candidate? gt-shapes)) + ;; Stores the distance candidates to be shown + distance-candidates (d/concat + #{} + (map first distance-coincidences) + (filter #(check-in-set % lt-distances) gt-distances) + (filter #(check-in-set % gt-distances) lt-distances)) + + ;; Of these candidates we keep only the smaller to be displayed + min-distance (apply min distance-candidates) ;; Show the distances that either match one of the distances from the selrect ;; or are from the selrect and go to a shape on the left and to the right - show-distance? - (fn [dist] - (let [distances-to-show - (->> (d/concat #{} - (map first distance-coincidences) - (filter #(check-in-set % lt-distances) gt-distances) - (filter #(check-in-set % gt-distances) lt-distances)))] - (check-in-set dist distances-to-show))) + show-distance? #(check-in-set % #{min-distance}) ;; These are the segments whose distance will be displayed - ;; First segments from segments different that the selectio + ;; First segments from segments different that the selection other-shapes-segments (->> distance-coincidences + (filter #(show-distance? (first %))) (map second) ;; Retrieves list of [shape,shape] tuples (map #(mapv :selrect %))) ;; Changes [shape,shape] to [selrec,selrec] + ;; Segments from the selection to the other shapes selection-segments (->> (concat lt-shapes gt-shapes) (filter #(show-distance? (distance-to-selrect %))) diff --git a/frontend/src/app/main/ui/workspace/snap_points.cljs b/frontend/src/app/main/ui/workspace/snap_points.cljs index 480d1d57c..1a24da932 100644 --- a/frontend/src/app/main/ui/workspace/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/snap_points.cljs @@ -58,7 +58,7 @@ (defn get-snap [coord {:keys [shapes page-id filter-shapes local]}] (let [shape (if (> (count shapes) 1) - (->> shapes (map gsh/transform-shape) gsh/selection-rect) + (->> shapes (map gsh/transform-shape) gsh/selection-rect (gsh/setup {:type :rect})) (->> shapes (first))) shape (if (:modifiers local) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 402399bd6..1a97b663e 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -1,4 +1,4 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public +; 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/. ;; @@ -52,7 +52,8 @@ [goog.events :as events] [potok.core :as ptk] [promesa.core :as p] - [rumext.alpha :as mf]) + [rumext.alpha :as mf] + [app.main.ui.workspace.shapes.path.actions :refer [path-actions]]) (:import goog.events.EventType)) ;; --- Coordinates Widget @@ -115,8 +116,7 @@ (st/emit! dw/start-pan) (rx/subscribe stream (fn [delta] - (let [vbox (.. ^js node -viewBox -baseVal) - zoom (gpt/point @refs/selected-zoom) + (let [zoom (gpt/point @refs/selected-zoom) delta (gpt/divide delta zoom)] (st/emit! (dw/update-viewport-position {:x #(- % (:x delta)) @@ -198,17 +198,21 @@ vport vbox edition + edit-path tooltip selected panning picking-color?]} local page-id (mf/use-ctx ctx/current-page-id) - selrect-orig (->> (mf/deref refs/selected-objects) - (gsh/selection-rect)) - selrect (-> selrect-orig - (assoc :modifiers (:modifiers local)) - (gsh/transform-shape)) + + selected-objects (mf/deref refs/selected-objects) + selrect-orig (->> selected-objects + (gsh/selection-rect)) + selrect (->> selected-objects + (map #(assoc % :modifiers (:modifiers local))) + (map gsh/transform-shape) + (gsh/selection-rect)) alt? (mf/use-state false) viewport-ref (mf/use-ref nil) @@ -217,9 +221,9 @@ drawing (mf/deref refs/workspace-drawing) drawing-tool (:tool drawing) drawing-obj (:object drawing) + drawing-path? (and edition (= :draw (get-in edit-path [edition :edit-mode]))) zoom (or zoom 1) - on-mouse-down (mf/use-callback (mf/deps drawing-tool edition) @@ -229,23 +233,23 @@ ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event)] - (st/emit! (ms/->MouseEvent :down ctrl? shift? alt?)) + (when (= 1 (.-which event)) + (st/emit! (ms/->MouseEvent :down ctrl? shift? alt?))) + (cond - (and (= 1 (.-which event))) + (and (= 1 (.-which event)) (not edition)) (if drawing-tool - (when (not= drawing-tool :comments) + (when (not (#{:comments :path} drawing-tool)) (st/emit! (dd/start-drawing drawing-tool))) (st/emit! dw/handle-selection)) - (and (not edition) - (= 2 (.-which event))) + (and (= 2 (.-which event))) (handle-viewport-positioning viewport-ref))))) on-context-menu (mf/use-callback (fn [event] (dom/prevent-default event) - (dom/stop-propagation event) (let [position (dom/get-client-position event)] (st/emit! (dw/show-context-menu {:position position}))))) @@ -257,32 +261,34 @@ ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event)] - (st/emit! (ms/->MouseEvent :up ctrl? shift? alt?)) + (when (= 1 (.-which event)) + (st/emit! (ms/->MouseEvent :up ctrl? shift? alt?))) (when (= 2 (.-which event)) - (st/emit! dw/finish-pan - ::finish-positioning))))) + (do + (dom/prevent-default event) + (st/emit! dw/finish-pan + ::finish-positioning)))))) on-pointer-down (mf/use-callback - (fn [event] + (fn [event] (let [target (dom/get-target event)] - ; Capture mouse pointer to detect the movements even if cursor - ; leaves the viewport or the browser itself - ; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + ; Capture mouse pointer to detect the movements even if cursor + ; leaves the viewport or the browser itself + ; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture (.setPointerCapture target (.-pointerId event))))) on-pointer-up (mf/use-callback - (fn [event] + (fn [event] (let [target (dom/get-target event)] - ; Release pointer on mouse up + ; Release pointer on mouse up (.releasePointerCapture target (.-pointerId event))))) on-click (mf/use-callback (fn [event] - (dom/stop-propagation event) (let [ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event)] @@ -290,12 +296,16 @@ on-double-click (mf/use-callback + (mf/deps drawing-path?) (fn [event] (dom/stop-propagation event) (let [ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event)] - (st/emit! (ms/->MouseEvent :double-click ctrl? shift? alt?))))) + (st/emit! (ms/->MouseEvent :double-click ctrl? shift? alt?)) + + (if (not drawing-path?) + (st/emit! dw/clear-edition-mode))))) on-key-down (mf/use-callback @@ -344,10 +354,15 @@ on-mouse-move (fn [event] (let [event (.getBrowserEvent ^js event) - pt (dom/get-client-position ^js event) - pt (translate-point-to-viewport pt) - delta (gpt/point (.-movementX ^js event) - (.-movementY ^js event))] + raw-pt (dom/get-client-position ^js event) + pt (translate-point-to-viewport raw-pt) + + ;; We calculate the delta because Safari's MouseEvent.movementX/Y drop + ;; events + delta (if @last-position + (gpt/subtract raw-pt @last-position) + (gpt/point 0 0))] + (reset! last-position raw-pt) (st/emit! (ms/->PointerEvent :delta delta (kbd/ctrl? event) (kbd/shift? event) @@ -425,6 +440,7 @@ final-x (- (:x viewport-coord) (/ (:width shape) 2)) final-y (- (:y viewport-coord) (/ (:height shape) 2))] (st/emit! (dw/add-shape (-> shape + (assoc :id (uuid/next)) (assoc :x final-x) (assoc :y final-y))))) @@ -439,6 +455,7 @@ (dnd/has-type? event "text/uri-list") (let [data (dnd/get-data event "text/uri-list") + name (dnd/get-data event "text/asset-name") lines (str/lines data) urls (filter #(and (not (str/blank? %)) (not (str/starts-with? % "#"))) @@ -447,7 +464,8 @@ (map (fn [uri] (with-meta {:file-id (:id file) :local? true - :uri uri} + :uri uri + :name name} {:on-success #(on-uploaded % viewport-coord)}))) (map dw/upload-media-objects) (apply st/emit!))) @@ -462,6 +480,10 @@ (with-meta params {:on-success #(on-uploaded % viewport-coord)}))))))) + on-paste + (fn [event] + (st/emit! (dw/paste-from-event event))) + on-resize (fn [event] (let [node (mf/ref-val viewport-ref) @@ -477,13 +499,14 @@ (let [node (mf/ref-val viewport-ref) prnt (dom/get-parent node) - keys [(events/listen (dom/get-root) EventType.KEYDOWN on-key-down) - (events/listen (dom/get-root) EventType.KEYUP on-key-up) + keys [(events/listen js/document EventType.KEYDOWN on-key-down) + (events/listen js/document EventType.KEYUP on-key-up) (events/listen node EventType.MOUSEMOVE on-mouse-move) ;; bind with passive=false to allow the event to be cancelled ;; https://stackoverflow.com/a/57582286/3219895 (events/listen js/window EventType.WHEEL on-mouse-wheel #js {:passive false}) - (events/listen js/window EventType.RESIZE on-resize)]] + (events/listen js/window EventType.RESIZE on-resize) + (events/listen js/window EventType.PASTE on-paste)]] (fn [] (doseq [key keys] @@ -527,12 +550,12 @@ :class (when drawing-tool "drawing") :style {:cursor (cond panning cur/hand - (= drawing-tool :comments) cur/hand + (= drawing-tool :comments) cur/comments (= drawing-tool :frame) cur/create-artboard (= drawing-tool :rect) cur/create-rectangle (= drawing-tool :circle) cur/create-ellipse - (= drawing-tool :path) cur/pen - (= drawing-tool :curve)cur/pencil + (or (= drawing-tool :path) drawing-path?) cur/pen + (= drawing-tool :curve) cur/pencil drawing-tool cur/create-shape :else cur/pointer-inner) :background-color (get options :background "#E8E9EA")} @@ -579,6 +602,7 @@ (when drawing-obj [:& draw-area {:shape drawing-obj :zoom zoom + :tool drawing-tool :modifiers (:modifiers local)}]) (when (contains? layout :display-grid) @@ -606,3 +630,13 @@ (when (= options-mode :prototype) [:& interactions {:selected selected}])]])) + +(mf/defc viewport-actions [] + (let [edition (mf/deref refs/selected-edition) + selected (mf/deref refs/selected-objects) + shape (-> selected first)] + (when (and (= (count selected) 1) + (= (:id shape) edition) + (= :path (:type shape))) + [:div.viewport-actions + [:& path-actions {:shape shape}]]))) diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index 63e490834..b0569216a 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -15,8 +15,8 @@ [app.util.worker :as uw])) (defn on-error - [instance error] - (js/console.error "Error on worker" (.-data error))) + [error] + (js/console.error "Error on worker" error)) (defonce instance (when (not= *target* "nodejs") diff --git a/frontend/src/app/util/avatars.cljs b/frontend/src/app/util/avatars.cljs index 39f3908a6..04a9bd38c 100644 --- a/frontend/src/app/util/avatars.cljs +++ b/frontend/src/app/util/avatars.cljs @@ -35,3 +35,14 @@ (.fillText context letters (/ size 2) (/ size 1.5)) (.toDataURL canvas))) + +(defn assoc-avatar + [{:keys [photo] :as object} key] + (cond-> object + (or (nil? photo) (empty? photo)) + (assoc :photo (generate {:name (get object key)})))) + +(defn assoc-profile-avatar + [object] + (assoc-avatar object :fullname)) + diff --git a/frontend/src/app/util/data.cljs b/frontend/src/app/util/data.cljs index 0a6c2889c..2350262e9 100644 --- a/frontend/src/app/util/data.cljs +++ b/frontend/src/app/util/data.cljs @@ -118,6 +118,33 @@ (into {})) m1)) +(defn with-next + "Given a collectin will return a new collection where each element + is paried with the next item in the collection + (with-next (range 5)) => [[0 1] [1 2] [2 3] [3 4] [4 nil]" + [coll] + (map vector + coll + (concat [] (rest coll) [nil]))) + +(defn with-prev + "Given a collectin will return a new collection where each element + is paried with the previous item in the collection + (with-prev (range 5)) => [[0 nil] [1 0] [2 1] [3 2] [4 3]" + [coll] + (map vector + coll + (concat [nil] coll))) + +(defn with-prev-next + "Given a collection will return a new collection where every item is paired + with the previous and the next item of a collection + (with-prev-next (range 5)) => [[0 nil 1] [1 0 2] [2 1 3] [3 2 4] [4 3 nil]" + [coll] + (map vector + coll + (concat [nil] coll) + (concat [] (rest coll) [nil]))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numbers Parsing @@ -221,3 +248,7 @@ ;; nil ;; (throw e#))))))) +(defn prefix-keyword [prefix kw] + (let [prefix (if (keyword? prefix) (name prefix) prefix) + kw (if (keyword? kw) (name kw) kw)] + (keyword (str prefix kw)))) diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index c562b1b3f..b1787add2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -4,6 +4,11 @@ (def debug-options #{:bounding-boxes :group :events :rotation-handler :resize-handler :selection-center #_:simple-selection}) +;; These events are excluded when we activate the :events flag +(def debug-exclude-events + #{:app.main.data.workspace.notifications/handle-pointer-update + :app.main.data.workspace.selection/change-hover-state}) + (defonce ^:dynamic *debug* (atom #{})) (defn debug-all! [] (reset! *debug* debug-options)) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 92edd6535..1dad84d48 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -9,12 +9,11 @@ (ns app.util.dom (:require - [goog.dom :as dom] - [cuerdas.core :as str] - [beicon.core :as rx] - [cuerdas.core :as str] + [app.common.exceptions :as ex] [app.common.geom.point :as gpt] - [app.util.transit :as ts])) + [app.util.object :as obj] + [cuerdas.core :as str] + [goog.dom :as dom])) ;; --- Deprecated methods @@ -30,6 +29,7 @@ [e] (.-target e)) + (defn classnames [& params] (assert (even? (count params))) @@ -40,6 +40,7 @@ [] (partition 2 params)))) + ;; --- New methods (defn get-element-by-class @@ -76,6 +77,11 @@ [node] (.-value node)) +(defn get-attribute + "Extract the value of one attribute of a dom node." + [node attr-name] + (.getAttribute node attr-name)) + (def get-target-val (comp get-value get-target)) (defn click @@ -111,11 +117,11 @@ (defn select-text! [node] - (.select node)) + (.select ^js node)) (defn ^boolean equals? [node-a node-b] - (.isEqualNode node-a node-b)) + (.isEqualNode ^js node-a node-b)) (defn get-event-files "Extract the files from event instance." @@ -162,6 +168,12 @@ y (.-clientY event)] (gpt/point x y))) +(defn get-offset-position + [event] + (let [x (.-offsetX event) + y (.-offsetY event)] + (gpt/point x y))) + (defn get-client-size [node] {:width (.-clientWidth ^js node) @@ -188,7 +200,16 @@ (defn fullscreen? [] - (boolean (.-fullscreenElement js/document))) + (cond + (obj/in? js/document "webkitFullscreenElement") + (boolean (.-webkitFullscreenElement js/document)) + + (obj/in? js/document "fullscreenElement") + (boolean (.-fullscreenElement js/document)) + + :else + (ex/raise :type :not-supported + :hint "seems like the current browset does not support fullscreen api."))) (defn ^boolean blob? [v] @@ -219,6 +240,13 @@ (defn release-pointer [event] (-> event get-target (.releasePointerCapture (.-pointerId event)))) - + (defn get-root [] (query js/document "#app")) + +(defn ^boolean class? [node class-name] + (let [class-list (.-classList ^js node)] + (.contains ^js class-list class-name))) + +(defn get-user-agent [] + (.-userAgent js/navigator)) diff --git a/frontend/src/app/util/geom/path.cljs b/frontend/src/app/util/geom/path.cljs index 0ef690191..5357563a7 100644 --- a/frontend/src/app/util/geom/path.cljs +++ b/frontend/src/app/util/geom/path.cljs @@ -8,7 +8,13 @@ ;; Copyright (c) 2016-2017 Andrey Antukh (ns app.util.geom.path - (:require [app.util.geom.path-impl-simplify :as impl-simplify])) + (:require + [cuerdas.core :as str] + [app.common.data :as cd] + [app.util.data :as d] + [app.common.data :as cd] + [app.common.geom.point :as gpt] + [app.util.geom.path-impl-simplify :as impl-simplify])) (defn simplify ([points] @@ -16,3 +22,374 @@ ([points tolerance] (let [points (into-array points)] (into [] (impl-simplify/simplify points tolerance true))))) + +;; +(def commands-regex #"(?i)[a-z][^a-z]*") + +;; Matches numbers for path values allows values like... -.01, 10, +12.22 +;; 0 and 1 are special because can refer to flags +(def num-regex #"([+-]?(([1-9]\d*(\.\d+)?)|(\.\d+)|0|1))") + + +(defn coord-n [size] + (re-pattern (str "(?i)[a-z]\\s*" + (->> (range size) + (map #(identity num-regex)) + (str/join "\\s+"))))) + + +(defn parse-params [cmd-str num-params] + (let [fix-starting-dot (fn [arg] (str/replace arg #"([^\d]|^)\." "$10."))] + (->> (re-seq num-regex cmd-str) + (map first) + (map fix-starting-dot) + (map d/read-string) + (partition num-params)))) + +(defn command->param-list [{:keys [command params]}] + (case command + (:move-to :line-to :smooth-quadratic-bezier-curve-to) + (let [{:keys [x y]} params] [x y]) + + :close-path + [] + + (:line-to-horizontal :line-to-vertical) + (let [{:keys [value]} params] [value]) + + :curve-to + (let [{:keys [c1x c1y c2x c2y x y]} params] [c1x c1y c2x c2y x y]) + + (:smooth-curve-to :quadratic-bezier-curve-to) + (let [{:keys [cx cy x y]} params] [cx cy x y]) + + :elliptical-arc + (let [{:keys [rx ry x-axis-rotation large-arc-flag sweep-flag x y]} params] + [rx ry x-axis-rotation large-arc-flag sweep-flag x y]))) + +;; Path specification +;; https://www.w3.org/TR/SVG11/paths.html +(defmulti parse-command (comp str/upper first)) + +(defmethod parse-command "M" [cmd] + (let [relative (str/starts-with? cmd "m") + params (parse-params cmd 2)] + (for [[x y] params] + {:command :move-to + :relative relative + :params {:x x :y y}}))) + +(defmethod parse-command "Z" [cmd] + [{:command :close-path}]) + +(defmethod parse-command "L" [cmd] + (let [relative (str/starts-with? cmd "l") + params (parse-params cmd 2)] + (for [[x y] params] + {:command :line-to + :relative relative + :params {:x x :y y}}))) + +(defmethod parse-command "H" [cmd] + (let [relative (str/starts-with? cmd "h") + params (parse-params cmd 1)] + (for [[value] params] + {:command :line-to-horizontal + :relative relative + :params {:value value}}))) + +(defmethod parse-command "V" [cmd] + (let [relative (str/starts-with? cmd "v") + params (parse-params cmd 1)] + (for [[value] params] + {:command :line-to-vertical + :relative relative + :params {:value value}}))) + +(defmethod parse-command "C" [cmd] + (let [relative (str/starts-with? cmd "c") + params (parse-params cmd 6)] + (for [[c1x c1y c2x c2y x y] params] + {:command :curve-to + :relative relative + :params {:c1x c1x + :c1y c1y + :c2x c2x + :c2y c2y + :x x + :y y}}))) + +(defmethod parse-command "S" [cmd] + (let [relative (str/starts-with? cmd "s") + params (parse-params cmd 4)] + (for [[cx cy x y] params] + {:command :smooth-curve-to + :relative relative + :params {:cx cx + :cy cy + :x x + :y y}}))) + +(defmethod parse-command "Q" [cmd] + (let [relative (str/starts-with? cmd "s") + params (parse-params cmd 4)] + (for [[cx cy x y] params] + {:command :quadratic-bezier-curve-to + :relative relative + :params {:cx cx + :cy cy + :x x + :y y}}))) + +(defmethod parse-command "T" [cmd] + (let [relative (str/starts-with? cmd "t") + params (parse-params cmd (coord-n 2))] + (for [[cx cy x y] params] + {:command :smooth-quadratic-bezier-curve-to + :relative relative + :params {:x x + :y y}}))) + +(defmethod parse-command "A" [cmd] + (let [relative (str/starts-with? cmd "a") + params (parse-params cmd 7)] + (for [[rx ry x-axis-rotation large-arc-flag sweep-flag x y] params] + {:command :elliptical-arc + :relative relative + :params {:rx rx + :ry ry + :x-axis-rotation x-axis-rotation + :large-arc-flag large-arc-flag + :sweep-flag sweep-flag + :x x + :y y}}))) + +(defn command->string [{:keys [command relative params] :as entry}] + (let [command-str (case command + :move-to "M" + :close-path "Z" + :line-to "L" + :line-to-horizontal "H" + :line-to-vertical "V" + :curve-to "C" + :smooth-curve-to "S" + :quadratic-bezier-curve-to "Q" + :smooth-quadratic-bezier-curve-to "T" + :elliptical-arc "A") + command-str (if relative (str/lower command-str) command-str) + param-list (command->param-list entry)] + (str/fmt "%s%s" command-str (str/join " " param-list)))) + +(defn path->content [string] + (let [clean-string (-> string + (str/trim) + ;; Change "commas" for spaces + (str/replace #"," " ") + ;; Remove all consecutive spaces + (str/replace #"\s+" " ")) + commands (re-seq commands-regex clean-string)] + (mapcat parse-command commands))) + +(defn content->path [content] + (->> content + (map command->string) + (str/join ""))) + +(defn make-curve-params + ([point] + (make-curve-params point point point)) + + ([point handler] (make-curve-params point handler point)) + + ([point h1 h2] + {:x (:x point) + :y (:y point) + :c1x (:x h1) + :c1y (:y h1) + :c2x (:x h2) + :c2y (:y h2)})) + +(defn opposite-handler + "Calculates the coordinates of the opposite handler" + [point handler] + (let [phv (gpt/to-vec point handler)] + (gpt/add point (gpt/negate phv)))) + +(defn opposite-handler-keep-distance + "Calculates the coordinates of the opposite handler but keeping the old distance" + [point handler old-opposite] + (let [old-distance (gpt/distance point old-opposite) + phv (gpt/to-vec point handler) + phv2 (gpt/multiply + (gpt/unit (gpt/negate phv)) + (gpt/point old-distance))] + (gpt/add point phv2))) + +(defn apply-content-modifiers [content modifiers] + (letfn [(apply-to-index [content [index params]] + (if (contains? content index) + (cond-> content + (and + (or (:c1x params) (:c1y params) (:c2x params) (:c2y params)) + (= :line-to (get-in content [index :params :command]))) + (-> (assoc-in [index :command] :curve-to) + (assoc-in [index :params] :curve-to) (make-curve-params + (get-in content [index :params]) + (get-in content [(dec index) :params]))) + + (:x params) (update-in [index :params :x] + (:x params)) + (:y params) (update-in [index :params :y] + (:y params)) + + (:c1x params) (update-in [index :params :c1x] + (:c1x params)) + (:c1y params) (update-in [index :params :c1y] + (:c1y params)) + + (:c2x params) (update-in [index :params :c2x] + (:c2x params)) + (:c2y params) (update-in [index :params :c2y] + (:c2y params))) + content))] + (reduce apply-to-index content modifiers))) + +(defn command->point [command] + (when-not (nil? command) + (let [{{:keys [x y]} :params} command] + (gpt/point x y)))) + +(defn content->points [content] + (->> content + (map #(when (-> % :params :x) (gpt/point (-> % :params :x) (-> % :params :y)))) + (remove nil?) + (into []))) + +(defn get-handler [{:keys [params] :as command} prefix] + (let [cx (d/prefix-keyword prefix :x) + cy (d/prefix-keyword prefix :y)] + (when (and command + (contains? params cx) + (contains? params cy)) + (gpt/point (get params cx) + (get params cy))))) + +(defn content->handlers + "Retrieve a map where for every point will retrieve a list of + the handlers that are associated with that point. + point -> [[index, prefix]]" + [content] + (->> (d/with-prev content) + (d/enumerate) + + (mapcat (fn [[index [cur-cmd pre-cmd]]] + (if (and pre-cmd (= :curve-to (:command cur-cmd))) + (let [cur-pos (command->point cur-cmd) + pre-pos (command->point pre-cmd)] + (-> [[pre-pos [index :c1]] + [cur-pos [index :c2]]])) + []))) + + (group-by first) + (cd/mapm #(mapv second %2)))) + +(defn opposite-index + "Calculate sthe opposite index given a prefix and an index" + [content index prefix] + (let [point (if (= prefix :c2) + (command->point (nth content index)) + (command->point (nth content (dec index)))) + + handlers (-> (content->handlers content) + (get point)) + + opposite-prefix (if (= prefix :c1) :c2 :c1)] + (when (<= (count handlers) 2) + (->> handlers + (d/seek (fn [[index prefix]] (= prefix opposite-prefix))) + (first))))) + +(defn remove-line-curves + "Remove all curves that have both handlers in the same position that the + beggining and end points. This makes them really line-to commands" + [content] + (let [with-prev (d/enumerate (d/with-prev content)) + process-command + (fn [content [index [command prev]]] + + (let [cur-point (command->point command) + pre-point (command->point prev) + handler-c1 (get-handler command :c1) + handler-c2 (get-handler command :c2)] + (if (and (= :curve-to (:command command)) + (= cur-point handler-c2) + (= pre-point handler-c1)) + (assoc content index {:command :line-to + :params cur-point}) + content)))] + + (reduce process-command content with-prev))) + +(defn make-corner-point + "Changes the content to make a point a 'corner'" + [content point] + (let [handlers (-> (content->handlers content) + (get point)) + change-content + (fn [content [index prefix]] + (let [cx (d/prefix-keyword prefix :x) + cy (d/prefix-keyword prefix :y)] + (-> content + (assoc-in [index :params cx] (:x point)) + (assoc-in [index :params cy] (:y point)))))] + (as-> content $ + (reduce change-content $ handlers) + (remove-line-curves $)))) + +(defn make-curve-point + "Changes the content to make the point a 'curve'. The handlers will be positioned + in the same vector that results from te previous->next points but with fixed length." + [content point] + (let [content-next (d/enumerate (d/with-prev-next content)) + + make-curve + (fn [command previous] + (if (= :line-to (:command command)) + (let [cur-point (command->point command) + pre-point (command->point previous)] + (-> command + (assoc :command :curve-to) + (assoc :params (make-curve-params cur-point pre-point)))) + command)) + + update-handler + (fn [command prefix handler] + (if (= :curve-to (:command command)) + (let [cx (d/prefix-keyword prefix :x) + cy (d/prefix-keyword prefix :y)] + (-> command + (assoc-in [:params cx] (:x handler)) + (assoc-in [:params cy] (:y handler)))) + command)) + + calculate-vector + (fn [point next prev] + (let [base-vector (if (or (nil? next) (nil? prev) (= next prev)) + (-> (gpt/to-vec point (or next prev)) + (gpt/normal-left)) + (gpt/to-vec next prev))] + (-> base-vector + (gpt/unit) + (gpt/multiply (gpt/point 100))))) + + redfn (fn [content [index [command prev next]]] + (if (= point (command->point command)) + (let [prev-point (if (= :move-to (:command command)) nil (command->point prev)) + next-point (if (= :move-to (:command next)) nil (command->point next)) + handler-vector (calculate-vector point next-point prev-point) + handler (gpt/add point handler-vector) + handler-opposite (gpt/add point (gpt/negate handler-vector))] + (-> content + (cd/update-when index make-curve prev) + (cd/update-when index update-handler :c2 handler) + (cd/update-when (inc index) make-curve command) + (cd/update-when (inc index) update-handler :c1 handler-opposite))) + + content))] + (as-> content $ + (reduce redfn $ content-next) + (remove-line-curves $)))) diff --git a/frontend/src/app/util/geom/snap_points.cljs b/frontend/src/app/util/geom/snap_points.cljs index 8859a96c6..2f9fdb000 100644 --- a/frontend/src/app/util/geom/snap_points.cljs +++ b/frontend/src/app/util/geom/snap_points.cljs @@ -14,22 +14,22 @@ [app.common.geom.shapes :as gsh] [app.common.geom.point :as gpt])) -(defn- frame-snap-points [{:keys [x y width height] :as frame}] - (into #{(gpt/point x y) - (gpt/point (+ x (/ width 2)) y) - (gpt/point (+ x width) y) +(defn- selrect-snap-points [{:keys [x y width height]}] + #{(gpt/point x y) + (gpt/point (+ x width) y) + (gpt/point (+ x width) (+ y height)) + (gpt/point x (+ y height))}) + +(defn- frame-snap-points [{:keys [x y width height] :as selrect}] + (into (selrect-snap-points selrect) + #{(gpt/point (+ x (/ width 2)) y) (gpt/point (+ x width) (+ y (/ height 2))) - (gpt/point (+ x width) (+ y height)) (gpt/point (+ x (/ width 2)) (+ y height)) - (gpt/point x (+ y height)) (gpt/point x (+ y (/ height 2)))})) (defn shape-snap-points [shape] - (let [shape (gsh/transform-shape shape) - shape-center (gsh/center shape)] - (if (= :frame (:type shape)) - (-> shape - (gsh/shape->rect-shape) - (frame-snap-points)) - (into #{shape-center} (:points shape))))) + (let [shape (gsh/transform-shape shape)] + (case (:type shape) + :frame (-> shape :selrect frame-snap-points) + (into #{(gsh/center-shape shape)} (:points shape))))) diff --git a/frontend/src/app/util/object.cljs b/frontend/src/app/util/object.cljs index a867cc646..25d9b254f 100644 --- a/frontend/src/app/util/object.cljs +++ b/frontend/src/app/util/object.cljs @@ -76,3 +76,7 @@ (defn clj->props [props] (clj->js props :keyword-fn props-key-fn)) + +(defn ^boolean in? + [obj prop] + (js* "~{} in ~{}" prop obj)) diff --git a/frontend/src/app/util/text.cljs b/frontend/src/app/util/text.cljs index 4e1ad7136..c00547c9f 100644 --- a/frontend/src/app/util/text.cljs +++ b/frontend/src/app/util/text.cljs @@ -16,7 +16,7 @@ :text-transform "none" :text-align "left" :text-decoration "none" - :fill-color "#000000" + :fill-color nil :fill-opacity 1}) (def typography-fields diff --git a/frontend/src/app/util/timers.cljs b/frontend/src/app/util/timers.cljs index ef6f39e29..a7afaff2b 100644 --- a/frontend/src/app/util/timers.cljs +++ b/frontend/src/app/util/timers.cljs @@ -21,6 +21,10 @@ (-dispose [_] (js/clearTimeout sem)))))) +(defn dispose! + [v] + (rx/dispose! v)) + (defn asap [f] (-> (p/resolved nil) @@ -33,16 +37,28 @@ (-dispose [_] (js/clearInterval sem))))) +(if (and (exists? js/window) (.-requestIdleCallback js/window)) + (do + (def ^:private request-idle-callback #(js/requestIdleCallback %)) + (def ^:private cancel-idle-callback #(js/cancelIdleCallback %))) + (do + (def ^:private request-idle-callback #(js/setTimeout % 100)) + (def ^:private cancel-idle-callback #(js/cancelTimeout %)))) + (defn schedule-on-idle [func] - (let [sem (js/requestIdleCallback #(func))] + (let [sem (request-idle-callback #(func))] (reify rx/IDisposable (-dispose [_] - (js/cancelIdleCallback sem))))) + (cancel-idle-callback sem))))) + +(def ^:private request-animation-frame + (or (and (exists? js/window) (.-requestAnimationFrame js/window)) + #(js/setTimeout % 16))) (defn raf [f] - (js/window.requestAnimationFrame f)) + (request-animation-frame f)) (defn idle-then-raf [f] diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index f5b3eb75f..a08e9889f 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -10,6 +10,8 @@ (ns app.util.webapi "HTML5 web api helpers." (:require + [app.common.exceptions :as ex] + [app.util.object :as obj] [promesa.core :as p] [beicon.core :as rx] [cuerdas.core :as str] @@ -77,12 +79,15 @@ (let [cboard (unchecked-get js/navigator "clipboard")] (.writeText ^js cboard data))) -(defn- read-from-clipboard +(defn read-from-clipboard [] (let [cboard (unchecked-get js/navigator "clipboard")] - (rx/from (.readText ^js cboard)))) + (if (.-readText ^js cboard) + (rx/from (.readText ^js cboard)) + (throw (ex-info "This browser does not implement read from clipboard protocol" + {:not-implemented true}))))) -(defn- read-image-from-clipboard +(defn read-image-from-clipboard [] (let [cboard (unchecked-get js/navigator "clipboard") read-item (fn [item] @@ -95,10 +100,50 @@ (rx/mapcat identity) ;; Convert each item into an emission (rx/switch-map read-item)))) +(defn read-from-paste-event + [event] + (let [target (.-target ^js event)] + (when (and (not (.-isContentEditable target)) ;; ignore when pasting into + (not= (.-tagName target) "INPUT")) ;; an editable control + (-> ^js event + (.getBrowserEvent) + (.-clipboardData))))) + +(defn extract-text + [clipboard-data] + (when clipboard-data + (.getData clipboard-data "text"))) + +(defn extract-images + [clipboard-data] + (when clipboard-data + (let [file-list (-> (.-files ^js clipboard-data))] + (->> (range (.-length file-list)) + (map #(.item file-list %)) + (filter #(str/starts-with? (.-type %) "image/")))))) + (defn request-fullscreen [el] - (.requestFullscreen el)) + (cond + (obj/in? el "requestFullscreen") + (.requestFullscreen el) + + (obj/in? el "webkitRequestFullscreen") + (.webkitRequestFullscreen el) + + :else + (ex/raise :type :not-supported + :hint "seems like the current browset does not support fullscreen api."))) (defn exit-fullscreen [] - (.exitFullscreen js/document)) + (cond + (obj/in? js/document "exitFullscreen") + (.exitFullscreen js/document) + + (obj/in? js/document "webkitExitFullscreen") + (.webkitExitFullscreen js/document) + + :else + (ex/raise :type :not-supported + :hint "seems like the current browset does not support fullscreen api."))) diff --git a/frontend/src/app/util/worker.cljs b/frontend/src/app/util/worker.cljs index 3458d1898..e3f944011 100644 --- a/frontend/src/app/util/worker.cljs +++ b/frontend/src/app/util/worker.cljs @@ -38,10 +38,12 @@ (fn [event] (let [data (.-data event) data (t/decode data)] - (rx/push! bus data)))) + (if (:error data) + (on-error (:error data)) + (rx/push! bus data))))) (.addEventListener ins "error" (fn [error] - (on-error wrk error))) + (on-error wrk (.-data error)))) wrk)) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 952d5e184..d26963dfd 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -62,6 +62,7 @@ {:reply-to sender-id :payload result})))) (catch :default e + (.error js/console "error" e) (let [message {:reply-to sender-id :error {:data (ex-data e) :message (ex-message e)}}] diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index 2ff9b8ce5..0372abf46 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -15,7 +15,6 @@ [app.common.exceptions :as ex] [app.common.geom.shapes :as geom] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.spec :as us] [app.common.uuid :as uuid] [app.util.quadtree :as qdt] @@ -65,8 +64,7 @@ (defn- create-index [objects] - (let [shapes (->> (cph/select-toplevel-shapes objects {:include-frames? true}) - (map #(merge % (select-keys % [:x :y :width :height])))) + (let [shapes (cp/select-toplevel-shapes objects {:include-frames? true}) bounds (geom/selection-rect shapes) bounds #js {:x (:x bounds) :y (:y bounds) @@ -77,7 +75,8 @@ shapes))) (defn- index-object - [index {:keys [id x y width height] :as obj}] - (let [rect #js {:x x :y y :width width :height height}] + [index obj] + (let [{:keys [id x y width height]} (:selrect obj) + rect #js {:x x :y y :width width :height height}] (qdt/insert index rect obj))) diff --git a/frontend/src/app/worker/snaps.cljs b/frontend/src/app/worker/snaps.cljs index 4f95a9d40..ecd5ba34d 100644 --- a/frontend/src/app/worker/snaps.cljs +++ b/frontend/src/app/worker/snaps.cljs @@ -12,7 +12,6 @@ [okulary.core :as l] [app.common.uuid :as uuid] [app.common.pages :as cp] - [app.common.pages-helpers :as cph] [app.common.data :as d] [app.worker.impl :as impl] [app.util.range-tree :as rt] @@ -46,7 +45,7 @@ (let [frame-shapes (->> (vals objects) (filter :frame-id) (group-by :frame-id)) - frame-shapes (->> (cph/select-frames objects) + frame-shapes (->> (cp/select-frames objects) (reduce #(update %1 (:id %2) conj %2) frame-shapes))] (d/mapm (fn [frame-id shapes] {:x (create-coord-data frame-id shapes :x) diff --git a/frontend/tests/app/test_util_geom.cljs b/frontend/tests/app/test_util_geom.cljs deleted file mode 100644 index 3fdf0dc5e..000000000 --- a/frontend/tests/app/test_util_geom.cljs +++ /dev/null @@ -1,83 +0,0 @@ -(ns app.test-util-geom - (:require [cljs.test :as t :include-macros true] - [cljs.pprint :refer [pprint]] - [app.common.geom.point :as gpt] - [app.common.geom.matrix :as gmt])) - -(t/deftest point-constructors-test - (let [p (gpt/point 1 2)] - (t/is (= (:x p) 1)) - (t/is (= (:y p) 2))) - - (let [p (gpt/point 1)] - (t/is (= (:x p) 1)) - (t/is (= (:y p) 1))) - - (let [p (gpt/point)] - (t/is (= (:x p) 0)) - (t/is (= (:y p) 0)))) - -;; (t/deftest point-rotate-test -;; (let [p1 (gpt/point 10 0) -;; p2 (gpt/rotate p1 90)] -;; (t/is (= (:x p2) 0)) -;; (t/is (= (:y p2) 10)))) - -(t/deftest point-add-test - (let [p1 (gpt/point 1 1) - p2 (gpt/point 2 2) - p3 (gpt/add p1 p2)] - (t/is (= (:x p3) 3)) - (t/is (= (:y p3) 3)))) - -(t/deftest point-subtract-test - (let [p1 (gpt/point 3 3) - p2 (gpt/point 2 2) - p3 (gpt/subtract p1 p2)] - (t/is (= (:x p3) 1)) - (t/is (= (:y p3) 1)))) - -(t/deftest point-distance-test - (let [p1 (gpt/point 0 0) - p2 (gpt/point 10 0) - d (gpt/distance p1 p2)] - (t/is (number? d)) - (t/is (= d 10)))) - -(t/deftest point-length-test - (let [p1 (gpt/point 10 0) - ln (gpt/length p1)] - (t/is (number? ln)) - (t/is (= ln 10)))) - -(t/deftest point-angle-test - (let [p1 (gpt/point 0 10) - angle (gpt/angle p1)] - (t/is (number? angle)) - (t/is (= angle 90))) - (let [p1 (gpt/point 0 10) - p2 (gpt/point 10 10) - angle (gpt/angle-with-other p1 p2)] - (t/is (number? angle)) - (t/is (= angle 45)))) - -(t/deftest matrix-constructors-test - (let [m (gmt/matrix)] - (t/is (= (str m) "matrix(1,0,0,1,0,0)"))) - (let [m (gmt/matrix 1 1 1 2 2 2)] - (t/is (= (str m) "matrix(1,1,1,2,2,2)")))) - -(t/deftest matrix-translate-test - (let [m (-> (gmt/matrix) - (gmt/translate (gpt/point 2 10)))] - (t/is (= (str m) "matrix(1,0,0,1,2,10)")))) - -(t/deftest matrix-scale-test - (let [m (-> (gmt/matrix) - (gmt/scale (gpt/point 2)))] - (t/is (= (str m) "matrix(2,0,0,2,0,0)")))) - -(t/deftest matrix-rotate-test - (let [m (-> (gmt/matrix) - (gmt/rotate 10))] - (t/is (= (str m) "matrix(0.984807753012208,0.17364817766693033,-0.17364817766693033,0.984807753012208,0,0)")))) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6be49c138..fd6f4a6d5 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@babel/runtime-corejs3@^7.8.3": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.1.tgz#51b9092befbeeed938335a109dbe0df51451e9dc" - integrity sha512-umhPIcMrlBZ2aTWlWjUseW9LjQKxi1dpFlQS8DzsxB//5K+u6GLTC/JliPKHsd5kJVPIU6X/Hy0YvWOYPcMxBw== +"@babel/runtime-corejs3@^7.12.1": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" + integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== dependencies: core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" @@ -19,6 +19,25 @@ enabled "2.0.x" kuler "^2.0.0" +"@gulp-sourcemaps/identity-map@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz#a6e8b1abec8f790ec6be2b8c500e6e68037c0019" + integrity sha512-Tb+nSISZku+eQ4X1lAkevcQa+jknn/OVUgZ3XCxEKIsLsqYuPoJwJOPQeaOk75X3WPftb29GWY1eqE7GLsXb1Q== + dependencies: + acorn "^6.4.1" + normalize-path "^3.0.0" + postcss "^7.0.16" + source-map "^0.6.0" + through2 "^3.0.1" + +"@gulp-sourcemaps/map-sources@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz#890ae7c5d8c877f6d384860215ace9d7ec945bda" + integrity sha1-iQrnxdjId/bThIYCFazp1+yUW9o= + dependencies: + normalize-path "^2.0.1" + through2 "^2.0.3" + "@types/esrever@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@types/esrever/-/esrever-0.2.0.tgz#96404a2284b2c7527f08a1e957f8a31705f9880f" @@ -44,6 +63,11 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +acorn@^6.4.1: + version "6.4.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" + integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== + ajv@^6.12.3: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -54,6 +78,11 @@ ajv@^6.12.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= + ansi-colors@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" @@ -78,7 +107,17 @@ ansi-regex@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= -ansi-styles@^3.2.1: +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -118,11 +157,24 @@ append-buffer@^1.0.2: dependencies: buffer-equal "^1.0.0" +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + archy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -164,6 +216,11 @@ array-each@^1.0.0, array-each@^1.0.1: resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" integrity sha1-p5SvDAWrF1KEbudTofIRoFugxE8= +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" + integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= + array-initial@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/array-initial/-/array-initial-1.1.0.tgz#2fa74b26739371c3947bd7a7adc73be334b3d795" @@ -248,6 +305,11 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== +async-foreach@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" + integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" @@ -283,12 +345,12 @@ atob@^2.1.2: integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== autoprefixer@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.0.1.tgz#e2d9000f84ebd98d77b7bc16f8adb2ff1f7bb946" - integrity sha512-aQo2BDIsoOdemXUAOBpFv4ZQa2DrOtEufarYhtFsK1088Ca0TUwu/aQWf0M3mrILXZ3mTIVn1lR3hPW8acacsw== + version "10.0.2" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.0.2.tgz#a79f9a02bfb95c621998776ac0d85f8f855b367e" + integrity sha512-okBmu9OMdt6DNEcZmnl0IYVv8Xl/xYWRSnc2OJ9UJEOt1u30opG1B8aLsViqKryBaYv1SKB4f85fOGZs5zYxHQ== dependencies: - browserslist "^4.14.5" - caniuse-lite "^1.0.30001137" + browserslist "^4.14.7" + caniuse-lite "^1.0.30001157" colorette "^1.2.1" normalize-range "^0.1.2" num2fraction "^1.2.2" @@ -371,6 +433,13 @@ bintrees@1.0.1: resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= +block-stream@*: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= + dependencies: + inherits "~2.0.0" + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" @@ -488,15 +557,16 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.14.5: - version "4.14.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.5.tgz#1c751461a102ddc60e40993639b709be7f2c4015" - integrity sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA== +browserslist@^4.14.7: + version "4.14.7" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.7.tgz#c071c1b3622c1c2e790799a37bb09473a4351cb6" + integrity sha512-BSVRLCeG3Xt/j/1cCGj1019Wbty0H+Yvu2AOuZSuoaUWn3RatbL33Cxk+Q4jRMRAbOm0p7SLravLjpnT6s0vzQ== dependencies: - caniuse-lite "^1.0.30001135" - electron-to-chromium "^1.3.571" - escalade "^3.1.0" - node-releases "^1.1.61" + caniuse-lite "^1.0.30001157" + colorette "^1.2.1" + electron-to-chromium "^1.3.591" + escalade "^3.1.1" + node-releases "^1.1.66" buffer-crc32@~0.2.3: version "0.2.13" @@ -552,6 +622,38 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +camelcase-keys@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" + integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc= + dependencies: + camelcase "^2.0.0" + map-obj "^1.0.0" + +camelcase@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" + integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= + camelcase@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" @@ -562,17 +664,28 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001137: - version "1.0.30001151" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001151.tgz#1ddfde5e6fff02aad7940b4edb7d3ac76b0cb00b" - integrity sha512-Zh3sHqskX6mHNrqUerh+fkf0N72cMxrmflzje/JyVImfpknscMnkeJrlFGJcqTmaa0iszdYptGpWMJCRQDkBVw== +caniuse-lite@^1.0.30001157: + version "1.0.30001157" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001157.tgz#2d11aaeb239b340bc1aa730eca18a37fdb07a9ab" + integrity sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA== caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^2.4.1: +chalk@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -633,7 +746,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -clean-css@^4.2.3: +clean-css@^4.x: version "4.2.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== @@ -658,6 +771,15 @@ cliui@^4.0.0: strip-ansi "^4.0.0" wrap-ansi "^2.0.0" +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" @@ -811,6 +933,13 @@ concat-stream@^1.6.0, concat-stream@^1.6.2: readable-stream "^2.2.2" typedarray "^0.0.6" +concat-with-sourcemaps@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e" + integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg== + dependencies: + source-map "^0.6.1" + config-chain@^1.1.12: version "1.1.12" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" @@ -824,12 +953,17 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + constants-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= -convert-source-map@^1.5.0: +convert-source-map@^1.0.0, convert-source-map@^1.5.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== @@ -850,15 +984,25 @@ copy-props@^2.0.1: is-plain-object "^2.0.1" core-js-pure@^3.0.0: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" - integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== + version "3.7.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.7.0.tgz#28a57c861d5698e053f0ff36905f7a3301b4191e" + integrity sha512-EZD2ckZysv8MMt4J6HSvS9K2GdtlZtdBncKAmF9lr2n0c9dJUaUN88PSTjvgwCgQPWKTkERXITgS6JJRAnljtg== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cosmiconfig@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" + integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.13.1" + parse-json "^4.0.0" + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -890,6 +1034,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +cross-spawn@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" + integrity sha1-ElYDfsufDF9549bvE14wdwGEuYI= + dependencies: + lru-cache "^4.0.1" + which "^1.2.9" + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -959,6 +1111,15 @@ css-what@^3.2.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== +css@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + dependencies: + inherits "^2.0.4" + source-map "^0.6.1" + source-map-resolve "^0.6.0" + cssmin@^0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/cssmin/-/cssmin-0.4.3.tgz#c9194077e0ebdacd691d5f59015b9d819f38d015" @@ -976,6 +1137,13 @@ cssom@^0.3.4: resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" + integrity sha1-mI3zP+qxke95mmE2nddsF635V+o= + dependencies: + array-find-index "^1.0.1" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -1001,6 +1169,15 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +debug-fabulous@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/debug-fabulous/-/debug-fabulous-1.1.0.tgz#af8a08632465224ef4174a9f06308c3c2a1ebc8e" + integrity sha512-GZqvGIgKNlUnHUPQhepnUZFIMoi3dgZKQBzKDeL2g7oJF9SNAji/AAu36dusFUas0O+pae74lNeoIPHqXWDkLg== + dependencies: + debug "3.X" + memoizee "0.4.X" + object-assign "4.X" + debug@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" @@ -1008,6 +1185,13 @@ debug@3.1.0: dependencies: ms "2.0.0" +debug@3.X: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -1015,7 +1199,7 @@ debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: dependencies: ms "2.0.0" -decamelize@^1.1.1, decamelize@^1.2.0: +decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -1071,6 +1255,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + des.js@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" @@ -1084,6 +1273,11 @@ detect-file@^1.0.0: resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= +detect-newline@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + diff@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -1144,16 +1338,6 @@ duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" -duplexify@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.1.tgz#7027dc374f157b122a8ae08c2d3ea4d2d953aa61" - integrity sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA== - dependencies: - end-of-stream "^1.4.1" - inherits "^2.0.3" - readable-stream "^3.1.1" - stream-shift "^1.0.0" - each-props@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/each-props/-/each-props-1.3.2.tgz#ea45a414d16dd5cfa419b1a81720d5ca06892333" @@ -1180,10 +1364,10 @@ editorconfig@^0.15.3: semver "^5.6.0" sigmund "^1.0.1" -electron-to-chromium@^1.3.571: - version "1.3.583" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.583.tgz#47a9fde74740b1205dba96db2e433132964ba3ee" - integrity sha512-L9BwLwJohjZW9mQESI79HRzhicPk1DFgM+8hOCfGgGCFEcA3Otpv7QK6SGtYoZvfQfE3wKLh0Hd5ptqUFv3gvQ== +electron-to-chromium@^1.3.591: + version "1.3.593" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.593.tgz#947ccf6dc8e013e2b053d2463ecd1043c164fcef" + integrity sha512-GvO7G1ZxvffnMvPCr4A7+iQPVuvpyqMrx2VWSERAjG+pHK6tmO9XqYdBfMIq9corRyi4bNImSDEiDvIoDb8HrA== elliptic@^6.5.3: version "6.5.3" @@ -1198,12 +1382,17 @@ elliptic@^6.5.3: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + enabled@2.0.x: version "2.0.0" resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== -end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -1215,7 +1404,7 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== -error-ex@^1.2.0: +error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== @@ -1266,7 +1455,7 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50: +es5-ext@^0.10.35, es5-ext@^0.10.45, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: version "0.10.53" resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== @@ -1297,7 +1486,7 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3: d "^1.0.1" ext "^1.1.2" -es6-weak-map@^2.0.1: +es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== @@ -1307,12 +1496,12 @@ es6-weak-map@^2.0.1: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -escalade@^3.1.0: +escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: +escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= @@ -1332,6 +1521,14 @@ esrever@^0.2.0: resolved "https://registry.yarnpkg.com/esrever/-/esrever-0.2.0.tgz#96e9d28f4f1b1a76784cd5d490eaae010e7407b8" integrity sha1-lunSj08bGnZ4TNXUkOquAQ50B7g= +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + events@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" @@ -1439,7 +1636,7 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -fancy-log@^1.3.2: +fancy-log@^1.3.2, fancy-log@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" integrity sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw== @@ -1584,11 +1781,6 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -fork-stream@^0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70" - integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA= - form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -1640,16 +1832,57 @@ fsevents@~2.1.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== +fstream@^1.0.0, fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +gaze@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" + integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== + dependencies: + globule "^1.0.0" + get-caller-file@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-stdin@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" + integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4= + get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -1725,7 +1958,7 @@ glob@7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.1, glob@^7.1.3: +glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@~7.1.1: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -1757,6 +1990,15 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" +globule@^1.0.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.2.tgz#d8bdd9e9e4eef8f96e245999a5dee7eb5d8529c4" + integrity sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA== + dependencies: + glob "~7.1.1" + lodash "~4.17.10" + minimatch "~3.0.2" + glogg@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.2.tgz#2d7dd702beda22eb3bffadf880696da6d846313f" @@ -1798,6 +2040,15 @@ gulp-cli@^2.2.0: v8flags "^3.2.0" yargs "^7.1.0" +gulp-concat@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353" + integrity sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M= + dependencies: + concat-with-sourcemaps "^1.0.0" + through2 "^2.0.0" + vinyl "^2.0.0" + gulp-gzip@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/gulp-gzip/-/gulp-gzip-1.4.2.tgz#0422a94014248655b5b1a9eea1c2abee1d4f4337" @@ -1810,22 +2061,6 @@ gulp-gzip@^1.4.2: stream-to-array "^2.3.0" through2 "^2.0.3" -gulp-if@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-3.0.0.tgz#6c3e7edc8bafadc34f2ebecb314bf43324ba1e40" - integrity sha512-fCUEngzNiEZEK2YuPm+sdMpO6ukb8+/qzbGfJBXyNOXz85bCG7yBI+pPSl+N90d7gnLvMsarthsAImx0qy7BAw== - dependencies: - gulp-match "^1.1.0" - ternary-stream "^3.0.0" - through2 "^3.0.1" - -gulp-match@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f" - integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ== - dependencies: - minimatch "^3.0.3" - gulp-mustache@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/gulp-mustache/-/gulp-mustache-5.0.0.tgz#5ebc8bbb36a0e657391b341f11325579d4502b07" @@ -1837,11 +2072,52 @@ gulp-mustache@^5.0.0: replace-ext "^1.0.0" through2 "^3.0.1" +gulp-postcss@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/gulp-postcss/-/gulp-postcss-9.0.0.tgz#2ade18809ab475dae743a88bd6501af0b04ee54e" + integrity sha512-5mSQ9CK8salSagrXgrVyILfEMy6I5rUGPRiR9rVjgJV9m/rwdZYUhekMr+XxDlApfc5ZdEJ8gXNZrU/TsgT5dQ== + dependencies: + fancy-log "^1.3.3" + plugin-error "^1.0.1" + postcss-load-config "^2.1.1" + vinyl-sourcemaps-apply "^0.2.1" + gulp-rename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-2.0.0.tgz#9bbc3962b0c0f52fc67cd5eaff6c223ec5b9cf6c" integrity sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ== +gulp-sass@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/gulp-sass/-/gulp-sass-4.1.0.tgz#486d7443c32d42bf31a6b1573ebbdaa361de7427" + integrity sha512-xIiwp9nkBLcJDpmYHbEHdoWZv+j+WtYaKD6Zil/67F3nrAaZtWYN5mDwerdo7EvcdBenSAj7Xb2hx2DqURLGdA== + dependencies: + chalk "^2.3.0" + lodash "^4.17.11" + node-sass "^4.8.3" + plugin-error "^1.0.1" + replace-ext "^1.0.0" + strip-ansi "^4.0.0" + through2 "^2.0.0" + vinyl-sourcemaps-apply "^0.2.0" + +gulp-sourcemaps@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-3.0.0.tgz#2e154e1a2efed033c0e48013969e6f30337b2743" + integrity sha512-RqvUckJkuYqy4VaIH60RMal4ZtG0IbQ6PXMNkNsshEGJ9cldUPRb/YCgboYae+CLAs1HQNb4ADTKCx65HInquQ== + dependencies: + "@gulp-sourcemaps/identity-map" "^2.0.1" + "@gulp-sourcemaps/map-sources" "^1.0.0" + acorn "^6.4.1" + convert-source-map "^1.0.0" + css "^3.0.0" + debug-fabulous "^1.0.0" + detect-newline "^2.0.0" + graceful-fs "^4.0.0" + source-map "^0.6.0" + strip-bom-string "^1.0.0" + through2 "^2.0.0" + gulp-svg-sprite@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/gulp-svg-sprite/-/gulp-svg-sprite-1.5.0.tgz#292694c6af8570093f62cba09092ec8e5241d322" @@ -1881,6 +2157,13 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -1891,6 +2174,11 @@ has-symbols@^1.0.1: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -1959,10 +2247,10 @@ he@1.1.1: resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= -highlight.js@^10.3.1: - version "10.3.1" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.3.1.tgz#3ca6bf007377faae347e8135ff25900aac734b9a" - integrity sha512-jeW8rdPdhshYKObedYg5XGbpVgb1/DT4AHvDFXhkU7UnGSIjy9kkJ7zHG7qplhFHMitTSzh5/iClKQk3Kb2RFQ== +highlight.js@^10.4.1: + version "10.4.1" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" + integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== hmac-drbg@^1.0.0: version "1.0.1" @@ -2009,6 +2297,40 @@ immer@^5.0.0: resolved "https://registry.yarnpkg.com/immer/-/immer-5.3.6.tgz#51eab8cbbeb13075fe2244250f221598818cac04" integrity sha512-pqWQ6ozVfNOUDjrLfm4Pt7q4Q12cGw2HUZgry4Q5+Myxu9nmHRkWBpI0J4+MK0AxbdFtdMTwEGVl7Vd+vEiK+A== +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + dependencies: + import-from "^2.1.0" + +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + integrity sha1-M1238qev/VOqpHHUuAId7ja387E= + dependencies: + resolve-from "^3.0.0" + +in-publish@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.1.tgz#948b1a535c8030561cea522f73f78f4be357e00c" + integrity sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ== + +indent-string@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" + integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= + dependencies: + repeating "^2.0.0" + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -2017,7 +2339,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2152,6 +2474,11 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" @@ -2169,6 +2496,11 @@ is-extglob@^2.1.0, is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= +is-finite@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" + integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== + is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -2239,6 +2571,11 @@ is-plain-object@^3.0.0: resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b" integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g== +is-promise@^2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" @@ -2324,6 +2661,11 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= +js-base64@^2.1.8: + version "2.6.4" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4" + integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ== + js-beautify@^1.13.0: version "1.13.0" resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.13.0.tgz#a056d5d3acfd4918549aae3ab039f9f3c51eebb2" @@ -2353,6 +2695,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -2618,7 +2965,7 @@ lodash.pluck@^3.1.2: lodash.isarray "^3.0.0" lodash.map "^3.0.0" -lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.4: +lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@~4.17.10: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -2641,7 +2988,15 @@ loose-envify@^1.1.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lru-cache@^4.1.5: +loud-rejection@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" + integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8= + dependencies: + currently-unhandled "^0.4.1" + signal-exit "^3.0.0" + +lru-cache@^4.0.1, lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== @@ -2649,6 +3004,13 @@ lru-cache@^4.1.5: pseudomap "^1.0.2" yallist "^2.1.2" +lru-queue@0.1: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + dependencies: + es5-ext "~0.10.2" + make-iterator@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6" @@ -2668,6 +3030,11 @@ map-cache@^0.2.0, map-cache@^0.2.2: resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= +map-obj@^1.0.0, map-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" + integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= + map-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" @@ -2718,10 +3085,35 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +memoizee@0.4.X: + version "0.4.14" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" + integrity sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg== + dependencies: + d "1" + es5-ext "^0.10.45" + es6-weak-map "^2.0.2" + event-emitter "^0.3.5" + is-promise "^2.1" + lru-queue "0.1" + next-tick "1" + timers-ext "^0.1.5" + +meow@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" + integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= + dependencies: + camelcase-keys "^2.0.0" + decamelize "^1.1.2" + loud-rejection "^1.0.0" + map-obj "^1.0.1" + minimist "^1.1.3" + normalize-package-data "^2.3.4" + object-assign "^4.0.1" + read-pkg-up "^1.0.1" + redent "^1.0.0" + trim-newlines "^1.0.0" micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" @@ -2777,7 +3169,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@3.0.4, minimatch@^3.0.3, minimatch@^3.0.4: +minimatch@3.0.4, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -2789,7 +3181,7 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@^1.2.5: +minimist@^1.1.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -2809,7 +3201,7 @@ mkdirp@0.5.1: dependencies: minimist "0.0.8" -mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@~0.5.1: +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -2868,12 +3260,12 @@ mute-stdout@^1.0.0: resolved "https://registry.yarnpkg.com/mute-stdout/-/mute-stdout-1.0.1.tgz#acb0300eb4de23a7ddeec014e3e96044b3472331" integrity sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg== -nan@^2.12.1: +nan@^2.12.1, nan@^2.13.2: version "2.14.2" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== -nanoid@^3.1.15: +nanoid@^3.1.16: version "3.1.16" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.16.tgz#b21f0a7d031196faf75314d7c65d36352beeef64" integrity sha512-+AK8MN0WHji40lj8AEuwLOvLSbWYApQpre/aFJZD71r43wVRLrOYS4FmJOPQYon1TqB462RzrrxlfA74XRES8w== @@ -2895,6 +3287,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +next-tick@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + next-tick@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -2905,6 +3302,24 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-gyp@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" + integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== + dependencies: + fstream "^1.0.0" + glob "^7.0.3" + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3 || 4" + osenv "0" + request "^2.87.0" + rimraf "2" + semver "~5.3.0" + tar "^2.0.0" + which "1" + node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" @@ -2934,10 +3349,40 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-releases@^1.1.61: - version "1.1.64" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.64.tgz#71b4ae988e9b1dd7c1ffce58dd9e561752dfebc5" - integrity sha512-Iec8O9166/x2HRMJyLLLWkd0sFFLrFNy+Xf+JQfSQsdBJzPcHpNl3JQ9gD4j+aJxmCa25jNsIbM4bmACtSbkSg== +node-releases@^1.1.66: + version "1.1.66" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.66.tgz#609bd0dc069381015cd982300bae51ab4f1b1814" + integrity sha512-JHEQ1iWPGK+38VLB2H9ef2otU4l8s3yAMt9Xf934r6+ojCYDMHPMqvCc9TnzfeFSP1QEOeU6YZEd3+De0LTCgg== + +node-sass@^4.8.3: + version "4.14.1" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.14.1.tgz#99c87ec2efb7047ed638fb4c9db7f3a42e2217b5" + integrity sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g== + dependencies: + async-foreach "^0.1.3" + chalk "^1.1.1" + cross-spawn "^3.0.0" + gaze "^1.0.0" + get-stdin "^4.0.1" + glob "^7.0.3" + in-publish "^2.0.0" + lodash "^4.17.15" + meow "^3.7.0" + mkdirp "^0.5.1" + nan "^2.13.2" + node-gyp "^3.8.0" + npmlog "^4.0.0" + request "^2.88.0" + sass-graph "2.2.5" + stdout-stream "^1.4.0" + "true-case-path" "^1.0.2" + +"nopt@2 || 3": + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= + dependencies: + abbrev "1" nopt@^5.0.0: version "5.0.0" @@ -2946,7 +3391,7 @@ nopt@^5.0.0: dependencies: abbrev "1" -normalize-package-data@^2.3.2: +normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== @@ -2956,7 +3401,7 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-path@^2.1.1: +normalize-path@^2.0.1, normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= @@ -2987,6 +3432,16 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + nth-check@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" @@ -3009,7 +3464,7 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -object-assign@^4.1.1: +object-assign@4.X, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -3127,6 +3582,11 @@ os-browserify@^0.3.0: resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + os-locale@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" @@ -3143,6 +3603,19 @@ os-locale@^3.0.0: lcid "^2.0.0" mem "^4.0.0" +os-tmpdir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@0: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + p-defer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" @@ -3209,6 +3682,14 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + parse-node-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -3355,19 +3836,53 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +postcss-clean@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-clean/-/postcss-clean-1.1.0.tgz#c2d61d5d8caf19a585adba16897726c2674c4207" + integrity sha512-83g3GqMbCM5NL6MlbbPLJ/m2NrUepBF44MoDk4Gt04QGXeXKh9+ilQa0DzLnYnvqYHQCw83nckuEzBFr2muwbg== + dependencies: + clean-css "^4.x" + postcss "^6.x" + +postcss-load-config@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.2.tgz#c5ea504f2c4aef33c7359a34de3573772ad7502a" + integrity sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw== + dependencies: + cosmiconfig "^5.0.0" + import-cwd "^2.0.0" + postcss-value-parser@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -postcss@^8.1.2: - version "8.1.4" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.1.4.tgz#356dfef367a70f3d04347f74560c85846e20e4c1" - integrity sha512-LfqcwgMq9LOd8pX7K2+r2HPitlIGC5p6PoZhVELlqhh2YGDVcXKpkCseqan73Hrdik6nBd2OvoDPUaP/oMj9hQ== +postcss@^6.x: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postcss@^7.0.16: + version "7.0.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +postcss@^8.1.7: + version "8.1.7" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.1.7.tgz#ff6a82691bd861f3354fd9b17b2332f88171233f" + integrity sha512-llCQW1Pz4MOPwbZLmOddGM9eIJ8Bh7SZ2Oj5sxZva77uVaotYDsYTch1WBTNu7fUY0fpWp0fdt7uW40D4sRiiQ== dependencies: colorette "^1.2.1" line-column "^1.0.2" - nanoid "^3.1.15" + nanoid "^3.1.16" source-map "^0.6.1" pretty-hrtime@^1.0.0: @@ -3536,7 +4051,7 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" -"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +"readable-stream@2 || 3", readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -3545,7 +4060,7 @@ read-pkg@^1.0.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -3586,6 +4101,14 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redent@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" + integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94= + dependencies: + indent-string "^2.1.0" + strip-indent "^1.0.1" + regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" @@ -3631,6 +4154,13 @@ repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= + dependencies: + is-finite "^1.0.0" + replace-ext@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a" @@ -3652,7 +4182,7 @@ request-progress@^2.0.1: dependencies: throttleit "^1.0.0" -request@^2.81.0: +request@^2.81.0, request@^2.87.0, request@^2.88.0: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -3688,6 +4218,11 @@ require-main-filename@^1.0.1: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" @@ -3696,6 +4231,11 @@ resolve-dir@^1.0.0, resolve-dir@^1.0.1: expand-tilde "^2.0.0" global-modules "^1.0.0" +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + resolve-options@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/resolve-options/-/resolve-options-1.1.0.tgz#32bb9e39c06d67338dc9378c0d6d6074566ad131" @@ -3721,6 +4261,13 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +rimraf@2: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + rimraf@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -3765,10 +4312,20 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sass-graph@2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.5.tgz#a981c87446b8319d96dce0671e487879bd24c2e8" + integrity sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag== + dependencies: + glob "^7.0.0" + lodash "^4.0.0" + scss-tokenizer "^0.2.3" + yargs "^13.3.2" + sass@^1.26.10: - version "1.27.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.27.0.tgz#0657ff674206b95ec20dc638a93e179c78f6ada2" - integrity sha512-0gcrER56OkzotK/GGwgg4fPrKuiFlPNitO7eUJ18Bs+/NBlofJfMxmxqpqJxjae9vu0Wq8TZzrSyxZal00WDig== + version "1.29.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.29.0.tgz#ec4e1842c146d8ea9258c28c141b8c2b7c6ab7f1" + integrity sha512-ZpwAUFgnvAUCdkjwPREny+17BpUj8nh5Yr6zKPGtLNTLrmtoRYIjm7njP24COhjJldjwW1dcv52Lpf4tNZVVRA== dependencies: chokidar ">=2.0.0 <4.0.0" @@ -3792,6 +4349,14 @@ scroll-into-view-if-needed@^2.2.20: dependencies: compute-scroll-into-view "^1.0.16" +scss-tokenizer@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" + integrity sha1-jrBtualyMzOCTT9VMGQRSYR85dE= + dependencies: + js-base64 "^2.1.8" + source-map "^0.4.2" + semver-greatest-satisfied-range@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz#13e8c2658ab9691cb0cd71093240280d36f77a5b" @@ -3804,7 +4369,12 @@ semver-greatest-satisfied-range@^1.1.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -set-blocking@^2.0.0: +semver@~5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= + +set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -3987,6 +4557,14 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.4.15: version "0.4.18" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" @@ -4007,7 +4585,14 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= -source-map@^0.5.6: +source-map@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" + integrity sha1-66T12pwNyZneaAMti092FzZSA2s= + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.1, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -4093,6 +4678,13 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stdout-stream@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de" + integrity sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA== + dependencies: + readable-stream "^2.0.1" + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -4138,7 +4730,7 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -string-width@^2.0.0, string-width@^2.1.1: +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -4146,6 +4738,15 @@ string-width@^2.0.0, string-width@^2.1.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + string.prototype.trimend@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz#6ddd9a8796bc714b489a3ae22246a208f37bfa46" @@ -4190,6 +4791,18 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-bom-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + integrity sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI= + strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" @@ -4202,6 +4815,13 @@ strip-eof@^1.0.0: resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= +strip-indent@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" + integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI= + dependencies: + get-stdin "^4.0.1" + supports-color@5.4.0: version "5.4.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54" @@ -4209,13 +4829,25 @@ supports-color@5.4.0: dependencies: has-flag "^3.0.0" -supports-color@^5.3.0: +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0, supports-color@^5.4.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== dependencies: has-flag "^3.0.0" +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + sver-compat@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/sver-compat/-/sver-compat-1.5.0.tgz#3cf87dfeb4d07b4a3f14827bc186b3fd0c645cd8" @@ -4270,6 +4902,15 @@ svgo@^1.1.1: unquote "~1.1.1" util.promisify "~1.0.0" +tar@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" + integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== + dependencies: + block-stream "*" + fstream "^1.0.12" + inherits "2" + tdigest@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" @@ -4277,16 +4918,6 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.1" -ternary-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-3.0.0.tgz#7951930ea9e823924d956f03d516151a2d516253" - integrity sha512-oIzdi+UL/JdktkT+7KU5tSIQjj8pbShj3OASuvDEhm0NT5lppsm7aXWAmAq4/QMaBIyfuEcNLbAQA+HpaISobQ== - dependencies: - duplexify "^4.1.1" - fork-stream "^0.0.4" - merge-stream "^2.0.0" - through2 "^3.0.1" - text-hex@1.0.x: version "1.0.0" resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" @@ -4333,6 +4964,14 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +timers-ext@^0.1.5: + version "0.1.7" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" + integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== + dependencies: + es5-ext "~0.10.46" + next-tick "1" + tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -4398,11 +5037,23 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" +trim-newlines@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" + integrity sha1-WIeWa7WCpFA6QetST301ARgVphM= + triple-beam@^1.2.0, triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== +"true-case-path@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d" + integrity sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew== + dependencies: + glob "^7.1.2" + tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -4631,6 +5282,13 @@ vinyl-sourcemap@^1.1.0: remove-bom-buffer "^3.0.0" vinyl "^2.0.0" +vinyl-sourcemaps-apply@^0.2.0, vinyl-sourcemaps-apply@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705" + integrity sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU= + dependencies: + source-map "^0.5.1" + vinyl@^2.0.0, vinyl@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974" @@ -4658,13 +5316,20 @@ which-module@^2.0.0: resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= -which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.1: +which@1, which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== dependencies: isexe "^2.0.0" +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + winston-transport@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" @@ -4696,6 +5361,15 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -4721,11 +5395,11 @@ xpath@^0.0.27: integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== xregexp@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.3.0.tgz#7e92e73d9174a99a59743f67a4ce879a04b5ae50" - integrity sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g== + version "4.4.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.4.0.tgz#29660f5d6567cd2ef981dd4a50cb05d22c10719d" + integrity sha512-83y4aa8o8o4NZe+L+46wpa+F1cWR/wCGOWI3tzqUso0w3/KAvXy0+Di7Oe/cbNMixDR4Jmi7NEybWU6ps25Wkg== dependencies: - "@babel/runtime-corejs3" "^7.8.3" + "@babel/runtime-corejs3" "^7.12.1" xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" @@ -4737,7 +5411,7 @@ y18n@^3.2.1: resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= -"y18n@^3.2.1 || ^4.0.0": +"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== @@ -4763,6 +5437,14 @@ yargs-parser@^11.1.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs@^12.0.2: version "12.0.5" resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" @@ -4781,6 +5463,22 @@ yargs@^12.0.2: y18n "^3.2.1 || ^4.0.0" yargs-parser "^11.1.1" +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + yargs@^7.1.0: version "7.1.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.1.tgz#67f0ef52e228d4ee0d6311acede8850f53464df6" diff --git a/manage.sh b/manage.sh index 6f29902b7..480b8f800 100755 --- a/manage.sh +++ b/manage.sh @@ -1,58 +1,75 @@ #!/usr/bin/env bash set -e -REV=`git log -n 1 --pretty=format:%h -- docker/` -DEVENV_IMGNAME="penpot-devenv" +export ORGANIZATION="penpotapp"; +export DEVENV_IMGNAME="$ORGANIZATION/devenv"; +export DEVENV_PNAME="penpotdev"; + +export CURRENT_USER_ID=$(id -u); +export CURRENT_VERSION=$(git describe --tags); +export CURRENT_GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD); function build-devenv { - echo "Building development image $DEVENV_IMGNAME:latest with UID $EXTERNAL_UID..." - local EXTERNAL_UID=${1:-$(id -u)} - docker-compose -p penpotdev -f docker/devenv/docker-compose.yaml build \ - --force-rm --build-arg EXTERNAL_UID=$EXTERNAL_UID + echo "Building development image $DEVENV_IMGNAME:latest..." + + pushd docker/devenv; + docker build -t $DEVENV_IMGNAME:latest . + popd; } -function build-devenv-if-not-exists { +function publish-devenv { + docker push $DEVENV_IMGNAME:latest +} + +function pull-devenv { + set -ex + docker pull $DEVENV_IMGNAME:latest +} + +function pull-devenv-if-not-exists { if [[ ! $(docker images $DEVENV_IMGNAME:latest -q) ]]; then - build-devenv $@ + pull-devenv $@ fi } function start-devenv { - build-devenv-if-not-exists $@; - docker-compose -p penpotdev -f docker/devenv/docker-compose.yaml up -d; + pull-devenv-if-not-exists $@; + docker-compose -p $DEVENV_PNAME -f docker/devenv/docker-compose.yaml up -d; } function stop-devenv { - docker-compose -p penpotdev -f docker/devenv/docker-compose.yaml stop -t 2; + docker-compose -p $DEVENV_PNAME -f docker/devenv/docker-compose.yaml stop -t 2; } function drop-devenv { - docker-compose -p penpotdev -f docker/devenv/docker-compose.yaml down -t 2 -v; + docker-compose -p $DEVENV_PNAME -f docker/devenv/docker-compose.yaml down -t 2 -v; echo "Clean old development image $DEVENV_IMGNAME..." docker images $DEVENV_IMGNAME -q | awk '{print $3}' | xargs --no-run-if-empty docker rmi } +function log-devenv { + docker-compose -p $DEVENV_PNAME -f docker/devenv/docker-compose.yaml logs -f --tail=50 +} + function run-devenv { if [[ ! $(docker ps -f "name=penpot-devenv-main" -q) ]]; then start-devenv fi - docker exec -ti penpot-devenv-main /home/start-tmux.sh + docker exec -ti penpot-devenv-main sudo -EH -u penpot /home/start-tmux.sh } function build { - build-devenv-if-not-exists; - local IMAGE=$DEVENV_IMGNAME:latest; - - docker volume create penpotdev_user_data; - - echo "Running development image $IMAGE to build frontend." + pull-devenv-if-not-exists; + docker volume create ${DEVENV_PNAME}_user_data; docker run -t --rm \ - --mount source=penpotdev_user_data,type=volume,target=/home/penpot/ \ + --mount source=${DEVENV_PNAME}_user_data,type=volume,target=/home/penpot/ \ --mount source=`pwd`,type=bind,target=/home/penpot/penpot \ + -e EXTERNAL_UID=$CURRENT_USER_ID \ + -e SHADOWCLJS_EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS \ -w /home/penpot/penpot/$1 \ - $IMAGE ./scripts/build.sh + $DEVENV_IMGNAME:latest sudo -EH -u penpot ./scripts/build.sh } function build-frontend { @@ -68,7 +85,6 @@ function build-backend { } function build-bundle { - build "frontend"; build "exporter"; build "backend"; @@ -79,70 +95,106 @@ function build-bundle { mv ./backend/target/dist ./bundle/backend mv ./exporter/target ./bundle/exporter - NAME="penpot-$(date '+%Y.%m.%d-%H%M')" + local name="penpot-$CURRENT_VERSION"; + echo $CURRENT_VERSION > ./bundle/version.txt - pushd bundle/ - tar -cvf ../$NAME.tar *; - popd + sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./bundle/frontend/index.html; + sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./bundle/backend/main/app/config.clj; - xz -vez4f -T4 $NAME.tar + local generate_tar=${PENPOT_BUILD_GENERATE_TAR:-"true"}; + + if [ $generate_tar == "true" ]; then + pushd bundle/ + tar -cvf ../$name.tar *; + popd + + xz -vez1f -T4 $name.tar + + echo "##############################################################"; + echo "# Generated $name.tar.xz"; + echo "##############################################################"; + fi } -function log-devenv { - docker-compose -p penpotdev -f docker/devenv/docker-compose.yaml logs -f --tail=50 +function build-image { + set -ex; + + local image=$1; + + pushd ./docker/images; + local docker_image="$ORGANIZATION/$image"; + docker build -t $docker_image:$CURRENT_VERSION -f Dockerfile.$image .; + popd; } -function build-testenv { - local BUNDLE_FILE=$1; - local BUNDLE_FILE_PATH=`readlink -f $BUNDLE_FILE`; +function build-images { + local bundle_file="penpot-$CURRENT_VERSION.tar.xz"; - echo "Building testenv with bundle: $BUNDLE_FILE_PATH." - - if [ ! -f $BUNDLE_FILE ]; then - echo "File $BUNDLE_FILE does not exists." + if [ ! -f $bundle_file ]; then + echo "File '$bundle_file' does not exists."; + exit 1; fi - rm -rf ./docker/testenv/bundle; - mkdir -p ./docker/testenv/bundle; + rm -rf ./docker/images/bundle; + mkdir -p ./docker/images/bundle; - pushd ./docker/testenv/bundle; - tar xvf $BUNDLE_FILE_PATH; + local bundle_file_path=`readlink -f $bundle_file`; + echo "Building docker image from: $bundle_file_path."; + + pushd ./docker/images/bundle; + tar xvf $bundle_file_path; popd - pushd ./docker/testenv; - docker-compose -p penpot-testenv -f ./docker-compose.yaml build - popd + build-image "backend"; + build-image "frontend"; + build-image "exporter"; } -function start-testenv { - pushd ./docker/testenv; - docker-compose -p penpot-testenv -f ./docker-compose.yaml up - popd +function publish-snapshot { + set -x + docker tag $ORGANIZATION/frontend:$CURRENT_VERSION $ORGANIZATION/frontend:$CURRENT_GIT_BRANCH + docker tag $ORGANIZATION/backend:$CURRENT_VERSION $ORGANIZATION/backend:$CURRENT_GIT_BRANCH + docker tag $ORGANIZATION/exporter:$CURRENT_VERSION $ORGANIZATION/exporter:$CURRENT_GIT_BRANCH + + docker push $ORGANIZATION/frontend:$CURRENT_GIT_BRANCH; + docker push $ORGANIZATION/backend:$CURRENT_GIT_BRANCH; + docker push $ORGANIZATION/exporter:$CURRENT_GIT_BRANCH; } function usage { - echo "PENPOT build & release manager v$REV" + echo "PENPOT build & release manager" echo "USAGE: $0 OPTION" echo "Options:" # echo "- clean Stop and clean up docker containers" # echo "" - echo "- build-devenv Build docker development oriented image; (can specify external user id in parameter)" + echo "- pull-devenv Pulls docker development oriented image" + echo "- build-devenv Build docker development oriented image" echo "- start-devenv Start the development oriented docker-compose service." echo "- stop-devenv Stops the development oriented docker-compose service." echo "- drop-devenv Remove the development oriented docker-compose containers, volumes and clean images." echo "- run-devenv Attaches to the running devenv container and starts development environment" echo " based on tmux (frontend at localhost:3449, backend at localhost:6060)." echo "" - echo "- run-all-tests Execute unit tests for both backend and frontend." - echo "- run-frontend-tests Execute unit tests for frontend only." - echo "- run-backend-tests Execute unit tests for backend only." + # echo "- run-all-tests Execute unit tests for both backend and frontend." + # echo "- run-frontend-tests Execute unit tests for frontend only." + # echo "- run-backend-tests Execute unit tests for backend only." } case $1 in ## devenv related commands + pull-devenv) + pull-devenv ${@:2}; + ;; + build-devenv) build-devenv ${@:2} ;; + + + publish-devenv) + publish-devenv ${@:2} + ;; + start-devenv) start-devenv ${@:2} ;; @@ -159,16 +211,6 @@ case $1 in log-devenv ${@:2} ;; - - # Test Env - start-testenv) - start-testenv - ;; - - build-testenv) - build-testenv ${@:2} - ;; - ## testin related commands # run-all-tests) @@ -198,6 +240,15 @@ case $1 in build-bundle ;; + # Docker Image Tasks + build-images) + build-images; + ;; + + publish-snapshot) + publish-snapshot ${@:2} + ;; + *) usage ;;
- +