From d788a4d25228a8fbc519c7573018ab09138848b6 Mon Sep 17 00:00:00 2001 From: "Florian Schroedl (aider)" Date: Tue, 1 Jul 2025 09:34:40 +0200 Subject: [PATCH] :sparkles: Implement new token-type `:font-families` --- common/src/app/common/types/token.cljc | 58 ++- common/src/app/common/types/tokens_lib.cljc | 25 +- .../common_tests/logic/token_apply_test.cljc | 26 +- .../src/app/main/data/style_dictionary.cljs | 1 + .../data/workspace/tokens/application.cljs | 30 ++ .../data/workspace/tokens/propagation.cljs | 1 + frontend/src/app/main/fonts.cljs | 9 + .../ui/ds/controls/utilities/input_field.cljs | 2 +- .../tokens/management/create/form.cljs | 484 ++++++++++++------ .../tokens/management/create/form.scss | 9 + .../management/create/input_tokens_value.cljs | 24 +- .../tokens/management/create/modals.cljs | 18 +- .../ui/workspace/tokens/management/group.cljs | 1 + .../tokens/management/token_pill.cljs | 4 +- .../tokens/logic/token_actions_test.cljs | 34 ++ frontend/translations/en.po | 14 +- 16 files changed, 544 insertions(+), 196 deletions(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index d3c1f27657..1f9cf0ace1 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -10,6 +10,7 @@ [app.common.schema :as sm] [clojure.data :as data] [clojure.set :as set] + [cuerdas.core :as str] [malli.util :as mu])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -29,20 +30,21 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def token-type->dtcg-token-type - {:boolean "boolean" - :border-radius "borderRadius" - :color "color" - :dimensions "dimension" - :font-size "fontSizes" + {:boolean "boolean" + :border-radius "borderRadius" + :color "color" + :dimensions "dimension" + :font-family "fontFamilies" + :font-size "fontSizes" :letter-spacing "letterSpacing" - :number "number" - :opacity "opacity" - :other "other" - :rotation "rotation" - :sizing "sizing" - :spacing "spacing" - :string "string" - :stroke-width "strokeWidth"}) + :number "number" + :opacity "opacity" + :other "other" + :rotation "rotation" + :sizing "sizing" + :spacing "spacing" + :string "string" + :stroke-width "strokeWidth"}) (def dtcg-token-type->token-type (set/map-invert token-type->dtcg-token-type)) @@ -133,7 +135,13 @@ (def letter-spacing-keys (schema-keys schema:letter-spacing)) -(def typography-keys (set/union font-size-keys letter-spacing-keys)) +(def ^:private schema:font-family + [:map + [:font-family {:optional true} token-name-ref]]) + +(def font-family-keys (schema-keys schema:font-family)) + +(def typography-keys (set/union font-size-keys letter-spacing-keys font-family-keys)) ;; TODO: Created to extract the font-size feature from the typography feature flag. ;; Delete this once the typography feature flag is removed. @@ -169,6 +177,7 @@ schema:number schema:font-size schema:letter-spacing + schema:font-family schema:dimensions]) (defn shape-attr->token-attrs @@ -198,6 +207,7 @@ (font-size-keys shape-attr) #{shape-attr} (letter-spacing-keys shape-attr) #{shape-attr} + (font-family-keys shape-attr) #{shape-attr} (border-radius-keys shape-attr) #{shape-attr} (sizing-keys shape-attr) #{shape-attr} (opacity-keys shape-attr) #{shape-attr} @@ -291,3 +301,23 @@ (defn unapply-token-id [shape attributes] (update shape :applied-tokens d/without-keys attributes)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TYPOGRAPHY +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn split-font-family + "Splits font family `value` string from into vector of font families. + + Doesn't handle possible edge-case of font-families with `,` in their font family name." + [font-value] + (let [families (str/split font-value ",") + xform (comp + (map str/trim) + (remove str/empty?))] + (into [] xform families))) + +(defn join-font-family + "Joins font family `value` into a string to be edited with a single input." + [font-families] + (str/join ", " font-families)) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index ecfd92b5da..398bf4a881 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1333,13 +1333,25 @@ Will return a value that matches this schema: (walk/postwalk (fn [node] (cond-> node + ;; Handle sequential values that are objects with type (and (map? node) (contains? node "value") - (sequential? (get node "value"))) + (sequential? (get node "value")) + (map? (first (get node "value")))) (update "value" (fn [seq-value] (map #(set/rename-keys % {"type" "$type"}) seq-value))) + ;; Keep array of font families + (and (map? node) + (contains? node "type") + (= "fontFamilies" (get node "type")) + (contains? node "value") + (sequential? (get node "value")) + (not (map? (first (get node "value"))))) + identity + + ;; Rename keys for all token nodes (and (map? node) (and (contains? node "type") (contains? node "value"))) @@ -1371,7 +1383,16 @@ Will return a value that matches this schema: (assoc tokens child-path (make-token :name child-path :type token-type - :value (get v "$value") + :value (cond-> (get v "$value") + ;; Split string of font-families + (and (= :font-family token-type) + (string? (get v "$value"))) + cto/split-font-family + + ;; Keep array of font-families + (and (= :font-family token-type) + (sequential? (get v "$value"))) + identity) :description (get v "$description"))) ;; Discard unknown type tokens tokens))))) diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 16cc0cd199..668330efb3 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -62,7 +62,11 @@ (ctob/add-token-in-set "test-token-set" (ctob/make-token :name "token-letter-spacing" :type :letter-spacing - :value 2)))) + :value 2)) + (ctob/add-token-in-set "test-token-set" + (ctob/make-token :name "token-font-family" + :type :font-family + :value ["Helvetica" "Arial" "sans-serif"])))) (tho/add-frame :frame1) (tho/add-text :text1 "Hello World!"))) @@ -77,7 +81,8 @@ (tht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00") (tht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100) (tht/apply-token-to-shape :text1 "token-font-size" [:font-size] [:font-size] 24) - (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] 2))) + (tht/apply-token-to-shape :text1 "token-letter-spacing" [:letter-spacing] [:letter-spacing] 2) + (tht/apply-token-to-shape :text1 "token-font-family" [:font-family] [:font-family] ["Helvetica" "Arial" "sans-serif"]))) (t/deftest apply-tokens-to-shape (let [;; ==== Setup @@ -93,6 +98,7 @@ token-dimensions (tht/get-token file "test-token-set" "token-dimensions") token-font-size (tht/get-token file "test-token-set" "token-font-size") token-letter-spacing (tht/get-token file "test-token-set" "token-letter-spacing") + token-font-family (tht/get-token file "test-token-set" "token-font-family") ;; ==== Action changes (-> (-> (pcb/empty-changes nil) @@ -132,7 +138,10 @@ :attributes [:font-size]}) (cto/apply-token-to-shape {:token token-letter-spacing :shape $ - :attributes [:letter-spacing]}))) + :attributes [:letter-spacing]}) + (cto/apply-token-to-shape {:token token-font-family + :shape $ + :attributes [:font-family]}))) (:objects page) {})) @@ -157,9 +166,10 @@ (t/is (= (:fill applied-tokens') "token-color")) (t/is (= (:width applied-tokens') "token-dimensions")) (t/is (= (:height applied-tokens') "token-dimensions")) - (t/is (= (count text1-applied-tokens) 2)) + (t/is (= (count text1-applied-tokens) 3)) (t/is (= (:font-size text1-applied-tokens) "token-font-size")) - (t/is (= (:letter-spacing text1-applied-tokens) "token-letter-spacing")))) + (t/is (= (:letter-spacing text1-applied-tokens) "token-letter-spacing")) + (t/is (= (:font-family text1-applied-tokens) "token-font-family")))) (t/deftest unapply-tokens-from-shape (let [;; ==== Setup @@ -189,7 +199,8 @@ (fn [shape] (-> shape (cto/unapply-token-id [:font-size]) - (cto/unapply-token-id [:letter-spacing]))) + (cto/unapply-token-id [:letter-spacing]) + (cto/unapply-token-id [:font-family]))) (:objects page) {})) @@ -240,7 +251,8 @@ d/txt-merge {:fills (ths/sample-fills-color :fill-color "#fabada") :font-size "1" - :letter-spacing "0"})) + :letter-spacing "0" + :font-family "Arial"})) (:objects page) {})) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index dad463cf5f..205cdd7954 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -195,6 +195,7 @@ value (.-value sd-token) has-references? (str/includes? (:value origin-token) "{") parsed-token-value (case (:type origin-token) + :font-family {:value (-> (js->clj value) (flatten))} :color (parse-sd-token-color-value value) :opacity (parse-sd-token-opacity-value value has-references?) :stroke-width (parse-sd-token-stroke-width-value value has-references?) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 35b0cb8266..98571612a7 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -14,6 +14,7 @@ [app.common.types.token :as ctt] [app.common.types.tokens-lib :as ctob] [app.common.types.typography :as cty] + [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] [app.main.data.style-dictionary :as sd] @@ -24,6 +25,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.undo :as dwu] + [app.main.fonts :as fonts] [app.main.store :as st] [beicon.v2.core :as rx] [clojure.set :as set] @@ -364,6 +366,26 @@ {:ignore-touched true :page-id page-id}))))) +(defn update-font-family + ([value shape-ids attributes] (update-font-family value shape-ids attributes nil)) + ([value shape-ids _attributes page-id] + (let [font-family (first value) + font (some-> font-family + (fonts/find-font-family)) + text-attrs (if font + {:font-id (:id font) + :font-family (:family font)} + {:font-id (str uuid/zero) + :font-family font-family}) + update-node? (fn [node] + (or (txt/is-text-node? node) + (txt/is-paragraph-node? node)))] + (when text-attrs + (dwsh/update-shapes shape-ids + #(txt/update-text-content % update-node? d/txt-merge text-attrs) + {:ignore-touched true + :page-id page-id}))))) + (defn update-font-size ([value shape-ids attributes] (update-font-size value shape-ids attributes nil)) ([value shape-ids _attributes page-id] @@ -421,6 +443,14 @@ :fields [{:label "Letter Spacing" :key :letter-spacing}]}} + :font-family + {:title "Font Family" + :attributes ctt/font-family-keys + :on-update-shape update-font-family + :modal {:key :tokens/font-family + :fields [{:label "Font Family" + :key :font-family}]}} + :stroke-width {:title "Stroke Width" :attributes ctt/stroke-width-keys diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 30f403bfbf..cd490f2447 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -35,6 +35,7 @@ #{:line-height} dwta/update-line-height #{:font-size} dwta/update-font-size #{:letter-spacing} dwta/update-letter-spacing + #{:font-family} dwta/update-font-family #{:x :y} dwta/update-shape-position #{:p1 :p2 :p3 :p4} dwta/update-layout-padding #{:m1 :m2 :m3 :m4} dwta/update-layout-item-margin diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 9f1d216cd1..d72b3880cc 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -78,6 +78,15 @@ data)) (vals @fontsdb))) +(defn find-font-family + "Case insensitive lookup of font-family." + [family] + (let [family' (str/lower family)] + (d/seek + (fn [{:keys [family]}] + (= family' (str/lower family))) + (vals @fontsdb)))) + (defn resolve-variants [id] (get-in @fontsdb [id :variants])) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs index 938376bc13..65913d9e09 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs @@ -20,7 +20,7 @@ [:class {:optional true} :string] [:id :string] [:icon {:optional true} - [:and :string [:fn #(contains? icon-list %)]]] + [:maybe [:and :string [:fn #(contains? icon-list %)]]]] [:has-hint {:optional true} :boolean] [:hint-type {:optional true} [:maybe [:enum "hint" "error" "warning"]]] [:type {:optional true} :string] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs index d5d1a8c532..1d2488c602 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.tokens :as cft] [app.common.types.color :as c] + [app.common.types.token :as ctt] [app.common.types.tokens-lib :as ctob] [app.main.constants :refer [max-input-length]] [app.main.data.modal :as modal] @@ -21,9 +22,11 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.tokens.propagation :as dwtp] [app.main.data.workspace.tokens.warnings :as wtw] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.foundations.assets.icon :as i] @@ -31,6 +34,8 @@ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.workspace.colorpicker :as colorpicker] [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] + [app.main.ui.workspace.sidebar.options.menus.typography :refer [font-selector*]] + [app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]] [app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-tokens-value*]] [app.util.dom :as dom] [app.util.functions :as uf] @@ -94,7 +99,17 @@ (defn valid-value? [value] (seq (finalize-value value))) -;; Component ------------------------------------------------------------------- +;; Validation ------------------------------------------------------------------ + +(defn validate-empty-input [value] + (if (sequential? value) + (empty? value) + (empty? (str/trim value)))) + +(defn validate-self-reference? [token-name value] + (if (sequential? value) + (some #(ctob/token-value-self-reference? token-name %) value) + (ctob/token-value-self-reference? token-name value))) (defn validate-token-value "Validates token value by resolving the value `input` using `StyleDictionary`. @@ -104,17 +119,19 @@ ;; 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)) + (validate-empty-input value) (rx/throw {:errors [(wte/get-error-code :error.token/empty-input)]}) - (ctob/token-value-self-reference? token-name value) + (validate-self-reference? token-name value) (rx/throw {:errors [(wte/get-error-code :error.token/direct-self-reference)]}) :else (let [tokens' (cond-> tokens ;; Remove previous token when renaming a token (not= name-value (:name token)) (dissoc (:name token)) - :always (update token-name #(ctob/make-token (merge % {:value value + :always (update token-name #(ctob/make-token (merge % {:value (cond + (= (:type token) :font-family) (ctt/split-font-family value) + :else value) :name token-name :type (:type token)}))))] (->> tokens' @@ -156,59 +173,7 @@ (defonce form-token-cache-atom (atom nil)) -;; FIXME: this function has confusing name -(defn- hex->value - [hex] - (when-let [tc (tinycolor/valid-color hex)] - (let [hex (tinycolor/->hex-string tc) - alpha (tinycolor/alpha 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 alpha}))) - -(mf/defc ramp* - [{:keys [color on-change]}] - (let [wrapper-node-ref (mf/use-ref nil) - dragging-ref (mf/use-ref false) - - on-start-drag - (mf/use-fn #(mf/set-ref-val! dragging-ref true)) - - on-finish-drag - (mf/use-fn #(mf/set-ref-val! dragging-ref false)) - - internal-color* - (mf/use-state #(hex->value color)) - - internal-color - (deref internal-color*) - - on-change' - (mf/use-fn - (mf/deps on-change) - (fn [{:keys [hex alpha] :as selector-color}] - (let [dragging? (mf/ref-val dragging-ref)] - (when-not (and dragging? hex) - (reset! internal-color* selector-color) - (on-change hex alpha)))))] - (mf/use-effect - (mf/deps color) - (fn [] - ;; Update internal color when user changes input value - (when-let [color (tinycolor/valid-color color)] - (when-not (= (tinycolor/->hex-string color) (:hex internal-color)) - (reset! internal-color* (hex->value color)))))) - - (colorpicker/use-color-picker-css-variables! wrapper-node-ref internal-color) - [:div {:ref wrapper-node-ref} - [:> ramp-selector* - {:color internal-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag - :on-change on-change'}]])) +;; Component ------------------------------------------------------------------- (mf/defc token-value-hint [{:keys [result]}] @@ -232,13 +197,11 @@ :class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors)))) :type type}])) -(mf/defc form - {::mf/wrap-props false} - [{:keys [token token-type action selected-token-set-name on-display-colorpicker]}] +(mf/defc form* + [{:keys [token token-type action selected-token-set-name transform-value on-value-resolve custom-input-token-value custom-input-token-value-props]}] (let [create? (not (instance? ctob/Token token)) token (or token {:type token-type}) token-properties (dwta/get-token-properties token) - is-color-token (cft/color-token? token) tokens-in-selected-set (mf/deref refs/workspace-all-tokens-in-selected-set) active-theme-tokens (cond-> (mf/deref refs/workspace-active-theme-sets-tokens) @@ -246,7 +209,11 @@ ;; even if the name has been overriden by a token with the same name ;; in another set below. (and (:name token) (:value token)) - (assoc (:name token) token)) + (assoc (:name token) token) + + ;; Style dictionary resolver needs font families to be an array of strings + (= :font-family (or (:type token) token-type)) + (update-in [(:name token) :value] ctt/split-font-family)) resolved-tokens (sd/use-resolved-tokens active-theme-tokens {:cache-atom form-token-cache-atom :interactive? true}) @@ -260,13 +227,6 @@ (-> (ctob/tokens-tree tokens-in-selected-set) ;; Allow setting editing token to it's own path (d/dissoc-in token-path)))) - cancel-ref (mf/use-ref nil) - - on-cancel-ref - (mf/use-fn - (fn [node] - (mf/set-ref-val! cancel-ref node))) - ;; Name touched-name* (mf/use-state false) touched-name? (deref touched-name*) @@ -286,16 +246,13 @@ on-blur-name (mf/use-fn - (mf/deps cancel-ref touched-name? warning-name-change?) + (mf/deps touched-name? warning-name-change?) (fn [e] - (let [node (dom/get-related-target e) - on-cancel-btn (= node (mf/ref-val cancel-ref))] - (when-not on-cancel-btn - (let [value (dom/get-target-val e) - errors (validate-name value)] - (when touched-name? - (reset! warning-name-change* true)) - (reset! name-errors errors)))))) + (let [value (dom/get-target-val e) + errors (validate-name value)] + (when touched-name? + (reset! warning-name-change* true)) + (reset! name-errors errors)))) on-update-name-debounced (mf/use-fn @@ -321,10 +278,6 @@ (valid-name? @token-name-ref)) ;; Value - color* (mf/use-state (when is-color-token (:value token))) - color (deref color*) - color-ramp-open* (mf/use-state false) - color-ramp-open? (deref color-ramp-open*) value-input-ref (mf/use-ref nil) value-ref (mf/use-ref (:value token)) @@ -333,68 +286,46 @@ set-resolve-value (mf/use-fn + (mf/deps on-value-resolve) (fn [token-or-err] (let [error? (:errors token-or-err) warnings? (:warnings token-or-err) v (cond error? - token-or-err + (do + (when on-value-resolve (on-value-resolve nil)) + token-or-err) warnings? (:warnings {:warnings token-or-err}) :else - (:resolved-value token-or-err))] - (when is-color-token (reset! color* (if error? nil v))) + (cond-> (:resolved-value token-or-err) + on-value-resolve on-value-resolve))] (reset! token-resolve-result* v)))) on-update-value-debounced (use-debonced-resolve-callback token-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) - ;; Automatically add # for hex values - value' (if (and is-color-token (tinycolor/hex-without-hash-prefix? value)) - (let [hex (dm/str "#" value)] - (dom/set-value! (mf/ref-val value-input-ref) hex) - hex) - value)] - (mf/set-ref-val! value-ref value') - (on-update-value-debounced value')))) - on-update-color (mf/use-fn - (mf/deps color on-update-value-debounced) - (fn [hex-value alpha] - (let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field - prev-input-color (some-> (dom/get-value (mf/ref-val value-input-ref)) - (tinycolor/valid-color)) - ;; If the input is a reference we will take the format from the computed value - prev-computed-color (when-not prev-input-color - (some-> color (tinycolor/valid-color))) - prev-format (some-> (or prev-input-color prev-computed-color) - (tinycolor/color-format)) - to-rgba? (and - (< alpha 1) - (or (= prev-format "hex") (not prev-format))) - to-hex? (and (not prev-format) (= alpha 1)) - format (cond - to-rgba? "rgba" - to-hex? "hex" - prev-format prev-format - :else "hex") - color-value (-> (tinycolor/valid-color hex-value) - (tinycolor/set-alpha (or alpha 1)) - (tinycolor/->string format))] - (mf/set-ref-val! value-ref color-value) - (dom/set-value! (mf/ref-val value-input-ref) color-value) - (on-update-value-debounced color-value)))) - - on-display-colorpicker' + on-update-value (mf/use-fn - (mf/deps color-ramp-open? on-display-colorpicker) - (fn [] - (let [open? (not color-ramp-open?)] - (reset! color-ramp-open* open?) - (on-display-colorpicker open?)))) + (mf/deps on-update-value-debounced transform-value) + (fn [e] + (let [value (dom/get-target-val e) + value' (if (fn? transform-value) + (transform-value value) + value)] + ;; Value got updated in transform, update the dom node + (when (not= value value') + (dom/set-value! (mf/ref-val value-input-ref) value')) + (mf/set-ref-val! value-ref value) + (on-update-value-debounced value)))) + + on-external-update-value + (mf/use-fn + (mf/deps on-update-value-debounced) + (fn [next-value] + (dom/set-value! (mf/ref-val value-input-ref) next-value) + (mf/set-ref-val! value-ref next-value) + (on-update-value-debounced next-value))) value-error? (seq (:errors token-resolve-result)) @@ -431,7 +362,6 @@ (mf/deps validate-name validate-descripion token active-theme-tokens) (fn [e] (dom/prevent-default e) - (mf/set-ref-val! cancel-ref nil) ;; 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, @@ -440,7 +370,11 @@ valid-name? (try (not (:errors (validate-name final-name))) (catch js/Error _ nil)) - final-value (finalize-value (mf/ref-val value-ref)) + final-value (let [value (mf/ref-val value-ref) + font-family? (= :font-family (or (:type token) token-type))] + (if font-family? + (ctt/split-font-family value) + (finalize-value value))) final-description @description-ref valid-description? (if final-description (try @@ -479,9 +413,9 @@ on-cancel (mf/use-fn (fn [e] - (mf/set-ref-val! cancel-ref nil) (dom/prevent-default e) (modal/hide!))) + handle-key-down-delete (mf/use-fn (mf/deps on-delete-token) @@ -555,20 +489,31 @@ {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] [:div {:class (stl/css :input-row)} - [:> input-tokens-value* - {:placeholder (tr "workspace.tokens.token-value-enter") - :label (tr "workspace.tokens.token-value") - :default-value (mf/ref-val value-ref) - :ref value-input-ref - :is-color-token is-color-token - :color color - :on-change on-update-value - :error (not (nil? (:errors token-resolve-result))) - :display-colorpicker on-display-colorpicker' - :on-blur on-update-value}] - (when color-ramp-open? - [:> ramp* {:color (some-> color (tinycolor/valid-color)) - :on-change on-update-color}]) + (let [placeholder (tr "workspace.tokens.token-value-enter") + label (tr "workspace.tokens.token-value") + default-value (mf/ref-val value-ref) + ref value-input-ref + error (not (nil? (:errors token-resolve-result))) + on-blur on-update-value] + (if (fn? custom-input-token-value) + [:> custom-input-token-value + {:placeholder placeholder + :label label + :default-value default-value + :input-ref ref + :error error + :on-blur on-blur + :on-update-value on-update-value + :on-external-update-value on-external-update-value + :custom-input-token-value-props custom-input-token-value-props}] + [:> input-tokens-value* + {:placeholder placeholder + :label label + :default-value default-value + :ref ref + :error error + :on-blur on-blur + :on-change on-update-value}])) [:& token-value-hint {:result token-resolve-result}]] [:div {:class (stl/css :input-row)} [:> input* {:label (tr "workspace.tokens.token-description") @@ -593,7 +538,6 @@ [:> button* {:on-click on-cancel :on-key-down handle-key-down-cancel :type "button" - :on-ref on-cancel-ref :id "token-modal-cancel" :variant "secondary"} (tr "labels.cancel")] @@ -602,3 +546,247 @@ :variant "primary" :disabled disabled?} (tr "labels.save")]]]])) + +;; FIXME: this function has confusing name +(defn- hex->value + [hex] + (when-let [tc (tinycolor/valid-color hex)] + (let [hex (tinycolor/->hex-string tc) + alpha (tinycolor/alpha 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 alpha}))) + +(mf/defc ramp* + [{:keys [color on-change]}] + (let [wrapper-node-ref (mf/use-ref nil) + dragging-ref (mf/use-ref false) + + on-start-drag + (mf/use-fn #(mf/set-ref-val! dragging-ref true)) + + on-finish-drag + (mf/use-fn #(mf/set-ref-val! dragging-ref false)) + + internal-color* + (mf/use-state #(hex->value color)) + + internal-color + (deref internal-color*) + + on-change' + (mf/use-fn + (mf/deps on-change) + (fn [{:keys [hex alpha] :as selector-color}] + (let [dragging? (mf/ref-val dragging-ref)] + (when-not (and dragging? hex) + (reset! internal-color* selector-color) + (on-change hex alpha)))))] + (mf/use-effect + (mf/deps color) + (fn [] + ;; Update internal color when user changes input value + (when-let [color (tinycolor/valid-color color)] + (when-not (= (tinycolor/->hex-string color) (:hex internal-color)) + (reset! internal-color* (hex->value color)))))) + + (colorpicker/use-color-picker-css-variables! wrapper-node-ref internal-color) + [:div {:ref wrapper-node-ref} + [:> ramp-selector* + {:color internal-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag + :on-change on-change'}]])) + +(mf/defc color-picker* + [{:keys [placeholder label default-value input-ref error on-blur on-update-value on-external-update-value custom-input-token-value-props]}] + (let [{:keys [color on-display-colorpicker]} custom-input-token-value-props + color-ramp-open* (mf/use-state false) + color-ramp-open? (deref color-ramp-open*) + + on-click-swatch + (mf/use-fn + (mf/deps color-ramp-open? on-display-colorpicker) + (fn [] + (let [open? (not color-ramp-open?)] + (reset! color-ramp-open* open?) + (on-display-colorpicker open?)))) + + swatch + (mf/html + [:> input-token-color-bullet* + {:color color + :class (stl/css :slot-start) + :on-click on-click-swatch}]) + + on-change' + (mf/use-fn + (mf/deps color on-external-update-value) + (fn [hex-value alpha] + (let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field + prev-input-color (some-> (dom/get-value (mf/ref-val input-ref)) + (tinycolor/valid-color)) + ;; If the input is a reference we will take the format from the computed value + prev-computed-color (when-not prev-input-color + (some-> color (tinycolor/valid-color))) + prev-format (some-> (or prev-input-color prev-computed-color) + (tinycolor/color-format)) + to-rgba? (and + (< alpha 1) + (or (= prev-format "hex") (not prev-format))) + to-hex? (and (not prev-format) (= alpha 1)) + format (cond + to-rgba? "rgba" + to-hex? "hex" + prev-format prev-format + :else "hex") + color-value (-> (tinycolor/valid-color hex-value) + (tinycolor/set-alpha (or alpha 1)) + (tinycolor/->string format))] + (on-external-update-value color-value))))] + + [:* + [:> input-tokens-value* + {:placeholder placeholder + :label label + :default-value default-value + :ref input-ref + :error error + :on-blur on-blur + :on-change on-update-value + :slot-start swatch}] + (when color-ramp-open? + [:> ramp* + {:color (some-> color (tinycolor/valid-color)) + :on-change on-change'}])])) + +(mf/defc color-form* + [{:keys [token on-display-colorpicker] :rest props}] + (let [color* (mf/use-state (:value token)) + color (deref color*) + on-value-resolve (mf/use-fn + (mf/deps color) + (fn [value] + (reset! color* value) + value)) + + custom-input-token-value-props + (mf/use-memo + (mf/deps color on-display-colorpicker) + (fn [] + {:color color + :on-display-colorpicker on-display-colorpicker})) + + transform-value + (mf/use-fn + (fn [value] + (if (tinycolor/hex-without-hash-prefix? value) + (dm/str "#" value) + value)))] + [:> form* + (mf/spread-props props {:token token + :transform-value transform-value + :on-value-resolve on-value-resolve + :custom-input-token-value color-picker* + :custom-input-token-value-props custom-input-token-value-props})])) + +(mf/defc font-selector-wrapper* + [{:keys [font input-ref on-select-font on-close-font-selector]}] + (let [current-font* (mf/use-state (or font + (some-> (mf/ref-val input-ref) + (dom/get-value) + (ctt/split-font-family) + (first) + (fonts/find-font-family)))) + current-font (deref current-font*)] + [:div {:class (stl/css :font-select-wrapper)} + [:> font-selector* {:current-font current-font + :on-select on-select-font + :on-close on-close-font-selector + :full-size true}]])) + +(mf/defc font-picker* + [{:keys [default-value input-ref error on-blur on-update-value on-external-update-value]}] + (let [font* (mf/use-state (fonts/find-font-family default-value)) + font (deref font*) + set-font (mf/use-fn + (mf/deps font) + #(reset! font* %)) + + font-selector-open* (mf/use-state false) + font-selector-open? (deref font-selector-open*) + + on-close-font-selector + (mf/use-fn + (fn [] + (reset! font-selector-open* false))) + + on-click-dropdown-button + (mf/use-fn + (mf/deps font-selector-open?) + (fn [e] + (dom/prevent-default e) + (reset! font-selector-open* (not font-selector-open?)))) + + on-select-font + (mf/use-fn + (mf/deps on-external-update-value set-font) + (fn [{:keys [family] :as font}] + (when font + (set-font font) + (on-external-update-value family)))) + + on-update-value' + (mf/use-fn + (mf/deps on-update-value set-font) + (fn [value] + (set-font nil) + (on-update-value value))) + + font-selector-button + (mf/html + [:> icon-button* + {:on-click on-click-dropdown-button + :aria-label (tr "workspace.tokens.token-font-family-select") + :icon "arrow-down" + :variant "action" + :type "button"}])] + [:* + [:> input-tokens-value* + {:placeholder (tr "workspace.tokens.token-font-family-value-enter") + :label (tr "workspace.tokens.token-font-family-value") + :default-value default-value + :ref input-ref + :error error + :on-blur on-blur + :on-change on-update-value' + :icon "text-font-family" + :slot-end font-selector-button}] + (when font-selector-open? + [:> font-selector-wrapper* {:font font + :input-ref input-ref + :on-select-font on-select-font + :on-close-font-selector on-close-font-selector}])])) + +(mf/defc font-family-form* + [{:keys [token] :rest props}] + (let [on-value-resolve + (mf/use-fn + (fn [value] + (when value + (ctt/join-font-family value))))] + [:> form* + (mf/spread-props props {:token (when token (update token :value ctt/join-font-family)) + :custom-input-token-value font-picker* + :on-value-resolve on-value-resolve})])) + +(mf/defc form-wrapper* + [{:keys [token token-type] :as props}] + (let [token-type' (or (:type token) token-type)] + (case token-type' + :color [:> color-form* props] + :font-family [:> font-family-form* props] + [:> form* props]))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss b/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss index 64a8d29a1e..12e40529b7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss @@ -8,6 +8,7 @@ .form-wrapper { width: $s-384; + position: relative; } .button-row { @@ -64,3 +65,11 @@ .form-modal-title { color: var(--color-foreground-primary); } + +.font-select-wrapper { + position: absolute; + inset: 0; + // This padding from the modal should be shared as a variable + // Need to set this or the font-select will cause scroll + bottom: $s-32; +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs index 0abeb85863..0f53e2bf12 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs @@ -7,11 +7,10 @@ (ns app.main.ui.workspace.tokens.management.create.input-tokens-value (:require-macros [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.data.macros :as dm] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.label :refer [label*]] - [app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon-list]] [rumext.v2 :as mf])) (def ^:private schema::input-tokens-value @@ -20,26 +19,18 @@ [:placeholder {:optional true} :string] [:value {:optional true} [:maybe :string]] [:class {:optional true} :string] - [:is-color-token {:optional true} :boolean] - [:color {:optional true} [:maybe :string]] - [:display-colorpicker {:optional true} fn?] - [:error {:optional true} :boolean]]) - + [:error {:optional true} :boolean] + [:slot-start {:optional true} [:maybe some?]] + [:icon {:optional true} + [:maybe [:and :string [:fn #(contains? icon-list %)]]]]]) (mf/defc input-tokens-value* {::mf/props :obj ::mf/forward-ref true ::mf/schema schema::input-tokens-value} - [{:keys [class label is-color-token placeholder error value color display-colorpicker] :rest props} ref] + [{:keys [class label placeholder error value icon slot-start] :rest props} ref] (let [id (mf/use-id) input-ref (mf/use-ref) - is-color-token (d/nilv is-color-token false) - swatch - (mf/html [:> input-token-color-bullet* - {:color color - :class (stl/css :slot-start) - :on-click display-colorpicker}]) - props (mf/spread-props props {:id id :type "text" :class (stl/css :input) @@ -47,7 +38,8 @@ :value value :variant "comfortable" :hint-type (when error "error") - :slot-start (when is-color-token swatch) + :slot-start slot-start + :icon icon :ref (or ref input-ref)})] [:div {:class (dm/str class " " (stl/css-case :wrapper true :input-error error))} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs index 758b015e0e..a75851bf9c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs @@ -12,7 +12,7 @@ [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.management.create.form :refer [form]] + [app.main.ui.workspace.tokens.management.create.form :refer [form-wrapper*]] [app.util.i18n :refer [tr]] [okulary.core :as l] [rumext.v2 :as mf])) @@ -88,11 +88,11 @@ :icon i/close :variant "action" :aria-label (tr "labels.close")}] - [:& form {:token token - :action action - :selected-token-set-name selected-token-set-name - :token-type token-type - :on-display-colorpicker update-modal-size}]])) + [:> form-wrapper* {:token token + :action action + :selected-token-set-name selected-token-set-name + :token-type token-type + :on-display-colorpicker update-modal-size}]])) ;; Modals ---------------------------------------------------------------------- @@ -191,3 +191,9 @@ ::mf/register-as :tokens/letter-spacing} [properties] [:& token-update-create-modal properties]) + +(mf/defc font-familiy-modal + {::mf/register modal/components + ::mf/register-as :tokens/font-family} + [properties] + [:& token-update-create-modal properties]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index c96ed54cdf..6a32aade65 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -27,6 +27,7 @@ :border-radius "corner-radius" :color "drop" :boolean "boolean-difference" + :font-family "text-font-family" :font-size "text-font-size" :letter-spacing "text-letterspacing" :opacity "percentage" diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index fa4890d9f0..82575ba0fc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -106,7 +106,9 @@ (defn- generate-tooltip "Generates a tooltip for a given token" [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set] - (let [{:keys [name value type resolved-value]} token + (let [{:keys [name type resolved-value]} token + value (cond->> (:value token) + (= :font-family type) ctt/join-font-family) resolved-value-theme (:resolved-value theme-token) resolved-value (or resolved-value-theme resolved-value) {:keys [title] :as token-props} (dwta/get-token-properties theme-token) diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 03af45b67c..ffa4d17354 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -558,6 +558,40 @@ (t/is (= (:letter-spacing (:applied-tokens text-1')) (:name token-target'))) (t/is (= (:letter-spacing style-text-blocks) "2"))))))))) +(t/deftest test-apply-font-family + (t/testing "applies font-family token and updates the text font-family" + (t/async + done + (let [font-family-token {:name "primary-font" + :value "Arial" + :type :font-family} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(ctob/add-token-in-set % "Set A" (ctob/make-token font-family-token)))) + store (ths/setup-store file) + text-1 (cths/get-shape file :text-1) + events [(dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:font-family} + :token (toht/get-token file "primary-font") + :on-update-shape dwta/update-font-family})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + token-target' (toht/get-token file' "primary-font") + text-1' (cths/get-shape file' :text-1) + style-text-blocks (->> (:content text-1') + (txt/content->text+styles) + (remove (fn [[_ text]] (str/empty? (str/trim text)))) + (mapv (fn [[style text]] + {:styles (merge txt/default-text-attrs style) + :text-content text})) + (first) + (:styles))] + (t/is (some? (:applied-tokens text-1'))) + (t/is (= (:font-family (:applied-tokens text-1')) (:name token-target'))) + (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs)))))))))) + (t/deftest test-toggle-token-none (t/testing "should apply token to all selected items, where no item has the token applied" (t/async diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fd363eb1f1..b65708bae7 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7528,10 +7528,22 @@ msgstr "Could not resolve reference token with the name: %s" msgid "workspace.tokens.token-value" msgstr "Value" +#: src/app/main/ui/workspace/tokens/form.cljs:560 +msgid "workspace.tokens.token-font-family-value" +msgstr "Font family" + #: src/app/main/ui/workspace/tokens/form.cljs:559 msgid "workspace.tokens.token-value-enter" msgstr "Enter a value or alias with {alias}" +#: src/app/main/ui/workspace/tokens/form.cljs:689 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Font family or list of fonts separated by comma (,)" + +#: src/app/main/ui/workspace/tokens/form.cljs:688 +msgid "workspace.tokens.token-font-family-select" +msgstr "Select font family" + #: src/app/main/ui/workspace/tokens/sidebar.cljs:336 msgid "workspace.tokens.tokens-section-title" msgstr "TOKENS - %s" @@ -7900,4 +7912,4 @@ msgid "labels.sources" msgstr "Sources" msgid "labels.pinned-projects" -msgstr "Pinned Projects" \ No newline at end of file +msgstr "Pinned Projects"