♻️ Make the namespacing independent of the branding.

This commit is contained in:
Andrey Antukh 2020-08-18 19:26:37 +02:00
parent aaf8b71837
commit 6c67c3c71b
305 changed files with 2399 additions and 2580 deletions

View file

@ -0,0 +1,26 @@
;; 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.config
(:require [app.util.object :as obj]))
(this-as global
(def default-language "en")
(def demo-warning (obj/get global "appDemoWarning" false))
(def google-client-id (obj/get global "appGoogleClientID" nil))
(def login-with-ldap (obj/get global "appLoginWithLDAP" false))
(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"))
(defn resolve-media-path
[path]
(str media-uri "/" path))

View file

@ -0,0 +1,82 @@
;; 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
(:require
[hashp.core :include-macros true]
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.common.uuid :as uuid]
[app.main.data.auth :refer [logout]]
[app.main.data.users :as udu]
[app.main.store :as st]
[app.main.ui :as ui]
[app.main.ui.modal :refer [modal]]
[app.main.worker]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[app.util.theme :as theme]
[app.util.router :as rt]
[app.util.object :as obj]
[app.util.storage :refer [storage]]
[app.util.timers :as ts]))
(declare reinit)
(defn on-navigate
[router path]
(let [match (rt/match router path)
profile (:profile storage)
authed? (and (not (nil? profile))
(not= (:id profile) uuid/zero))]
(cond
(and (or (= path "")
(nil? match))
(not authed?))
(st/emit! (rt/nav :auth-login))
(and (nil? match) authed?)
(st/emit! (rt/nav :dashboard-team {:team-id (:default-team-id profile)}))
(nil? match)
(st/emit! (rt/nav :not-found))
:else
(st/emit! #(assoc % :route match)))))
(defn init-ui
[]
(st/emit! (rt/initialize-router ui/routes)
(rt/initialize-history on-navigate))
(st/emit! udu/fetch-profile)
(mf/mount (mf/element ui/app-wrapper) (dom/get-element "app"))
(mf/mount (mf/element modal) (dom/get-element "modal")))
(defn ^:export init
[]
(let [translations (obj/get js/window "appTranslations")
themes (obj/get js/window "appThemes")]
(i18n/init! translations)
(theme/init! themes)
(st/init)
(init-ui)))
(defn reinit
[]
(mf/unmount (dom/get-element "app"))
(mf/unmount (dom/get-element "modal"))
(init-ui))
(defn ^:dev/after-load after-load
[]
(reinit))

View file

@ -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) 2015-2016 Andrey Antukh <niwi@niwi.nz>
(ns app.main.constants)
(def viewport-width 4000)
(def viewport-height 4000)
(def frame-start-x 1200)
(def frame-start-y 1200)
(def grid-x-axis 10)
(def grid-y-axis 10)
(def page-metadata
"Default data for page metadata."
{:grid-x-axis grid-x-axis
:grid-y-axis grid-y-axis
:grid-color "#cccccc"
:grid-alignment true
:background "#ffffff"})
(def zoom-levels
[0.01 0.03 0.05 0.07 0.09 0.10 0.11 0.13 0.15 0.18
0.20 0.21 0.22 0.23 0.24 0.25 0.27 0.28 0.30 0.32 0.34
0.36 0.38 0.40 0.42 0.44 0.46 0.48 0.50 0.54 0.57 0.60
0.63 0.66 0.69 0.73 0.77 0.81 0.85 0.90 0.95 1.00 1.05
1.10 1.15 1.21 1.27 1.33 1.40 1.47 1.54 1.62 1.70 1.78
1.87 1.96 2.00 2.16 2.27 2.38 2.50 2.62 2.75 2.88 3.00])

View file

@ -0,0 +1,222 @@
;; 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.auth
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[potok.core :as ptk]
[app.common.spec :as us]
[app.main.repo :as rp]
[app.main.store :refer [initial-state]]
[app.main.data.users :as du]
[app.main.data.messages :as dm]
[app.util.router :as rt]
[app.util.i18n :as i18n :refer [tr]]
[app.util.storage :refer [storage]]))
(s/def ::email ::us/email)
(s/def ::password string?)
(s/def ::fullname string?)
;; --- Logged In
(defn logged-in
[data]
(ptk/reify ::logged-in
ptk/WatchEvent
(watch [this state stream]
(let [team-id (:default-team-id data)]
(rx/of (du/profile-fetched data)
(rt/nav :dashboard-team {:team-id team-id}))))))
;; --- Login
(s/def ::login-params
(s/keys :req-un [::email ::password]))
(defn login
[{:keys [email password] :as data}]
(us/verify ::login-params data)
(ptk/reify ::login
ptk/UpdateEvent
(update [_ state]
(merge state (dissoc initial-state :route :router)))
ptk/WatchEvent
(watch [this state s]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)
params {:email email
:password password
:scope "webapp"}]
(->> (rx/timer 100)
(rx/mapcat #(rp/mutation :login params))
(rx/tap on-success)
(rx/catch (fn [err]
(on-error err)
(rx/empty)))
(rx/map logged-in))))))
(defn login-from-token
[{:keys [profile] :as tdata}]
(ptk/reify ::login-from-token
ptk/UpdateEvent
(update [_ state]
(merge state (dissoc initial-state :route :router)))
ptk/WatchEvent
(watch [this state s]
(let [team-id (:default-team-id profile)]
(rx/of (du/profile-fetched profile)
(rt/nav' :dashboard-team {:team-id team-id}))))))
(defn login-with-ldap
[{:keys [email password] :as data}]
(us/verify ::login-params data)
(ptk/reify ::login-with-ldap
ptk/UpdateEvent
(update [_ state]
(merge state (dissoc initial-state :route :router)))
ptk/WatchEvent
(watch [this state s]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)
params {:email email
:password password
:scope "webapp"}]
(->> (rx/timer 100)
(rx/mapcat #(rp/mutation :login-with-ldap params))
(rx/tap on-success)
(rx/catch (fn [err]
(on-error err)
(rx/empty)))
(rx/map logged-in))))))
;; --- Logout
(def clear-user-data
(ptk/reify ::clear-user-data
ptk/UpdateEvent
(update [_ state]
(select-keys state [:route :router :session-id :history]))
ptk/WatchEvent
(watch [_ state s]
(->> (rp/mutation :logout)
(rx/ignore)))
ptk/EffectEvent
(effect [_ state s]
(reset! storage {})
(i18n/set-default-locale!))))
(def logout
(ptk/reify ::logout
ptk/WatchEvent
(watch [_ state stream]
(rx/of clear-user-data
(rt/nav :auth-login)))))
;; --- Register
(s/def ::register
(s/keys :req-un [::fullname
::password
::email]))
(defn register
"Create a register event instance."
[data]
(s/assert ::register data)
(ptk/reify ::register
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)]
(->> (rp/mutation :register-profile data)
(rx/tap on-success)
(rx/map #(login data))
(rx/catch (fn [err]
(on-error err)
(rx/empty))))))))
;; --- Request Account Deletion
(def request-account-deletion
(letfn [(on-error [{:keys [code] :as error}]
(if (= :app.services.mutations.profile/owner-teams-with-people code)
(let [msg (tr "settings.notifications.profile-deletion-not-allowed")]
(rx/of (dm/error msg)))
(rx/empty)))]
(ptk/reify ::request-account-deletion
ptk/WatchEvent
(watch [_ state stream]
(rx/concat
(->> (rp/mutation :delete-profile {})
(rx/map #(rt/nav :auth-goodbye))
(rx/catch on-error)))))))
;; --- Recovery Request
(s/def ::recovery-request
(s/keys :req-un [::email]))
(defn request-profile-recovery
[data]
(us/verify ::recovery-request data)
(ptk/reify ::request-profile-recovery
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)]
(->> (rp/mutation :request-profile-recovery data)
(rx/tap on-success)
(rx/catch (fn [err]
(on-error err)
(rx/empty))))))))
;; --- Recovery (Password)
(s/def ::token string?)
(s/def ::recover-profile
(s/keys :req-un [::password ::token]))
(defn recover-profile
[{:keys [token password] :as data}]
(us/verify ::recover-profile data)
(ptk/reify ::recover-profile
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)]
(->> (rp/mutation :recover-profile data)
(rx/tap on-success)
(rx/catch (fn [err]
(on-error)
(rx/empty))))))))
;; --- Create Demo Profile
(def create-demo-profile
(ptk/reify ::create-demo-profile
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation :create-demo-profile {})
(rx/map login)))))

View file

@ -0,0 +1,109 @@
;; 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-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.data.colors
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[clojure.set :as set]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.spec :as us]
[app.main.repo :as rp]
[app.main.store :as st]
[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]))
(declare create-color-result)
(defn create-color
[file-id color]
(s/assert (s/nilable uuid?) file-id)
(ptk/reify ::create-color
ptk/WatchEvent
(watch [_ state s]
(->> (rp/mutation! :create-color {:file-id file-id
:content color
:name color})
(rx/map (partial create-color-result file-id))))))
(defn create-color-result
[file-id color]
(ptk/reify ::create-color-result
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-file :colors] #(conj % color))
(assoc-in [:workspace-local :color-for-rename] (:id color))))))
(def clear-color-for-rename
(ptk/reify ::clear-color-for-rename
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :color-for-rename] nil))))
(declare rename-color-result)
(defn rename-color
[file-id color-id name]
(ptk/reify ::rename-color
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :rename-color {:id color-id
:name name})
(rx/map (partial rename-color-result file-id))))))
(defn rename-color-result
[file-id color]
(ptk/reify ::rename-color-result
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-file :colors] #(d/replace-by-id % color))))))
(declare update-color-result)
(defn update-color
[file-id color-id content]
(ptk/reify ::update-color
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :update-color {:id color-id
:content content})
(rx/map (partial update-color-result file-id))))))
(defn update-color-result
[file-id color]
(ptk/reify ::update-color-result
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-file :colors] #(d/replace-by-id % color))))))
(declare delete-color-result)
(defn delete-color
[file-id color-id]
(ptk/reify ::delete-color
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :delete-color {:id color-id})
(rx/map #(delete-color-result file-id color-id))))))
(defn delete-color-result
[file-id color-id]
(ptk/reify ::delete-color-result
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-file :colors]
(fn [colors] (filter #(not= (:id %) color-id) colors)))))))

View file

@ -0,0 +1,447 @@
;; 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-2016 Andrey Antukh <niwi@niwi.nz>
(ns app.main.data.dashboard
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.pages :as cp]
[app.common.spec :as us]
[app.main.repo :as rp]
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.timers :as ts]
[app.common.uuid :as uuid]))
;; --- Specs
(s/def ::id ::us/uuid)
(s/def ::name string?)
(s/def ::team-id ::us/uuid)
(s/def ::profile-id ::us/uuid)
(s/def ::project-id ::us/uuid)
(s/def ::created-at ::us/inst)
(s/def ::modified-at ::us/inst)
(s/def ::team
(s/keys :req-un [::id
::name
::created-at
::modified-at]))
(s/def ::project
(s/keys ::req-un [::id
::name
::team-id
::profile-id
::created-at
::modified-at]))
(s/def ::file
(s/keys :req-un [::id
::name
::created-at
::modified-at
::project-id]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Initialization
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare search-files)
(defn initialize-search
[team-id search-term]
(ptk/reify ::initialize-search
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:search-result nil))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(when-not (empty? search-term)
(rx/of (search-files team-id search-term)))))))
(declare fetch-files)
(declare fetch-projects)
(declare fetch-recent-files)
(declare fetch-shared-files)
(def initialize-drafts
(ptk/reify ::initialize-drafts
ptk/UpdateEvent
(update [_ state]
(let [profile (:profile state)]
(update state :dashboard-local assoc
:project-for-edit nil
:team-id (:default-team-id profile)
:project-id (:default-project-id profile))))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-files (:project-id local))
(fetch-projects (:team-id local) nil))))))
(defn initialize-recent
[team-id]
(us/verify ::us/uuid team-id)
(ptk/reify ::initialize-recent
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:project-id nil
:team-id team-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-recent-files (:team-id local)))))))
(defn initialize-project
[team-id project-id]
(us/verify ::us/uuid team-id)
(us/verify ::us/uuid project-id)
(ptk/reify ::initialize-project
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:team-id team-id
:project-id project-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-files (:project-id local)))))))
(defn initialize-libraries
[team-id]
(us/verify ::us/uuid team-id)
(ptk/reify ::initialize-libraries
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:project-id nil
:team-id team-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-shared-files (:team-id local)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Fetching
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Fetch Projects
(declare projects-fetched)
(defn fetch-projects
[team-id project-id]
(us/assert ::us/uuid team-id)
(us/assert (s/nilable ::us/uuid) project-id)
(ptk/reify ::fetch-projects
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :projects-by-team {:team-id team-id})
(rx/map projects-fetched)
(rx/catch (fn [error]
(rx/of (rt/nav' :auth-login))))))))
(defn projects-fetched
[projects]
(us/verify (s/every ::project) projects)
(ptk/reify ::projects-fetched
ptk/UpdateEvent
(update [_ state]
(assoc state :projects (d/index-by :id projects)))))
;; --- Search Files
(declare files-searched)
(defn search-files
[team-id search-term]
(us/assert ::us/uuid team-id)
(us/assert ::us/string search-term)
(ptk/reify ::search-files
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :search-files {:team-id team-id :search-term search-term})
(rx/map files-searched)))))
(defn files-searched
[files]
(us/verify (s/every ::file) files)
(ptk/reify ::files-searched
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:search-result files))))
;; --- Fetch Files
(declare files-fetched)
(defn fetch-files
[project-id]
(ptk/reify ::fetch-files
ptk/WatchEvent
(watch [_ state stream]
(let [params {:project-id project-id}]
(->> (rp/query :files params)
(rx/map files-fetched))))))
(defn files-fetched
[files]
(us/verify (s/every ::file) files)
(ptk/reify ::files-fetched
ptk/UpdateEvent
(update [_ state]
(let [state (dissoc state :files)
files (d/index-by :id files)]
(assoc state :files files)))))
;; --- Fetch Shared Files
(declare shared-files-fetched)
(defn fetch-shared-files
[]
(ptk/reify ::fetch-shared-files
ptk/WatchEvent
(watch [_ state stream]
(let [params {}]
(->> (rp/query :shared-files params)
(rx/map shared-files-fetched))))))
(defn shared-files-fetched
[files]
(us/verify (s/every ::file) files)
(ptk/reify ::shared-files-fetched
ptk/UpdateEvent
(update [_ state]
(let [state (dissoc state :files)
files (d/index-by :id files)]
(assoc state :files files)))))
;; --- Fetch recent files
(declare recent-files-fetched)
(defn fetch-recent-files
[team-id]
(ptk/reify ::fetch-recent-files
ptk/WatchEvent
(watch [_ state stream]
(let [params {:team-id team-id}]
(->> (rp/query :recent-files params)
(rx/map recent-files-fetched)
(rx/catch (fn [e]
(rx/of (rt/nav' :auth-login)))))))))
(defn recent-files-fetched
[recent-files]
(ptk/reify ::recent-files-fetched
ptk/UpdateEvent
(update [_ state]
(let [flatten-files #(reduce (fn [acc [project-id files]]
(merge acc (d/index-by :id files)))
{}
%1)
extract-ids #(reduce (fn [acc [project-id files]]
(assoc acc project-id (map :id files)))
{}
%1)]
(assoc state
:files (flatten-files recent-files)
:recent-file-ids (extract-ids recent-files))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Modification
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Create Project
(declare project-created)
(def create-project
(ptk/reify ::create-project
ptk/WatchEvent
(watch [_ state stream]
(let [name (name (gensym "New Project "))
team-id (get-in state [:dashboard-local :team-id])]
(->> (rp/mutation! :create-project {:name name :team-id team-id})
(rx/map project-created))))))
(defn project-created
[data]
(us/verify ::project data)
(ptk/reify ::project-created
ptk/UpdateEvent
(update [_ state]
(-> state
(update :projects assoc (:id data) data)
(update :dashboard-local assoc :project-for-edit (:id data))))
ptk/WatchEvent
(watch [_ state stream]
(rx/of (rt/nav :dashboard-project {:team-id (:team-id data) :project-id (:id data)})))))
(def clear-project-for-edit
(ptk/reify ::clear-project-for-edit
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:dashboard-local :project-for-edit] nil))))
;; --- Rename Project
(defn rename-project
[id name]
{:pre [(uuid? id) (string? name)]}
(ptk/reify ::rename-project
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:projects id :name] name))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :name name}]
(->> (rp/mutation :rename-project params)
(rx/ignore))))))
;; --- Delete Project (by id)
(defn delete-project
[id]
(us/verify ::us/uuid id)
(ptk/reify ::delete-project
ptk/UpdateEvent
(update [_ state]
(update state :projects dissoc id))
ptk/WatchEvent
(watch [_ state s]
(->> (rp/mutation :delete-project {:id id})
(rx/ignore)))))
;; --- Delete File (by id)
(defn delete-file
[id]
(us/verify ::us/uuid id)
(ptk/reify ::delete-file
ptk/UpdateEvent
(update [_ state]
(let [project-id (get-in state [:files id :project-id])
recent-project-files (get-in state [:recent-file-ids project-id] [])]
(-> state
(update :files dissoc id)
(assoc-in [:recent-file-ids project-id] (remove #(= % id) recent-project-files)))))
ptk/WatchEvent
(watch [_ state s]
(->> (rp/mutation :delete-file {:id id})
(rx/ignore)))))
;; --- Rename File
(defn rename-file
[id name]
{:pre [(uuid? id) (string? name)]}
(ptk/reify ::rename-file
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:files id :name] name))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :name name}]
(->> (rp/mutation :rename-file params)
(rx/ignore))))))
;; --- Set File shared
(defn set-file-shared
[id is-shared]
{:pre [(uuid? id) (boolean? is-shared)]}
(ptk/reify ::set-file-shared
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:files id :is-shared] is-shared))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :is-shared is-shared}]
(->> (rp/mutation :set-file-shared params)
(rx/ignore))))))
;; --- Create File
(declare file-created)
(defn create-file
[project-id]
(ptk/reify ::create-file
ptk/WatchEvent
(watch [_ state stream]
(let [name (name (gensym "New File "))
params {:name name :project-id project-id}]
(->> (rp/mutation! :create-file params)
(rx/map file-created))))))
(defn file-created
[data]
(us/verify ::file data)
(ptk/reify ::file-created
ptk/UpdateEvent
(update [_ state]
(let [project-id (:project-id data)
file-id (:id data)
recent-project-files (get-in state [:recent-file-ids project-id] [])]
(-> state
(assoc-in [:files file-id] data)
(assoc-in [:recent-file-ids project-id] (conj recent-project-files file-id)))))
ptk/WatchEvent
(watch [_ state stream]
(rx/of (rt/nav :workspace {:project-id (:project-id data)
:file-id (:id data)}
{:page-id (first (:pages data))})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UI State Handling
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Update Opts (Filtering & Ordering)
;; (defn update-opts
;; [& {:keys [order filter] :as opts}]
;; (ptk/reify ::update-opts
;; ptk/UpdateEvent
;; (update [_ state]
;; (update state :dashboard-local merge
;; (when order {:order order})
;; (when filter {:filter filter})))))

View file

@ -0,0 +1,270 @@
;; 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) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.data.history
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]
[app.common.spec :as us]
[app.common.pages :as cp]
[app.main.repo :as rp]
[app.util.data :refer [replace-by-id index-by]]))
;; --- Schema
(s/def ::pinned boolean?)
(s/def ::id uuid?)
(s/def ::label string?)
(s/def ::project uuid?)
(s/def ::created-at inst?)
(s/def ::modified-at inst?)
(s/def ::version number?)
(s/def ::user uuid?)
(s/def ::shapes
(s/every ::cp/minimal-shape :kind vector?))
(s/def ::data
(s/keys :req-un [::shapes]))
(s/def ::history-entry
(s/keys :req-un [::id
::pinned
::label
::project
::created-at
::modified-at
::version
::user
::data]))
(s/def ::history-entries
(s/every ::history-entry))
;; --- Initialize History State
(declare fetch-history)
(declare fetch-pinned-history)
(defn initialize
[id]
(us/verify ::us/uuid id)
(ptk/reify ::initialize
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace id]
assoc :history {:selected nil
:pinned #{}
:items #{}
:byver {}}))
ptk/WatchEvent
(watch [_ state stream]
(rx/of (fetch-history id)
(fetch-pinned-history id)))))
;; --- Watch Page Changes
(defn watch-page-changes
[id]
(us/verify ::us/uuid id)
(reify
ptk/WatchEvent
(watch [_ state stream]
#_(let [stopper (rx/filter #(= % ::stop-page-watcher) stream)]
(->> stream
(rx/filter dp/page-persisted?)
(rx/debounce 1000)
(rx/flat-map #(rx/of (fetch-history id)
(fetch-pinned-history id)))
(rx/take-until stopper))))))
;; --- Pinned Page History Fetched
(defn pinned-history-fetched
[items]
(us/verify ::history-entries items)
(ptk/reify ::pinned-history-fetched
ptk/UpdateEvent
(update [_ state]
(let [pid (get-in state [:workspace :current])
items-map (index-by :version items)
items-set (into #{} items)]
(update-in state [:workspace pid :history]
(fn [history]
(-> history
(assoc :pinned items-set)
(update :byver merge items-map))))))))
;; --- Fetch Pinned Page History
(defn fetch-pinned-history
[id]
(us/verify ::us/uuid id)
(ptk/reify ::fetch-pinned-history
ptk/WatchEvent
(watch [_ state s]
(let [params {:page id :pinned true}]
#_(->> (rp/req :fetch/page-history params)
(rx/map :payload)
(rx/map pinned-history-fetched))))))
;; --- Page History Fetched
(defn history-fetched
[items]
(us/verify ::history-entries items)
(ptk/reify ::history-fetched
ptk/UpdateEvent
(update [_ state]
(let [pid (get-in state [:workspace :current])
versions (into #{} (map :version) items)
items-map (index-by :version items)
min-version (apply min versions)
max-version (apply max versions)]
(update-in state [:workspace pid :history]
(fn [history]
(-> history
(assoc :min-version min-version)
(assoc :max-version max-version)
(update :byver merge items-map)
(update :items #(reduce conj % items)))))))))
;; --- Fetch Page History
(defn fetch-history
([id]
(fetch-history id nil))
([id {:keys [since max]}]
(us/verify ::us/uuid id)
(ptk/reify ::fetch-history
ptk/WatchEvent
(watch [_ state s]
(let [params (merge {:page id
:max (or max 20)}
(when since
{:since since}))]
#_(->> (rp/req :fetch/page-history params)
(rx/map :payload)
(rx/map history-fetched)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Context Aware Events
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Select Section
(deftype SelectSection [section]
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace :history :section] section)))
(defn select-section
[section]
{:pre [(keyword? section)]}
(SelectSection. section))
;; --- Load More
(def load-more
(ptk/reify ::load-more
ptk/WatchEvent
(watch [_ state stream]
(let [pid (get-in state [:workspace :current])
since (get-in state [:workspace pid :history :min-version])]
(rx/of (fetch-history pid {:since since}))))))
;; --- Select Page History
(defn select
[version]
(us/verify int? version)
(ptk/reify ::select
ptk/UpdateEvent
(update [_ state]
#_(let [pid (get-in state [:workspace :current])
item (get-in state [:workspace pid :history :byver version])
page (-> (get-in state [:pages pid])
(assoc :history true
:data (:data item)))]
(-> state
(dp/unpack-page page)
(assoc-in [:workspace pid :history :selected] version))))))
;; --- Apply Selected History
(def apply-selected
(ptk/reify ::apply-selected
ptk/UpdateEvent
(update [_ state]
(let [pid (get-in state [:workspace :current])]
(-> state
(update-in [:pages pid] dissoc :history)
(assoc-in [:workspace pid :history :selected] nil))))
ptk/WatchEvent
(watch [_ state s]
#_(let [pid (get-in state [:workspace :current])]
(rx/of (dp/persist-page pid))))))
;; --- Deselect Page History
(def deselect
(ptk/reify ::deselect
ptk/UpdateEvent
(update [_ state]
#_(let [pid (get-in state [:workspace :current])
packed (get-in state [:packed-pages pid])]
(-> (dp/unpack-page state packed)
(assoc-in [:workspace pid :history :selected] nil))))))
;; --- Refresh Page History
(def refres-history
(ptk/reify ::refresh-history
ptk/WatchEvent
(watch [_ state stream]
(let [pid (get-in state [:workspace :current])
history (get-in state [:workspace pid :history])
maxitems (count (:items history))]
(rx/of (fetch-history pid {:max maxitems})
(fetch-pinned-history pid))))))
;; --- History Item Updated
(defn history-updated
[item]
(us/verify ::history-entry item)
(ptk/reify ::history-item-updated
ptk/UpdateEvent
(update [_ state]
(let [pid (get-in state [:workspace :current])]
(update-in state [:workspace pid :history]
(fn [history]
(-> history
(update :items #(into #{} (replace-by-id item) %))
(update :pinned #(into #{} (replace-by-id item) %))
(assoc-in [:byver (:version item)] item))))))))
(defn history-updated?
[v]
(= ::history-item-updated (ptk/type v)))
;; --- Update History Item
(defn update-history-item
[item]
(ptk/reify ::update-history-item
ptk/WatchEvent
(watch [_ state stream]
(rx/concat
#_(->> (rp/req :update/page-history item)
(rx/map :payload)
(rx/map history-updated))
(->> (rx/filter history-updated? stream)
(rx/take 1)
(rx/map (constantly refres-history)))))))

View file

@ -0,0 +1,67 @@
;; 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) 2016 Andrey Antukh <niwi@niwi.nz>
(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.uuid :as uuid]
[app.util.time :as ts]
[app.util.router :as r]
[app.util.files :as files]))
;; --- Specs
(s/def ::js-file #(instance? js/Blob %))
(s/def ::js-files (s/coll-of ::js-file))
;; --- Utility functions
(defn validate-file
;; Check that a file obtained with the file javascript API is valid.
[file]
(when (> (.-size file) cm/max-file-size)
(throw (ex-info (tr "errors.media-too-large") {})))
(when-not (contains? cm/valid-media-types (.-type file))
(throw (ex-info (tr "errors.media-format-unsupported") {})))
file)
(defn notify-start-loading
[]
(st/emit! (dm/show {:content (tr "media.loading")
:type :info
:timeout nil})))
(defn notify-finished-loading
[]
(st/emit! dm/hide))
(defn process-error
[error]
(let [msg (cond
(.-message error)
(.-message error)
(= (:code error) :media-type-not-allowed)
(tr "errors.media-type-not-allowed")
(= (:code error) :media-type-mismatch)
(tr "errors.media-type-mismatch")
:else
(tr "errors.unexpected-error"))]
(rx/of (dm/error msg))))

View file

@ -0,0 +1,81 @@
;; 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.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]))
(declare hide)
(declare show)
(def +animation-timeout+ 600)
(defn show
[data]
(ptk/reify ::show
ptk/UpdateEvent
(update [_ state]
(let [message (assoc data :status :visible)]
(assoc state :message message)))
ptk/WatchEvent
(watch [_ state stream]
(when (:timeout data)
(let [stoper (rx/filter (ptk/type? ::show) stream)]
(->> (rx/of hide)
(rx/delay (:timeout data))
(rx/take-until stoper)))))))
(def hide
(ptk/reify ::hide
ptk/UpdateEvent
(update [_ state]
(update state :message assoc :status :hide))
ptk/WatchEvent
(watch [_ state stream]
(let [stoper (rx/filter (ptk/type? ::show) stream)]
(->> (rx/of #(dissoc % :message))
(rx/delay +animation-timeout+)
(rx/take-until stoper))))))
(defn error
([content] (error content {}))
([content {:keys [timeout] :or {timeout 3000}}]
(show {:content content
:type :error
:timeout timeout})))
(defn info
([content] (info content {}))
([content {:keys [timeout] :or {timeout 3000}}]
(show {:content content
:type :info
:timeout timeout})))
(defn success
([content] (success content {}))
([content {:keys [timeout] :or {timeout 3000}}]
(show {:content content
:type :success
:timeout timeout})))
(defn warn
([content] (warn content {}))
([content {:keys [timeout] :or {timeout 3000}}]
(show {:content content
:type :warning
:timeout timeout})))

View file

@ -0,0 +1,183 @@
;; 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) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.data.users
(:require
[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]))
;; --- Common Specs
(s/def ::id ::us/uuid)
(s/def ::fullname ::us/string)
(s/def ::email ::us/email)
(s/def ::password ::us/string)
(s/def ::lang ::us/string)
(s/def ::theme ::us/string)
(s/def ::photo ::us/string)
(s/def ::created-at ::us/inst)
(s/def ::password-1 ::us/string)
(s/def ::password-2 ::us/string)
(s/def ::password-old ::us/string)
(s/def ::profile
(s/keys :req-un [::id]
:opt-un [::created-at
::fullname
::photo
::email
::lang
::theme]))
;; --- Profile Fetched
(defn profile-fetched
[{:keys [fullname] :as data}]
(us/verify ::profile data)
(ptk/reify ::profile-fetched
ptk/UpdateEvent
(update [_ state]
(assoc state :profile
(cond-> data
(nil? (:photo-uri data))
(assoc :photo-uri (avatars/generate {:name fullname}))
(nil? (:lang data))
(assoc :lang cfg/default-language)
(nil? (:theme data))
(assoc :theme cfg/default-theme))))
ptk/EffectEvent
(effect [_ state stream]
(let [profile (:profile state)]
(swap! storage assoc :profile profile)
(i18n/set-current-locale! (:lang profile))
(theme/set-current-theme! (:theme profile))))))
;; --- Fetch Profile
(def fetch-profile
(reify
ptk/WatchEvent
(watch [_ state s]
(->> (rp/query! :profile)
(rx/map profile-fetched)
(rx/catch (fn [error]
(if (= (:type error) :not-found)
(rx/of (rt/nav :auth-login))
(rx/empty))))))))
;; --- Update Profile
(defn update-profile
[data]
(us/assert ::profile data)
(ptk/reify ::update-profile
ptk/WatchEvent
(watch [_ state s]
(let [mdata (meta data)
on-success (:on-success mdata identity)
on-error (:on-error mdata identity)
handle-error #(do (on-error (:payload %))
(rx/empty))]
(->> (rp/mutation :update-profile data)
(rx/do on-success)
(rx/map (constantly fetch-profile))
(rx/catch rp/client-error? handle-error))))))
;; --- Request Email Change
(defn request-email-change
[{:keys [email] :as data}]
(ptk/reify ::request-email-change
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)]
(->> (rp/mutation :request-email-change data)
(rx/tap on-success)
(rx/map (constantly fetch-profile))
(rx/catch (fn [err]
(on-error err)
(rx/empty))))))))
;; --- Cancel Email Change
(def cancel-email-change
(ptk/reify ::cancel-email-change
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation :cancel-email-change {})
(rx/map (constantly fetch-profile))))))
;; --- Update Password (Form)
(s/def ::update-password
(s/keys :req-un [::password-1
::password-2
::password-old]))
(defn update-password
[data]
(us/verify ::update-password data)
(ptk/reify ::update-password
ptk/WatchEvent
(watch [_ state s]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)
params {:old-password (:password-old data)
:password (:password-1 data)}]
(->> (rp/mutation :update-profile-password params)
(rx/tap on-success)
(rx/catch (fn [err]
(on-error err)
(rx/empty)))
(rx/ignore))))))
;; --- Update Photo
(defn update-photo
[file]
(us/verify ::di/js-file file)
(ptk/reify ::update-photo
ptk/WatchEvent
(watch [_ state stream]
(let [on-success di/notify-finished-loading
on-error #(do (di/notify-finished-loading)
(di/process-error %))
prepare
(fn [file]
{:file file})]
(di/notify-start-loading)
(->> (rx/of file)
(rx/map di/validate-file)
(rx/map prepare)
(rx/mapcat #(rp/mutation :update-profile-photo %))
(rx/do on-success)
(rx/map (constantly fetch-profile))
(rx/catch on-error))))))

View file

@ -0,0 +1,242 @@
;; 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.viewer
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[potok.core :as ptk]
[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.util.router :as rt]
[app.common.uuid :as uuid]))
;; --- Specs
(s/def ::id ::us/uuid)
(s/def ::name ::us/string)
(s/def ::project (s/keys ::req-un [::id ::name]))
(s/def ::file (s/keys :req-un [::id ::name]))
(s/def ::page (s/keys :req-un [::id ::name ::cp/data]))
(s/def ::interactions-mode #{:hide :show :show-on-click})
(s/def ::bundle
(s/keys :req-un [::project ::file ::page]))
;; --- Initialization
(declare fetch-bundle)
(declare bundle-fetched)
(defn initialize
[page-id share-token]
(ptk/reify ::initialize
ptk/UpdateEvent
(update [_ state]
(assoc state :viewer-local {:zoom 1
:page-id page-id
:interactions-mode :hide
:show-interactions? false}))
ptk/WatchEvent
(watch [_ state stream]
(rx/of (fetch-bundle page-id share-token)))))
;; --- Data Fetching
(defn fetch-bundle
[page-id share-token]
(ptk/reify ::fetch-file
ptk/WatchEvent
(watch [_ state stream]
(let [params (cond-> {:page-id page-id}
(string? share-token) (assoc :share-token share-token))]
(->> (rp/query :viewer-bundle params)
(rx/map bundle-fetched)
(rx/catch (fn [error-data]
(rx/of (rt/nav :not-found)))))))))
(defn- extract-frames
[page]
(let [objects (get-in page [:data :objects])
root (get objects uuid/zero)]
(->> (:shapes root)
(map #(get objects %))
(filter #(= :frame (:type %)))
(reverse)
(vec))))
(defn bundle-fetched
[{:keys [project file page images] :as bundle}]
(us/verify ::bundle bundle)
(ptk/reify ::file-fetched
ptk/UpdateEvent
(update [_ state]
(let [frames (extract-frames page)
objects (get-in page [:data :objects])]
(assoc state :viewer-data {:project project
:objects objects
:file file
:page page
:images images
:frames frames})))))
(def create-share-link
(ptk/reify ::create-share-link
ptk/WatchEvent
(watch [_ state stream]
(let [id (get-in state [:viewer-local :page-id])]
(->> (rp/mutation :generate-page-share-token {:id id})
(rx/map (fn [{:keys [share-token]}]
#(assoc-in % [:viewer-data :page :share-token] share-token))))))))
(def delete-share-link
(ptk/reify ::delete-share-link
ptk/WatchEvent
(watch [_ state stream]
(let [id (get-in state [:viewer-local :page-id])]
(->> (rp/mutation :clear-page-share-token {:id id})
(rx/map (fn [_]
#(assoc-in % [:viewer-data :page :share-token] nil))))))))
;; --- Zoom Management
(def increase-zoom
(ptk/reify ::increase-zoom
ptk/UpdateEvent
(update [_ state]
(let [increase #(nth c/zoom-levels
(+ (d/index-of c/zoom-levels %) 1)
(last c/zoom-levels))]
(update-in state [:viewer-local :zoom] (fnil increase 1))))))
(def decrease-zoom
(ptk/reify ::decrease-zoom
ptk/UpdateEvent
(update [_ state]
(let [decrease #(nth c/zoom-levels
(- (d/index-of c/zoom-levels %) 1)
(first c/zoom-levels))]
(update-in state [:viewer-local :zoom] (fnil decrease 1))))))
(def reset-zoom
(ptk/reify ::reset-zoom
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:viewer-local :zoom] 1))))
(def zoom-to-50
(ptk/reify ::zoom-to-50
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:viewer-local :zoom] 0.5))))
(def zoom-to-200
(ptk/reify ::zoom-to-200
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:viewer-local :zoom] 2))))
;; --- Local State Management
(def toggle-thumbnails-panel
(ptk/reify ::toggle-thumbnails-panel
ptk/UpdateEvent
(update [_ state]
(update-in state [:viewer-local :show-thumbnails] not))))
(def select-prev-frame
(ptk/reify ::select-prev-frame
ptk/WatchEvent
(watch [_ state stream]
(let [route (:route state)
qparams (get-in route [:params :query])
pparams (get-in route [:params :path])
index (d/parse-integer (:index qparams))]
(when (pos? index)
(rx/of (rt/nav :viewer pparams (assoc qparams :index (dec index)))))))))
(def select-next-frame
(ptk/reify ::select-prev-frame
ptk/WatchEvent
(watch [_ state stream]
(let [route (:route state)
qparams (get-in route [:params :query])
pparams (get-in route [:params :path])
index (d/parse-integer (:index qparams))
total (count (get-in state [:viewer-data :frames]))]
(when (< index (dec total))
(rx/of (rt/nav :viewer pparams (assoc qparams :index (inc index)))))))))
(defn set-interactions-mode
[mode]
(us/verify ::interactions-mode mode)
(ptk/reify ::set-interactions-mode
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:viewer-local :interactions-mode] mode)
(assoc-in [:viewer-local :show-interactions?] (case mode
:hide false
:show true
:show-on-click false))))))
(declare flash-done)
(def flash-interactions
(ptk/reify ::flash-interactions
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:viewer-local :show-interactions?] true))
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (rx/filter (ptk/type? ::flash-interactions) stream)]
(->> (rx/of flash-done)
(rx/delay 500)
(rx/take-until stopper))))))
(def flash-done
(ptk/reify ::flash-done
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:viewer-local :show-interactions?] false))))
;; --- Navigation
(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])
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} {:index index}))))))
;; --- Shortcuts
(def shortcuts
{"+" #(st/emit! increase-zoom)
"-" #(st/emit! decrease-zoom)
"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)})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,375 @@
;; 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.common
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[clojure.set :as set]
[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]))
;; --- Protocols
(declare setup-selection-index)
(declare update-page-indices)
(declare reset-undo)
(declare append-undo)
;; --- Changes Handling
(defn commit-changes
([changes undo-changes]
(commit-changes changes undo-changes {}))
([changes undo-changes {:keys [save-undo?
commit-local?]
:or {save-undo? true
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)
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)
state (update-in state [:workspace-pages page-id :data] cp/process-changes changes)]
(cond-> state
commit-local? (update-in [:workspace-data page-id] cp/process-changes changes))))
ptk/WatchEvent
(watch [_ state stream]
(let [page (:workspace-page state)
uidx (get-in state [:workspace-local :undo-index] ::not-found)]
(rx/concat
(rx/of (update-page-indices (:id page)))
(when (and save-undo? (not= uidx ::not-found))
(rx/of (reset-undo uidx)))
(when save-undo?
(let [entry {:undo-changes undo-changes
:redo-changes changes}]
(rx/of (append-undo entry))))))))))
(defn generate-operations
[ma mb]
(let [ma-keys (set (keys ma))
mb-keys (set (keys mb))
added (set/difference mb-keys ma-keys)
removed (set/difference ma-keys mb-keys)
both (set/intersection ma-keys mb-keys)]
(d/concat
(mapv #(array-map :type :set :attr % :val (get mb %)) added)
(mapv #(array-map :type :set :attr % :val nil) removed)
(loop [items (seq both)
result []]
(if items
(let [k (first items)
vma (get ma k)
vmb (get mb k)]
(if (= vma vmb)
(recur (next items) result)
(recur (next items)
(conj result {:type :set
:attr k
:val vmb}))))
result)))))
(defn generate-changes
[objects1 objects2]
(letfn [(impl-diff [res id]
(let [obj1 (get objects1 id)
obj2 (get objects2 id)
ops (generate-operations (dissoc obj1 :shapes :frame-id)
(dissoc obj2 :shapes :frame-id))]
(if (empty? ops)
res
(conj res {:type :mod-obj
:operations ops
:id id}))))]
(reduce impl-diff [] (set/union (set (keys objects1))
(set (keys objects2))))))
;; --- Selection Index Handling
(defn- setup-selection-index
[{:keys [file pages] :as bundle}]
(ptk/reify ::setup-selection-index
ptk/WatchEvent
(watch [_ state stream]
(let [msg {:cmd :create-page-indices
:file-id (:id file)
:pages pages}]
(->> (uw/ask! msg)
(rx/map (constantly ::index-initialized)))))))
(defn update-page-indices
[page-id]
(ptk/reify ::update-page-indices
ptk/EffectEvent
(effect [_ state stream]
(let [objects (get-in state [:workspace-pages page-id :data :objects])
lookup #(get objects %)]
(uw/ask! {:cmd :update-page-indices
:page-id page-id
:objects objects})))))
;; --- Common Helpers & Events
(defn- calculate-frame-overlap
[frames shape]
(let [xf (comp
(filter #(geom/overlaps? % (:selrect shape)))
(take 1))
frame (first (into [] xf frames))]
(or (:id frame) uuid/zero)))
(defn- calculate-shape-to-frame-relationship-changes
[frames shapes]
(loop [shape (first shapes)
shapes (rest shapes)
rch []
uch []]
(if (nil? shape)
[rch uch]
(let [fid (calculate-frame-overlap frames shape)]
(if (not= fid (:frame-id shape))
(recur (first shapes)
(rest shapes)
(conj rch {:type :mov-objects
:parent-id fid
:shapes [(:id shape)]})
(conj uch {:type :mov-objects
:parent-id (:frame-id shape)
:shapes [(:id shape)]}))
(recur (first shapes)
(rest shapes)
rch
uch))))))
(defn rehash-shape-frame-relationship
[ids]
(ptk/reify ::rehash-shape-frame-relationship
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])
shapes (cph/select-toplevel-shapes objects)
frames (cph/select-frames objects)
[rch uch] (calculate-shape-to-frame-relationship-changes frames shapes)]
(when-not (empty? rch)
(rx/of (commit-changes rch uch {:commit-local? true})))))))
(defn get-frame-at-point
[objects point]
(let [frames (cph/select-frames objects)]
(loop [frame (first frames)
rest (rest frames)]
(d/seek #(geom/has-point? % point) frames))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Undo / Redo
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::undo-changes ::cp/changes)
(s/def ::redo-changes ::cp/changes)
(s/def ::undo-entry
(s/keys :req-un [::undo-changes ::redo-changes]))
(def MAX-UNDO-SIZE 50)
(defn- conj-undo-entry
[undo data]
(let [undo (conj undo data)]
(if (> (count undo) MAX-UNDO-SIZE)
(into [] (take MAX-UNDO-SIZE undo))
undo)))
(defn- materialize-undo
[changes index]
(ptk/reify ::materialize-undo
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)]
(-> state
(update-in [:workspace-data page-id] cp/process-changes changes)
(assoc-in [:workspace-local :undo-index] index))))))
(defn- reset-undo
[index]
(ptk/reify ::reset-undo
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-local dissoc :undo-index)
(update-in [:workspace-local :undo]
(fn [queue]
(into [] (take (inc index) queue))))))))
(defn- append-undo
[entry]
(us/verify ::undo-entry entry)
(ptk/reify ::append-undo
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :undo] (fnil conj-undo-entry []) entry))))
(def undo
(ptk/reify ::undo
ptk/WatchEvent
(watch [_ state stream]
(let [local (:workspace-local state)
undo (:undo local [])
index (or (:undo-index local)
(dec (count undo)))]
(when-not (or (empty? undo) (= index -1))
(let [changes (get-in undo [index :undo-changes])]
(rx/of (materialize-undo changes (dec index))
(commit-changes changes [] {:save-undo? false}))))))))
(def redo
(ptk/reify ::redo
ptk/WatchEvent
(watch [_ state stream]
(let [local (:workspace-local state)
undo (:undo local [])
index (or (:undo-index local)
(dec (count undo)))]
(when-not (or (empty? undo) (= index (dec (count undo))))
(let [changes (get-in undo [(inc index) :redo-changes])]
(rx/of (materialize-undo changes (inc index))
(commit-changes changes [] {:save-undo? false}))))))))
(def reinitialize-undo
(ptk/reify ::reset-undo
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local dissoc :undo-index :undo))))
(defn expand-all-parents
[ids objects]
(ptk/reify ::expand-all-parents
ptk/UpdateEvent
(update [_ state]
(let [expand-fn (fn [expanded]
(merge expanded
(->> ids
(map #(cph/get-parents % objects))
flatten
(filter #(not= % uuid/zero))
(map (fn [id] {id true}))
(into {}))))]
(update-in state [:workspace-local :expanded] expand-fn)))))
;; --- Update Shape Attrs
;; NOTE: This is a generic implementation for update multiple shapes
;; in one single commit/undo entry.
(s/def ::coll-of-uuid
(s/every ::us/uuid))
(defn update-shapes
([ids f] (update-shapes ids f nil))
([ids f {:keys [reg-objects?] :or {reg-objects? false}}]
(us/assert ::coll-of-uuid ids)
(us/assert fn? f)
(ptk/reify ::update-shapes
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
objects (get-in state [:workspace-data page-id :objects])]
(loop [ids (seq ids)
rch []
uch []]
(if (nil? ids)
(rx/of (commit-changes
(cond-> rch reg-objects? (conj {:type :reg-objects :shapes (vec ids)}))
(cond-> uch reg-objects? (conj {:type :reg-objects :shapes (vec ids)}))
{:commit-local? true}))
(let [id (first ids)
obj1 (get objects id)
obj2 (f obj1)
rchg {:type :mod-obj
:operations (generate-operations obj1 obj2)
:id id}
uchg {:type :mod-obj
:operations (generate-operations obj2 obj1)
:id id}]
(recur (next ids)
(conj rch rchg)
(conj uch uchg))))))))))
(defn update-shapes-recursive
[ids f]
(us/assert ::coll-of-uuid ids)
(us/assert fn? f)
(letfn [(impl-get-children [objects id]
(cons id (cph/get-children id objects)))
(impl-gen-changes [objects ids]
(loop [sids (seq ids)
cids (seq (impl-get-children objects (first sids)))
rchanges []
uchanges []]
(cond
(nil? sids)
[rchanges uchanges]
(nil? cids)
(recur (next sids)
(seq (impl-get-children objects (first (next sids))))
rchanges
uchanges)
:else
(let [id (first cids)
obj1 (get objects id)
obj2 (f obj1)
rops (generate-operations obj1 obj2)
uops (generate-operations obj2 obj1)
rchg {:type :mod-obj
:operations rops
:id id}
uchg {:type :mod-obj
:operations uops
:id id}]
(recur sids
(next cids)
(conj rchanges rchg)
(conj uchanges uchg))))))]
(ptk/reify ::update-shapes-recursive
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])
[rchanges uchanges] (impl-gen-changes objects (seq ids))]
(rx/of (commit-changes rchanges uchanges {:commit-local? true})))))))

View file

@ -0,0 +1,285 @@
;; 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
"Drawing interactions."
(:require
[beicon.core :as rx]
[potok.core :as ptk]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as geom]
[app.common.pages :as cp]
[app.common.pages-helpers :as cph]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.snap :as snap]
[app.main.streams :as ms]
[app.util.geom.path :as path]))
(declare handle-drawing)
(declare handle-drawing-generic)
(declare handle-drawing-path)
(declare handle-drawing-curve)
(declare handle-finish-drawing)
(declare conditional-align)
(defn start-drawing
[type]
{:pre [(keyword? type)]}
(let [id (gensym "drawing")]
(ptk/reify ::start-drawing
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :drawing-lock] #(if (nil? %) id %)))
ptk/WatchEvent
(watch [_ state stream]
(let [lock (get-in state [:workspace-local :drawing-lock])]
(if (= lock id)
(rx/merge
(->> (rx/filter #(= % handle-finish-drawing) stream)
(rx/take 1)
(rx/map (fn [_] #(update % :workspace-local dissoc :drawing-lock))))
(rx/of (handle-drawing type)))
(rx/empty)))))))
(defn handle-drawing
[type]
(ptk/reify ::handle-drawing
ptk/UpdateEvent
(update [_ state]
(let [data (cp/make-minimal-shape type)]
(update-in state [:workspace-local :drawing] merge data)))
ptk/WatchEvent
(watch [_ state stream]
(case type
:path (rx/of handle-drawing-path)
:curve (rx/of handle-drawing-curve)
(rx/of handle-drawing-generic)))))
(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-in [:modifiers :resize-vector] scalev)
(assoc-in [:modifiers :resize-origin] (gpt/point x y))
(assoc-in [:modifiers :resize-rotation] 0))))
(update-drawing [state point lock? point-snap]
(update-in state [:workspace-local :drawing] 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
page-id (get state :current-page-id)
objects (get-in state [:workspace-data page-id :objects])
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-local :drawing])
(geom/setup {:x (:x initial) :y (:y initial) :width 1 :height 1})
(assoc :frame-id fid)
(assoc ::initialized? true))]
(rx/concat
;; Add shape to drawing state
(rx/of #(assoc-in state [:workspace-local :drawing] shape))
;; Initial SNAP
(->> (snap/closest-snap-point page-id [shape] layout initial)
(rx/map (fn [{:keys [x y]}]
#(update-in % [:workspace-local :drawing] assoc :x x :y y))))
(->> ms/mouse-position
(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-local :drawing :segments] [point point])
(assoc-in [:workspace-local :drawing ::initialized?] true)))
(insert-point-segment [state point]
(-> state
(update-in [:workspace-local :drawing :segments] (fnil conj []) point)))
(update-point-segment [state index point]
(let [segments (count (get-in state [:workspace-local :drawing :segments]))
exists? (< -1 index segments)]
(cond-> state
exists? (assoc-in [:workspace-local :drawing :segments index] point))))
(finish-drawing-path [state]
(update-in
state [:workspace-local :drawing]
(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-local :drawing ::initialized?] true))
(insert-point-segment [state point]
(update-in state [:workspace-local :drawing :segments] (fnil conj []) point))
(finish-drawing-curve [state]
(update-in
state [:workspace-local :drawing]
(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-local :drawing])]
(rx/concat
(rx/of dw/clear-drawing)
(when (::initialized? shape)
(let [shape-min-width (case (:type shape)
:text 20
5)
shape-min-height (case (:type shape)
:text 16
5)
shape (-> shape
geom/transform-shape
(dissoc ::initialized?)) ]
;; Add & select the created shape to the workspace
(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-local :drawing :close?] true))))

View file

@ -0,0 +1,85 @@
;; 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.grid
(:require
[beicon.core :as rx]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.spec :as us]
[app.main.data.workspace.common :as dwc]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Grid
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defonce ^:private default-square-params
{:size 16
:color {:value "#59B9E2"
:opacity 0.2}})
(defonce ^:private default-layout-params
{:size 12
:type :stretch
:item-length nil
:gutter 8
:margin 0
:color {:value "#DE4762"
:opacity 0.1}})
(defonce default-grid-params
{:square default-square-params
:column default-layout-params
:row default-layout-params})
(defn add-frame-grid
[frame-id]
(us/assert ::us/uuid frame-id)
(ptk/reify ::add-frame-grid
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
data (get-in state [:workspace-data page-id])
params (or (get-in data [:options :saved-grids :square])
(:square default-grid-params))
grid {:type :square
:params params
:display true}]
(rx/of (dwc/update-shapes [frame-id]
(fn [obj] (update obj :grids (fnil #(conj % grid) [])))))))))
(defn remove-frame-grid
[frame-id index]
(ptk/reify ::set-frame-grid
ptk/WatchEvent
(watch [_ state stream]
(rx/of (dwc/update-shapes [frame-id] (fn [o] (update o :grids (fnil #(d/remove-at-index % index) []))))))))
(defn set-frame-grid
[frame-id index data]
(ptk/reify ::set-frame-grid
ptk/WatchEvent
(watch [_ state stream]
(rx/of (dwc/update-shapes [frame-id] #(assoc-in % [:grids index] data))))))
(defn set-default-grid
[type params]
(ptk/reify ::set-default-grid
ptk/WatchEvent
(watch [_ state stream]
(let [pid (:current-page-id state)
prev-value (get-in state [:workspace-data pid :options :saved-grids type])]
(rx/of (dwc/commit-changes [{:type :set-option
:option [:saved-grids type]
:value params}]
[{:type :set-option
:option [:saved-grids type]
:value prev-value}]
{:commit-local? true}))))))

View file

@ -0,0 +1,176 @@
;; 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.notifications
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[clojure.set :as set]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.spec :as us]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.streams :as ms]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.persistence :as dwp]
[app.util.avatars :as avatars]
[app.common.geom.point :as gpt]
[app.util.time :as dt]
[app.util.transit :as t]
[app.util.websockets :as ws]))
(declare handle-presence)
(declare handle-pointer-update)
(declare handle-page-change)
(declare handle-pointer-send)
(declare send-keepalive)
(s/def ::type keyword?)
(s/def ::message
(s/keys :req-un [::type]))
(defn initialize
[file-id]
(ptk/reify ::initialize
ptk/UpdateEvent
(update [_ state]
(let [sid (:session-id state)
uri (ws/uri "/ws/notifications" {:file-id file-id
:session-id sid})]
(assoc-in state [:ws file-id] (ws/open uri))))
ptk/WatchEvent
(watch [_ state stream]
(let [wsession (get-in state [:ws file-id])
stoper (rx/filter #(= ::finalize %) stream)
interval (* 1000 60)]
(->> (rx/merge
(->> (rx/timer interval interval)
(rx/map #(send-keepalive file-id)))
(->> (ws/-stream wsession)
(rx/filter #(= :message (:type %)))
(rx/map (comp t/decode :payload))
(rx/filter #(s/valid? ::message %))
(rx/map (fn [{:keys [type] :as msg}]
(case type
:presence (handle-presence msg)
:pointer-update (handle-pointer-update msg)
:page-change (handle-page-change msg)
::unknown))))
(->> stream
(rx/filter ms/pointer-event?)
(rx/sample 50)
(rx/map #(handle-pointer-send file-id (:pt %)))))
(rx/take-until stoper))))))
(defn send-keepalive
[file-id]
(ptk/reify ::send-keepalive
ptk/EffectEvent
(effect [_ state stream]
(when-let [ws (get-in state [:ws file-id])]
(ws/-send ws (t/encode {:type :keepalive}))))))
;; --- Finalize Websocket
(defn finalize
[file-id]
(ptk/reify ::finalize
ptk/WatchEvent
(watch [_ state stream]
(when-let [ws (get-in state [:ws file-id])]
(ws/-close ws))
(rx/of ::finalize))))
;; --- Handle: Presence
(def ^:private presence-palette
#{"#2e8b57" ; seagreen
"#808000" ; olive
"#b22222" ; firebrick
"#ff8c00" ; darkorage
"#ffd700" ; gold
"#ba55d3" ; mediumorchid
"#00fa9a" ; mediumspringgreen
"#00bfff" ; deepskyblue
"#dda0dd" ; plum
"#ff1493" ; deeppink
"#ffa07a" ; lightsalmon
})
(defn handle-presence
[{:keys [sessions] :as msg}]
(letfn [(assign-color [sessions session]
(if (string? (:color session))
session
(let [used (into #{}
(comp (map second)
(map :color)
(remove nil?))
sessions)
avail (set/difference presence-palette used)
color (or (first avail) "#000000")]
(assoc session :color color))))
(update-sessions [previous profiles]
(reduce (fn [current [session-id profile-id]]
(let [profile (get profiles profile-id)
session {:id session-id
:fullname (:fullname profile)
:photo-uri (or (:photo-uri profile)
(avatars/generate {:name (:fullname profile)}))}
session (assign-color current session)]
(assoc current session-id session)))
(select-keys previous (map first sessions))
(filter (fn [[sid]] (not (contains? previous sid))) sessions)))]
(ptk/reify ::handle-presence
ptk/UpdateEvent
(update [_ state]
(let [profiles (:workspace-users state)]
(update state :workspace-presence update-sessions profiles))))))
(defn handle-pointer-update
[{:keys [page-id profile-id session-id x y] :as msg}]
(ptk/reify ::handle-pointer-update
ptk/UpdateEvent
(update [_ state]
(let [profile (get-in state [:workspace-users profile-id])]
(update-in state [:workspace-presence session-id]
(fn [session]
(assoc session
:point (gpt/point x y)
:updated-at (dt/now)
:page-id page-id)))))))
(defn handle-pointer-send
[file-id point]
(ptk/reify ::handle-pointer-update
ptk/EffectEvent
(effect [_ state stream]
(let [ws (get-in state [:ws file-id])
sid (:session-id state)
pid (get-in state [:workspace-page :id])
msg {:type :pointer-update
:page-id pid
:x (:x point)
:y (:y point)}]
(ws/-send ws (t/encode msg))))))
(defn handle-page-change
[msg]
(ptk/reify ::handle-page-change
ptk/WatchEvent
(watch [_ state stream]
(rx/of (dwp/shapes-changes-persisted msg)
(dwc/update-page-indices (:page-id msg))))))

View file

@ -0,0 +1,546 @@
;; 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.persistence
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.media :as cm]
[app.common.geom.point :as gpt]
[app.common.pages :as cp]
[app.common.spec :as us]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.media :as di]
[app.main.data.workspace.common :as dwc]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.transit :as t]))
(declare persist-changes)
(declare update-selection-index)
(declare shapes-changes-persisted)
;; --- Persistence
(defn initialize-page-persistence
[page-id]
(letfn [(enable-reload-stoper []
(obj/set! js/window "onbeforeunload" (constantly false)))
(disable-reload-stoper []
(obj/set! js/window "onbeforeunload" nil))]
(ptk/reify ::initialize-persistence
ptk/UpdateEvent
(update [_ state]
(assoc state :current-page-id page-id))
ptk/WatchEvent
(watch [_ state stream]
(let [stoper (rx/filter #(= ::finalize %) stream)
notifier (->> stream
(rx/filter (ptk/type? ::dwc/commit-changes))
(rx/debounce 2000)
(rx/merge stoper))]
(rx/merge
(->> stream
(rx/filter (ptk/type? ::dwc/commit-changes))
(rx/map deref)
(rx/tap enable-reload-stoper)
(rx/buffer-until notifier)
(rx/map vec)
(rx/filter (complement empty?))
(rx/map #(persist-changes page-id %))
(rx/take-until (rx/delay 100 stoper)))
(->> stream
(rx/filter (ptk/type? ::changes-persisted))
(rx/tap disable-reload-stoper)
(rx/ignore)
(rx/take-until stoper))))))))
(defn persist-changes
[page-id changes]
(ptk/reify ::persist-changes
ptk/WatchEvent
(watch [_ state stream]
(let [sid (:session-id state)
page (get-in state [:workspace-pages page-id])
changes (into [] (mapcat identity) changes)
params {:id (:id page)
:revn (:revn page)
:session-id sid
:changes changes}]
(->> (rp/mutation :update-page params)
(rx/map (fn [lagged]
(if (= #{sid} (into #{} (map :session-id) lagged))
(map #(assoc % :changes []) lagged)
lagged)))
(rx/mapcat seq)
(rx/map shapes-changes-persisted))))))
(s/def ::shapes-changes-persisted
(s/keys :req-un [::page-id ::revn ::cp/changes]))
(defn shapes-changes-persisted
[{:keys [page-id revn changes] :as params}]
(us/verify ::shapes-changes-persisted params)
(ptk/reify ::changes-persisted
ptk/UpdateEvent
(update [_ state]
(let [sid (:session-id state)
page (get-in state [:workspace-pages page-id])
state (update-in state [:workspace-pages page-id :revn] #(max % revn))]
(-> state
(update-in [:workspace-data page-id] cp/process-changes changes)
(update-in [:workspace-pages page-id :data] cp/process-changes changes))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Fetching & Uploading
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Specs
(s/def ::id ::us/uuid)
(s/def ::profile-id ::us/uuid)
(s/def ::name string?)
(s/def ::type keyword?)
(s/def ::file-id ::us/uuid)
(s/def ::created-at ::us/inst)
(s/def ::modified-at ::us/inst)
(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)
(s/def ::project ::dd/project)
(s/def ::page
(s/keys :req-un [::id
::name
::file-id
::revn
::created-at
::modified-at
::ordering
::data]))
(declare fetch-libraries-content)
(declare bundle-fetched)
(defn- fetch-bundle
[project-id file-id]
(ptk/reify ::fetch-bundle
ptk/WatchEvent
(watch [_ state stream]
(->> (rx/zip (rp/query :file {:id file-id})
(rp/query :file-users {:id file-id})
(rp/query :project-by-id {:project-id project-id})
(rp/query :pages {:file-id file-id})
(rp/query :media-objects {:file-id file-id :is-local false})
(rp/query :colors {:file-id file-id})
(rp/query :file-libraries {:file-id file-id}))
(rx/first)
(rx/mapcat
(fn [bundle]
(->> (fetch-libraries-content (get bundle 6))
(rx/map (fn [[lib-media-objects lib-colors]]
(conj bundle lib-media-objects lib-colors))))))
(rx/map (fn [bundle]
(apply bundle-fetched bundle)))
(rx/catch (fn [{:keys [type code] :as error}]
(cond
(= :not-found type)
(rx/of (rt/nav' :not-found))
(and (= :authentication type)
(= :unauthorized code))
(rx/of (rt/nav' :not-authorized))
:else
(throw error))))))))
(defn- fetch-libraries-content
[libraries]
(if (empty? libraries)
(rx/of [{} {}])
(rx/zip
(->> ;; fetch media-objects list of each library, and concatenate in a sequence
(apply rx/zip (for [library libraries]
(->> (rp/query :media-objects {:file-id (:id library)
:is-local false})
(rx/map (fn [media-objects]
[(:id library) media-objects])))))
;; reorganize the sequence as a map {library-id -> media-objects}
(rx/map (fn [media-list]
(reduce (fn [result, [library-id media-objects]]
(assoc result library-id media-objects))
{}
media-list))))
(->> ;; fetch colorss list of each library, and concatenate in a vector
(apply rx/zip (for [library libraries]
(->> (rp/query :colors {:file-id (:id library)})
(rx/map (fn [colors]
[(:id library) colors])))))
;; reorganize the sequence as a map {library-id -> colors}
(rx/map (fn [colors-list]
(reduce (fn [result, [library-id colors]]
(assoc result library-id colors))
{}
colors-list)))))))
(defn- bundle-fetched
[file users project pages media-objects colors libraries lib-media-objects lib-colors]
(ptk/reify ::bundle-fetched
IDeref
(-deref [_]
{:file file
:users users
:project project
:pages pages
:media-objects media-objects
:colors colors
:libraries libraries})
ptk/UpdateEvent
(update [_ state]
(let [assoc-page
#(assoc-in %1 [:workspace-pages (:id %2)] %2)
assoc-media-objects
#(assoc-in %1 [:workspace-libraries %2 :media-objects]
(get lib-media-objects %2))
assoc-colors
#(assoc-in %1 [:workspace-libraries %2 :colors]
(get lib-colors %2))]
(as-> state $$
(assoc $$
:workspace-file (assoc file
:media-objects media-objects
:colors colors)
:workspace-users (d/index-by :id users)
:workspace-pages {}
:workspace-project project
:workspace-libraries (d/index-by :id libraries))
(reduce assoc-media-objects $$ (keys lib-media-objects))
(reduce assoc-colors $$ (keys lib-colors))
(reduce assoc-page $$ pages))))))
;; --- Set File shared
(defn set-file-shared
[id is-shared]
{:pre [(uuid? id) (boolean? is-shared)]}
(ptk/reify ::set-file-shared
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-file :is-shared] is-shared))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :is-shared is-shared}]
(->> (rp/mutation :set-file-shared params)
(rx/ignore))))))
;; --- Fetch Shared Files
(declare shared-files-fetched)
(defn fetch-shared-files
[]
(ptk/reify ::fetch-shared-files
ptk/WatchEvent
(watch [_ state stream]
(let [params {}]
(->> (rp/query :shared-files params)
(rx/map shared-files-fetched))))))
(defn shared-files-fetched
[files]
(us/verify (s/every ::file) files)
(ptk/reify ::shared-files-fetched
ptk/UpdateEvent
(update [_ state]
(let [state (dissoc state :files)]
(assoc state :workspace-shared-files files)))))
;; --- 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})
(rp/query :colors {:file-id library-id}))))
(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)
(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}]
(->> (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]))))
;; --- Fetch Pages
(declare page-fetched)
(defn fetch-page
[page-id]
(us/verify ::us/uuid page-id)
(ptk/reify ::fetch-pages
ptk/WatchEvent
(watch [_ state s]
(->> (rp/query :page {:id page-id})
(rx/map page-fetched)))))
(defn page-fetched
[{:keys [id] :as page}]
(us/verify ::page page)
(ptk/reify ::page-fetched
IDeref
(-deref [_] page)
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-pages id] page))))
;; --- Page Crud
(declare page-created)
(def create-empty-page
(ptk/reify ::create-empty-page
ptk/WatchEvent
(watch [this state stream]
(let [file-id (get-in state [:workspace-file :id])
name (name (gensym "Page "))
ordering (count (get-in state [:workspace-file :pages]))
params {:name name
:file-id file-id
:ordering ordering
:data cp/default-page-data}]
(->> (rp/mutation :create-page params)
(rx/map page-created))))))
(defn page-created
[{:keys [id file-id] :as page}]
(us/verify ::page page)
(ptk/reify ::page-created
cljs.core/IDeref
(-deref [_] page)
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-file :pages] (fnil conj []) id)
(assoc-in [:workspace-pages id] page)))))
(s/def ::rename-page
(s/keys :req-un [::id ::name]))
(defn rename-page
[id name]
(us/verify ::us/uuid id)
(us/verify string? name)
(ptk/reify ::rename-page
ptk/UpdateEvent
(update [_ state]
(let [pid (get-in state [:workspace-page :id])
state (assoc-in state [:workspace-pages id :name] name)]
(cond-> state
(= pid id) (assoc-in [:workspace-page :name] name))))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :name name}]
(->> (rp/mutation :rename-page params)
(rx/map #(ptk/data-event ::page-renamed params)))))))
(declare purge-page)
(declare go-to-file)
(defn delete-page
[id]
{:pre [(uuid? id)]}
(reify
ptk/UpdateEvent
(update [_ state]
(purge-page state id))
ptk/WatchEvent
(watch [_ state s]
(let [page (:workspace-page state)]
(rx/merge
(->> (rp/mutation :delete-page {:id id})
(rx/flat-map (fn [_]
(if (= id (:id page))
(rx/of go-to-file)
(rx/empty))))))))))
;; --- Upload local media objects
(s/def ::local? ::us/boolean)
(s/def ::uri ::us/string)
(s/def ::upload-media-objects-params
(s/keys :req-un [::file-id ::local?]
:opt-un [::uri ::di/js-files]))
(defn upload-media-objects
[{:keys [file-id local? js-files uri] :as params}]
(us/assert ::upload-media-objects-params params)
(ptk/reify ::upload-media-objects
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-success on-error]
:or {on-success identity}} (meta params)
is-library (not= file-id (:id (:workspace-file state)))
prepare-js-file
(fn [js-file]
{:name (.-name js-file)
:file-id file-id
:content js-file
:is-local local?})
prepare-uri
(fn [uri]
{:file-id file-id
:is-local local?
:url uri})
assoc-to-library
(fn [media-object state]
(cond
(true? local?)
state
(true? is-library)
(update-in state
[:workspace-libraries file-id :media-objects]
#(conj % media-object))
:else
(update-in state
[:workspace-file :media-objects]
#(conj % media-object))))]
(rx/concat
(rx/of (dm/show {:content (tr "media.loading")
:type :info
:timeout nil}))
(->> (if (string? uri)
(->> (rx/of uri)
(rx/map prepare-uri)
(rx/mapcat #(rp/mutation! :add-media-object-from-url %)))
(->> (rx/from js-files)
(rx/map di/validate-file)
(rx/map prepare-js-file)
(rx/mapcat #(rp/mutation! :upload-media-object %))))
(rx/do on-success)
(rx/map (fn [mobj] (partial assoc-to-library mobj)))
(rx/catch (fn [error]
(cond
(= (:code error) :media-type-not-allowed)
(rx/of (dm/error (tr "errors.media-type-not-allowed")))
(= (:code error) :media-type-mismatch)
(rx/of (dm/error (tr "errors.media-type-mismatch")))
(fn? on-error)
(do
(on-error error)
(rx/empty))
:else
(rx/throw error))))
(rx/finalize (fn []
(st/emit! dm/hide)))))))))
;; --- Delete media object
(defn delete-media-object
[file-id id]
(ptk/reify ::delete-media-object
ptk/UpdateEvent
(update [_ state]
(let [is-library (not= file-id (:id (:workspace-file state)))]
(if is-library
(update-in state
[:workspace-libraries file-id :media-objects]
(fn [media-objects] (filter #(not= (:id %) id) media-objects)))
(update-in state
[:workspace-file :media-objects]
(fn [media-objects] (filter #(not= (:id %) id) media-objects))))))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id}]
(rp/mutation :delete-media-object params)))))
;; --- Helpers
(defn purge-page
"Remove page and all related stuff from the state."
[state id]
(-> state
(update-in [:workspace-file :pages] #(filterv (partial not= id) %))
(update :workspace-pages dissoc id)))

View file

@ -0,0 +1,312 @@
;; 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.selection
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]
[app.common.data :as d]
[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.main.data.workspace.common :as dwc]
[app.main.streams :as ms]
[app.main.worker :as uw]))
(s/def ::set-of-uuid
(s/every uuid? :kind set?))
(s/def ::ordered-set-of-uuid
(s/every uuid? :kind d/ordered-set?))
(s/def ::set-of-string
(s/every string? :kind set?))
;; Duplicate from workspace.
;; FIXME: Move these functions to a common place
(defn interrupt? [e] (= e :interrupt))
(defn- retrieve-used-names
[objects]
(into #{} (map :name) (vals objects)))
(defn- extract-numeric-suffix
[basename]
(if-let [[match p1 p2] (re-find #"(.*)-([0-9]+)$" basename)]
[p1 (+ 1 (d/parse-integer p2))]
[basename 1]))
(defn- generate-unique-name
"A unique name generator"
[used basename]
(s/assert ::set-of-string used)
(s/assert ::us/string basename)
(let [[prefix initial] (extract-numeric-suffix basename)]
(loop [counter initial]
(let [candidate (str prefix "-" counter)]
(if (contains? used candidate)
(recur (inc counter))
candidate)))))
;; --- Selection Rect
(declare select-shapes-by-current-selrect)
(declare deselect-all)
(defn update-selrect
[selrect]
(ptk/reify ::update-selrect
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :selrect] selrect))))
(def handle-selection
(letfn [(data->selrect [data]
(let [start (:start data)
stop (:stop data)
start-x (min (:x start) (:x stop))
start-y (min (:y start) (:y stop))
end-x (max (:x start) (:x stop))
end-y (max (:y start) (:y stop))]
{:type :rect
:x start-x
:y start-y
:width (mth/abs (- end-x start-x))
:height (mth/abs (- end-y start-y))}))]
(ptk/reify ::handle-selection
ptk/WatchEvent
(watch [_ state stream]
(let [stoper (rx/filter #(or (interrupt? %)
(ms/mouse-up? %))
stream)]
(rx/concat
(rx/of deselect-all)
(->> ms/mouse-position
(rx/scan (fn [data pos]
(if data
(assoc data :stop pos)
{:start pos :stop pos}))
nil)
(rx/map data->selrect)
(rx/filter #(or (> (:width %) 10)
(> (:height %) 10)))
(rx/map update-selrect)
(rx/take-until stoper))
(rx/of select-shapes-by-current-selrect)))))))
;; --- Toggle shape's selection status (selected or deselected)
(defn select-shape
([id] (select-shape id false))
([id toggle?]
(us/verify ::us/uuid id)
(ptk/reify ::select-shape
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :selected]
(fn [selected]
(if-not toggle?
(conj selected id)
(if (contains? selected id)
(disj selected id)
(conj selected id))))))
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])]
(rx/of (dwc/expand-all-parents [id] objects)))))))
(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 (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])]
(rx/of (dwc/expand-all-parents ids objects))))))
(def deselect-all
"Clear all possible state of drawing, edition
or any similar action taken by the user."
(ptk/reify ::deselect-all
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local #(-> %
(assoc :selected (d/ordered-set))
(dissoc :selected-frame))))))
;; --- Select Shapes (By selrect)
(def select-shapes-by-current-selrect
(ptk/reify ::select-shapes-by-current-selrect
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get-in state [:workspace-page :id])
selrect (get-in state [:workspace-local :selrect])]
(rx/merge
(rx/of (update-selrect nil))
(when selrect
(->> (uw/ask! {:cmd :selection/query
:page-id page-id
:rect selrect})
(rx/map select-shapes))))))))
(defn select-inside-group
[group-id position]
(ptk/reify ::select-inside-group
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
objects (get-in state [:workspace-data page-id :objects])
group (get objects group-id)
children (map #(get objects %) (:shapes group))
selected (->> children (filter #(geom/has-point? % position)) first)]
(when selected
(rx/of deselect-all (select-shape (:id selected))))))))
;; --- Duplicate Shapes
(declare prepare-duplicate-changes)
(declare prepare-duplicate-change)
(declare prepare-duplicate-frame-change)
(declare prepare-duplicate-shape-change)
(def ^:private change->name #(get-in % [:obj :name]))
(defn- prepare-duplicate-changes
"Prepare objects to paste: generate new id, give them unique names,
move to the position of mouse pointer, and find in what frame they
fit."
[objects names ids delta]
(loop [names names
ids (seq ids)
chgs []]
(if ids
(let [id (first ids)
result (prepare-duplicate-change objects names id delta)
result (if (vector? result) result [result])]
(recur
(into names (map change->name) result)
(next ids)
(into chgs result)))
chgs)))
(defn- prepare-duplicate-change
[objects names id delta]
(let [obj (get objects id)]
(if (= :frame (:type obj))
(prepare-duplicate-frame-change objects names obj delta)
(prepare-duplicate-shape-change objects names obj delta (:frame-id obj) (:parent-id obj)))))
(defn- prepare-duplicate-shape-change
[objects names obj delta frame-id parent-id]
(let [id (uuid/next)
name (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)
frame-id (if frame-id
frame-id
(dwc/calculate-frame-overlap frames moved-obj))
parent-id (or parent-id frame-id)
children-changes
(loop [names names
result []
cid (first (:shapes obj))
cids (rest (:shapes obj))]
(if (nil? cid)
result
(let [obj (get objects cid)
changes (prepare-duplicate-shape-change objects names obj delta frame-id id)]
(recur
(into names (map change->name changes))
(into result changes)
(first cids)
(rest cids)))))
reframed-obj (-> moved-obj
(assoc :frame-id frame-id)
(dissoc :shapes))]
(into [{:type :add-obj
:id id
:old-id (:id obj)
:frame-id frame-id
:parent-id parent-id
:obj (dissoc reframed-obj :shapes)}]
children-changes)))
(defn- prepare-duplicate-frame-change
[objects names obj delta]
(let [frame-id (uuid/next)
frame-name (generate-unique-name names (:name obj))
sch (->> (map #(get objects %) (:shapes obj))
(mapcat #(prepare-duplicate-shape-change objects names % delta frame-id frame-id)))
renamed-frame (-> obj
(assoc :id frame-id)
(assoc :name frame-name)
(assoc :frame-id uuid/zero)
(dissoc :shapes))
moved-frame (geom/move renamed-frame delta)
fch {:type :add-obj
:old-id (:id obj)
:id frame-id
:frame-id uuid/zero
:obj moved-frame}]
(into [fch] sch)))
(def duplicate-selected
(ptk/reify ::duplicate-selected
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
selected (get-in state [:workspace-local :selected])
objects (get-in state [:workspace-data page-id :objects])
delta (gpt/point 0 0)
unames (retrieve-used-names objects)
rchanges (prepare-duplicate-changes objects unames selected delta)
uchanges (mapv #(array-map :type :del-obj :id (:id %))
(reverse rchanges))
selected (->> rchanges
(filter #(selected (:old-id %)))
(map #(get-in % [:obj :id]))
(into (d/ordered-set)))]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})
(select-shapes selected))))))
(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 #{}))))))

View file

@ -0,0 +1,188 @@
;; 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.texts
(:require
["slate" :as slate :refer [Editor Node Transforms Text]]
["slate-react" :as rslate]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[clojure.walk :as walk]
[goog.object :as gobj]
[potok.core :as ptk]
[app.common.geom.shapes :as geom]
[app.main.data.workspace.common :as dwc]
[app.main.fonts :as fonts]
[app.util.object :as obj]))
(defn create-editor
[]
(rslate/withReact (slate/createEditor)))
(defn assign-editor
[id editor]
(ptk/reify ::assign-editor
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :editors id] editor)
(update-in [:workspace-local :editor-n] (fnil inc 0))))))
;; --- Helpers
(defn- calculate-full-selection
[editor]
(let [children (obj/get editor "children")
paragraphs (obj/get-in children [0 "children" 0 "children"])
lastp (aget paragraphs (dec (alength paragraphs)))
lastptxt (.string Node lastp)]
#js {:anchor #js {:path #js [0 0 0]
:offset 0}
:focus #js {:path #js [0 0 (dec (alength paragraphs))]
:offset (alength lastptxt)}}))
(defn- editor-select-all!
[editor]
(let [children (obj/get editor "children")
paragraphs (obj/get-in children [0 "children" 0 "children"])
range (calculate-full-selection editor)]
(.select Transforms editor range)))
(defn- editor-set!
([editor props]
(editor-set! editor props #js {}))
([editor props options]
(.setNodes Transforms editor props options)
editor))
(defn- transform-nodes
[pred transform data]
(walk/postwalk
(fn [item]
(if (and (map? item) (pred item))
(transform item)
item))
data))
;; --- Editor Related Helpers
(defn- ^boolean is-text-node?
[node]
(cond
(object? node) (.isText Text node)
(map? node) (string? (:text node))
(nil? node) false
:else (throw (ex-info "unexpected type" {:node node}))))
(defn- ^boolean is-paragraph-node?
[node]
(cond
(object? node) (= (.-type node) "paragraph")
(map? node) (= "paragraph" (:type node))
(nil? node) false
:else (throw (ex-info "unexpected type" {:node node}))))
(defn- ^boolean is-root-node?
[node]
(cond
(object? node) (= (.-type node) "root")
(map? node) (= "root" (:type node))
(nil? node) false
:else (throw (ex-info "unexpected type" {:node node}))))
(defn- editor-current-values
[editor pred attrs universal?]
(let [options #js {:match pred :universal universal?}
_ (when (nil? (obj/get editor "selection"))
(obj/set! options "at" (calculate-full-selection editor)))
result (.nodes Editor editor options)
match (ffirst (es6-iterator-seq result))]
(when (object? match)
(let [attrs (clj->js attrs)
result (areduce attrs i ret #js {}
(let [val (obj/get match (aget attrs i))]
(if val
(obj/set! ret (aget attrs i) val)
ret)))]
(js->clj result :keywordize-keys true)))))
(defn nodes-seq
[match? node]
(->> (tree-seq map? :children node)
(filter match?)))
(defn- shape-current-values
[shape pred attrs]
(let [root (:content shape)
nodes (nodes-seq pred root)]
(geom/get-attrs-multi nodes attrs)))
(defn current-text-values
[{:keys [editor default attrs shape]}]
(if editor
(editor-current-values editor is-text-node? attrs true)
(shape-current-values shape is-text-node? attrs)))
(defn current-paragraph-values
[{:keys [editor attrs shape]}]
(if editor
(editor-current-values editor is-paragraph-node? attrs false)
(shape-current-values shape is-paragraph-node? attrs)))
(defn current-root-values
[{:keys [editor attrs shape]}]
(if editor
(editor-current-values editor is-root-node? attrs false)
(shape-current-values shape is-root-node? attrs)))
(defn- merge-attrs
[node attrs]
(reduce-kv (fn [node k v]
(if (nil? v)
(dissoc node k)
(assoc node k v)))
node
attrs))
(defn impl-update-shape-attrs
([shape attrs]
;; NOTE: this arity is used in workspace for properly update the
;; fill color using colorpalette, then the predicate should be
;; defined.
(impl-update-shape-attrs shape attrs is-text-node?))
([{:keys [type content] :as shape} attrs pred]
(assert (= :text type) "should be shape type")
(let [merge-attrs #(merge-attrs % attrs)]
(update shape :content #(transform-nodes pred merge-attrs %)))))
(defn update-attrs
[{:keys [id editor attrs pred split]
:or {pred is-text-node?}}]
(if editor
(ptk/reify ::update-attrs
ptk/EffectEvent
(effect [_ state stream]
(editor-set! editor (clj->js attrs) #js {:match pred :split split})))
(ptk/reify ::update-attrs
ptk/WatchEvent
(watch [_ state stream]
(rx/of (dwc/update-shapes [id] #(impl-update-shape-attrs % attrs pred)))))))
(defn update-text-attrs
[options]
(update-attrs (assoc options :pred is-text-node? :split true)))
(defn update-paragraph-attrs
[options]
(update-attrs (assoc options :pred is-paragraph-node? :split false)))
(defn update-root-attrs
[options]
(update-attrs (assoc options :pred is-root-node? :split false)))

View file

@ -0,0 +1,429 @@
;; 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.transforms
"Events related with shapes transformations"
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.spec :as us]
[app.common.pages :as cp]
[app.common.pages-helpers :as cph]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.selection :as dws]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.streams :as ms]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.main.snap :as snap]))
;; -- Declarations
(declare set-modifiers)
(declare set-rotation)
(declare apply-modifiers)
;; -- Helpers
;; For each of the 8 handlers gives the modifier for resize
;; for example, right will only grow in the x coordinate and left
;; will grow in the inverse of the x coordinate
(def ^:private handler-modifiers
{:right [ 1 0]
:bottom [ 0 1]
:left [-1 0]
:top [ 0 -1]
:top-right [ 1 -1]
:top-left [-1 -1]
:bottom-right [ 1 1]
:bottom-left [-1 1]})
;; Given a handler returns the coordinate origin for resizes
;; this is the opposite of the handler so for right we want the
;; left side as origin of the resize
;; sx, sy => start x/y
;; mx, my => middle x/y
;; ex, ey => end x/y
(defn- handler-resize-origin [{sx :x sy :y :keys [width height]} handler]
(let [mx (+ sx (/ width 2))
my (+ sy (/ height 2))
ex (+ sx width)
ey (+ sy height)
[x y] (case handler
:right [sx my]
:bottom [mx sy]
:left [ex my]
:top [mx ey]
:top-right [sx ey]
:top-left [ex ey]
:bottom-right [sx sy]
:bottom-left [ex sy])]
(gpt/point x y)))
(defn finish-transform [state]
(update state :workspace-local dissoc :transform))
;; -- RESIZE
(defn start-resize
[handler initial ids shape]
(letfn [(resize [shape initial resizing-shapes [point lock? point-snap]]
(let [{:keys [width height rotation]} shape
shapev (-> (gpt/point width height))
rotation (if (#{:curve :path} (:type shape)) 0 rotation)
;; Vector modifiers depending on the handler
handler-modif (let [[x y] (handler-modifiers handler)] (gpt/point x y))
;; Difference between the origin point in the coordinate system of the rotation
deltav (-> (gpt/to-vec initial (if (= rotation 0) point-snap point))
(gpt/transform (gmt/rotate-matrix (- rotation)))
(gpt/multiply handler-modif))
;; Resize vector
scalev (gpt/divide (gpt/add shapev deltav) shapev)
scalev (if lock? (let [v (max (:x scalev) (:y scalev))] (gpt/point v v)) scalev)
shape-transform (:transform shape (gmt/matrix))
shape-transform-inverse (:transform-inverse shape (gmt/matrix))
;; Resize origin point given the selected handler
origin (-> (handler-resize-origin shape handler)
(gsh/transform-shape-point shape shape-transform))]
(rx/of (set-modifiers ids
{:resize-vector scalev
:resize-origin origin
:resize-transform shape-transform
:resize-transform-inverse shape-transform-inverse}
false))))
;; Unifies the instantaneous proportion lock modifier
;; activated by Shift key and the shapes own proportion
;; lock flag that can be activated on element options.
(normalize-proportion-lock [[point shift?]]
(let [proportion-lock? (:proportion-lock shape)]
[point (or proportion-lock? shift?)]))
;; Applies alginment to point if it is currently
;; activated on the current workspace
;; (apply-grid-alignment [point]
;; (if @refs/selected-alignment
;; (uwrk/align-point point)
;; (rx/of point)))
]
(reify
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :transform] :resize)))
ptk/WatchEvent
(watch [_ state stream]
(let [current-pointer @ms/mouse-position
initial-position (merge current-pointer initial)
stoper (rx/filter ms/mouse-up? stream)
page-id (get state :current-page-id)
resizing-shapes (map #(get-in state [:workspace-data page-id :objects %]) ids)
layout (get state :workspace-layout)]
(rx/concat
(->> ms/mouse-position
(rx/with-latest vector ms/mouse-position-shift)
(rx/map normalize-proportion-lock)
(rx/switch-map (fn [[point :as current]]
(->> (snap/closest-snap-point page-id resizing-shapes layout point)
(rx/map #(conj current %)))))
(rx/mapcat (partial resize shape initial-position resizing-shapes))
(rx/take-until stoper))
#_(rx/empty)
(rx/of (apply-modifiers ids)
finish-transform)))))))
;; -- ROTATE
(defn start-rotate
[shapes]
(ptk/reify ::start-rotate
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :transform] :rotate)))
ptk/WatchEvent
(watch [_ state stream]
(let [stoper (rx/filter ms/mouse-up? stream)
group (gsh/selection-rect shapes)
group-center (gsh/center group)
initial-angle (gpt/angle @ms/mouse-position group-center)
calculate-angle (fn [pos ctrl?]
(let [angle (- (gpt/angle pos group-center) initial-angle)
angle (if (neg? angle) (+ 360 angle) angle)
modval (mod angle 45)
angle (if ctrl?
(if (< 22.5 modval)
(+ angle (- 45 modval))
(- angle modval))
angle)
angle (if (= angle 360)
0
angle)]
angle))]
(rx/concat
(->> ms/mouse-position
(rx/with-latest vector ms/mouse-position-ctrl)
(rx/map (fn [[pos ctrl?]]
(let [delta-angle (calculate-angle pos ctrl?)]
(set-rotation delta-angle shapes group-center))))
(rx/take-until stoper))
(rx/of (apply-modifiers (map :id shapes))
finish-transform))))))
;; -- MOVE
(declare start-move)
(declare start-move-duplicate)
(defn start-move-selected
[]
(ptk/reify ::start-move-selected
ptk/WatchEvent
(watch [_ state stream]
(let [initial @ms/mouse-position
selected (get-in state [:workspace-local :selected])
stopper (rx/filter ms/mouse-up? stream)]
(->> ms/mouse-position
(rx/take-until stopper)
(rx/map #(gpt/to-vec initial %))
(rx/map #(gpt/length %))
(rx/filter #(> % 1))
(rx/take 1)
(rx/with-latest vector ms/mouse-position-alt)
(rx/mapcat
(fn [[_ alt?]]
(if alt?
;; When alt is down we start a duplicate+move
(rx/of (start-move-duplicate initial)
dws/duplicate-selected)
;; Otherwise just plain old move
(rx/of (start-move initial selected))))))))))
(defn start-move-duplicate [from-position]
(ptk/reify ::start-move-selected
ptk/WatchEvent
(watch [_ state stream]
(->> stream
(rx/filter (ptk/type? ::dws/duplicate-selected))
(rx/first)
(rx/map #(start-move from-position))))))
(defn start-move
([from-position] (start-move from-position nil))
([from-position ids]
(ptk/reify ::start-move
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc-in [:workspace-local :transform] :move)))
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (get state :current-page-id)
objects (get-in state [:workspace-data page-id :objects])
ids (if (nil? ids) (get-in state [:workspace-local :selected]) ids)
shapes (mapv #(get objects %) ids)
stopper (rx/filter ms/mouse-up? stream)
layout (get state :workspace-layout)]
(rx/concat
(->> ms/mouse-position
(rx/take-until stopper)
(rx/map #(gpt/to-vec from-position %))
(rx/switch-map #(snap/closest-snap-move page-id shapes objects layout %))
(rx/map #(gpt/round % 0))
(rx/map gmt/translate-matrix)
(rx/map #(set-modifiers ids {:displacement %})))
(rx/of (apply-modifiers ids)
finish-transform)))))))
(defn- get-displacement-with-grid
"Retrieve the correct displacement delta point for the
provided direction speed and distances thresholds."
[shape direction options]
(let [grid-x (:grid-x options 10)
grid-y (:grid-y options 10)
x-mod (mod (:x shape) grid-x)
y-mod (mod (:y shape) grid-y)]
(case direction
:up (gpt/point 0 (- (if (zero? y-mod) grid-y y-mod)))
:down (gpt/point 0 (- grid-y y-mod))
:left (gpt/point (- (if (zero? x-mod) grid-x x-mod)) 0)
:right (gpt/point (- grid-x x-mod) 0))))
(defn- get-displacement
"Retrieve the correct displacement delta point for the
provided direction speed and distances thresholds."
[direction]
(case direction
:up (gpt/point 0 (- 1))
:down (gpt/point 0 1)
:left (gpt/point (- 1) 0)
:right (gpt/point 1 0)))
(s/def ::direction #{:up :down :right :left})
(defn move-selected
[direction shift?]
(us/verify ::direction direction)
(us/verify boolean? shift?)
(let [same-event (js/Symbol "same-event")]
(ptk/reify ::move-selected
IDeref
(-deref [_] direction)
ptk/UpdateEvent
(update [_ state]
(if (nil? (get-in state [:workspace-local :current-move-selected]))
(-> state
(assoc-in [:workspace-local :transform] :move)
(assoc-in [:workspace-local :current-move-selected] same-event))
state))
ptk/WatchEvent
(watch [_ state stream]
(if (= same-event (get-in state [:workspace-local :current-move-selected]))
(let [selected (get-in state [:workspace-local :selected])
move-events (->> stream
(rx/filter (ptk/type? ::move-selected))
(rx/filter #(= direction (deref %))))
stopper (->> move-events
(rx/debounce 100)
(rx/first))
scale (if shift? (gpt/point 10) (gpt/point 1))
mov-vec (gpt/multiply (get-displacement direction) scale)]
(rx/concat
(rx/merge
(->> move-events
(rx/take-until stopper)
(rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0))
(rx/map #(set-modifiers selected {:displacement (gmt/translate-matrix %)})))
(rx/of (move-selected direction shift?)))
(rx/of (apply-modifiers selected)
(fn [state] (-> state
(update :workspace-local dissoc :current-move-selected))))
(->>
(rx/timer 100)
(rx/map (fn [] finish-transform)))))
(rx/empty))))))
;; -- Apply modifiers
(defn set-modifiers
([ids modifiers] (set-modifiers ids modifiers true))
([ids modifiers recurse-frames?]
(us/verify (s/coll-of uuid?) ids)
(ptk/reify ::set-modifiers
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)
objects (get-in state [:workspace-data page-id :objects])
not-frame-id? (fn [shape-id]
(let [shape (get objects shape-id)]
(or recurse-frames? (not (= :frame (:type shape))))))
;; ID's + Children but remove frame children if the flag is set to false
ids-with-children (concat ids (mapcat #(cph/get-children % objects)
(filter not-frame-id? ids)))
;; For each shape updates the modifiers given as arguments
update-shape (fn [state shape-id]
(update-in
state
[:workspace-data page-id :objects shape-id :modifiers]
#(merge % modifiers)))]
(reduce update-shape state 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)))
([delta-rotation shapes center]
(ptk/reify ::set-rotation
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)]
(letfn [(rotate-shape [state angle shape center]
(let [objects (get-in state [:workspace-data page-id :objects])
path [:workspace-data page-id :objects (:id shape) :modifiers]
modifiers (rotation-modifiers center shape angle)]
(-> state
(update-in path merge modifiers))))
(rotate-around-center [state angle center shapes]
(reduce #(rotate-shape %1 angle %2 center) state shapes))]
(let [objects (get-in state [:workspace-data page-id :objects])
id->obj #(get objects %)
get-children (fn [shape] (map id->obj (cph/get-children (:id shape) objects)))
shapes (concat shapes (mapcat get-children shapes))]
(rotate-around-center state delta-rotation center shapes))))))))
(defn apply-modifiers
[ids]
(us/verify (s/coll-of uuid?) ids)
(ptk/reify ::apply-modifiers
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
objects0 (get-in state [:workspace-pages page-id :data :objects])
objects1 (get-in state [:workspace-data page-id :objects])
;; ID's + Children ID's
ids-with-children (d/concat [] (mapcat #(cph/get-children % objects1) ids) ids)
;; For each shape applies the modifiers by transforming the objects
update-shape #(update %1 %2 gsh/transform-shape)
objects2 (reduce update-shape objects1 ids-with-children)
regchg {:type :reg-objects :shapes (vec ids)}
;; we need to generate redo chages from current
;; state (with current temporal values) to new state but
;; the undo should be calculated from clear current
;; state (without temporal values in it, for this reason
;; we have 3 different objects references).
rchanges (conj (dwc/generate-changes objects1 objects2) regchg)
uchanges (conj (dwc/generate-changes objects2 objects0) regchg)
]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})
(dwc/rehash-shape-frame-relationship ids))))))

View file

@ -0,0 +1,156 @@
;; 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.exports
"The main logic for SVG export functionality."
(:require
[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]
[app.common.geom.matrix :as gmt]
[app.main.ui.shapes.frame :as frame]
[app.main.ui.shapes.circle :as circle]
[app.main.ui.shapes.icon :as icon]
[app.main.ui.shapes.image :as image]
[app.main.ui.shapes.path :as path]
[app.main.ui.shapes.rect :as rect]
[app.main.ui.shapes.text :as text]
[app.main.ui.shapes.group :as group]))
(def ^:private default-color "#E8E9EA") ;; $color-canvas
(mf/defc background
[{:keys [vbox color]}]
[:rect
{:x (:x vbox)
:y (:y vbox)
:width (:width vbox)
:height (:height vbox)
:fill color}])
(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))))
(declare shape-wrapper-factory)
(defn frame-wrapper-factory
[objects]
(let [shape-wrapper (shape-wrapper-factory objects)
frame-shape (frame/frame-shape shape-wrapper)]
(mf/fnc frame-wrapper
[{:keys [shape] :as props}]
(let [childs (mapv #(get objects %) (:shapes shape))
shape (geom/transform-shape shape)]
[:& frame-shape {:shape shape :childs childs}]))))
(defn group-wrapper-factory
[objects]
(let [shape-wrapper (shape-wrapper-factory objects)
group-shape (group/group-shape shape-wrapper)]
(mf/fnc group-wrapper
[{:keys [shape frame] :as props}]
(let [childs (mapv #(get objects %) (:shapes shape))]
[:& group-shape {:frame frame
:shape shape
:is-child-selected? true
:childs childs}]))))
(defn shape-wrapper-factory
[objects]
(mf/fnc shape-wrapper
[{:keys [frame shape] :as props}]
(let [group-wrapper (mf/use-memo (mf/deps objects) #(group-wrapper-factory objects))]
(when (and shape (not (:hidden shape)))
(let [shape (geom/transform-shape frame shape)
opts #js {:shape shape}]
(case (:type shape)
:curve [:> path/path-shape opts]
:text [:> text/text-shape opts]
:icon [:> icon/icon-shape opts]
:rect [:> rect/rect-shape opts]
:path [:> path/path-shape opts]
:image [:> image/image-shape opts]
:circle [:> circle/circle-shape opts]
:group [:> group-wrapper {:shape shape :frame frame}]
nil))))))
(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))
background-color (get-in data [:options :background] default-color)
frame-wrapper
(mf/use-memo
(mf/deps objects)
#(frame-wrapper-factory objects))
shape-wrapper
(mf/use-memo
(mf/deps objects)
#(shape-wrapper-factory objects))]
[:svg {:view-box vbox
:version "1.1"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:xmlns "http://www.w3.org/2000/svg"}
[:& background {:vbox dim :color background-color}]
(for [item shapes]
(if (= (:type item) :frame)
[:& frame-wrapper {:shape item
:key (:id item)}]
[:& shape-wrapper {:shape item
:key (:id item)}]))]))
(mf/defc frame-svg
{::mf/wrap [mf/memo]}
[{:keys [objects frame zoom] :or {zoom 1} :as props}]
(let [modifier (-> (gpt/point (:x frame) (:y frame))
(gpt/negate)
(gmt/translate-matrix))
frame-id (:id frame)
modifier-ids (concat [frame-id] (cph/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)
width (* (:width frame) zoom)
height (* (:height frame) zoom)
vbox (str "0 0 " (:width frame 0)
" " (:height frame 0))
wrapper (mf/use-memo
(mf/deps objects)
#(frame-wrapper-factory objects))]
[:svg {:view-box vbox
:width width
:height height
:version "1.1"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:xmlns "http://www.w3.org/2000/svg"}
[:& wrapper {:shape frame :view-box vbox}]]))

View file

@ -0,0 +1,50 @@
;; 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.fonts
"A fonts loading macros."
(:require
[cuerdas.core :as str]
[clojure.java.io :as io]
[clojure.data.json :as json]))
(defn- parse-gfont-variant
[variant]
(cond
(= "regular" variant)
{:name "regular" :weight "400" :style "normal"}
(= "italic" variant)
{:name "italic" :weight "400" :style "italic"}
:else
(when-let [[a b c] (re-find #"^(\d+)(.*)$" variant)]
(if (str/empty? c)
{:id a :name b :weight b :style "normal"}
{:id a :name (str b " (" c ")") :weight b :style c}))))
(defn- parse-gfont
[font]
(let [family (get font "family")
variants (get font "variants")]
{:id (str "gfont-" (str/slug family))
:family family
:name family
:variants (into [] (comp (map parse-gfont-variant)
(filter identity))
variants)}))
(defmacro preload-gfonts
[path]
(let [data (slurp (io/resource path))
data (json/read-str data)]
`~(mapv parse-gfont (get data "items"))))

View file

@ -0,0 +1,153 @@
;; 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.fonts
"Fonts management and loading logic."
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require
[beicon.core :as rx]
[promesa.core :as p]
[okulary.core :as l]
[cuerdas.core :as str]
[app.util.dom :as dom]
[app.util.timers :as ts]
[app.common.data :as d]
[clojure.set :as set]))
(def google-fonts
(preload-gfonts "fonts/gfonts.2020.04.23.json"))
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants [{:id "100" :name "100" :weight "100" :style "normal"}
{:id "100italic" :name "100 (italic)" :weight "100" :style "italic"}
{:id "200" :name "200" :weight "200" :style "normal"}
{:id "200italic" :name "200 (italic)" :weight "200" :style "italic"}
{:id "300" :name "300" :weight "300" :style "normal"}
{:id "300italic" :name "300 (italic)" :weight "300" :style "italic"}
{:id "regular" :name "regular" :weight "400" :style "normal"}
{:id "italic" :name "italic" :weight "400" :style "italic"}
{:id "500" :name "500" :weight "500" :style "normal"}
{:id "500italic" :name "500 (italic)" :weight "500" :style "italic"}
{:id "bold" :name "bold" :weight "bold" :style "normal"}
{:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"}
{:id "black" :name "black" :weight "900" :style "normal"}
{:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]}
{:id "roboto"
:family "roboto"
:name "Roboto"
:variants [{:id "100" :name "100" :weight "100" :style "normal"}
{:id "100italic" :name "100 (italic)" :weight "100" :style "italic"}
{:id "200" :name "200" :weight "200" :style "normal"}
{:id "200italic" :name "200 (italic)" :weight "200" :style "italic"}
{:id "regular" :name "regular" :weight "400" :style "normal"}
{:id "italic" :name "italic" :weight "400" :style "italic"}
{:id "500" :name "500" :weight "500" :style "normal"}
{:id "500italic" :name "500 (italic)" :weight "500" :style "italic"}
{:id "bold" :name "bold" :weight "bold" :style "normal"}
{:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"}
{:id "black" :name "black" :weight "900" :style "normal"}
{:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]}
{:id "robotocondensed"
:family "robotocondensed"
:name "Roboto Condensed"
:variants [{:id "100" :name "100" :weight "100" :style "normal"}
{:id "100italic" :name "100 (italic)" :weight "100" :style "italic"}
{:id "200" :name "200" :weight "200" :style "normal"}
{:id "200italic" :name "200 (italic)" :weight "200" :style "italic"}
{:id "regular" :name "regular" :weight "400" :style "normal"}
{:id "italic" :name "italic" :weight "400" :style "italic"}
{:id "500" :name "500" :weight "500" :style "normal"}
{:id "500italic" :name "500 (italic)" :weight "500" :style "italic"}
{:id "bold" :name "bold" :weight "bold" :style "normal"}
{:id "bolditalic" :name "bold (italic)" :weight "bold" :style "italic"}
{:id "black" :name "black" :weight "900" :style "normal"}
{:id "blackitalic" :name "black (italic)" :weight "900" :style "italic"}]}])
(defonce fontsdb (l/atom {}))
(defonce fontsview (l/atom {}))
(defn- materialize-fontsview
[db]
(reset! fontsview (reduce-kv (fn [acc k v]
(assoc acc k (sort-by :name v)))
{}
(group-by :backend (vals db)))))
(add-watch fontsdb "main"
(fn [_ _ _ db]
(ts/schedule #(materialize-fontsview db))))
(defn register!
[backend fonts]
(let [fonts (map #(assoc % :backend backend) fonts)]
(swap! fontsdb #(merge % (d/index-by :id fonts)))))
(register! :builtin local-fonts)
(register! :google google-fonts)
(defn resolve-variants
[id]
(get-in @fontsdb [id :variants]))
(defn resolve-fonts
[backend]
(get @fontsview backend))
;; --- Fonts Loader
(defonce loaded (l/atom #{}))
(defn- create-link-node
[uri]
(let [node (.createElement js/document "link")]
(unchecked-set node "href" uri)
(unchecked-set node "rel" "stylesheet")
(unchecked-set node "type" "text/css")
node))
(defmulti ^:private load-font :backend)
(defmethod load-font :builtin
[{:keys [id ::on-loaded] :as font}]
(js/console.log "[debug:fonts]: loading builtin font" id)
(when (fn? on-loaded)
(on-loaded id)))
(defmethod load-font :google
[{:keys [id family variants ::on-loaded] :as font}]
(when (exists? js/window)
(js/console.log "[debug:fonts]: loading google font" id)
(let [base (str "https://fonts.googleapis.com/css?family=" family)
variants (str/join "," (map :id variants))
uri (str base ":" variants "&display=block")
node (create-link-node uri)]
(.addEventListener node "load" (fn [event] (when (fn? on-loaded)
(on-loaded id))))
(.append (.-head js/document) node)
nil)))
(defmethod load-font :default
[{:keys [backend] :as font}]
(js/console.warn "no implementation found for" backend))
(defn ensure-loaded!
([id]
(p/create (fn [resolve]
(ensure-loaded! id resolve))))
([id on-loaded]
(if (contains? @loaded id)
(on-loaded id)
(when-let [font (get @fontsdb id)]
(load-font (assoc font ::on-loaded on-loaded))
(swap! loaded conj id)))))

View file

@ -0,0 +1,186 @@
;; 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.refs
"A collection of derived refs."
(:require
[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]))
;; ---- Global refs
(def route
(l/derived :route st/state))
(def router
(l/derived :router st/state))
(def message
(l/derived :message st/state))
(def profile
(l/derived :profile st/state))
;; ---- Dashboard refs
(def dashboard-local
(l/derived :dashboard-local st/state))
;; ---- Workspace refs
(def workspace-local
(l/derived :workspace-local st/state))
(def workspace-layout
(l/derived :workspace-layout st/state))
(def workspace-page
(l/derived :workspace-page st/state))
(def workspace-page-id
(l/derived :id workspace-page))
(def workspace-file
(l/derived :workspace-file st/state))
(def workspace-project
(l/derived :workspace-project st/state))
(def workspace-shared-files
(l/derived :workspace-shared-files st/state))
(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))
(def workspace-snap-data
(l/derived :workspace-snap-data st/state))
(def workspace-data
(-> #(let [page-id (get-in % [:workspace-page :id])]
(get-in % [:workspace-data page-id]))
(l/derived st/state)))
(def workspace-page-options
(l/derived :options workspace-data))
(def workspace-saved-grids
(l/derived :saved-grids workspace-page-options))
(def workspace-objects
(l/derived :objects workspace-data))
(def workspace-frames
(l/derived cph/select-frames workspace-objects))
(defn object-by-id
[id]
(letfn [(selector [state]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])]
(get objects id)))]
(l/derived selector st/state =)))
(defn objects-by-id
[ids]
(letfn [(selector [state]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])]
(->> (set ids)
(map #(get objects %))
(filter identity)
(vec))))]
(l/derived selector st/state =)))
(defn is-child-selected?
[id]
(letfn [(selector [state]
(let [page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])
selected (get-in state [:workspace-local :selected])
shape (get objects id)
children (cph/get-children id objects)]
(some selected children)))]
(l/derived selector st/state)))
(def selected-shapes
(l/derived :selected workspace-local))
(def selected-objects
(letfn [(selector [state]
(let [selected (get-in state [:workspace-local :selected])
page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])]
(mapv #(get objects %) selected)))]
(l/derived selector st/state =)))
(def selected-shapes-with-children
(letfn [(selector [state]
(let [selected (get-in state [:workspace-local :selected])
page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])
children (mapcat #(cph/get-children % objects) selected)]
(into selected children)))]
(l/derived selector st/state)))
(def selected-objects-with-children
(letfn [(selector [state]
(let [selected (get-in state [:workspace-local :selected])
page-id (get-in state [:workspace-page :id])
objects (get-in state [:workspace-data page-id :objects])
children (mapcat #(cph/get-children % objects) selected)
accumulated (into selected children)]
(mapv #(get objects %) accumulated)))]
(l/derived selector st/state)))
(defn make-selected
[id]
(l/derived #(contains? % id) selected-shapes))
(def selected-zoom
(l/derived :zoom workspace-local))
(def selected-drawing-tool
(l/derived :drawing-tool workspace-local))
(def current-drawing-shape
(l/derived :drawing workspace-local))
(def selected-edition
(l/derived :edition workspace-local))
(def current-transform
(l/derived :transform workspace-local))
(def options-mode
(l/derived :options-mode workspace-local))
(def vbox
(l/derived :vbox workspace-local))
(def current-hover
(l/derived :hover workspace-local))
;; ---- Viewer refs
(def viewer-data
(l/derived :viewer-data st/state))
(def viewer-local
(l/derived :viewer-local st/state))

View file

@ -0,0 +1,106 @@
;; 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.repo
(:require
[beicon.core :as rx]
[cuerdas.core :as str]
[app.config :as cfg]
[app.util.http-api :as http]))
(defn- handle-response
[response]
(cond
(http/success? response)
(rx/of (:body response))
(http/client-error? response)
(rx/throw (:body response))
:else
(rx/throw {:type :unexpected
:code (:error response)})))
(defn send-query!
[id params]
(let [uri (str cfg/public-uri "/api/w/query/" (name id))]
(->> (http/send! {:method :get :uri uri :query params})
(rx/mapcat handle-response))))
(defn send-mutation!
[id params]
(let [uri (str cfg/public-uri "/api/w/mutation/" (name id))]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(defn- dispatch
[& args]
(first args))
(defmulti query dispatch)
(defmulti mutation dispatch)
(defmethod query :default
[id params]
(send-query! id params))
(defmethod mutation :default
[id params]
(send-mutation! id params))
(defn query!
([id] (query id {}))
([id params] (query id params)))
(defn mutation!
([id] (mutation id {}))
([id params] (mutation id params)))
(defmethod mutation :login-with-google
[id params]
(let [uri (str cfg/public-uri "/api/oauth/google")]
(->> (http/send! {:method :post :uri uri})
(rx/mapcat handle-response))))
(defmethod mutation :upload-media-object
[id params]
(let [form (js/FormData.)]
(run! (fn [[key val]]
(.append form (name key) val))
(seq params))
(send-mutation! id form)))
(defmethod mutation :update-profile-photo
[id params]
(let [form (js/FormData.)]
(run! (fn [[key val]]
(.append form (name key) val))
(seq params))
(send-mutation! id form)))
(defmethod mutation :login
[id params]
(let [uri (str cfg/public-uri "/api/login")]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(defmethod mutation :logout
[id params]
(let [uri (str cfg/public-uri "/api/logout")]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(defmethod mutation :login-with-ldap
[id params]
(let [uri (str cfg/public-uri "/api/login-ldap")]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(def client-error? http/client-error?)
(def server-error? http/server-error?)

View file

@ -0,0 +1,210 @@
;; 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.snap
(:require
[clojure.set :as set]
[beicon.core :as rx]
[app.common.uuid :refer [zero]]
[app.common.math :as mth]
[app.common.data :as d]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.main.worker :as uw]
[app.main.refs :as refs]
[app.util.geom.snap-points :as sp]))
(def ^:private snap-accuracy 5)
(def ^:private snap-distance-accuracy 10)
(defn- remove-from-snap-points
[remove-id?]
(fn [query-result]
(->> query-result
(map (fn [[value data]] [value (remove (comp remove-id? second) data)]))
(filter (fn [[_ data]] (not (empty? data)))))))
(defn- flatten-to-points
[query-result]
(mapcat (fn [[v data]] (map (fn [[point _]] point) data)) query-result))
(defn- calculate-distance [query-result point coord]
(->> query-result
(map (fn [[value data]] [(mth/abs (- value (coord point))) [(coord point) value]]))))
(defn- get-min-distance-snap [points coord]
(fn [query-result]
(->> points
(mapcat #(calculate-distance query-result % coord))
(apply min-key first)
second)))
(defn- snap-frame-id [shapes]
(let [frames (into #{} (map :frame-id shapes))]
(cond
;; Only shapes from one frame. The common is the only one
(= 0 (count frames)) (first frames)
;; Frames doesn't contain zero. So we take the first frame
(not (frames zero)) (-> shapes first :frame-id)
;; Otherwise the root frame is the common
:else zero)))
(defn get-snap-points [page-id frame-id filter-shapes point coord]
(let [value (get point coord)]
(->> (uw/ask! {:cmd :snaps/range-query
:page-id page-id
:frame-id frame-id
:coord coord
:ranges [[value value]]})
(rx/first)
(rx/map (remove-from-snap-points filter-shapes))
(rx/map flatten-to-points))))
(defn- search-snap
[page-id frame-id points coord filter-shapes]
(let [ranges (->> points
(map coord)
(mapv #(vector (- % snap-accuracy)
(+ % snap-accuracy))))]
(->> (uw/ask! {:cmd :snaps/range-query
:page-id page-id
:frame-id frame-id
:coord coord
:ranges ranges})
(rx/first)
(rx/map (remove-from-snap-points filter-shapes))
(rx/map (get-min-distance-snap points coord)))))
(defn snap->vector [[from-x to-x] [from-y to-y]]
(when (or from-x to-x from-y to-y)
(let [from (gpt/point (or from-x 0) (or from-y 0))
to (gpt/point (or to-x 0) (or to-y 0))]
(gpt/to-vec from to))))
(defn- closest-snap
[page-id frame-id points filter-shapes]
(let [snap-x (search-snap page-id frame-id points :x filter-shapes)
snap-y (search-snap page-id frame-id points :y filter-shapes)]
;; snap-x is the second parameter because is the "source" to combine
(rx/combine-latest snap->vector snap-y snap-x)))
(defn search-snap-distance [selrect coord shapes-lt shapes-gt]
(let [dist (fn [[sh1 sh2]] (-> sh1 (gsh/distance-shapes sh2) coord))
dist-lt (fn [other] (-> (:selrect other) (gsh/distance-selrect selrect) coord))
dist-gt (fn [other] (-> selrect (gsh/distance-selrect (:selrect other)) coord))
;; Calculates the distance between all the shapes given as argument
inner-distance (fn [shapes]
(->> shapes
(sort-by coord)
(d/map-perm vector)
(filter (fn [[sh1 sh2]] (gsh/overlap-coord? coord sh1 sh2)))
(map dist)
(filter #(> % 0))))
;; Calculates the snap distance when in the middle of two shapes
between-snap (fn [[sh-lt sh-gt]]
;; To calculate the middle snap.
;; Given x, the distance to a left shape and y to a right shape
;; x - v = y + v => v = (x - y)/2
;; v will be the vector that we need to move the shape so it "snaps"
;; in the middle
(/ (- (dist-gt sh-gt)
(dist-lt sh-lt)) 2))
]
(->> shapes-lt
(rx/combine-latest vector shapes-gt)
(rx/map (fn [[shapes-lt shapes-gt]]
(let [;; Distance between the elements in an area, these are the snap
;; candidates to either side
lt-cand (inner-distance shapes-lt)
gt-cand (inner-distance shapes-gt)
;; Distance between the elements to either side and the current shape
;; this is the distance that will "snap"
lt-dist (map dist-lt shapes-lt)
gt-dist (map dist-gt shapes-gt)
;; Calculate the snaps, we need to reverse depending on area
lt-snap (d/join lt-cand lt-dist -)
gt-snap (d/join gt-dist gt-cand -)
;; Calculate snap-between
between-snap (->> (d/join shapes-lt shapes-gt)
(map between-snap))
;; Search the minimum snap
min-snap (->> (concat lt-snap gt-snap between-snap)
(filter #(<= (mth/abs %) snap-distance-accuracy))
(reduce min ##Inf))]
(if (mth/finite? min-snap) [0 min-snap] nil)))))))
(defn select-shapes-area
[page-id shapes objects area-selrect]
(->> (uw/ask! {:cmd :selection/query
:page-id page-id
:frame-id (->> shapes first :frame-id)
:rect area-selrect})
(rx/map #(set/difference % (into #{} (map :id shapes))))
(rx/map (fn [ids] (map #(get objects %) ids)))))
(defn closest-distance-snap
[page-id shapes objects movev]
(->> (rx/of shapes)
(rx/map #(vector (->> % first :frame-id (get objects))
(-> % gsh/selection-rect (gsh/move movev))))
(rx/merge-map
(fn [[frame selrect]]
(let [areas (->> (gsh/selrect->areas (or (:selrect frame)
(gsh/rect->rect-shape @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))]
(rx/combine-latest snap->vector snap-y snap-x))))))
(defn closest-snap-point
[page-id shapes layout point]
(let [frame-id (snap-frame-id shapes)
filter-shapes (into #{} (map :id shapes))
filter-shapes (fn [id] (if (= id :layout)
(or (not (contains? layout :display-grid))
(not (contains? layout :snap-grid)))
(or (filter-shapes id)
(not (contains? layout :dynamic-alignment)))))]
(->> (closest-snap page-id frame-id [point] filter-shapes)
(rx/map #(or % (gpt/point 0 0)))
(rx/map #(gpt/add point %)))))
(defn closest-snap-move
[page-id shapes objects layout movev]
(let [frame-id (snap-frame-id shapes)
filter-shapes (into #{} (map :id shapes))
filter-shapes (fn [id] (if (= id :layout)
(or (not (contains? layout :display-grid))
(not (contains? layout :snap-grid)))
(or (filter-shapes id)
(not (contains? layout :dynamic-alignment)))))
shapes-points (->> shapes
;; Unroll all the possible snap-points
(mapcat (partial sp/shape-snap-points))
;; Move the points in the translation vector
(map #(gpt/add % movev)))]
(->> (rx/merge (closest-snap page-id frame-id shapes-points filter-shapes)
(when (contains? layout :dynamic-alignment)
(closest-distance-snap page-id shapes objects movev)))
(rx/reduce gpt/min)
(rx/map #(or % (gpt/point 0 0)))
(rx/map #(gpt/add movev %))
(rx/map #(gpt/round % 0))
)))

View file

@ -0,0 +1,70 @@
;; 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) 2020 UXBOX Labs SL
(ns app.main.store
(:require
[beicon.core :as rx]
[okulary.core :as l]
[potok.core :as ptk]
[app.common.uuid :as uuid]
[app.util.storage :refer [storage]]
[app.util.debug :refer [debug? logjs]]))
(enable-console-print!)
(def ^:dynamic *on-error* identity)
(defonce state (l/atom {}))
(defonce loader (l/atom false))
(defonce store (ptk/store {:on-error #(*on-error* %)}))
(defonce stream (ptk/input-stream store))
(defn- repr-event
[event]
(cond
(satisfies? ptk/Event event)
(str "typ: " (pr-str (ptk/type event)))
(and (fn? event)
(pos? (count (.-name event))))
(str "fn: " (demunge (.-name event)))
:else
(str "unk: " (pr-str event))))
(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)))))))
(defn emit!
([] nil)
([event]
(ptk/emit! store event)
nil)
([event & events]
(apply ptk/emit! store (cons event events))
nil))
(def initial-state
{:session-id (uuid/next)
:profile (:profile storage)})
(defn init
"Initialize the state materialization."
([] (init {}))
([props]
(emit! #(merge % initial-state props))
(rx/to-atom store state)))
(defn ^:export dump-state []
(logjs "state" @state))
(defn ^:export dump-objects []
(let [page-id (get @state :current-page-id)]
(logjs "state" (get-in @state [:workspace-data page-id :objects]))))

View file

@ -0,0 +1,126 @@
;; 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 <niwi@niwi.nz>
(ns app.main.streams
"User interaction events and streams."
(:require
[beicon.core :as rx]
[app.main.store :as st]
[app.main.refs :as refs]
[app.common.geom.point :as gpt]))
;; --- User Events
(defrecord KeyboardEvent [type key shift ctrl alt])
(defn keyboard-event?
[v]
(instance? KeyboardEvent v))
(defrecord MouseEvent [type ctrl shift alt])
(defn mouse-event?
[v]
(instance? MouseEvent v))
(defn mouse-up?
[v]
(and (mouse-event? v)
(= :up (:type v))))
(defn mouse-click?
[v]
(and (mouse-event? v)
(= :click (:type v))))
(defrecord PointerEvent [source pt ctrl shift alt])
(defn pointer-event?
[v]
(instance? PointerEvent v))
(defrecord ScrollEvent [point])
(defn scroll-event?
[v]
(instance? ScrollEvent v))
(defn interaction-event?
[event]
(or (keyboard-event? event)
(mouse-event? event)))
;; --- Derived streams
(defonce mouse-position
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter pointer-event?)
(rx/filter #(= :viewport (:source %)))
(rx/map :pt))]
(rx/subscribe-with ob sub)
sub))
(defonce mouse-position-ctrl
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter pointer-event?)
(rx/map :ctrl)
(rx/dedupe))]
(rx/subscribe-with ob sub)
sub))
(defonce mouse-position-shift
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter pointer-event?)
(rx/map :shift)
(rx/dedupe))]
(rx/subscribe-with ob sub)
sub))
(defonce mouse-position-alt
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter pointer-event?)
(rx/map :alt)
(rx/dedupe))]
(rx/subscribe-with ob sub)
sub))
(defonce keyboard-alt
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter keyboard-event?)
(rx/map :alt)
(rx/dedupe))]
(rx/subscribe-with ob sub)
sub))
(defn mouse-position-deltas
[current]
(->> (rx/concat (rx/of current)
(rx/sample 10 mouse-position))
(rx/buffer 2 1)
(rx/map (fn [[old new]]
(gpt/subtract new old)))))
(defonce mouse-position-delta
(let [sub (rx/behavior-subject nil)
ob (->> st/stream
(rx/filter pointer-event?)
(rx/filter #(= :delta (:source %)))
(rx/map :pt))]
(rx/subscribe-with ob sub)
sub))
(defonce viewport-scroll
(let [sub (rx/behavior-subject nil)
sob (->> (rx/filter scroll-event? st/stream)
(rx/map :point))]
(rx/subscribe-with sob sub)
sub))

View file

@ -0,0 +1,185 @@
;; 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
(:require
[beicon.core :as rx]
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.main.data.auth :refer [logout]]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.auth :refer [auth verify-token]]
[app.main.ui.dashboard :refer [dashboard]]
[app.main.ui.icons :as i]
[app.main.ui.cursors :as c]
[app.main.ui.messages :as msgs]
[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.render :as render]
[app.main.ui.workspace :as workspace]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.timers :as ts]))
;; --- Routes
(def routes
[["/auth"
["/login" :auth-login]
["/register" :auth-register]
["/recovery/request" :auth-recovery-request]
["/recovery" :auth-recovery]
["/verify-token" :auth-verify-token]
["/goodbye" :auth-goodbye]]
["/settings"
["/profile" :settings-profile]
["/password" :settings-password]
["/options" :settings-options]]
["/view/:page-id" :viewer]
["/not-found" :not-found]
["/not-authorized" :not-authorized]
(when *assert*
["/debug/icons-preview" :debug-icons-preview])
;; Used for export
["/render-object/:page-id/:object-id" :render-object]
["/dashboard"
["/team/:team-id"
["/" :dashboard-team]
["/search" :dashboard-search]
["/project/:project-id" :dashboard-project]
["/libraries" :dashboard-libraries]]]
["/workspace/:project-id/:file-id" :workspace]])
(mf/defc app-error
[{:keys [error] :as props}]
(let [data (ex-data error)]
(case (:type data)
:not-found [:& not-found-page {:error data}]
[:span "Internal application errror"])))
(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}]
(:settings-profile
:settings-password
:settings-options)
[:& settings/settings {:route route}]
:debug-icons-preview
(when *assert*
[:div.debug-preview
[:h1 "Cursors"]
[:& c/debug-preview]
[:h1 "Icons"]
[:& i/debug-icons-preview]
])
(:dashboard-search
:dashboard-team
:dashboard-project
:dashboard-libraries)
[:& dashboard {:route route}]
:viewer
(let [index (d/parse-integer (get-in route [:params :query :index]))
token (get-in route [:params :query :token])
page-id (uuid (get-in route [:params :path :page-id]))]
[:& viewer-page {:page-id page-id
:index index
:token token}])
:render-object
(do
(let [page-id (uuid (get-in route [:params :path :page-id]))
object-id (uuid (get-in route [:params :path :object-id]))]
[:& render/render-object {:page-id page-id
:object-id object-id}]))
: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]))]
[:& workspace/workspace {:project-id project-id
:file-id file-id
:page-id page-id
:key file-id}])
:not-authorized
[:& not-authorized-page]
:not-found
[:& not-found-page]
nil))
(mf/defc app-wrapper
[]
(let [route (mf/deref refs/route)]
[:*
[:& msgs/notifications]
(when route
[:& app {:route route}])]))
;; --- Error Handling
(defn- on-error
"A default error handler."
[{:keys [type code] :as error}]
(reset! st/loader false)
(cond
(and (map? error)
(= :validation type)
(= :spec-validation code))
(do
(println "============ SERVER RESPONSE ERROR ================")
(println (:explain error))
(println "============ END SERVER RESPONSE ERROR ================"))
;; Unauthorized or Auth timeout
(and (map? error)
(= :authentication type)
(= :unauthorized code))
(ts/schedule 0 #(st/emit! logout))
;; Network error
(and (map? error)
(= :unexpected type)
(= :abort code))
(ts/schedule 100 #(st/emit! (dm/error (tr "errors.network"))))
;; Something else
:else
(do
(js/console.error error)
(ts/schedule 100 #(st/emit! (dm/error (tr "errors.generic")))))))
(set! st/*on-error* on-error)

View file

@ -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.auth
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.auth :as da]
[app.main.data.users :as du]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-page]]
[app.main.ui.auth.recovery :refer [recovery-page]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page]]
[app.main.ui.auth.register :refer [register-page]]
[app.main.repo :as rp]
[app.util.timers :as ts]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]))
(mf/defc goodbye-page
[{:keys [locale] :as props}]
[:div.goodbay
[:h1 (t locale "auth.goodbye-title")]])
(mf/defc auth
[{:keys [route] :as props}]
(let [section (get-in route [:data :name])
locale (mf/deref i18n/locale)]
[:div.auth
[:section.auth-sidebar
[:a.logo {:href "/#/"} i/logo]
[:span.tagline (t locale "auth.sidebar-tagline")]]
[:section.auth-content
(case section
:auth-register [:& register-page {:locale locale}]
:auth-login [:& login-page {:locale locale}]
:auth-goodbye [:& goodbye-page {:locale locale}]
:auth-recovery-request [:& recovery-request-page {:locale locale}]
:auth-recovery [:& recovery-page {:locale locale
:params (:query-params route)}])]]))
(defn- handle-email-verified
[data]
(let [msg (tr "settings.notifications.email-verified-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :settings-profile)
du/fetch-profile)))
(defn- handle-email-changed
[data]
(let [msg (tr "settings.notifications.email-changed-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :settings-profile)
du/fetch-profile)))
(defn- handle-authentication
[tdata]
(st/emit! (da/login-from-token tdata)))
(mf/defc verify-token
[{:keys [route] :as props}]
(let [token (get-in route [:query-params :token])]
(mf/use-effect
(fn []
(->> (rp/mutation :verify-profile-token {:token token})
(rx/subs
(fn [tdata]
(case (:type tdata)
:verify-email (handle-email-verified tdata)
:change-email (handle-email-changed tdata)
:authentication (handle-authentication tdata)
nil))
(fn [error]
(case (:code error)
:app.services.mutations.profile/email-already-exists
(let [msg (tr "errors.email-already-exists")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :settings-profile)))
(let [msg (tr "errors.generic")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :settings-profile)))))))))
[:div.verify-token
i/loader-pencil]))

View file

@ -0,0 +1,121 @@
;; 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.auth.login
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.config :as cfg]
[app.common.spec :as us]
[app.main.ui.icons :as i]
[app.main.data.auth :as da]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.messages :as msgs]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.util.object :as obj]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr t]]
[app.util.router :as rt]))
(s/def ::email ::us/email)
(s/def ::password ::us/not-empty-string)
(s/def ::login-form
(s/keys :req-un [::email ::password]))
(defn- login-with-google
[event]
(dom/prevent-default event)
(->> (rp/mutation! :login-with-google {})
(rx/subs (fn [{:keys [redirect-uri] :as rsp}]
(.replace js/location redirect-uri)))))
(mf/defc login-form
[{:keys [locale] :as props}]
(let [error? (mf/use-state false)
submit-event (mf/use-var da/login)
on-error
(fn [form event]
(reset! error? true))
on-submit
(fn [form event]
(reset! error? false)
(let [params (with-meta (:clean-data form)
{:on-error on-error})]
(st/emit! (@submit-event params))))]
[:*
(when @error?
[:& msgs/inline-banner
{:type :warning
:content (t locale "errors.auth.unauthorized")
:on-close #(reset! error? false)}])
[:& form {:on-submit on-submit
:spec ::login-form
:initial {}}
[:& input
{:name :email
:type "text"
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]
[:& input
{:type "password"
:name :password
:tab-index "3"
:help-icon i/eye
:label (t locale "auth.password-label")}]
[:& submit-button
{:label (t locale "auth.login-submit-label")
:on-click #(reset! submit-event da/login)}]
(when cfg/login-with-ldap
[:& submit-button
{:label (t locale "auth.login-with-ldap-submit-label")
:on-click #(reset! submit-event da/login-with-ldap)}])]]))
(mf/defc login-page
[{:keys [locale] :as props}]
[:div.generic-form.login-form
[:div.form-container
[:h1 (t locale "auth.login-title")]
[:div.subtitle (t locale "auth.login-subtitle")]
[:& login-form {:locale locale}]
[:div.links
[:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-recovery-request))
:tab-index "5"}
(t locale "auth.forgot-password")]]
[:div.link-entry
[:span (t locale "auth.register-label") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-register))
:tab-index "6"}
(t locale "auth.register")]]]
(when cfg/google-client-id
[:a.btn-ocean.btn-large.btn-google-auth
{:on-click login-with-google}
"Login with Google"])
[:div.links.demo
[:div.link-entry
[:span (t locale "auth.create-demo-profile-label") " "]
[:a {:on-click #(st/emit! da/create-demo-profile)
:tab-index "6"}
(t locale "auth.create-demo-profile")]]]]])

View file

@ -0,0 +1,97 @@
;; 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.auth.recovery
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.common.spec :as us]
[app.main.data.auth :as uda]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.navigation :as nav]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]))
(s/def ::password-1 ::fm/not-empty-string)
(s/def ::password-2 ::fm/not-empty-string)
(s/def ::token ::fm/not-empty-string)
(s/def ::recovery-form
(s/keys :req-un [::password-1
::password-2]))
(defn- password-equality
[data]
(let [password-1 (:password-1 data)
password-2 (:password-2 data)]
(cond-> {}
(and password-1 password-2
(not= password-1 password-2))
(assoc :password-2 {:message "errors.password-invalid-confirmation"})
(and password-1 (> 8 (count password-1)))
(assoc :password-1 {:message "errors.password-too-short"}))))
(defn- on-error
[form error]
(st/emit! (dm/error (tr "auth.notifications.invalid-token-error"))))
(defn- on-success
[_]
(st/emit! (dm/info (tr "auth.notifications.password-changed-succesfully"))
(rt/nav :auth-login)))
(defn- on-submit
[form event]
(let [params (with-meta {:token (get-in form [:clean-data :token])
:password (get-in form [:clean-data :password-2])}
{:on-error (partial on-error form)
:on-success (partial on-success form)})]
(st/emit! (uda/recover-profile params))))
(mf/defc recovery-form
[{:keys [locale params] :as props}]
[:& form {:on-submit on-submit
:spec ::recovery-form
:validators [password-equality]
:initial params}
[:& input {:type "password"
:name :password-1
:label (t locale "auth.new-password-label")}]
[:& input {:type "password"
:name :password-2
:label (t locale "auth.confirm-password-label")}]
[:& submit-button
{:label (t locale "auth.recovery-submit-label")}]])
;; --- Recovery Request Page
(mf/defc recovery-page
[{:keys [locale params] :as props}]
[:section.generic-form
[:div.form-container
[:h1 "Forgot your password?"]
[:div.subtitle "Please enter your new password"]
[:& recovery-form {:locale locale :params params}]
[:div.links
[:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-login))}
(t locale "profile.recovery.go-to-login")]]]]])

View file

@ -0,0 +1,67 @@
;; 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.auth.recovery-request
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.common.spec :as us]
[app.main.data.auth :as uda]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.icons :as i]
[app.main.ui.navigation :as nav]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]))
(s/def ::email ::us/email)
(s/def ::recovery-request-form (s/keys :req-un [::email]))
(defn- on-submit
[form event]
(let [on-success #(st/emit!
(dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login))
params (with-meta (:clean-data form)
{:on-success on-success})]
(st/emit! (uda/request-profile-recovery params))))
(mf/defc recovery-form
[{:keys [locale] :as props}]
[:& form {:on-submit on-submit
:spec ::recovery-request-form
:initial {}}
[:& input {:name :email
:label (t locale "auth.email-label")
:help-icon i/at
:type "text"}]
[:& submit-button
{:label (t locale "auth.recovery-request-submit-label")}]])
;; --- Recovery Request Page
(mf/defc recovery-request-page
[{:keys [locale] :as props}]
[:section.generic-form
[:div.form-container
[:h1 (t locale "auth.recovery-request-title")]
[:div.subtitle (t locale "auth.recovery-request-subtitle")]
[:& recovery-form {:locale locale}]
[:div.links
[:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-login))}
(t locale "auth.go-back-to-login")]]]]])

View file

@ -0,0 +1,116 @@
;; 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.auth.register
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.config :as cfg]
[app.main.ui.icons :as i]
[app.main.data.auth :as uda]
[app.main.store :as st]
[app.main.data.auth :as da]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.navigation :as nav]
[app.main.ui.messages :as msgs]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr t]]
[app.util.router :as rt]))
(mf/defc demo-warning
[_]
[:& msgs/inline-banner
{:type :warning
:content (tr "auth.demo-warning")}])
(s/def ::fullname ::fm/not-empty-string)
(s/def ::password ::fm/not-empty-string)
(s/def ::email ::fm/email)
(s/def ::register-form
(s/keys :req-un [::password
::fullname
::email]))
(defn- on-error
[form error]
(case (:code error)
:app.services.mutations.profile/registration-disabled
(st/emit! (tr "errors.registration-disabled"))
:app.services.mutations.profile/email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(st/emit! (tr "errors.unexpected-error"))))
(defn- validate
[data]
(let [password (:password data)]
(when (> 8 (count password))
{:password {:message "errors.password-too-short"}})))
(defn- on-submit
[form event]
(let [data (with-meta (:clean-data form)
{:on-error (partial on-error form)})]
(st/emit! (uda/register data))))
(mf/defc register-form
[{:keys [locale] :as props}]
[:& form {:on-submit on-submit
:spec ::register-form
:validators [validate]
:initial {}}
[:& input {:name :fullname
:tab-index "1"
:label (t locale "auth.fullname-label")
:type "text"}]
[:& input {:type "email"
:name :email
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]
[:& input {:name :password
:tab-index "3"
:hint (t locale "auth.password-length-hint")
:label (t locale "auth.password-label")
:type "password"}]
[:& submit-button
{:label (t locale "auth.register-submit-label")}]])
;; --- Register Page
(mf/defc register-page
[{:keys [locale] :as props}]
[:section.generic-form
[:div.form-container
[:h1 (t locale "auth.register-title")]
[:div.subtitle (t locale "auth.register-subtitle")]
(when cfg/demo-warning
[:& demo-warning])
[:& register-form {:locale locale}]
[:div.links
[:div.link-entry
[:span (t locale "auth.already-have-account") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-login))
:tab-index "4"}
(t locale "auth.login-here")]]
[:div.link-entry
[:span (t locale "auth.create-demo-profile-label") " "]
[:a {:on-click #(st/emit! da/create-demo-profile)
:tab-index "5"}
(t locale "auth.create-demo-profile")]]]]])

View file

@ -0,0 +1,44 @@
;; 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 <niwi@niwi.nz>
(ns app.main.ui.colorpicker
(:require
[okulary.core :as l]
[app.main.store :as st]
[goog.object :as gobj]
[rumext.alpha :as mf]
[app.util.color :refer [hex->rgb]]
["react-color/lib/components/chrome/Chrome" :as pickerskin]))
(mf/defc colorpicker
[{:keys [on-change value opacity colors disable-opacity] :as props}]
(let [hex-value (mf/use-state (or value "#FFFFFF"))
alpha-value (mf/use-state (or opacity 1))
[r g b] (hex->rgb @hex-value)
on-change-complete #(let [hex (gobj/get % "hex")
opacity (-> % (gobj/get "rgb") (gobj/get "a"))]
(reset! hex-value hex)
(reset! alpha-value opacity)
(on-change hex opacity))]
[:> pickerskin/default {:color #js { :r r :g g :b b :a @alpha-value}
:presetColors colors
:onChange on-change-complete
:disableAlpha disable-opacity
:styles {:default {:picker {:padding "10px"}}}}]))
(def most-used-colors
(letfn [(selector [{:keys [objects]}]
(as-> {} $
(reduce (fn [acc shape]
(-> acc
(update (:fill-color shape) (fnil inc 0))
(update (:stroke-color shape) (fnil inc 0))))
$ (vals objects))
(reverse (sort-by second $))
(map first $)
(remove nil? $)))]
(l/derived selector st/state)))

View file

@ -0,0 +1,42 @@
;; 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.context-menu
(:require
[rumext.alpha :as mf]
[goog.object :as gobj]
[app.main.ui.components.dropdown :refer [dropdown']]
[app.common.uuid :as uuid]
[app.util.data :refer [classnames]]))
(mf/defc context-menu
{::mf/wrap-props false}
[props]
(assert (fn? (gobj/get props "on-close")) "missing `on-close` prop")
(assert (boolean? (gobj/get props "show")) "missing `show` prop")
(assert (vector? (gobj/get props "options")) "missing `options` prop")
(let [open? (gobj/get props "show")
options (gobj/get props "options")
is-selectable (gobj/get props "selectable")
selected (gobj/get props "selected")
top (gobj/get props "top")
left (gobj/get props "left")]
(when open?
[:> dropdown' props
[:div.context-menu {:class (classnames :is-open open?
:is-selectable is-selectable)
:style {:top top
:left left}}
[:ul.context-menu-items
(for [[action-name action-handler] options]
[:li.context-menu-item {:class (classnames :is-selected (and selected (= action-name selected)))
:key action-name}
[:a.context-menu-action {:on-click action-handler}
action-name]])]]])))

View file

@ -0,0 +1,50 @@
(ns app.main.ui.components.dropdown
(:require
[rumext.alpha :as mf]
[app.common.uuid :as uuid]
[app.util.dom :as dom]
[goog.events :as events]
[goog.object :as gobj])
(:import goog.events.EventType
goog.events.KeyCodes))
(mf/defc dropdown'
{::mf/wrap-props false}
[props]
(let [children (gobj/get props "children")
on-close (gobj/get props "on-close")
ref (gobj/get props "container")
on-click
(fn [event]
(if ref
(let [target (dom/get-target event)
parent (mf/ref-val ref)]
(when-not (.contains parent target)
(on-close)))
(on-close)))
on-keyup
(fn [event]
(when (= (.-keyCode event) 27) ; ESC
(on-close)))
on-mount
(fn []
(let [lkey1 (events/listen js/document EventType.CLICK on-click)
lkey2 (events/listen js/document EventType.KEYUP on-keyup)]
#(do
(events/unlistenByKey lkey1)
(events/unlistenByKey lkey2))))]
(mf/use-effect on-mount)
children))
(mf/defc dropdown
{::mf/wrap-props false}
[props]
(assert (fn? (gobj/get props "on-close")) "missing `on-close` prop")
(assert (boolean? (gobj/get props "show")) "missing `show` prop")
(when (gobj/get props "show")
(mf/element dropdown' props)))

View file

@ -0,0 +1,51 @@
;; 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.editable-label
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[app.util.timers :as timers]
[app.util.data :refer [classnames]]))
(mf/defc editable-label
[{:keys [ value on-change on-cancel edit readonly class-name]}]
(let [input (mf/use-ref nil)
state (mf/use-state (:editing false))
is-editing (or edit (:editing @state))
start-editing (fn []
(swap! state assoc :editing true)
(timers/schedule 100 #(dom/focus! (mf/ref-val input))))
stop-editing (fn [] (swap! state assoc :editing false))
cancel-editing (fn []
(stop-editing)
(when on-cancel (on-cancel)))
on-dbl-click (fn [e] (when (not readonly) (start-editing)))
on-key-up (fn [e]
(cond
(kbd/esc? e)
(cancel-editing)
(kbd/enter? e)
(let [value (-> e dom/get-target dom/get-value)]
(on-change value)
(stop-editing))))
]
(if is-editing
[:div.editable-label {:class class-name}
[:input.editable-label-input {:ref input
:default-value value
:on-key-down on-key-up}]
[:span.editable-label-close {:on-click cancel-editing} i/close]]
[:span.editable-label {:class class-name
:on-double-click on-dbl-click} value]
)))

View file

@ -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.main.ui.components.editable-select
(:require
[rumext.alpha :as mf]
[app.common.uuid :as uuid]
[app.common.data :as d]
[app.util.dom :as dom]
[app.main.ui.icons :as i]
[app.main.ui.components.dropdown :refer [dropdown]]))
(mf/defc editable-select [{:keys [value type options class on-change placeholder]}]
(let [state (mf/use-state {:id (uuid/next)
:is-open? false
:current-value value})
open-dropdown #(swap! state assoc :is-open? true)
close-dropdown #(swap! state assoc :is-open? false)
select-item (fn [value]
(fn [event]
(swap! state assoc :current-value value)
(when on-change (on-change value))))
as-key-value (fn [item] (if (map? item) [(:value item) (:label item)] [item item]))
labels-map (into {} (->> options (map as-key-value)))
value->label (fn [value] (get labels-map value value))
handle-change-input (fn [event]
(let [value (-> event dom/get-target dom/get-value)
value (or (d/parse-integer value) value)]
(swap! state assoc :current-value value)
(when on-change (on-change value))))]
(mf/use-effect
(mf/deps value)
#(reset! state {:current-value value}))
(mf/use-effect
(mf/deps options)
#(reset! state {:is-open? false
:current-value value}))
[:div.editable-select {:class class}
[:input.input-text {:value (or (-> @state :current-value value->label) "")
:on-change handle-change-input
:placeholder placeholder
:type type}]
[:span.dropdown-button {:on-click open-dropdown} i/arrow-down]
[:& dropdown {:show (get @state :is-open? false)
:on-close close-dropdown}
[:ul.custom-select-dropdown
(for [[index item] (map-indexed vector options)]
(cond
(= :separator item) [:hr {:key (str (:id @state) "-" index)}]
:else (let [[value label] (as-key-value item)]
[:li.checked-element
{:key (str (:id @state) "-" index)
:class (when (= value (-> @state :current-value)) "is-selected")
:on-click (select-item value)}
[:span.check-icon i/tick]
[:span label]])))]]]))

View file

@ -0,0 +1,41 @@
;; 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.file-uploader
(:require
[rumext.alpha :as mf]
[app.main.data.workspace :as dw]
[app.main.store :as st]
[app.util.dom :as dom]))
(mf/defc file-uploader
[{:keys [accept multi label-text label-class input-id input-ref on-selected] :as props}]
(let [opt-pick-one #(if multi % (first %))
on-files-selected (fn [event]
(let [target (dom/get-target event)]
(st/emit!
(some-> target
(dom/get-files)
(opt-pick-one)
(on-selected)))
(dom/clean-value! target)))]
[:*
(when label-text
[:label {:for input-id :class-name label-class} label-text])
[:input
{:style {:display "none"}
:id input-id
:multiple multi
:accept accept
:type "file"
:ref input-ref
:on-change on-files-selected}]]))

View file

@ -0,0 +1,155 @@
;; 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.forms
(:require
[rumext.alpha :as mf]
[cuerdas.core :as str]
[app.common.data :as d]
[app.main.ui.icons :as i]
[app.util.object :as obj]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t]]
["react" :as react]
[app.util.dom :as dom]))
(def form-ctx (mf/create-context nil))
(mf/defc input
[{:keys [type label help-icon disabled name form hint trim] :as props}]
(let [form (mf/use-ctx form-ctx)
type' (mf/use-state type)
focus? (mf/use-state false)
locale (mf/deref i18n/locale)
touched? (get-in form [:touched name])
error (get-in form [:errors name])
value (get-in form [:data name] "")
help-icon' (cond
(and (= type "password")
(= @type' "password"))
i/eye
(and (= type "password")
(= @type' "text"))
i/eye-closed
:else
help-icon)
klass (dom/classnames
:focus @focus?
:valid (and touched? (not error))
:invalid (and touched? error)
:disabled disabled
:empty (str/empty? value)
:with-icon (not (nil? help-icon')))
swap-text-password
(fn []
(swap! type' (fn [type]
(if (= "password" type)
"text"
"password"))))
on-focus #(reset! focus? true)
on-change (fm/on-input-change form name trim)
on-blur
(fn [event]
(reset! focus? false)
(when-not (get-in form [:touched name])
(swap! form assoc-in [:touched name] true)))
props (-> props
(dissoc :help-icon :form :trim)
(assoc :value value
:on-focus on-focus
:on-blur on-blur
:placeholder label
:on-change on-change
:type @type')
(obj/clj->props))]
[:div.field.custom-input
{:class klass}
[:*
[:label label]
[:> :input props]
(when help-icon'
[:div.help-icon
{:style {:cursor "pointer"}
:on-click (when (= "password" type)
swap-text-password)}
help-icon'])
(cond
(and touched? (:message error))
[:span.error (t locale (:message error))]
(string? hint)
[:span.hint hint])]]))
(mf/defc select
[{:keys [options label name form default]
:or {default ""}}]
(let [form (mf/use-ctx form-ctx)
value (get-in form [:data name] default)
cvalue (d/seek #(= value (:value %)) options)
on-change (fm/on-input-change form name)]
[:div.field.custom-select
[:select {:value value
:on-change on-change}
(for [item options]
[:option {:key (:value item) :value (:value item)} (:label item)])]
[:div.input-container
[:div.main-content
[:label label]
[:span.value (:label cvalue "")]]
[:div.icon
i/arrow-slide]]]))
(mf/defc submit-button
[{:keys [label form on-click] :as props}]
(let [form (mf/use-ctx form-ctx)]
[:input.btn-primary.btn-large
{:name "submit"
:class (when-not (:valid form) "btn-disabled")
:disabled (not (:valid form))
:on-click on-click
:value label
:type "submit"}]))
(mf/defc form
[{:keys [on-submit spec validators initial children class] :as props}]
(let [frm (fm/use-form :spec spec
:validators validators
:initial initial)]
(mf/use-effect
(mf/deps initial)
(fn []
(if (fn? initial)
(swap! frm update :data merge (initial))
(swap! frm update :data merge initial))))
[:& (mf/provider form-ctx) {:value frm}
[:form {:class class
:on-submit (fn [event]
(dom/prevent-default event)
(on-submit frm event))}
children]]))

View file

@ -0,0 +1,51 @@
;; 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.select
(:require
[rumext.alpha :as mf]
[app.common.uuid :as uuid]
[app.main.ui.icons :as i]
[app.main.ui.components.dropdown :refer [dropdown]]))
(mf/defc select [{:keys [default-value options class on-change]}]
(let [state (mf/use-state {:id (uuid/next)
:is-open? false
:current-value default-value})
open-dropdown #(swap! state assoc :is-open? true)
close-dropdown #(swap! state assoc :is-open? false)
select-item (fn [value] (fn [event]
(swap! state assoc :current-value value)
(when on-change (on-change value))))
as-key-value (fn [item] (if (map? item) [(:value item) (:label item)] [item item]))
value->label (into {} (->> options
(map as-key-value))) ]
(mf/use-effect
(mf/deps options)
#(reset! state {:is-open? false
:current-value default-value}))
[:div.custom-select {:on-click open-dropdown
:class class}
[:span (-> @state :current-value value->label)]
[:span.dropdown-button i/arrow-down]
[:& dropdown {:show (:is-open? @state)
:on-close close-dropdown}
[:ul.custom-select-dropdown
(for [[index item] (map-indexed vector options)]
(cond
(= :separator item) [:hr {:key (str (:id @state) "-" index)}]
:else (let [[value label] (as-key-value item)]
[:li.checked-element
{:key (str (:id @state) "-" index)
:class (when (= value (-> @state :current-value)) "is-selected")
:on-click (select-item value)}
[:span.check-icon i/tick]
[:span label]])))]]]))

View file

@ -0,0 +1,36 @@
;; 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.tab-container
(:require [rumext.alpha :as mf]))
(mf/defc tab-element
[{:keys [children id title]}]
[:div.tab-element
[:div.tab-element-content children]])
(mf/defc tab-container
[{:keys [children selected on-change-tab]}]
(let [first-id (-> children first .-props .-id)
state (mf/use-state {:selected first-id})
selected (or selected (:selected @state))
handle-select (fn [tab]
(let [id (-> tab .-props .-id)]
(swap! state assoc :selected id)
(when on-change-tab (on-change-tab id))))]
[:div.tab-container
[:div.tab-container-tabs
(for [tab children]
[:div.tab-container-tab-title
{:key (str "tab-" (-> tab .-props .-id))
:on-click (partial handle-select tab)
:class (when (= selected (-> tab .-props .-id)) "current")}
(-> tab .-props .-title)])]
[:div.tab-container-content
(filter #(= selected (-> % .-props .-id)) children)]]))

View file

@ -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/.
;;
;; Copyright (c) 2016 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2016 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.confirm
(:require
[app.main.ui.icons :as i]
[rumext.alpha :as mf]
[app.main.ui.modal :as modal]
[app.util.i18n :refer (tr)]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]))
(mf/defc confirm-dialog
[{:keys [message on-accept on-cancel hint cancel-text accept-text not-danger?] :as ctx}]
(let [message (or message (tr "ds.confirm-title"))
cancel-text (or cancel-text (tr "ds.confirm-cancel"))
accept-text (or accept-text (tr "ds.confirm-ok"))
accept
(fn [event]
(dom/prevent-default event)
(modal/hide!)
(on-accept (dissoc ctx :on-accept :on-cancel)))
cancel
(fn [event]
(dom/prevent-default event)
(modal/hide!)
(when on-cancel
(on-cancel (dissoc ctx :on-accept :on-cancel))))]
[:div.modal-overlay
[:div.modal.confirm-dialog
[:a.close {:on-click cancel} i/close]
[:div.modal-content
[:h3.dialog-title message]
(if hint [:span hint])
[:div.dialog-buttons
[:input.dialog-cancel-button
{:type "button"
:value cancel-text
:on-click cancel}]
[:input.dialog-accept-button
{:type "button"
:class (classnames :not-danger not-danger?)
:value accept-text
:on-click accept}]]]]]))

View file

@ -0,0 +1,77 @@
;; 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.cursors
(:import java.net.URLEncoder)
(:require [rumext.alpha]
[clojure.java.io :as io]
[lambdaisland.uri.normalize :as uri]
[cuerdas.core :as str]))
(def cursor-folder "images/cursors")
(def default-hotspot-x 12)
(def default-hotspot-y 12)
(def default-rotation 0)
(defn parse-svg [svg-data]
(-> svg-data
;; Remove the <?xml ?> header
(str/replace #"(?i)<\?xml[^\?]*\?>", "")
;; Remove comments
(str/replace #"<\!\-\-(.*?(?=\-\->))\-\->" "")
;; Remofe end of line
(str/replace #"\r?\n|\r" " ")
;; Replace double quotes for single
(str/replace #"\"" "'")
;; Remove the svg root tag
(str/replace #"(?i)<svg.*?>" "")
;; And the closing tag
(str/replace #"(?i)<\/svg>" "")
;; Remove some defs that can be redundant
(str/replace #"<defs.*?/>" "")
;; Unifies the spaces into single space
(str/replace #"\s+" " ")
;; Remove spaces at the beginning of the svg
(str/replace #"^\s+" "")
;; Remove spaces at the end
(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))
(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)))
(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)]
`(fn [rot#]
(str/replace ~cursor "{{rotation}}" (+ ~initial rot#)))))

View file

@ -0,0 +1,55 @@
;; 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.cursors
(: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]))
(def create-artboard (cursor-ref :create-artboard))
(def create-ellipse (cursor-ref :create-ellipse))
(def create-polygon (cursor-ref :create-polygon))
(def create-rectangle (cursor-ref :create-reclangle))
(def create-shape (cursor-ref :create-shape))
(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))
(mf/defc debug-preview
{::mf/wrap-props false}
[props]
(let [rotation (mf/use-state 0)]
(mf/use-effect (fn [] (ts/interval 100 #(reset! rotation inc))))
[:section.debug-icons-preview
(for [[key val] (sort-by first (ns-publics 'app.main.ui.cursors))]
(when (not= key 'debug-icons-preview)
(let [value (deref val)
value (if (fn? value) (value @rotation) value)]
[:div.cursor-item {:key key}
[:div {:style {:width "100px"
:height "100px"
:background-image (-> value (str/replace #"(url\(.*\)).*" "$1"))
:background-size "cover"
:cursor value}}]
[:span {:style {:white-space "nowrap"
:margin-right "1rem"}} (pr-str key)]])))]))

View file

@ -0,0 +1,99 @@
;; 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
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.common.spec :as us]
[app.main.store :as st]
[app.main.refs :as refs]
[app.main.ui.dashboard.sidebar :refer [sidebar]]
[app.main.ui.dashboard.search :refer [search-page]]
[app.main.ui.dashboard.project :refer [project-page]]
[app.main.ui.dashboard.recent-files :refer [recent-files-page]]
[app.main.ui.dashboard.libraries :refer [libraries-page]]
[app.main.ui.dashboard.profile :refer [profile-section]]
[app.util.router :as rt]
[app.util.i18n :as i18n :refer [t]]))
(defn ^boolean uuid-str?
[s]
(and (string? s)
(boolean (re-seq us/uuid-rx s))))
(defn- parse-params
[route profile]
(let [search-term (get-in route [:params :query :search-term])
route-name (get-in route [:data :name])
team-id (get-in route [:params :path :team-id])
project-id (get-in route [:params :path :project-id])]
(cond->
{:search-term search-term}
(uuid-str? team-id)
(assoc :team-id (uuid team-id))
(uuid-str? project-id)
(assoc :project-id (uuid project-id))
(= "drafts" project-id)
(assoc :project-id (:default-project-id profile)))))
(declare global-notifications)
(mf/defc dashboard
[{:keys [route] :as props}]
(let [profile (mf/deref refs/profile)
page (get-in route [:data :name])
{:keys [search-term team-id project-id] :as params}
(parse-params route profile)]
[:*
[:& global-notifications {:profile profile}]
[:section.dashboard-layout
[:div.main-logo
[:a {:on-click #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))}
i/logo-icon]]
[:& profile-section {:profile profile}]
[:& sidebar {:team-id team-id
:project-id project-id
:section page
:search-term search-term}]
[:div.dashboard-content
(case page
:dashboard-search
[:& search-page {:team-id team-id :search-term search-term}]
:dashboard-team
[:& recent-files-page {:team-id team-id}]
:dashboard-libraries
[:& libraries-page {:team-id team-id}]
:dashboard-project
[:& project-page {:team-id team-id
:project-id project-id}])]]]))
(mf/defc global-notifications
[{:keys [profile] :as props}]
(let [locale (mf/deref i18n/locale)]
(when (and profile
(not= uuid/zero (:id profile))
(= (:pending-email profile)
(:email profile)))
[:section.banner.error.quick
[:div.content
[:div.icon i/msg-warning]
[:span (t locale "settings.notifications.email-not-verified" (:email profile))]]])))

View file

@ -0,0 +1,58 @@
;; 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 <niwi@niwi.nz>
(ns app.main.ui.dashboard.common
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as k]
[app.util.dom :as dom]
[app.util.i18n :as t :refer [tr]]))
;; --- Page Title
(mf/defc grid-header
[{:keys [on-change on-delete value read-only?] :as props}]
(let [edit? (mf/use-state false)
input (mf/use-ref nil)]
(letfn [(save []
(let [new-value (-> (mf/ref-val input)
(dom/get-inner-text)
(str/trim))]
(on-change new-value)
(reset! edit? false)))
(cancel []
(reset! edit? false))
(edit []
(reset! edit? true))
(on-input-keydown [e]
(cond
(k/esc? e) (cancel)
(k/enter? e)
(do
(dom/prevent-default e)
(dom/stop-propagation e)
(save))))]
[:div.dashboard-title
[:h2
(if @edit?
[:div.dashboard-title-field
[:span.edit {:content-editable true
:ref input
:on-key-down on-input-keydown
:dangerouslySetInnerHTML {"__html" value}}]
[:span.close {:on-click cancel} i/close]]
(if-not read-only?
[:span.dashboard-title-field {:on-double-click edit} value]
[:span.dashboard-title-field value]))]
(when-not read-only?
[:div.edition
(if @edit?
[:span {:on-click save} i/save]
[:span {:on-click edit} i/pencil])
[:span {:on-click on-delete} i/trash]])])))

View file

@ -0,0 +1,154 @@
(ns app.main.ui.dashboard.grid
(:require
[cuerdas.core :as str]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.dashboard :as dsh]
[app.main.store :as st]
[app.main.ui.modal :as modal]
[app.main.ui.keyboard :as kbd]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.worker :as wrk]
[app.main.fonts :as fonts]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.timers :as ts]
[app.util.time :as dt]))
;; --- Grid Item Thumbnail
(mf/defc grid-item-thumbnail
{::mf/wrap [mf/memo]}
[{:keys [file] :as props}]
(let [container (mf/use-ref)]
(mf/use-effect
(mf/deps file)
(fn []
(-> (wrk/ask! {:cmd :thumbnails/generate
:id (first (:pages file))
})
(rx/subscribe (fn [{:keys [svg fonts]}]
(run! fonts/ensure-loaded! fonts)
(when-let [node (mf/ref-val container)]
(set! (.-innerHTML ^js node) svg)))))))
[:div.grid-item-th {:style {:background-color (get-in file [:data :options :background])}
:ref container}]))
;; --- Grid Item
(mf/defc grid-item-metadata
[{:keys [modified-at]}]
(let [locale (i18n/use-locale)
time (dt/timeago modified-at {:locale locale})]
(str (t locale "ds.updated-at" time))))
(mf/defc grid-item
{:wrap [mf/memo]}
[{:keys [file] :as props}]
(let [local (mf/use-state {:menu-open false
:edition false})
locale (i18n/use-locale)
on-navigate #(st/emit! (rt/nav :workspace
{:project-id (:project-id file)
:file-id (:id file)}
{:page-id (first (:pages file))}))
delete-fn #(st/emit! nil (dsh/delete-file (:id file)))
on-delete #(do
(dom/stop-propagation %)
(modal/show! confirm-dialog {:on-accept delete-fn}))
add-shared-fn #(st/emit! nil (dsh/set-file-shared (:id file) true))
on-add-shared
#(do
(dom/stop-propagation %)
(modal/show! confirm-dialog
{:message (t locale "dashboard.grid.add-shared-message" (:name file))
:hint (t locale "dashboard.grid.add-shared-hint")
:accept-text (t locale "dashboard.grid.add-shared-accept")
:not-danger? true
:on-accept add-shared-fn}))
remove-shared-fn #(st/emit! nil (dsh/set-file-shared (:id file) false))
on-remove-shared
#(do
(dom/stop-propagation %)
(modal/show! confirm-dialog
{:message (t locale "dashboard.grid.remove-shared-message" (:name file))
:hint (t locale "dashboard.grid.remove-shared-hint")
:accept-text (t locale "dashboard.grid.remove-shared-accept")
:not-danger? false
:on-accept remove-shared-fn}))
on-blur #(let [name (-> % dom/get-target dom/get-value)]
(st/emit! (dsh/rename-file (:id file) name))
(swap! local assoc :edition false))
on-key-down #(cond
(kbd/enter? %) (on-blur %)
(kbd/esc? %) (swap! local assoc :edition false))
on-menu-click #(do
(dom/stop-propagation %)
(swap! local assoc :menu-open true))
on-menu-close #(swap! local assoc :menu-open false)
on-edit #(do
(dom/stop-propagation %)
(swap! local assoc :edition true))]
[:div.grid-item.project-th {:on-click on-navigate}
[:div.overlay]
[:& grid-item-thumbnail {:file file}]
(when (:is-shared file)
[:div.item-badge
i/library])
[:div.item-info
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name file)}]
[:h3 (:name file)])
[:& grid-item-metadata {:modified-at (:modified-at file)}]]
[:div.project-th-actions {:class (dom/classnames
:force-display (:menu-open @local))}
;; [:div.project-th-icon.pages
;; i/page
;; #_[:span (:total-pages project)]]
;; [:div.project-th-icon.comments
;; i/chat
;; [:span "0"]]
[:div.project-th-icon.menu
{:on-click on-menu-click}
i/actions]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]
(if (:is-shared file)
[(t locale "dashboard.grid.remove-shared") on-remove-shared]
[(t locale "dashboard.grid.add-shared") on-add-shared])]}]]]))
;; --- Grid
(mf/defc grid
[{:keys [id opts files hide-new?] :as props}]
(let [locale (i18n/use-locale)
order (:order opts :modified)
filter (:filter opts "")
on-click #(do
(dom/prevent-default %)
(st/emit! (dsh/create-file id)))]
[:section.dashboard-grid
(if (> (count files) 0)
[:div.dashboard-grid-row
(when (not hide-new?)
[:div.grid-item.add-file {:on-click on-click}
[:span (tr "ds.new-file")]])
(for [item files]
[:& grid-item {:file item :key (:id item)}])]
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.grid.empty-files")]
[:div.grid-files-link
[:a.btn-secondary.btn-small {:on-click on-click} (t locale "ds.new-file")]]])]))

View file

@ -0,0 +1,44 @@
;; 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.libraries
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[app.util.dom :as dom]
[app.util.router :as rt]
[app.main.data.dashboard :as dsh]
[app.main.store :as st]
[app.main.ui.modal :as modal]
[app.main.ui.keyboard :as kbd]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]))
(def files-ref
(-> (comp vals :files)
(l/derived st/state)))
(mf/defc libraries-page
[{:keys [section team-id] :as props}]
(let [files (->> (mf/deref files-ref)
(sort-by :modified-at)
(reverse))]
(mf/use-effect
(mf/deps section team-id)
#(st/emit! (dsh/initialize-libraries team-id)))
[:*
[:header.main-bar
[:h1.dashboard-title (tr "dashboard.header.libraries")]]
[:section.libraries-page
[:& grid {:files files :hide-new? true}]]]))

View file

@ -0,0 +1,58 @@
;; 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) 2015-2020 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2015-2020 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.dashboard.profile
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.data.auth :as da]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.icons :as i]
[app.main.ui.navigation :as nav]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]))
;; --- Component: Profile
(mf/defc profile-section
[{:keys [profile] :as props}]
(let [show (mf/use-state false)
photo (:photo-uri profile "")
photo (if (str/empty? photo)
"/images/avatar.jpg"
photo)
locale (i18n/use-locale)
on-click
(fn [event section]
(dom/stop-propagation event)
(if (keyword? section)
(st/emit! (rt/nav section))
(st/emit! section)))]
[:div.user-zone {:on-click #(reset! show true)}
[:img {:src photo}]
[:span (:fullname profile)]
[:& dropdown {:on-close #(reset! show false)
:show @show}
[:ul.profile-menu
[:li {:on-click #(on-click % :settings-profile)}
i/user
[:span (t locale "dashboard.header.profile-menu.profile")]]
[:li {:on-click #(on-click % :settings-password)}
i/lock
[:span (t locale "dashboard.header.profile-menu.password")]]
[:li {:on-click #(on-click % da/logout)}
i/exit
[:span (t locale "dashboard.header.profile-menu.logout")]]]]]))

View file

@ -0,0 +1,86 @@
;; 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.project
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t]]
[app.util.dom :as dom]
[app.util.router :as rt]
[app.main.data.dashboard :as dsh]
[app.main.store :as st]
[app.main.ui.modal :as modal]
[app.main.ui.keyboard :as kbd]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]))
(def projects-ref
(l/derived :projects st/state))
(def files-ref
(-> (comp vals :files)
(l/derived st/state)))
(mf/defc project-header
[{:keys [team-id project-id] :as props}]
(let [local (mf/use-state {:menu-open false
:edition false})
projects (mf/deref projects-ref)
project (get projects project-id)
locale (i18n/use-locale)
on-menu-click #(swap! local assoc :menu-open true)
on-menu-close #(swap! local assoc :menu-open false)
on-edit #(swap! local assoc :edition true :menu-open false)
on-blur #(let [name (-> % dom/get-target dom/get-value)]
(st/emit! (dsh/rename-project project-id name))
(swap! local assoc :edition false))
on-key-down #(cond
(kbd/enter? %) (on-blur %)
(kbd/esc? %) (swap! local assoc :edition false))
delete-fn #(do
(st/emit! (dsh/delete-project project-id))
(st/emit! (rt/nav :dashboard-team {:team-id team-id})))
on-delete #(modal/show! confirm-dialog {:on-accept delete-fn})]
[:header.main-bar
(if (:is-default project)
[:h1.dashboard-title (t locale "dashboard.header.draft")]
[:*
[:h1.dashboard-title (t locale "dashboard.header.project" (:name project))]
[:div.main-bar-icon {:on-click on-menu-click} i/arrow-down]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]]}]
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name project)}])])
[:a.btn-secondary.btn-small {:on-click #(do
(dom/prevent-default %)
(st/emit! (dsh/create-file project-id)))}
(t locale "dashboard.header.new-file")]]))
(mf/defc project-page
[{:keys [section team-id project-id] :as props}]
(let [files (->> (mf/deref files-ref)
(sort-by :modified-at)
(reverse))]
(mf/use-effect
(mf/deps section team-id project-id)
#(st/emit! (dsh/initialize-project team-id project-id)))
[:*
[:& project-header {:team-id team-id :project-id project-id}]
[:section.projects-page
[:& grid { :id project-id :files files :hide-new? true}]]]))

View file

@ -0,0 +1,97 @@
;; 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.recent-files
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.common.exceptions :as ex]
[app.main.constants :as c]
[app.main.data.dashboard :as dsh]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]))
;; --- Component: Content
(def projects-ref
(l/derived :projects st/state))
(def recent-file-ids-ref
(l/derived :recent-file-ids st/state))
(def files-ref
(l/derived :files st/state))
;; --- Component: Recent files
(mf/defc recent-files-header
[{:keys [profile] :as props}]
(let [locale (i18n/use-locale)]
[:header#main-bar.main-bar
[:h1.dashboard-title "Recent"]
[:a.btn-secondary.btn-small {:on-click #(st/emit! dsh/create-project)}
(t locale "dashboard.header.new-project")]]))
(mf/defc recent-project
[{:keys [project files first? locale] :as props}]
(let [project-id (:id project)
team-id (:team-id project)
file-count (or (:file-count project) 0)]
[:div.recent-files-row
{:class-name (when first? "first")}
[:div.recent-files-row-title
[:h2.recent-files-row-title-name {:on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id
:project-id project-id}))
:style {:cursor "pointer"}} (:name project)]
[:span.recent-files-row-title-info (str file-count " files")]
(when (> file-count 0)
(let [time (-> (:modified-at project)
(dt/timeago {:locale locale}))]
[:span.recent-files-row-title-info (str ", " time)]))]
[:& grid {:id (:id project)
:files files
:hide-new? true}]]))
(mf/defc recent-files-page
[{:keys [team-id] :as props}]
(let [projects (->> (mf/deref projects-ref)
(vals)
(sort-by :modified-at)
(reverse))
files (mf/deref files-ref)
recent-file-ids (mf/deref recent-file-ids-ref)
locale (i18n/use-locale)
setup #(st/emit! (dsh/initialize-recent team-id))]
(-> (mf/deps team-id)
(mf/use-effect #(st/emit! (dsh/initialize-recent team-id))))
(when (and projects recent-file-ids)
[:*
[:& recent-files-header]
[:section.recent-files-page
(for [project projects]
[:& recent-project {:project project
:locale locale
:key (:id project)
:files (->> (get recent-file-ids (:id project))
(map #(get files %))
(filter identity)) ;; avoid failure if a "project only" files list is in global state
:first? (= project (first projects))}])]])))

View file

@ -0,0 +1,51 @@
;; 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) 2015-2017 Juan de la Cruz <delacruzgarciajuan@gmail.com>
;; Copyright (c) 2015-2020 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.dashboard.search
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.store :as st]
[app.main.data.dashboard :as dsh]
[app.util.i18n :as i18n :refer [t]]
[app.main.ui.dashboard.grid :refer [grid]]))
;; --- Component: Search
(def search-result-ref
(-> #(get-in % [:dashboard-local :search-result])
(l/derived st/state)))
(mf/defc search-page
[{:keys [team-id search-term] :as props}]
(let [search-result (mf/deref search-result-ref)
locale (i18n/use-locale)]
(mf/use-effect
(mf/deps search-term)
#(st/emit! (dsh/initialize-search team-id search-term)))
[:section.search-page
[:section.dashboard-grid
(cond
(empty? search-term)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.type-something")]]
(nil? search-result)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.searching-for" search-term)]]
(empty? search-result)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.no-matches-for" search-term)]]
:else
[:& grid { :files search-result :hide-new? true}])]]))

View file

@ -0,0 +1,201 @@
;; 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 <niwi@niwi.nz>
;; Copyright (c) 2020 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.dashboard.sidebar
(:require
[cuerdas.core :as str]
[goog.functions :as f]
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.constants :as c]
[app.main.data.dashboard :as dsh]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.dashboard.common :as common]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]))
;; --- Component: Sidebar
(mf/defc sidebar-project
[{:keys [id name selected? team-id] :as props}]
(let [dashboard-local @refs/dashboard-local
project-for-edit (:project-for-edit dashboard-local)
local (mf/use-state {:name name
:editing (= id project-for-edit)})
editable? (not (nil? id))
edit-input-ref (mf/use-ref)
on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id :project-id id}))
on-dbl-click #(when editable? (swap! local assoc :editing true))
on-input #(as-> % $
(dom/get-target $)
(dom/get-value $)
(swap! local assoc :name $))
on-cancel #(do
(st/emit! dsh/clear-project-for-edit)
(swap! local assoc :editing false :name name))
on-keyup #(cond
(kbd/esc? %)
(on-cancel)
(kbd/enter? %)
(let [name (-> % dom/get-target dom/get-value)]
(st/emit! dsh/clear-project-for-edit)
(st/emit! (dsh/rename-project id name))
(swap! local assoc :editing false)))]
(mf/use-effect
(mf/deps (:editing @local))
#(when (:editing @local)
(let [edit-input (mf/ref-val edit-input-ref)]
(dom/focus! edit-input)
(dom/select-text! edit-input))
nil))
[:li {:on-click on-click
:on-double-click on-dbl-click
:class-name (when selected? "current")}
(if (:editing @local)
[:div.edit-wrapper
[:input.element-title {:value (:name @local)
:ref edit-input-ref
:on-change on-input
:on-key-down on-keyup}]
[:span.close {:on-click on-cancel} i/close]]
[:*
i/folder
[:span.element-title name]])]))
(def projects-iref
(l/derived :projects st/state))
(mf/defc sidebar-projects
[{:keys [team-id selected-project-id] :as props}]
(let [projects (->> (mf/deref projects-iref)
(vals)
(remove #(:is-default %))
(sort-by :created-at))]
(for [item projects]
[:& sidebar-project
{:id (:id item)
:key (:id item)
:name (:name item)
:selected? (= (:id item) selected-project-id)
:team-id team-id
}])))
(mf/defc sidebar-team
[{:keys [profile
team-id
selected-section
selected-project-id
selected-team-id] :as props}]
(let [home? (and (= selected-section :dashboard-team)
(= selected-team-id (:default-team-id profile)))
drafts? (and (= selected-section :dashboard-project)
(= selected-team-id (:default-team-id profile))
(= selected-project-id (:default-project-id profile)))
libraries? (= selected-section :dashboard-libraries)
;; library? (and (str/starts-with? (name selected-section) "dashboard-library")
;; (= selected-team-id (:default-team-id profile)))
locale (i18n/use-locale)]
[:div.sidebar-team
[:ul.dashboard-elements.dashboard-common
[:li.recent-projects
{:on-click #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
:class-name (when home? "current")}
i/recent
[:span.element-title (t locale "dashboard.sidebar.recent")]]
[:li
{:on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id
:project-id "drafts"}))
:class-name (when drafts? "current")}
i/file-html
[:span.element-title (t locale "dashboard.sidebar.drafts")]]
[:li
{:on-click #(st/emit! (rt/nav :dashboard-libraries {:team-id team-id}))
:class-name (when libraries? "current")}
i/icon-set
[:span.element-title (t locale "dashboard.sidebar.libraries")]]]
[:div.projects-row
[:span "PROJECTS"]
[:a.btn-icon-light.btn-small {:on-click #(st/emit! dsh/create-project)}
i/close]]
[:ul.dashboard-elements
[:& sidebar-projects
{:selected-team-id selected-team-id
:selected-project-id selected-project-id
:team-id team-id}]]]
))
(def debounced-emit! (f/debounce st/emit! 500))
(mf/defc sidebar
[{:keys [section team-id project-id search-term] :as props}]
(let [locale (i18n/use-locale)
profile (mf/deref refs/profile)
search-term-not-nil (or search-term "")
on-search-focus
(fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
(dom/select-text! target)
(if (empty? value)
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {}))
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value})))))
on-search-change
(fn [event]
(let [value (-> (dom/get-target event)
(dom/get-value))]
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value}))))
on-clear-click
(fn [event]
(let [search-input (dom/get-element "search-input")]
(dom/clean-value! search-input)
(dom/focus! search-input)
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {}))))]
[:div.dashboard-sidebar
[:div.dashboard-sidebar-inside
[:form.dashboard-search
[:input.input-text
{:key :images-search-box
:id "search-input"
:type "text"
:placeholder (t locale "ds.search.placeholder")
:default-value search-term-not-nil
:auto-complete "off"
:on-focus on-search-focus
:on-change on-search-change
:ref #(when % (set! (.-value %) search-term-not-nil))}]
[:div.clear-search
{:on-click on-clear-click}
i/close]]
[:& sidebar-team {:selected-team-id team-id
:selected-project-id project-id
:selected-section section
:profile profile
:team-id (:default-team-id profile)}]]]))

View file

@ -0,0 +1,233 @@
;; 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 S.L
(ns app.main.ui.hooks
"A collection of general purpose react hooks."
(:require
[cljs.spec.alpha :as s]
[app.common.spec :as us]
[beicon.core :as rx]
[goog.events :as events]
[rumext.alpha :as mf]
[app.util.transit :as t]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.webapi :as wapi]
[app.util.timers :as ts]
["mousetrap" :as mousetrap])
(:import goog.events.EventType))
(defn use-rxsub
[ob]
(let [[state reset-state!] (mf/useState @ob)]
(mf/useEffect
(fn []
(let [sub (rx/subscribe ob #(reset-state! %))]
#(rx/cancel! sub)))
#js [ob])
state))
(s/def ::shortcuts
(s/map-of ::us/string fn?))
(defn use-shortcuts
[shortcuts]
(us/assert ::shortcuts shortcuts)
(mf/use-effect
(fn []
(->> (seq shortcuts)
(run! (fn [[key f]]
(mousetrap/bind key (fn [event]
(js/console.log "[debug]: shortcut:" key)
(.preventDefault event)
(f event))))))
(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.)
imd "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="]
(set! (.-src img) imd)
img))
(defn- set-timer
[state ms func]
(assoc state :timer (ts/schedule ms func)))
(defn- cancel-timer
[state]
(let [timer (:timer state)]
(if timer
(do
(rx/dispose! timer)
(dissoc state :timer))
state)))
(def sortable-ctx (mf/create-context nil))
(mf/defc sortable-container
[{:keys [children] :as props}]
(let [global-drag-end (mf/use-memo #(rx/subject))]
[:& (mf/provider sortable-ctx) {:value global-drag-end}
children]))
;; The dnd API is problematic for nested elements, such a sortable items tree.
;; The approach used here to solve bad situations is:
;; - Capture all events in the leaf draggable elements, and stop propagation.
;; - Ignore events originated in non-draggable children.
;; - At drag operation end, all elements that have received some enter/over
;; event and have not received the corresponding leave event, are notified
;; so they can clean up. This can be occur, for example, if
;; * some leave events are throttled out because of a slow computer
;; * some corner cases of mouse entering a container element, and then
;; moving into a contained element. This is anyway mitigated by not
;; stopping propagation of leave event.
;;
;; Do not remove commented out lines, they are useful to debug events when
;; things go weird.
(defn use-sortable
[& {:keys [data-type data on-drop on-drag on-hold detect-center?] :as opts}]
(let [ref (mf/use-ref)
state (mf/use-state {:over nil
:timer nil
:subscr nil})
global-drag-end (mf/use-ctx sortable-ctx)
cleanup
(fn []
;; (js/console.log "cleanup" (:name data))
(when-let [subscr (:subscr @state)]
;; (js/console.log "unsubscribing" (:name data))
(rx/unsub! (:subscr @state)))
(swap! state (fn [state]
(-> state
(cancel-timer)
(dissoc :over :subscr)))))
subscribe-to-drag-end
(fn []
(when (nil? (:subscr @state))
;; (js/console.log "subscribing" (:name data))
(swap! state
#(assoc % :subscr (rx/sub! global-drag-end cleanup)))))
on-drag-start
(fn [event]
(dom/stop-propagation event)
;; (dnd/trace event data "drag-start")
(dnd/set-data! event data-type data)
(dnd/set-drag-image! event (invisible-image))
(dnd/set-allowed-effect! event "move")
(when (fn? on-drag)
(on-drag data)))
on-drag-enter
(fn [event]
(dom/prevent-default event) ;; prevent default to allow drag enter
(when-not (dnd/from-child? event)
(dom/stop-propagation event)
(subscribe-to-drag-end)
;; (dnd/trace event data "drag-enter")
(when (fn? on-hold)
(swap! state (fn [state]
(-> state
(cancel-timer)
(set-timer 1000 on-hold)))))))
on-drag-over
(fn [event]
(when (dnd/has-type? event data-type)
(dom/prevent-default event) ;; prevent default to allow drag over
(when-not (dnd/from-child? event)
(dom/stop-propagation event)
(subscribe-to-drag-end)
;; (dnd/trace event data "drag-over")
(let [side (dnd/drop-side event detect-center?)]
(swap! state assoc :over side)))))
on-drag-leave
(fn [event]
(when-not (dnd/from-child? event)
;; (dnd/trace event data "drag-leave")
(cleanup)))
on-drop'
(fn [event]
(dom/stop-propagation event)
;; (dnd/trace event data "drop")
(let [side (dnd/drop-side event detect-center?)
drop-data (dnd/get-data event data-type)]
(cleanup)
(rx/push! global-drag-end nil)
(when (fn? on-drop)
(on-drop side drop-data))))
on-drag-end
(fn [event]
(dom/stop-propagation event)
;; (dnd/trace event data "drag-end")
(rx/push! global-drag-end nil)
(cleanup))
on-mount
(fn []
(let [dom (mf/ref-val ref)]
(.setAttribute dom "draggable" true)
;; Register all events in the (default) bubble mode, so that they
;; are captured by the most leaf item. The handler will stop
;; propagation, so they will not go up in the containment tree.
(.addEventListener dom "dragstart" on-drag-start false)
(.addEventListener dom "dragenter" on-drag-enter false)
(.addEventListener dom "dragover" on-drag-over false)
(.addEventListener dom "dragleave" on-drag-leave false)
(.addEventListener dom "drop" on-drop' false)
(.addEventListener dom "dragend" on-drag-end false)
#(do
(.removeEventListener dom "dragstart" on-drag-start)
(.removeEventListener dom "dragenter" on-drag-enter)
(.removeEventListener dom "dragover" on-drag-over)
(.removeEventListener dom "dragleave" on-drag-leave)
(.removeEventListener dom "drop" on-drop')
(.removeEventListener dom "dragend" on-drag-end))))]
(mf/use-effect
(mf/deps data on-drop)
on-mount)
[(deref state) ref]))
(defn use-stream
"Wraps the subscription to a strem into a `use-effect` call"
[stream on-subscribe]
(mf/use-effect (fn []
(let [sub (->> stream (rx/subs on-subscribe))]
#(rx/dispose! sub)))))

View file

@ -0,0 +1,21 @@
;; 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.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))]
`(rumext.alpha/html
[:svg {:width 500 :height 500}
[:use {:xlinkHref ~href}]])))

View file

@ -0,0 +1,149 @@
;; 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.icons
(:require-macros [app.main.ui.icons :refer [icon-xref]])
(:require [rumext.alpha :as mf]))
(def action (icon-xref :action))
(def actions (icon-xref :actions))
(def align-bottom (icon-xref :align-bottom))
(def align-middle (icon-xref :align-middle))
(def align-top (icon-xref :align-top))
(def alignment (icon-xref :alignment))
(def arrow (icon-xref :arrow))
(def arrow-down (icon-xref :arrow-down))
(def arrow-end (icon-xref :arrow-end))
(def arrow-slide (icon-xref :arrow-slide))
(def artboard (icon-xref :artboard))
(def at (icon-xref :at))
(def auto-fix (icon-xref :auto-fix))
(def auto-height (icon-xref :auto-height))
(def auto-width (icon-xref :auto-width))
(def box (icon-xref :box))
(def chain (icon-xref :chain))
(def chat (icon-xref :chat))
(def circle (icon-xref :circle))
(def close (icon-xref :close))
(def copy (icon-xref :copy))
(def curve (icon-xref :curve))
(def download (icon-xref :download))
(def exit (icon-xref :exit))
(def export (icon-xref :export))
(def eye (icon-xref :eye))
(def eye-closed (icon-xref :eye-closed))
(def file-html (icon-xref :file-html))
(def file-svg (icon-xref :file-svg))
(def fill (icon-xref :fill))
(def folder (icon-xref :folder))
(def folder-zip (icon-xref :folder-zip))
(def full-screen (icon-xref :full-screen))
(def full-screen-off (icon-xref :full-screen-off))
(def grid (icon-xref :grid))
(def grid-snap (icon-xref :grid-snap))
(def icon-empty (icon-xref :icon-empty))
(def icon-lock (icon-xref :icon-lock))
(def icon-set (icon-xref :icon-set))
(def image (icon-xref :image))
(def infocard (icon-xref :infocard))
(def interaction (icon-xref :interaction))
(def layers (icon-xref :layers))
(def letter-spacing (icon-xref :letter-spacing))
(def library (icon-xref :library))
(def libraries (icon-xref :libraries))
(def line (icon-xref :line))
(def line-height (icon-xref :line-height))
(def loader (icon-xref :loader))
(def lock (icon-xref :lock))
(def lock-open (icon-xref :lock-open))
(def logo (icon-xref :app-logo))
(def logout (icon-xref :logout))
(def logo-icon (icon-xref :app-logo-icon))
(def lowercase (icon-xref :lowercase))
(def mail (icon-xref :mail))
(def minus (icon-xref :minus))
(def move (icon-xref :move))
(def msg-error (icon-xref :msg-error))
(def msg-success (icon-xref :msg-success))
(def msg-warning (icon-xref :msg-warning))
(def msg-info (icon-xref :msg-info))
(def navigate (icon-xref :navigate))
(def options (icon-xref :options))
(def organize (icon-xref :organize))
(def palette (icon-xref :palette))
(def pencil (icon-xref :pencil))
(def picker (icon-xref :picker))
(def pin (icon-xref :pin))
(def play (icon-xref :play))
(def plus (icon-xref :plus))
(def radius (icon-xref :radius))
(def recent (icon-xref :recent))
(def redo (icon-xref :redo))
(def rotate (icon-xref :rotate))
(def ruler (icon-xref :ruler))
(def ruler-tool (icon-xref :ruler-tool))
(def save (icon-xref :save))
(def search (icon-xref :search))
(def shape-halign-center (icon-xref :shape-halign-center))
(def shape-halign-left (icon-xref :shape-halign-left))
(def shape-halign-right (icon-xref :shape-halign-right))
(def shape-hdistribute (icon-xref :shape-hdistribute))
(def shape-valign-bottom (icon-xref :shape-valign-bottom))
(def shape-valign-center (icon-xref :shape-valign-center))
(def shape-valign-top (icon-xref :shape-valign-top))
(def shape-vdistribute (icon-xref :shape-vdistribute))
(def size-horiz (icon-xref :size-horiz))
(def size-vert (icon-xref :size-vert))
(def strikethrough (icon-xref :strikethrough))
(def stroke (icon-xref :stroke))
(def sublevel (icon-xref :sublevel))
(def text (icon-xref :text))
(def text-align-center (icon-xref :text-align-center))
(def text-align-justify (icon-xref :text-align-justify))
(def text-align-left (icon-xref :text-align-left))
(def text-align-right (icon-xref :text-align-right))
(def titlecase (icon-xref :titlecase))
(def toggle (icon-xref :toggle))
(def trash (icon-xref :trash))
(def tree (icon-xref :tree))
(def underline (icon-xref :underline))
(def undo (icon-xref :undo))
(def undo-history (icon-xref :undo-history))
(def ungroup (icon-xref :ungroup))
(def unlock (icon-xref :unlock))
(def uppercase (icon-xref :uppercase))
(def user (icon-xref :user))
(def tick (icon-xref :tick))
(def loader-pencil
(mf/html
[:svg
{:viewBox "0 0 677.34762 182.15429"
:height "182"
:width "667"
:id "loader-pencil"}
[:g
[:path
{:id "body-body"
:d
"M128.273 0l-3.9 2.77L0 91.078l128.273 91.076 549.075-.006V.008L128.273 0zm20.852 30l498.223.006V152.15l-498.223.007V30zm-25 9.74v102.678l-49.033-34.813-.578-32.64 49.61-35.225z"}]
[:path
{:id "loader-line"
:d
"M134.482 157.147v25l518.57.008.002-25-518.572-.008z"}]]]))
(mf/defc debug-icons-preview
{::mf/wrap-props false}
[props]
[:section.debug-icons-preview
(for [[key val] (sort-by first (ns-publics 'app.main.ui.icons))]
(when (not= key 'debug-icons-preview)
[:div.icon-item {:key key}
(deref val)
[:span (pr-str key)]]))])

View file

@ -0,0 +1,22 @@
(ns app.main.ui.keyboard)
(defn is-keycode?
[keycode]
(fn [e]
(= (.-keyCode e) keycode)))
(defn ^boolean alt?
[event]
(.-altKey event))
(defn ^boolean ctrl?
[event]
(.-ctrlKey event))
(defn ^boolean shift?
[event]
(.-shiftKey event))
(def esc? (is-keycode? 27))
(def enter? (is-keycode? 13))
(def space? (is-keycode? 32))

View file

@ -0,0 +1,18 @@
;; 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) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.loader
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.store :as st]))
;; --- Component
(mf/defc loader
[]
(when (mf/deref st/loader)
[:div.loader-content i/loader]))

View file

@ -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.messages
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.timers :as ts]))
(defn- type->icon
[type]
(case type
:warning i/msg-warning
:error i/msg-error
:success i/msg-success
:info i/msg-info
i/msg-error))
(mf/defc notification-item
[{:keys [type status on-close quick? content] :as props}]
(let [klass (dom/classnames
:fixed true
:success (= type :success)
:error (= type :error)
:info (= type :info)
:warning (= type :warning)
:hide (= status :hide)
:quick quick?)]
[:section.banner {:class klass}
[:div.content
[:div.icon (type->icon type)]
[:span content]]
[:div.btn-close {:on-click on-close} i/close]]))
(mf/defc notifications
[]
(let [message (mf/deref refs/message)
on-close #(st/emit! dm/hide)]
(when message
[:& notification-item {:type (:type message)
:quick? (boolean (:timeout message))
:status (:status message)
:content (:content message)
:on-close on-close}])))
(mf/defc inline-banner
{::mf/wrap [mf/memo]}
[{:keys [type on-close content children] :as props}]
[:div.inline-banner {:class (dom/classnames
:warning (= type :warning)
:error (= type :error)
:success (= type :success)
:info (= type :info)
:quick (not on-close))}
[:div.icon (type->icon type)]
[:div.content
[:div.main
[:span.text content]
[:div.btn-close {:on-click on-close} i/close]]
(when children
[:div.extra
children])]])

View file

@ -0,0 +1,61 @@
(ns app.main.ui.modal
(:require
[cuerdas.core :as str]
[goog.events :as events]
[rumext.alpha :as mf]
[app.main.store :as st]
[app.main.ui.keyboard :as k]
[app.util.data :refer [classnames]]
[app.util.dom :as dom])
(:import goog.events.EventType))
(defonce state (atom nil))
(defn show!
[component props]
(reset! state {:component component :props props}))
(defn hide!
[]
(reset! state nil))
(defn- on-esc-clicked
[event]
(when (k/esc? event)
(reset! state nil)
(dom/stop-propagation event)))
(defn- on-parent-clicked
[event parent-ref]
(let [parent (mf/ref-val parent-ref)
current (dom/get-target event)]
;; (js/console.log current (.-className ^js current))
(when (and (dom/equals? (.-firstElementChild ^js parent) current)
(str/includes? (.-className ^js current) "modal-overlay"))
(dom/stop-propagation event)
(dom/prevent-default event)
(reset! state nil))))
(mf/defc modal-wrapper
[{:keys [component props]}]
(mf/use-effect
(fn []
(let [key (events/listen js/document EventType.KEYDOWN on-esc-clicked)]
#(events/unlistenByKey %))))
(let [ref (mf/use-ref nil)]
[:div.modal-wrapper
{:ref ref
:on-click #(on-parent-clicked % ref)}
[:& component props]]))
(mf/defc modal
[]
(when-let [{:keys [component props]} (mf/deref state)]
[:& modal-wrapper {:component component
:props props
:key (random-uuid)}]))

View file

@ -0,0 +1,13 @@
(ns app.main.ui.navigation
;; TODO: deprecated
(:require [rumext.alpha :refer-macros [html]]
[goog.events :as events]
[app.util.dom :as dom]))
(defn link
"Given an href and a component, return a link component that will navigate
to the given URI withour reloading the page."
[href component]
(html
[:a {:href (str "/#" href)} component]))

View file

@ -0,0 +1,93 @@
;; 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.render
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[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]
[app.common.geom.matrix :as gmt]
[app.main.exports :as exports]
[app.main.repo :as repo]))
(mf/defc object-svg
{::mf/wrap [mf/memo]}
[{:keys [objects object-id zoom] :or {zoom 1} :as props}]
(let [object (get objects object-id)
frame-id (if (= :frame (:type object))
(:id object)
(:frame-id object))
modifier (-> (gpt/point (:x object) (:y object))
(gpt/negate)
(gmt/translate-matrix))
mod-ids (cons frame-id (cph/get-children frame-id objects))
updt-fn #(-> %1
(assoc-in [%2 :modifiers :displacement] modifier)
(update %2 geom/transform-shape))
objects (reduce updt-fn objects mod-ids)
object (get objects object-id)
width (* (get-in object [:selrect :width]) zoom)
height (* (get-in object [:selrect :height]) zoom)
vbox (str (get-in object [:selrect :x]) " "
(get-in object [:selrect :y]) " "
(get-in object [:selrect :width]) " "
(get-in object [:selrect :height]))
frame-wrapper
(mf/use-memo
(mf/deps objects)
#(exports/frame-wrapper-factory objects))
group-wrapper
(mf/use-memo
(mf/deps objects)
#(exports/group-wrapper-factory objects))
shape-wrapper
(mf/use-memo
(mf/deps objects)
#(exports/shape-wrapper-factory objects))
]
[:svg {:id "screenshot"
:view-box vbox
:width width
:height height
:version "1.1"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:xmlns "http://www.w3.org/2000/svg"}
(case (:type object)
:frame [:& frame-wrapper {:shape object :view-box vbox}]
:group [:& group-wrapper {:shape object}]
[:& shape-wrapper {:shape object}])]))
(mf/defc render-object
[{:keys [page-id object-id] :as props}]
(let [data (mf/use-state nil)]
(mf/use-effect
(fn []
(let [subs (->> (repo/query! :page {:id page-id})
(rx/subs (fn [result]
(reset! data (:data result)))))]
#(rx/dispose! subs))))
(when @data
[:& object-svg {:objects (:objects @data)
:object-id object-id
:zoom 1}])))

View file

@ -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.settings
(:require
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.router :as rt]
[app.main.ui.dashboard.profile :refer [profile-section]]
[app.main.ui.settings.header :refer [header]]
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.profile :refer [profile-page]]))
(mf/defc settings
[{:keys [route] :as props}]
(let [section (get-in route [:data :name])
profile (mf/deref refs/profile)]
[:main.settings-main
[:div.settings-content
[:& header {:section section :profile profile}]
(case section
:settings-profile (mf/element profile-page)
:settings-password (mf/element password-page)
:settings-options (mf/element options-page))]]))

View file

@ -0,0 +1,109 @@
;; 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.settings.change-email
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]))
(s/def ::email-1 ::fm/email)
(s/def ::email-2 ::fm/email)
(defn- email-equality
[data]
(let [email-1 (:email-1 data)
email-2 (:email-2 data)]
(cond-> {}
(and email-1 email-2 (not= email-1 email-2))
(assoc :email-2 {:message (tr "errors.email-invalid-confirmation")}))))
(s/def ::email-change-form
(s/keys :req-un [::email-1 ::email-2]))
(defn- on-error
[form error]
(cond
(= (:code error) :app.services.mutations.profile/email-already-exists)
(swap! form (fn [data]
(let [error {:message (tr "errors.email-already-exists")}]
(assoc-in data [:errors :email-1] error))))
:else
(let [msg (tr "errors.unexpected-error")]
(st/emit! (dm/error msg)))))
(defn- on-submit
[form event]
(let [data (with-meta {:email (get-in form [:clean-data :email-1])}
{:on-error (partial on-error form)})]
(st/emit! (du/request-email-change data))))
(mf/defc change-email-form
[{:keys [locale profile] :as props}]
[:section.modal-content.generic-form
[:h2 (t locale "settings.change-email-title")]
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.change-email-info" (:email profile))}]
[:& form {:on-submit on-submit
:spec ::email-change-form
:validators [email-equality]
:initial {}}
[:& input {:type "text"
:name :email-1
:label (t locale "settings.new-email-label")
:trim true}]
[:& input {:type "text"
:name :email-2
:label (t locale "settings.confirm-email-label")
:trim true}]
[:& submit-button
{:label (t locale "settings.change-email-submit-label")}]]])
(mf/defc change-email-confirmation
[{:keys [locale profile] :as locale}]
[:section.modal-content.generic-form.confirmation
[:h2 (t locale "settings.verification-sent-title")]
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.change-email-info2" (:email profile))}]
[:button.btn-primary.btn-large
{:on-click #(modal/hide!)}
(t locale "settings.close-modal-label")]])
(mf/defc change-email-modal
[props]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)]
[:div.modal-overlay
[:div.generic-modal.change-email-modal
[:span.close {:on-click #(modal/hide!)} i/close]
(if (:pending-email profile)
[:& change-email-confirmation {:locale locale :profile profile}]
[:& change-email-form {:locale locale :profile profile}])]]))

View file

@ -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.main.ui.settings.delete-account
(:require
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]
[app.main.data.auth :as da]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.util.i18n :as i18n :refer [tr t]]))
(mf/defc delete-account-modal
[props]
(let [locale (mf/deref i18n/locale)]
[:section.generic-modal.change-email-modal
[:span.close {:on-click #(modal/hide!)} i/close]
[:section.modal-content.generic-form
[:h2 (t locale "settings.delete-account-title")]
[:& msgs/inline-banner
{:type :warning
:content (t locale "settings.delete-account-info")}]
[:div.button-row
[:button.btn-warning.btn-large
{:on-click #(do
(modal/hide!)
(st/emit! da/request-account-deletion))}
(t locale "settings.yes-delete-my-account")]
[:button.btn-secondary.btn-large
{:on-click #(modal/hide!)}
(t locale "settings.cancel-and-keep-my-account")]]]]))

View file

@ -0,0 +1,59 @@
;; 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.settings.header
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.auth :as da]
[app.main.store :as st]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]))
(mf/defc header
[{:keys [section profile] :as props}]
(let [profile? (= section :settings-profile)
password? (= section :settings-password)
options? (= section :settings-options)
team-id (:default-team-id profile)
go-back #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
logout #(st/emit! da/logout)
locale (mf/deref i18n/locale)
team-id (:default-team-id profile)]
[:header
[:section.secondary-menu
[:div.left {:on-click go-back}
[:span.icon i/arrow-slide]
[:span.label "Dashboard"]]
[:div.right {:on-click logout}
[:span.label "Log out"]
[:span.icon i/logout]]]
[:h1 "Your account"]
[:nav
[:a.nav-item
{:class (when profile? "current")
:on-click #(st/emit! (rt/nav :settings-profile))}
(t locale "settings.profile")]
[:a.nav-item
{:class (when password? "current")
:on-click #(st/emit! (rt/nav :settings-password))}
(t locale "settings.password")]
[:a.nav-item
{:class (when options? "current")
:on-click #(st/emit! (rt/nav :settings-options))}
(t locale "settings.options")]]]))
;; [:a.nav-item
;; {:class "foobar"
;; :on-click #(st/emit! (rt/nav :settings-profile))}
;; (t locale "settings.teams")]]]))

View file

@ -0,0 +1,77 @@
;; 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.settings.options
(:require
[rumext.alpha :as mf]
[cljs.spec.alpha :as s]
[app.main.ui.icons :as i]
[app.main.data.users :as udu]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [select submit-button form]]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]))
(s/def ::lang (s/nilable ::fm/not-empty-string))
(s/def ::theme (s/nilable ::fm/not-empty-string))
(s/def ::options-form
(s/keys :opt-un [::lang ::theme]))
(defn- on-error
[form error])
(defn- on-submit
[form event]
(dom/prevent-default event)
(let [data (:clean-data form)
on-success #(st/emit! (dm/success (tr "settings.notifications.profile-saved")))
on-error #(on-error % form)]
(st/emit! (udu/update-profile (with-meta data
{:on-success on-success
:on-error on-error})))))
(mf/defc options-form
[{:keys [locale profile] :as props}]
[:& form {:class "options-form"
:on-submit on-submit
:spec ::options-form
:initial profile}
[:h2 (t locale "settings.language-change-title")]
[:& select {:options [{:label "English" :value "en"}
{:label "Français" :value "fr"}
{:label "Español" :value "es"}
{:label "Русский" :value "ru"}]
:label (t locale "settings.language-label")
:default "en"
:name :lang}]
[:h2 (t locale "settings.theme-change-title")]
[:& select {:label (t locale "settings.theme-label")
:name :theme
:default "default"
:options [{:label "Default" :value "default"}]}]
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]])
;; --- Password Page
(mf/defc options-page
[props]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)]
[:section.settings-options.generic-form
[:div.forms-container
[:& options-form {:locale locale :profile profile}]]]))

View file

@ -0,0 +1,102 @@
;; 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.settings.password
(:require
[rumext.alpha :as mf]
[cljs.spec.alpha :as s]
[app.main.ui.icons :as i]
[app.main.data.users :as udu]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]))
(defn- on-error
[form error]
(case (:code error)
:app.services.mutations.profile/old-password-not-match
(swap! form assoc-in [:errors :password-old]
{:message (tr "errors.wrong-old-password")})
:else
(let [msg (tr "generic.error")]
(st/emit! (dm/error msg)))))
(defn- on-success
[form]
(let [msg (tr "settings.notifications.password-saved")]
(st/emit! (dm/success msg))))
(defn- on-submit
[form event]
(dom/prevent-default event)
(let [params (with-meta (:clean-data form)
{:on-success (partial on-success form)
:on-error (partial on-error form)})]
(st/emit! (udu/update-password params))))
(s/def ::password-1 ::fm/not-empty-string)
(s/def ::password-2 ::fm/not-empty-string)
(s/def ::password-old ::fm/not-empty-string)
(defn- password-equality
[data]
(let [password-1 (:password-1 data)
password-2 (:password-2 data)]
(cond-> {}
(and password-1 password-2 (not= password-1 password-2))
(assoc :password-2 {:message (tr "errors.password-invalid-confirmation")})
(and password-1 (> 8 (count password-1)))
(assoc :password-1 {:message (tr "errors.password-too-short")}))))
(s/def ::password-form
(s/keys :req-un [::password-1
::password-2
::password-old]))
(mf/defc password-form
[{:keys [locale] :as props}]
[:& form {:class "password-form"
:on-submit on-submit
:spec ::password-form
:validators [password-equality]
:initial {}}
[:h2 (t locale "settings.password-change-title")]
[:& input
{:type "password"
:name :password-old
:label (t locale "settings.old-password-label")}]
[:& input
{:type "password"
:name :password-1
:label (t locale "settings.new-password-label")}]
[:& input
{:type "password"
:name :password-2
:label (t locale "settings.confirm-password-label")}]
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]])
;; --- Password Page
(mf/defc password-page
[props]
(let [locale (mf/deref i18n/locale)]
[:section.settings-password.generic-form
[:div.forms-container
[:& password-form {:locale locale}]]]))

View file

@ -0,0 +1,133 @@
;; 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.settings.profile
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.data.messages :as dm]
[app.main.data.users :as udu]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.main.ui.settings.change-email :refer [change-email-modal]]
[app.main.ui.settings.delete-account :refer [delete-account-modal]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]))
(s/def ::fullname ::fm/not-empty-string)
(s/def ::email ::fm/email)
(s/def ::profile-form
(s/keys :req-un [::fullname ::lang ::theme ::email]))
(defn- on-error
[error form]
(st/emit! (dm/error (tr "errors.generic"))))
(defn- on-submit
[form event]
(let [data (:clean-data form)
on-success #(st/emit! (dm/success (tr "settings.notifications.profile-saved")))
on-error #(on-error % form)]
(st/emit! (udu/update-profile (with-meta data
{:on-success on-success
:on-error on-error})))))
;; --- Profile Form
(mf/defc profile-form
[{:keys [locale] :as props}]
(let [prof (mf/deref refs/profile)]
[:& form {:on-submit on-submit
:class "profile-form"
:spec ::profile-form
:initial prof}
[:& input
{:type "text"
:name :fullname
:label (t locale "settings.fullname-label")
:trim true}]
[:& input
{:type "email"
:name :email
:disabled true
:help-icon i/at
:label (t locale "settings.email-label")}]
(cond
(nil? (:pending-email prof))
[:div.change-email
[:a {:on-click #(modal/show! change-email-modal {})}
(t locale "settings.change-email-label")]]
(not= (:pending-email prof) (:email prof))
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.change-email-info3" (:pending-email prof))}
[:div.btn-secondary.btn-small
{:on-click #(st/emit! udu/cancel-email-change)}
(t locale "settings.cancel-email-change")]]
:else
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.email-verification-pending")}])
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]
[:div.links
[:div.link-item
[:a {:on-click #(modal/show! delete-account-modal {})}
(t locale "settings.remove-account-label")]]]]))
;; --- Profile Photo Form
(mf/defc profile-photo-form
[{:keys [locale] :as props}]
(let [file-input (mf/use-ref nil)
profile (mf/deref refs/profile)
photo (:photo-uri profile)
photo (if (or (str/empty? photo) (nil? photo))
"images/avatar.jpg"
photo)
on-image-click #(dom/click (mf/ref-val file-input))
on-file-selected
(fn [file]
(st/emit! (udu/update-photo file)))]
[:form.avatar-form
[:div.image-change-field
[:span.update-overlay {:on-click on-image-click} (t locale "settings.update-photo-label")]
[:img {:src photo}]
[:& file-uploader {:accept "image/jpeg,image/png"
:multi false
:input-ref file-input
:on-selected on-file-selected}]]]))
;; --- Profile Page
(mf/defc profile-page
{::mf/wrap-props false}
[props]
(let [locale (i18n/use-locale)]
[:section.settings-profile.generic-form
[:div.forms-container
[:& profile-photo-form {:locale locale}]
[:& profile-form {:locale locale}]]]))

View file

@ -0,0 +1,34 @@
;; 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) 2016-2020 UXBOX Labs SL
(ns app.main.ui.shapes.attrs
(:require [app.util.object :as obj]))
(defn- stroke-type->dasharray
[style]
(case style
:mixed "5,5,1,5"
:dotted "5,5"
:dashed "10,10"
nil))
(defn extract-style-attrs
[shape]
(let [stroke-style (:stroke-style shape :none)
attrs #js {:fill (or (:fill-color shape) "transparent")
:fillOpacity (:fill-opacity shape nil)
:rx (:rx shape nil)
:ry (:ry shape nil)}]
(when (not= stroke-style :none)
(obj/merge! attrs
#js {:stroke (:stroke-color shape nil)
:strokeWidth (:stroke-width shape 1)
:strokeOpacity (:stroke-opacity shape nil)
:strokeDasharray (stroke-type->dasharray stroke-style)}))
attrs))

View file

@ -0,0 +1,41 @@
;; 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.circle
(:require
[rumext.alpha :as mf]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]]
[app.common.geom.shapes :as geom]
[app.util.object :as obj]))
(mf/defc circle-shape
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
{:keys [id x y width height]} shape
transform (geom/transform-matrix shape)
cx (+ x (/ width 2))
cy (+ y (/ height 2))
rx (/ width 2)
ry (/ height 2)
props (-> (attrs/extract-style-attrs shape)
(obj/merge!
#js {:cx cx
:cy cy
:rx rx
:ry ry
:transform transform
:id (str "shape-" id)}))]
[:& shape-custom-stroke {:shape shape
:base-props props
:elem-name "ellipse"}]))

View file

@ -0,0 +1,97 @@
;; 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) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.shapes.custom-stroke
(:require
[rumext.alpha :as mf]
[app.common.geom.shapes :as geom]
[app.util.object :as obj]))
; The SVG standard does not implement yet the 'stroke-alignment'
; attribute, to define the position of the stroke relative to the
; stroke axis (inner, center, outer). Here we implement a patch to be
; able to draw the stroke in the three cases. See discussion at:
; https://stackoverflow.com/questions/7241393/can-you-control-how-an-svgs-stroke-width-is-drawn
(mf/defc shape-custom-stroke
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
base-props (unchecked-get props "base-props")
elem-name (unchecked-get props "elem-name")
{:keys [id x y width height]} (geom/shape->rect-shape shape)
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 base-props]
;; Inner alignment: display the shape with double width stroke,
;; and clip the result with the original shape without stroke.
(= stroke-position :inner)
(let [clip-id (str "clip-" id)
clip-props (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:stroke nil
:strokeWidth nil
:strokeOpacity nil
:strokeDasharray nil
:fill "white"
:fillOpacity 1
:transform nil}))
stroke-width (obj/get base-props "strokeWidth")
shape-props (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:strokeWidth (* stroke-width 2)
:clipPath (str "url('#" clip-id "')")}))]
[:*
[:> "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
;; at double width (transparent fill) and passed through a mask
;; that shows the whole shape, but hides the original shape
;; without stroke
(= stroke-position :outer)
(let [mask-id (str "mask-" id)
stroke-width (.-strokeWidth ^js base-props)
mask-props1 (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:stroke "white"
:strokeWidth (* stroke-width 2)
:strokeOpacity 1
:strokeDasharray nil
:fill "white"
:fillOpacity 1
:transform nil}))
mask-props2 (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:stroke nil
:strokeWidth nil
:strokeOpacity nil
:strokeDasharray nil
:fill "black"
:fillOpacity 1
:transform nil}))
shape-props1 (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:stroke nil
:strokeWidth nil
:strokeOpacity nil
:strokeDasharray nil}))
shape-props2 (-> (obj/merge! #js {} base-props)
(obj/merge! #js {:strokeWidth (* stroke-width 2)
:fill "none"
:fillOpacity 0
:mask (str "url('#" mask-id "')")}))]
[:*
[:mask {:id mask-id}
[:> elem-name mask-props1]
[:> elem-name mask-props2]]
[:> elem-name shape-props1]
[:> elem-name shape-props2]]))))

View file

@ -0,0 +1,45 @@
;; 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.frame
(:require
[rumext.alpha :as mf]
[app.common.data :as d]
[app.main.ui.shapes.attrs :as attrs]
[app.common.geom.shapes :as geom]
[app.util.object :as obj]))
(def frame-default-props {:fill-color "#ffffff"})
(defn frame-shape
[shape-wrapper]
(mf/fnc frame-shape
{::mf/wrap-props false}
[props]
(let [childs (unchecked-get props "childs")
shape (unchecked-get props "shape")
{:keys [id x y width height]} shape
props (-> (merge frame-default-props shape)
(attrs/extract-style-attrs)
(obj/merge!
#js {:x 0
:y 0
:id (str "shape-" id)
:width width
:height height}))]
[:svg {:x x :y y :width width :height height
:xmlnsXlink "http://www.w3.org/1999/xlink"
:xmlns "http://www.w3.org/2000/svg"}
[:> "rect" props]
(for [[i item] (d/enumerate childs)]
[:& shape-wrapper {:frame shape
:shape item
:key (:id item)}])])))

View file

@ -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.main.ui.shapes.group
(:require
[rumext.alpha :as mf]
[app.main.ui.shapes.attrs :as attrs]
[app.util.debug :refer [debug?]]
[app.common.geom.shapes :as geom]))
(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")
is-child-selected? (unchecked-get props "is-child-selected?")
{:keys [id x y width height]} shape
transform (geom/transform-matrix shape)]
[:g
(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
:id (str "group-" id)
:width width
:height height}])])))

View file

@ -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.shapes.icon
(:require
[rumext.alpha :as mf]
[app.common.geom.shapes :as geom]
[app.main.ui.shapes.attrs :as attrs]
[app.util.object :as obj]))
(mf/defc icon-shape
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
{:keys [id x y width height metadata rotation content]} shape
transform (geom/transform-matrix shape)
vbox (apply str (interpose " " (:view-box metadata)))
props (-> (attrs/extract-style-attrs shape)
(obj/merge!
#js {:x x
:y y
:transform transform
:id (str "shape-" id)
:width width
:height height
:viewBox vbox
:preserveAspectRatio "none"
:dangerouslySetInnerHTML #js {:__html content}}))]
[:g {:transform transform}
[:> "svg" props]]))
(mf/defc icon-svg
[{:keys [shape] :as props}]
(let [{:keys [content id metadata]} shape
view-box (apply str (interpose " " (:view-box metadata)))
props {:viewBox view-box
:id (str "shape-" id)
:dangerouslySetInnerHTML #js {:__html content}}]
[:& "svg" props]))

View file

@ -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.image
(:require
[rumext.alpha :as mf]
[app.config :as cfg]
[app.common.geom.shapes :as geom]
[app.main.ui.shapes.attrs :as attrs]
[app.util.object :as obj]))
(mf/defc image-shape
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
{:keys [id x y width height rotation metadata]} shape
transform (geom/transform-matrix shape)
uri (cfg/resolve-media-path (:path metadata))
props (-> (attrs/extract-style-attrs shape)
(obj/merge!
#js {:x x
:y y
:transform transform
:id (str "shape-" id)
:preserveAspectRatio "none"
:xlinkHref uri
:width width
:height height}))]
[:> "image" props]))

View file

@ -0,0 +1,67 @@
;; 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.path
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]]
[app.common.geom.shapes :as geom]
[app.util.object :as obj]))
;; --- 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)
transform (geom/transform-matrix shape)
pdata (render-path shape)
props (-> (attrs/extract-style-attrs shape)
(obj/merge!
#js {:transform transform
:id (str "shape-" id)
:d pdata}))]
(if background?
[:g
[:path {:stroke "transparent"
:fill "transparent"
:stroke-width "20px"
:d pdata}]
[:& shape-custom-stroke {:shape shape
:base-props props
:elem-name "path"}]]
[:& shape-custom-stroke {:shape shape
:base-props props
:elem-name "path"}])))

View file

@ -0,0 +1,36 @@
;; 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.rect
(:require
[rumext.alpha :as mf]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]]
[app.common.geom.shapes :as geom]
[app.util.object :as obj]))
(mf/defc rect-shape
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
{:keys [id x y width height]} shape
transform (geom/transform-matrix shape)
props (-> (attrs/extract-style-attrs shape)
(obj/merge!
#js {:x x
:y y
:transform transform
:id (str "shape-" id)
:width width
:height height}))]
[:& shape-custom-stroke {:shape shape
:base-props props
:elem-name "rect"}]))

View file

@ -0,0 +1,11 @@
;; 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.shape)

View file

@ -0,0 +1,144 @@
;; 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) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.shapes.text
(:require
[rumext.alpha :as mf]
[app.common.data :as d]
[app.common.geom.shapes :as geom]
[app.common.geom.matrix :as gmt]
[app.main.fonts :as fonts]
[app.util.object :as obj]))
;; --- Text Editor Rendering
(defn- generate-root-styles
[data]
(let [valign (obj/get data "vertical-align")
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"))))
(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")
font-id (obj/get data "font-id")
font-variant-id (obj/get data "font-variant-id")
font-family (obj/get data "font-family")
font-size (obj/get data "font-size")
fill (obj/get data "fill")
opacity (obj/get data "opacity")
fontsdb (deref fonts/fontsdb)
base #js {:textDecoration text-decoration
:color fill
:opacity opacity
:textTransform text-transform}]
(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- render-text-node
([node] (render-text-node 0 node))
([index {:keys [type text children] :as node}]
(mf/html
(if (string? text)
(let [style (generate-text-styles (clj->js node))]
[:span {:style style :key index} text])
(let [children (map-indexed render-text-node children)]
(case type
"root"
(let [style (generate-root-styles (clj->js node))]
[:div.root.rich-text
{:key index
:style style
:xmlns "http://www.w3.org/1999/xhtml"}
children])
"paragraph-set"
(let [style #js {:display "inline-block"
:width "100%"}]
[:div.paragraphs {:key index :style style} children])
"paragraph"
(let [style (generate-paragraph-styles (clj->js node))]
[:p {:key index :style style} children])
nil))))))
(mf/defc text-content
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[props]
(let [root (obj/get props "content")]
(render-text-node root)))
(defn- retrieve-colors
[shape]
(let [colors (into #{} (comp (map :fill)
(filter string?))
(tree-seq map? :children (:content shape)))]
(if (empty? colors)
"#000000"
(apply str (interpose "," colors)))))
(mf/defc text-shape
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
selected? (unchecked-get props "selected?")
{:keys [id x y width height rotation content]} shape]
[:foreignObject {:x x
:y y
:data-colors (retrieve-colors shape)
:transform (geom/transform-matrix shape)
:id (str id)
:width width
:height height}
[:& text-content {:content (:content shape)}]]))

View file

@ -0,0 +1,37 @@
;; 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.static
(:require
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]))
(mf/defc not-found-page
[{:keys [error] :as props}]
[:section.not-found-layout
[:div.not-found-header i/logo]
[:div.not-found-content
[:div.message-container
[:div.error-img i/icon-empty]
[:div.main-message "404"]
[:div.desc-message "Oops! Page not found"]
[:a.btn-primary.btn-small "Go back"]]]])
(mf/defc not-authorized-page
[{:keys [error] :as props}]
[:section.not-found-layout
[:div.not-found-header i/logo]
[:div.not-found-content
[:div.message-container
[:div.error-img i/icon-lock]
[:div.main-message "403"]
[:div.desc-message "Sorry, you are not authorized to access this page."]
#_[:a.btn-primary.btn-small "Go back"]]]])

View file

@ -0,0 +1,119 @@
;; 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.viewer
(:require
[beicon.core :as rx]
[goog.events :as events]
[goog.object :as gobj]
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.common.exceptions :as ex]
[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.hooks :as hooks]
[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.shapes :refer [frame-svg]]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]])
(:import goog.events.EventType))
(mf/defc main-panel
[{:keys [data local index]}]
(let [locale (mf/deref i18n/locale)
frames (:frames data [])
objects (:objects data)
frame (get frames index)]
[:section.viewer-preview
(cond
(empty? frames)
[:section.empty-state
[:span (t locale "viewer.empty-state")]]
(nil? frame)
[: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}])]))
(mf/defc viewer-content
[{:keys [data local index] :as props}]
(let [container (mf/use-ref)
[toggle-fullscreen fullscreen?] (hooks/use-fullscreen container)
on-click
(fn [event]
(dom/stop-propagation event)
(let [mode (get local :interactions-mode)]
(when (= mode :show-on-click)
(st/emit! dv/flash-interactions))))
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)))))
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})]
(fn []
(events/unlistenByKey key1))))]
(mf/use-effect on-mount)
(hooks/use-shortcuts dv/shortcuts)
[:div.viewer-layout {:class (classnames :fullscreen fullscreen?)
:ref container}
[:& header {:data data
:toggle-fullscreen toggle-fullscreen
:fullscreen? fullscreen?
:local local
:index index}]
[:div.viewer-content {:on-click on-click}
(when (:show-thumbnails local)
[:& thumbnails-panel {:index index
:data data}])
[:& main-panel {:data data
:local local
:index index}]]]))
;; --- Component: Viewer Page
(mf/defc viewer-page
[{:keys [page-id index token] :as props}]
(mf/use-effect
(mf/deps page-id token)
#(st/emit! (dv/initialize page-id token)))
(let [data (mf/deref refs/viewer-data)
local (mf/deref refs/viewer-local)]
(when data
[:& viewer-content {:index index
:local local
:data data}])))

View file

@ -0,0 +1,182 @@
;; 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.viewer.header
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.messages :as dm]
[app.main.data.viewer :as dv]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[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]))
(mf/defc zoom-widget
{:wrap [mf/memo]}
[{:keys [zoom
on-increase
on-decrease
on-zoom-to-50
on-zoom-to-100
on-zoom-to-200]
:as props}]
(let [show-dropdown? (mf/use-state false)]
[:div.zoom-widget {:on-click #(reset! show-dropdown? true)}
[:span {} (str (mth/round (* 100 zoom)) "%")]
[:span.dropdown-button i/arrow-down]
[:& dropdown {:show @show-dropdown?
:on-close #(reset! show-dropdown? false)}
[:ul.zoom-dropdown
[:li {:on-click on-increase}
"Zoom in" [:span "+"]]
[:li {:on-click on-decrease}
"Zoom out" [:span "-"]]
[:li {:on-click on-zoom-to-50}
"Zoom to 50%" [:span "Shift + 0"]]
[:li {:on-click on-zoom-to-100}
"Zoom to 100%" [:span "Shift + 1"]]
[: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] :as props}]
(let [show-dropdown? (mf/use-state false)
dropdown-ref (mf/use-ref)
token (:share-token page)
locale (i18n/use-locale)
create #(st/emit! dv/create-share-link)
delete #(st/emit! dv/delete-share-link)
href (.-href js/location)
link (str href "&token=" token)
copy-link
(fn [event]
(wapi/write-to-clipboard link)
(st/emit! (dm/show {:type :info
:content "Link copied successfuly!"
:timeout 2000})))]
[:*
[:span.btn-primary.btn-small
{:alt (t locale "viewer.header.share.title")
:on-click #(swap! show-dropdown? not)}
(t locale "viewer.header.share.title")]
[:& dropdown {:show @show-dropdown?
:on-close #(swap! show-dropdown? not)
:container dropdown-ref}
[:div.share-link-dropdown {:ref dropdown-ref}
[:span.share-link-title (t locale "viewer.header.share.title")]
[:div.share-link-input
(if (string? token)
[:*
[:span.link link]
[:span.link-button {:on-click copy-link}
(t locale "viewer.header.share.copy-link")]]
[:span.link-placeholder (t locale "viewer.header.share.placeholder")])]
[:span.share-link-subtitle (t locale "viewer.header.share.subtitle")]
[:div.share-link-buttons
(if (string? token)
[:button.btn-warning {:on-click delete}
(t locale "viewer.header.share.remove-link")]
[:button.btn-primary {:on-click create}
(t locale "viewer.header.share.create-link")])]]]]))
(mf/defc header
[{:keys [data index local fullscreen? toggle-fullscreen] :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)
locale (i18n/use-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])
on-edit #(st/emit! (rt/nav :workspace
{:project-id project-id
:file-id file-id}
{:page-id page-id}))]
[:header.viewer-header
[:div.main-icon
[:a {:on-click on-edit} i/logo-icon]]
[:div.sitemap-zone {:alt (t locale "viewer.header.sitemap")
:on-click on-click}
[:span.project-name (:name project)]
[:span "/"]
[:span.file-name (:name file)]
[:span "/"]
[:span.page-name (:name page)]
[:span.dropdown-button i/arrow-down]
[:span.counters (str (inc index) " / " total)]]
[:div.options-zone
[:& interactions-menu {:interactions-mode interactions-mode}]
(when-not anonymous?
[:& share-link {:page (:page data)}])
(when-not anonymous?
[:a.btn-text-basic.btn-small {:on-click on-edit}
(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)}]
[:span.btn-icon-dark.btn-small.tooltip.tooltip-bottom
{:alt (t locale "viewer.header.fullscreen")
:on-click toggle-fullscreen}
(if fullscreen?
i/full-screen-off
i/full-screen)]
]]))

View file

@ -0,0 +1,199 @@
;; 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.viewer.shapes
"The main container for a frame in viewer mode"
(:require
[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]
[app.main.ui.shapes.circle :as circle]
[app.main.ui.shapes.frame :as frame]
[app.main.ui.shapes.group :as group]
[app.main.ui.shapes.icon :as icon]
[app.main.ui.shapes.image :as image]
[app.main.ui.shapes.path :as path]
[app.main.ui.shapes.rect :as rect]
[app.main.ui.shapes.text :as text]
[app.util.object :as obj]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as geom]))
(defn on-mouse-down
[event {:keys [interactions] :as shape}]
(let [interaction (first (filter #(= (:event-type %) :click) interactions))]
(case (:action-type interaction)
:navigate
(let [frame-id (:destination interaction)]
(st/emit! (dv/go-to-frame frame-id)))
nil)))
(defn generic-wrapper-factory
"Wrap some svg shape and add interaction controls"
[component show-interactions?]
(mf/fnc generic-wrapper
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
{:keys [x y width height]} (:selrect shape)
childs (unchecked-get props "childs")
frame (unchecked-get props "frame")
on-mouse-down (mf/use-callback
(mf/deps shape)
#(on-mouse-down % shape))]
[:g.shape {:on-mouse-down on-mouse-down
:cursor (when (:interactions shape) "pointer")}
[:& component {:shape shape
:frame frame
:childs childs}]
(when (and (:interactions shape) show-interactions?)
[:rect {:x (- x 1)
:y (- y 1)
:width (+ width 2)
:height (+ height 2)
:fill "#31EFB8"
:stroke "#31EFB8"
:stroke-width 1
:fill-opacity 0.2}])])))
(defn frame-wrapper
[shape-container show-interactions?]
(generic-wrapper-factory (frame/frame-shape shape-container) show-interactions?))
(defn group-wrapper
[shape-container show-interactions?]
(generic-wrapper-factory (group/group-shape shape-container) show-interactions?))
(defn rect-wrapper
[show-interactions?]
(generic-wrapper-factory rect/rect-shape show-interactions?))
(defn icon-wrapper
[show-interactions?]
(generic-wrapper-factory icon/icon-shape show-interactions?))
(defn image-wrapper
[show-interactions?]
(generic-wrapper-factory image/image-shape show-interactions?))
(defn path-wrapper
[show-interactions?]
(generic-wrapper-factory path/path-shape show-interactions?))
(defn text-wrapper
[show-interactions?]
(generic-wrapper-factory text/text-shape show-interactions?))
(defn circle-wrapper
[show-interactions?]
(generic-wrapper-factory circle/circle-shape show-interactions?))
(declare shape-container-factory)
(defn frame-container-factory
[objects show-interactions?]
(let [shape-container (shape-container-factory objects show-interactions?)
frame-wrapper (frame-wrapper shape-container show-interactions?)]
(mf/fnc frame-container
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
childs (mapv #(get objects %) (:shapes shape))
shape (geom/transform-shape shape)
props (obj/merge! #js {} props
#js {:shape shape
:childs childs
:show-interactions? show-interactions?})]
[:> frame-wrapper props]))))
(defn group-container-factory
[objects show-interactions?]
(let [shape-container (shape-container-factory objects show-interactions?)
group-wrapper (group-wrapper shape-container show-interactions?)]
(mf/fnc group-container
{::mf/wrap-props false}
[props]
(let [shape (unchecked-get props "shape")
childs (mapv #(get objects %) (:shapes shape))
props (obj/merge! #js {} props
#js {:childs childs
:show-interactions? show-interactions?})]
[:> group-wrapper props]))))
(defn shape-container-factory
[objects show-interactions?]
(let [path-wrapper (path-wrapper show-interactions?)
text-wrapper (text-wrapper show-interactions?)
icon-wrapper (icon-wrapper show-interactions?)
rect-wrapper (rect-wrapper show-interactions?)
image-wrapper (image-wrapper show-interactions?)
circle-wrapper (circle-wrapper show-interactions?)]
(mf/fnc shape-container
{::mf/wrap-props false}
[props]
(let [group-container (mf/use-memo
(mf/deps objects)
#(group-container-factory objects show-interactions?))
shape (unchecked-get props "shape")
frame (unchecked-get props "frame")]
(when (and shape (not (:hidden shape)))
(let [shape (geom/transform-shape frame shape)
opts #js {:shape shape}]
(case (:type shape)
:curve [:> path-wrapper opts]
:text [:> text-wrapper opts]
:icon [:> icon-wrapper opts]
:rect [:> rect-wrapper opts]
:path [:> path-wrapper opts]
:image [:> image-wrapper opts]
:circle [:> circle-wrapper opts]
:group [:> group-container
{:shape shape
:frame frame}])))))))
(mf/defc frame-svg
{::mf/wrap [mf/memo]}
[{:keys [objects frame zoom show-interactions?] :or {zoom 1} :as props}]
(let [modifier (-> (gpt/point (:x frame) (:y frame))
(gpt/negate)
(gmt/translate-matrix))
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))
objects (reduce update-fn objects modifier-ids)
frame (assoc-in frame [:modifiers :displacement] modifier)
width (* (:width frame) zoom)
height (* (:height frame) zoom)
vbox (str "0 0 " (:width frame 0)
" " (:height frame 0))
wrapper (mf/use-memo
(mf/deps objects)
#(frame-container-factory objects show-interactions?))]
[: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? show-interactions?
:view-box vbox}]]))

View file

@ -0,0 +1,135 @@
;; 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.viewer.thumbnails
(:require
[goog.events :as events]
[goog.object :as gobj]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.common.data :as d]
[app.main.store :as st]
[app.main.data.viewer :as dv]
[app.main.ui.components.dropdown :refer [dropdown']]
[app.main.ui.shapes.frame :as frame]
[app.main.exports :as exports]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.util.i18n :as i18n :refer [t tr]]
[app.common.math :as mth]
[app.util.router :as rt]
[app.main.data.viewer :as vd])
(:import goog.events.EventType
goog.events.KeyCodes))
(mf/defc thumbnails-content
[{:keys [children expanded? total] :as props}]
(let [container (mf/use-ref)
width (mf/use-var (.. js/document -documentElement -clientWidth))
element-width (mf/use-var 152)
offset (mf/use-state 0)
on-left-arrow-click
(fn [event]
(swap! offset (fn [v]
(if (pos? v)
(dec v)
v))))
on-right-arrow-click
(fn [event]
(let [visible (/ @width @element-width)
max-val (- total visible)]
(swap! offset (fn [v]
(if (< v max-val)
(inc v)
v)))))
on-scroll
(fn [event]
(if (pos? (.. event -nativeEvent -deltaY))
(on-right-arrow-click event)
(on-left-arrow-click event)))
on-mount
(fn []
(let [dom (mf/ref-val container)]
(reset! width (gobj/get dom "clientWidth"))))]
(mf/use-effect on-mount)
(if expanded?
[:div.thumbnails-content
[:div.thumbnails-list-expanded children]]
[:div.thumbnails-content
[:div.left-scroll-handler {:on-click on-left-arrow-click} i/arrow-slide]
[:div.right-scroll-handler {:on-click on-right-arrow-click} i/arrow-slide]
[:div.thumbnails-list {:ref container :on-wheel on-scroll}
[:div.thumbnails-list-inside {:style {:right (str (* @offset 152) "px")}}
children]]])))
(mf/defc thumbnails-summary
[{:keys [on-toggle-expand on-close total] :as props}]
[:div.thumbnails-summary
[:span.counter (str total " frames")]
[:span.buttons
[:span.btn-expand {:on-click on-toggle-expand} i/arrow-down]
[:span.btn-close {:on-click on-close} i/close]]])
(mf/defc thumbnail-item
[{:keys [selected? frame on-click index objects] :as props}]
[:div.thumbnail-item {:on-click #(on-click % index)}
[:div.thumbnail-preview
{:class (classnames :selected selected?)}
[:& exports/frame-svg {:frame frame :objects objects}]]
[:div.thumbnail-info
[:span.name (:name frame)]]])
(mf/defc thumbnails-panel
[{:keys [data index] :as props}]
(let [expanded? (mf/use-state false)
container (mf/use-ref)
page-id (get-in data [:page :id])
on-close #(st/emit! dv/toggle-thumbnails-panel)
selected (mf/use-var false)
on-mouse-leave
(fn [event]
(when @selected
(on-close)))
on-item-click
(fn [event index]
(compare-and-set! selected false true)
(st/emit! (rt/nav :viewer {:page-id page-id} {:index index}))
(when @expanded?
(on-close)))]
[:& dropdown' {:on-close on-close
:container container
:show true}
[:section.viewer-thumbnails
{:class (classnames :expanded @expanded?)
:ref container
:on-mouse-leave on-mouse-leave}
[:& thumbnails-summary {:on-toggle-expand #(swap! expanded? not)
:on-close on-close
:total (count (:frames data))}]
[:& thumbnails-content {:expanded? @expanded?
:total (count (:frames data))}
(for [[i frame] (d/enumerate (:frames data))]
[:& thumbnail-item {:key i
:index i
:frame frame
:objects (:objects data)
:on-click on-item-click
:selected? (= i index)}])]]]))

View file

@ -0,0 +1,129 @@
;; 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
(:require
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.constants :as c]
[app.main.data.history :as udh]
[app.main.data.workspace :as dw]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.streams :as ms]
[app.main.ui.keyboard :as kbd]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.viewport :refer [viewport coordinates]]
[app.main.ui.workspace.colorpalette :refer [colorpalette]]
[app.main.ui.workspace.context-menu :refer [context-menu]]
[app.main.ui.workspace.header :refer [header]]
[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.sidebar.history :refer [history-dialog]]
[app.main.ui.workspace.left-toolbar :refer [left-toolbar]]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]
[app.common.geom.point :as gpt]))
;; --- Workspace
(mf/defc workspace-content
[{:keys [page file layout project] :as params}]
(let [local (mf/deref refs/workspace-local)
left-sidebar? (:left-sidebar? local)
right-sidebar? (:right-sidebar? local)
classes (classnames
:no-tool-bar-right (not right-sidebar?)
:no-tool-bar-left (not left-sidebar?))]
[:*
(when (:colorpalette layout)
[:& colorpalette {:left-sidebar? left-sidebar?
:project project}])
[:section.workspace-content {:class classes}
[:& history-dialog]
[:section.workspace-viewport
(when (contains? layout :rules)
[:*
[:div.empty-rule-square]
[:& horizontal-rule {:zoom (:zoom local)
:vbox (:vbox local)
:vport (:vport local)}]
[:& vertical-rule {:zoom (:zoom local 1)
:vbox (:vbox local)
:vport (:vport local)}]
[:& coordinates]])
[:& viewport {:page page
:key (:id page)
:file file
:local local
:layout layout}]]]
[:& left-toolbar {:page page :layout layout}]
;; Aside
(when left-sidebar?
[:& left-sidebar {:file file :page page :layout layout}])
(when right-sidebar?
[:& right-sidebar {:page page
:local local
:layout layout}])]))
(mf/defc workspace-page
[{:keys [project file layout page-id] :as props}]
(mf/use-effect
(mf/deps page-id)
(fn []
(st/emit! (dw/initialize-page page-id))
#(st/emit! (dw/finalize-page page-id))))
(when-let [page (mf/deref refs/workspace-page)]
[:& workspace-content {:page page
:project project
:file file
:layout layout}]))
(mf/defc workspace-loader
[]
[:div.workspace-loader
i/loader-pencil])
(mf/defc workspace
[{:keys [project-id file-id page-id] :as props}]
(mf/use-effect #(st/emit! dw/initialize-layout))
(mf/use-effect
(mf/deps project-id file-id)
(fn []
(st/emit! (dw/initialize-file project-id file-id))
#(st/emit! (dw/finalize-file project-id file-id))))
(hooks/use-shortcuts dw/shortcuts)
(let [file (mf/deref refs/workspace-file)
project (mf/deref refs/workspace-project)
layout (mf/deref refs/workspace-layout)]
[:section#workspace
[:& header {:file file
:project project
:layout layout}]
[:& context-menu]
(if (and (and file project)
(:initialized file))
[:& workspace-page {:file file
:project project
:layout layout
:page-id page-id}]
[:& workspace-loader])]))

View file

@ -0,0 +1,173 @@
;; 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.colorpalette
(:require
[beicon.core :as rx]
[goog.events :as events]
[okulary.core :as l]
[rumext.alpha :as mf]
[app.common.math :as mth]
;; [app.main.data.library :as dlib]
[app.main.data.workspace :as udw]
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.color :refer [hex->rgb]]
[app.util.dom :as dom]
[app.util.object :as obj]))
;; --- Refs
(def palettes-ref
(-> (l/in [:library :palettes])
(l/derived st/state)))
(def selected-palette-ref
(-> (l/in [:library-selected :palettes])
(l/derived st/state)))
(defn- make-selected-palette-item-ref
[lib-id]
(-> (l/in [:library-items :palettes lib-id])
(l/derived st/state)))
;; --- Components
(mf/defc palette-item
[{:keys [color] :as props}]
(let [rgb-vec (hex->rgb color)
select-color
(fn [event]
(if (kbd/shift? event)
(st/emit! (udw/update-color-on-selected-shapes {:stroke-color color}))
(st/emit! (udw/update-color-on-selected-shapes {:fill-color color}))))]
[:div.color-cell {:key (str color)
:on-click select-color}
[:span.color {:style {:background color}}]
[:span.color-text color]]))
(mf/defc palette
[{:keys [palettes selected left-sidebar?] :as props}]
(let [items-ref (mf/use-memo
(mf/deps selected)
(partial make-selected-palette-item-ref selected))
items (mf/deref items-ref)
state (mf/use-state {:show-menu false })
width (:width @state 0)
visible (mth/round (/ width 66))
offset (:offset @state 0)
max-offset (- (count items)
visible)
close-fn #(st/emit! (udw/toggle-layout-flags :colorpalette))
container (mf/use-ref nil)
on-left-arrow-click
(mf/use-callback
(mf/deps max-offset visible)
(fn [event]
(swap! state update :offset
(fn [offset]
(if (pos? offset)
(max (- offset (mth/round (/ visible 2))) 0)
offset)))))
on-right-arrow-click
(mf/use-callback
(mf/deps max-offset visible)
(fn [event]
(swap! state update :offset
(fn [offset]
(if (< offset max-offset)
(min max-offset (+ offset (mth/round (/ visible 2))))
offset)))))
on-scroll
(mf/use-callback
(mf/deps max-offset)
(fn [event]
(if (pos? (.. event -nativeEvent -deltaY))
(on-right-arrow-click event)
(on-left-arrow-click event))))
on-resize
(mf/use-callback
(fn [event]
(let [dom (mf/ref-val container)
width (obj/get dom "clientWidth")]
(swap! state assoc :width width))))
handle-click
(mf/use-callback
(fn [library]))]
;; (st/emit! (dlib/select-library :palettes (:id library)))))]
(mf/use-layout-effect
#(let [dom (mf/ref-val container)
width (obj/get dom "clientWidth")]
(swap! state assoc :width width)))
(mf/use-effect
#(let [key1 (events/listen js/window "resize" on-resize)]
(fn []
(events/unlistenByKey key1))))
(mf/use-effect
(mf/deps selected)
(fn []
(when selected)))
;; (st/emit! (dlib/retrieve-library-data :palettes selected)))))
[:div.color-palette {:class (when left-sidebar? "left-sidebar-open")}
[:& context-menu
{:selectable true
:selected (->> palettes
(filter #(= (:id %) selected))
first
:name)
:show (:show-menu @state)
:on-close #(swap! state assoc :show-menu false)
:options (mapv #(vector (:name %) (partial handle-click %)) palettes)}]
[:div.color-palette-actions
{:on-click #(swap! state assoc :show-menu true)}
[:div.color-palette-actions-button i/actions]]
[:span.left-arrow {:on-click on-left-arrow-click} i/arrow-slide]
[:div.color-palette-content {:ref container :on-wheel on-scroll}
[:div.color-palette-inside {:style {:position "relative"
:right (str (* 66 offset) "px")}}
(for [item items]
[:& palette-item {:color (:content item) :key (:id item)}])]]
[:span.right-arrow {:on-click on-right-arrow-click} i/arrow-slide]]))
(mf/defc colorpalette
[{:keys [left-sidebar? project] :as props}]
(let [team-id (:team-id project)
palettes (->> (mf/deref palettes-ref)
(vals)
(mapcat identity))
selected (or (mf/deref selected-palette-ref)
(:id (first palettes)))]
(mf/use-effect
(mf/deps team-id)
(fn []))
;; (st/emit! (dlib/retrieve-libraries :palettes)
;; (dlib/retrieve-libraries :palettes team-id))))
[:& palette {:left-sidebar? left-sidebar?
:selected selected
:palettes palettes}]))

View file

@ -0,0 +1,28 @@
;; 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) 2016-2017 Juan de la Cruz <delacruzgarciajuan@gmail.com>
;; Copyright (c) 2016-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.workspace.colorpicker
(:require
[rumext.alpha :as mf]
[app.main.store :as st]
[app.main.ui.colorpicker :as cp]))
;; --- Color Picker Modal
(mf/defc colorpicker-modal
[{:keys [x y default value opacity page on-change disable-opacity] :as props}]
[:div.modal-overlay.transparent
[:div.colorpicker-tooltip
{:style {:left (str (- x 270) "px")
:top (str (- y 50) "px")}}
[:& cp/colorpicker {:value (or value default)
:opacity (or opacity 1)
:colors (into-array @cp/most-used-colors)
:on-change on-change
:disable-opacity disable-opacity}]]])

View file

@ -0,0 +1,157 @@
;; 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.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.main.data.workspace :as dw]
[app.main.ui.hooks :refer [use-rxsub]]
[app.main.ui.components.dropdown :refer [dropdown]]))
(def menu-ref
(l/derived :context-menu refs/workspace-local))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc menu-entry
[{:keys [title shortcut on-click] :as props}]
[:li {:on-click on-click}
[:span.title title]
[:span.shortcut (or shortcut "")]])
(mf/defc menu-separator
[props]
[:li.separator])
(mf/defc shape-context-menu
[{:keys [mdata] :as props}]
(let [{:keys [id] :as shape} (:shape mdata)
selected (:selected mdata)
do-duplicate #(st/emit! dw/duplicate-selected)
do-delete #(st/emit! dw/delete-selected)
do-copy #(st/emit! dw/copy-selected)
do-paste #(st/emit! dw/paste)
do-bring-forward #(st/emit! (dw/vertical-order-selected :up))
do-bring-to-front #(st/emit! (dw/vertical-order-selected :top))
do-send-backward #(st/emit! (dw/vertical-order-selected :down))
do-send-to-back #(st/emit! (dw/vertical-order-selected :bottom))
do-show-shape #(st/emit! (dw/update-shape-flags id {:hidden false}))
do-hide-shape #(st/emit! (dw/update-shape-flags id {:hidden true}))
do-lock-shape #(st/emit! (dw/update-shape-flags id {:blocked true}))
do-unlock-shape #(st/emit! (dw/update-shape-flags id {:blocked false}))
do-create-group #(st/emit! dw/group-selected)
do-remove-group #(st/emit! dw/ungroup-selected)]
[:*
[:& menu-entry {:title "Copy"
:shortcut "Ctrl + c"
:on-click do-copy}]
[:& menu-entry {:title "Paste"
:shortcut "Ctrl + v"
:on-click do-paste}]
[:& menu-entry {:title "Duplicate"
:shortcut "Ctrl + d"
:on-click do-duplicate}]
[:& menu-separator]
[:& menu-entry {:title "Bring forward"
:shortcut "Ctrl + ↑"
:on-click do-bring-forward}]
[:& menu-entry {:title "Bring to front"
:shortcut "Ctrl + Shift + ↑"
:on-click do-bring-to-front}]
[:& menu-entry {:title "Send backward"
:shortcut "Ctrl + ↓"
:on-click do-send-backward}]
[:& menu-entry {:title "Send to back"
:shortcut "Ctrl + Shift + ↓"
:on-click do-send-to-back}]
[:& menu-separator]
(when (> (count selected) 1)
[:& menu-entry {:title "Group"
:shortcut "Ctrl + g"
:on-click do-create-group}])
(when (and (= (count selected) 1) (= (:type shape) :group))
[:& menu-entry {:title "Ungroup"
:shortcut "Shift + g"
:on-click do-remove-group}])
(if (:hidden shape)
[:& menu-entry {:title "Show"
:on-click do-show-shape}]
[:& menu-entry {:title "Hide"
:on-click do-hide-shape}])
(if (:blocked shape)
[:& menu-entry {:title "Unlock"
:on-click do-unlock-shape}]
[:& menu-entry {:title "Lock"
:on-click do-lock-shape}])
[:& menu-separator]
[:& menu-entry {:title "Delete"
:shortcut "Supr"
:on-click do-delete}]
]))
(mf/defc viewport-context-menu
[{:keys [mdata] :as props}]
(let [do-paste #(st/emit! dw/paste)]
[:*
[:& menu-entry {:title "Paste"
:shortcut "Ctrl + v"
:on-click do-paste}]]))
(mf/defc context-menu
[props]
(let [mdata (mf/deref menu-ref)
top (- (get-in mdata [:position :y]) 20)
left (get-in mdata [:position :x])
dropdown-ref (mf/use-ref)]
(mf/use-effect
(mf/deps mdata)
#(let [dropdown (mf/ref-val dropdown-ref)]
(when dropdown
(let [bounding-rect (dom/get-bounding-rect dropdown)
window-size (dom/get-window-size)
delta-x (max (- (:right bounding-rect) (:width window-size)) 0)
delta-y (max (- (:bottom bounding-rect) (:height window-size)) 0)
new-style (str "top: " (- top delta-y) "px; "
"left: " (- left delta-x) "px;")]
(when (or (> delta-x 0) (> delta-y 0))
(.setAttribute ^js dropdown "style" new-style))))))
[:& dropdown {:show (boolean mdata)
:on-close #(st/emit! dw/hide-context-menu)}
[:ul.workspace-context-menu
{:ref dropdown-ref
:style {:top top :left left}
:on-context-menu prevent-default}
(if (:shape mdata)
[:& shape-context-menu {:mdata mdata}]
[:& viewport-context-menu {:mdata mdata}])]]))

View file

@ -0,0 +1,76 @@
;; 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-2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.workspace.drawarea
"Drawing components."
(:require
[rumext.alpha :as mf]
[app.main.data.workspace :as dw]
[app.main.data.workspace.drawing :as dd]
[app.main.store :as st]
[app.main.ui.workspace.shapes :as shapes]
[app.common.geom.shapes :as gsh]
[app.common.data :as d]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]))
(declare generic-draw-area)
(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}])))
(mf/defc generic-draw-area
[{:keys [shape zoom]}]
(let [{:keys [x y width height] :as kk} (:selrect (gsh/transform-shape shape))]
(when (and x y
(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)}}]])))
(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}])])))

View file

@ -0,0 +1,84 @@
;; 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.frame-grid
(:require
[rumext.alpha :as mf]
[app.main.refs :as refs]
[app.common.pages :as cp]
[app.common.geom.shapes :as gsh]
[app.util.geom.grid :as gg]))
(mf/defc square-grid [{:keys [frame zoom grid] :as props}]
(let [{:keys [color size] :as params} (-> grid :params)
{color-value :value color-opacity :opacity} (-> grid :params :color)
{frame-width :width frame-height :height :keys [x y]} frame]
(when (> size 0)
[:g.grid
[:*
(for [xs (range size frame-width size)]
[:line {:key (str (:id frame) "-y-" xs)
:x1 (+ x xs)
:y1 y
:x2 (+ x xs)
:y2 (+ y frame-height)
:style {:stroke color-value
:stroke-opacity color-opacity
:stroke-width (str (/ 1 zoom))}}])
(for [ys (range size frame-height size)]
[:line {:key (str (:id frame) "-x-" ys)
:x1 x
:y1 (+ y ys)
:x2 (+ x frame-width)
:y2 (+ y ys)
:style {:stroke color-value
:stroke-opacity color-opacity
:stroke-width (str (/ 1 zoom))}}])]])))
(mf/defc layout-grid [{:keys [key frame zoom grid]}]
(let [{color-value :value color-opacity :opacity} (-> grid :params :color)
gutter (-> grid :params :gutter)
gutter? (and (not (nil? gutter)) (not= gutter 0))
style (if gutter?
#js {:fill color-value
:opacity color-opacity}
#js {:stroke color-value
:strokeOpacity color-opacity
:fill "transparent"})]
[:g.grid
(for [{:keys [x y width height]} (gg/grid-areas frame grid)]
[:rect {:key (str key "-" x "-" y)
:x x
:y y
:width width
:height height
:style style}])]))
(mf/defc grid-display-frame [{:keys [frame zoom]}]
(let [grids (:grids frame)]
(for [[index {:keys [type display] :as grid}] (map-indexed vector grids)]
(let [props #js {:key (str (:id frame) "-grid-" index)
:frame frame
:zoom zoom
:grid grid}]
(when display
(case type
:square [:> square-grid props]
:column [:> layout-grid props]
:row [:> layout-grid props]))))))
(mf/defc frame-grid [{:keys [zoom]}]
(let [frames (mf/deref refs/workspace-frames)]
[:g.grid-display {:style {:pointer-events "none"}}
(for [frame frames]
[:& grid-display-frame {:key (str "grid-" (:id frame))
:zoom zoom
:frame (gsh/transform-shape frame)}])]))

View file

@ -0,0 +1,186 @@
;; 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) 2015-2017 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2015-2017 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.workspace.header
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i :include-macros true]
[app.config :as cfg]
[app.main.data.history :as udh]
[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.ui.modal :as modal]
[app.main.ui.confirm :refer [confirm-dialog]]
[app.main.ui.workspace.presence :as presence]
[app.util.i18n :as i18n :refer [t]]
[app.util.data :refer [classnames]]
[app.common.math :as mth]
[app.util.router :as rt]))
;; --- Zoom Widget
(mf/defc zoom-widget
{:wrap [mf/memo]}
[{:keys [zoom
on-increase
on-decrease
on-zoom-reset
on-zoom-fit
on-zoom-selected]
:as props}]
(let [show-dropdown? (mf/use-state false)]
[:div.zoom-widget {:on-click #(reset! show-dropdown? true)}
[:span {} (str (mth/round (* 100 zoom)) "%")]
[:span.dropdown-button i/arrow-down]
[:& dropdown {:show @show-dropdown?
:on-close #(reset! show-dropdown? false)}
[:ul.zoom-dropdown
[:li {:on-click on-increase}
"Zoom in" [:span "+"]]
[:li {:on-click on-decrease}
"Zoom out" [:span "-"]]
[:li {:on-click on-zoom-reset}
"Zoom to 100%" [:span "Shift + 0"]]
[:li {:on-click on-zoom-fit}
"Zoom to fit all" [:span "Shift + 1"]]
[:li {:on-click on-zoom-selected}
"Zoom to selected" [:span "Shift + 2"]]]]]))
;; --- Header Users
(mf/defc menu
[{:keys [layout project file] :as props}]
(let [show-menu? (mf/use-state false)
locale (i18n/use-locale)
add-shared-fn #(st/emit! nil (dw/set-file-shared (:id file) true))
on-add-shared
#(modal/show! confirm-dialog
{:message (t locale "dashboard.grid.add-shared-message" (:name file))
:hint (t locale "dashboard.grid.add-shared-hint")
:accept-text (t locale "dashboard.grid.add-shared-accept")
:not-danger? true
:on-accept add-shared-fn})
remove-shared-fn #(st/emit! nil (dw/set-file-shared (:id file) false))
on-remove-shared
#(modal/show! confirm-dialog
{:message (t locale "dashboard.grid.remove-shared-message" (:name file))
:hint (t locale "dashboard.grid.remove-shared-hint")
:accept-text (t locale "dashboard.grid.remove-shared-accept")
:not-danger? false
:on-accept remove-shared-fn})]
[:div.menu-section
[:div.btn-icon-dark.btn-small {:on-click #(reset! show-menu? true)} i/actions]
[:div.project-tree {:alt (t locale "header.sitemap")}
[:span.project-name (:name project) " /"]
[:span (:name file)]]
(when (:is-shared file)
[:div.shared-badge i/library])
[:& dropdown {:show @show-menu?
:on-close #(reset! show-menu? false)}
[:ul.menu
[: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"))]
[:span.shortcut "Ctrl+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"))]
[: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"))]
[: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"))]
[: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"))]
[: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"))]
[:span.shortcut "Ctrl+i"]]
[: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"]]
(if (:is-shared file)
[:li {:on-click on-remove-shared}
[:span (t locale "dashboard.grid.remove-shared")]]
[:li {:on-click on-add-shared}
[:span (t locale "dashboard.grid.add-shared")]])
]]]))
;; --- Header Component
(mf/defc header
[{:keys [file layout project] :as props}]
(let [locale (i18n/use-locale)
team-id (:team-id project)
go-back #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
zoom (mf/deref refs/selected-zoom)
page (mf/deref refs/workspace-page)
locale (i18n/use-locale)
router (mf/deref refs/router)
view-url (rt/resolve router :viewer {:page-id (:id page)} {:index 0})]
[:header.workspace-header
[:div.main-icon
[:a {:on-click go-back} i/logo-icon]]
[:& menu {:layout layout
:project project
:file file}]
[:div.users-section
[:& presence/active-sessions]]
[:div.options-section
[:& zoom-widget
{:zoom zoom
:on-increase #(st/emit! (dw/increase-zoom nil))
:on-decrease #(st/emit! (dw/decrease-zoom nil))
:on-zoom-reset #(st/emit! dw/reset-zoom)
: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]]]))

View file

@ -0,0 +1,115 @@
;; 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) 2015-2020 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2015-2020 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.workspace.left-toolbar
(:require
[rumext.alpha :as mf]
[app.common.media :as cm]
[app.main.refs :as refs]
[app.main.data.workspace :as dw]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.main.ui.icons :as i]))
;; --- Component: Left toolbar
(mf/defc left-toolbar
[{:keys [page layout] :as props}]
(let [file-input (mf/use-ref nil)
selected-drawtool (mf/deref refs/selected-drawing-tool)
select-drawtool #(st/emit! :interrupt
(dw/select-for-drawing %))
file (mf/deref refs/workspace-file)
locale (i18n/use-locale)
on-image #(dom/click (mf/ref-val file-input))
on-uploaded
(fn [{:keys [id name] :as image}]
(let [shape {:name name
:metadata {:width (:width image)
:height (:height image)
:id (:id image)
:path (:path image)}}
aspect-ratio (/ (:width image) (:height image))]
(st/emit! (dw/create-and-add-shape :image shape aspect-ratio))))
on-files-selected
(fn [js-files]
(st/emit! (dw/upload-media-objects
(with-meta {:file-id (:id file)
:local? true
:js-files js-files}
{:on-success on-uploaded}))))]
[:aside.left-toolbar
[:div.left-toolbar-inside
[:ul.left-toolbar-options
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.frame")
:class (when (= selected-drawtool :frame) "selected")
:on-click (partial select-drawtool :frame)}
i/artboard]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.rect")
:class (when (= selected-drawtool :rect) "selected")
:on-click (partial select-drawtool :rect)}
i/box]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.circle")
:class (when (= selected-drawtool :circle) "selected")
:on-click (partial select-drawtool :circle)}
i/circle]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.text")
:class (when (= selected-drawtool :text) "selected")
:on-click (partial select-drawtool :text)}
i/text]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.image")
:on-click on-image}
[:*
i/image
[:& file-uploader {: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")
:on-click (partial select-drawtool :curve)}
i/pencil]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.path")
:class (when (= selected-drawtool :path) "selected")
:on-click (partial select-drawtool :path)}
i/curve]]
[:ul.left-toolbar-options.panels
[:li.tooltip.tooltip-right
{:alt "Layers"
:class (when (contains? layout :layers) "selected")
:on-click #(st/emit! (dw/toggle-layout-flags :sitemap :layers))}
i/layers]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.assets")
:class (when (contains? layout :assets) "selected")
:on-click #(st/emit! (dw/toggle-layout-flags :assets))}
i/icon-set]
[:li.tooltip.tooltip-right
{:alt "History"}
i/undo-history]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.color-palette")
:class (when (contains? layout :colorpalette) "selected")
:on-click #(st/emit! (dw/toggle-layout-flags :colorpalette))}
i/palette]]]]))

View file

@ -0,0 +1,155 @@
;; 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) 2016 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2016 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.workspace.libraries
(:require
[rumext.alpha :as mf]
[cuerdas.core :as str]
[app.util.dom :as dom]
[app.util.i18n :refer (tr)]
[app.util.data :refer [classnames matches-search]]
[app.main.store :as st]
[app.main.refs :as refs]
[app.main.data.workspace :as dw]
[app.main.ui.icons :as i]
[app.main.ui.modal :as modal]))
(mf/defc libraries-tab
[{:keys [file libraries shared-files] :as props}]
(let [state (mf/use-state {:search-term ""})
sorted-libraries (->> (vals libraries)
(sort-by #(str/lower (:name %))))
filtered-files (->> shared-files
(filter #(not= (:id %) (:id file)))
(filter #(nil? (get libraries (:id %))))
(filter #(matches-search (:name %) (:search-term @state)))
(sort-by #(str/lower (:name %))))
on-search-term-change (fn [event]
(let [value (-> (dom/get-target event)
(dom/get-value))]
(swap! state assoc :search-term value)))
on-search-clear-click (fn [event]
(swap! state assoc :search-term ""))
link-library (fn [library-id]
(st/emit! (dw/link-file-to-library (:id file) library-id)))
unlink-library (fn [library-id]
(st/emit! (dw/unlink-file-from-library (:id file) library-id)))
contents-str (fn [library graphics-count colors-count]
(str
(str/join " · "
(cond-> []
(< 0 graphics-count)
(conj (tr "workspace.libraries.graphics" graphics-count))
(< 0 colors-count)
(conj (tr "workspace.libraries.colors" colors-count))))
"\u00A0"))] ;; Include a &nbsp; so this block has always some content
[:*
[:div.section
[:div.section-title (tr "workspace.libraries.in-this-file")]
[:div.section-list
[:div.section-list-item
[:div.item-name (tr "workspace.libraries.file-library")]
[:div.item-contents (contents-str file
(count (:media-objects file))
(count (:colors file)))]]
(for [library sorted-libraries]
[:div.section-list-item {:key (:id library)}
[:div.item-name (:name library)]
[:div.item-contents (contents-str library
(count (:media-objects library))
(count (:colors library)))]
[:input.item-button {:type "button"
:value (tr "workspace.libraries.remove")
:on-click #(unlink-library (:id library))}]])
]]
[:div.section
[:div.section-title (tr "workspace.libraries.shared-libraries")]
[:div.libraries-search
[:input.search-input
{:placeholder (tr "workspace.libraries.search-shared-libraries")
:type "text"
:value (:search-term @state)
:on-change on-search-term-change}]
(if (str/empty? (:search-term @state))
[:div.search-icon
i/search]
[:div.search-icon.search-close
{:on-click on-search-clear-click}
i/close])]
(if (> (count filtered-files) 0)
[:div.section-list
(for [file filtered-files]
[:div.section-list-item {:key (:id file)}
[:div.item-name (:name file)]
[:div.item-contents (contents-str file
(:graphics-count file)
(:colors-count file))]
[:input.item-button {:type "button"
:value (tr "workspace.libraries.add")
:on-click #(link-library (:id file))}]])]
[:div.section-list-empty
i/library
(if (str/empty? (:search-term @state))
(tr "workspace.libraries.no-shared-libraries-available")
(tr "workspace.libraries.no-matches-for" (:search-term @state)))])]]))
(mf/defc updates-tab
[]
[:div])
(mf/defc libraries-dialog
[{:keys [] :as ctx}]
(let [state (mf/use-state {:current-tab :libraries})
current-tab (:current-tab @state)
file (mf/deref refs/workspace-file)
libraries (mf/deref refs/workspace-libraries)
shared-files (mf/deref refs/workspace-shared-files)
change-tab (fn [tab]
(swap! state assoc :current-tab tab))
close (fn [event]
(dom/prevent-default event)
(modal/hide!))]
(mf/use-effect
#(st/emit! (dw/fetch-shared-files)))
[:div.modal-overlay
[:div.modal.libraries-dialog
[:a.close {:on-click close} i/close]
[:div.modal-content
[:div.libraries-header
[:div.header-item
{:class (classnames :active (= current-tab :libraries))
:on-click #(change-tab :libraries)}
(tr "workspace.libraries.libraries")]
[:div.header-item
{:class (classnames :active (= current-tab :updates))
:on-click #(change-tab :updates)}
(tr "workspace.libraries.updates")]]
[:div.libraries-content
(case current-tab
:libraries
[:& libraries-tab {:file file
:libraries libraries
:shared-files shared-files}]
:updates
[:& updates-tab {}])]]]]))

View file

@ -0,0 +1,81 @@
;; 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.presence
(:require
[rumext.alpha :as mf]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.router :as rt]))
(def pointer-icon-path
(str "M5.292 4.027L1.524.26l-.05-.01L0 0l.258 1.524 3.769 3.768zm-.45 "
"0l-.313.314L1.139.95l.314-.314zm-.5.5l-.315.316-3.39-3.39.315-.315 "
"3.39 3.39zM1.192.526l-.668.667L.431.646.64.43l.552.094z"))
(mf/defc session-cursor
[{:keys [session] :as props}]
(let [point (:point session)
color (:color session "#000000")
transform (str "translate(" (:x point) "," (:y point) ") scale(4)")]
[:g.multiuser-cursor {:transform transform}
[:path {:fill color
:d pointer-icon-path
:font-family "sans-serif"}]
[:g {:transform "translate(0 -291.708)"}
[:rect {:width "21.415"
:height "5.292"
:x "6.849"
:y "291.755"
:fill color
:fill-opacity ".893"
:paint-order "stroke fill markers"
:rx ".794"
:ry ".794"}]
[:text {:x "9.811"
:y "295.216"
:fill "#fff"
:stroke-width ".265"
:font-family "Open Sans"
:font-size"2.91"
:font-weight "400"
:letter-spacing"0"
:style {:line-height "1.25"}
:word-spacing "0"}
(:fullname session)]]]))
(mf/defc active-cursors
{::mf/wrap [mf/memo]}
[{:keys [page] :as props}]
(let [sessions (mf/deref refs/workspace-presence)
sessions (->> (vals sessions)
(filter #(= (:id page) (:page-id %))))]
(for [session sessions]
[:& session-cursor {:session session :key (:id session)}])))
(mf/defc session-widget
[{:keys [session self?] :as props}]
(let [photo (:photo-uri session "/images/avatar.jpg")]
[:li.tooltip.tooltip-bottom
{:alt (:fullname session)
:on-click (when self?
#(st/emit! (rt/navigate :settings/profile)))}
[:img {:style {:border-color (:color session)}
:src photo}]]))
(mf/defc active-sessions
{::mf/wrap [mf/memo]}
[]
(let [profile (mf/deref refs/profile)
sessions (mf/deref refs/workspace-presence)]
[:ul.active-users
(for [session (vals sessions)]
[:& session-widget {:session session :key (:id session)}])]))

View file

@ -0,0 +1,120 @@
;; 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.rules
(:require
[rumext.alpha :as mf]
[app.common.math :as mth]
[app.util.object :as obj]))
(defn- calculate-step-size
[zoom]
(cond
(< 0 zoom 0.008) 10000
(< 0.008 zoom 0.015) 5000
(< 0.015 zoom 0.04) 2500
(< 0.04 zoom 0.07) 1000
(< 0.07 zoom 0.2) 500
(< 0.2 zoom 0.5) 250
(< 0.5 zoom 1) 100
(<= 1 zoom 2) 50
(< 2 zoom 4) 25
(< 4 zoom 6) 10
(< 6 zoom 15) 5
(< 15 zoom 25) 2
(< 25 zoom) 1
:else 1))
(defn draw-rule!
[dctx {:keys [zoom size start count type] :or {count 200}}]
(let [txfm (- (* (- 0 start) zoom) 20)
minv (mth/round start)
maxv (mth/round (+ start (/ size zoom)))
step (calculate-step-size zoom)]
(if (= type :horizontal)
(.translate dctx txfm 0)
(.translate dctx 0 txfm))
(obj/set! dctx "font" "12px sourcesanspro")
(obj/set! dctx "fillStyle" "#7B7D85")
(obj/set! dctx "strokeStyle" "#7B7D85")
(obj/set! dctx "textAlign" "center")
(loop [i minv]
(when (< i maxv)
(let [pos (+ (* i zoom) 0)]
(when (= (mod i step) 0)
(.save dctx)
(if (= type :horizontal)
(do
(.fillText dctx (str i) pos 13))
(do
(.translate dctx 12 pos)
(.rotate dctx (/ (* 270 js/Math.PI) 180))
(.fillText dctx (str i) 0 0)))
(.restore dctx))
(recur (inc i)))))
(let [path (js/Path2D.)]
(loop [i minv]
(if (> i maxv)
(.stroke dctx path)
(let [pos (+ (* i zoom) 0)]
(when (= (mod i step) 0)
(if (= type :horizontal)
(do
(.moveTo path pos 17)
(.lineTo path pos 20))
(do
(.moveTo path 17 pos)
(.lineTo path 20 pos))))
(recur (inc i))))))))
(mf/defc horizontal-rule
[{:keys [zoom vbox vport] :as props}]
(let [canvas (mf/use-ref)
width (- (:width vport) 20)]
(mf/use-layout-effect
(mf/deps zoom width (:x vbox))
(fn []
(let [node (mf/ref-val canvas)
dctx (.getContext ^js node "2d")]
(obj/set! node "width" width)
(draw-rule! dctx {:zoom zoom
:type :horizontal
:size width
:start (+ (:x vbox) (:left-offset vbox))}))))
[:canvas.horizontal-rule
{:ref canvas
:width width
:height 20}]))
(mf/defc vertical-rule
[{:keys [zoom vbox vport] :as props}]
(let [canvas (mf/use-ref)
height (- (:height vport) 20)]
(mf/use-layout-effect
(mf/deps zoom height (:y vbox))
(fn []
(let [node (mf/ref-val canvas)
dctx (.getContext ^js node "2d")]
(obj/set! node "height" height)
(draw-rule! dctx {:zoom zoom
:type :vertical
:size height
:count 100
:start (:y vbox)}))))
[:canvas.vertical-rule
{:ref canvas
:width 20
:height height}]))

View file

@ -0,0 +1,73 @@
;; 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 <niwi@niwi.nz>
;; Copyright (c) 2015-2017 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(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)))

View file

@ -0,0 +1,329 @@
;; 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.selection
"Selection handlers component."
(:require
[beicon.core :as rx]
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[rumext.util :refer [map->obj]]
[app.main.data.workspace :as dw]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.streams :as ms]
[app.main.ui.cursors :as cur]
[app.util.dom :as dom]
[app.util.object :as obj]
[app.common.geom.shapes :as geom]
[app.common.geom.point :as gpt]
[app.common.geom.matrix :as gmt]
[app.util.debug :refer [debug?]]
[app.main.ui.workspace.shapes.outline :refer [outline]]))
(def rotation-handler-size 25)
(def resize-point-radius 4)
(def resize-point-circle-radius 10)
(def resize-point-rect-size 8)
(def resize-side-height 8)
(def selection-rect-color "#1FDEA7")
(def selection-rect-width 1)
(mf/defc selection-rect [{:keys [transform rect zoom]}]
(let [{:keys [x y width height]} rect]
[:rect.main
{:x x
:y y
:width width
:height height
:transform transform
:style {:stroke selection-rect-color
:stroke-width (/ selection-rect-width zoom)
:fill "transparent"}}]))
(defn- handlers-for-selection [{:keys [x y width height]}]
[;; TOP-LEFT
{:type :rotation
:position :top-left
:props {:cx x :cy y}}
{:type :resize-point
:position :top-left
:props {:cx x :cy y}}
;; TOP
{: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 }}])
(mf/defc rotation-handler [{:keys [cx cy transform position rotation zoom on-rotate]}]
(let [size (/ rotation-handler-size zoom)
x (- cx (if (#{:top-left :bottom-left} position) size 0))
y (- cy (if (#{:top-left :top-right} position) size 0))
angle (case position
:top-left 0
:top-right 90
:bottom-right 180
:bottom-left 270)]
[:rect {:style {:cursor (cur/rotate (+ rotation angle))}
:x x
:y y
:width size
:height size
:fill (if (debug? :rotation-handler) "blue" "transparent")
:transform transform
:on-mouse-down on-rotate}]))
(mf/defc resize-point-handler
[{:keys [cx cy zoom position on-resize transform rotation]}]
(let [{cx' :x cy' :y} (gpt/transform (gpt/point cx cy) transform)
rot-square (case position
:top-left 0
:top-right 90
:bottom-right 180
:bottom-left 270)]
[:g.resize-handler
[:circle {:r (/ resize-point-radius zoom)
:style {:fillOpacity "1"
:strokeWidth "1px"
:vectorEffect "non-scaling-stroke"
}
:fill "#FFFFFF"
:stroke "#1FDEA7"
:cx cx'
:cy cy'}]
[:circle {:on-mouse-down #(on-resize {:x cx' :y cy'} %)
:r (/ resize-point-circle-radius zoom)
:fill (if (debug? :resize-handler) "red" "transparent")
:cx cx'
:cy cy'
:style {:cursor (if (#{:top-left :bottom-right} position)
(cur/resize-nesw rotation) (cur/resize-nwse rotation))}}]
]))
(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)
:transform (gmt/multiply transform
(gmt/rotate-matrix angle (gpt/point x y)))
:on-mouse-down #(on-resize res-point %)
:style {:fill (if (debug? :resize-handler) "yellow" "transparent")
:cursor (if (#{:left :right} position)
(cur/resize-ew rotation)
(cur/resize-ns rotation)) }}]))
(mf/defc controls
{::mf/wrap-props false}
[props]
(let [shape (obj/get props "shape")
zoom (obj/get props "zoom")
on-resize (obj/get props "on-resize")
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)]
(when (not (#{:move :rotate} current-transform))
[:g.controls
;; Selection rect
[:& selection-rect {:rect selrect
:transform transform
:zoom zoom}]
[:& outline {:shape (geom/transform-shape shape)}]
;; Handlers
(for [{:keys [type position props]} (handlers-for-selection selrect)]
(let [common-props {:key (str (name type) "-" (name position))
:zoom zoom
:position position
:on-rotate on-rotate
:on-resize (partial on-resize position)
:transform transform
:rotation (:rotation shape)}
props (map->obj (merge common-props props))]
(case type
:rotation (when (not= :frame (:type shape)) [:> rotation-handler props])
:resize-point [:> resize-point-handler props]
:resize-side [:> resize-side-handler props])))])))
;; --- Selection Handlers (Component)
(mf/defc path-edition-selection-handlers
[{:keys [shape modifiers zoom] :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 "#1FDEA7"
:style {:cursor cur/move-pointer}}]))])))
;; TODO: add specs for clarity
(mf/defc text-edition-selection-handlers
[{:keys [shape zoom] :as props}]
(let [{:keys [x y width height]} shape]
[:g.controls
[:rect.main {:x x :y y
:transform (geom/transform-matrix shape)
:width width
:height height
:style {:stroke "#1FDEA7"
:stroke-width "0.5"
:stroke-opacity "1"
:fill "transparent"}}]]))
(mf/defc multiple-selection-handlers
[{:keys [shapes selected zoom] :as props}]
(let [shape (geom/selection-rect shapes)
shape-center (geom/center shape)
on-resize (fn [current-position initial-position event]
(dom/stop-propagation event)
(st/emit! (dw/start-resize current-position initial-position selected shape)))
on-rotate #(do (dom/stop-propagation %)
(st/emit! (dw/start-rotate shapes)))]
[:*
[:& controls {:shape shape
:zoom zoom
:on-resize on-resize
:on-rotate on-rotate}]
(when (debug? :selection-center)
[:circle {:cx (:x shape-center) :cy (:y shape-center) :r 5 :fill "yellow"}])]))
(mf/defc single-selection-handlers
[{:keys [shape zoom] :as props}]
(let [shape-id (:id shape)
shape (geom/transform-shape shape)
shape' (if (debug? :simple-selection) (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')))
on-rotate
#(do (dom/stop-propagation %)
(st/emit! (dw/start-rotate [shape])))]
[:*
[:& controls {:shape shape'
:zoom zoom
:on-rotate on-rotate
:on-resize on-resize}]]))
(mf/defc selection-handlers
[{:keys [selected edition zoom] :as props}]
(let [;; We need remove posible nil values because on shape
;; deletion many shape will reamin selected and deleted
;; in the same time for small instant of time
shapes (->> (mf/deref (refs/objects-by-id selected))
(remove nil?))
num (count shapes)
{:keys [id type] :as shape} (first shapes)]
(cond
(zero? num)
nil
(> num 1)
[:& multiple-selection-handlers {:shapes shapes
:selected selected
:zoom zoom}]
(and (= type :text)
(= edition (:id shape)))
[:& text-edition-selection-handlers {:shape shape
:zoom zoom}]
(and (or (= type :path)
(= type :curve))
(= edition (:id shape)))
[:& path-edition-selection-handlers {:shape shape
:zoom zoom}]
:else
[:& single-selection-handlers {:shape shape
:zoom zoom}])))

Some files were not shown because too many files have changed in this diff Show more