mirror of
https://github.com/penpot/penpot.git
synced 2025-06-05 23:41:38 +02:00
♻️ Make the namespacing independent of the branding.
This commit is contained in:
parent
aaf8b71837
commit
6c67c3c71b
305 changed files with 2399 additions and 2580 deletions
26
frontend/src/app/config.cljs
Normal file
26
frontend/src/app/config.cljs
Normal 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))
|
82
frontend/src/app/main.cljs
Normal file
82
frontend/src/app/main.cljs
Normal 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))
|
||||
|
35
frontend/src/app/main/constants.cljs
Normal file
35
frontend/src/app/main/constants.cljs
Normal 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])
|
222
frontend/src/app/main/data/auth.cljs
Normal file
222
frontend/src/app/main/data/auth.cljs
Normal 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)))))
|
109
frontend/src/app/main/data/colors.cljs
Normal file
109
frontend/src/app/main/data/colors.cljs
Normal 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)))))))
|
||||
|
447
frontend/src/app/main/data/dashboard.cljs
Normal file
447
frontend/src/app/main/data/dashboard.cljs
Normal 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})))))
|
||||
|
270
frontend/src/app/main/data/history.cljs
Normal file
270
frontend/src/app/main/data/history.cljs
Normal 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)))))))
|
67
frontend/src/app/main/data/media.cljs
Normal file
67
frontend/src/app/main/data/media.cljs
Normal 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))))
|
||||
|
81
frontend/src/app/main/data/messages.cljs
Normal file
81
frontend/src/app/main/data/messages.cljs
Normal 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})))
|
183
frontend/src/app/main/data/users.cljs
Normal file
183
frontend/src/app/main/data/users.cljs
Normal 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))))))
|
||||
|
242
frontend/src/app/main/data/viewer.cljs
Normal file
242
frontend/src/app/main/data/viewer.cljs
Normal 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)})
|
1508
frontend/src/app/main/data/workspace.cljs
Normal file
1508
frontend/src/app/main/data/workspace.cljs
Normal file
File diff suppressed because it is too large
Load diff
375
frontend/src/app/main/data/workspace/common.cljs
Normal file
375
frontend/src/app/main/data/workspace/common.cljs
Normal 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})))))))
|
||||
|
||||
|
||||
|
285
frontend/src/app/main/data/workspace/drawing.cljs
Normal file
285
frontend/src/app/main/data/workspace/drawing.cljs
Normal 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))))
|
||||
|
85
frontend/src/app/main/data/workspace/grid.cljs
Normal file
85
frontend/src/app/main/data/workspace/grid.cljs
Normal 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}))))))
|
176
frontend/src/app/main/data/workspace/notifications.cljs
Normal file
176
frontend/src/app/main/data/workspace/notifications.cljs
Normal 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))))))
|
||||
|
||||
|
546
frontend/src/app/main/data/workspace/persistence.cljs
Normal file
546
frontend/src/app/main/data/workspace/persistence.cljs
Normal 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)))
|
||||
|
312
frontend/src/app/main/data/workspace/selection.cljs
Normal file
312
frontend/src/app/main/data/workspace/selection.cljs
Normal 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 #{}))))))
|
188
frontend/src/app/main/data/workspace/texts.cljs
Normal file
188
frontend/src/app/main/data/workspace/texts.cljs
Normal 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)))
|
429
frontend/src/app/main/data/workspace/transforms.cljs
Normal file
429
frontend/src/app/main/data/workspace/transforms.cljs
Normal 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))))))
|
156
frontend/src/app/main/exports.cljs
Normal file
156
frontend/src/app/main/exports.cljs
Normal 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}]]))
|
50
frontend/src/app/main/fonts.clj
Normal file
50
frontend/src/app/main/fonts.clj
Normal 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"))))
|
||||
|
||||
|
||||
|
153
frontend/src/app/main/fonts.cljs
Normal file
153
frontend/src/app/main/fonts.cljs
Normal 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)))))
|
||||
|
||||
|
186
frontend/src/app/main/refs.cljs
Normal file
186
frontend/src/app/main/refs.cljs
Normal 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))
|
106
frontend/src/app/main/repo.cljs
Normal file
106
frontend/src/app/main/repo.cljs
Normal 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?)
|
210
frontend/src/app/main/snap.cljs
Normal file
210
frontend/src/app/main/snap.cljs
Normal 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))
|
||||
)))
|
||||
|
70
frontend/src/app/main/store.cljs
Normal file
70
frontend/src/app/main/store.cljs
Normal 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]))))
|
126
frontend/src/app/main/streams.cljs
Normal file
126
frontend/src/app/main/streams.cljs
Normal 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))
|
185
frontend/src/app/main/ui.cljs
Normal file
185
frontend/src/app/main/ui.cljs
Normal 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)
|
96
frontend/src/app/main/ui/auth.cljs
Normal file
96
frontend/src/app/main/ui/auth.cljs
Normal 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]))
|
121
frontend/src/app/main/ui/auth/login.cljs
Normal file
121
frontend/src/app/main/ui/auth/login.cljs
Normal 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")]]]]])
|
97
frontend/src/app/main/ui/auth/recovery.cljs
Normal file
97
frontend/src/app/main/ui/auth/recovery.cljs
Normal 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")]]]]])
|
||||
|
67
frontend/src/app/main/ui/auth/recovery_request.cljs
Normal file
67
frontend/src/app/main/ui/auth/recovery_request.cljs
Normal 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")]]]]])
|
116
frontend/src/app/main/ui/auth/register.cljs
Normal file
116
frontend/src/app/main/ui/auth/register.cljs
Normal 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")]]]]])
|
44
frontend/src/app/main/ui/colorpicker.cljs
Normal file
44
frontend/src/app/main/ui/colorpicker.cljs
Normal 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)))
|
42
frontend/src/app/main/ui/components/context_menu.cljs
Normal file
42
frontend/src/app/main/ui/components/context_menu.cljs
Normal 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]])]]])))
|
50
frontend/src/app/main/ui/components/dropdown.cljs
Normal file
50
frontend/src/app/main/ui/components/dropdown.cljs
Normal 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)))
|
51
frontend/src/app/main/ui/components/editable_label.cljs
Normal file
51
frontend/src/app/main/ui/components/editable_label.cljs
Normal 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]
|
||||
)))
|
71
frontend/src/app/main/ui/components/editable_select.cljs
Normal file
71
frontend/src/app/main/ui/components/editable_select.cljs
Normal 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]])))]]]))
|
41
frontend/src/app/main/ui/components/file_uploader.cljs
Normal file
41
frontend/src/app/main/ui/components/file_uploader.cljs
Normal 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}]]))
|
||||
|
155
frontend/src/app/main/ui/components/forms.cljs
Normal file
155
frontend/src/app/main/ui/components/forms.cljs
Normal 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]]))
|
||||
|
||||
|
||||
|
51
frontend/src/app/main/ui/components/select.cljs
Normal file
51
frontend/src/app/main/ui/components/select.cljs
Normal 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]])))]]]))
|
36
frontend/src/app/main/ui/components/tab_container.cljs
Normal file
36
frontend/src/app/main/ui/components/tab_container.cljs
Normal 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)]]))
|
52
frontend/src/app/main/ui/confirm.cljs
Normal file
52
frontend/src/app/main/ui/confirm.cljs
Normal 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}]]]]]))
|
||||
|
77
frontend/src/app/main/ui/cursors.clj
Normal file
77
frontend/src/app/main/ui/cursors.clj
Normal 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#)))))
|
55
frontend/src/app/main/ui/cursors.cljs
Normal file
55
frontend/src/app/main/ui/cursors.cljs
Normal 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)]])))]))
|
99
frontend/src/app/main/ui/dashboard.cljs
Normal file
99
frontend/src/app/main/ui/dashboard.cljs
Normal 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))]]])))
|
||||
|
58
frontend/src/app/main/ui/dashboard/common.cljs
Normal file
58
frontend/src/app/main/ui/dashboard/common.cljs
Normal 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]])])))
|
||||
|
154
frontend/src/app/main/ui/dashboard/grid.cljs
Normal file
154
frontend/src/app/main/ui/dashboard/grid.cljs
Normal 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")]]])]))
|
44
frontend/src/app/main/ui/dashboard/libraries.cljs
Normal file
44
frontend/src/app/main/ui/dashboard/libraries.cljs
Normal 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}]]]))
|
||||
|
58
frontend/src/app/main/ui/dashboard/profile.cljs
Normal file
58
frontend/src/app/main/ui/dashboard/profile.cljs
Normal 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")]]]]]))
|
86
frontend/src/app/main/ui/dashboard/project.cljs
Normal file
86
frontend/src/app/main/ui/dashboard/project.cljs
Normal 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}]]]))
|
97
frontend/src/app/main/ui/dashboard/recent_files.cljs
Normal file
97
frontend/src/app/main/ui/dashboard/recent_files.cljs
Normal 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))}])]])))
|
||||
|
51
frontend/src/app/main/ui/dashboard/search.cljs
Normal file
51
frontend/src/app/main/ui/dashboard/search.cljs
Normal 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}])]]))
|
||||
|
201
frontend/src/app/main/ui/dashboard/sidebar.cljs
Normal file
201
frontend/src/app/main/ui/dashboard/sidebar.cljs
Normal 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)}]]]))
|
233
frontend/src/app/main/ui/hooks.cljs
Normal file
233
frontend/src/app/main/ui/hooks.cljs
Normal 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)))))
|
21
frontend/src/app/main/ui/icons.clj
Normal file
21
frontend/src/app/main/ui/icons.clj
Normal 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}]])))
|
||||
|
149
frontend/src/app/main/ui/icons.cljs
Normal file
149
frontend/src/app/main/ui/icons.cljs
Normal 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)]]))])
|
22
frontend/src/app/main/ui/keyboard.cljs
Normal file
22
frontend/src/app/main/ui/keyboard.cljs
Normal 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))
|
18
frontend/src/app/main/ui/loader.cljs
Normal file
18
frontend/src/app/main/ui/loader.cljs
Normal 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]))
|
75
frontend/src/app/main/ui/messages.cljs
Normal file
75
frontend/src/app/main/ui/messages.cljs
Normal 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])]])
|
||||
|
61
frontend/src/app/main/ui/modal.cljs
Normal file
61
frontend/src/app/main/ui/modal.cljs
Normal 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)}]))
|
||||
|
||||
|
||||
|
13
frontend/src/app/main/ui/navigation.cljs
Normal file
13
frontend/src/app/main/ui/navigation.cljs
Normal 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]))
|
93
frontend/src/app/main/ui/render.cljs
Normal file
93
frontend/src/app/main/ui/render.cljs
Normal 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}])))
|
39
frontend/src/app/main/ui/settings.cljs
Normal file
39
frontend/src/app/main/ui/settings.cljs
Normal 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))]]))
|
||||
|
||||
|
||||
|
||||
|
109
frontend/src/app/main/ui/settings/change_email.cljs
Normal file
109
frontend/src/app/main/ui/settings/change_email.cljs
Normal 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}])]]))
|
43
frontend/src/app/main/ui/settings/delete_account.cljs
Normal file
43
frontend/src/app/main/ui/settings/delete_account.cljs
Normal 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")]]]]))
|
59
frontend/src/app/main/ui/settings/header.cljs
Normal file
59
frontend/src/app/main/ui/settings/header.cljs
Normal 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")]]]))
|
77
frontend/src/app/main/ui/settings/options.cljs
Normal file
77
frontend/src/app/main/ui/settings/options.cljs
Normal 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}]]]))
|
102
frontend/src/app/main/ui/settings/password.cljs
Normal file
102
frontend/src/app/main/ui/settings/password.cljs
Normal 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}]]]))
|
133
frontend/src/app/main/ui/settings/profile.cljs
Normal file
133
frontend/src/app/main/ui/settings/profile.cljs
Normal 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}]]]))
|
34
frontend/src/app/main/ui/shapes/attrs.cljs
Normal file
34
frontend/src/app/main/ui/shapes/attrs.cljs
Normal 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))
|
41
frontend/src/app/main/ui/shapes/circle.cljs
Normal file
41
frontend/src/app/main/ui/shapes/circle.cljs
Normal 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"}]))
|
97
frontend/src/app/main/ui/shapes/custom_stroke.cljs
Normal file
97
frontend/src/app/main/ui/shapes/custom_stroke.cljs
Normal 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]]))))
|
||||
|
45
frontend/src/app/main/ui/shapes/frame.cljs
Normal file
45
frontend/src/app/main/ui/shapes/frame.cljs
Normal 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)}])])))
|
||||
|
43
frontend/src/app/main/ui/shapes/group.cljs
Normal file
43
frontend/src/app/main/ui/shapes/group.cljs
Normal 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}])])))
|
||||
|
||||
|
47
frontend/src/app/main/ui/shapes/icon.cljs
Normal file
47
frontend/src/app/main/ui/shapes/icon.cljs
Normal 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]))
|
35
frontend/src/app/main/ui/shapes/image.cljs
Normal file
35
frontend/src/app/main/ui/shapes/image.cljs
Normal 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]))
|
67
frontend/src/app/main/ui/shapes/path.cljs
Normal file
67
frontend/src/app/main/ui/shapes/path.cljs
Normal 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"}])))
|
||||
|
36
frontend/src/app/main/ui/shapes/rect.cljs
Normal file
36
frontend/src/app/main/ui/shapes/rect.cljs
Normal 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"}]))
|
||||
|
11
frontend/src/app/main/ui/shapes/shape.cljs
Normal file
11
frontend/src/app/main/ui/shapes/shape.cljs
Normal 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)
|
||||
|
144
frontend/src/app/main/ui/shapes/text.cljs
Normal file
144
frontend/src/app/main/ui/shapes/text.cljs
Normal 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)}]]))
|
||||
|
37
frontend/src/app/main/ui/static.cljs
Normal file
37
frontend/src/app/main/ui/static.cljs
Normal 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"]]]])
|
||||
|
119
frontend/src/app/main/ui/viewer.cljs
Normal file
119
frontend/src/app/main/ui/viewer.cljs
Normal 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}])))
|
182
frontend/src/app/main/ui/viewer/header.cljs
Normal file
182
frontend/src/app/main/ui/viewer/header.cljs
Normal 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)]
|
||||
]]))
|
||||
|
199
frontend/src/app/main/ui/viewer/shapes.cljs
Normal file
199
frontend/src/app/main/ui/viewer/shapes.cljs
Normal 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}]]))
|
||||
|
135
frontend/src/app/main/ui/viewer/thumbnails.cljs
Normal file
135
frontend/src/app/main/ui/viewer/thumbnails.cljs
Normal 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)}])]]]))
|
129
frontend/src/app/main/ui/workspace.cljs
Normal file
129
frontend/src/app/main/ui/workspace.cljs
Normal 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])]))
|
173
frontend/src/app/main/ui/workspace/colorpalette.cljs
Normal file
173
frontend/src/app/main/ui/workspace/colorpalette.cljs
Normal 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}]))
|
28
frontend/src/app/main/ui/workspace/colorpicker.cljs
Normal file
28
frontend/src/app/main/ui/workspace/colorpicker.cljs
Normal 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}]]])
|
||||
|
||||
|
157
frontend/src/app/main/ui/workspace/context_menu.cljs
Normal file
157
frontend/src/app/main/ui/workspace/context_menu.cljs
Normal 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}])]]))
|
||||
|
||||
|
||||
|
76
frontend/src/app/main/ui/workspace/drawarea.cljs
Normal file
76
frontend/src/app/main/ui/workspace/drawarea.cljs
Normal 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}])])))
|
84
frontend/src/app/main/ui/workspace/frame_grid.cljs
Normal file
84
frontend/src/app/main/ui/workspace/frame_grid.cljs
Normal 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)}])]))
|
186
frontend/src/app/main/ui/workspace/header.cljs
Normal file
186
frontend/src/app/main/ui/workspace/header.cljs
Normal 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]]]))
|
||||
|
115
frontend/src/app/main/ui/workspace/left_toolbar.cljs
Normal file
115
frontend/src/app/main/ui/workspace/left_toolbar.cljs
Normal 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]]]]))
|
155
frontend/src/app/main/ui/workspace/libraries.cljs
Normal file
155
frontend/src/app/main/ui/workspace/libraries.cljs
Normal 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 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 {}])]]]]))
|
||||
|
81
frontend/src/app/main/ui/workspace/presence.cljs
Normal file
81
frontend/src/app/main/ui/workspace/presence.cljs
Normal 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)}])]))
|
||||
|
||||
|
120
frontend/src/app/main/ui/workspace/rules.cljs
Normal file
120
frontend/src/app/main/ui/workspace/rules.cljs
Normal 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}]))
|
73
frontend/src/app/main/ui/workspace/scroll.cljs
Normal file
73
frontend/src/app/main/ui/workspace/scroll.cljs
Normal 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)))
|
329
frontend/src/app/main/ui/workspace/selection.cljs
Normal file
329
frontend/src/app/main/ui/workspace/selection.cljs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue