🎉 Merge tokens-studio

This commit is contained in:
Andrey Antukh 2024-10-31 15:57:35 +01:00
parent 0cd446421d
commit b82679deaf
85 changed files with 10581 additions and 168 deletions

View file

@ -0,0 +1,352 @@
;; 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) KALEIDOS INC
(ns app.main.data.tokens
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.geom.point :as gpt]
[app.common.types.shape :as cts]
[app.common.types.tokens-lib :as ctob]
[app.main.data.changes :as dch]
[app.main.data.workspace.shapes :as dwsh]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.token-set :as wtts]
[app.main.ui.workspace.tokens.update :as wtu]
[beicon.v2.core :as rx]
[clojure.data :as data]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TODO HYMA: Copied over from workspace.cljs
(defn update-shape
[id attrs]
(dm/assert!
"expected valid parameters"
(and (cts/check-shape-attrs! attrs)
(uuid? id)))
(ptk/reify ::update-shape
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [id] #(merge % attrs))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS Getters
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn get-tokens-lib [state]
(get-in state [:workspace-data :tokens-lib]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS Actions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn toggle-or-apply-token
"Remove any shape attributes from token if they exists.
Othewise apply token attributes."
[shape token]
(let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)]
(merge {} shape-leftover token-leftover)))
(defn token-from-attributes [token attributes]
(->> (map (fn [attr] [attr (wtt/token-identifier token)]) attributes)
(into {})))
(defn unapply-token-id [shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn apply-token-to-attributes [{:keys [shape token attributes]}]
(let [token (token-from-attributes token attributes)]
(toggle-or-apply-token shape token)))
(defn apply-token-to-shape
[{:keys [shape token attributes] :as _props}]
(let [applied-tokens (apply-token-to-attributes {:shape shape
:token token
:attributes attributes})]
(update shape :applied-tokens #(merge % applied-tokens))))
(defn maybe-apply-token-to-shape
"When the passed `:token` is non-nil apply it to the `:applied-tokens` on a shape."
[{:keys [shape token _attributes] :as props}]
(if token
(apply-token-to-shape props)
shape))
(defn get-token-data-from-token-id
[id]
(let [workspace-data (deref refs/workspace-data)]
(get (:tokens workspace-data) id)))
(defn set-selected-token-set-id
[id]
(ptk/reify ::set-selected-token-set-id
ptk/UpdateEvent
(update [_ state]
(wtts/assoc-selected-token-set-id state id))))
(defn create-token-theme [token-theme]
(let [new-token-theme token-theme]
(ptk/reify ::create-token-theme
ptk/WatchEvent
(watch [it _ _]
(let [changes (-> (pcb/empty-changes it)
(pcb/add-token-theme new-token-theme))]
(rx/of
(dch/commit-changes changes)))))))
(defn update-token-theme [[group name] token-theme]
(ptk/reify ::update-token-theme
ptk/WatchEvent
(watch [it state _]
(let [tokens-lib (get-tokens-lib state)
prev-token-theme (some-> tokens-lib (ctob/get-theme group name))
changes (pcb/update-token-theme (pcb/empty-changes it) token-theme prev-token-theme)]
(rx/of
(dch/commit-changes changes))))))
(defn toggle-token-theme-active? [group name]
(ptk/reify ::toggle-token-theme-active?
ptk/WatchEvent
(watch [it state _]
(let [tokens-lib (get-tokens-lib state)
prev-active-token-themes (some-> tokens-lib
(ctob/get-active-theme-paths))
active-token-themes (some-> tokens-lib
(ctob/toggle-theme-active? group name)
(ctob/get-active-theme-paths))
active-token-themes' (if (= active-token-themes #{ctob/hidden-token-theme-path})
active-token-themes
(disj active-token-themes ctob/hidden-token-theme-path))
changes (-> (pcb/empty-changes it)
(pcb/update-active-token-themes active-token-themes' prev-active-token-themes))]
(rx/of
(dch/commit-changes changes)
(wtu/update-workspace-tokens))))))
(defn delete-token-theme [group name]
(ptk/reify ::delete-token-theme
ptk/WatchEvent
(watch [it state _]
(let [data (get state :workspace-data)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/delete-token-theme group name))]
(rx/of
(dch/commit-changes changes)
(wtu/update-workspace-tokens))))))
(defn create-token-set [token-set]
(let [new-token-set (merge
{:name "Token Set"
:tokens []}
token-set)]
(ptk/reify ::create-token-set
ptk/WatchEvent
(watch [it _ _]
(let [changes (-> (pcb/empty-changes it)
(pcb/add-token-set new-token-set))]
(rx/of
(set-selected-token-set-id (:name new-token-set))
(dch/commit-changes changes)))))))
(defn update-token-set [set-name token-set]
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [prev-token-set (some-> (get-tokens-lib state)
(ctob/get-set set-name))
changes (-> (pcb/empty-changes it)
(pcb/update-token-set token-set prev-token-set))]
(rx/of
(set-selected-token-set-id (:name token-set))
(dch/commit-changes changes))))))
(defn toggle-token-set [{:keys [token-set-name]}]
(ptk/reify ::toggle-token-set
ptk/WatchEvent
(watch [it state _]
(let [tokens-lib (get-tokens-lib state)
prev-theme (ctob/get-theme tokens-lib ctob/hidden-token-theme-group ctob/hidden-token-theme-name)
active-token-set-names (ctob/get-active-themes-set-names tokens-lib)
theme (-> (or (some-> prev-theme
(ctob/set-sets active-token-set-names))
(ctob/make-hidden-token-theme :sets active-token-set-names))
(ctob/toggle-set token-set-name))
prev-active-token-themes (ctob/get-active-theme-paths tokens-lib)
changes (-> (pcb/empty-changes it)
(pcb/update-active-token-themes #{(ctob/token-theme-path ctob/hidden-token-theme-group ctob/hidden-token-theme-name)} prev-active-token-themes))
changes' (if prev-theme
(pcb/update-token-theme changes theme prev-theme)
(pcb/add-token-theme changes theme))]
(rx/of
(dch/commit-changes changes')
(wtu/update-workspace-tokens))))))
(defn import-tokens-lib [lib]
(ptk/reify ::import-tokens-lib
ptk/WatchEvent
(watch [it state _]
(let [data (get state :workspace-data)
update-token-set-change (some-> lib
(ctob/get-sets)
(first)
(:name)
(set-selected-token-set-id))
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-tokens-lib lib))]
(rx/of
(dch/commit-changes changes)
update-token-set-change
(wtu/update-workspace-tokens))))))
(defn delete-token-set [token-set-name]
(ptk/reify ::delete-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (get state :workspace-data)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/delete-token-set token-set-name))]
(rx/of
(dch/commit-changes changes)
(wtu/update-workspace-tokens))))))
(defn move-token-set [source-set-name dest-set-name position]
(ptk/reify ::move-token-set
ptk/WatchEvent
(watch [it state _]
(let [tokens-lib (get-tokens-lib state)
prev-before-set-name (ctob/get-neighbor-set-name tokens-lib source-set-name 1)
[source-set-name' dest-set-name'] (if (= :top position)
[source-set-name dest-set-name]
[source-set-name (ctob/get-neighbor-set-name tokens-lib dest-set-name 1)])
changes (-> (pcb/empty-changes it)
(pcb/move-token-set-before source-set-name' dest-set-name' prev-before-set-name))]
(rx/of
(dch/commit-changes changes)
(wtu/update-workspace-tokens))))))
(defn update-create-token
[{:keys [token prev-token-name]}]
(ptk/reify ::update-create-token
ptk/WatchEvent
(watch [_ state _]
(let [token-set (wtts/get-selected-token-set state)
token-set-name (or (:name token-set) "Global")
changes (if (not token-set)
;; No set created add a global set
(let [tokens-lib (get-tokens-lib state)
token-set (ctob/make-token-set :name token-set-name :tokens {(:name token) token})
hidden-theme (ctob/make-hidden-token-theme :sets [token-set-name])
active-theme-paths (some-> tokens-lib ctob/get-active-theme-paths)
add-to-hidden-theme? (= active-theme-paths #{ctob/hidden-token-theme-path})
base-changes (pcb/add-token-set (pcb/empty-changes) token-set)]
(cond
(not tokens-lib) (-> base-changes
(pcb/add-token-theme hidden-theme)
(pcb/update-active-token-themes #{ctob/hidden-token-theme-path} #{}))
add-to-hidden-theme? (let [prev-hidden-theme (ctob/get-theme tokens-lib ctob/hidden-token-theme-group ctob/hidden-token-theme-name)]
(-> base-changes
(pcb/update-token-theme (ctob/toggle-set prev-hidden-theme ctob/hidden-token-theme-path) prev-hidden-theme)))
:else base-changes))
;; Either update or add token to existing set
(if-let [prev-token (ctob/get-token token-set (or prev-token-name (:name token)))]
(pcb/update-token (pcb/empty-changes) (:name token-set) token prev-token)
(pcb/add-token (pcb/empty-changes) (:name token-set) token)))]
(rx/of
(set-selected-token-set-id token-set-name)
(dch/commit-changes changes))))))
(defn delete-token
[set-name token-name]
(dm/assert! (string? set-name))
(dm/assert! (string? token-name))
(ptk/reify ::delete-token
ptk/WatchEvent
(watch [it state _]
(let [data (get state :workspace-data)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/delete-token set-name token-name))]
(rx/of (dch/commit-changes changes))))))
(defn duplicate-token
[token-name]
(dm/assert! (string? token-name))
(ptk/reify ::duplicate-token
ptk/WatchEvent
(watch [_ state _]
(when-let [token (some-> (wtts/get-selected-token-set state)
(ctob/get-token token-name)
(update :name #(str/concat % "-copy")))]
(rx/of
(update-create-token {:token token}))))))
(defn set-token-type-section-open
[token-type open?]
(ptk/reify ::set-token-type-section-open
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-tokens :open-status token-type] open?))))
;; === Token Context Menu
(defn show-token-context-menu
[{:keys [position _token-name] :as params}]
(dm/assert! (gpt/point? position))
(ptk/reify ::show-token-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :token-context-menu] params))))
(def hide-token-context-menu
(ptk/reify ::hide-token-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :token-context-menu] nil))))
;; === Token Set Context Menu
(defn show-token-set-context-menu
[{:keys [position _token-set-name] :as params}]
(dm/assert! (gpt/point? position))
(ptk/reify ::show-token-set-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :token-set-context-menu] params))))
(def hide-token-set-context-menu
(ptk/reify ::hide-token-set-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :token-set-context-menu] nil))))
;; === Import Export Context Menu
(defn show-import-export-context-menu
[{:keys [position] :as params}]
(dm/assert! (gpt/point? position))
(ptk/reify ::show-import-export-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :import-export-context-menu] params))))
(def hide-import-export-set-context-menu
(ptk/reify ::hide-import-export-set-context-menu
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :import-export-set-context-menu] nil))))

View file

@ -44,7 +44,11 @@
:layers
{:del #{:document-history :assets}
:add #{:sitemap :layers}}})
:add #{:sitemap :layers}}
:tokens
{:del #{:sitemap :layers :document-history :assets}
:add #{:tokens}}})
(def valid-options-mode
#{:design :prototype :inspect})

View file

@ -12,8 +12,10 @@
[app.common.files.helpers :as cph]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.store :as st]
[app.main.ui.workspace.tokens.token-set :as wtts]
[okulary.core :as l]))
;; ---- Global refs
@ -205,6 +207,9 @@
(def context-menu
(l/derived :context-menu workspace-local))
(def token-context-menu
(l/derived :token-context-menu workspace-local))
;; page item that it is being edited
(def editing-page-item
(l/derived :page-item workspace-local))
@ -448,6 +453,65 @@
ids)))
st/state =))
;; ---- Token refs
(def tokens-lib
(l/derived :tokens-lib workspace-data))
(def workspace-token-theme-groups
(l/derived (d/nilf ctob/get-theme-groups) tokens-lib))
(defn workspace-token-theme
[group name]
(l/derived
(fn [lib]
(when lib
(ctob/get-theme lib group name)))
tokens-lib))
(def workspace-token-theme-tree-no-hidden
(l/derived (fn [lib]
(or
(some-> lib
(ctob/delete-theme ctob/hidden-token-theme-group ctob/hidden-token-theme-name)
(ctob/get-theme-tree))
[]))
tokens-lib))
(def workspace-token-themes
(l/derived #(or (some-> % ctob/get-themes) []) tokens-lib))
(def workspace-token-themes-no-hidden
(l/derived #(remove ctob/hidden-temporary-theme? %) workspace-token-themes))
(def workspace-selected-token-set-id
(l/derived wtts/get-selected-token-set-id st/state))
(def workspace-ordered-token-sets
(l/derived #(or (some-> % ctob/get-sets) []) tokens-lib))
(def workspace-active-theme-paths
(l/derived (d/nilf ctob/get-active-theme-paths) tokens-lib))
(def workspace-active-theme-paths-no-hidden
(l/derived #(disj % ctob/hidden-token-theme-path) workspace-active-theme-paths))
(def workspace-active-set-names
(l/derived (d/nilf ctob/get-active-themes-set-names) tokens-lib))
(def workspace-active-theme-sets-tokens
(l/derived #(or (some-> % ctob/get-active-themes-set-tokens) {}) tokens-lib))
(def workspace-selected-token-set-token
(fn [token-name]
(l/derived
#(some-> (wtts/get-selected-token-set %)
(ctob/get-token token-name))
st/state)))
(def workspace-selected-token-set-tokens
(l/derived #(or (wtts/get-selected-token-set-tokens %) {}) st/state))
;; ---- Viewer refs
(defn lookup-viewer-objects-by-id

View file

@ -99,7 +99,7 @@
content (mf/use-state "")
disabled? (or (str/blank? @content)
(str/empty-or-nil? @content))
(str/empty? @content))
on-focus
(mf/use-fn
@ -159,7 +159,7 @@
pos-y (* (:y position) zoom)
disabled? (or (str/blank? content)
(str/empty-or-nil? content))
(str/empty? content))
on-esc
(mf/use-fn
@ -230,7 +230,7 @@
(fn [] (on-submit @content)))
disabled? (or (str/blank? @content)
(str/empty-or-nil? @content))]
(str/empty? @content))]
[:div {:class (stl/css :edit-form)}
[:& resizing-textarea {:value @content

View file

@ -16,6 +16,8 @@
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(set! *warn-on-infer* false)
(mf/defc tab-element
{::mf/wrap-props false}
[{:keys [children]}]

View file

@ -24,6 +24,7 @@
(def libraries (mf/create-context nil))
(def components-v2 (mf/create-context nil))
(def design-tokens (mf/create-context nil))
(def current-scroll (mf/create-context nil))
(def current-zoom (mf/create-context nil))

View file

@ -130,3 +130,21 @@
box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgba(0, 0, 0, 0.2);
}
}
%base-button-action {
--button-bg-color: transparent;
--button-fg-color: var(--color-foreground-secondary);
--button-hover-bg-color: transparent;
--button-hover-fg-color: var(--color-accent-primary);
--button-active-bg-color: var(--color-background-quaternary);
--button-disabled-bg-color: transparent;
--button-disabled-fg-color: var(--color-accent-primary-muted);
--button-focus-bg-color: transparent;
--button-focus-fg-color: var(--color-accent-primary);
--button-focus-inner-ring-color: transparent;
--button-focus-outer-ring-color: var(--color-accent-primary);
}

View file

@ -12,9 +12,6 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
[rumext.v2 :as mf]))
(def button-variants (set '("primary" "secondary" "ghost" "destructive")))
(def ^:private schema:icon-button
[:map
[:class {:optional true} :string]
@ -22,7 +19,7 @@
[:and :string [:fn #(contains? icon-list %)]]]
[:aria-label :string]
[:variant {:optional true}
[:maybe [:enum "primary" "secondary" "ghost" "destructive"]]]])
[:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]])
(mf/defc icon-button*
{::mf/props :obj
@ -33,6 +30,7 @@
:icon-button-primary (= variant "primary")
:icon-button-secondary (= variant "secondary")
:icon-button-ghost (= variant "ghost")
:icon-button-action (= variant "action")
:icon-button-destructive (= variant "destructive")))
props (mf/spread-props props {:class class :title aria-label})]
[:> "button" props [:> icon* {:id icon :aria-label aria-label}] children]))

View file

@ -31,3 +31,7 @@
.icon-button-destructive {
@extend %base-button-destructive;
}
.icon-button-action {
@extend %base-button-action;
}

View file

@ -26,7 +26,7 @@ export default {
},
disabled: { control: "boolean" },
variant: {
options: ["primary", "secondary", "ghost", "destructive"],
options: ["primary", "secondary", "ghost", "destructive", "action"],
control: { type: "select" },
},
},
@ -59,6 +59,12 @@ export const Ghost = {
},
};
export const Action = {
args: {
variant: "action",
},
};
export const Destructive = {
args: {
variant: "destructive",

View file

@ -50,6 +50,10 @@
(def ^:icon-id align-top "align-top")
(def ^:icon-id align-vertical-center "align-vertical-center")
(def ^:icon-id arrow "arrow")
(def ^:icon-id arrow-up "arrow-up")
(def ^:icon-id arrow-down "arrow-down")
(def ^:icon-id arrow-left "arrow-left")
(def ^:icon-id arrow-right "arrow-right")
(def ^:icon-id asc-sort "asc-sort")
(def ^:icon-id board "board")
(def ^:icon-id boards-thumbnail "boards-thumbnail")

View file

@ -32,17 +32,17 @@
(let [level (or level "1")
tag (dm/str "h" level)
class (dm/str (or class "") " " (stl/css-case :display-typography (= typography t/display)
:title-large-typography (= typography t/title-large)
:title-medium-typography (= typography t/title-medium)
:title-small-typography (= typography t/title-small)
:headline-large-typography (= typography t/headline-large)
:headline-medium-typography (= typography t/headline-medium)
:headline-small-typography (= typography t/headline-small)
:body-large-typography (= typography t/body-large)
:body-medium-typography (= typography t/body-medium)
:body-small-typography (= typography t/body-small)
:code-font-typography (= typography t/code-font)))
class (dm/str class " " (stl/css-case :display-typography (= typography t/display)
:title-large-typography (= typography t/title-large)
:title-medium-typography (= typography t/title-medium)
:title-small-typography (= typography t/title-small)
:headline-large-typography (= typography t/headline-large)
:headline-medium-typography (= typography t/headline-medium)
:headline-small-typography (= typography t/headline-small)
:body-large-typography (= typography t/body-large)
:body-medium-typography (= typography t/body-medium)
:body-small-typography (= typography t/body-small)
:code-font-typography (= typography t/code-font)))
props (mf/spread-props props {:class class})]
[:> tag props
children]))

View file

@ -30,6 +30,7 @@
[app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]]
[app.main.ui.workspace.sidebar.collapsable-button :refer [collapsed-button]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox]]
[app.main.ui.workspace.tokens.modals]
[app.main.ui.workspace.viewport :refer [viewport]]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@ -179,6 +180,7 @@
file-ready? (mf/deref file-ready*)
components-v2? (features/use-feature "components/v2")
design-tokens? (features/use-feature "design-tokens/v1")
background-color (:background-color wglobal)]
@ -207,15 +209,16 @@
[:& (mf/provider ctx/current-team-id) {:value team-id}
[:& (mf/provider ctx/current-page-id) {:value page-id}
[:& (mf/provider ctx/components-v2) {:value components-v2?}
[:& (mf/provider ctx/workspace-read-only?) {:value read-only?}
[:& (mf/provider ctx/team-permissions) {:value permissions}
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"}}
[:& context-menu]
(if ^boolean file-ready?
[:& workspace-page {:page-id page-id
:file file
:wglobal wglobal
:layout layout}]
[:& workspace-loader])]]]]]]]]))
[:& (mf/provider ctx/design-tokens) {:value design-tokens?}
[:& (mf/provider ctx/workspace-read-only?) {:value read-only?}
[:& (mf/provider ctx/team-permissions) {:value permissions}
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"}}
[:& context-menu]
(if ^boolean file-ready?
[:& workspace-page {:page-id page-id
:file file
:wglobal wglobal
:layout layout}]
[:& workspace-loader])]]]]]]]]]))

View file

@ -53,6 +53,25 @@
;; --- Color Picker Modal
(defn use-color-picker-css-variables! [node-ref current-color]
(mf/with-effect [current-color]
(let [node (mf/ref-val node-ref)
{:keys [r g b h v]} current-color
rgb [r g b]
hue-rgb (cc/hsv->rgb [h 1.0 255])
hsl-from (cc/hsv->hsl [h 0.0 v])
hsl-to (cc/hsv->hsl [h 1.0 v])
format-hsl (fn [[h s l]]
(str/fmt "hsl(%s, %s, %s)"
h
(str (* s 100) "%")
(str (* l 100) "%")))]
(dom/set-css-property! node "--color" (str/join ", " rgb))
(dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb))
(dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)))))
(mf/defc colorpicker
{::mf/props :obj}
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept]}]
@ -264,23 +283,7 @@
(st/emit! (dc/update-colorpicker data)))
;; Updates the CSS color variable when there is a change in the color
(mf/with-effect [current-color]
(let [node (mf/ref-val node-ref)
{:keys [r g b h v]} current-color
rgb [r g b]
hue-rgb (cc/hsv->rgb [h 1.0 255])
hsl-from (cc/hsv->hsl [h 0.0 v])
hsl-to (cc/hsv->hsl [h 1.0 v])
format-hsl (fn [[h s l]]
(str/fmt "hsl(%s, %s, %s)"
h
(str (* s 100) "%")
(str (* l 100) "%")))]
(dom/set-css-property! node "--color" (str/join ", " rgb))
(dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb))
(dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))
(use-color-picker-css-variables! node-ref current-color)
;; Updates color when pixel picker is used
(mf/with-effect [picking-color? picked-color picked-color-select]

View file

@ -27,6 +27,7 @@
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]]
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap]]
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox]]
[app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab]]
[app.util.debug :as dbg]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
@ -52,8 +53,11 @@
mode-inspect? (= options-mode :inspect)
project (mf/deref refs/workspace-project)
design-tokens? (mf/use-ctx muc/design-tokens)
section (cond (or mode-inspect? (contains? layout :layers)) :layers
(contains? layout :assets) :assets)
(contains? layout :assets) :assets
(contains? layout :tokens) :tokens)
shortcuts? (contains? layout :shortcuts)
show-debug? (contains? layout :debug-panel)
@ -97,6 +101,9 @@
assets-tab
(mf/html [:& assets-toolbox {:size (- size 58)}])
tokens-tab
(mf/html [:& tokens-sidebar-tab])
tabs
(if ^boolean mode-inspect?
#js [#js {:label (tr "workspace.sidebar.layers")
@ -107,7 +114,11 @@
:content layers-tab}
#js {:label (tr "workspace.toolbar.assets")
:id "assets"
:content assets-tab}])]
:content assets-tab}
(when design-tokens?
#js {:label "Tokens"
:id "tokens"
:content tokens-tab})])]
[:& (mf/provider muc/sidebar) {:value :left}
[:aside {:ref parent-ref

View file

@ -130,7 +130,7 @@
(mf/defc asset-section
{::mf/wrap-props false}
[{:keys [children file-id title section assets-count open?]}]
[{:keys [children file-id title section assets-count icon open?]}]
(let [children (-> (array/normalize-to-array children)
(array/without-nils))
@ -151,7 +151,7 @@
(mf/html
[:span {:class (stl/css :title-name)}
[:span {:class (stl/css :section-icon)}
[:& section-icon {:section section}]]
[:& (or icon section-icon) {:section section}]]
[:span {:class (stl/css :section-name)}
title]

View file

@ -69,11 +69,11 @@
(defn group-assets
"Convert a list of assets in a nested structure like this:
{'': [{assetA} {assetB}]
'group1': {'': [{asset1A} {asset1B}]
'subgroup11': {'': [{asset11A} {asset11B} {asset11C}]}
'subgroup12': {'': [{asset12A}]}}
'group2': {'subgroup21': {'': [{asset21A}}}}
{'': [assetA assetB]
'group1': {'': [asset1A asset1B]
'subgroup11': {'': [asset11A asset11B asset11C]}
'subgroup12': {'': [asset12A]}}
'group2': {'subgroup21': {'': [asset21A]}}}
"
[assets reverse-sort?]
(when-not (empty? assets)

View file

@ -12,7 +12,9 @@
[app.common.logic.shapes :as cls]
[app.common.types.shape.layout :as ctl]
[app.common.types.shape.radius :as ctsr]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [size-presets]]
[app.main.data.tokens :as dt]
[app.main.data.workspace :as udw]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.shapes :as dwsh]
@ -24,6 +26,10 @@
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as i]
[app.main.ui.workspace.tokens.core :as wtc]
[app.main.ui.workspace.tokens.editable-select :refer [editable-select]]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[clojure.set :refer [rename-keys union]]
@ -96,6 +102,34 @@
selection-parents-ref (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids))
selection-parents (mf/deref selection-parents-ref)
tokens (sd/use-active-theme-sets-tokens)
tokens-by-type (mf/use-memo
(mf/deps tokens)
#(ctob/group-by-type tokens))
border-radius-tokens (:border-radius tokens-by-type)
border-radius-options (mf/use-memo
(mf/deps shape border-radius-tokens)
#(wtc/tokens->select-options
{:shape shape
:tokens border-radius-tokens
:attributes (wtty/token-attributes :border-radius)}))
sizing-tokens (:sizing tokens-by-type)
width-options (mf/use-memo
(mf/deps shape sizing-tokens)
#(wtc/tokens->select-options
{:shape shape
:tokens sizing-tokens
:attributes (wtty/token-attributes :sizing)
:selected-attributes #{:width}}))
height-options (mf/use-memo
(mf/deps shape sizing-tokens)
#(wtc/tokens->select-options
{:shape shape
:tokens sizing-tokens
:attributes (wtty/token-attributes :sizing)
:selected-attributes #{:height}}))
flex-child? (->> selection-parents (some ctl/flex-layout?))
absolute? (ctl/item-absolute? shape)
flex-container? (ctl/flex-layout? shape)
@ -209,8 +243,20 @@
(mf/use-fn
(mf/deps ids)
(fn [value attr]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(udw/update-dimensions ids attr value))))
(let [token-value (wtc/maybe-resolve-token-value value)
undo-id (js/Symbol)]
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids
(if token-value
#(assoc-in % [:applied-tokens attr] (:id value))
#(d/dissoc-in % [:applied-tokens attr]))
{:reg-objects? true
:attrs [:applied-tokens]})
(udw/update-dimensions ids attr (or token-value value))
(dwu/commit-undo-transaction undo-id)))))
on-proportion-lock-change
(mf/use-fn
@ -256,7 +302,7 @@
(update-fn shape)
shape))
{:reg-objects? true
:attrs [:rx :ry :r1 :r2 :r3 :r4]})))
:attrs [:rx :ry :r1 :r2 :r3 :r4 :applied-tokens]})))
on-switch-to-radius-1
(mf/use-fn
@ -281,11 +327,27 @@
(on-switch-to-radius-4)
(on-switch-to-radius-1))))
on-border-radius-token-unapply
(mf/use-fn
(mf/deps ids change-radius)
(fn [token]
(let [token-value (wtc/maybe-resolve-token-value token)]
(st/emit!
(change-radius (fn [shape]
(-> (dt/unapply-token-id shape (wtty/token-attributes :border-radius))
(ctsr/set-radius-1 token-value))))))))
on-radius-1-change
(mf/use-fn
(mf/deps ids change-radius)
(fn [value]
(st/emit! (change-radius #(ctsr/set-radius-1 % value)))))
(let [token-value (wtc/maybe-resolve-token-value value)]
(st/emit!
(change-radius (fn [shape]
(-> (dt/maybe-apply-token-to-shape {:token (when token-value value)
:shape shape
:attributes (wtty/token-attributes :border-radius)})
(ctsr/set-radius-1 (or token-value value)))))))))
on-radius-multi-change
(mf/use-fn
@ -394,24 +456,34 @@
:disabled disabled-width-sizing?)
:title (tr "workspace.options.width")}
[:span {:class (stl/css :icon-text)} "W"]
[:> numeric-input* {:min 0.01
:no-validate true
:placeholder (if (= :multiple (:width values)) (tr "settings.multiple") "--")
:on-change on-width-change
:disabled disabled-width-sizing?
:class (stl/css :numeric-input)
:value (:width values)}]]
[:& editable-select
{:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
:class (stl/css :token-select)
:disabled disabled-width-sizing?
:on-change on-width-change
:on-token-remove #(on-width-change (wtc/maybe-resolve-token-value %))
:options width-options
:position :left
:value (:width values)
:input-props {:type "number"
:no-validate true
:min 0.01}}]]
[:div {:class (stl/css-case :height true
:disabled disabled-height-sizing?)
:title (tr "workspace.options.height")}
[:span {:class (stl/css :icon-text)} "H"]
[:> numeric-input* {:min 0.01
:no-validate true
:placeholder (if (= :multiple (:height values)) (tr "settings.multiple") "--")
:on-change on-height-change
:disabled disabled-height-sizing?
:class (stl/css :numeric-input)
:value (:height values)}]]
[:& editable-select
{:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
:class (stl/css :token-select)
:disabled disabled-height-sizing?
:on-change on-height-change
:on-token-remove #(on-height-change (wtc/maybe-resolve-token-value %))
:options height-options
:position :right
:value (:height values)
:input-props {:type "number"
:no-validate true
:min 0.01}}]]
[:button {:class (stl/css-case
:lock-size-btn true
:selected (true? proportion-lock)
@ -468,13 +540,16 @@
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:span {:class (stl/css :icon)} i/corner-radius]
[:> numeric-input*
[:& editable-select
{:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
:ref radius-input-ref
:min 0
:class (stl/css :token-select)
:on-change on-radius-1-change
:class (stl/css :numeric-input)
:value (:rx values)}]]
:on-token-remove on-border-radius-token-unapply
:options border-radius-options
:position :right
:value (:rx values)
:input-props {:type "number"
:min 0}}]]
@radius-multi?
[:div {:class (stl/css :radius-1)

View file

@ -109,6 +109,7 @@
.size {
@include flexRow;
position: relative;
}
.height,
@ -186,6 +187,7 @@
@extend .input-element;
@include bodySmallTypography;
width: $s-108;
position: relative;
}
.radius-4 {

View file

@ -0,0 +1,183 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.changes
(:require
[app.common.types.shape.radius :as ctsr]
[app.common.types.token :as ctt]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace :as udw]
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.tinycolor :as tinycolor]
[app.main.ui.workspace.tokens.token :as wtt]
[beicon.v2.core :as rx]
[clojure.set :as set]
[potok.v2.core :as ptk]))
;; Token Updates ---------------------------------------------------------------
(defn apply-token
"Apply `attributes` that match `token` for `shape-ids`.
Optionally remove attributes from `attributes-to-remove`,
this is useful for applying a single attribute from an attributes set
while removing other applied tokens from this set."
[{:keys [attributes attributes-to-remove token shape-ids on-update-shape] :as _props}]
(ptk/reify ::apply-token
ptk/WatchEvent
(watch [_ state _]
(when-let [tokens (some-> (get-in state [:workspace-data :tokens-lib])
(ctob/get-active-themes-set-tokens))]
(->> (rx/from (sd/resolve-tokens+ tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
resolved-value (get-in resolved-tokens [(wtt/token-identifier token) :resolved-value])
tokenized-attributes (wtt/attributes-map attributes token)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always (update :applied-tokens merge tokenized-attributes))))
(when on-update-shape
(on-update-shape resolved-value shape-ids attributes))
(dwu/commit-undo-transaction undo-id))))))))))
(defn unapply-token
"Removes `attributes` that match `token` for `shape-ids`.
Doesn't update shape attributes."
[{:keys [attributes token shape-ids] :as _props}]
(ptk/reify ::unapply-token
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(let [remove-token #(when % (wtt/remove-attributes-for-token attributes token %))]
(dwsh/update-shapes
shape-ids
(fn [shape]
(update shape :applied-tokens remove-token))))))))
(defn toggle-token
[{:keys [token-type-props token shapes] :as _props}]
(ptk/reify ::on-toggle-token
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [attributes all-attributes on-update-shape]} token-type-props
unapply-tokens? (wtt/shapes-token-applied? token shapes (or all-attributes attributes))
shape-ids (map :id shapes)]
(if unapply-tokens?
(rx/of
(unapply-token {:attributes (or all-attributes attributes)
:token token
:shape-ids shape-ids}))
(rx/of
(apply-token {:attributes attributes
:token token
:shape-ids shape-ids
:on-update-shape on-update-shape})))))))
;; Shape Updates ---------------------------------------------------------------
(defn update-shape-radius-all [value shape-ids]
(dwsh/update-shapes shape-ids
(fn [shape]
(when (ctsr/has-radius? shape)
(ctsr/set-radius-1 shape value)))
{:reg-objects? true
:attrs ctt/border-radius-keys}))
(defn update-opacity [value shape-ids]
(when (<= 0 value 1)
(dwsh/update-shapes shape-ids #(assoc % :opacity value))))
(defn update-rotation [value shape-ids]
(ptk/reify ::update-shape-rotation
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(udw/trigger-bounding-box-cloaking shape-ids)
(udw/increase-rotation shape-ids value)))))
(defn update-shape-radius-single-corner [value shape-ids attributes]
(dwsh/update-shapes shape-ids
(fn [shape]
(when (ctsr/has-radius? shape)
(cond-> shape
(:rx shape) (ctsr/switch-to-radius-4)
:always (ctsr/set-radius-4 (first attributes) value))))
{:reg-objects? true
:attrs [:rx :ry :r1 :r2 :r3 :r4]}))
(defn update-stroke-width
[value shape-ids]
(dwsh/update-shapes shape-ids
(fn [shape]
(when (seq (:strokes shape))
(assoc-in shape [:strokes 0 :stroke-width] value)))
{:reg-objects? true
:attrs [:strokes]}))
(defn update-color
[value shape-ids]
(let [color (some->> value
(tinycolor/valid-color)
(tinycolor/->hex)
(str "#"))]
(wdc/change-fill shape-ids {:color color} 0)))
(defn update-shape-dimensions [value shape-ids attributes]
(ptk/reify ::update-shape-dimensions
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(when (:width attributes) (dwt/update-dimensions shape-ids :width value))
(when (:height attributes) (dwt/update-dimensions shape-ids :height value))))))
(defn- attributes->layout-gap [attributes value]
(let [layout-gap (-> (set/intersection attributes #{:column-gap :row-gap})
(zipmap (repeat value)))]
{:layout-gap layout-gap}))
(defn update-layout-padding [value shape-ids attrs]
(dwsl/update-layout shape-ids {:layout-padding (zipmap attrs (repeat value))}))
(defn update-layout-spacing [value shape-ids attributes]
(ptk/reify ::update-layout-spacing
ptk/WatchEvent
(watch [_ state _]
(let [layout-shape-ids (->> (wsh/lookup-shapes state shape-ids)
(eduction
(filter :layout)
(map :id)))
layout-attributes (attributes->layout-gap attributes value)]
(rx/of
(dwsl/update-layout layout-shape-ids layout-attributes))))))
(defn update-shape-position [value shape-ids attributes]
(ptk/reify ::update-shape-position
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(map #(dwt/update-position % (zipmap attributes (repeat value))) shape-ids)))))
(defn update-layout-sizing-limits [value shape-ids attributes]
(ptk/reify ::update-layout-sizing-limits
ptk/WatchEvent
(watch [_ _ _]
(let [props (-> {:layout-item-min-w value
:layout-item-min-h value
:layout-item-max-w value
:layout-item-max-h value}
(select-keys attributes))]
(dwsl/update-layout-child shape-ids props)))))

View file

@ -0,0 +1,131 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.common
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.data.shortcuts :as dsc]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
;; Helpers ---------------------------------------------------------------------
(defn camel-keys [m]
(->> m
(d/deep-mapm
(fn [[k v]]
(if (or (keyword? k) (string? k))
[(keyword (str/camel (name k))) v]
[k v])))))
(defn direction-select
"Returns next `n` in `direction` while wrapping around at the last item at the count of `coll`.
`direction` accepts `:up` or `:down`."
[direction n coll]
(let [last-n (dec (count coll))
next-n (case direction
:up (dec n)
:down (inc n))
wrap-around-n (cond
(neg? next-n) last-n
(> next-n last-n) 0
:else next-n)]
wrap-around-n))
(defn use-arrow-highlight [{:keys [shortcuts-key options on-select]}]
(let [highlighted* (mf/use-state nil)
highlighted (deref highlighted*)
on-dehighlight #(reset! highlighted* nil)
on-keyup (fn [event]
(cond
(and (kbd/enter? event) highlighted) (on-select (nth options highlighted))
(kbd/up-arrow? event) (do
(dom/prevent-default event)
(->> (direction-select :up (or highlighted 0) options)
(reset! highlighted*)))
(kbd/down-arrow? event) (do
(dom/prevent-default event)
(->> (direction-select :down (or highlighted -1) options)
(reset! highlighted*)))))]
(mf/with-effect [highlighted]
(let [shortcuts-key shortcuts-key
keys [(events/listen globals/document EventType.KEYUP on-keyup)
(events/listen globals/document EventType.KEYDOWN dom/prevent-default)]]
(st/emit! (dsc/push-shortcuts shortcuts-key {}))
(fn []
(doseq [key keys]
(events/unlistenByKey key))
(st/emit! (dsc/pop-shortcuts shortcuts-key)))))
{:highlighted highlighted
:on-dehighlight on-dehighlight}))
(defn use-dropdown-open-state []
(let [open? (mf/use-state false)
on-open (mf/use-fn #(reset! open? true))
on-close (mf/use-fn #(reset! open? false))
on-toggle (mf/use-fn #(swap! open? not))]
{:dropdown-open? @open?
:on-open-dropdown on-open
:on-close-dropdown on-close
:on-toggle-dropdown on-toggle}))
;; Components ------------------------------------------------------------------
(mf/defc dropdown-select
[{:keys [id _shortcuts-key options on-close element-ref on-select] :as props}]
(let [{:keys [highlighted on-dehighlight]} (use-arrow-highlight props)]
[:& dropdown {:show true
:on-close on-close}
[:> :div {:class (stl/css :dropdown)
:on-mouse-enter on-dehighlight
:ref element-ref}
[:ul {:class (stl/css :dropdown-list)}
(for [[index item] (d/enumerate options)]
(cond
(= :separator item)
[:li {:class (stl/css :separator)
:key (dm/str id "-" index)}]
:else
(let [{:keys [label selected? disabled?]} item
highlighted? (= highlighted index)]
[:li
{:key (str id "-" index)
:class (stl/css-case :dropdown-element true
:is-selected selected?
:is-highlighted highlighted?)
:data-label label
:disabled disabled?
:on-click #(on-select item)}
[:span {:class (stl/css :label)} label]
[:span {:class (stl/css :check-icon)} i/tick]])))]]]))
(mf/defc labeled-input
{::mf/wrap-props false}
[{:keys [label input-props auto-complete? error? render-right]}]
(let [input-props (cond-> input-props
:always camel-keys
;; Disable auto-complete on form fields for proprietary password managers
;; https://github.com/orgs/tokens-studio/projects/69/views/11?pane=issue&itemId=63724204
(not auto-complete?) (assoc "data-1p-ignore" true
"data-lpignore" true
:auto-complete "off"))]
[:label {:class (stl/css-case :labeled-input true
:labeled-input-error error?)}
[:span {:class (stl/css :label)} label]
[:& :input input-props]
(when render-right
[:& render-right])]))

View file

@ -0,0 +1,115 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
.input {
@extend .input-element;
}
.labeled-input {
@extend .input-element;
.label {
width: auto;
text-wrap: nowrap;
}
}
.labeled-input-error {
border: 1px solid var(--status-color-error-500) !important;
}
.button {
@extend .button-primary;
}
.action-button {
@extend .button-tertiary;
height: $s-32;
width: $s-28;
svg {
@extend .button-icon;
}
}
.dropdown {
@extend .dropdown-wrapper;
max-height: $s-320;
width: 100%;
margin-top: $s-4;
ul {
margin: 0;
}
.separator {
margin: 0;
height: $s-12;
}
.dropdown-element {
@extend .dropdown-element-base;
color: var(--menu-foreground-color-rest);
display: flex;
& > span {
display: flex;
justify-content: flex-start;
align-content: center;
}
.label,
.value {
width: fit-content;
}
.label {
text-transform: unset;
flex: 1;
}
.value {
text-align: right;
justify-content: flex-end;
flex: 0.6;
}
.check-icon {
@include flexCenter;
translate: -$s-4 0;
svg {
@extend .button-icon-small;
visibility: hidden;
stroke: var(--icon-foreground);
}
}
&.is-selected {
color: var(--menu-foreground-color);
.check-icon svg {
stroke: var(--menu-foreground-color);
visibility: visible;
}
}
&:hover {
background-color: var(--menu-background-color-hover);
color: var(--menu-foreground-color-hover);
.check-icon svg {
stroke: var(--menu-foreground-color-hover);
}
}
&.is-highlighted {
background-color: var(--button-primary-background-color-rest);
span {
color: var(--button-primary-foreground-color-rest);
}
.check-icon svg {
stroke: var(--button-primary-foreground-color-rest);
}
}
}
}

View file

@ -0,0 +1,336 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.context-menu
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.data.modal :as modal]
[app.main.data.tokens :as dt]
[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.workspace.tokens.changes :as wtch]
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[app.util.timers :as timers]
[okulary.core :as l]
[rumext.v2 :as mf]))
;; Actions ---------------------------------------------------------------------
(defn attribute-actions [token selected-shapes attributes]
(let [ids-by-attributes (wtt/shapes-ids-by-applied-attributes token selected-shapes attributes)
shape-ids (into #{} (map :id selected-shapes))]
{:all-selected? (wtt/shapes-applied-all? ids-by-attributes shape-ids attributes)
:shape-ids shape-ids
:selected-pred #(seq (% ids-by-attributes))}))
(defn generic-attribute-actions [attributes title {:keys [token selected-shapes on-update-shape]}]
(let [on-update-shape-fn (or on-update-shape
(-> (wtty/get-token-properties token)
(:on-update-shape)))
{:keys [selected-pred shape-ids]} (attribute-actions token selected-shapes attributes)]
(map (fn [attribute]
(let [selected? (selected-pred attribute)
props {:attributes #{attribute}
:token token
:shape-ids shape-ids}]
{:title title
:selected? selected?
:action (fn []
(if selected?
(st/emit! (wtch/unapply-token props))
(st/emit! (wtch/apply-token (assoc props :on-update-shape on-update-shape-fn)))))}))
attributes)))
(defn all-or-sepearate-actions [{:keys [attribute-labels on-update-shape-all on-update-shape]}
{:keys [token selected-shapes]}]
(let [attributes (set (keys attribute-labels))
{:keys [all-selected? selected-pred shape-ids]} (attribute-actions token selected-shapes attributes)
all-action (let [props {:attributes attributes
:token token
:shape-ids shape-ids}]
{:title "All"
:selected? all-selected?
:action #(if all-selected?
(st/emit! (wtch/unapply-token props))
(st/emit! (wtch/apply-token (assoc props :on-update-shape (or on-update-shape-all on-update-shape)))))})
single-actions (map (fn [[attr title]]
(let [selected? (selected-pred attr)]
{:title title
:selected? (and (not all-selected?) selected?)
:action #(let [props {:attributes #{attr}
:token token
:shape-ids shape-ids}
event (cond
all-selected? (-> (assoc props :attributes-to-remove attributes)
(wtch/apply-token))
selected? (wtch/unapply-token props)
:else (-> (assoc props :on-update-shape on-update-shape)
(wtch/apply-token)))]
(st/emit! event))}))
attribute-labels)]
(concat [all-action] single-actions)))
(defn spacing-attribute-actions [{:keys [token selected-shapes] :as context-data}]
(let [on-update-shape-padding wtch/update-layout-padding
padding-attrs {:p1 "Top"
:p2 "Right"
:p3 "Bottom"
:p4 "Left"}
all-padding-attrs (into #{} (keys padding-attrs))
{:keys [all-selected? selected-pred shape-ids]} (attribute-actions token selected-shapes all-padding-attrs)
horizontal-attributes #{:p1 :p3}
horizontal-padding-selected? (and
(not all-selected?)
(every? selected-pred horizontal-attributes))
vertical-attributes #{:p2 :p4}
vertical-padding-selected? (and
(not all-selected?)
(every? selected-pred vertical-attributes))
padding-items [{:title "All"
:selected? all-selected?
:action (fn []
(let [props {:attributes all-padding-attrs
:token token
:shape-ids shape-ids}]
(if all-selected?
(st/emit! (wtch/unapply-token props))
(st/emit! (wtch/apply-token (assoc props :on-update-shape on-update-shape-padding))))))}
{:title "Horizontal"
:selected? horizontal-padding-selected?
:action (fn []
(let [props {:token token
:shape-ids shape-ids}
event (cond
all-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
horizontal-padding-selected? (wtch/apply-token (assoc props :attributes-to-remove horizontal-attributes))
:else (wtch/apply-token (assoc props
:attributes horizontal-attributes
:on-update-shape on-update-shape-padding)))]
(st/emit! event)))}
{:title "Vertical"
:selected? vertical-padding-selected?
:action (fn []
(let [props {:token token
:shape-ids shape-ids}
event (cond
all-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
vertical-padding-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
:else (wtch/apply-token (assoc props
:attributes vertical-attributes
:on-update-shape on-update-shape-padding)))]
(st/emit! event)))}]
single-padding-items (->> padding-attrs
(map (fn [[attr title]]
(let [same-axis-selected? (cond
(get horizontal-attributes attr) horizontal-padding-selected?
(get vertical-attributes attr) vertical-padding-selected?
:else true)
selected? (and
(not all-selected?)
(not same-axis-selected?)
(selected-pred attr))]
{:title title
:selected? selected?
:action #(let [props {:attributes #{attr}
:token token
:shape-ids shape-ids}
event (cond
all-selected? (-> (assoc props :attributes-to-remove all-padding-attrs)
(wtch/apply-token))
selected? (wtch/unapply-token props)
:else (-> (assoc props :on-update-shape on-update-shape-padding)
(wtch/apply-token)))]
(st/emit! event))}))))
gap-items (all-or-sepearate-actions {:attribute-labels {:column-gap "Column Gap"
:row-gap "Row Gap"}
:on-update-shape wtch/update-layout-spacing}
context-data)]
(concat padding-items
single-padding-items
[:separator]
gap-items)))
(defn sizing-attribute-actions [context-data]
(concat
(all-or-sepearate-actions {:attribute-labels {:width "Width"
:height "Height"}
:on-update-shape wtch/update-shape-dimensions}
context-data)
[:separator]
(all-or-sepearate-actions {:attribute-labels {:layout-item-min-w "Min Width"
:layout-item-min-h "Min Height"}
:on-update-shape wtch/update-layout-sizing-limits}
context-data)
[:separator]
(all-or-sepearate-actions {:attribute-labels {:layout-item-max-w "Max Width"
:layout-item-max-h "Max Height"}
:on-update-shape wtch/update-layout-sizing-limits}
context-data)))
(def shape-attribute-actions-map
(let [stroke-width (partial generic-attribute-actions #{:stroke-width} "Stroke Width")]
{:border-radius (partial all-or-sepearate-actions {:attribute-labels {:r1 "Top Left"
:r2 "Top Right"
:r4 "Bottom Left"
:r3 "Bottom Right"}
:on-update-shape-all wtch/update-shape-radius-all
:on-update-shape wtch/update-shape-radius-single-corner})
:spacing spacing-attribute-actions
:sizing sizing-attribute-actions
:rotation (partial generic-attribute-actions #{:rotation} "Rotation")
:opacity (partial generic-attribute-actions #{:opacity} "Opacity")
:stroke-width stroke-width
:dimensions (fn [context-data]
(concat
[{:title "Spacing" :submenu :spacing}
{:title "Sizing" :submenu :sizing}
:separator
{:title "Border Radius" :submenu :border-radius}]
(stroke-width context-data)
[:separator]
(generic-attribute-actions #{:x} "X" (assoc context-data :on-update-shape wtch/update-shape-position))
(generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape wtch/update-shape-position))))}))
(defn default-actions [{:keys [token selected-token-set-id]}]
(let [{:keys [modal]} (wtty/get-token-properties token)]
[{:title "Delete Token"
:action #(st/emit! (dt/delete-token selected-token-set-id (:name token)))}
{:title "Duplicate Token"
:action #(st/emit! (dt/duplicate-token (:name token)))}
{:title "Edit Token"
:action (fn [event]
(let [{:keys [key fields]} modal]
(st/emit! dt/hide-token-context-menu)
(dom/stop-propagation event)
(modal/show! key {:x (.-clientX ^js event)
:y (.-clientY ^js event)
:position :right
:fields fields
:action "edit"
:selected-token-set-id selected-token-set-id
:token token})))}]))
(defn selection-actions [{:keys [type token] :as context-data}]
(let [with-actions (get shape-attribute-actions-map (or type (:type token)))
attribute-actions (if with-actions (with-actions context-data) [])]
(concat
attribute-actions
(when (seq attribute-actions) [:separator])
(default-actions context-data))))
;; Components ------------------------------------------------------------------
(def tokens-menu-ref
(l/derived :token-context-menu refs/workspace-local))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc menu-entry
{::mf/props :obj}
[{:keys [title value on-click selected? children submenu-offset]}]
(let [submenu-ref (mf/use-ref nil)
hovering? (mf/use-ref false)
on-pointer-enter
(mf/use-callback
(fn []
(mf/set-ref-val! hovering? true)
(when-let [submenu-node (mf/ref-val submenu-ref)]
(dom/set-css-property! submenu-node "display" "block"))))
on-pointer-leave
(mf/use-callback
(fn []
(mf/set-ref-val! hovering? false)
(when-let [submenu-node (mf/ref-val submenu-ref)]
(timers/schedule 50 #(when-not (mf/ref-val hovering?)
(dom/set-css-property! submenu-node "display" "none"))))))
set-dom-node
(mf/use-callback
(fn [dom]
(let [submenu-node (mf/ref-val submenu-ref)]
(when (and (some? dom) (some? submenu-node))
(dom/set-css-property! submenu-node "top" (str (.-offsetTop dom) "px"))))))]
[:li
{:class (stl/css :context-menu-item)
:ref set-dom-node
:data-value value
:on-click on-click
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
(when selected?
[:span {:class (stl/css :icon-wrapper)}
[:span {:class (stl/css :selected-icon)} i/tick]])
[:span {:class (stl/css :title)} title]
(when children
[:*
[:span {:class (stl/css :submenu-icon)} i/arrow]
[:ul {:class (stl/css :token-context-submenu)
:ref submenu-ref
:style {:display "none"
:top 0
:left (str submenu-offset "px")}
:on-context-menu prevent-default}
children]])]))
(mf/defc menu-tree
[{:keys [selected-shapes] :as context-data}]
(let [entries (if (seq selected-shapes)
(selection-actions context-data)
(default-actions context-data))]
(for [[index {:keys [title action selected? submenu] :as entry}] (d/enumerate entries)]
[:* {:key (str title " " index)}
(cond
(= :separator entry) [:li {:class (stl/css :separator)}]
submenu [:& menu-entry {:title title
:submenu-offset (:submenu-offset context-data)}
[:& menu-tree (assoc context-data :type submenu)]]
:else [:& menu-entry
{:title title
:on-click action
:selected? selected?}])])))
(mf/defc token-context-menu-tree
[{:keys [width] :as mdata}]
(let [objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
selected-shapes (into [] (keep (d/getf objects)) selected)
token-name (:token-name mdata)
token (mf/deref (refs/workspace-selected-token-set-token token-name))
selected-token-set-id (mf/deref refs/workspace-selected-token-set-id)]
[:ul {:class (stl/css :context-list)}
[:& menu-tree {:submenu-offset width
:token token
:selected-token-set-id selected-token-set-id
:selected-shapes selected-shapes}]]))
(mf/defc token-context-menu
[]
(let [mdata (mf/deref tokens-menu-ref)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
width (mf/use-state 0)
dropdown-ref (mf/use-ref)]
(mf/use-effect
(mf/deps mdata)
(fn []
(when-let [node (mf/ref-val dropdown-ref)]
(reset! width (.-offsetWidth node)))))
[:& dropdown {:show (boolean mdata)
:on-close #(st/emit! dt/hide-token-context-menu)}
[:div {:class (stl/css :token-context-menu)
:ref dropdown-ref
:style {:top top :left left}
:on-context-menu prevent-default}
(when mdata
[:& token-context-menu-tree (assoc mdata :offset @width)])]]))

View file

@ -0,0 +1,103 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.token-context-menu {
position: absolute;
z-index: $z-index-4;
}
.context-list,
.token-context-submenu {
@include menuShadow;
display: grid;
width: $s-240;
padding: $s-4;
border-radius: $br-8;
border: $s-2 solid var(--panel-border-color);
background-color: var(--menu-background-color);
max-height: 100vh;
overflow-y: auto;
li {
@include bodySmallTypography;
color: var(--menu-foreground-color);
}
}
.token-context-submenu {
position: absolute;
padding: $s-4;
margin-left: $s-6;
}
.separator {
@include bodySmallTypography;
margin: $s-6;
border-block-start: $s-1 solid var(--panel-border-color);
}
.context-menu-item {
display: flex;
align-items: center;
height: $s-28;
width: 100%;
padding: $s-6;
border-radius: $br-8;
cursor: pointer;
.title {
flex-grow: 1;
@include bodySmallTypography;
color: var(--menu-foreground-color);
margin-left: calc(($s-32 + $s-28) / 2);
}
.icon-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
}
.icon-wrapper + .title {
margin-left: $s-6;
}
.selected-icon {
svg {
@extend .button-icon-small;
stroke: var(--menu-foreground-color);
}
}
.submenu-icon {
margin-left: $s-2;
svg {
@extend .button-icon-small;
stroke: var(--menu-foreground-color);
}
}
&:hover {
background-color: var(--menu-background-color-hover);
.title {
color: var(--menu-foreground-color-hover);
}
.shortcut {
color: var(--menu-shortcut-foreground-color-hover);
}
}
&:focus {
border: 1px solid var(--menu-border-color-focus);
background-color: var(--menu-background-color-focus);
}
&[disabled] {
pointer-events: none;
opacity: 0.6;
}
}

View file

@ -0,0 +1,34 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.core
(:require
[app.common.data :as d]
[app.main.ui.workspace.tokens.token :as wtt]))
;; Helpers ---------------------------------------------------------------------
(defn resolve-token-value [{:keys [value resolved-value] :as _token}]
(or
resolved-value
(d/parse-double value)))
(defn maybe-resolve-token-value [{:keys [value] :as token}]
(when value (resolve-token-value token)))
(defn tokens->select-options [{:keys [shape tokens attributes selected-attributes]}]
(map
(fn [{:keys [name] :as token}]
(cond-> (assoc token :label name)
(wtt/token-applied? token shape (or selected-attributes attributes)) (assoc :selected? true)))
tokens))
(defn tokens-name-map->select-options [{:keys [shape tokens attributes selected-attributes]}]
(map
(fn [[_k {:keys [name] :as token}]]
(cond-> (assoc token :label name)
(wtt/token-applied? token shape (or selected-attributes attributes)) (assoc :selected? true)))
tokens))

View file

@ -0,0 +1,301 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.editable-select
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.math :as mth]
[app.common.uuid :as uuid]
[app.main.data.shortcuts :as dsc]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.icons :as i]
[app.main.ui.workspace.tokens.core :as wtc]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
[app.util.timers :as timers]
[cuerdas.core :as str]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defn on-number-input-key-down [{:keys [event min-val max-val set-value!]}]
(let [up? (kbd/up-arrow? event)
down? (kbd/down-arrow? event)]
(when (or up? down?)
(dom/prevent-default event)
(let [value (-> event dom/get-target dom/get-value)
value (or (d/parse-double value) value)
increment (cond
(kbd/shift? event) (if up? 10 -10)
(kbd/alt? event) (if up? 0.1 -0.1)
:else (if up? 1 -1))
new-value (+ value increment)
new-value (cond
(and (d/num? min-val) (< new-value min-val)) min-val
(and (d/num? max-val) (> new-value max-val)) max-val
:else new-value)]
(set-value! new-value)))))
(defn direction-select
"Returns next `n` in `direction` while wrapping around at the last item at the count of `coll`.
`direction` accepts `:up` or `:down`."
[direction n coll]
(let [last-n (dec (count coll))
next-n (case direction
:up (dec n)
:down (inc n))
wrap-around-n (cond
(neg? next-n) last-n
(> next-n last-n) 0
:else next-n)]
wrap-around-n))
(mf/defc dropdown-select [{:keys [position on-close element-id element-ref options on-select]}]
(let [highlighted* (mf/use-state nil)
highlighted (deref highlighted*)
on-keyup (fn [event]
(cond
(and (kbd/enter? event) highlighted) (on-select (nth options highlighted))
(kbd/up-arrow? event) (do
(dom/prevent-default event)
(->> (direction-select :up (or highlighted 0) options)
(reset! highlighted*)))
(kbd/down-arrow? event) (do
(dom/prevent-default event)
(->> (direction-select :down (or highlighted -1) options)
(reset! highlighted*)))))]
(mf/with-effect [highlighted]
(let [keys [(events/listen globals/document EventType.KEYUP on-keyup)
(events/listen globals/document EventType.KEYDOWN dom/prevent-default)]]
(st/emit! (dsc/push-shortcuts :token {}))
(fn []
(doseq [key keys]
(events/unlistenByKey key))
(st/emit! (dsc/pop-shortcuts :token)))))
[:& dropdown {:show true
:on-close on-close}
[:> :div {:class (stl/css-case :custom-select-dropdown true
:custom-select-dropdown-right (= position :right)
:custom-select-dropdown-left (= position :left))
:on-mouse-enter #(reset! highlighted* nil)
:ref element-ref}
[:ul {:class (stl/css :custom-select-dropdown-list)}
(for [[index item] (d/enumerate options)]
(cond
(= :separator item) [:li {:class (stl/css :separator)
:key (dm/str element-id "-" index)}]
;; Remove items with missing references
(seq (:errors item)) nil
:else (let [{:keys [label selected? errors]} item
highlighted? (= highlighted index)]
[:li
{:key (str element-id "-" index)
:class (stl/css-case :dropdown-element true
:is-selected selected?
:is-highlighted highlighted?)
:data-label label
:disabled (seq errors)
:on-click #(on-select item)}
[:span {:class (stl/css :label)} label]
[:span {:class (stl/css :value)} (wtc/resolve-token-value item)]
[:span {:class (stl/css :check-icon)} i/tick]])))]]]))
(mf/defc editable-select
[{:keys [value options disabled class on-change placeholder on-blur on-token-remove position input-props] :as params}]
(let [{:keys [type]} input-props
input-class (:class input-props)
state* (mf/use-state {:id (uuid/next)
:is-open? false
:current-value value
:token-value nil
:current-item nil
:top nil
:left nil
:bottom nil})
state (deref state*)
is-open? (:is-open? state)
refocus? (:refocus? state)
current-value (:current-value state)
element-id (:id state)
min-val (get params :min)
max-val (get params :max)
multiple? (= :multiple value)
token (when-not multiple?
(-> (filter :selected? options) (first)))
emit-blur? (mf/use-ref nil)
select-wrapper-ref (mf/use-ref)
toggle-dropdown
(mf/use-fn
(mf/deps state)
#(swap! state* update :is-open? not))
close-dropdown
(fn [event]
(dom/stop-propagation event)
(swap! state* assoc :is-open? false))
labels-map (->> (map (fn [{:keys [label] :as item}]
[label item])
options)
(into {}))
set-token-value!
(fn [value]
(swap! state* assoc :token-value value))
set-value
(fn [value event]
(swap! state* assoc
:current-value value
:token-value value)
(when on-change (on-change value event)))
select-item
(mf/use-fn
(mf/deps on-change on-blur labels-map)
(fn [{:keys [value] :as item}]
(swap! state* assoc
:current-value value
:token-value nil
:current-item item)
(when on-change (on-change item))
(when on-blur (on-blur))))
handle-change-input
(fn [event]
(let [value (-> event dom/get-target dom/get-value)
value (or (d/parse-double value) value)]
(set-value value event)))
handle-token-change-input
(fn [event]
(let [value (-> event dom/get-target dom/get-value)
value (or (d/parse-double value) value)]
(set-token-value! value)))
handle-key-down
(mf/use-fn
(mf/deps set-value is-open? token)
(fn [^js event]
(cond
token (let [backspace? (kbd/backspace? event)
enter? (kbd/enter? event)
value (-> event dom/get-target dom/get-value)
caret-at-beginning? (zero? (.. event -target -selectionStart))
no-text-selected? (str/empty? (.toString (js/document.getSelection)))
delete-token? (and backspace? caret-at-beginning? no-text-selected?)
replace-token-with-value? (and enter? (seq (str/trim value)))]
(cond
delete-token? (do
(dom/prevent-default event)
(on-token-remove token)
;; Re-focus the input value of the newly rendered input element
(swap! state* assoc :refocus? true))
replace-token-with-value? (do
(dom/prevent-default event)
(on-token-remove token)
(handle-change-input event)
(set-token-value! nil))
:else (set-token-value! value)))
(= type "number") (on-number-input-key-down {:event event
:min-val min-val
:max-val max-val
:set-value! set-value}))))
handle-focus
(mf/use-fn
(mf/deps refocus?)
(fn []
(when refocus?
(swap! state* dissoc :refocus?))
(mf/set-ref-val! emit-blur? false)))
handle-blur
(mf/use-fn
(fn []
(mf/set-ref-val! emit-blur? true)
(swap! state* assoc :token-value nil)
(timers/schedule
200
(fn []
(when (and on-blur (mf/ref-val emit-blur?)) (on-blur))))))]
(mf/use-effect
(mf/deps value current-value)
#(when (not= (str value) current-value)
(swap! state* assoc :current-value value)))
(mf/with-effect [is-open?]
(let [wrapper-node (mf/ref-val select-wrapper-ref)
node (dom/get-element-by-class "checked-element is-selected" wrapper-node)
nodes (dom/get-elements-by-class "checked-element-value" wrapper-node)
closest (fn [a b] (first (sort-by #(mth/abs (- % b)) a)))
closest-value (str (closest options value))]
(when is-open?
(if (some? node)
(dom/scroll-into-view-if-needed! node)
(some->> nodes
(d/seek #(= closest-value (dom/get-inner-text %)))
(dom/scroll-into-view-if-needed!)))))
(mf/set-ref-val! emit-blur? (not is-open?)))
[:div {:class (dm/str class " " (stl/css-case :editable-select true
:editable-select-disabled disabled))}
(when-let [{:keys [label value]} token]
[:div {:title (str label ": " value)
:class (stl/css :token-pill)}
(wtc/resolve-token-value token)])
(cond
token [:& :input (merge input-props
{:value (or (:token-value state) "")
:type "text"
:class input-class
:onChange handle-token-change-input
:onKeyDown handle-key-down
:onFocus handle-focus
:onBlur handle-blur})]
(= type "number") [:& numeric-input* (merge input-props
{:autoFocus refocus?
:value (or current-value "")
:className input-class
:onChange set-value
:onFocus handle-focus
:onBlur handle-blur
:placeholder placeholder})]
:else [:& :input (merge input-props
{:value (or current-value "")
:class input-class
:onChange handle-change-input
:onKeyDown handle-key-down
:onFocus handle-focus
:onBlur handle-blur
:placeholder placeholder
:type type})])
(when (seq options)
[:div {:class (stl/css :dropdown-button)
:on-click toggle-dropdown}
i/arrow])
(when (and is-open? (seq options))
[:& dropdown-select {:position position
:on-close close-dropdown
:element-id element-id
:element-ref select-wrapper-ref
:options options
:on-select select-item}])]))

View file

@ -0,0 +1,155 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
.editable-select {
@extend .asset-element;
margin: 0;
display: flex;
height: calc($s-32 - 2px); // Fixes border being clipped by the input field
width: 100%;
padding: $s-8;
border-radius: $br-8;
position: relative;
cursor: pointer;
background: transparent;
&:hover {
background: transparent;
}
&:focus-within {
.token-pill {
background-color: var(--button-primary-background-color-rest);
color: var(--button-primary-foreground-color-rest);
}
}
.dropdown-button {
@include flexCenter;
margin-right: -$s-8;
padding-right: $s-8;
padding-left: 0;
aspect-ratio: 0.8 / 1;
width: auto;
svg {
@extend .button-icon-small;
transform: rotate(90deg);
stroke: var(--icon-foreground);
}
}
.custom-select-dropdown-list {
min-width: 150px;
width: 100%;
max-width: 200px;
margin-bottom: 0;
}
.token-pill {
background-color: rgb(94 107 120 / 25%);
border-radius: $br-4;
padding: $s-2 $s-6;
text-overflow: ellipsis;
flex: 0 0 auto;
}
.token-pill + input {
flex: 1 1 auto;
width: 0;
}
.custom-select-dropdown-left {
left: 0;
right: unset;
}
.custom-select-dropdown-right {
right: 0;
left: unset;
}
.custom-select-dropdown {
@extend .dropdown-wrapper;
max-height: $s-320;
width: auto;
margin-top: $s-4;
.separator {
margin: 0;
height: $s-12;
}
.dropdown-element {
@extend .dropdown-element-base;
color: var(--menu-foreground-color-rest);
padding: 0;
display: flex;
& > span {
display: flex;
justify-content: flex-start;
align-content: center;
}
.label,
.value {
width: fit-content;
}
.label {
text-transform: unset;
flex: 1;
}
.value {
text-align: right;
justify-content: flex-end;
flex: 0.6;
}
.check-icon {
@include flexCenter;
translate: -$s-4 0;
svg {
@extend .button-icon-small;
visibility: hidden;
stroke: var(--icon-foreground);
}
}
&.is-selected {
color: var(--menu-foreground-color);
.check-icon svg {
stroke: var(--menu-foreground-color);
visibility: visible;
}
}
&:hover {
background-color: var(--menu-background-color-hover);
color: var(--menu-foreground-color-hover);
.check-icon svg {
stroke: var(--menu-foreground-color-hover);
}
}
&.is-highlighted {
background-color: var(--button-primary-background-color-rest);
span {
color: var(--button-primary-foreground-color-rest);
}
.check-icon svg {
stroke: var(--button-primary-foreground-color-rest);
}
}
}
}
}
.editable-select-disabled {
pointer-events: none;
}

View file

@ -0,0 +1,63 @@
(ns app.main.ui.workspace.tokens.errors
(:require
[cuerdas.core :as str]))
(def error-codes
{:error.import/json-parse-error
{:error/code :error.import/json-parse-error
:error/message "Import Error: Could not parse json"}
:error.import/invalid-json-data
{:error/code :error.import/invalid-json-data
:error/message "Import Error: Invalid token data in json."}
:error.import/style-dictionary-reference-errors
{:error/code :error.import/style-dictionary-reference-errors
:error/fn #(str "Import Error:\n\n" (str/join "\n\n" %))}
:error.import/style-dictionary-unknown-error
{:error/code :error.import/style-dictionary-reference-errors
:error/message "Import Error:"}
:error.token/direct-self-reference
{:error/code :error.token/direct-self-reference
:error/message "Token has self reference"}
:error.token/invalid-color
{:error/code :error.token/invalid-color
:error/fn #(str "Invalid color value: " %)}
:error.style-dictionary/missing-reference
{:error/code :error.style-dictionary/missing-reference
:error/fn #(str "Missing token references: " (str/join " " %))}
:error.style-dictionary/invalid-token-value
{:error/code :error.style-dictionary/invalid-token-value
:error/fn #(str "Invalid token value: " %)}
:error/unknown
{:error/code :error/unknown
:error/message "Unknown error"}})
(defn get-error-code [error-key]
(get error-codes error-key (:error/unknown error-codes)))
(defn error-with-value [error-key error-value]
(-> (get-error-code error-key)
(assoc :error/value error-value)))
(defn error-ex-info [error-key error-value exception]
(let [err (-> (error-with-value error-key error-value)
(assoc :error/exception exception))]
(ex-info (:error/code err) err)))
(defn has-error-code? [error-key errors]
(some #(= (:error/code %) error-key) errors))
(defn humanize-errors [errors]
(->> errors
(map (fn [err]
(cond
(:error/fn err) ((:error/fn err) (:error/value err))
(:error/message err) (:error/message err)
:else err)))))

View file

@ -0,0 +1,431 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.form
(:require-macros [app.main.style :as stl])
(:require
;; ["lodash.debounce" :as debounce]
[app.common.colors :as c]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.data.tokens :as dt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.color-bullet :refer [color-bullet]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.colorpicker :as colorpicker]
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector]]
[app.main.ui.workspace.tokens.common :as tokens.common]
[app.main.ui.workspace.tokens.errors :as wte]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.tinycolor :as tinycolor]
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.update :as wtu]
[app.util.dom :as dom]
[app.util.functions :as uf]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[malli.core :as m]
[malli.error :as me]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; Schemas ---------------------------------------------------------------------
(def valid-token-name-regexp
"Only allow letters and digits for token names.
Also allow one `.` for a namespace separator.
Caution: This will allow a trailing dot like `token-name.`,
But we will trim that in the `finalize-name`,
to not throw too many errors while the user is editing."
#"([a-zA-Z0-9-]+\.?)*")
(def valid-token-name-schema
(m/-simple-schema
{:type :token/invalid-token-name
:pred #(re-matches valid-token-name-regexp %)
:type-properties {:error/fn #(str (:value %) " is not a valid token name.
Token names should only contain letters and digits separated by . characters.")}}))
(defn token-name-schema
"Generate a dynamic schema validation to check if a token path derived from the name already exists at `tokens-tree`."
[{:keys [tokens-tree]}]
(let [path-exists-schema
(m/-simple-schema
{:type :token/name-exists
:pred #(not (wtt/token-name-path-exists? % tokens-tree))
:type-properties {:error/fn #(str "A token already exists at the path: " (:value %))}})]
(m/schema
[:and
[:string {:min 1 :max 255}]
valid-token-name-schema
path-exists-schema])))
(def token-description-schema
(m/schema
[:string {:max 2048}]))
;; Helpers ---------------------------------------------------------------------
(defn finalize-name [name]
(-> (str/trim name)
;; Remove trailing dots
(str/replace #"\.+$" "")))
(defn valid-name? [name]
(seq (finalize-name (str name))))
(defn finalize-value [value]
(-> (str value)
(str/trim)))
(defn valid-value? [value]
(seq (finalize-value value)))
(defn schema-validation->promise [validated]
(if (:errors validated)
(p/rejected validated)
(p/resolved validated)))
;; Component -------------------------------------------------------------------
(defn validate-token-value+
"Validates token value by resolving the value `input` using `StyleDictionary`.
Returns a promise of either resolved tokens or rejects with an error state."
[{:keys [value name-value token tokens]}]
(let [;; When creating a new token we dont have a token name yet,
;; so we use a temporary token name that hopefully doesn't clash with any of the users token names
token-name (if (str/empty? name-value) "__TOKEN_STUDIO_SYSTEM.TEMP" name-value)]
(cond
(empty? (str/trim value))
(p/rejected {:errors [{:error/code :error/empty-input}]})
(ctob/token-value-self-reference? token-name value)
(p/rejected {:errors [(wte/get-error-code :error.token/direct-self-reference)]})
:else
(-> (update tokens token-name merge {:value value
:name token-name
:type (:type token)})
(sd/resolve-tokens+)
(p/then
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens token-name)]
(cond
resolved-value (p/resolved resolved-token)
:else (p/rejected {:errors (or errors (wte/get-error-code :error/unknown-error))})))))))))
(defn use-debonced-resolve-callback
"Resolves a token values using `StyleDictionary`.
This function is debounced as the resolving might be an expensive calculation.
Uses a custom debouncing logic, as the resolve function is async."
[name-ref token tokens callback & {:keys [timeout] :or {timeout 160}}]
(let [timeout-id-ref (mf/use-ref nil)
debounced-resolver-callback
(mf/use-fn
(mf/deps token callback tokens)
(fn [value]
(let [timeout-id (js/Symbol)
;; Dont execute callback when the timout-id-ref is outdated because this function got called again
timeout-outdated-cb? #(not= (mf/ref-val timeout-id-ref) timeout-id)]
(mf/set-ref-val! timeout-id-ref timeout-id)
(js/setTimeout
(fn []
(when (not (timeout-outdated-cb?))
(-> (validate-token-value+ {:value value
:name-value @name-ref
:token token
:tokens tokens})
(p/finally
(fn [x err]
(when-not (timeout-outdated-cb?)
(callback (or err x))))))))
timeout))))]
debounced-resolver-callback))
(defonce form-token-cache-atom (atom nil))
(mf/defc ramp
[{:keys [color on-change]}]
(let [wrapper-node-ref (mf/use-ref nil)
dragging? (mf/use-state)
hex->value (fn [hex]
(when-let [tc (tinycolor/valid-color hex)]
(let [hex (str "#" (tinycolor/->hex tc))
[r g b] (c/hex->rgb hex)
[h s v] (c/hex->hsv hex)]
{:hex hex
:r r :g g :b b
:h h :s s :v v
:alpha 1})))
value (mf/use-state (hex->value color))
on-change' (fn [{:keys [hex]}]
(reset! value (hex->value hex))
(when-not (and @dragging? hex)
(on-change hex)))]
(colorpicker/use-color-picker-css-variables! wrapper-node-ref @value)
[:div {:ref wrapper-node-ref}
[:& ramp-selector
{:color @value
:disable-opacity true
:on-start-drag #(reset! dragging? true)
:on-finish-drag #(reset! dragging? false)
:on-change on-change'}]]))
(mf/defc token-value-or-errors
[{:keys [result-or-errors]}]
(let [{:keys [errors]} result-or-errors
empty-message? (or (nil? result-or-errors)
(wte/has-error-code? :error/empty-input errors))
message (cond
empty-message? (dm/str (tr "workspace.token.resolved-value") "-")
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else (dm/str (tr "workspace.token.resolved-value") result-or-errors))]
[:> text* {:as "p"
:typography "body-small"
:class (stl/css-case :resolved-value true
:resolved-value-placeholder empty-message?
:resolved-value-error (seq errors))}
message]))
(mf/defc form
{::mf/wrap-props false}
[{:keys [token token-type action selected-token-set-id]}]
(let [validate-name? (mf/use-state (not (:id token)))
token (or token {:type token-type})
color? (wtt/color-token? token)
selected-set-tokens (mf/deref refs/workspace-selected-token-set-tokens)
active-theme-tokens (mf/deref refs/workspace-active-theme-sets-tokens)
resolved-tokens (sd/use-resolved-tokens active-theme-tokens {:cache-atom form-token-cache-atom
:interactive? true})
token-path (mf/use-memo
(mf/deps (:name token))
#(wtt/token-name->path (:name token)))
selected-set-tokens-tree (mf/use-memo
(mf/deps token-path selected-set-tokens)
(fn []
(-> (ctob/tokens-tree selected-set-tokens)
;; Allow setting editing token to it's own path
(d/dissoc-in token-path))))
;; Name
name-ref (mf/use-var (:name token))
name-errors (mf/use-state nil)
validate-name
(mf/use-fn
(mf/deps selected-set-tokens-tree)
(fn [value]
(let [schema (token-name-schema {:token token
:tokens-tree selected-set-tokens-tree})]
(m/explain schema (finalize-name value)))))
on-update-name-debounced
(mf/use-fn
(uf/debounce (fn [e]
(let [value (dom/get-target-val e)
errors (validate-name value)]
;; Prevent showing error when just going to another field on a new token
(when-not (and validate-name? (str/empty? value))
(reset! validate-name? false)
(reset! name-errors errors))))))
on-update-name
(mf/use-fn
(mf/deps on-update-name-debounced)
(fn [e]
(reset! name-ref (dom/get-target-val e))
(on-update-name-debounced e)))
valid-name-field? (and
(not @name-errors)
(valid-name? @name-ref))
;; Value
color (mf/use-state (when color? (:value token)))
color-ramp-open? (mf/use-state false)
value-input-ref (mf/use-ref nil)
value-ref (mf/use-var (:value token))
token-resolve-result (mf/use-state (get-in resolved-tokens [(wtt/token-identifier token) :resolved-value]))
set-resolve-value
(mf/use-fn
(fn [token-or-err]
(let [error? (:errors token-or-err)
v (if error?
token-or-err
(:resolved-value token-or-err))]
(when color? (reset! color (if error? nil v)))
(reset! token-resolve-result v))))
on-update-value-debounced (use-debonced-resolve-callback name-ref token active-theme-tokens set-resolve-value)
on-update-value (mf/use-fn
(mf/deps on-update-value-debounced)
(fn [e]
(let [value (dom/get-target-val e)]
(reset! value-ref value)
(on-update-value-debounced value))))
on-update-color (mf/use-fn
(mf/deps on-update-value-debounced)
(fn [hex-value]
(reset! value-ref hex-value)
(set! (.-value (mf/ref-val value-input-ref)) hex-value)
(on-update-value-debounced hex-value)))
value-error? (seq (:errors @token-resolve-result))
valid-value-field? (and
(not value-error?)
(valid-value? @token-resolve-result))
;; Description
description-ref (mf/use-var (:description token))
description-errors (mf/use-state nil)
validate-descripion (mf/use-fn #(m/explain token-description-schema %))
on-update-description-debounced (mf/use-fn
(uf/debounce (fn [e]
(let [value (dom/get-target-val e)
errors (validate-descripion value)]
(reset! description-errors errors)))))
on-update-description
(mf/use-fn
(mf/deps on-update-description-debounced)
(fn [e]
(reset! description-ref (dom/get-target-val e))
(on-update-description-debounced e)))
valid-description-field? (not @description-errors)
;; Form
disabled? (or (not valid-name-field?)
(not valid-value-field?)
(not valid-description-field?))
on-submit
(mf/use-fn
(mf/deps validate-name validate-descripion token resolved-tokens)
(fn [e]
(dom/prevent-default e)
;; We have to re-validate the current form values before submitting
;; because the validation is asynchronous/debounced
;; and the user might have edited a valid form to make it invalid,
;; and press enter before the next validations could return.
(let [final-name (finalize-name @name-ref)
valid-name?+ (-> (validate-name final-name) schema-validation->promise)
final-value (finalize-value @value-ref)
final-description @description-ref
valid-description?+ (some-> final-description validate-descripion schema-validation->promise)]
(-> (p/all [valid-name?+
valid-description?+
(validate-token-value+ {:value final-value
:name-value final-name
:token token
:tokens resolved-tokens})])
(p/finally (fn [result err]
;; The result should be a vector of all resolved validations
;; We do not handle the error case as it will be handled by the components validations
(when (and (seq result) (not err))
(st/emit! (dt/update-create-token {:token (ctob/make-token :name final-name
:type (or (:type token) token-type)
:value final-value
:description final-description)
:prev-token-name (:name token)}))
(st/emit! (wtu/update-workspace-tokens))
(modal/hide!))))))))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dt/delete-token selected-token-set-id (:name token)))))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))]
[:form {:class (stl/css :form-wrapper)
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(if (= action "edit")
(tr "workspace.token.edit-token")
(tr "workspace.token.create-token" token-type))]
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span {:class (stl/css :labeled-input-label)} "Name"]
[:& tokens.common/labeled-input {:label "Name"
:error? @name-errors
:input-props {:default-value @name-ref
:auto-focus true
:on-blur on-update-name
:on-change on-update-name}}]
(for [error (->> (:errors @name-errors)
(map #(-> (assoc @name-errors :errors [%])
(me/humanize))))]
[:> text* {:as "p"
:key error
:typography "body-small"
:class (stl/css :error)}
error])]
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span {:class (stl/css :labeled-input-label)} "value"]
[:& tokens.common/labeled-input {:label "Value"
:input-props {:default-value @value-ref
:on-blur on-update-value
:on-change on-update-value
:ref value-input-ref}
:render-right (when color?
(mf/fnc color-bullet []
[:div {:class (stl/css :color-bullet)
:on-click #(swap! color-ramp-open? not)}
(if-let [hex (some-> @color tinycolor/valid-color tinycolor/->hex)]
[:& color-bullet {:color hex
:mini? true}]
[:div {:class (stl/css :color-bullet-placeholder)}])]))}]
(when @color-ramp-open?
[:& ramp {:color (some-> (or @token-resolve-result (:value token))
(tinycolor/valid-color))
:on-change on-update-color}])
[:& token-value-or-errors {:result-or-errors @token-resolve-result}]]
[:div {:class (stl/css :input-row)}
;; This should be remove when labeled-imput is modified
[:span {:class (stl/css :labeled-input-label)} "Description"]
[:& tokens.common/labeled-input {:label "Description"
:input-props {:default-value @description-ref
:on-change on-update-description}}]
(when @description-errors
[:> text* {:as "p"
:typography "body-small"
:class (stl/css :error)}
(me/humanize @description-errors)])]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:type "button"
:variant "secondary"}
(tr "labels.cancel")]
[:> button* {:type "submit"
:variant "primary"
:disabled disabled?}
(tr "labels.save")]]]]))

View file

@ -0,0 +1,85 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
@import "./common.scss";
.form-wrapper {
width: $s-384;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: $s-12;
padding-block-start: $s-8;
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.delete-btn {
justify-self: start;
}
.token-rows {
display: flex;
flex-direction: column;
gap: $s-16;
}
.input-row {
display: flex;
flex-direction: column;
gap: $s-4;
}
.labeled-input-label {
color: var(--color-foreground-primary);
}
.error {
padding: $s-4 $s-6;
margin-bottom: 0;
color: var(--status-color-error-500);
}
.resolved-value {
--input-hint-color: var(--color-foreground-primary);
margin-bottom: 0;
padding: $s-4 $s-6;
color: var(--input-hint-color);
}
.resolved-value-placeholder {
--input-hint-color: var(--color-foreground-secondary);
}
.resolved-value-error {
--input-hint-color: var(--status-color-error-500);
}
.color-bullet {
margin-right: $s-8;
cursor: pointer;
}
.color-bullet-placeholder {
width: var(--bullet-size, $s-16);
height: var(--bullet-size, $s-16);
min-width: var(--bullet-size, $s-16);
min-height: var(--bullet-size, $s-16);
margin-top: 0;
background-color: color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent);
border-radius: $br-4;
cursor: pointer;
}
.form-modal-title {
color: var(--color-foreground-primary);
}

View file

@ -0,0 +1,146 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.modals
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.tokens.form :refer [form]]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
;; Component -------------------------------------------------------------------
(defn calculate-position
"Calculates the style properties for the given coordinates and position"
[{vh :height} position x y]
(let [;; picker height in pixels
h 510
;; Checks for overflow outside the viewport height
overflow-fix (max 0 (+ y (- 50) h (- vh)))
x-pos 325]
(cond
(or (nil? x) (nil? y)) {:left "auto" :right "16rem" :top "4rem"}
(= position :left) {:left (str (- x x-pos) "px")
:top (str (- y 50 overflow-fix) "px")}
:else {:left (str (+ x 80) "px")
:top (str (- y 70 overflow-fix) "px")})))
(defn use-viewport-position-style [x y position]
(let [vport (-> (l/derived :vport refs/workspace-local)
(mf/deref))]
(-> (calculate-position vport position x y)
(clj->js))))
(mf/defc token-update-create-modal
{::mf/wrap-props false}
[{:keys [x y position token token-type action selected-token-set-id] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position)
close-modal (mf/use-fn
(fn []
(modal/hide!)))]
[:div {:class (stl/css :token-modal-wrapper)
:style wrapper-style}
[:> icon-button* {:on-click close-modal
:class (stl/css :close-btn)
:icon i/close
:variant "action"
:aria-label (tr "labels.close")}]
[:& form {:token token
:action action
:selected-token-set-id selected-token-set-id
:token-type token-type}]]))
;; Modals ----------------------------------------------------------------------
(mf/defc boolean-modal
{::mf/register modal/components
::mf/register-as :tokens/boolean}
[properties]
[:& token-update-create-modal properties])
(mf/defc border-radius-modal
{::mf/register modal/components
::mf/register-as :tokens/border-radius}
[properties]
[:& token-update-create-modal properties])
(mf/defc color-modal
{::mf/register modal/components
::mf/register-as :tokens/color}
[properties]
[:& token-update-create-modal properties])
(mf/defc stroke-width-modal
{::mf/register modal/components
::mf/register-as :tokens/stroke-width}
[properties]
[:& token-update-create-modal properties])
(mf/defc box-shadow-modal
{::mf/register modal/components
::mf/register-as :tokens/box-shadow}
[properties]
[:& token-update-create-modal properties])
(mf/defc sizing-modal
{::mf/register modal/components
::mf/register-as :tokens/sizing}
[properties]
[:& token-update-create-modal properties])
(mf/defc dimensions-modal
{::mf/register modal/components
::mf/register-as :tokens/dimensions}
[properties]
[:& token-update-create-modal properties])
(mf/defc numeric-modal
{::mf/register modal/components
::mf/register-as :tokens/numeric}
[properties]
[:& token-update-create-modal properties])
(mf/defc opacity-modal
{::mf/register modal/components
::mf/register-as :tokens/opacity}
[properties]
[:& token-update-create-modal properties])
(mf/defc other-modal
{::mf/register modal/components
::mf/register-as :tokens/other}
[properties]
[:& token-update-create-modal properties])
(mf/defc rotation-modal
{::mf/register modal/components
::mf/register-as :tokens/rotation}
[properties]
[:& token-update-create-modal properties])
(mf/defc spacing-modal
{::mf/register modal/components
::mf/register-as :tokens/spacing}
[properties]
[:& token-update-create-modal properties])
(mf/defc string-modal
{::mf/register modal/components
::mf/register-as :tokens/string}
[properties]
[:& token-update-create-modal properties])
(mf/defc typography-modal
{::mf/register modal/components
::mf/register-as :tokens/typography}
[properties]
[:& token-update-create-modal properties])

View file

@ -0,0 +1,24 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.token-modal-wrapper {
@extend .modal-container-base;
@include menuShadow;
position: absolute;
width: auto;
min-width: auto;
z-index: 11;
overflow-y: auto;
overflow-x: hidden;
}
.close-btn {
position: absolute;
top: $s-6;
right: $s-6;
}

View file

@ -0,0 +1,369 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.modals.themes
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.data.tokens :as wdt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.icons :as i]
[app.main.ui.workspace.tokens.common :refer [labeled-input] :as wtco]
[app.main.ui.workspace.tokens.sets :as wts]
[app.main.ui.workspace.tokens.sets-context :as sets-context]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(mf/defc empty-themes
[{:keys [set-state]}]
(let [create-theme
(mf/use-fn
(mf/deps set-state)
#(set-state (fn [_] {:type :create-theme})))]
[:div {:class (stl/css :themes-modal-wrapper)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
(tr "workspace.token.themes")]
[:div {:class (stl/css :empty-themes-wrapper)}
[:div {:class (stl/css :empty-themes-message)}
[:> text* {:as "span" :typography "title-medium" :class (stl/css :empty-theme-title)}
(tr "workspace.token.no-themes-currently")]
[:> text* {:as "span"
:class (stl/css :empty-theme-subtitle)
:typography "body-medium"}
(tr "workspace.token.create-new-theme")]]
[:div {:class (stl/css :button-footer)}
[:> button* {:variant "primary"
:type "button"
:on-click create-theme}
(tr "workspace.token.new-theme")]]]]))
(mf/defc switch
[{:keys [selected? name on-change]}]
(let [selected (if selected? :on :off)]
[:& radio-buttons {:selected selected
:on-change on-change
:name name}
[:& radio-button {:id :on
:value :on
:icon i/tick
:label ""}]
[:& radio-button {:id :off
:value :off
:icon i/close
:label ""}]]))
(mf/defc themes-overview
[{:keys [set-state]}]
(let [active-theme-ids (mf/deref refs/workspace-active-theme-paths)
themes-groups (mf/deref refs/workspace-token-theme-tree-no-hidden)
create-theme
(mf/use-fn
(mf/deps set-state)
(fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(set-state (fn [_] {:type :create-theme}))))]
[:div {:class (stl/css :themes-modal-wrapper)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
(tr "workspace.token.themes")]
[:ul {:class (stl/css :theme-group-wrapper)}
(for [[group themes] themes-groups]
[:li {:key (dm/str "token-theme-group" group)}
(when (seq group)
[:> heading* {:level 3
:class (stl/css :theme-group-label)
:typography "body-large"}
[:span {:class (stl/css :group-title)}
[:> icon* {:id "group"}]
group]])
[:ul {:class (stl/css :theme-group-rows-wrapper)}
(for [[_ {:keys [group name] :as theme}] themes
:let [theme-id (ctob/theme-path theme)
selected? (some? (get active-theme-ids theme-id))
delete-theme
(fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (wdt/delete-token-theme group name)))
on-edit-theme
(fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(set-state (fn [_] {:type :edit-theme
:theme-path [(:id theme) (:group theme) (:name theme)]})))]]
[:li {:key theme-id
:class (stl/css :theme-row)}
[:div {:class (stl/css :theme-row-left)}
;; FIREEEEEEEEEE THIS
[:div {:on-click (fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(st/emit! (wdt/toggle-token-theme-active? group name)))}
[:& switch {:name (tr "workspace.token.theme" name)
:on-change (constantly nil)
:selected? selected?}]]
[:> text* {:as "span" :typography "body-medium" :class (stl/css :theme-name)} name]]
[:div {:class (stl/css :theme-row-right)}
(if-let [sets-count (some-> theme :sets seq count)]
[:> button* {:class (stl/css :sets-count-button)
:variant "secondary"
:type "button"
:on-click on-edit-theme}
[:div {:class (stl/css :label-wrapper)}
[:> text* {:as "span" :typography "body-medium"}
(tr "workspace.token.num-sets" sets-count)]
[:> icon* {:id "arrow-right"}]]]
[:> button* {:class (stl/css :sets-count-empty-button)
:type "button"
:variant "secondary"
:on-click on-edit-theme}
[:div {:class (stl/css :label-wrapper)}
[:> text* {:as "span" :typography "body-medium"}
(tr "workspace.token.no-sets")]
[:> icon* {:id "arrow-right"}]]])
[:> icon-button* {:on-click delete-theme
:variant "ghost"
:aria-label (tr "workspace.token.delete-theme-title")
:icon "delete"}]]])]])]
[:div {:class (stl/css :button-footer)}
[:> button* {:variant "primary"
:type "button"
:icon "add"
:on-click create-theme}
(tr "workspace.token.create-theme-title")]]]))
(mf/defc theme-inputs
[{:keys [theme dropdown-open? on-close-dropdown on-toggle-dropdown on-change-field]}]
(let [theme-groups (mf/deref refs/workspace-token-theme-groups)
group-input-ref (mf/use-ref)
on-update-group (partial on-change-field :group)
on-update-name (partial on-change-field :name)]
[:div {:class (stl/css :edit-theme-inputs-wrapper)}
[:div {:class (stl/css :group-input-wrapper)}
(when dropdown-open?
[:& wtco/dropdown-select {:id ::groups-dropdown
:shortcuts-key ::groups-dropdown
:options (map (fn [group]
{:label group
:value group})
theme-groups)
:on-select (fn [{:keys [value]}]
(set! (.-value (mf/ref-val group-input-ref)) value)
(on-update-group value))
:on-close on-close-dropdown}])
;; TODO: This span should be remove when labeled-input is updated
[:span {:class (stl/css :labeled-input-label)} "Theme group"]
[:& labeled-input {:label "Group"
:input-props {:ref group-input-ref
:default-value (:group theme)
:on-change (comp on-update-group dom/get-target-val)}
:render-right (when (seq theme-groups)
(mf/fnc drop-down-button []
[:button {:class (stl/css :group-drop-down-button)
:type "button"
:on-click (fn [e]
(dom/stop-propagation e)
(on-toggle-dropdown))}
[:> icon* {:id "arrow-down"}]]))}]]
[:div {:class (stl/css :group-input-wrapper)}
;; TODO: This span should be remove when labeled-input is updated
[:span {:class (stl/css :labeled-input-label)} "Theme"]
[:& labeled-input {:label "Theme"
:input-props {:default-value (:name theme)
:on-change (comp on-update-name dom/get-target-val)}}]]]))
(mf/defc theme-modal-buttons
[{:keys [close-modal on-save-form disabled?] :as props}]
[:*
[:> button* {:variant "secondary"
:type "button"
:on-click close-modal}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:type "submit"
:on-click on-save-form
:disabled disabled?}
(tr "workspace.token.save-theme")]])
(mf/defc create-theme
[{:keys [set-state]}]
(let [{:keys [dropdown-open? _on-open-dropdown on-close-dropdown on-toggle-dropdown]} (wtco/use-dropdown-open-state)
theme (ctob/make-token-theme :name "")
on-back #(set-state (constantly {:type :themes-overview}))
on-submit #(st/emit! (wdt/create-token-theme %))
theme-state (mf/use-state theme)
disabled? (-> (:name @theme-state)
(str/trim)
(str/empty?))
on-change-field (fn [field value]
(swap! theme-state #(assoc % field value)))
on-save-form (mf/use-callback
(mf/deps theme-state on-submit)
(fn [e]
(dom/prevent-default e)
(let [theme (-> @theme-state
(update :name str/trim)
(update :group str/trim)
(update :description str/trim))]
(when-not (str/empty? (:name theme))
(on-submit theme)))
(on-back)))
close-modal (mf/use-fn
(fn [e]
(dom/prevent-default e)
(st/emit! (modal/hide))))]
[:div {:class (stl/css :themes-modal-wrapper)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
(tr "workspace.token.create-theme-title")]
[:form {:on-submit on-save-form}
[:div {:class (stl/css :create-theme-wrapper)}
[:& theme-inputs {:dropdown-open? dropdown-open?
:on-close-dropdown on-close-dropdown
:on-toggle-dropdown on-toggle-dropdown
:theme theme
:on-change-field on-change-field}]
[:div {:class (stl/css :button-footer)}
[:& theme-modal-buttons {:close-modal close-modal
:on-save-form on-save-form
:disabled? disabled?}]]]]]))
(mf/defc controlled-edit-theme
[{:keys [state set-state]}]
(let [{:keys [theme-path]} @state
[_ theme-group theme-name] theme-path
token-sets (mf/deref refs/workspace-ordered-token-sets)
theme (mf/deref (refs/workspace-token-theme theme-group theme-name))
on-back #(set-state (constantly {:type :themes-overview}))
on-submit #(st/emit! (wdt/update-token-theme [(:group theme) (:name theme)] %))
{:keys [dropdown-open? _on-open-dropdown on-close-dropdown on-toggle-dropdown]} (wtco/use-dropdown-open-state)
theme-state (mf/use-state theme)
disabled? (-> (:name @theme-state)
(str/trim)
(str/empty?))
token-set-active? (mf/use-callback
(mf/deps theme-state)
(fn [set-name]
(get-in @theme-state [:sets set-name])))
on-toggle-token-set (mf/use-callback
(mf/deps theme-state)
(fn [set-name]
(swap! theme-state #(ctob/toggle-set % set-name))))
on-change-field (fn [field value]
(swap! theme-state #(assoc % field value)))
on-save-form (mf/use-callback
(mf/deps theme-state on-submit)
(fn [e]
(dom/prevent-default e)
(let [theme (-> @theme-state
(update :name str/trim)
(update :group str/trim)
(update :description str/trim))]
(when-not (str/empty? (:name theme))
(on-submit theme)))
(on-back)))
close-modal
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(st/emit! (modal/hide))))
on-delete-token
(mf/use-fn
(mf/deps theme on-back)
(fn []
(st/emit! (wdt/delete-token-theme (:group theme) (:name theme)))
(on-back)))]
[:div {:class (stl/css :themes-modal-wrapper)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
(tr "workspace.token.edit-theme-title")]
[:form {:on-submit on-save-form}
[:div {:class (stl/css :edit-theme-wrapper)}
[:button {:on-click on-back
:class (stl/css :back-btn)
:type "button"}
[:> icon* {:id ic/arrow-left :aria-hidden true}]
(tr "workspace.token.back-to-themes")]
[:& theme-inputs {:dropdown-open? dropdown-open?
:on-close-dropdown on-close-dropdown
:on-toggle-dropdown on-toggle-dropdown
:theme theme
:on-change-field on-change-field}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :select-sets-message)}
(tr "workspace.token.set-selection-theme")]
[:div {:class (stl/css :sets-list-wrapper)}
[:& wts/controlled-sets-list
{:token-sets token-sets
:token-set-selected? (constantly false)
:token-set-active? token-set-active?
:on-select on-toggle-token-set
:on-toggle-token-set on-toggle-token-set
:origin "theme-modal"
:context sets-context/static-context}]]
[:div {:class (stl/css :edit-theme-footer)}
[:> button* {:variant "secondary"
:type "button"
:icon "delete"
:on-click on-delete-token}
(tr "labels.delete")]
[:div {:class (stl/css :button-footer)}
[:& theme-modal-buttons {:close-modal close-modal
:on-save-form on-save-form
:disabled? disabled?}]]]]]]))
(mf/defc themes-modal-body
[_]
(let [themes (mf/deref refs/workspace-token-themes-no-hidden)
state (mf/use-state (if (empty? themes)
{:type :create-theme}
{:type :themes-overview}))
set-state (mf/use-callback #(swap! state %))
component (case (:type @state)
:empty-themes empty-themes
:themes-overview (if (empty? themes) empty-themes themes-overview)
:edit-theme controlled-edit-theme
:create-theme create-theme)]
[:& component {:state state
:set-state set-state}]))
(mf/defc token-themes-modal
{::mf/wrap-props false
::mf/register modal/components
::mf/register-as :tokens/themes}
[_args]
(let [handle-close-dialog (mf/use-callback #(st/emit! (modal/hide)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)}
[:> icon-button* {:class (stl/css :close-btn)
:on-click handle-close-dialog
:aria-label (tr "labels.close")
:variant "action"
:icon "close"}]
[:& themes-modal-body]]]))

View file

@ -0,0 +1,198 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-dialog {
@extend .modal-container-base;
display: grid;
grid-template-rows: auto 1fr auto;
width: 100%;
max-width: $s-468;
user-select: none;
}
.empty-themes-message {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: $s-12;
padding: $s-72 0;
}
.themes-modal-wrapper {
display: flex;
flex-direction: column;
gap: $s-24;
}
.themes-modal-title {
color: var(--color-foreground-primary);
}
.back-btn {
background-color: transparent;
border: none;
appearance: none;
color: var(--color-foreground-secondary);
width: fit-content;
display: grid;
grid-template-columns: auto auto;
gap: $s-4;
align-items: center;
padding: 0;
&:hover {
color: var(--color-accent-primary);
}
}
.labeled-input-label {
color: var(--color-foreground-primary);
}
.button-footer {
display: flex;
justify-content: flex-end;
gap: $s-6;
}
.edit-theme-footer {
display: flex;
justify-content: space-between;
}
.empty-themes-wrapper {
display: flex;
flex-direction: column;
color: var(--color-foreground-secondary);
}
.empty-theme-subtitle {
color: var(--color-foreground-secondary);
}
.empty-theme-title {
color: var(--color-foreground-primary);
}
.select-sets-message {
color: var(--color-foreground-secondary);
}
.create-theme-wrapper {
display: flex;
flex-direction: column;
gap: $s-24;
}
.close-btn {
position: absolute;
top: $s-8;
right: $s-6;
}
.theme-group-label {
color: var(--color-foreground-secondary);
}
.group-title {
display: flex;
align-items: center;
justify-content: flex-start;
gap: $s-4;
}
.theme-group-rows-wrapper {
display: flex;
flex-direction: column;
gap: $s-6;
}
.theme-group-wrapper {
display: flex;
flex-direction: column;
gap: $s-8;
}
.theme-row {
display: flex;
align-items: center;
gap: $s-12;
justify-content: space-between;
}
.theme-row-left {
display: flex;
align-items: center;
gap: $s-16;
}
.theme-name {
color: var(--color-foreground-primary);
}
.theme-row-right {
display: flex;
align-items: center;
gap: $s-6;
}
.sets-count-button {
text-transform: lowercase;
padding: $s-6;
padding-left: $s-12;
}
.label-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.edit-theme-wrapper {
display: flex;
flex-direction: column;
gap: $s-12;
}
.sets-list-wrapper {
border: 1px solid color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent);
border-radius: $s-8;
overflow: hidden;
}
.sets-count-empty-button {
text-transform: lowercase;
padding: $s-6;
padding-left: $s-12;
}
.group-input-wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: $s-4;
}
.edit-theme-inputs-wrapper {
display: grid;
grid-template-columns: 0.6fr 1fr;
gap: $s-12;
}
.group-drop-down-button {
@include buttonStyle;
color: var(--color-foreground-secondary);
width: $s-24;
height: 100%;
padding: 0;
margin: 0 $s-6;
}

View file

@ -0,0 +1,270 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.sets
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.notifications :as ntf]
[app.main.data.tokens :as wdt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.hooks :as h]
[app.main.ui.workspace.tokens.sets-context :as sets-context]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn on-toggle-token-set-click [token-set-name]
(st/emit! (wdt/toggle-token-set {:token-set-name token-set-name})))
(defn on-select-token-set-click [name]
(st/emit! (wdt/set-selected-token-set-id name)))
(defn on-update-token-set [set-name token-set]
(st/emit! (wdt/update-token-set set-name token-set)))
(defn on-create-token-set [token-set]
(st/emit! (wdt/create-token-set token-set)))
(mf/defc editing-node
[{:keys [default-value on-cancel on-submit]}]
(let [ref (mf/use-ref)
on-submit-valid (mf/use-fn
(fn [event]
(let [value (str/trim (dom/get-target-val event))]
(if (or (str/empty? value)
(= value default-value))
(on-cancel)
(on-submit value)))))
on-key-down (mf/use-fn
(fn [event]
(cond
(kbd/enter? event) (on-submit-valid event)
(kbd/esc? event) (on-cancel))))]
[:input
{:class (stl/css :editing-node)
:type "text"
:ref ref
:on-blur on-submit-valid
:on-key-down on-key-down
:auto-focus true
:default-value default-value}]))
(mf/defc sets-tree
[{:keys [token-set
token-set-active?
token-set-selected?
editing?
on-select
on-toggle
on-edit
on-submit
on-cancel]
:as _props}]
(let [{:keys [name _children]} token-set
selected? (and set? (token-set-selected? name))
visible? (token-set-active? name)
collapsed? (mf/use-state false)
set? true #_(= type :set)
group? false #_(= type :group)
editing-node? (editing? name)
on-click
(mf/use-fn
(mf/deps editing-node?)
(fn [event]
(dom/stop-propagation event)
(when-not editing-node?
(on-select name))))
on-context-menu
(mf/use-fn
(mf/deps editing-node? name)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(when-not editing-node?
(st/emit!
(wdt/show-token-set-context-menu
{:position (dom/get-client-position event)
:token-set-name name})))))
on-drag
(mf/use-fn
(mf/deps name)
(fn [_]
(when-not selected?
(on-select name))))
on-drop
(mf/use-fn
(mf/deps name)
(fn [position data]
(st/emit! (wdt/move-token-set (:name data) name position))))
on-submit-edit
(mf/use-fn
(mf/deps on-submit token-set)
#(on-submit (assoc token-set :name %)))
on-edit-name
(mf/use-fn
(fn [e]
(let [name (-> (dom/get-current-target e)
(dom/get-data "name"))]
(on-edit name))))
on-toggle-set (fn [event]
(dom/stop-propagation event)
(on-toggle name))
on-collapse (mf/use-fn #(swap! collapsed? not))
[dprops dref]
(h/use-sortable
:data-type "penpot/token-set"
:on-drag on-drag
:on-drop on-drop
:data {:name name}
:draggable? true)]
[:div {:ref dref
:role "button"
:class (stl/css-case :set-item-container true
:dnd-over (= (:over dprops) :center)
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot))
:on-click on-click
:on-double-click on-edit-name
:on-context-menu on-context-menu
:data-name name}
[:div {:class (stl/css-case :set-item-group group?
:set-item-set set?
:selected-set selected?)}
(when group?
[:> icon-button* {:on-click on-collapse
:aria-label (tr "labels.collapse")
:icon (if @collapsed?
"arrow-right"
"arrow-down")
:variant "action"}])
[:> icon* {:id (if set? "document" "group")
:class (stl/css :icon)}]
(if editing-node?
[:& editing-node {:default-value name
:on-submit on-submit-edit
:on-cancel on-cancel}]
[:*
[:div {:class (stl/css :set-name)} name]
(if set?
[:button {:on-click on-toggle-set
:class (stl/css-case :checkbox-style true
:checkbox-checked-style visible?)}
(when visible?
[:> icon* {:aria-label (tr "workspace.token.select-set")
:class (stl/css :check-icon)
:size "s"
:id ic/tick}])]
nil
#_(when (and children (not @collapsed?))
[:div {:class (stl/css :set-children)}
(for [child-id children]
[:& sets-tree (assoc props :key child-id
{:key child-id}
:set-id child-id
:selected-set-id selected-token-set-id)])]))])]]))
(defn warn-on-try-create-token-set-group! []
(st/emit! (ntf/show {:content (tr "workspace.token.grouping-set-alert")
:notification-type :toast
:type :warning
:timeout 3000})))
(mf/defc controlled-sets-list
[{:keys [token-sets
on-update-token-set
token-set-selected?
token-set-active?
on-create-token-set
on-toggle-token-set
origin
on-select
context]
:as _props}]
(let [{:keys [editing? new? on-edit on-create on-reset] :as ctx} (or context (sets-context/use-context))
avoid-token-set-grouping #(str/replace % "/" "-")
submit-token
#(do
;; TODO: We don't support set grouping for now so we rename sets for now
(when (str/includes? (:name %) "/")
(warn-on-try-create-token-set-group!))
(on-create-token-set (update % :name avoid-token-set-grouping))
(on-reset))]
[:ul {:class (stl/css :sets-list)}
(if (and
(= origin "theme-modal")
(empty? token-sets))
[:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message-sets)}
(tr "workspace.token.no-sets-create")]
(for [token-set token-sets]
(when token-set
(let [update-token
#(do
;; TODO: We don't support set grouping for now so we rename sets for now
(when (str/includes? (:name %) "/")
(warn-on-try-create-token-set-group!))
(on-update-token-set (avoid-token-set-grouping (:name token-set)) (update % :name avoid-token-set-grouping))
(on-reset))]
[:& sets-tree
{:key (:name token-set)
:token-set token-set
:token-set-selected? (if new? (constantly false) token-set-selected?)
:token-set-active? token-set-active?
:editing? editing?
:on-select on-select
:on-edit on-edit
:on-toggle on-toggle-token-set
:on-submit update-token
:on-cancel on-reset}]))))
(when new?
[:& sets-tree
{:token-set {:name ""}
:token-set-selected? (constantly true)
:token-set-active? (constantly true)
:editing? (constantly true)
:on-select (constantly nil)
:on-edit on-create
:on-submit submit-token
:on-cancel on-reset}])]))
(mf/defc sets-list
[{:keys []}]
(let [token-sets (mf/deref refs/workspace-ordered-token-sets)
selected-token-set-id (mf/deref refs/workspace-selected-token-set-id)
token-set-selected? (mf/use-fn
(mf/deps token-sets selected-token-set-id)
(fn [set-name]
(= set-name selected-token-set-id)))
active-token-set-ids (mf/deref refs/workspace-active-set-names)
token-set-active? (mf/use-fn
(mf/deps active-token-set-ids)
(fn [id]
(get active-token-set-ids id)))]
[:& controlled-sets-list
{:token-sets token-sets
:token-set-selected? token-set-selected?
:token-set-active? token-set-active?
:on-select on-select-token-set-click
:origin "set-panel"
:on-toggle-token-set on-toggle-token-set-click
:on-update-token-set on-update-token-set
:on-create-token-set on-create-token-set}]))

View file

@ -0,0 +1,125 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.sets-list {
width: 100%;
margin-bottom: 0;
overflow-y: auto;
}
.set-item-container {
width: 100%;
cursor: pointer;
color: var(--layer-row-foreground-color);
padding-left: $s-20;
border: $s-2 solid transparent;
&.dnd-over-bot {
border-bottom: $s-2 solid var(--layer-row-foreground-color-hover);
}
&.dnd-over-top {
border-top: $s-2 solid var(--layer-row-foreground-color-hover);
}
&.dnd-over {
border: $s-2 solid var(--layer-row-foreground-color-hover);
}
}
.set-item-set,
.set-item-group {
@include bodySmallTypography;
display: flex;
align-items: center;
min-height: $s-32;
width: 100%;
cursor: pointer;
color: var(--layer-row-foreground-color);
}
.set-name {
@include textEllipsis;
flex-grow: 1;
padding-left: $s-2;
}
.icon {
display: flex;
align-items: center;
width: $s-20;
height: $s-20;
padding-right: $s-4;
}
.checkbox-style {
display: flex;
justify-content: center;
align-items: center;
width: $s-16;
height: $s-16;
margin-inline: $s-6;
background-color: var(--input-checkbox-background-color-rest);
border: 1px solid var(--input-checkbox-border-color-rest);
border-radius: 0.25rem;
padding: 0;
}
.checkbox-checked-style {
background-color: var(--input-border-color-active);
}
.check-icon {
color: var(--color-background-secondary);
}
.set-item-set:hover {
background-color: var(--layer-row-background-color-hover);
color: var(--layer-row-foreground-color-hover);
box-shadow: -100px 0 0 0 var(--layer-row-background-color-hover);
}
.empty-state-message-sets {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: $s-12;
color: var(--color-foreground-secondary);
}
.selected-set {
background-color: var(--layer-row-background-color-selected);
color: var(--layer-row-foreground-color-selected);
box-shadow: -100px 0 0 0 var(--layer-row-background-color-selected);
}
.collapsabled-icon {
@include buttonStyle;
@include flexCenter;
height: $s-24;
border-radius: $br-8;
&:hover {
color: var(--title-foreground-color-hover);
}
}
.editing-node {
@include textEllipsis;
color: var(--layer-row-foreground-color-focus);
}
.editing-node {
@include textEllipsis;
@include bodySmallTypography;
@include removeInputStyle;
flex-grow: 1;
height: $s-28;
padding-left: $s-6;
margin: 0;
border-radius: $br-8;
border: $s-1 solid var(--input-border-color-focus);
color: var(--layer-row-foreground-color);
}

View file

@ -0,0 +1,47 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.sets-context
(:require
[rumext.v2 :as mf]))
(def initial {:editing-id nil
:new? false})
(def context (mf/create-context initial))
(def static-context
{:editing? (constantly false)
:new? false
:on-edit (constantly nil)
:on-create (constantly nil)
:on-reset (constantly nil)})
(mf/defc provider
{::mf/wrap-props false}
[props]
(let [children (unchecked-get props "children")
state (mf/use-state initial)]
[:& (mf/provider context) {:value state}
children]))
(defn use-context []
(let [ctx (mf/use-ctx context)
{:keys [editing-id new?]} @ctx
editing? (mf/use-callback
(mf/deps editing-id)
#(= editing-id %))
on-edit (mf/use-fn
#(swap! ctx assoc :editing-id %))
on-create (mf/use-fn
#(swap! ctx assoc :editing-id (random-uuid) :new? true))
on-reset (mf/use-fn
#(reset! ctx initial))]
{:editing? editing?
:new? new?
:on-edit on-edit
:on-create on-create
:on-reset on-reset}))

View file

@ -0,0 +1,65 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.sets-context-menu
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.tokens :as wdt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.workspace.tokens.sets-context :as sets-context]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def sets-menu-ref
(l/derived :token-set-context-menu refs/workspace-local))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc menu-entry
{::mf/props :obj}
[{:keys [title value on-click]}]
[:li
{:class (stl/css :context-menu-item)
:data-value value
:on-click on-click}
[:span {:class (stl/css :title)} title]])
(mf/defc menu
[{:keys [token-set-name]}]
(let [{:keys [on-edit]} (sets-context/use-context)
edit-name (mf/use-fn #(on-edit token-set-name))
delete-set (mf/use-fn #(st/emit! (wdt/delete-token-set token-set-name)))]
[:ul {:class (stl/css :context-list)}
[:& menu-entry {:title (tr "labels.rename") :on-click edit-name}]
[:& menu-entry {:title (tr "labels.delete") :on-click delete-set}]]))
(mf/defc sets-context-menu
[]
(let [mdata (mf/deref sets-menu-ref)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
width (mf/use-state 0)
dropdown-ref (mf/use-ref)
token-set-name (:token-set-name mdata)]
(mf/use-effect
(mf/deps mdata)
(fn []
(when-let [node (mf/ref-val dropdown-ref)]
(reset! width (.-offsetWidth node)))))
[:& dropdown {:show (boolean mdata)
:on-close #(st/emit! wdt/hide-token-set-context-menu)}
[:div {:class (stl/css :token-set-context-menu)
:ref dropdown-ref
:style {:top top :left left}
:on-context-menu prevent-default}
[:& menu {:token-set-name token-set-name}]]]))

View file

@ -0,0 +1,46 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.token-set-context-menu {
position: absolute;
z-index: $z-index-4;
}
.context-list {
@include menuShadow;
display: grid;
width: $s-240;
padding: $s-4;
border-radius: $br-8;
border: $s-2 solid var(--panel-border-color);
background-color: var(--menu-background-color);
max-height: 100vh;
overflow-y: auto;
li {
@include bodySmallTypography;
color: var(--menu-foreground-color);
}
}
.context-menu-item {
display: flex;
align-items: center;
height: $s-28;
width: 100%;
padding: $s-6;
border-radius: $br-8;
cursor: pointer;
&:hover {
background-color: var(--menu-background-color-hover);
.title {
color: var(--menu-foreground-color-hover);
}
}
}

View file

@ -0,0 +1,360 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.sidebar
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.tokens :as dt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.color-bullet :refer [color-bullet]]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu dropdown-menu-item*]]
[app.main.ui.components.title-bar :refer [title-bar]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.hooks :as h]
[app.main.ui.hooks.resize :refer [use-resize-hook]]
[app.main.ui.icons :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.main.ui.workspace.tokens.changes :as wtch]
[app.main.ui.workspace.tokens.context-menu :refer [token-context-menu]]
[app.main.ui.workspace.tokens.errors :as wte]
[app.main.ui.workspace.tokens.sets :refer [sets-list]]
[app.main.ui.workspace.tokens.sets-context :as sets-context]
[app.main.ui.workspace.tokens.sets-context-menu :refer [sets-context-menu]]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.theme-select :refer [theme-select]]
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]
[shadow.resource]))
(def lens:token-type-open-status
(l/derived (l/in [:workspace-tokens :open-status]) st/state))
(def ^:private download-icon
(i/icon-xref :download (stl/css :download-icon)))
;; Components ------------------------------------------------------------------
(mf/defc token-pill
{::mf/wrap-props false}
[{:keys [on-click token theme-token highlighted? on-context-menu]}]
(let [{:keys [name value resolved-value errors]} token
errors? (and (seq errors) (seq (:errors theme-token)))]
[:button
{:class (stl/css-case :token-pill true
:token-pill-highlighted highlighted?
:token-pill-invalid errors?)
:title (cond
errors? (sd/humanize-errors token)
:else (->> [(str "Token: " name)
(str (tr "workspace.token.original-value") value)
(str (tr "workspace.token.resolved-value") resolved-value)]
(str/join "\n")))
:on-click on-click
:on-context-menu on-context-menu
:disabled errors?}
(when-let [color (if (seq (ctob/find-token-value-references (:value token)))
(wtt/resolved-value-hex theme-token)
(wtt/resolved-value-hex token))]
[:& color-bullet {:color color
:mini? true}])
name]))
(mf/defc token-section-icon
{::mf/wrap-props false}
[{:keys [type]}]
(case type
:border-radius i/corner-radius
:numeric [:span {:class (stl/css :section-text-icon)} "123"]
:color i/drop-icon
:boolean i/boolean-difference
:opacity [:span {:class (stl/css :section-text-icon)} "%"]
:rotation i/rotation
:spacing i/padding-extended
:string i/text-mixed
:stroke-width i/stroke-size
:typography i/text
;; TODO: Add diagonal icon here when it's available
:dimensions [:div {:style {:rotate "45deg"}} i/constraint-horizontal]
:sizing [:div {:style {:rotate "45deg"}} i/constraint-horizontal]
i/add))
(mf/defc token-component
[{:keys [type tokens selected-shapes token-type-props active-theme-tokens]}]
(let [open? (mf/deref (-> (l/key type)
(l/derived lens:token-type-open-status)))
{:keys [modal attributes all-attributes title]} token-type-props
on-context-menu (mf/use-fn
(fn [event token]
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dt/show-token-context-menu {:type :token
:position (dom/get-client-position event)
:token-name (:name token)}))))
on-toggle-open-click (mf/use-fn
(mf/deps open? tokens)
#(st/emit! (dt/set-token-type-section-open type (not open?))))
on-popover-open-click (mf/use-fn
(fn [event]
(mf/deps type title)
(let [{:keys [key fields]} modal]
(dom/stop-propagation event)
(st/emit! (dt/set-token-type-section-open type true))
(modal/show! key {:x (.-clientX ^js event)
:y (.-clientY ^js event)
:position :right
:fields fields
:title title
:action "create"
:token-type type}))))
on-token-pill-click (mf/use-fn
(mf/deps selected-shapes token-type-props)
(fn [event token]
(dom/stop-propagation event)
(when (seq selected-shapes)
(st/emit!
(wtch/toggle-token {:token token
:shapes selected-shapes
:token-type-props token-type-props})))))
tokens-count (count tokens)]
[:div {:on-click on-toggle-open-click}
[:& cmm/asset-section {:icon (mf/fnc icon-wrapper []
[:div {:class (stl/css :section-icon)}
[:& token-section-icon {:type type}]])
:title title
:assets-count tokens-count
:open? open?}
[:& cmm/asset-section-block {:role :title-button}
[:button {:class (stl/css :action-button)
:on-click on-popover-open-click}
i/add]]
(when open?
[:& cmm/asset-section-block {:role :content}
[:div {:class (stl/css :token-pills-wrapper)}
(for [token (sort-by :name tokens)]
(let [theme-token (get active-theme-tokens (wtt/token-identifier token))]
[:& token-pill
{:key (:name token)
:token token
:theme-token theme-token
:highlighted? (wtt/shapes-token-applied? token selected-shapes (or all-attributes attributes))
:on-click #(on-token-pill-click % token)
:on-context-menu #(on-context-menu % token)}]))]])]]))
(defn sorted-token-groups
"Separate token-types into groups of `:empty` or `:filled` depending if tokens exist for that type.
Sort each group alphabetically (by their `:token-key`)."
[tokens]
(let [tokens-by-type (ctob/group-by-type tokens)
{:keys [empty filled]} (->> wtty/token-types
(map (fn [[token-key token-type-props]]
{:token-key token-key
:token-type-props token-type-props
:tokens (get tokens-by-type token-key [])}))
(group-by (fn [{:keys [tokens]}]
(if (empty? tokens) :empty :filled))))]
{:empty (sort-by :token-key empty)
:filled (sort-by :token-key filled)}))
(mf/defc themes-header
[_props]
(let [ordered-themes (mf/deref refs/workspace-token-themes-no-hidden)
open-modal
(mf/use-fn
(fn [e]
(dom/stop-propagation e)
(modal/show! :tokens/themes {})))]
[:div {:class (stl/css :themes-wrapper)}
[:span {:class (stl/css :themes-header)} (tr "labels.themes")]
(if (empty? ordered-themes)
[:div {:class (stl/css :empty-theme-wrapper)}
[:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message)}
(tr "workspace.token.no-themes")]
[:button {:on-click open-modal
:class (stl/css :create-theme-button)}
(tr "workspace.token.create-one")]]
[:div {:class (stl/css :theme-select-wrapper)}
[:& theme-select]
[:> button* {:variant "secondary"
:on-click open-modal}
(tr "labels.edit")]])]))
(mf/defc add-set-button
[{:keys [on-open style]}]
(let [{:keys [on-create]} (sets-context/use-context)
on-click #(do
(on-open)
(on-create))]
(if (= style "inline")
[:button {:on-click on-click
:class (stl/css :create-theme-button)}
(tr "workspace.token.create-one")]
[:> icon-button* {:variant "ghost"
:icon "add"
:on-click on-click
:aria-label (tr "workspace.token.add set")}])))
(mf/defc themes-sets-tab
[]
(let [token-sets (mf/deref refs/workspace-ordered-token-sets)
open? (mf/use-state true)
on-open (mf/use-fn #(reset! open? true))]
[:& sets-context/provider {}
[:& sets-context-menu]
[:div {:class (stl/css :sets-sidebar)}
[:& themes-header]
[:div {:class (stl/css :sidebar-header)}
[:& title-bar {:collapsable true
:collapsed (not @open?)
:all-clickable true
:title (tr "labels.sets")
:on-collapsed #(swap! open? not)}
[:& add-set-button {:on-open on-open
:style "header"}]]]
(when @open?
[:& h/sortable-container {}
[:*
(when (empty? token-sets)
[:div {:class (stl/css :empty-sets-wrapper)}
[:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message)}
(tr "workspace.token.no-sets-yet")]
[:& add-set-button {:on-open on-open
:style "inline"}]])
[:& sets-list]]])]]))
(mf/defc tokens-tab
[_props]
(let [objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
selected-shapes (into [] (keep (d/getf objects)) selected)
active-theme-tokens (sd/use-active-theme-sets-tokens)
tokens (sd/use-resolved-workspace-tokens)
token-groups (mf/with-memo [tokens]
(sorted-token-groups tokens))]
[:*
[:& token-context-menu]
[:& title-bar {:all-clickable true
:title "TOKENS"}]
[:div.assets-bar
(for [{:keys [token-key token-type-props tokens]} (concat (:filled token-groups)
(:empty token-groups))]
[:& token-component {:key token-key
:type token-key
:selected-shapes selected-shapes
:active-theme-tokens active-theme-tokens
:tokens tokens
:token-type-props token-type-props}])]]))
(mf/defc json-import-button []
(let []
[:div
[:button {:class (stl/css :download-json-button)
:on-click #(.click (js/document.getElementById "file-input"))}
download-icon
"Import JSON"]]))
(mf/defc import-export-button
{::mf/wrap-props false}
[{:keys []}]
(let [show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
open-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* true)))
close-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* false)))
input-ref (mf/use-ref)
on-import
(fn [event]
(let [file (-> event .-target .-files (aget 0))]
(->> (wapi/read-file-as-text file)
(sd/process-json-stream)
(rx/subs! (fn [lib]
(st/emit! (dt/import-tokens-lib lib)))
(fn [err]
(js/console.error err)
(st/emit! (ntf/show {:content (wte/humanize-errors [(ex-data err)])
:type :toast
:level :warning
:timeout 9000})))))
(set! (.-value (mf/ref-val input-ref)) "")))
on-export (fn []
(let [tokens-blob (some-> (deref refs/tokens-lib)
(ctob/encode-dtcg)
(clj->js)
(js/JSON.stringify nil 2)
(wapi/create-blob "application/json"))]
(dom/trigger-download "tokens.json" tokens-blob)))]
[:div {:class (stl/css :import-export-button-wrapper)}
[:input {:type "file"
:ref input-ref
:style {:display "none"}
:id "file-input"
:accept ".json"
:on-change on-import}]
[:button {:class (stl/css :import-export-button)
:on-click open-menu}
download-icon
"Tokens"]
[:& dropdown-menu {:show show-menu?
:on-close close-menu
:list-class (stl/css :import-export-menu)}
[:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
:on-click #(.click (mf/ref-val input-ref))}
"Import"]
[:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
:on-click on-export}
"Export"]]]))
(mf/defc tokens-sidebar-tab
{::mf/wrap [mf/memo]
::mf/wrap-props false}
[_props]
(let [{on-pointer-down-pages :on-pointer-down
on-lost-pointer-capture-pages :on-lost-pointer-capture
on-pointer-move-pages :on-pointer-move
size-pages-opened :size}
(use-resize-hook :tokens 200 38 400 :y false nil)]
[:div {:class (stl/css :sidebar-wrapper)}
[:article {:class (stl/css :sets-section-wrapper)
:style {"--resize-height" (str size-pages-opened "px")}}
[:& themes-sets-tab]]
[:article {:class (stl/css :tokens-section-wrapper)}
[:div {:class (stl/css :resize-area-horiz)
:on-pointer-down on-pointer-down-pages
:on-lost-pointer-capture on-lost-pointer-capture-pages
:on-pointer-move on-pointer-move-pages}]
[:& tokens-tab]
[:& import-export-button]]]))

View file

@ -0,0 +1,196 @@
// 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) KALEIDOS INC
@use "../../ds/typography.scss" as *;
@import "refactor/common-refactor.scss";
@import "./common.scss";
.sidebar-wrapper {
display: grid;
grid-template-rows: auto auto 1fr;
// Overflow on the bottom section can't be done without hardcoded values for the height
// This has to be changed from the wrapping sidebar styles
height: calc(100vh - #{$s-84});
overflow: hidden;
}
.sets-section-wrapper {
position: relative;
display: flex;
flex: 1;
height: var(--resize-height);
flex-direction: column;
overflow-y: auto;
scrollbar-gutter: stable;
}
.tokens-section-wrapper {
height: 100%;
padding-left: $s-12;
overflow-y: auto;
scrollbar-gutter: stable;
}
.sets-sidebar {
position: relative;
}
.themes-header {
display: block;
@include headlineSmallTypography;
margin-bottom: $s-8;
padding-left: $s-8;
color: var(--title-foreground-color);
}
.themes-wrapper {
padding: $s-12 0 0 $s-12;
}
.empty-theme-wrapper {
padding: $s-12;
color: var(--color-foreground-secondary);
}
.empty-sets-wrapper {
padding: $s-12;
padding-inline-start: $s-24;
color: var(--color-foreground-secondary);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-left: $s-8;
padding-top: $s-12;
color: var(--layer-row-foreground-color);
}
.empty-state-message {
color: var(--color-foreground-secondary);
}
.token-pills-wrapper {
display: flex;
gap: $s-4;
flex-wrap: wrap;
}
.token-pill {
@extend .button-secondary;
gap: $s-8;
padding: $s-4 $s-8;
border-radius: $br-6;
font-size: $fs-14;
&.token-pill-highlighted {
color: var(--button-primary-foreground-color-rest);
background: var(--button-primary-background-color-rest);
}
&.token-pill-invalid {
background-color: var(--button-secondary-background-color-rest);
color: var(--status-color-error-500);
opacity: 0.8;
}
}
.section-text-icon {
font-size: $fs-12;
width: 16px;
height: 16px;
display: flex;
place-content: center;
}
.section-icon {
margin-right: $s-4;
// Align better with the label
translate: 0px -1px;
}
.import-export-button-wrapper {
position: absolute;
bottom: $s-12;
right: $s-12;
}
.import-export-button {
@extend .button-secondary;
display: flex;
align-items: center;
padding: $s-6 $s-8;
text-transform: uppercase;
gap: $s-8;
.download-icon {
@extend .button-icon;
stroke: var(--icon-foreground);
width: 20px;
height: 20px;
}
}
.import-export-menu {
@extend .menu-dropdown;
top: -#{$s-6};
right: 0;
translate: 0 -100%;
width: $s-192;
margin: 0;
}
.import-export-menu-item {
@extend .menu-item-base;
cursor: pointer;
.open-arrow {
@include flexCenter;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
&:hover {
color: var(--menu-foreground-color-hover);
.open-arrow {
svg {
stroke: var(--menu-foreground-color-hover);
}
}
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
.theme-select-wrapper {
display: grid;
grid-template-columns: 1fr 0.28fr;
gap: $s-6;
}
.themes-button {
@extend .button-secondary;
width: auto;
}
.create-theme-button {
@include use-typography("body-small");
background-color: transparent;
border: none;
appearance: none;
color: var(--color-accent-primary);
cursor: pointer;
}
.resize-area-horiz {
position: absolute;
left: 0;
width: 100%;
border-bottom: $s-2 solid var(--resize-area-border-color);
cursor: ns-resize;
}

View file

@ -0,0 +1,261 @@
(ns app.main.ui.workspace.tokens.style-dictionary
(:require
["@tokens-studio/sd-transforms" :as sd-transforms]
["style-dictionary$default" :as sd]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.tokens-lib :as ctob]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.errors :as wte]
[app.main.ui.workspace.tokens.tinycolor :as tinycolor]
[app.main.ui.workspace.tokens.token :as wtt]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
(l/set-level! "app.main.ui.workspace.tokens.style-dictionary" :warn)
;; === Style Dictionary
(def setup-style-dictionary
"Initiates the StyleDictionary instance.
Setup transforms from tokens-studio used to parse and resolved token values."
(do
(sd-transforms/registerTransforms sd)
(.registerFormat sd #js {:name "custom/json"
:format (fn [^js res]
(.-tokens (.-dictionary res)))})
sd))
(def default-config
{:platforms {:json
{:transformGroup "tokens-studio"
;; Required: The StyleDictionary API is focused on files even when working in the browser
:files [{:format "custom/json" :destination "penpot"}]}}
:preprocessors ["tokens-studio"]
;; Silences style dictionary logs and errors
;; We handle token errors in the UI
:log {:verbosity "silent"
:warnings "silent"
:errors {:brokenReferences "console"}}})
(defn parse-sd-token-color-value
"Parses `value` of a color `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the value is not parseable and/or has missing references returns a map with `:errors`."
[value]
(if-let [tc (tinycolor/valid-color value)]
{:value value :unit (tinycolor/color-format tc)}
{:errors [(wte/error-with-value :error.token/invalid-color value)]}))
(defn parse-sd-token-dimensions-value
"Parses `value` of a dimensions `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(or
(wtt/parse-token-value value)
(if-let [references (seq (ctob/find-token-value-references value))]
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
:references references}
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]})))
(defn process-sd-tokens
"Converts a StyleDictionary dictionary with resolved tokens (aka `sd-tokens`) back to clojure.
The `get-origin-token` argument should be a function that takes an
`sd-token` and returns the original penpot token, so we can merge
the resolved attributes back in.
The `sd-token` will have references in `value` replaced with the computed value as a string.
Here's an example for a `sd-token`:
```js
{
name: 'token.with.reference',
value: '12px',
type: 'border-radius',
path: ['token', 'with', 'reference'],
// The penpot origin token converted to a js object
original: {
name: 'token.with.reference',
value: '{referenced.token}',
type: 'border-radius'
},
}
```
We also convert `sd-token` value string into a unit that can be used as penpot shape attributes.
- Dimensions like '12px' will be converted into numbers
- Colors will be validated & converted to hex
Lastly we check for errors in each token
`sd-token` will keep the missing references in the `value` (E.g \"{missing} + {existing}\" -> \"{missing} + 12px\")
So we parse out the missing references and add them to `:errors` in the final token."
[sd-tokens get-origin-token]
(reduce
(fn [acc ^js sd-token]
(let [origin-token (get-origin-token sd-token)
value (.-value sd-token)
parsed-token-value (case (:type origin-token)
:color (parse-sd-token-color-value value)
(parse-sd-token-dimensions-value value))
output-token (if (:errors parsed-token-value)
(merge origin-token parsed-token-value)
(assoc origin-token
:resolved-value (:value parsed-token-value)
:unit (:unit parsed-token-value)))]
(assoc acc (:name output-token) output-token)))
{} sd-tokens))
(defprotocol IStyleDictionary
(add-tokens [_ tokens])
(enable-debug [_])
(get-config [_])
(build-dictionary [_]))
(deftype StyleDictionary [config]
IStyleDictionary
(add-tokens [_ tokens]
(StyleDictionary. (assoc config :tokens tokens)))
(enable-debug [_]
(StyleDictionary. (update config :log merge {:verbosity "verbose"})))
(get-config [_]
config)
(build-dictionary [_]
(-> (sd. (clj->js config))
(.buildAllPlatforms "json")
(p/then #(.-allTokens ^js %)))))
(defn resolve-tokens-tree+
([tokens-tree get-token]
(resolve-tokens-tree+ tokens-tree get-token (StyleDictionary. default-config)))
([tokens-tree get-token style-dictionary]
(-> style-dictionary
(add-tokens tokens-tree)
(build-dictionary)
(p/then #(process-sd-tokens % get-token)))))
(defn sd-token-name [^js sd-token]
(.. sd-token -original -name))
(defn sd-token-uuid [^js sd-token]
(uuid (.-uuid (.-id ^js sd-token))))
(defn resolve-tokens+ [tokens]
(resolve-tokens-tree+ (ctob/tokens-tree tokens) #(get tokens (sd-token-name %))))
(defn resolve-tokens-interactive+
"Interactive check of resolving tokens.
Uses a ids map to backtrace the original token from the resolved StyleDictionary token.
We have to pass in all tokens from all sets in the entire library to style dictionary
so we know if references are missing / to resolve them and possibly show interactive previews (in the tokens form) to the user.
Since we're using the :name path as the identifier we might be throwing away or overriding tokens in the tree that we pass to StyleDictionary.
So to get back the original token from the resolved sd-token (see my updates for what an sd-token is) we include a temporary :id for the token that we pass to StyleDictionary,
this way after the resolving computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map."
[tokens]
(let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)]
(resolve-tokens-tree+ tokens-tree #(get ids (sd-token-uuid %)))))
(defn resolve-tokens-with-errors+ [tokens]
(resolve-tokens-tree+
(ctob/tokens-tree tokens)
#(get tokens (sd-token-name %))
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
;; === Import
(defn reference-errors
"Extracts reference errors from StyleDictionary."
[err]
(let [[header-1 header-2 & errors] (str/split err "\n")]
(when (and
(= header-1 "Error: ")
(= header-2 "Reference Errors:"))
errors)))
(defn process-json-stream [data-stream]
(->> data-stream
(rx/map (fn [data]
(try
(-> (str/replace data "/" "-") ;; TODO Remove when token groups work
(t/decode-str))
(catch js/Error e
(throw (wte/error-ex-info :error.import/json-parse-error data e))))))
(rx/map (fn [json-data]
(try
(ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) json-data)
(catch js/Error e
(throw (wte/error-ex-info :error.import/invalid-json-data json-data e))))))
(rx/mapcat (fn [tokens-lib]
(try
(-> (ctob/get-all-tokens tokens-lib)
(resolve-tokens-with-errors+)
(p/then (fn [_] tokens-lib))
(p/catch (fn [sd-error]
(let [reference-errors (reference-errors sd-error)
err (if reference-errors
(wte/error-ex-info :error.import/style-dictionary-reference-errors reference-errors sd-error)
(wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error))]
(throw err)))))
(catch js/Error e
(p/rejected (wte/error-ex-info :error.import/style-dictionary-unknown-error "" e))))))))
;; === Errors
(defn humanize-errors [{:keys [errors value] :as _token}]
(->> (map (fn [err]
(case err
:error.style-dictionary/missing-reference (str "Could not resolve reference token with the name: " value)
nil))
errors)
(str/join "\n")))
;; === Hooks
(defonce !tokens-cache (atom nil))
(defonce !theme-tokens-cache (atom nil))
(defn use-resolved-tokens
"The StyleDictionary process function is async, so we can't use resolved values directly.
This hook will return the unresolved tokens as state until they are processed,
then the state will be updated with the resolved tokens."
[tokens & {:keys [cache-atom interactive?]
:or {cache-atom !tokens-cache}
:as config}]
(let [tokens-state (mf/use-state (get @cache-atom tokens))]
(mf/use-effect
(mf/deps tokens config)
(fn []
(let [cached (get @cache-atom tokens)]
(cond
(nil? tokens) nil
;; The tokens are already processing somewhere
(p/promise? cached) (-> cached
(p/then #(reset! tokens-state %))
#_(p/catch js/console.error))
;; Get the cached entry
(some? cached) (reset! tokens-state cached)
;; No cached entry, start processing
:else (let [promise+ (if interactive?
(resolve-tokens-interactive+ tokens)
(resolve-tokens+ tokens))]
(swap! cache-atom assoc tokens promise+)
(p/then promise+ (fn [resolved-tokens]
(swap! cache-atom assoc tokens resolved-tokens)
(reset! tokens-state resolved-tokens))))))))
@tokens-state))
(defn use-resolved-workspace-tokens []
(-> (mf/deref refs/workspace-selected-token-set-tokens)
(use-resolved-tokens)))
(defn use-active-theme-sets-tokens []
(-> (mf/deref refs/workspace-active-theme-sets-tokens)
(use-resolved-tokens {:cache-atom !theme-tokens-cache})))

View file

@ -0,0 +1,117 @@
;; 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) KALEIDOS INC
(ns app.main.ui.workspace.tokens.theme-select
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]
[app.main.data.modal :as modal]
[app.main.data.tokens :as wdt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(mf/defc themes-list
[{:keys [themes active-theme-paths on-close grouped?]}]
(when (seq themes)
[:ul {:class (stl/css :theme-options)}
(for [[_ {:keys [group name] :as theme}] themes
:let [theme-id (ctob/theme-path theme)
selected? (get active-theme-paths theme-id)
select-theme (fn [e]
(dom/stop-propagation e)
(st/emit! (wdt/toggle-token-theme-active? group name))
(on-close))]]
[:li {:key theme-id
:role "option"
:aria-selected selected?
:class (stl/css-case
:checked-element true
:sub-item grouped?
:is-selected selected?)
:on-click select-theme}
[:> text* {:as "span" :typography "body-small" :class (stl/css :label)} name]
[:> icon* {:id i/tick
:aria-hidden true
:class (stl/css-case :check-icon true
:check-icon-visible selected?)}]])]))
(mf/defc theme-options
[{:keys [active-theme-paths themes on-close]}]
(let []
(let [on-edit-click #(modal/show! :tokens/themes {})]
[:ul {:class (stl/css :theme-options :custom-select-dropdown)
:role "listbox"}
(for [[group themes] themes]
[:li {:key group
:aria-labelledby (dm/str group "-label")
:role "group"}
(when (seq group)
[:> text* {:as "span" :typography "headline-small" :class (stl/css :group) :id (dm/str group "-label")} group])
[:& themes-list {:themes themes
:active-theme-paths active-theme-paths
:on-close on-close
:grouped? true}]])
[:li {:class (stl/css :separator)
:aria-hidden true}]
[:li {:class (stl/css-case :checked-element true
:checked-element-button true)
:role "option"
:on-click on-edit-click}
[:> text* {:as "span" :typography "body-small"} (tr "workspace.token.edit-themes")]
[:> icon* {:id i/arrow-right :aria-hidden true}]]])))
(mf/defc theme-select
[{:keys []}]
(let [;; Store
active-theme-paths (mf/deref refs/workspace-active-theme-paths-no-hidden)
active-themes-count (count active-theme-paths)
themes (mf/deref refs/workspace-token-theme-tree-no-hidden)
;; Data
current-label (cond
(> active-themes-count 1) (tr "workspace.token.active-themes" active-themes-count)
(= active-themes-count 1) (some->> (first active-theme-paths)
(ctob/split-token-theme-path)
(str/join " / "))
:else (tr "workspace.token.no-active-theme"))
;; State
state* (mf/use-state
{:id (uuid/next)
:is-open? false})
state (deref state*)
is-open? (:is-open? state)
;; Dropdown
dropdown-element* (mf/use-ref nil)
on-close-dropdown (mf/use-fn #(swap! state* assoc :is-open? false))
on-open-dropdown (mf/use-fn #(swap! state* assoc :is-open? true))]
;; TODO: This element should be accessible by keyboard
[:div {:on-click on-open-dropdown
:aria-expanded is-open?
:aria-haspopup "listbox"
:tab-index "0"
:role "combobox"
:class (stl/css :custom-select)}
[:> text* {:as "span" :typography "body-small" :class (stl/css :current-label)}
current-label]
[:> icon* {:id i/arrow-down :class (stl/css :dropdown-button) :aria-hidden true}]
[:& dropdown {:show is-open?
:on-close on-close-dropdown
:ref dropdown-element*}
[:& theme-options {:active-theme-paths active-theme-paths
:themes themes
:on-close on-close-dropdown}]]]))

View file

@ -0,0 +1,124 @@
// 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) KALEIDOS INC
@import "refactor/common-refactor.scss";
.custom-select {
--custom-select-border-color: var(--menu-background-color);
--custom-select-bg-color: var(--menu-background-color);
--custom-select-icon-color: var(--color-foreground-secondary);
--custom-select-text-color: var(--menu-foreground-color);
@extend .new-scrollbar;
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: $s-32;
width: 100%;
margin: 0;
padding: $s-8;
border-radius: $br-8;
background-color: var(--custom-select-bg-color);
border: $s-1 solid var(--custom-select-border-color);
color: var(--custom-select-text-color);
cursor: pointer;
&:hover {
--custom-select-bg-color: var(--menu-background-color-hover);
--custom-select-border-color: var(--menu-background-color);
--custom-select-icon-color: var(--menu-foreground-color-hover);
}
&:focus {
--custom-select-bg-color: var(--menu-background-color-focus);
--custom-select-border-color: var(--menu-background-focus);
}
}
.theme-options {
margin-bottom: 0;
}
.group {
display: block;
padding: $s-8;
color: var(--color-foreground-secondary);
}
.disabled {
--custom-select-bg-color: var(--menu-background-color-disabled);
--custom-select-border-color: var(--menu-border-color-disabled);
--custom-select-icon-color: var(--menu-foreground-color-disabled);
--custom-select-text-color: var(--menu-foreground-color-disabled);
pointer-events: none;
cursor: default;
}
.dropdown-button {
@include flexCenter;
color: var(--color-foreground-secondary);
}
.current-icon {
@include flexCenter;
width: $s-24;
padding-right: $s-4;
}
.custom-select-dropdown {
@extend .dropdown-wrapper;
}
.separator {
margin: 0;
height: $s-2;
border-block-start: $s-1 solid color-mix(in hsl, var(--color-foreground-secondary) 20%, transparent);
}
.custom-select-dropdown[data-direction="up"] {
bottom: $s-32;
top: auto;
}
.sub-item {
padding-left: $s-16;
}
.checked-element-button {
@extend .dropdown-element-base;
position: relative;
display: flex;
justify-content: space-between;
padding-right: 0;
}
.checked-element {
@extend .dropdown-element-base;
&.is-selected {
color: var(--menu-foreground-color);
}
&.disabled {
display: none;
}
}
.check-icon {
@include flexCenter;
color: var(--icon-foreground-primary);
visibility: hidden;
}
.label {
flex-grow: 1;
width: 100%;
}
.check-icon-visible {
visibility: visible;
}
.current-label {
@include textEllipsis;
}

View file

@ -0,0 +1,27 @@
(ns app.main.ui.workspace.tokens.tinycolor
"Bindings for tinycolor2 which supports a wide range of css compatible colors.
This library was chosen as it is already used by StyleDictionary,
so there is no extra dependency cost and there was no clojure alternatives with all the necessary features."
(:require
["tinycolor2" :as tinycolor]))
(defn tinycolor? [^js x]
(and (instance? tinycolor x) (.isValid x)))
(defn valid-color [color-str]
(let [tc (tinycolor color-str)]
(when (.isValid tc) tc)))
(defn ->hex [^js tc]
(assert (tinycolor? tc))
(.toHex tc))
(defn color-format [^js tc]
(assert (tinycolor? tc))
(.getFormat tc))
(comment
(some-> (valid-color "red") ->hex)
(some-> (valid-color "red") color-format)
nil)

View file

@ -0,0 +1,142 @@
(ns app.main.ui.workspace.tokens.token
(:require
[app.common.data :as d]
[app.main.ui.workspace.tokens.tinycolor :as tinycolor]
[clojure.set :as set]
[cuerdas.core :as str]))
(def parseable-token-value-regexp
"Regexp that can be used to parse a number value out of resolved token value.
This regexp also trims whitespace around the value."
#"^\s*(-?[0-9]+\.?[0-9]*)(px|%)?\s*$")
(defn parse-token-value
"Parses a resolved value and separates the unit from the value.
Returns a map of {:value `number` :unit `string`}."
[value]
(cond
(number? value) {:value value}
(string? value) (when-let [[_ value unit] (re-find parseable-token-value-regexp value)]
(when-let [parsed-value (d/parse-double value)]
{:value parsed-value
:unit unit}))))
(defn token-identifier [{:keys [name] :as _token}]
name)
(defn attributes-map
"Creats an attributes map using collection of `attributes` for `id`."
[attributes token]
(->> (map (fn [attr] [attr (token-identifier token)]) attributes)
(into {})))
(defn remove-attributes-for-token
"Removes applied tokens with `token-id` for the given `attributes` set from `applied-tokens`."
[attributes token applied-tokens]
(let [attr? (set attributes)]
(->> (remove (fn [[k v]]
(and (attr? k)
(= v (token-identifier token))))
applied-tokens)
(into {}))))
(defn token-attribute-applied?
"Test if `token` is applied to a `shape` on single `token-attribute`."
[token shape token-attribute]
(when-let [id (get-in shape [:applied-tokens token-attribute])]
(= (token-identifier token) id)))
(defn token-applied?
"Test if `token` is applied to a `shape` with at least one of the one of the given `token-attributes`."
[token shape token-attributes]
(some #(token-attribute-applied? token shape %) token-attributes))
(defn shapes-token-applied?
"Test if `token` is applied to to any of `shapes` with at least one of the one of the given `token-attributes`."
[token shapes token-attributes]
(some #(token-applied? token % token-attributes) shapes))
(defn shapes-ids-by-applied-attributes [token shapes token-attributes]
(reduce (fn [acc shape]
(let [applied-ids-by-attribute (->> (map #(when (token-attribute-applied? token shape %)
[% #{(:id shape)}])
token-attributes)
(filter some?)
(into {}))]
(merge-with into acc applied-ids-by-attribute)))
{} shapes))
(defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
(every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.
Will concatenate multiple `.` characters into one."
[token-name]
(str/split token-name #"\.+"))
(defn token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (token-name->path token-name)
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-names-tree-id-map [tokens]
(reduce
(fn [acc [_ {:keys [name] :as token}]]
(when (string? name)
(let [temp-id (random-uuid)
token (assoc token :temp/id temp-id)]
(-> acc
(assoc-in (concat [:tree] (token-name->path name)) token)
(assoc-in [:ids-map temp-id] token)))))
{:tree {}
:ids-map {}}
tokens))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name token-names-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
token-names-tree path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
(defn color-token? [token]
(= (:type token) :color))
(defn resolved-value-hex [{:keys [resolved-value] :as token}]
(when (and resolved-value (color-token? token))
(some->> (tinycolor/valid-color resolved-value)
(tinycolor/->hex)
(str "#"))))

View file

@ -0,0 +1,56 @@
(ns app.main.ui.workspace.tokens.token-set
(:require
[app.common.types.tokens-lib :as ctob]))
(defn get-workspace-tokens-lib [state]
(get-in state [:workspace-data :tokens-lib]))
;; Themes ----------------------------------------------------------------------
(defn get-active-theme-ids [state]
(get-in state [:workspace-data :token-active-themes] #{}))
(defn get-temp-theme-id [state]
(get-in state [:workspace-data :token-theme-temporary-id]))
(defn update-theme-id
[state]
(let [active-themes (get-active-theme-ids state)
temporary-theme-id (get-temp-theme-id state)]
(cond
(empty? active-themes) temporary-theme-id
(= 1 (count active-themes)) (first active-themes)
:else temporary-theme-id)))
(defn get-workspace-token-theme [id state]
(get-in state [:workspace-data :token-themes-index id]))
(defn add-token-set-to-token-theme [token-set-id token-theme]
(update token-theme :sets conj token-set-id))
;; Sets ------------------------------------------------------------------------
(defn get-active-theme-sets-tokens-names-map [state]
(when-let [lib (get-workspace-tokens-lib state)]
(ctob/get-active-themes-set-tokens lib)))
;; === Set selection
(defn get-selected-token-set-id [state]
(or (get-in state [:workspace-local :selected-token-set-id])
(some-> (get-workspace-tokens-lib state)
(ctob/get-sets)
(first)
(:name))))
(defn get-selected-token-set [state]
(when-let [id (get-selected-token-set-id state)]
(some-> (get-workspace-tokens-lib state)
(ctob/get-set id))))
(defn get-selected-token-set-tokens [state]
(some-> (get-selected-token-set state)
:tokens))
(defn assoc-selected-token-set-id [state id]
(assoc-in state [:workspace-local :selected-token-set-id] id))

View file

@ -0,0 +1,88 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.token-types
(:require
[app.common.data :as d :refer [ordered-map]]
[app.common.types.token :as ctt]
[app.main.ui.workspace.tokens.changes :as wtch]
[clojure.set :as set]))
(def token-types
(ordered-map
:border-radius
{:title "Border Radius"
:attributes ctt/border-radius-keys
:on-update-shape wtch/update-shape-radius-all
:modal {:key :tokens/border-radius
:fields [{:label "Border Radius"
:key :border-radius}]}}
:color
{:title "Color"
:attributes ctt/color-keys
:on-update-shape wtch/update-color
:modal {:key :tokens/color
:fields [{:label "Color" :key :color}]}}
:stroke-width
{:title "Stroke Width"
:attributes ctt/stroke-width-keys
:on-update-shape wtch/update-stroke-width
:modal {:key :tokens/stroke-width
:fields [{:label "Stroke Width"
:key :stroke-width}]}}
:sizing
{:title "Sizing"
:attributes #{:width :height}
:all-attributes ctt/sizing-keys
:on-update-shape wtch/update-shape-dimensions
:modal {:key :tokens/sizing
:fields [{:label "Sizing"
:key :sizing}]}}
:dimensions
{:title "Dimensions"
:attributes #{:width :height}
:all-attributes (set/union
ctt/spacing-keys
ctt/sizing-keys
ctt/border-radius-keys
ctt/stroke-width-keys)
:on-update-shape wtch/update-shape-dimensions
:modal {:key :tokens/dimensions
:fields [{:label "Dimensions"
:key :dimensions}]}}
:opacity
{:title "Opacity"
:attributes ctt/opacity-keys
:on-update-shape wtch/update-opacity
:modal {:key :tokens/opacity
:fields [{:label "Opacity"
:key :opacity}]}}
:rotation
{:title "Rotation"
:attributes ctt/rotation-keys
:on-update-shape wtch/update-rotation
:modal {:key :tokens/rotation
:fields [{:label "Rotation"
:key :rotation}]}}
:spacing
{:title "Spacing"
:attributes #{:column-gap :row-gap}
:all-attributes ctt/spacing-keys
:on-update-shape wtch/update-layout-spacing
:modal {:key :tokens/spacing
:fields [{:label "Spacing"
:key :spacing}]}}))
(defn get-token-properties [token]
(get token-types (:type token)))
(defn token-attributes [token-type]
(get-in token-types [token-type :attributes]))

View file

@ -0,0 +1,135 @@
(ns app.main.ui.workspace.tokens.update
(:require
[app.common.types.token :as ctt]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.undo :as dwu]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.changes :as wtch]
[app.main.ui.workspace.tokens.style-dictionary :as wtsd]
[app.main.ui.workspace.tokens.token-set :as wtts]
[beicon.v2.core :as rx]
[clojure.data :as data]
[clojure.set :as set]
[potok.v2.core :as ptk]))
;; Constants -------------------------------------------------------------------
(def filter-existing-values? false)
(def attributes->shape-update
{#{:rx :ry} (fn [v ids _] (wtch/update-shape-radius-all v ids))
#{:r1 :r2 :r3 :r4} wtch/update-shape-radius-single-corner
ctt/color-keys wtch/update-color
ctt/stroke-width-keys wtch/update-stroke-width
ctt/sizing-keys wtch/update-shape-dimensions
ctt/opacity-keys wtch/update-opacity
#{:x :y} wtch/update-shape-position
#{:p1 :p2 :p3 :p4} (fn [resolved-value shape-ids attrs]
(dwsl/update-layout shape-ids {:layout-padding (zipmap attrs (repeat resolved-value))}))
#{:column-gap :row-gap} wtch/update-layout-spacing
#{:width :height} wtch/update-shape-dimensions
#{:layout-item-min-w :layout-item-min-h :layout-item-max-w :layout-item-max-h} wtch/update-layout-sizing-limits
ctt/rotation-keys wtch/update-rotation})
(def attribute-actions-map
(reduce
(fn [acc [ks action]]
(into acc (map (fn [k] [k action]) ks)))
{} attributes->shape-update))
;; Helpers ---------------------------------------------------------------------
(defn deep-merge
"Like d/deep-merge but unions set values."
([a b]
(cond
(map? a) (merge-with deep-merge a b)
(set? a) (set/union a b)
:else b))
([a b & rest]
(reduce deep-merge a (cons b rest))))
;; Data flows ------------------------------------------------------------------
(defn invert-collect-key-vals
[xs resolved-tokens shape]
(-> (reduce
(fn [acc [k v]]
(let [resolved-token (get resolved-tokens v)
resolved-value (get resolved-token :resolved-value)
skip? (or
(not (get resolved-tokens v))
(and filter-existing-values? (= (get shape k) resolved-value)))]
(if skip?
acc
(update acc resolved-value (fnil conj #{}) k))))
{} xs)))
(defn split-attribute-groups [attrs-values-map]
(reduce
(fn [acc [attrs v]]
(cond
(some attrs #{:rx :ry}) (let [[_ a b] (data/diff #{:rx :ry} attrs)]
(cond-> (assoc acc b v)
;; Exact match in attrs
a (assoc a v)))
(some attrs #{:widht :height}) (let [[_ a b] (data/diff #{:width :height} attrs)]
(cond-> (assoc acc b v)
;; Exact match in attrs
a (assoc a v)))
(some attrs ctt/spacing-keys) (let [[_ rst gap] (data/diff #{:row-gap :column-gap} attrs)
[_ position padding] (data/diff #{:p1 :p2 :p3 :p4} rst)]
(cond-> acc
(seq gap) (assoc gap v)
(seq position) (assoc position v)
(seq padding) (assoc padding v)))
attrs (assoc acc attrs v)))
{} attrs-values-map))
(defn shape-ids-by-values
[attrs-values-map object-id]
(->> (map (fn [[value attrs]] [attrs {value #{object-id}}]) attrs-values-map)
(into {})))
(defn collect-shapes-update-info [resolved-tokens shapes]
(reduce
(fn [acc [object-id {:keys [applied-tokens] :as shape}]]
(if (seq applied-tokens)
(let [applied-tokens (-> (invert-collect-key-vals applied-tokens resolved-tokens shape)
(shape-ids-by-values object-id)
(split-attribute-groups))]
(deep-merge acc applied-tokens))
acc))
{} shapes))
(defn actionize-shapes-update-info [shapes-update-info]
(mapcat (fn [[attrs update-infos]]
(let [action (some attribute-actions-map attrs)]
(map
(fn [[v shape-ids]]
(action v shape-ids attrs))
update-infos)))
shapes-update-info))
(defn update-tokens [resolved-tokens]
(->> @refs/workspace-page-objects
(collect-shapes-update-info resolved-tokens)
(actionize-shapes-update-info)))
(defn update-workspace-tokens []
(ptk/reify ::update-workspace-tokens
ptk/WatchEvent
(watch [_ state _]
(->>
(rx/from
(->
(wtts/get-active-theme-sets-tokens-names-map state)
(wtsd/resolve-tokens+)))
(rx/mapcat
(fn [sd-tokens]
(let [undo-id (js/Symbol)]
(rx/concat
(rx/of (dwu/start-undo-transaction undo-id))
(update-tokens sd-tokens)
(rx/of (dwu/commit-undo-transaction undo-id))))))))))

View file

@ -25,5 +25,7 @@
lodash-debounce))
(defn debounce
[f timeout]
(ext-debounce f timeout #{:leading false :trailing true}))
([f]
(debounce f 0))
([f timeout]
(ext-debounce f timeout #{:leading false :trailing true})))