0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-21 06:02:32 -05:00

Merge pull request #224 from tokens-studio/ux-context-menu

Ux context menu
This commit is contained in:
Florian Schrödl 2024-07-30 08:02:50 +02:00 committed by GitHub
commit ab72bdf09c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 915 additions and 772 deletions

View file

@ -5,7 +5,9 @@
"author": "Kaleidos INC",
"private": true,
"packageManager": "yarn@4.2.2",
"browserslist": ["defaults"],
"browserslist": [
"defaults"
],
"type": "module",
"repository": {
"type": "git",

View file

@ -30,7 +30,6 @@
[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.context-menu :refer [token-context-menu]]
[app.main.ui.workspace.tokens.modals]
[app.main.ui.workspace.viewport :refer [viewport]]
[app.util.debug :as dbg]
@ -206,7 +205,6 @@
:style {:background-color background-color
:touch-action "none"}}
[:& context-menu]
[:& token-context-menu]
(if ^boolean file-ready?
[:& workspace-page {:page-id page-id

View file

@ -14,10 +14,8 @@
[app.config :as cf]
[app.main.data.events :as-alias ev]
[app.main.data.workspace :as udw]
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.grid-layout.editor :as dwge]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@ -31,6 +29,7 @@
[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.token-types :as wtty]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
@ -864,7 +863,7 @@
(wtc/tokens-name-map->select-options
{:shape shape
:tokens spacing-tokens
:attributes (wtc/token-attributes :spacing)
:attributes (wtty/token-attributes :spacing)
:selected-attributes #{:spacing-column}})))
spacing-row-options (mf/use-memo
@ -873,7 +872,7 @@
(wtc/tokens-name-map->select-options
{:shape shape
:tokens spacing-tokens
:attributes (wtc/token-attributes :spacing)
:attributes (wtty/token-attributes :spacing)
:selected-attributes #{:spacing-row}})))
padding-x-options (mf/use-memo
@ -882,7 +881,7 @@
(wtc/tokens-name-map->select-options
{:shape shape
:tokens spacing-tokens
:attributes (wtc/token-attributes :spacing)
:attributes (wtty/token-attributes :spacing)
:selected-attributes #{:padding-p1 :padding-p3}})))
padding-y-options (mf/use-memo
@ -891,7 +890,7 @@
(wtc/tokens-name-map->select-options
{:shape shape
:tokens spacing-tokens
:attributes (wtc/token-attributes :spacing)
:attributes (wtty/token-attributes :spacing)
:selected-attributes #{:padding-p2 :padding-p4}})))
on-add-layout

View file

@ -27,6 +27,7 @@
[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]]
@ -109,21 +110,21 @@
#(wtc/tokens-name-map->select-options
{:shape shape
:tokens border-radius-tokens
:attributes (wtc/token-attributes :border-radius)}))
:attributes (wtty/token-attributes :border-radius)}))
sizing-tokens (:sizing tokens-by-type)
width-options (mf/use-memo
(mf/deps shape sizing-tokens)
#(wtc/tokens-name-map->select-options
{:shape shape
:tokens sizing-tokens
:attributes (wtc/token-attributes :sizing)
:attributes (wtty/token-attributes :sizing)
:selected-attributes #{:width}}))
height-options (mf/use-memo
(mf/deps shape sizing-tokens)
#(wtc/tokens-name-map->select-options
{:shape shape
:tokens sizing-tokens
:attributes (wtc/token-attributes :sizing)
:attributes (wtty/token-attributes :sizing)
:selected-attributes #{:height}}))
flex-child? (->> selection-parents (some ctl/flex-layout?))
@ -330,7 +331,7 @@
(let [token-value (wtc/maybe-resolve-token-value token)]
(st/emit!
(change-radius (fn [shape]
(-> (dt/unapply-token-id shape (wtc/token-attributes :border-radius))
(-> (dt/unapply-token-id shape (wtty/token-attributes :border-radius))
(ctsr/set-radius-1 token-value))))))))
on-radius-1-change
@ -342,7 +343,7 @@
(change-radius (fn [shape]
(-> (dt/maybe-apply-token-to-shape {:token (when token-value value)
:shape shape
:attributes (wtc/token-attributes :border-radius)})
:attributes (wtty/token-attributes :border-radius)})
(ctsr/set-radius-1 (or token-value value)))))))))
on-radius-multi-change

View file

@ -18,6 +18,25 @@ If possible add video here from PR as well
## Changes
### 2024-07-25 - UX Improvements for the context menu
[Link to PR](https://github.com/tokens-studio/tokens-studio-for-penpot/pull/224)
Changes context menu behavior according to [Specs](https://github.com/tokens-studio/obsidian-docs/blob/31f0d7f98ff5ac922970f3009fe877cc02d6d0cd/Products/TS%20for%20Penpot/Specs/Token%20State%20Specs.md)
- Removing a token wont update the shape
- Mixed selection (shapes with applied, shapes without applied) will always unapply token
- Multi selection of shapes without token will apply the token to all
- Every shape change and token applying should be one undo step now
- Prevent token applying when nothign is selected
- `All` is a toggle instead of a checkbox if all tokens have been applied
- For instance with border radius the context menu can `:r1 :r2 :r3 :r4` which will highlight `All`
- If one attribute is missing it will check the single attributes
- Clicking a single attribute after clicking `All` will remove the other attributes
- Fixed some issues for switching between split and uniform border radius
- Clicking a token wont apply all attributes anymore. We apply only a select collection of attributes, which makes most sense. For instance on `sizing` we only apply `width` and `height` instead of all (`max-width`, `max-height`, `min-heigt`, `min-width`)
### 2024-07-05 - UX Improvements when applying tokens
[Link to PR](https://github.com/tokens-studio/tokens-studio-for-penpot/compare/token-studio-develop...ux-improvements?body=&expand=1)

View file

@ -0,0 +1,176 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; 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.main.data.tokens :as dt]
[app.main.data.workspace :as udw]
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.store :as st]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.token :as wtt]
[beicon.v2.core :as rx]
[clojure.set :as set]
[potok.v2.core :as ptk]
[promesa.core :as p]))
;; 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 _]
(->> (rx/from (sd/resolve-tokens+ (get-in state [:workspace-data :tokens])))
(rx/mapcat
(fn [sd-tokens]
(let [undo-id (js/Symbol)
resolved-value (-> (get sd-tokens (:id token))
(wtt/resolve-token-value))
tokenized-attributes (wtt/attributes-map attributes (:id token))]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/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-id attributes (:id token) %))]
(dch/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]
(dch/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]
(dch/update-shapes shape-ids #(assoc % :opacity value)))
(defn update-rotation [value shape-ids]
(ptk/reify ::update-shape-dimensions
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]
(dch/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]
(dch/update-shapes shape-ids (fn [shape]
(when (seq (:strokes shape))
(assoc-in shape [:strokes 0 :stroke-width] value)))))
(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-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-layout-spacing-column [value shape-ids]
(ptk/reify ::update-layout-spacing-column
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(for [shape-id shape-ids]
(let [layout-update {:layout-gap {:column-gap value :row-gap value}}]
(dwsl/update-layout [shape-id] layout-update)))))))
(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

@ -8,27 +8,228 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.shape.radius :as ctsr]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
[app.main.data.tokens :as dt]
[app.main.data.workspace :as dw]
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.transforms :as dwt]
[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.core :as wtc]
[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]
[clojure.set :as set]
[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 (fn [resolved-value shape-ids attrs]
(dwsl/update-layout shape-ids {:layout-padding (zipmap attrs (repeat resolved-value))}))
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))))))}
{: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)))]
(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)))]
(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)
(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]}]
(let [{:keys [modal]} (wtty/get-token-properties token)]
[{:title "Delete Token"
:action #(st/emit! (dt/delete-token (:id token)))}
{:title "Duplicate Token"
:action #(st/emit! (dt/duplicate-token (:id token)))}
{:title "Edit Token"
:action (fn [event]
(let [{:keys [key fields]} modal
token (dt/get-token-data-from-token-id (:id token))]
(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
: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 (with-actions context-data)]
(concat
attribute-actions
[:separator]
(default-actions context-data))))
;; Components ------------------------------------------------------------------
(def tokens-menu-ref
(l/derived :token-context-menu refs/workspace-local))
@ -39,312 +240,92 @@
(mf/defc menu-entry
{::mf/props :obj}
[{:keys [title shortcut on-click on-pointer-enter on-pointer-leave
on-unmount children selected? icon disabled value]}]
[{: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)
(let [submenu-node (mf/ref-val submenu-ref)]
(when (some? submenu-node)
(dom/set-css-property! submenu-node "display" "block")))
(when on-pointer-enter (on-pointer-enter))))
(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)
(let [submenu-node (mf/ref-val submenu-ref)]
(when (some? submenu-node)
(timers/schedule
50
#(when-not (mf/ref-val hovering?)
(dom/set-css-property! submenu-node "display" "none")))))
(when on-pointer-leave (on-pointer-leave))))
(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/use-effect
(mf/deps on-unmount)
(constantly on-unmount))
(if icon
[:li {:class (stl/css :icon-menu-item)
:disabled disabled
:data-value value
:ref set-dom-node
:on-click on-click
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:span
{:class (stl/css :icon-wrapper)}
(if selected? [:span {:class (stl/css :selected-icon)}
i/tick]
[:span {:class (stl/css :selected-icon)}])
[:span {:class (stl/css :shape-icon)} icon]]
[:span {:class (stl/css :title)} title]]
[:li {:class (stl/css :context-menu-item)
:disabled disabled
:ref set-dom-node
:data-value value
:on-click on-click
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:span {:class (stl/css :title)} title]
(when shortcut
[:span {:class (stl/css :shortcut)}
(for [[idx sc] (d/enumerate (scd/split-sc shortcut))]
[:span {:key (dm/str shortcut "-" idx)
:class (stl/css :shortcut-key)} sc])])
(when (> (count children) 1)
[:span {:class (stl/css :submenu-icon)} i/arrow])
(when (> (count children) 1)
[:ul {:class (stl/css :token-context-submenu)
:ref submenu-ref
:style {:display "none" :left 235}
:on-context-menu prevent-default}
children])])))
(mf/defc menu-separator
[]
[:li {:class (stl/css :separator)}])
(defn update-shape-radius-single-corner [value shape-ids attribute]
(st/emit!
(dch/update-shapes shape-ids
(fn [shape]
(when (ctsr/has-radius? shape)
(ctsr/set-radius-4 shape (first attribute) value)))
{:reg-objects? true
:attrs [:rx :ry :r1 :r2 :r3 :r4]})))
(defn apply-border-radius-token [{:keys [token-id token-type-props selected-shapes]} attributes]
(let [token (dt/get-token-data-from-token-id token-id)
updated-token-type-props (if (set/superset? #{:r1 :r2 :r3 :r4} attributes)
(assoc token-type-props
:on-update-shape update-shape-radius-single-corner
:attributes attributes)
token-type-props)]
(wtc/on-apply-token {:token token
:token-type-props updated-token-type-props
:selected-shapes selected-shapes})))
(defn update-layout-spacing [value selected-shapes attributes]
(doseq [shape selected-shapes]
(let [shape-id (:id shape)]
(if-let [layout-gap (cond
(:row-gap attributes) {:row-gap value}
(:column-gap attributes) {:column-gap value})]
(st/emit! (dwsl/update-layout [shape-id] {:layout-gap layout-gap}))
(when (:layout shape)
(st/emit! (dwsl/update-layout [shape-id] {:layout-padding (zipmap attributes (repeat value))})))))))
(defn apply-spacing-token [{:keys [token-id token-type-props selected-shapes]} attributes]
(let [token (dt/get-token-data-from-token-id token-id)
attributes (set attributes)
updated-token-type-props (assoc token-type-props
:on-update-shape (fn [value shape-ids]
(update-layout-spacing value selected-shapes attributes))
:attributes attributes)]
(wtc/on-apply-token {:token token
:token-type-props updated-token-type-props
:selected-shapes selected-shapes})))
(defn update-shape-position [value shape-ids attributes]
(doseq [shape-id shape-ids]
(st/emit! (dw/update-position shape-id {(first attributes) value}))))
(defn apply-dimensions-token [{:keys [token-id token-type-props selected-shapes]} attributes]
(let [token (dt/get-token-data-from-token-id token-id)
attributes (set attributes)
updated-token-type-props (cond
(set/superset? #{:x :y} attributes)
(assoc token-type-props
:on-update-shape update-shape-position
:attributes attributes)
(set/superset? #{:stroke-width} attributes)
(assoc token-type-props
:on-update-shape wtc/update-stroke-width
:attributes attributes)
:else token-type-props)]
(wtc/on-apply-token {:token token
:token-type-props updated-token-type-props
:selected-shapes selected-shapes})))
(defn update-shape-dimensions [value shape-ids attributes]
(st/emit! (dwt/update-dimensions shape-ids (first attributes) value)))
(defn update-layout-sizing-limits [value shape-ids attributes]
(st/emit! (dwsl/update-layout-child shape-ids {(first attributes) value})))
(defn apply-sizing-token [{:keys [token-id token-type-props selected-shapes]} attributes]
(let [token (dt/get-token-data-from-token-id token-id)
updated-token-type-props (cond
(set/superset? #{:width :height} attributes)
(assoc token-type-props
:on-update-shape update-shape-dimensions
:attributes attributes)
(set/superset? #{:layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h} attributes)
(assoc token-type-props
:on-update-shape update-layout-sizing-limits
:attributes attributes)
:else token-type-props)]
(wtc/on-apply-token {:token token
:token-type-props updated-token-type-props
:selected-shapes selected-shapes})))
(defn apply-rotation-opacity-stroke-token [{:keys [token-id token-type-props selected-shapes]} attributes]
(let [token (dt/get-token-data-from-token-id token-id)]
(wtc/on-apply-token {:token token
:token-type-props token-type-props
:selected-shapes selected-shapes})))
(defn additional-actions [{:keys [token-id token-type selected-shapes] :as context-data}]
(let [attributes->actions (fn [update-fn coll]
(for [{:keys [attributes] :as item} coll]
(let [selected? (wtt/shapes-token-applied? {:id token-id} selected-shapes attributes)]
(assoc item
:action #(update-fn context-data attributes)
:selected? selected?))))]
(case token-type
:border-radius (attributes->actions
apply-border-radius-token
[{:title "All" :attributes #{:r1 :r2 :r3 :r4}}
{:title "Top Left" :attributes #{:r1}}
{:title "Top Right" :attributes #{:r2}}
{:title "Bottom Right" :attributes #{:r3}}
{:title "Bottom Left" :attributes #{:r4}}])
:spacing (attributes->actions
apply-spacing-token
[{:title "All" :attributes #{:p1 :p2 :p3 :p4}}
{:title "Column Gap" :attributes #{:column-gap}}
{:title "Vertical padding" :attributes #{:p1 :p3}}
{:title "Horizontal padding" :attributes #{:p2 :p4}}
{:title "Row Gap" :attributes #{:row-gap}}
{:title "Top" :attributes #{:p1}}
{:title "Right" :attributes #{:p2}}
{:title "Bottom" :attributes #{:p3}}
{:title "Left" :attributes #{:p4}}])
:sizing (attributes->actions
apply-sizing-token
[{:title "All" :attributes #{:width :height :layout-item-min-w :layout-item-max-w :layout-item-min-h :layout-item-max-h}}
{:title "Width" :attributes #{:width}}
{:title "Height" :attributes #{:height}}
{:title "Min width" :attributes #{:layout-item-min-w}}
{:title "Max width" :attributes #{:layout-item-max-w}}
{:title "Min height" :attributes #{:layout-item-min-h}}
{:title "Max height" :attributes #{:layout-item-max-h}}])
:dimensions (attributes->actions
apply-dimensions-token
[{:title "Spacing" :submenu :spacing}
{:title "Sizing" :submenu :sizing}
{:title "Border Radius" :submenu :border-radius}
{:title "Border Width" :attributes #{:stroke-width}}
{:title "x" :attributes #{:x}}
{:title "y" :attributes #{:y}}])
;;TODO: Background blur {:title "Background blur" :attributes #{:width}}])
:opacity (attributes->actions
apply-rotation-opacity-stroke-token
[{:title "opacity" :attributes #{:opacity}}])
:rotation (attributes->actions
apply-rotation-opacity-stroke-token
[{:title "rotation" :attributes #{:rotation}}])
:stroke-width (attributes->actions
apply-rotation-opacity-stroke-token
[{:title "stroke width" :attributes #{:stroke-width}}])
[])))
(defn generate-menu-entries [{:keys [token-id token-type-props token-type selected-shapes] :as context-data}]
(let [{:keys [modal]} token-type-props
default-actions [{:title "Delete Token" :action #(st/emit! (dt/delete-token token-id))}
{:title "Duplicate Token" :action #(st/emit! (dt/duplicate-token token-id))}
{:title "Edit Token" :action (fn [event]
(let [{:keys [key fields]} modal
token (dt/get-token-data-from-token-id token-id)]
(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
:token token})))}]
specific-actions (additional-actions context-data)
all-actions (concat specific-actions default-actions)]
all-actions))
(mf/defc token-pill-context-menu
[context-data]
(let [menu-entries (generate-menu-entries context-data)]
(for [[index {:keys [title action selected? children submenu]}] (d/enumerate menu-entries)]
[:& menu-entry (cond-> {:key index
:title title}
(not submenu) (assoc :on-click action
;; TODO: Allow selected items wihtout an icon for the context menu
:icon (mf/html [:div {:class (stl/css-case :empty-icon true
:hidden-icon (not selected?))}])
:selected? selected?))
(when submenu
(let [submenu-entries (additional-actions (assoc context-data :token-type submenu))]
(for [[index {:keys [title action selected?]}] (d/enumerate submenu-entries)]
[:& menu-entry {:key index
:title title
:on-click action
:icon (mf/html [:div {:class (stl/css-case :empty-icon true
:hidden-icon (not selected?))}])
:selected? selected?}])))])))
(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
[]
(let [mdata (mf/deref tokens-menu-ref)
top (- (get-in mdata [:position :y]) 20)
left (get-in mdata [:position :x])
dropdown-ref (mf/use-ref)
(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)
objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
selected-shapes (into [] (keep (d/getf objects)) selected)]
selected-shapes (into [] (keep (d/getf objects)) selected)
token-id (:token-id mdata)
token (get (mf/deref refs/workspace-tokens) token-id)]
(mf/use-effect
(mf/deps mdata)
#(let [dropdown (mf/ref-val dropdown-ref)]
(when dropdown
(let [bounding-rect (dom/get-bounding-rect dropdown)
window-size (dom/get-window-size)
delta-x (max (- (+ (:right bounding-rect) 250) (:width window-size)) 0)
delta-y (max (- (:bottom bounding-rect) (:height window-size)) 0)
new-style (str "top: " (- top delta-y) "px; "
"left: " (- left delta-x) "px;")]
(when (or (> delta-x 0) (> delta-y 0))
(.setAttribute ^js dropdown "style" new-style))))))
(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 (= :token (:type mdata))
(when token
[:ul {:class (stl/css :context-list)}
[:& token-pill-context-menu {:token-id (:token-id mdata)
:token-type-props (:token-type-props mdata)
:token-type (:token-type mdata)
:selected-shapes selected-shapes}]])]]))
[:& menu-tree {:submenu-offset @width
:token token
:selected-shapes selected-shapes}]])]]))

View file

@ -8,8 +8,6 @@
.token-context-menu {
position: absolute;
top: $s-40;
left: $s-736;
z-index: $z-index-4;
}
@ -38,13 +36,14 @@
}
.separator {
height: $s-12;
@include bodySmallTypography;
margin: $s-6;
border-block-start: $s-1 solid var(--panel-border-color);
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
height: $s-28;
width: 100%;
padding: $s-6;
@ -52,27 +51,34 @@
cursor: pointer;
.title {
flex-grow: 1;
@include bodySmallTypography;
color: var(--menu-foreground-color);
margin-left: calc(($s-32 + $s-28) / 2);
}
.shortcut {
@include flexCenter;
gap: $s-2;
color: var(--menu-shortcut-foreground-color);
.shortcut-key {
@include bodySmallTypography;
@include flexCenter;
height: $s-20;
padding: $s-2 $s-6;
border-radius: $br-6;
background-color: var(--menu-shortcut-background-color);
.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 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 {
@ -84,60 +90,14 @@
color: var(--menu-shortcut-foreground-color-hover);
}
}
&:focus {
border: 1px solid var(--menu-border-color-focus);
background-color: var(--menu-background-color-focus);
}
}
.icon-menu-item {
display: flex;
justify-content: flex-start;
align-items: center;
height: $s-28;
padding: $s-6;
border-radius: $br-8;
&:hover {
background-color: var(--menu-background-color-hover);
}
span.title {
margin-left: $s-6;
}
.selected-icon {
svg {
@extend .button-icon-small;
stroke: var(--menu-foreground-color);
}
}
.shape-icon {
margin-left: $s-2;
svg {
@extend .button-icon-small;
stroke: var(--menu-foreground-color);
}
}
.icon-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
margin: 0;
&[disabled] {
pointer-events: none;
opacity: 0.6;
}
}
.icon-menu-item[disabled],
.context-menu-item[disabled] {
pointer-events: none;
opacity: 0.6;
}
// TODO: Allow selected items wihtout an icon for the context menu
.empty-icon {
width: 0;
height: 0;
}
.hidden-icon {
width: 11px;
}

View file

@ -6,29 +6,16 @@
(ns app.main.ui.workspace.tokens.core
(:require
[app.common.data :as d :refer [ordered-map]]
[app.common.types.shape.radius :as ctsr]
[app.common.types.token :as ctt]
[app.main.data.tokens :as dt]
[app.main.data.workspace :as udw]
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.common.data :as d]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.token :as wtt]
[app.util.dom :as dom]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[promesa.core :as p]))
[cuerdas.core :as str]))
;; Helpers ---------------------------------------------------------------------
(defn resolve-token-value [{:keys [value resolved-value] :as token}]
(defn resolve-token-value [{:keys [value resolved-value] :as _token}]
(or
resolved-value
(d/parse-double value)))
@ -48,128 +35,6 @@
(cond-> (assoc item :label name)
(wtt/token-applied? item shape (or selected-attributes attributes)) (assoc :selected? true))))))
;; Shape Update Functions ------------------------------------------------------
(defn update-shape-radius [value shape-ids]
(dch/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-shape-dimensions [value shape-ids]
(ptk/reify ::update-shape-dimensions
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(dwt/update-dimensions shape-ids :width value)
(dwt/update-dimensions shape-ids :height value)))))
(defn update-opacity [value shape-ids]
(dch/update-shapes shape-ids #(assoc % :opacity value)))
(defn update-stroke-width
[value shape-ids]
(dch/update-shapes shape-ids (fn [shape]
(if (seq (:strokes shape))
(assoc-in shape [:strokes 0 :stroke-width] value)
shape))))
(defn update-rotation [value shape-ids]
(ptk/reify ::update-shape-dimensions
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(udw/trigger-bounding-box-cloaking shape-ids)
(udw/increase-rotation shape-ids value)))))
(defn update-layout-spacing-column [value shape-ids]
(ptk/reify ::update-layout-spacing-column
ptk/WatchEvent
(watch [_ state _]
(rx/concat
(for [shape-id shape-ids]
(let [shape (dt/get-shape-from-state shape-id state)
layout-direction (:layout-flex-dir shape)
layout-update (if (or (= layout-direction :row-reverse) (= layout-direction :row))
{:layout-gap {:column-gap value}}
{:layout-gap {:row-gap value}})]
(dwsl/update-layout [shape-id] layout-update)))))))
;; Events ----------------------------------------------------------------------
(defn apply-token
[{:keys [attributes shape-ids token on-update-shape] :as _props}]
(ptk/reify ::apply-token
ptk/WatchEvent
(watch [_ state _]
(->> (rx/from (sd/resolve-tokens+ (get-in state [:workspace-data :tokens])))
(rx/mapcat
(fn [sd-tokens]
(let [undo-id (js/Symbol)
resolved-value (-> (get sd-tokens (:id token))
(resolve-token-value))
tokenized-attributes (wtt/attributes-map attributes (:id token))]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/update-shapes shape-ids (fn [shape]
(update shape :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-id attributes (:id token) %))]
(dch/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 on-update-shape]} token-type-props
unapply-tokens? (wtt/shapes-token-applied? token shapes (:attributes token-type-props))
shape-ids (map :id shapes)]
(if unapply-tokens?
(rx/of
(unapply-token {: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})))))))
(defn on-apply-token [{:keys [token token-type-props selected-shapes] :as _props}]
(let [{:keys [attributes on-apply on-update-shape]
:or {on-apply dt/update-token-from-attributes}} token-type-props
shape-ids (->> selected-shapes
(eduction
(remove #(wtt/shapes-token-applied? token % attributes))
(map :id)))]
(p/let [sd-tokens (sd/resolve-workspace-tokens+ {:debug? true})]
(let [resolved-token (get sd-tokens (:id token))
resolved-token-value (resolve-token-value resolved-token)]
(doseq [shape selected-shapes]
(st/emit! (on-apply {:token-id (:id token)
:shape-id (:id shape)
:attributes attributes}))
(on-update-shape resolved-token-value shape-ids attributes))))))
;; JSON export functions -------------------------------------------------------
(defn encode-tokens
@ -196,104 +61,3 @@
(let [all-tokens (deref refs/workspace-tokens)
transformed-tokens-json (transform-tokens-into-json-format all-tokens)]
(export-tokens-file transformed-tokens-json)))
;; Token types -----------------------------------------------------------------
(def token-types
(ordered-map
[:border-radius
{:title "Border Radius"
:attributes ctt/border-radius-keys
:on-update-shape update-shape-radius
:modal {:key :tokens/border-radius
:fields [{:label "Border Radius"
:key :border-radius}]}}]
[:stroke-width
{:title "Stroke Width"
:attributes ctt/stroke-width-keys
:on-update-shape update-stroke-width
:modal {:key :tokens/stroke-width
:fields [{:label "Stroke Width"
:key :stroke-width}]}}]
[:sizing
{:title "Sizing"
:attributes #{:width :height}
:on-update-shape update-shape-dimensions
:modal {:key :tokens/sizing
:fields [{:label "Sizing"
:key :sizing}]}}]
[:dimensions
{:title "Dimensions"
:attributes #{:width :height}
:on-update-shape update-shape-dimensions
:modal {:key :tokens/dimensions
:fields [{:label "Dimensions"
:key :dimensions}]}}]
[:opacity
{:title "Opacity"
:attributes ctt/opacity-keys
:on-update-shape update-opacity
:modal {:key :tokens/opacity
:fields [{:label "Opacity"
:key :opacity}]}}]
[:rotation
{:title "Rotation"
:attributes ctt/rotation-keys
:on-update-shape update-rotation
:modal {:key :tokens/rotation
:fields [{:label "Rotation"
:key :rotation}]}}]
[:spacing
{:title "Spacing"
:attributes ctt/spacing-keys
:on-update-shape update-layout-spacing-column
:modal {:key :tokens/spacing
:fields [{:label "Spacing"
:key :spacing}]}}]
(comment
[:boolean
{:title "Boolean"
:modal {:key :tokens/boolean
:fields [{:label "Boolean"}]}}]
[:box-shadow
{:title "Box Shadow"
:modal {:key :tokens/box-shadow
:fields [{:label "Box shadows"
:key :box-shadow
:type :box-shadow}]}}]
[:numeric
{:title "Numeric"
:modal {:key :tokens/numeric
:fields [{:label "Numeric"
:key :numeric}]}}]
[:other
{:title "Other"
:modal {:key :tokens/other
:fields [{:label "Other"
:key :other}]}}]
[:string
{:title "String"
:modal {:key :tokens/string
:fields [{:label "String"
:key :string}]}}]
[:typography
{:title "Typography"
:modal {:key :tokens/typography
:fields [{:label "Font" :key :font-family}
{:label "Weight" :key :weight}
{:label "Font Size" :key :font-size}
{:label "Line Height" :key :line-height}
{:label "Letter Spacing" :key :letter-spacing}
{:label "Paragraph Spacing" :key :paragraph-spacing}
{:label "Paragraph Indent" :key :paragraph-indent}
{:label "Text Decoration" :key :text-decoration}
{:label "Text Case" :key :text-case}]}}])))
(defn token-attributes [token-type]
(get-in token-types [token-type :attributes]))

View file

@ -14,9 +14,12 @@
[app.main.store :as st]
[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.core :as wtc]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.main.ui.workspace.tokens.token :as wtt]
[app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[cuerdas.core :as str]
[okulary.core :as l]
@ -70,7 +73,7 @@
[{:keys [type tokens selected-shapes token-type-props]}]
(let [open? (mf/deref (-> (l/key type)
(l/derived lens:token-type-open-status)))
{:keys [modal attributes title]} token-type-props
{:keys [modal attributes all-attributes title]} token-type-props
on-context-menu (mf/use-fn
(fn [event token]
@ -78,8 +81,6 @@
(dom/stop-propagation event)
(st/emit! (dt/show-token-context-menu {:type :token
:position (dom/get-client-position event)
:token-type-props token-type-props
:token-type type
:token-id (:id token)}))))
on-toggle-open-click (mf/use-fn
@ -99,10 +100,11 @@
(mf/deps selected-shapes token-type-props)
(fn [event token]
(dom/stop-propagation event)
(st/emit!
(wtc/toggle-token {:token token
:shapes selected-shapes
:token-type-props token-type-props}))))
(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 [_]
@ -123,7 +125,7 @@
[:& token-pill
{:key (:id token)
:token token
:highlighted? (wtt/shapes-token-applied? token selected-shapes attributes)
: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)}])]])]]))
@ -132,7 +134,7 @@
Sort each group alphabetically (by their `:token-key`)."
[tokens]
(let [tokens-by-type (wtc/group-tokens-by-type tokens)
{:keys [empty filled]} (->> wtc/token-types
{:keys [empty filled]} (->> wtty/token-types
(map (fn [[token-key token-type-props]]
{:token-key token-key
:token-type-props token-type-props
@ -154,6 +156,7 @@
token-groups (mf/with-memo [tokens]
(sorted-token-groups tokens))]
[:article
[:& token-context-menu]
[:div.assets-bar
(for [{:keys [token-key token-type-props tokens]} (concat (:filled token-groups)
(:empty token-groups))]

View file

@ -1,7 +1,14 @@
(ns app.main.ui.workspace.tokens.token
(:require
[app.common.data :as d]
[clojure.set :as set]
[cuerdas.core :as str]))
(defn resolve-token-value [{:keys [value resolved-value] :as _token}]
(or
resolved-value
(d/parse-double value)))
(defn attributes-map
"Creats an attributes map using collection of `attributes` for `id`."
[attributes id]
@ -18,20 +25,64 @@
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])]
(= (:id token) id)))
(defn token-applied?
"Test if `token` is applied to a `shape` with the given `token-attributes`."
"Test if `token` is applied to a `shape` with at least one of the one of the given `token-attributes`."
[token shape token-attributes]
(let [{:keys [id]} token
applied-tokens (get shape :applied-tokens {})]
(some (fn [attr]
(= (get applied-tokens attr) id))
token-attributes)))
(some #(token-attribute-applied? token shape %) token-attributes))
(defn token-applied-attributes
"Return a set of which `token-attributes` are applied with `token`."
[token shape token-attributes]
(-> (filter #(token-attribute-applied? token shape %) token-attributes)
(set)))
(defn shapes-token-applied?
"Test if `token` is applied to to any of `shapes` with the given `token-attributes`."
"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-token-applied-all?
"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 group-shapes-by-all-applied
[token shapes token-attributes]
(reduce
(fn [acc cur-shape]
(let [applied-attrs (token-applied-attributes token cur-shape token-attributes)]
(cond
(empty? applied-attrs) (update acc :none (fnil conj []) cur-shape)
(= applied-attrs token-attributes) (update acc :all (fnil conj []) cur-shape)
:else (reduce (fn [acc' cur']
(update-in acc' [:some cur'] (fnil conj []) cur-shape))
acc applied-attrs))))
{} shapes))
(defn group-shapes-by-all-applied-all? [grouped-shapes]
(and (seq (:all grouped-shapes))
(empty? (:other grouped-shapes))
(empty? (:some grouped-shapes))))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.

View file

@ -0,0 +1,120 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; 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}]}}]
[: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 ctt/spacing-keys
:on-update-shape wtch/update-layout-spacing
:modal {:key :tokens/spacing
:fields [{:label "Spacing"
:key :spacing}]}}]
(comment
[:boolean
{:title "Boolean"
:modal {:key :tokens/boolean
:fields [{:label "Boolean"}]}}]
[:box-shadow
{:title "Box Shadow"
:modal {:key :tokens/box-shadow
:fields [{:label "Box shadows"
:key :box-shadow
:type :box-shadow}]}}]
[:numeric
{:title "Numeric"
:modal {:key :tokens/numeric
:fields [{:label "Numeric"
:key :numeric}]}}]
[:other
{:title "Other"
:modal {:key :tokens/other
:fields [{:label "Other"
:key :other}]}}]
[:string
{:title "String"
:modal {:key :tokens/string
:fields [{:label "String"
:key :string}]}}]
[:typography
{:title "Typography"
:modal {:key :tokens/typography
:fields [{:label "Font" :key :font-family}
{:label "Weight" :key :weight}
{:label "Font Size" :key :font-size}
{:label "Line Height" :key :line-height}
{:label "Letter Spacing" :key :letter-spacing}
{:label "Paragraph Spacing" :key :paragraph-spacing}
{:label "Paragraph Indent" :key :paragraph-indent}
{:label "Text Decoration" :key :text-decoration}
{:label "Text Case" :key :text-case}]}}])))
(defn get-token-properties [token]
(get token-types (:type token)))
(defn token-attributes [token-type]
(get-in token-types [token-type :attributes]))

View file

@ -3,6 +3,7 @@
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.main.ui.workspace.tokens.changes :as wtch]
[app.main.ui.workspace.tokens.core :as wtc]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.pages :as thp]
@ -27,155 +28,189 @@
:type :border-radius})))
(t/deftest test-apply-token
(t/testing "applying a token twice with the same attributes will override")
(t/async
done
(let [file (setup-file)
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-1)
:on-update-shape wtc/update-shape-radius})
(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-2)
:on-update-shape wtc/update-shape-radius})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-2' (toht/get-token file' :token-2)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rx (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:ry (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:rx rect-1') 24))
(t/is (= (:ry rect-1') 24))))))))
(t/testing "applying a token twice with the same attributes will override the previous applied token"
(t/async
done
(let [file (setup-file)
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-1)
:on-update-shape wtch/update-shape-radius-all})
(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-2)
:on-update-shape wtch/update-shape-radius-all})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-2' (toht/get-token file' :token-2)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rx (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:ry (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:rx rect-1') 24))
(t/is (= (:ry rect-1') 24)))))))))
(t/deftest test-apply-token-overwrite
(t/testing "removes old token attributes and applies only single attribute"
(t/async
done
(let [file (setup-file)
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [;; Apply `:token-1` to all border radius attributes
(wtch/apply-token {:attributes #{:rx :ry :r1 :r2 :r3 :r4}
:token (toht/get-token file :token-1)
:shape-ids [(:id rect-1)]
:on-update-shape wtch/update-shape-radius-all})
;; Apply single `:r1` attribute to same shape
;; while removing other attributes from the border-radius set
;; but keep `:r4` for testing purposes
(wtch/apply-token {:attributes #{:r1}
:attributes-to-remove #{:rx :ry :r1 :r2 :r3}
:token (toht/get-token file :token-2)
:shape-ids [(:id rect-1)]
:on-update-shape wtch/update-shape-radius-all})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-1' (toht/get-token file' :token-1)
token-2' (toht/get-token file' :token-2)
rect-1' (cths/get-shape file' :rect-1)]
(t/testing "other border-radius attributes got removed"
(t/is (nil? (:rx (:applied-tokens rect-1')))))
(t/testing "r1 got applied with :token-2"
(t/is (= (:r1 (:applied-tokens rect-1')) (:id token-2'))))
(t/testing "while :r4 was kept"
(t/is (= (:r4 (:applied-tokens rect-1')) (:id token-1')))))))))));)))))))))))
(t/deftest test-apply-border-radius
(t/testing "applies radius token and updates the shapes radius")
(t/async
done
(let [file (setup-file)
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-2)
:on-update-shape wtc/update-shape-radius})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-2' (toht/get-token file' :token-2)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rx (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:ry (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:rx rect-1') 24))
(t/is (= (:ry rect-1') 24))))))))
(t/testing "applies radius token and updates the shapes radius"
(t/async
done
(let [file (setup-file)
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rx :ry}
:token (toht/get-token file :token-2)
:on-update-shape wtch/update-shape-radius-all})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-2' (toht/get-token file' :token-2)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rx (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:ry (:applied-tokens rect-1')) (:id token-2')))
(t/is (= (:rx rect-1') 24))
(t/is (= (:ry rect-1') 24)))))))))
(t/deftest test-apply-dimensions
(t/testing "applies dimensions token and updates the shapes width and height")
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "100"
:name "dimensions.sm"
:type :dimensions}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file :token-target)
:on-update-shape wtc/update-shape-dimensions})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:width (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:height (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:width rect-1') 100))
(t/is (= (:height rect-1') 100))))))))
(t/testing "applies dimensions token and updates the shapes width and height"
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "100"
:name "dimensions.sm"
:type :dimensions}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file :token-target)
:on-update-shape wtch/update-shape-dimensions})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:width (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:height (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:width rect-1') 100))
(t/is (= (:height rect-1') 100)))))))))
(t/deftest test-apply-sizing
(t/testing "applies sizing token and updates the shapes width and height")
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "100"
:name "sizing.sm"
:type :sizing}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file :token-target)
:on-update-shape wtc/update-shape-dimensions})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:width (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:height (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:width rect-1') 100))
(t/is (= (:height rect-1') 100))))))))
(t/testing "applies sizing token and updates the shapes width and height"
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "100"
:name "sizing.sm"
:type :sizing}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:width :height}
:token (toht/get-token file :token-target)
:on-update-shape wtch/update-shape-dimensions})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:width (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:height (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:width rect-1') 100))
(t/is (= (:height rect-1') 100)))))))))
(t/deftest test-apply-opacity
(t/testing "applies opacity token and updates the shapes opacity")
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "0.5"
:name "opacity.medium"
:type :opacity}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:opacity}
:token (toht/get-token file :token-target)
:on-update-shape wtc/update-opacity})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:opacity (:applied-tokens rect-1')) (:id token-target')))
;; TODO Fix opacity shape update not working?
#_(t/is (= (:opacity rect-1') 0.5))))))))
(t/testing "applies opacity token and updates the shapes opacity"
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "0.5"
:name "opacity.medium"
:type :opacity}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:opacity}
:token (toht/get-token file :token-target)
:on-update-shape wtch/update-opacity})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:opacity (:applied-tokens rect-1')) (:id token-target')))
;; TODO Fix opacity shape update not working?
#_(t/is (= (:opacity rect-1') 0.5)))))))))
(t/deftest test-apply-rotation
(t/testing "applies rotation token and updates the shapes rotation")
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "120"
:name "rotation.medium"
:type :rotation}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtc/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rotation}
:token (toht/get-token file :token-target)
:on-update-shape wtc/update-rotation})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rotation (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:rotation rect-1') 120))))))))
(t/testing "applies rotation token and updates the shapes rotation"
(t/async
done
(let [file (-> (setup-file)
(toht/add-token :token-target {:value "120"
:name "rotation.medium"
:type :rotation}))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(wtch/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:rotation}
:token (toht/get-token file :token-target)
:on-update-shape wtch/update-rotation})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-store new-state)
token-target' (toht/get-token file' :token-target)
rect-1' (cths/get-shape file' :rect-1)]
(t/is (some? (:applied-tokens rect-1')))
(t/is (= (:rotation (:applied-tokens rect-1')) (:id token-target')))
(t/is (= (:rotation rect-1') 120)))))))))
(t/deftest test-toggle-token-none
(t/testing "should apply token to all selected items, where no item has the token applied"
@ -185,10 +220,10 @@
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
rect-2 (cths/get-shape file :rect-2)
events [(wtc/toggle-token {:shapes [rect-1 rect-2]
:token-type-props {:attributes #{:rx :ry}
:on-update-shape wtc/update-shape-radius}
:token (toht/get-token file :token-2)})]]
events [(wtch/toggle-token {:shapes [rect-1 rect-2]
:token-type-props {:attributes #{:rx :ry}
:on-update-shape wtch/update-shape-radius-all}
:token (toht/get-token file :token-2)})]]
(tohs/run-store-async
store done events
(fn [new-state]
@ -218,9 +253,9 @@
rect-without-token (cths/get-shape file :rect-2)
rect-with-other-token (cths/get-shape file :rect-3)
events [(wtc/toggle-token {:shapes [rect-with-token rect-without-token rect-with-other-token]
:token (toht/get-token file :token-1)
:token-type-props {:attributes #{:rx :ry}}})]]
events [(wtch/toggle-token {:shapes [rect-with-token rect-without-token rect-with-other-token]
:token (toht/get-token file :token-1)
:token-type-props {:attributes #{:rx :ry}}})]]
(tohs/run-store-async
store done events
(fn [new-state]
@ -252,9 +287,9 @@
rect-without-token (cths/get-shape file :rect-2)
rect-with-other-token-2 (cths/get-shape file :rect-3)
events [(wtc/toggle-token {:shapes [rect-with-other-token-1 rect-without-token rect-with-other-token-2]
:token (toht/get-token file :token-1)
:token-type-props {:attributes #{:rx :ry}}})]]
events [(wtch/toggle-token {:shapes [rect-with-other-token-1 rect-without-token rect-with-other-token-2]
:token (toht/get-token file :token-1)
:token-type-props {:attributes #{:rx :ry}}})]]
(tohs/run-store-async
store done events
(fn [new-state]

View file

@ -25,6 +25,40 @@
(t/testing "doesn't match passed `:token-attributes`"
(t/is (nil? (wtt/token-applied? {:id :a} {:applied-tokens {:x :a}} #{:y})))))
(t/deftest token-applied-attributes
(t/is (= #{:x} (wtt/token-applied-attributes {:id :a}
{:applied-tokens {:x :a :y :b}}
#{:x :missing}))))
(t/deftest shapes-ids-by-applied-attributes
(t/testing "Returns set of matched attributes that fit the applied token"
(let [attributes #{:x :y :z}
shape-applied-x {:id :shape-applied-x
:applied-tokens {:x 1}}
shape-applied-y {:id :shape-applied-y
:applied-tokens {:y 1}}
shape-applied-x-y {:id :shape-applied-x-y
:applied-tokens {:x 1 :y 1}}
shape-applied-none {:id :shape-applied-none
:applied-tokens {}}
shape-applied-all {:id :shape-applied-all
:applied-tokens {:x 1 :y 1 :z 1}}
ids-set (fn [& xs] (into #{} (map :id xs)))
shapes [shape-applied-x
shape-applied-y
shape-applied-x-y
shape-applied-all
shape-applied-none]
expected (wtt/shapes-ids-by-applied-attributes {:id 1} shapes attributes)]
(t/is (= (:x expected) (ids-set shape-applied-x
shape-applied-x-y
shape-applied-all)))
(t/is (= (:y expected) (ids-set shape-applied-y
shape-applied-x-y
shape-applied-all)))
(t/is (= (:z expected) (ids-set shape-applied-all)))
(t/is (true? (wtt/shapes-applied-all? expected (ids-set shape-applied-all) attributes)))
(t/is (false? (wtt/shapes-applied-all? expected (apply ids-set shapes) attributes))))))
(t/deftest tokens-applied-test
(t/testing "is true when single shape matches the token and attributes"
(t/is (true? (wtt/shapes-token-applied? {:id :a} [{:applied-tokens {:x :a}}