mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +00:00
✨ Apply tokens to stroke per side (#10913)
* ♻️ Derive per-side stroke widths from the side values The per-stroke `:stroke-per-side` boolean only gated whether the renderer looked at the four side widths, and the CSS generator used it to decide whether to emit one `border-width` or four. Comparing the sides is enough, so drop the attribute from the shape schema and from the stroke attribute list. The WASM property and upload bridges and `stroke-per-side-widths` now derive the per-side widths from the values alone. AI-assisted-by: deepseek-v4.1-flash * ✨ Declare per-side stroke width token attributes Replace the single `:stroke-width` token attribute with `:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom` and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map the new attributes to the strokes shape attribute and to the dimensions token type. AI-assisted-by: deepseek-v4.1-flash * 🐛 Unapply only the token of the changed stroke side A stroke change that reports a single per-side sub-attribute now resolves to that side's token only. A plain `:stroke-width` change still resolves to every side, and a change with no sub-attribute resolves to all width keys plus the color. Add a regression test that tokens on untouched sides survive a change to another side. AI-assisted-by: deepseek-v4.1-flash * ✨ Add a predicate for per-side stroke shapes Boards and rectangles support independent stroke widths per side. Expose `per-side-stroke-shape?` so callers can gate the per-side UI, and cover the supported and unsupported shape types with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add stroke side width materialization helper `materialize-stroke-side-widths` concretizes the four per-side width keys from a stroke: edited sides take the new value, the others keep their current width (0 when there is no stroke), and `:stroke-width` mirrors the top side for legacy consumers. This pulls the logic already duplicated in the token apply path into a shared helper, ready for the direct-edit path. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to every side `update-stroke-width` now writes the four per-side width keys, both when the shape already has a stroke and when it gets a new default one, so the applied-token bookkeeping matches the stroke attributes. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to a single side Add `update-stroke-width-side`, which changes only the sides named in `attributes` on the first stroke of each shape. The remaining sides keep their current width (0 when the shape had no stroke) and all side keys are materialized through the shared helper, so consumers never fall back to `:stroke-width`. Route the per-side token keys to the new function and update the apply, remap and component tests. AI-assisted-by: deepseek-v4.1-flash * ✨ Complete a partially applied per-side token on toggle When explicit attributes come from an input or a plugin call, toggle the token off only if it already covers every target attribute on every selected shape. A partial per-side application is completed instead of removed. The token pill keeps the previous any-attribute behavior. Add tests for both the completion and the full removal. AI-assisted-by: deepseek-v4.1-flash * ✨ Offer per-side stroke width actions in the token menu Boards and rectangles get a stroke width submenu with an all-sides action and one action per side; other shapes keep the single global action. The global action targets every per-side attribute so the design tab keeps showing the token on each side. Add the pill labels for the new attributes, the menu test, and the `workspace.tokens.stroke-width` string. AI-assisted-by: deepseek-v4.1-flash * ✨ Persist the per-side stroke preference Add `:stroke-per-side` to the user profile props schema and expose it through a derived `stroke-per-side` ref. The design tab will read the preference from here instead of a per-stroke attribute. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width helpers to the stroke menu Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which checks the feature flag and that a single board or rectangle, or a uniform multi-selection of them, is selected. Use it instead of the inline shape-type check and drop the old per-side toggle handler. Cover both helpers with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width token inputs to the design tab Turn the four side width fields into token inputs with detach actions and a `:multiple` mixed value when the sides differ. The per-side toggle now reads the persisted profile preference instead of a per-stroke attribute, so previous per-side edits survive. AI-assisted-by: deepseek-v4.1-flash * 🐛 Materialize stroke sides on direct width edit The stroke menu per-side handler only wrote the edited side key and, for the top side, the global `:stroke-width`. A stroke holding just `:stroke-width` made every consumer fall back to the global value, so editing one side changed all the others. Add a `change-stroke-side-width` event that materializes the four side keys through the shared helper and reports only the edited key as changed, so tokens on untouched sides are not unapplied. Route the menu handler through it. AI-assisted-by: deepseek-v4.1-flash * 🐛 Make stroke width fields non nullable Drop `:nillable` from the global and per-side stroke width inputs and use `:multiple` for the mixed state, so an empty field no longer represents a null width. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix the numeric-input props schema key The schema declared `:applied-token`, but the component body and every caller use `:applied-token-name`, so the prop was never validated. Rename the schema entry to match. AI-assisted-by: deepseek-v4.1-flash * ✨ Add token-disabled support to the numeric input The design-system numeric input accepts `:token-disabled` and `:token-tooltip`; the token button is disabled and shows the reason. Scope the disabled input style to `input:disabled` so a disabled token button no longer dims the whole field. Pass both props through the token wrapper. AI-assisted-by: deepseek-v4.1-flash * 🐛 Disable token controls below the first fill or stroke Design tokens only apply to the first fill or stroke of a shape. Add `tokens-allowed-position?` and mark the fill and stroke lists with `tokens-first-only`, so later entries disable their token controls and explain why. The colorpicker opens on the direct color tab and disables the token tab for those rows. Cover the helper with a test and add the new translation. AI-assisted-by: deepseek-v4.1-flash * ♻️ Refactor colorpicker style switcher to DS radio buttons Replace the legacy `components/radio-buttons` markup in the colorpicker with the design system `radio-buttons*`, using its declarative options API. Switching between direct color and token mode now passes string values, as the DS component expects. The previous keyword values broke the round trip back to color mode: the DOM stringifies keywords with a leading colon, so the value never matched `:direct-color`. Using plain strings keeps the conversion clean. AI-assisted-by: deepseek-v4.1-flash * ✨ Add playwright tests * ✨ Scope per-side stroke controls to each stroke Give every stroke row its own expanded state instead of sharing one profile-wide preference. The state lives in `:workspace-local`, keyed by `[ids index]`, so it survives selecting another shape and coming back but resets on reload. Remove the `:stroke-per-side` profile prop and its ref. The ref now derives from `:workspace-local`. Update the Playwright spec to expand the controls per stroke through the toggle, and assert that strokes toggle independently, that the state resets on reload, and that it survives switching shapes. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep stroke tokens when editing or removing later strokes The token unapply logic decided which tokens to clear from the shape using only the changed sub-attributes, without knowing which stroke was edited. Since stroke tokens only live on the first stroke, editing or removing a later stroke cleared the first stroke's tokens. Add a `:changed-item-index` option to `generate-update-shapes` and skip unapplying fill/stroke tokens when the changed item is not the first. The stroke color, attrs, side-width and remove events now report the index they touch. AI-assisted-by: deepseek-v4.1-flash * 🐛 Ignore token shortcuts when tokens are disabled for input The numeric input opened the token dropdown on `{` regardless of `token-disabled?`, so inputs that cannot hold tokens (for example, strokes after the first one) still opened it, and typing `{token}` plus `}` could apply a token there. Extract the key handling into `token-shortcut`, which returns nil when tokens are disabled, and use it for both `{` and `}`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Gate per-side stroke tokens on the WASM renderer The token context menu offered per-side stroke width actions whenever the feature flag was on and the shape was a board or rectangle, without checking the renderer. The classic renderer only draws the single `:stroke-width`, so applying a per-side token there wrote inert data, the token pill reported it, and the stroke changed appearance when the WASM renderer was later enabled. Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from both the design tab and the token context menu. Thread the renderer flag into the context menu through `:render-wasm`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep first-stroke tokens when reordering later strokes Fill and stroke tokens only ever live on the first item of the collection. When a stroke update arrives without a changed item index (for example reordering the second and third strokes), the unapply logic assumed the first item had been edited and removed every stroke token from the shape. Compare the first item before and after the update instead: when no item index is given, unapply only if the first item actually changed. Reordering later strokes now leaves the first stroke and its tokens untouched, while moving the first stroke away still detaches them. Explicit item edits keep their previous behavior. AI-assisted-by: deepseek-v4-flash
This commit is contained in:
parent
ec5a1edbed
commit
98daf1592d
@ -29,7 +29,7 @@
|
||||
"When updating attributes that have a token applied, we must unapply it, because the value
|
||||
of the attribute now has been given directly, and does not come from the token.
|
||||
When applying a typography asset style we also unapply any typographic tokens."
|
||||
[changes objects changed-sub-attr]
|
||||
[changes objects changed-sub-attr changed-item-index]
|
||||
(let [new-objects (pcb/get-objects changes)
|
||||
mod-obj-changes (->> (:redo-changes changes)
|
||||
(filter #(= (:type %) :mod-obj)))
|
||||
@ -53,9 +53,18 @@
|
||||
(fn [shape changes attr]
|
||||
(let [shape-id (dm/get-prop shape :id)
|
||||
tokens (get shape :applied-tokens {})
|
||||
token-attrs (if (and (cfh/text-shape? shape) (= attr :content))
|
||||
(text-changed-attrs shape)
|
||||
(cto/shape-attr->token-attrs attr changed-sub-attr))]
|
||||
;; Fill and stroke tokens only ever live on the first item of
|
||||
;; the collection, so editing a later item or reordering items
|
||||
;; while the first one stays in place must not unapply them.
|
||||
later-item? (and (contains? #{:fills :strokes} attr)
|
||||
(if (some? changed-item-index)
|
||||
(not (zero? changed-item-index))
|
||||
(= (first (get shape attr))
|
||||
(first (get (get new-objects shape-id) attr)))))
|
||||
token-attrs (when-not later-item?
|
||||
(if (and (cfh/text-shape? shape) (= attr :content))
|
||||
(text-changed-attrs shape)
|
||||
(cto/shape-attr->token-attrs attr changed-sub-attr)))]
|
||||
|
||||
(if (some #(contains? tokens %) token-attrs)
|
||||
(pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs))
|
||||
@ -75,7 +84,7 @@
|
||||
(reduce check-shape changes mod-obj-changes)))
|
||||
|
||||
(defn generate-update-shapes
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? skip-grid-reassignment?]}]
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr changed-item-index ignore-tree ignore-touched with-objects? translation? skip-grid-reassignment?]}]
|
||||
(let [changes (reduce
|
||||
(fn [changes id]
|
||||
(let [opts {:attrs attrs
|
||||
@ -96,7 +105,7 @@
|
||||
(pcb/reorder-grid-children ids))
|
||||
|
||||
(not ignore-touched)
|
||||
(generate-unapply-tokens objects changed-sub-attr))]
|
||||
(generate-unapply-tokens objects changed-sub-attr changed-item-index))]
|
||||
changes))
|
||||
|
||||
(defn- generate-update-shape-flags
|
||||
|
||||
@ -85,13 +85,12 @@
|
||||
;; Per-side widths (rects/frames). Sides falling back to the
|
||||
;; uniform width; skipped when all sides end up equal, so the
|
||||
;; renderer keeps the uniform path (with dash/dot support).
|
||||
(when (:stroke-per-side stroke)
|
||||
(let [top (or (:stroke-width-top stroke) width)
|
||||
right (or (:stroke-width-right stroke) width)
|
||||
bottom (or (:stroke-width-bottom stroke) width)
|
||||
left (or (:stroke-width-left stroke) width)]
|
||||
(when-not (= top right bottom left)
|
||||
(h/call wasm/internal-module "_set_shape_stroke_sides" top right bottom left))))
|
||||
(let [top (or (:stroke-width-top stroke) width)
|
||||
right (or (:stroke-width-right stroke) width)
|
||||
bottom (or (:stroke-width-bottom stroke) width)
|
||||
left (or (:stroke-width-left stroke) width)]
|
||||
(when-not (= top right bottom left)
|
||||
(h/call wasm/internal-module "_set_shape_stroke_sides" top right bottom left)))
|
||||
|
||||
(cond
|
||||
(some? gradient)
|
||||
|
||||
@ -248,12 +248,14 @@
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
per-side? (boolean (:stroke-per-side stroke))
|
||||
|
||||
top (or (:stroke-width-top stroke) width)
|
||||
right (or (:stroke-width-right stroke) width)
|
||||
bottom (or (:stroke-width-bottom stroke) width)
|
||||
left (or (:stroke-width-left stroke) width)
|
||||
has-sides? (and per-side? (not= top right bottom left))]
|
||||
|
||||
has-sides? (not= top right bottom left)]
|
||||
|
||||
(buf/write-f32 dview offset width)
|
||||
(buf/write-u8 dview (+ offset 4) style)
|
||||
(buf/write-u8 dview (+ offset 5) align)
|
||||
|
||||
@ -139,8 +139,6 @@
|
||||
[:stroke-style {:optional true}
|
||||
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
|
||||
[:stroke-width {:optional true} ::sm/non-negative-safe-number]
|
||||
;; wasm-render only, backwards compatible
|
||||
[:stroke-per-side {:optional true} :boolean]
|
||||
[:stroke-width-top {:optional true} ::sm/non-negative-safe-number]
|
||||
[:stroke-width-right {:optional true} ::sm/non-negative-safe-number]
|
||||
[:stroke-width-bottom {:optional true} ::sm/non-negative-safe-number]
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
|
||||
(ns app.common.types.stroke
|
||||
(:require
|
||||
[app.common.types.color :as clr]))
|
||||
[app.common.data :as d]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.token :as ctt]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SCHEMAS
|
||||
@ -20,4 +22,23 @@
|
||||
:stroke-style :solid
|
||||
:stroke-color clr/black
|
||||
:stroke-opacity 1
|
||||
:stroke-width 1})
|
||||
:stroke-width 1})
|
||||
|
||||
(defn materialize-stroke-side-widths
|
||||
"Given a stroke (or nil) and the set of edited side keys, returns the stroke
|
||||
with the four per-side width keys concretized: edited sides take `value`,
|
||||
the rest keep their current per-side width, falling back to `:stroke-width`
|
||||
(0 when `stroke` is nil). `:stroke-width` mirrors the top side so legacy
|
||||
consumers that only read it see the top value."
|
||||
[stroke edited-keys value]
|
||||
(let [base-width (or (:stroke-width stroke) 0)
|
||||
current (or stroke default-stroke)
|
||||
side-attrs (reduce
|
||||
(fn [acc side-key]
|
||||
(assoc acc side-key
|
||||
(if (contains? edited-keys side-key)
|
||||
value
|
||||
(d/nilv (get current side-key) base-width))))
|
||||
{}
|
||||
ctt/per-side-stroke-width-keys)]
|
||||
(merge current side-attrs {:stroke-width (get side-attrs :stroke-width-top)})))
|
||||
|
||||
@ -273,11 +273,18 @@
|
||||
(def spacing-keys (schema-keys schema:spacing))
|
||||
|
||||
(def ^:private schema:stroke-width
|
||||
[:map
|
||||
[:stroke-width {:optional true} schema:token-name]])
|
||||
[:map {:title "StrokeWidthTokenAttrs"}
|
||||
[:stroke-width-top {:optional true} schema:token-name]
|
||||
[:stroke-width-right {:optional true} schema:token-name]
|
||||
[:stroke-width-bottom {:optional true} schema:token-name]
|
||||
[:stroke-width-left {:optional true} schema:token-name]])
|
||||
|
||||
(def stroke-width-keys (schema-keys schema:stroke-width))
|
||||
|
||||
(def per-side-stroke-width-keys
|
||||
"Per-side stroke width attribute keys."
|
||||
#{:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left})
|
||||
|
||||
(def ^:private schema:dimensions
|
||||
(-> (reduce mu/union [schema:sizing
|
||||
schema:spacing
|
||||
@ -425,6 +432,10 @@
|
||||
:fill :fills
|
||||
:stroke-color :strokes
|
||||
:stroke-width :strokes
|
||||
:stroke-width-top :strokes
|
||||
:stroke-width-right :strokes
|
||||
:stroke-width-bottom :strokes
|
||||
:stroke-width-left :strokes
|
||||
token-attr))
|
||||
|
||||
(defn- shape-attr->token-attrs*
|
||||
@ -435,12 +446,17 @@
|
||||
#{:fill}
|
||||
|
||||
(and (= :strokes shape-attr) (nil? changed-sub-attr))
|
||||
#{:stroke-width :stroke-color}
|
||||
(set/union stroke-width-keys #{:stroke-color})
|
||||
|
||||
(= :strokes shape-attr)
|
||||
(cond
|
||||
(some #{:stroke-color} changed-sub-attr) #{:stroke-color}
|
||||
(some #{:stroke-width} changed-sub-attr) #{:stroke-width})
|
||||
(let [sub-attrs (set changed-sub-attr)
|
||||
per-side (set/intersection sub-attrs stroke-width-keys)]
|
||||
(cond
|
||||
(sub-attrs :stroke-color) #{:stroke-color}
|
||||
;; A single side change must only unapply that side's token, even
|
||||
;; when the top side also writes the global :stroke-width.
|
||||
(seq per-side) per-side
|
||||
(sub-attrs :stroke-width) stroke-width-keys))
|
||||
|
||||
(= :layout-padding shape-attr)
|
||||
(if (seq changed-sub-attr)
|
||||
@ -461,6 +477,7 @@
|
||||
(font-weight-keys shape-attr) #{shape-attr :typography}
|
||||
|
||||
(border-radius-keys shape-attr) #{shape-attr}
|
||||
(stroke-width-keys shape-attr) #{shape-attr}
|
||||
(shadow-keys shape-attr) #{shape-attr}
|
||||
(sizing-keys shape-attr) #{shape-attr}
|
||||
(opacity-keys shape-attr) #{shape-attr}
|
||||
@ -528,6 +545,12 @@
|
||||
:text text-attributes
|
||||
nil))
|
||||
|
||||
(defn per-side-stroke-shape?
|
||||
"Returns true when the given shape type supports independent stroke
|
||||
widths per side (boards and rectangles)."
|
||||
[shape-type]
|
||||
(contains? #{:rect :frame} shape-type))
|
||||
|
||||
(defn appliable-attrs-for-shape
|
||||
"Returns which ones of the given `attributes` can be applied to a shape
|
||||
of type `shape-type` and `is-layout`."
|
||||
@ -570,6 +593,10 @@
|
||||
:line-height [:line-height :number]
|
||||
:opacity [:opacity]
|
||||
:stroke-width [:stroke-width :dimensions]
|
||||
:stroke-width-top [:stroke-width :dimensions]
|
||||
:stroke-width-right [:stroke-width :dimensions]
|
||||
:stroke-width-bottom [:stroke-width :dimensions]
|
||||
:stroke-width-left [:stroke-width :dimensions]
|
||||
:font-size [:font-size]
|
||||
:font-weight [:font-weight]
|
||||
:text-decoration [:text-decoration]
|
||||
|
||||
@ -110,7 +110,7 @@
|
||||
(tht/apply-token-to-shape :frame1 "token-radius" [:r1 :r2 :r3 :r4] [:r1 :r2 :r3 :r4] 10)
|
||||
(tht/apply-token-to-shape :frame1 "token-rotation" [:rotation] [:rotation] 30)
|
||||
(tht/apply-token-to-shape :frame1 "token-opacity" [:opacity] [:opacity] 0.7)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width] [:stroke-width] 2)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width" cto/per-side-stroke-width-keys [:stroke-width] 2)
|
||||
(tht/apply-token-to-shape :frame1 "token-color" [:stroke-color] [:stroke-color] "#00ff00")
|
||||
(tht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00")
|
||||
(tht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100)
|
||||
@ -261,7 +261,7 @@
|
||||
(cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4])
|
||||
(cto/unapply-tokens-from-shape [:rotation])
|
||||
(cto/unapply-tokens-from-shape [:opacity])
|
||||
(cto/unapply-tokens-from-shape [:stroke-width])
|
||||
(cto/unapply-tokens-from-shape cto/per-side-stroke-width-keys)
|
||||
(cto/unapply-tokens-from-shape [:stroke-color])
|
||||
(cto/unapply-tokens-from-shape [:fill])
|
||||
(cto/unapply-tokens-from-shape [:width :height])))
|
||||
@ -364,6 +364,174 @@
|
||||
(t/is (= (count applied-tokens-text') 0))
|
||||
(t/is (= (count applied-tokens-circle') 0))))
|
||||
|
||||
(t/deftest unapply-only-changed-per-side-token
|
||||
(let [file (-> (setup-file)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width"
|
||||
[:stroke-width-right] [:stroke-width] 2))
|
||||
page (thf/current-page file)
|
||||
frame1 (ths/get-shape file :frame1)
|
||||
|
||||
changes (-> (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
(cls/generate-update-shapes [(:id frame1)]
|
||||
(fn [shape]
|
||||
(ctn/set-shape-attr
|
||||
shape :strokes
|
||||
(assoc-in (:strokes shape) [0 :stroke-width-top] 5)))
|
||||
(:objects page)
|
||||
{:changed-sub-attr [:stroke-width-top :stroke-width]}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
frame1' (ths/get-shape file' :frame1)]
|
||||
|
||||
(t/testing "a token on an untouched side is preserved"
|
||||
(t/is (= (:stroke-width-right (:applied-tokens frame1')) "token-stroke-width")))
|
||||
|
||||
(t/testing "the other sides have no token"
|
||||
(t/is (nil? (:stroke-width-top (:applied-tokens frame1'))))
|
||||
(t/is (nil? (:stroke-width-bottom (:applied-tokens frame1'))))
|
||||
(t/is (nil? (:stroke-width-left (:applied-tokens frame1')))))))
|
||||
|
||||
(t/deftest unapply-not-triggered-when-editing-a-later-stroke
|
||||
(let [;; Apply a token to the first stroke, then add a second one.
|
||||
file (-> (setup-file)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width"
|
||||
[:stroke-width-top] [:stroke-width] 2))
|
||||
strokes (:strokes (ths/get-shape file :frame1))
|
||||
file (ths/update-shape file :frame1 :strokes
|
||||
(conj strokes {:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}))
|
||||
page (thf/current-page file)
|
||||
frame1 (ths/get-shape file :frame1)
|
||||
|
||||
changes (-> (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
(cls/generate-update-shapes [(:id frame1)]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(ctn/set-shape-attr :strokes
|
||||
(-> (:strokes shape)
|
||||
(assoc-in [1 :stroke-width-top] 7)
|
||||
(assoc-in [1 :stroke-width] 7)))))
|
||||
(:objects page)
|
||||
{:changed-sub-attr [:stroke-width-top :stroke-width]
|
||||
:changed-item-index 1}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
frame1' (ths/get-shape file' :frame1)]
|
||||
|
||||
(t/testing "the token on the first stroke is preserved"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens frame1')) "token-stroke-width")))
|
||||
|
||||
(t/testing "the edited second stroke gets the new value"
|
||||
(t/is (= 7 (get-in frame1' [:strokes 1 :stroke-width-top]))))
|
||||
|
||||
(t/testing "the first stroke is untouched"
|
||||
(t/is (= 2 (get-in frame1' [:strokes 0 :stroke-width]))))))
|
||||
|
||||
(t/deftest unapply-still-triggered-when-editing-the-first-stroke
|
||||
(let [file (-> (setup-file)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width"
|
||||
[:stroke-width-top] [:stroke-width] 2))
|
||||
page (thf/current-page file)
|
||||
frame1 (ths/get-shape file :frame1)
|
||||
|
||||
changes (-> (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
(cls/generate-update-shapes [(:id frame1)]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(ctn/set-shape-attr :strokes
|
||||
(assoc-in (:strokes shape) [0 :stroke-width-top] 5))))
|
||||
(:objects page)
|
||||
{:changed-sub-attr [:stroke-width-top :stroke-width]
|
||||
:changed-item-index 0}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
frame1' (ths/get-shape file' :frame1)]
|
||||
|
||||
(t/testing "editing the first stroke does unapply its token"
|
||||
(t/is (nil? (:stroke-width-top (:applied-tokens frame1')))))))
|
||||
|
||||
(t/deftest unapply-not-triggered-when-reordering-later-strokes
|
||||
(let [;; Apply a token to the first stroke, then add two more.
|
||||
file (-> (setup-file)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width"
|
||||
[:stroke-width-top] [:stroke-width] 2))
|
||||
strokes (:strokes (ths/get-shape file :frame1))
|
||||
file (ths/update-shape file :frame1 :strokes
|
||||
(into strokes
|
||||
[{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#111111"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}
|
||||
{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#222222"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 4}]))
|
||||
page (thf/current-page file)
|
||||
frame1 (ths/get-shape file :frame1)
|
||||
|
||||
changes (-> (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
(cls/generate-update-shapes [(:id frame1)]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(ctn/set-shape-attr :strokes
|
||||
(d/reorder (:strokes shape) 1 3))))
|
||||
(:objects page)
|
||||
{:attrs [:strokes]}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
frame1' (ths/get-shape file' :frame1)]
|
||||
|
||||
(t/testing "the token on the first stroke is preserved"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens frame1')) "token-stroke-width")))
|
||||
|
||||
(t/testing "the second and third strokes are swapped"
|
||||
(t/is (= "#222222" (get-in frame1' [:strokes 1 :stroke-color])))
|
||||
(t/is (= "#111111" (get-in frame1' [:strokes 2 :stroke-color]))))))
|
||||
|
||||
(t/deftest unapply-triggered-when-reordering-first-stroke
|
||||
(let [file (-> (setup-file)
|
||||
(tht/apply-token-to-shape :frame1 "token-stroke-width"
|
||||
[:stroke-width-top] [:stroke-width] 2))
|
||||
strokes (:strokes (ths/get-shape file :frame1))
|
||||
file (ths/update-shape file :frame1 :strokes
|
||||
(conj strokes {:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#111111"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}))
|
||||
page (thf/current-page file)
|
||||
frame1 (ths/get-shape file :frame1)
|
||||
|
||||
changes (-> (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects (:objects page)))
|
||||
(cls/generate-update-shapes [(:id frame1)]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(ctn/set-shape-attr :strokes
|
||||
(d/reorder (:strokes shape) 0 2))))
|
||||
(:objects page)
|
||||
{:attrs [:strokes]}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
frame1' (ths/get-shape file' :frame1)]
|
||||
|
||||
(t/testing "moving the first stroke away unapplies its token"
|
||||
(t/is (nil? (:stroke-width-top (:applied-tokens frame1')))))))
|
||||
|
||||
(t/deftest dont-unapply-automatic-when-null-change
|
||||
(let [;; ==== Setup
|
||||
file (-> (setup-file)
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
[common-tests.types.shape-decode-encode-test]
|
||||
[common-tests.types.shape-interactions-test]
|
||||
[common-tests.types.shape-layout-test]
|
||||
[common-tests.types.stroke-test]
|
||||
[common-tests.types.token-test]
|
||||
[common-tests.types.tokens-lib-test]
|
||||
[common-tests.types.tokens-status-test]
|
||||
@ -165,6 +166,7 @@
|
||||
'common-tests.types.shape-decode-encode-test
|
||||
'common-tests.types.shape-interactions-test
|
||||
'common-tests.types.shape-layout-test
|
||||
'common-tests.types.stroke-test
|
||||
'common-tests.types.token-test
|
||||
'common-tests.types.tokens-lib-test
|
||||
'common-tests.types.tokens-status-test
|
||||
|
||||
74
common/test/common_tests/types/stroke_test.cljc
Normal file
74
common/test/common_tests/types/stroke_test.cljc
Normal file
@ -0,0 +1,74 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns common-tests.types.stroke-test
|
||||
(:require
|
||||
[app.common.types.stroke :as cts]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; --- materialize-stroke-side-widths
|
||||
|
||||
(t/deftest materialize-with-nil-stroke
|
||||
(t/testing "a shape without stroke gets the default stroke with every side concretized"
|
||||
(let [stroke (cts/materialize-stroke-side-widths nil #{:stroke-width-top} 10)]
|
||||
(t/is (= (:stroke-width-top stroke) 10))
|
||||
(t/is (= (:stroke-width-right stroke) 0))
|
||||
(t/is (= (:stroke-width-bottom stroke) 0))
|
||||
(t/is (= (:stroke-width-left stroke) 0))
|
||||
(t/is (= (:stroke-width stroke) 10))
|
||||
(t/is (= (:stroke-style stroke) :solid))
|
||||
(t/is (= (:stroke-alignment stroke) :inner)))))
|
||||
|
||||
(t/deftest materialize-with-global-only-stroke
|
||||
(t/testing "editing the top side keeps the other sides on the global width"
|
||||
(let [stroke (cts/materialize-stroke-side-widths
|
||||
{:stroke-width 5 :stroke-color "#000000"}
|
||||
#{:stroke-width-top}
|
||||
10)]
|
||||
(t/is (= (:stroke-width-top stroke) 10))
|
||||
(t/is (= (:stroke-width-right stroke) 5))
|
||||
(t/is (= (:stroke-width-bottom stroke) 5))
|
||||
(t/is (= (:stroke-width-left stroke) 5))
|
||||
(t/is (= (:stroke-width stroke) 10))
|
||||
(t/is (= (:stroke-color stroke) "#000000"))))
|
||||
|
||||
(t/testing "editing a non-top side keeps the global width mirroring the top side"
|
||||
(let [stroke (cts/materialize-stroke-side-widths
|
||||
{:stroke-width 5}
|
||||
#{:stroke-width-right}
|
||||
10)]
|
||||
(t/is (= (:stroke-width-top stroke) 5))
|
||||
(t/is (= (:stroke-width-right stroke) 10))
|
||||
(t/is (= (:stroke-width-bottom stroke) 5))
|
||||
(t/is (= (:stroke-width-left stroke) 5))
|
||||
(t/is (= (:stroke-width stroke) 5)))))
|
||||
|
||||
(t/deftest materialize-keeps-explicit-sides
|
||||
(t/testing "sides with their own value keep it even when it differs from the global width"
|
||||
(let [stroke (cts/materialize-stroke-side-widths
|
||||
{:stroke-width 4
|
||||
:stroke-width-top 2
|
||||
:stroke-width-right 3
|
||||
:stroke-width-bottom 6}
|
||||
#{:stroke-width-left}
|
||||
7)]
|
||||
(t/is (= (:stroke-width-top stroke) 2))
|
||||
(t/is (= (:stroke-width-right stroke) 3))
|
||||
(t/is (= (:stroke-width-bottom stroke) 6))
|
||||
(t/is (= (:stroke-width-left stroke) 7))
|
||||
(t/is (= (:stroke-width stroke) 2)))))
|
||||
|
||||
(t/deftest materialize-multiple-sides
|
||||
(t/testing "several sides can be edited at once"
|
||||
(let [stroke (cts/materialize-stroke-side-widths
|
||||
{:stroke-width 5}
|
||||
#{:stroke-width-top :stroke-width-right}
|
||||
10)]
|
||||
(t/is (= (:stroke-width-top stroke) 10))
|
||||
(t/is (= (:stroke-width-right stroke) 10))
|
||||
(t/is (= (:stroke-width-bottom stroke) 5))
|
||||
(t/is (= (:stroke-width-left stroke) 5))
|
||||
(t/is (= (:stroke-width stroke) 10)))))
|
||||
@ -32,6 +32,19 @@
|
||||
(t/is (false? (sm/validate cto/schema:token-name "Hey / Foo/Bar"))))
|
||||
|
||||
|
||||
(t/deftest per-side-stroke-shape-test
|
||||
(t/testing "boards and rectangles support per-side stroke widths"
|
||||
(t/is (true? (cto/per-side-stroke-shape? :rect)))
|
||||
(t/is (true? (cto/per-side-stroke-shape? :frame))))
|
||||
|
||||
(t/testing "other shape types do not support per-side stroke widths"
|
||||
(t/is (false? (cto/per-side-stroke-shape? :circle)))
|
||||
(t/is (false? (cto/per-side-stroke-shape? :text)))
|
||||
(t/is (false? (cto/per-side-stroke-shape? :path)))
|
||||
(t/is (false? (cto/per-side-stroke-shape? :image)))
|
||||
(t/is (false? (cto/per-side-stroke-shape? :group)))
|
||||
(t/is (false? (cto/per-side-stroke-shape? nil)))))
|
||||
|
||||
(t/deftest token-value-with-refs
|
||||
(t/testing "empty value"
|
||||
(t/is (= (cto/insert-ref "" 0 "token1")
|
||||
|
||||
@ -2,6 +2,30 @@ import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
|
||||
/**
|
||||
* Sets up the workspace with a file that contains stroke width and dimensions
|
||||
* tokens, and the per-side stroke feature flag enabled.
|
||||
*
|
||||
* @param {import("@playwright/test").Page} page
|
||||
* @param {{flags?: string[]}} [options]
|
||||
*/
|
||||
const setupStrokePerSideFile = async (page, options = {}) => {
|
||||
const { flags = [] } = options;
|
||||
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await workspacePage.mockConfigFlags([...flags, "enable-stroke-per-side"]);
|
||||
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockGetFile(
|
||||
"workspace/get-file-layout-stroke-token-json",
|
||||
);
|
||||
|
||||
await workspacePage.goToWorkspace();
|
||||
await workspacePage.waitForFirstRender();
|
||||
|
||||
return workspacePage;
|
||||
};
|
||||
|
||||
const setupEmptyTokensFile = async (page, options = {}) => {
|
||||
const { flags = [] } = options;
|
||||
|
||||
@ -398,6 +422,7 @@ const createSet = async (sidebar, setName, finalKey = "Enter") => {
|
||||
export {
|
||||
setupEmptyTokensFile,
|
||||
setupEmptyTokensFileRender,
|
||||
setupStrokePerSideFile,
|
||||
setupTokensFile,
|
||||
setupTokensFileRender,
|
||||
setupTypographyTokensFile,
|
||||
|
||||
602
frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js
Normal file
602
frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js
Normal file
@ -0,0 +1,602 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { setupStrokePerSideFile, unfoldTokenType } from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
// Accessible names of the design tab controls, from the translations.
|
||||
const TOP = "Top (block start)";
|
||||
const RIGHT = "Right (inline end)";
|
||||
const BOTTOM = "Bottom (block end)";
|
||||
const LEFT = "Left (inline start)";
|
||||
const GLOBAL = "Stroke width";
|
||||
const PER_SIDE_TOGGLE_LABEL = "Stroke per side";
|
||||
const ONLY_FIRST =
|
||||
"Design tokens can only be applied to the first fill or stroke in the list.";
|
||||
const MIXED = "Mixed";
|
||||
|
||||
function strokeSection(page) {
|
||||
return page.getByTestId("right-sidebar").getByRole("region", {
|
||||
name: "Stroke section",
|
||||
});
|
||||
}
|
||||
|
||||
function strokeRow(page, index = 0) {
|
||||
return strokeSection(page).getByLabel(`stroke-row-${index}`);
|
||||
}
|
||||
|
||||
function perSideOptions(page) {
|
||||
return page.getByTestId("stroke.per-side-options");
|
||||
}
|
||||
|
||||
async function enablePerSide(page) {
|
||||
await page.getByTestId("stroke.per-side-toggle").click();
|
||||
await expect(perSideOptions(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function prepareShape(workspace, page) {
|
||||
await workspace.layers.getByTestId("layer-row").nth(0).click();
|
||||
await page.getByTestId("add-stroke").click();
|
||||
await expect(strokeSection(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function openTokensTab(page) {
|
||||
await page.getByRole("tab", { name: "Tokens" }).click();
|
||||
}
|
||||
|
||||
async function unfoldStrokeWidth(page, workspace) {
|
||||
await openTokensTab(page);
|
||||
await unfoldTokenType(workspace.tokensSidebar, "stroke width");
|
||||
}
|
||||
|
||||
test.describe("Tokens: stroke per side", () => {
|
||||
test("per-side toggle is hidden when the feature flag is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.mockConfigFlags(["enable-feature-token-input"]);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
|
||||
await expect(perSideOptions(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("per-side toggle is enabled for a board with the feature on", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
const toggle = page.getByTestId("stroke.per-side-toggle");
|
||||
await expect(toggle).toBeVisible();
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).toHaveAccessibleName(PER_SIDE_TOGGLE_LABEL);
|
||||
await expect(perSideOptions(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clicking the toggle expands and collapses the side inputs", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
const toggle = page.getByTestId("stroke.per-side-toggle");
|
||||
await toggle.click();
|
||||
|
||||
await expect(perSideOptions(page)).toBeVisible();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: LEFT }),
|
||||
).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(perSideOptions(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the per-side toggle is independent for each stroke", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
// The add button stays in the section header, so a second stroke is
|
||||
// one more click.
|
||||
await page.getByTestId("add-stroke").click();
|
||||
|
||||
const firstRow = strokeRow(page, 0);
|
||||
const secondRow = strokeRow(page, 1);
|
||||
await expect(firstRow).toBeVisible();
|
||||
await expect(secondRow).toBeVisible();
|
||||
|
||||
await firstRow.getByTestId("stroke.per-side-toggle").click();
|
||||
|
||||
await expect(firstRow.getByTestId("stroke.per-side-options")).toBeVisible();
|
||||
await expect(secondRow.getByTestId("stroke.per-side-options")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("the per-side state is not persisted across a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
await page.getByTestId("stroke.per-side-toggle").click();
|
||||
await expect(perSideOptions(page)).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await workspace.waitForFirstRender();
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
await expect(perSideOptions(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the per-side state survives switching shapes", async ({ page }) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
// Select another shape; it has no stroke, so nothing is expanded.
|
||||
await workspace.clickLeafLayer("Board");
|
||||
await expect(perSideOptions(page)).toHaveCount(0);
|
||||
|
||||
// Come back to the rectangle: its per-side state is still expanded.
|
||||
await workspace.clickLeafLayer("Rectangle", {}, 0);
|
||||
await expect(perSideOptions(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("applying a stroke width token from the panel covers every side", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click();
|
||||
|
||||
const row = strokeRow(page);
|
||||
await expect(
|
||||
row
|
||||
.getByLabel(GLOBAL, { exact: true })
|
||||
.getByRole("button", { name: "width-big" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(4);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("applying the global action from the token menu covers every side", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
await workspace.tokenContextMenuForToken.getByText("All").first().click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("a dimensions token targets every side through the menu", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await openTokensTab(page);
|
||||
await unfoldTokenType(workspace.tokensSidebar, "dimensions");
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "dim.md" })
|
||||
.click({ button: "right" });
|
||||
|
||||
const menu = workspace.tokenContextMenuForToken;
|
||||
const strokeWidthEntry = menu
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "Stroke Width" });
|
||||
await strokeWidthEntry.hover();
|
||||
await strokeWidthEntry
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "All" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "dim.md" }),
|
||||
).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("applying a token to one side leaves the other sides untouched", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
|
||||
const menu = workspace.tokenContextMenuForToken;
|
||||
await expect(menu.getByText("Top")).toBeVisible();
|
||||
await menu.getByText("Top").click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: BOTTOM }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: LEFT }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("editing one side does not change the other sides", async ({ page }) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
const options = perSideOptions(page);
|
||||
const rightInput = options.getByRole("textbox", { name: RIGHT });
|
||||
const bottomInput = options.getByRole("textbox", { name: BOTTOM });
|
||||
const leftInput = options.getByRole("textbox", { name: LEFT });
|
||||
const rightBefore = await rightInput.inputValue();
|
||||
const bottomBefore = await bottomInput.inputValue();
|
||||
const leftBefore = await leftInput.inputValue();
|
||||
|
||||
const topInput = options.getByRole("textbox", { name: TOP });
|
||||
await topInput.fill("8");
|
||||
await topInput.press("Enter");
|
||||
|
||||
await expect(topInput).toHaveValue("8");
|
||||
await expect(rightInput).toHaveValue(rightBefore);
|
||||
await expect(bottomInput).toHaveValue(bottomBefore);
|
||||
await expect(leftInput).toHaveValue(leftBefore);
|
||||
|
||||
// The uniform field cannot show a single width anymore.
|
||||
await expect(
|
||||
strokeRow(page).getByRole("textbox", { name: GLOBAL }),
|
||||
).toHaveAttribute("placeholder", MIXED);
|
||||
});
|
||||
|
||||
test("detaching a side token leaves the other sides applied", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(4);
|
||||
|
||||
await perSideOptions(page)
|
||||
.getByRole("button", { name: "Detach token" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: TOP }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("applying a token to one side creates the stroke when missing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await workspace.layers.getByTestId("layer-row").nth(0).click();
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
await workspace.tokenContextMenuForToken.getByText("Top").click();
|
||||
|
||||
await expect(strokeSection(page)).toBeVisible();
|
||||
await enablePerSide(page);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("textbox", { name: RIGHT }),
|
||||
).toHaveValue("0");
|
||||
});
|
||||
|
||||
test("a token on one side overrides a global token on that side only", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click();
|
||||
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-small" })
|
||||
.click({ button: "right" });
|
||||
await workspace.tokenContextMenuForToken.getByText("Right").click();
|
||||
|
||||
const options = perSideOptions(page);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(3);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "width-small" }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("selecting the token again on one side completes it on every side", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
await workspace.tokenContextMenuForToken.getByText("Top").click();
|
||||
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(1);
|
||||
|
||||
// The uniform field is mixed; picking the same token there covers
|
||||
// every side instead of removing the partial application. The uniform
|
||||
// field is the first token control in the row.
|
||||
const row = strokeRow(page);
|
||||
await row.getByRole("button", { name: "Open token list" }).first().click();
|
||||
await row.getByRole("option", { name: "width-big" }).click();
|
||||
|
||||
const options = perSideOptions(page);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(4);
|
||||
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clicking the token again removes it from every side", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
const chip = workspace.tokensSidebar.getByRole("button", {
|
||||
name: "width-big",
|
||||
});
|
||||
await chip.click();
|
||||
await expect(
|
||||
perSideOptions(page).getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(4);
|
||||
|
||||
await chip.click();
|
||||
const options = perSideOptions(page);
|
||||
await expect(
|
||||
options.getByRole("button", { name: "width-big" }),
|
||||
).toHaveCount(0);
|
||||
await expect(options.getByRole("textbox", { name: TOP })).toHaveCount(1);
|
||||
await expect(options.getByRole("textbox", { name: RIGHT })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("the token menu only shows per-side entries for eligible shapes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
|
||||
const menu = workspace.tokenContextMenuForToken;
|
||||
await expect(menu.getByText("All")).toBeVisible();
|
||||
await expect(menu.getByText("Top")).toBeVisible();
|
||||
await expect(menu.getByText("Right")).toBeVisible();
|
||||
await expect(menu.getByText("Bottom")).toBeVisible();
|
||||
await expect(menu.getByText("Left")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the token menu hides per-side entries when the flag is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.mockConfigFlags(["enable-feature-token-input"]);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
await prepareShape(workspace, page);
|
||||
await unfoldStrokeWidth(page, workspace);
|
||||
await workspace.tokensSidebar
|
||||
.getByRole("button", { name: "width-big" })
|
||||
.click({ button: "right" });
|
||||
|
||||
const menu = workspace.tokenContextMenuForToken;
|
||||
await expect(menu.getByText("Stroke Width").first()).toBeVisible();
|
||||
await expect(menu.getByText("Top")).toHaveCount(0);
|
||||
await expect(menu.getByText("Right")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("other shape types do not offer per-side controls", async ({ page }) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
|
||||
await workspace.selectToolFromFlyout(workspace, {
|
||||
triggerToolName: "Rectangle (R)",
|
||||
targetToolName: "Ellipse (E)",
|
||||
});
|
||||
await workspace.clickWithDragViewportAt(520, 100, 100, 100);
|
||||
await page.getByTestId("add-stroke").click();
|
||||
|
||||
await expect(strokeSection(page)).toBeVisible();
|
||||
await expect(page.getByTestId("stroke.per-side-toggle")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("token controls are disabled on the second stroke", async ({ page }) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
// The add button stays in the section header, so a second stroke is
|
||||
// one more click.
|
||||
await page.getByTestId("add-stroke").click();
|
||||
|
||||
const firstRow = strokeRow(page, 0);
|
||||
const secondRow = strokeRow(page, 1);
|
||||
await expect(firstRow).toBeVisible();
|
||||
await expect(secondRow).toBeVisible();
|
||||
|
||||
await expect(
|
||||
firstRow.getByRole("button", { name: "Open token list" }),
|
||||
).toBeEnabled();
|
||||
const disabledTokenButton = secondRow.getByRole("button", {
|
||||
name: ONLY_FIRST,
|
||||
});
|
||||
await expect(disabledTokenButton).toBeVisible();
|
||||
await expect(disabledTokenButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("the colorpicker disables tokens on the second stroke", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await page.getByTestId("add-stroke").click();
|
||||
|
||||
await strokeRow(page, 1)
|
||||
.getByRole("button", { name: "#000000" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const colorpicker = page.getByTestId("colorpicker");
|
||||
await expect(colorpicker).toBeVisible();
|
||||
await expect(
|
||||
colorpicker.getByRole("button", { name: ONLY_FIRST }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("per-side is offered for a multi-selection of rectangles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await workspace.clickToggableLayer("Board");
|
||||
await workspace.clickLeafLayer("Rectangle", {}, 0);
|
||||
await workspace.clickLeafLayer("Rectangle", { modifiers: ["Shift"] }, 1);
|
||||
await page.getByTestId("add-stroke").click();
|
||||
|
||||
await expect(page.getByTestId("stroke.per-side-toggle")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Tokens: stroke per side (inspect)", () => {
|
||||
test("Inspect styles expose the four per-side border widths", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await setupStrokePerSideFile(page);
|
||||
await prepareShape(workspace, page);
|
||||
await enablePerSide(page);
|
||||
|
||||
const options = perSideOptions(page);
|
||||
for (const [name, value] of [
|
||||
[TOP, "2"],
|
||||
[RIGHT, "8"],
|
||||
[BOTTOM, "4"],
|
||||
[LEFT, "6"],
|
||||
]) {
|
||||
const input = options.getByRole("textbox", { name });
|
||||
await input.fill(value);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
await page.getByRole("tab", { name: "Inspect" }).click();
|
||||
const panel = page
|
||||
.getByTestId("right-sidebar")
|
||||
.getByRole("article")
|
||||
.filter({ hasText: "Stroke" });
|
||||
|
||||
const expectRow = async (term, value) => {
|
||||
const row = panel.getByTestId("property-row").filter({ hasText: term });
|
||||
await expect(row).toContainText(value);
|
||||
};
|
||||
|
||||
await expectRow("Border block start width", "2px");
|
||||
await expectRow("Border inline end width", "8px");
|
||||
await expectRow("Border block end width", "4px");
|
||||
await expectRow("Border inline start width", "6px");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Tokens: stroke per side (non wasm)", () => {
|
||||
test("per-side toggle is disabled without the new renderer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page);
|
||||
await workspace.mockConfigFlags([
|
||||
"enable-stroke-per-side",
|
||||
"enable-feature-token-input",
|
||||
]);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("workspace/get-file-layout-stroke-token-json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await prepareShape(workspace, page);
|
||||
|
||||
const toggle = page.getByTestId("stroke.per-side-toggle");
|
||||
await expect(toggle).toBeVisible();
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(toggle).toHaveAccessibleName(
|
||||
/only available in the new render/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -16,6 +16,7 @@
|
||||
[app.common.types.library :as ctl]
|
||||
[app.common.types.shape :as shp]
|
||||
[app.common.types.shape.shadow :as types.shadow]
|
||||
[app.common.types.stroke :as cts]
|
||||
[app.common.types.text :as txt]
|
||||
[app.main.broadcast :as mbc]
|
||||
[app.main.data.helpers :as dsh]
|
||||
@ -335,7 +336,6 @@
|
||||
:stroke-width
|
||||
:stroke-dash
|
||||
:stroke-gap
|
||||
:stroke-per-side
|
||||
:stroke-width-top
|
||||
:stroke-width-right
|
||||
:stroke-width-bottom
|
||||
@ -402,7 +402,9 @@
|
||||
(ptk/reify ::change-stroke-color
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (let [options (assoc options :changed-sub-attr [:stroke-color])]
|
||||
(rx/of (let [options (assoc options
|
||||
:changed-sub-attr [:stroke-color]
|
||||
:changed-item-index index)]
|
||||
(dwsh/update-shapes ids #(update-shape-stroke-color % index color) options)))))))
|
||||
|
||||
(defn change-stroke-attrs
|
||||
@ -412,7 +414,9 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [changed-sub-attr (keys attrs)
|
||||
options (assoc options :changed-sub-attr changed-sub-attr)]
|
||||
options (assoc options
|
||||
:changed-sub-attr changed-sub-attr
|
||||
:changed-item-index index)]
|
||||
(rx/of (dwsh/update-shapes
|
||||
ids
|
||||
(fn [shape]
|
||||
@ -427,6 +431,57 @@
|
||||
(assoc-in [:strokes index] attrs))))
|
||||
options)))))))
|
||||
|
||||
;; --- Stroke side width
|
||||
|
||||
(defn change-stroke-side-width
|
||||
"Change the width of one side on the stroke at `index` of each shape. All
|
||||
four per-side keys are materialized so consumers never fall back to
|
||||
`:stroke-width`: the edited side takes `value`, the others keep their
|
||||
current width (0 when the shape has no stroke). `:stroke-width` mirrors the
|
||||
top side for legacy readers. Only the edited key (+ `:stroke-width` when
|
||||
top) is reported as changed, so a token on an untouched side is not
|
||||
unapplied."
|
||||
([ids attr value index] (change-stroke-side-width ids attr value index nil))
|
||||
([ids attr value index options]
|
||||
(when (number? value)
|
||||
(ptk/reify ::change-stroke-side-width
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [changed-sub-attr (if (= attr :stroke-width-top)
|
||||
[attr :stroke-width]
|
||||
[attr])
|
||||
options (assoc options
|
||||
:changed-sub-attr changed-sub-attr
|
||||
:changed-item-index index)]
|
||||
(rx/of (dwsh/update-shapes
|
||||
ids
|
||||
(fn [shape]
|
||||
(let [current (get-in shape [:strokes index])
|
||||
stroke (cts/materialize-stroke-side-widths
|
||||
current #{attr} value)]
|
||||
(cond-> shape
|
||||
(not (contains? shape :strokes))
|
||||
(assoc :strokes [])
|
||||
|
||||
:always
|
||||
(assoc-in [:strokes index] stroke))))
|
||||
options))))))))
|
||||
|
||||
;; --- Per-side controls expansion (Design tab UI state)
|
||||
|
||||
(defn toggle-stroke-per-side
|
||||
"Toggle the per-side width controls of the stroke at `index` for the shapes
|
||||
in `ids`. Purely ephemeral UI state: it lives in `:workspace-local` keyed by
|
||||
`[ids index]`, so it survives selecting another shape and coming back, but
|
||||
resets on reload."
|
||||
[ids index]
|
||||
(ptk/reify ::toggle-stroke-per-side
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-local :stroke-per-side [ids index]] not))))
|
||||
|
||||
;; --- Shadows
|
||||
|
||||
(defn change-shadow
|
||||
[ids attrs index]
|
||||
(letfn [(update-shadow [shape]
|
||||
@ -504,7 +559,8 @@
|
||||
(update shape :strokes remove-fill-by-index position))]
|
||||
(rx/of (dwsh/update-shapes ids
|
||||
remove-stroke
|
||||
{:attrs [:strokes]}))))))
|
||||
{:attrs [:strokes]
|
||||
:changed-item-index position}))))))
|
||||
|
||||
(defn remove-all-strokes
|
||||
[ids]
|
||||
|
||||
@ -109,7 +109,7 @@
|
||||
(update-shapes-buffer ids update-fn nil))
|
||||
([ids update-fn
|
||||
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
||||
ignore-touched undo-group with-objects? changed-sub-attr
|
||||
ignore-touched undo-group with-objects? changed-sub-attr changed-item-index
|
||||
translation? skip-grid-reassignment? skip-component-sync?]
|
||||
:or {reg-objects? false
|
||||
save-undo? true
|
||||
@ -148,6 +148,7 @@
|
||||
nil
|
||||
{:attrs attrs
|
||||
:changed-sub-attr changed-sub-attr
|
||||
:changed-item-index changed-item-index
|
||||
:ignore-tree ignore-tree
|
||||
:ignore-touched ignore-touched
|
||||
:with-objects? with-objects?
|
||||
@ -189,7 +190,7 @@
|
||||
([ids update-fn
|
||||
{:as props
|
||||
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
||||
ignore-touched undo-group with-objects? changed-sub-attr translation?
|
||||
ignore-touched undo-group with-objects? changed-sub-attr changed-item-index translation?
|
||||
skip-grid-reassignment? skip-component-sync?]
|
||||
:or {reg-objects? false
|
||||
save-undo? true
|
||||
@ -220,6 +221,7 @@
|
||||
objects
|
||||
{:attrs attrs
|
||||
:changed-sub-attr changed-sub-attr
|
||||
:changed-item-index changed-item-index
|
||||
:ignore-tree ignore-tree
|
||||
:ignore-touched ignore-touched
|
||||
:with-objects? with-objects?
|
||||
|
||||
@ -101,21 +101,56 @@
|
||||
:no-wasm? true})))))))
|
||||
|
||||
(defn update-stroke-width
|
||||
([value shape-ids attributes] (update-stroke-width value shape-ids attributes nil))
|
||||
([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions
|
||||
([value shape-ids attributes]
|
||||
(update-stroke-width value shape-ids attributes nil))
|
||||
([value shape-ids _attributes page-id]
|
||||
(when (number? value)
|
||||
(let [value (max 0 value)]
|
||||
(dwsh/update-shapes shape-ids
|
||||
(fn [shape]
|
||||
(if (seq (:strokes shape))
|
||||
(assoc-in shape [:strokes 0 :stroke-width] value)
|
||||
(let [stroke (assoc cts/default-stroke :stroke-width value)]
|
||||
(let [stroke (get-in shape [:strokes 0])]
|
||||
(assoc-in shape [:strokes 0]
|
||||
(merge stroke
|
||||
{:stroke-width value
|
||||
:stroke-width-top value
|
||||
:stroke-width-right value
|
||||
:stroke-width-bottom value
|
||||
:stroke-width-left value})))
|
||||
(let [stroke (assoc cts/default-stroke
|
||||
:stroke-width value
|
||||
:stroke-width-top value
|
||||
:stroke-width-right value
|
||||
:stroke-width-bottom value
|
||||
:stroke-width-left value)]
|
||||
(assoc shape :strokes [stroke]))))
|
||||
{:reg-objects? true
|
||||
:ignore-touched true
|
||||
:page-id page-id
|
||||
:attrs [:strokes]})))))
|
||||
|
||||
(defn update-stroke-width-side
|
||||
"Updates the width of the sides in `attributes` on the first stroke of each
|
||||
shape. Sides not in `attributes` keep their current width (0 when the shape
|
||||
had no stroke yet) and all side keys are materialized so consumers never
|
||||
fall back to `:stroke-width`. `:stroke-width` keeps acting as the top-side
|
||||
alias."
|
||||
([value shape-ids attributes]
|
||||
(update-stroke-width-side value shape-ids attributes nil))
|
||||
([value shape-ids attributes page-id]
|
||||
(when (number? value)
|
||||
(dwsh/update-shapes shape-ids
|
||||
(fn [shape]
|
||||
(let [stroke (cts/materialize-stroke-side-widths
|
||||
(first (:strokes shape)) attributes value)]
|
||||
(if (seq (:strokes shape))
|
||||
(update shape :strokes #(into [stroke] (rest %)))
|
||||
(assoc shape :strokes [stroke]))))
|
||||
{:reg-objects? true
|
||||
:ignore-touched true
|
||||
:page-id page-id
|
||||
:attrs [:strokes]}))))
|
||||
|
||||
(defn update-color [f value shape-ids page-id]
|
||||
(when-let [tc (tinycolor/valid-color value)]
|
||||
(let [hex (tinycolor/->hex-string tc)
|
||||
@ -540,10 +575,10 @@
|
||||
(set (filter attributes #{:r1 :r2 :r3 :r4}))
|
||||
page-id)))
|
||||
|
||||
(some attributes #{:stroke-width})
|
||||
(conj #(update-stroke-width
|
||||
(some attributes ctt/stroke-width-keys)
|
||||
(conj #(update-stroke-width-side
|
||||
value shape-ids
|
||||
#{:stroke-width}
|
||||
(set/intersection attributes ctt/stroke-width-keys)
|
||||
page-id))
|
||||
|
||||
(some attributes #{:max-width :max-height :layout-item-max-h :layout-item-max-w :layout-item-min-h :layout-item-min-w})
|
||||
@ -618,7 +653,7 @@
|
||||
passed to toggle-token) and in propagation.cljs (re-exported from there)."
|
||||
{ctt/border-radius-keys update-shape-radius-for-corners
|
||||
ctt/color-keys update-fill-stroke
|
||||
ctt/stroke-width-keys update-stroke-width
|
||||
ctt/stroke-width-keys update-stroke-width-side
|
||||
ctt/sizing-keys apply-dimensions-token
|
||||
ctt/opacity-keys update-opacity
|
||||
ctt/rotation-keys update-rotation
|
||||
@ -819,15 +854,30 @@
|
||||
(or (get attr->shape-update (first attrs)) on-update-shape)
|
||||
on-update-shape)
|
||||
|
||||
target-attrs
|
||||
(or attrs all-attributes attributes)
|
||||
|
||||
unapply-tokens?
|
||||
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
|
||||
(if (seq attrs)
|
||||
;; Explicit attributes come from an input or a plugin apply
|
||||
;; call. Only toggle off when the token already covers every
|
||||
;; target attribute on every selected shape; a partial
|
||||
;; application is completed instead of removed.
|
||||
(and (seq target-attrs)
|
||||
(cfo/shapes-applied-all?
|
||||
(cfo/shapes-ids-by-applied-attributes token shapes target-attrs)
|
||||
(into #{} (map :id) shapes)
|
||||
target-attrs))
|
||||
;; No explicit attributes (token pill): toggle off when any
|
||||
;; selected shape has the token on any attribute.
|
||||
(cfo/shapes-token-applied? token shapes target-attrs))
|
||||
|
||||
shape-ids
|
||||
(map :id shapes)]
|
||||
|
||||
(if unapply-tokens?
|
||||
(rx/of
|
||||
(unapply-token {:attributes (or attrs all-attributes attributes)
|
||||
(unapply-token {:attributes target-attrs
|
||||
:token-name (:name token)
|
||||
:shape-ids shape-ids}))
|
||||
(rx/of
|
||||
|
||||
@ -151,6 +151,12 @@
|
||||
(def workspace-local
|
||||
(l/derived :workspace-local st/state))
|
||||
|
||||
(def stroke-per-side
|
||||
"Per-stroke expansion state of the design tab per-side controls, keyed by
|
||||
`[ids index]`. This is ephemeral UI state: it survives shape and page
|
||||
switches during the session but resets on reload."
|
||||
(l/derived (fn [state] (get-in state [:workspace-local :stroke-per-side])) st/state))
|
||||
|
||||
(def workspace-global
|
||||
(l/derived :workspace-global st/state))
|
||||
|
||||
|
||||
@ -100,6 +100,17 @@
|
||||
[options]
|
||||
(some #(when (focusable-option? %) (:id %)) options))
|
||||
|
||||
(defn token-shortcut
|
||||
"Returns the token shortcut triggered by `event`: `:open` for `{` and
|
||||
`:apply` for `}`. Returns nil when tokens are disabled for the input
|
||||
(for example, strokes after the first one), so the shortcuts are inert
|
||||
there."
|
||||
[event token-disabled?]
|
||||
(when-not token-disabled?
|
||||
(cond
|
||||
(kbd/is-key? event "{") :open
|
||||
(kbd/is-key? event "}") :apply)))
|
||||
|
||||
(defn next-focus-index
|
||||
[options focused-id direction]
|
||||
(let [options (if (delay? options) @options options)
|
||||
@ -145,7 +156,9 @@
|
||||
[:step {:optional true} [:maybe [:or :int :float]]]
|
||||
[:is-selected-on-focus {:optional true} :boolean]
|
||||
[:nillable {:optional true} :boolean]
|
||||
[:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]]
|
||||
[:applied-token-name {:optional true} [:maybe [:or :string [:= :multiple]]]]
|
||||
[:token-disabled {:optional true} [:maybe :boolean]]
|
||||
[:token-tooltip {:optional true} [:maybe :string]]
|
||||
[:empty-to-end {:optional true} :boolean]
|
||||
[:on-change {:optional true} fn?]
|
||||
[:on-change-start {:optional true} fn?]
|
||||
@ -165,6 +178,7 @@
|
||||
min max max-length step
|
||||
is-selected-on-focus nillable
|
||||
tokens applied-token-name empty-to-end
|
||||
token-disabled token-tooltip
|
||||
on-change on-change-start on-change-end
|
||||
on-blur on-focus on-detach
|
||||
property align ref name
|
||||
@ -199,6 +213,8 @@
|
||||
;; Default props
|
||||
nillable (d/nilv nillable false)
|
||||
disabled (d/nilv disabled false)
|
||||
token-disabled? (d/nilv token-disabled false)
|
||||
token-tooltip (d/nilv token-tooltip (tr "ds.inputs.numeric-input.open-token-list-dropdown"))
|
||||
select-on-focus (d/nilv is-selected-on-focus true)
|
||||
|
||||
default (mf/with-memo [default nillable]
|
||||
@ -429,7 +445,7 @@
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
(mf/deps is-open apply-value update-input is-open focused-id handle-focus-change)
|
||||
(mf/deps is-open apply-value update-input is-open focused-id handle-focus-change token-disabled?)
|
||||
(fn [event]
|
||||
(mf/set-ref-val! dirty-ref true)
|
||||
(let [up? (kbd/up-arrow? event)
|
||||
@ -437,8 +453,9 @@
|
||||
enter? (kbd/enter? event)
|
||||
esc? (kbd/esc? event)
|
||||
node (mf/ref-val ref)
|
||||
open-tokens (kbd/is-key? event "{")
|
||||
close-tokens (kbd/is-key? event "}")
|
||||
shortcut (token-shortcut event token-disabled?)
|
||||
open-tokens (= :open shortcut)
|
||||
close-tokens (= :apply shortcut)
|
||||
options (mf/ref-val options-ref)
|
||||
options (if (delay? options) @options options)]
|
||||
|
||||
@ -743,10 +760,11 @@
|
||||
:icon i/tokens
|
||||
:tooltip-class (stl/css :button-tooltip)
|
||||
:class (stl/css :invisible-button)
|
||||
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
|
||||
:aria-label token-tooltip
|
||||
:ref open-dropdown-ref
|
||||
:tooltip-placement tooltip-placement
|
||||
:on-click open-dropdown}])))
|
||||
:on-click open-dropdown
|
||||
:disabled token-disabled?}])))
|
||||
:max-length max-length})
|
||||
|
||||
token-props
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
--input-outline-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
&:has(*:disabled) {
|
||||
&:has(input:disabled) {
|
||||
--input-bg-color: var(--color-background-primary);
|
||||
--input-outline-color: var(--color-background-quaternary);
|
||||
--input-fg-color: var(--color-foreground-secondary);
|
||||
|
||||
@ -27,11 +27,11 @@
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
||||
[app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]]
|
||||
[app.main.ui.components.select :refer [select]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
|
||||
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
@ -107,20 +107,22 @@
|
||||
(dom/set-css-property! node "--lightness-grad-mid" (format-hsl lightness-mid)))))
|
||||
|
||||
(mf/defc colorpicker*
|
||||
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}]
|
||||
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token token-disabled]}]
|
||||
(let [state (mf/deref refs/colorpicker)
|
||||
node-ref (mf/use-ref)
|
||||
|
||||
should-update? (mf/use-var true)
|
||||
token-color (contains? cfg/flags :token-color)
|
||||
color-style* (mf/use-state (d/nilv tab :direct-color))
|
||||
color-style* (mf/use-state (if ^boolean token-disabled
|
||||
:direct-color
|
||||
(d/nilv tab :direct-color)))
|
||||
color-style (deref color-style*)
|
||||
toggle-token-color
|
||||
|
||||
handle-color-style-change
|
||||
(mf/use-fn
|
||||
(mf/deps color-style)
|
||||
(fn []
|
||||
(let [new-style (if (= :direct-color color-style) :token-color :direct-color)]
|
||||
(reset! color-style* new-style))))
|
||||
(mf/deps color-style*)
|
||||
(fn [value]
|
||||
(reset! color-style* (keyword value))))
|
||||
|
||||
;; TODO: I think we need to put all this picking state under
|
||||
;; the same object for avoid creating adhoc refs for each
|
||||
@ -422,17 +424,20 @@
|
||||
:on-change handle-change-mode}]])
|
||||
|
||||
(when (and (= origin :sidebar) show-tokens? token-color)
|
||||
[:& radio-buttons {:selected color-style
|
||||
:on-change toggle-token-color
|
||||
:name "color-style"}
|
||||
[:& radio-button {:icon i/swatches
|
||||
:value :direct-color
|
||||
:title (tr "labels.color")
|
||||
:id "opt-color"}]
|
||||
[:& radio-button {:icon i/tokens
|
||||
:value :token-color
|
||||
:title (tr "workspace.colorpicker.color-tokens")
|
||||
:id "opt-token-color"}]])]
|
||||
[:> radio-buttons* {:selected (name color-style)
|
||||
:on-change handle-color-style-change
|
||||
:name "color-style"
|
||||
:options [{:id "opt-color"
|
||||
:icon i/swatches
|
||||
:label (tr "labels.color")
|
||||
:value "direct-color"}
|
||||
{:id "opt-token-color"
|
||||
:icon i/tokens
|
||||
:label (if ^boolean token-disabled
|
||||
(tr "workspace.tokens.only-first-fill-or-stroke")
|
||||
(tr "workspace.colorpicker.color-tokens"))
|
||||
:value "token-color"
|
||||
:disabled token-disabled}]}])]
|
||||
|
||||
(when (and (not= selected-mode :image)
|
||||
(= color-style :direct-color))
|
||||
@ -735,6 +740,7 @@
|
||||
on-token-change
|
||||
on-close
|
||||
tab
|
||||
token-disabled
|
||||
applied-token
|
||||
on-accept]}]
|
||||
(let [vport (mf/deref viewport)
|
||||
@ -808,6 +814,7 @@
|
||||
:disable-image disable-image
|
||||
:on-token-change on-token-change
|
||||
:applied-token applied-token
|
||||
:token-disabled token-disabled
|
||||
:on-change on-change'
|
||||
:origin origin
|
||||
:tab tab
|
||||
|
||||
@ -36,3 +36,14 @@
|
||||
:attrs attrs
|
||||
:shape-ids ids}))))
|
||||
|
||||
(defn tokens-allowed-position?
|
||||
"Design tokens only apply to the first fill or stroke in a shape's ordered list,
|
||||
so token controls are only enabled for the entry at `index` zero.
|
||||
|
||||
`first-only?` marks the lists where that rule applies. Other color rows
|
||||
(shadows, gradients, the selection color list) reuse `index` for their own row
|
||||
order and must keep their token controls enabled."
|
||||
[first-only? index]
|
||||
(or (not first-only?)
|
||||
(and (some? index) (zero? index))))
|
||||
|
||||
|
||||
@ -236,6 +236,7 @@
|
||||
:key index
|
||||
:index index
|
||||
:title (tr "workspace.options.fill")
|
||||
:tokens-first-only true
|
||||
:on-change on-change
|
||||
:on-reorder on-reorder
|
||||
:on-detach on-detach
|
||||
|
||||
@ -8,7 +8,9 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc numeric-input-wrapper*
|
||||
[{:keys [value attr applied-token align on-detach placeholder input-type class] :rest props}]
|
||||
[{:keys [value attr applied-token align on-detach placeholder input-type class
|
||||
token-disabled token-tooltip]
|
||||
:rest props}]
|
||||
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
|
||||
|
||||
tokens (mf/with-memo [tokens input-type attr]
|
||||
@ -27,8 +29,10 @@
|
||||
:class [class (stl/css :numeric-input-wrapper)]
|
||||
:applied-token-name applied-token
|
||||
:tokens (if (delay? tokens) @tokens tokens)
|
||||
:token-disabled token-disabled
|
||||
:token-tooltip token-tooltip
|
||||
:align align
|
||||
:on-detach on-detach-attr
|
||||
:name attr
|
||||
:value value})]
|
||||
[:> numeric-input* props]))
|
||||
[:> numeric-input* props]))
|
||||
|
||||
@ -10,12 +10,14 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.stroke :as cts]
|
||||
[app.common.types.token :as ctt]
|
||||
[app.config :as cf]
|
||||
[app.main.data.workspace :as udw]
|
||||
[app.main.data.workspace.colors :as dc]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
@ -63,6 +65,37 @@
|
||||
n-strokes (get n-vals :strokes)]
|
||||
(identical? o-strokes n-strokes))))
|
||||
|
||||
(defn stroke-width-all-attrs
|
||||
"Returns the stroke attributes that set `width` both on the global
|
||||
stroke-width input and on every independent side."
|
||||
[width]
|
||||
{:stroke-width width
|
||||
:stroke-width-top width
|
||||
:stroke-width-right width
|
||||
:stroke-width-bottom width
|
||||
:stroke-width-left width})
|
||||
|
||||
(defn per-side-stroke-available?
|
||||
"Whether the per-side stroke width controls apply to this selection.
|
||||
Single boards and rectangles always qualify; a multi-selection only when
|
||||
every shape supports independent sides and the strokes are not mixed."
|
||||
[type strokes ids objects]
|
||||
(and (contains? cf/flags :stroke-per-side)
|
||||
(or (= type :rect)
|
||||
(= type :frame)
|
||||
(and (= type :multiple)
|
||||
(not= strokes :multiple)
|
||||
(seq ids)
|
||||
(every? #(ctt/per-side-stroke-shape? (:type (get objects %))) ids)))))
|
||||
|
||||
(defn per-side-stroke-enabled?
|
||||
"Whether per-side stroke editing can work at all: the feature flag must be
|
||||
on and the WASM renderer active. The classic renderer only draws the single
|
||||
`:stroke-width`, so per-side values are inert there."
|
||||
[render-wasm?]
|
||||
(and (contains? cf/flags :stroke-per-side)
|
||||
(boolean render-wasm?)))
|
||||
|
||||
(mf/defc stroke-menu*
|
||||
{::mf/wrap [#(mf/memo' % stroke-menu-check-props)]}
|
||||
[{:keys [ids type values show-caps disable-stroke-style applied-tokens]}]
|
||||
@ -136,40 +169,19 @@
|
||||
(fn [index value]
|
||||
(when-not (str/empty? value)
|
||||
(st/emit! (udw/trigger-bounding-box-cloaking ids))
|
||||
(st/emit! (dc/change-stroke-attrs ids {:stroke-width value} index))))
|
||||
(st/emit! (dc/change-stroke-attrs ids (stroke-width-all-attrs value) index))))
|
||||
|
||||
wasm-render?
|
||||
render-wasm?
|
||||
(features/use-feature "render-wasm/v1")
|
||||
|
||||
objects
|
||||
(mf/deref refs/workspace-page-objects)
|
||||
|
||||
per-side-available?
|
||||
(and (contains? cf/flags :stroke-per-side)
|
||||
(or (= type :rect) (= type :frame)))
|
||||
(per-side-stroke-available? type strokes ids objects)
|
||||
|
||||
per-side-disabled?
|
||||
(not wasm-render?)
|
||||
|
||||
on-stroke-per-side-toggle
|
||||
(fn [index]
|
||||
(let [stroke (get-in values [:strokes index])
|
||||
active? (:stroke-per-side stroke)
|
||||
width (:stroke-width stroke)
|
||||
width (if (number? width) width 1)]
|
||||
(st/emit! (udw/trigger-bounding-box-cloaking ids))
|
||||
(if active?
|
||||
(st/emit! (dc/change-stroke-attrs ids {:stroke-per-side false} index))
|
||||
;; Entering per-side mode seeds any missing side from the
|
||||
;; uniform width, so previous per-side edits are preserved.
|
||||
;; The top value doubles as the global :stroke-width.
|
||||
(let [top (d/nilv (:stroke-width-top stroke) width)]
|
||||
(st/emit! (dc/change-stroke-attrs
|
||||
ids
|
||||
{:stroke-per-side true
|
||||
:stroke-width top
|
||||
:stroke-width-top top
|
||||
:stroke-width-right (d/nilv (:stroke-width-right stroke) width)
|
||||
:stroke-width-bottom (d/nilv (:stroke-width-bottom stroke) width)
|
||||
:stroke-width-left (d/nilv (:stroke-width-left stroke) width)}
|
||||
index))))))
|
||||
(not (per-side-stroke-enabled? render-wasm?))
|
||||
|
||||
on-stroke-width-side-change
|
||||
(fn [index attr value]
|
||||
@ -177,13 +189,10 @@
|
||||
(st/emit! (udw/trigger-bounding-box-cloaking ids))
|
||||
;; Code paths that don't know about per-side data (old render,
|
||||
;; global width input, exports) keep reading :stroke-width, and
|
||||
;; per spec they must see the TOP side there. So editing the top
|
||||
;; side also writes :stroke-width; the other sides only write
|
||||
;; their own attr.
|
||||
(let [attrs (cond-> {attr value}
|
||||
(= attr :stroke-width-top)
|
||||
(assoc :stroke-width value))]
|
||||
(st/emit! (dc/change-stroke-attrs ids attrs index)))))
|
||||
;; per spec they must see the TOP side there. Editing any side
|
||||
;; materializes the four per-side keys; the other sides keep
|
||||
;; their current width, and :stroke-width mirrors the top side.
|
||||
(st/emit! (dc/change-stroke-side-width ids attr value index))))
|
||||
|
||||
on-stroke-dash-change
|
||||
(fn [index value]
|
||||
@ -288,7 +297,6 @@
|
||||
:on-stroke-width-change on-stroke-width-change
|
||||
:per-side-available per-side-available?
|
||||
:per-side-disabled per-side-disabled?
|
||||
:on-stroke-per-side-toggle on-stroke-per-side-toggle
|
||||
:on-stroke-width-side-change on-stroke-width-side-change
|
||||
:on-stroke-dash-change on-stroke-dash-change
|
||||
:on-stroke-gap-change on-stroke-gap-change
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
[app.main.ui.ds.utilities.swatch :refer [swatch*]]
|
||||
[app.main.ui.formats :as fmt]
|
||||
[app.main.ui.hooks :as h]
|
||||
[app.main.ui.workspace.sidebar.options.common :as soc]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
|
||||
[app.util.color :as uc]
|
||||
[app.util.dom :as dom]
|
||||
@ -179,12 +180,15 @@
|
||||
|
||||
(mf/defc color-row*
|
||||
[{:keys [index color class disable-gradient disable-opacity disable-image disable-picker hidden
|
||||
tokens-first-only
|
||||
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
|
||||
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
|
||||
|
||||
(let [token-color (contains? cfg/flags :token-color)
|
||||
libraries (mf/deref refs/files)
|
||||
|
||||
tokens-disabled? (not (soc/tokens-allowed-position? tokens-first-only index))
|
||||
|
||||
color-without-hash (mf/use-memo
|
||||
(mf/deps color)
|
||||
#(-> color :color clr/remove-hash))
|
||||
@ -280,6 +284,7 @@
|
||||
on-open
|
||||
tokens
|
||||
index
|
||||
tokens-disabled?
|
||||
applied-token)
|
||||
(fn [color pos tab]
|
||||
(let [color (cond
|
||||
@ -306,6 +311,7 @@
|
||||
:active-tokens tokens
|
||||
:applied-token applied-token
|
||||
:color-origin origin
|
||||
:token-disabled tokens-disabled?
|
||||
:tab tab
|
||||
:origin :sidebar
|
||||
:data color}]
|
||||
|
||||
@ -9,9 +9,11 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.color :as ctc]
|
||||
[app.common.types.token :as ctt]
|
||||
[app.main.data.workspace.colors :as dc]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.components.reorder-handler :refer [reorder-handler*]]
|
||||
@ -38,7 +40,6 @@
|
||||
on-stroke-width-change
|
||||
per-side-available
|
||||
per-side-disabled
|
||||
on-stroke-per-side-toggle
|
||||
on-stroke-width-side-change
|
||||
on-stroke-dash-change
|
||||
on-stroke-gap-change
|
||||
@ -60,6 +61,11 @@
|
||||
(let [hidden? (:hidden stroke)
|
||||
hidden? (if (nil? hidden?) false hidden?)
|
||||
|
||||
token-disabled? (not (soc/tokens-allowed-position? true index))
|
||||
|
||||
token-tooltip (when token-disabled?
|
||||
(tr "workspace.tokens.only-first-fill-or-stroke"))
|
||||
|
||||
token-numeric-inputs
|
||||
(features/use-feature "tokens/numeric-input")
|
||||
|
||||
@ -103,12 +109,10 @@
|
||||
|
||||
stroke-width (:stroke-width stroke)
|
||||
|
||||
per-side? (and per-side-available
|
||||
(not per-side-disabled)
|
||||
(true? (:stroke-per-side stroke)))
|
||||
|
||||
per-side-expanded* (mf/use-state false)
|
||||
per-side-expanded? (deref per-side-expanded*)
|
||||
stroke-per-side (mf/deref refs/stroke-per-side)
|
||||
per-side? (and per-side-available
|
||||
(not per-side-disabled)
|
||||
(true? (get stroke-per-side [ids index])))
|
||||
|
||||
all-sides-equal?
|
||||
(mf/with-memo [stroke]
|
||||
@ -119,7 +123,26 @@
|
||||
left (d/nilv (:stroke-width-left stroke) width)]
|
||||
(= top right bottom left)))
|
||||
|
||||
show-multiple-placeholder? (or per-side-expanded? (not all-sides-equal?))
|
||||
show-multiple-placeholder? (or per-side? (not all-sides-equal?))
|
||||
|
||||
applied-token-width
|
||||
(mf/with-memo [applied-tokens]
|
||||
(let [top (get applied-tokens :stroke-width-top)
|
||||
right (get applied-tokens :stroke-width-right)
|
||||
bottom (get applied-tokens :stroke-width-bottom)
|
||||
left (get applied-tokens :stroke-width-left)
|
||||
|
||||
per-side [top right bottom left]
|
||||
per-side-has-token? (some some? per-side)]
|
||||
(cond
|
||||
(and (every? some? per-side) (apply = per-side))
|
||||
(first per-side)
|
||||
|
||||
per-side-has-token?
|
||||
:multiple
|
||||
|
||||
:else
|
||||
nil)))
|
||||
|
||||
per-side-toggle-label
|
||||
(if per-side-disabled
|
||||
@ -128,53 +151,65 @@
|
||||
|
||||
on-per-side-toggle
|
||||
(mf/use-fn
|
||||
(mf/deps per-side? on-stroke-per-side-toggle index)
|
||||
(fn []
|
||||
(if per-side?
|
||||
(swap! per-side-expanded* not)
|
||||
(do
|
||||
(when on-stroke-per-side-toggle
|
||||
(on-stroke-per-side-toggle index))
|
||||
(reset! per-side-expanded* true)))))
|
||||
(mf/deps ids index)
|
||||
#(st/emit! (dc/toggle-stroke-per-side ids index)))
|
||||
|
||||
on-width-top-change
|
||||
(mf/use-fn
|
||||
(mf/deps index on-stroke-width-side-change)
|
||||
#(on-stroke-width-side-change index :stroke-width-top %))
|
||||
(mf/deps index on-stroke-width-side-change ids)
|
||||
#(soc/emit-value-or-token % (fn [v]
|
||||
(on-stroke-width-side-change index :stroke-width-top v)) ids #{:stroke-width-top}))
|
||||
|
||||
on-width-right-change
|
||||
(mf/use-fn
|
||||
(mf/deps index on-stroke-width-side-change)
|
||||
#(on-stroke-width-side-change index :stroke-width-right %))
|
||||
(mf/deps index on-stroke-width-side-change ids)
|
||||
#(soc/emit-value-or-token % (fn [v]
|
||||
(on-stroke-width-side-change index :stroke-width-right v)) ids #{:stroke-width-right}))
|
||||
|
||||
on-width-bottom-change
|
||||
(mf/use-fn
|
||||
(mf/deps index on-stroke-width-side-change)
|
||||
#(on-stroke-width-side-change index :stroke-width-bottom %))
|
||||
(mf/deps index on-stroke-width-side-change ids)
|
||||
#(soc/emit-value-or-token % (fn [v]
|
||||
(on-stroke-width-side-change index :stroke-width-bottom v)) ids #{:stroke-width-bottom}))
|
||||
|
||||
on-width-left-change
|
||||
(mf/use-fn
|
||||
(mf/deps index on-stroke-width-side-change)
|
||||
#(on-stroke-width-side-change index :stroke-width-left %))
|
||||
(mf/deps index on-stroke-width-side-change ids)
|
||||
#(soc/emit-value-or-token % (fn [v]
|
||||
(on-stroke-width-side-change index :stroke-width-left v)) ids #{:stroke-width-left}))
|
||||
|
||||
on-width-change
|
||||
(mf/use-fn
|
||||
(mf/deps index on-stroke-width-change ids per-side?)
|
||||
(fn [value]
|
||||
(if per-side?
|
||||
(st/emit! (dc/change-stroke-attrs
|
||||
ids
|
||||
{:stroke-width value
|
||||
:stroke-width-top value
|
||||
:stroke-width-right value
|
||||
:stroke-width-bottom value
|
||||
:stroke-width-left value}
|
||||
index))
|
||||
(if (coll? value)
|
||||
(soc/emit-value-or-token
|
||||
value
|
||||
(fn [v]
|
||||
(st/emit! (dc/change-stroke-attrs
|
||||
ids
|
||||
{:stroke-width v
|
||||
:stroke-width-top v
|
||||
:stroke-width-right v
|
||||
:stroke-width-bottom v
|
||||
:stroke-width-left v}
|
||||
index)))
|
||||
ids
|
||||
ctt/per-side-stroke-width-keys)
|
||||
(st/emit! (dc/change-stroke-attrs
|
||||
ids
|
||||
{:stroke-width value
|
||||
:stroke-width-top value
|
||||
:stroke-width-right value
|
||||
:stroke-width-bottom value
|
||||
:stroke-width-left value}
|
||||
index)))
|
||||
(soc/emit-value-or-token
|
||||
value
|
||||
#(on-stroke-width-change index %)
|
||||
ids
|
||||
#{:stroke-width}))))
|
||||
ctt/per-side-stroke-width-keys))))
|
||||
|
||||
;; The SVG renderer defaults dash and gap to `stroke-width + 10` when
|
||||
;; unset. Showing that value as placeholder makes the override obvious.
|
||||
@ -264,7 +299,31 @@
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach-token)
|
||||
(fn [token]
|
||||
(on-detach-token token #{:stroke-width})))
|
||||
(on-detach-token token ctt/per-side-stroke-width-keys)))
|
||||
|
||||
on-detach-token-width-top
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach-token)
|
||||
(fn [token]
|
||||
(on-detach-token token #{:stroke-width-top})))
|
||||
|
||||
on-detach-token-width-right
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach-token)
|
||||
(fn [token]
|
||||
(on-detach-token token #{:stroke-width-right})))
|
||||
|
||||
on-detach-token-width-bottom
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach-token)
|
||||
(fn [token]
|
||||
(on-detach-token token #{:stroke-width-bottom})))
|
||||
|
||||
on-detach-token-width-left
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach-token)
|
||||
(fn [token]
|
||||
(on-detach-token token #{:stroke-width-left})))
|
||||
|
||||
stroke-caps-options
|
||||
[{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")}
|
||||
@ -306,6 +365,7 @@
|
||||
[:> color-row* {:color (ctc/stroke->color stroke)
|
||||
:index index
|
||||
:title title
|
||||
:tokens-first-only true
|
||||
:on-change on-color-change-refactor
|
||||
:on-detach on-color-detach
|
||||
:disable-drag disable-drag
|
||||
@ -343,14 +403,15 @@
|
||||
:attr :stroke-width
|
||||
:class (stl/css :numeric-input-wrapper)
|
||||
:property (tr "workspace.options.stroke-width")
|
||||
:applied-token (get applied-tokens :stroke-width)
|
||||
:applied-token applied-token-width
|
||||
:token-disabled token-disabled?
|
||||
:token-tooltip token-tooltip
|
||||
:placeholder (if show-multiple-placeholder?
|
||||
(tr "settings.multiple")
|
||||
"--")
|
||||
:nillable true
|
||||
:value (if all-sides-equal?
|
||||
stroke-width
|
||||
nil)}]
|
||||
(:stroke-width stroke)
|
||||
:multiple)}]
|
||||
[:> select* {:default-selected (d/name stroke-alignment)
|
||||
:options stroke-alignment-options
|
||||
:variant "icon-only"
|
||||
@ -370,7 +431,7 @@
|
||||
:on-change on-style-change}])
|
||||
(when per-side-available
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-pressed per-side-expanded?
|
||||
:aria-pressed per-side?
|
||||
:aria-label per-side-toggle-label
|
||||
:disabled per-side-disabled
|
||||
:on-click on-per-side-toggle
|
||||
@ -410,56 +471,68 @@
|
||||
:on-change on-style-change}]])
|
||||
(when per-side-available
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-pressed per-side-expanded?
|
||||
:aria-pressed per-side?
|
||||
:aria-label per-side-toggle-label
|
||||
:disabled per-side-disabled
|
||||
:on-click on-per-side-toggle
|
||||
:icon i/stroke-extended
|
||||
:data-testid "stroke.per-side-toggle"}])])
|
||||
|
||||
(when per-side-expanded?
|
||||
(when per-side?
|
||||
[:div {:class (stl/css :stroke-sides-options)
|
||||
:data-testid "stroke.per-side-options"}
|
||||
[:div {:class (stl/css :stroke-side-input)
|
||||
:title (tr "workspace.options.stroke-width-top")}
|
||||
[:> icon* {:icon-id i/stroke-top
|
||||
:size "s"}]
|
||||
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-top stroke) stroke-width)
|
||||
:min 0
|
||||
:on-change on-width-top-change
|
||||
:on-focus on-focus
|
||||
:select-on-focus select-on-focus
|
||||
:on-blur on-blur}]]
|
||||
[:div {:class (stl/css :stroke-side-input)
|
||||
:title (tr "workspace.options.stroke-width-right")}
|
||||
[:> icon* {:icon-id i/stroke-right
|
||||
:size "s"}]
|
||||
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-right stroke) stroke-width)
|
||||
:min 0
|
||||
:on-change on-width-right-change
|
||||
:on-focus on-focus
|
||||
:select-on-focus select-on-focus
|
||||
:on-blur on-blur}]]
|
||||
[:div {:class (stl/css :stroke-side-input)
|
||||
:title (tr "workspace.options.stroke-width-bottom")}
|
||||
[:> icon* {:icon-id i/stroke-bottom
|
||||
:size "s"}]
|
||||
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-bottom stroke) stroke-width)
|
||||
:min 0
|
||||
:on-change on-width-bottom-change
|
||||
:on-focus on-focus
|
||||
:select-on-focus select-on-focus
|
||||
:on-blur on-blur}]]
|
||||
[:div {:class (stl/css :stroke-side-input)
|
||||
:title (tr "workspace.options.stroke-width-left")}
|
||||
[:> icon* {:icon-id i/stroke-left
|
||||
:size "s"}]
|
||||
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-left stroke) stroke-width)
|
||||
:min 0
|
||||
:on-change on-width-left-change
|
||||
:on-focus on-focus
|
||||
:select-on-focus select-on-focus
|
||||
:on-blur on-blur}]]])
|
||||
[:> numeric-input-wrapper* {:on-change on-width-top-change
|
||||
:on-detach on-detach-token-width-top
|
||||
:icon i/stroke-top
|
||||
:min 0
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:attr :stroke-width-top
|
||||
:class (stl/css :numeric-input-wrapper)
|
||||
:property (tr "workspace.options.stroke-width-top")
|
||||
:applied-token (get applied-tokens :stroke-width-top)
|
||||
:token-disabled token-disabled?
|
||||
:token-tooltip token-tooltip
|
||||
:value (d/nilv (:stroke-width-top stroke) stroke-width)}]
|
||||
[:> numeric-input-wrapper* {:on-change on-width-right-change
|
||||
:on-detach on-detach-token-width-right
|
||||
:icon i/stroke-right
|
||||
:min 0
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:attr :stroke-width-right
|
||||
:class (stl/css :numeric-input-wrapper)
|
||||
:property (tr "workspace.options.stroke-width-right")
|
||||
:applied-token (get applied-tokens :stroke-width-right)
|
||||
:token-disabled token-disabled?
|
||||
:token-tooltip token-tooltip
|
||||
:value (d/nilv (:stroke-width-right stroke) stroke-width)}]
|
||||
[:> numeric-input-wrapper* {:on-change on-width-bottom-change
|
||||
:on-detach on-detach-token-width-bottom
|
||||
:icon i/stroke-bottom
|
||||
:min 0
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:attr :stroke-width-bottom
|
||||
:class (stl/css :numeric-input-wrapper)
|
||||
:property (tr "workspace.options.stroke-width-bottom")
|
||||
:applied-token (get applied-tokens :stroke-width-bottom)
|
||||
:token-disabled token-disabled?
|
||||
:token-tooltip token-tooltip
|
||||
:value (d/nilv (:stroke-width-bottom stroke) stroke-width)}]
|
||||
[:> numeric-input-wrapper* {:on-change on-width-left-change
|
||||
:on-detach on-detach-token-width-left
|
||||
:icon i/stroke-left
|
||||
:min 0
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:attr :stroke-width-left
|
||||
:class (stl/css :numeric-input-wrapper)
|
||||
:property (tr "workspace.options.stroke-width-left")
|
||||
:applied-token (get applied-tokens :stroke-width-left)
|
||||
:token-disabled token-disabled?
|
||||
:token-tooltip token-tooltip
|
||||
:value (d/nilv (:stroke-width-left stroke) stroke-width)}]])
|
||||
|
||||
;; Stroke Dash / Gap (only visible for dashed style)
|
||||
(when (= stroke-style :dashed)
|
||||
|
||||
@ -16,12 +16,14 @@
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.workspace.sidebar.options.menus.stroke :as stroke]
|
||||
[app.util.clipboard :as clipboard]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
@ -288,9 +290,64 @@
|
||||
(ptk/data-event :expand-border-radius)
|
||||
(dwta/update-shape-radius-for-corners value shape-ids attributes)))
|
||||
|
||||
(defn- per-side-stroke-eligible?
|
||||
"Per-side stroke width tokens are only offered for selections made
|
||||
entirely of boards and rectangles, with the feature flag on and the WASM
|
||||
renderer active (the classic renderer ignores per-side widths)."
|
||||
[context-data]
|
||||
(let [shapes (:selected-shapes context-data)]
|
||||
(and (stroke/per-side-stroke-enabled? (:render-wasm context-data))
|
||||
(seq shapes)
|
||||
(every? #(ctt/per-side-stroke-shape? (:type %)) shapes))))
|
||||
|
||||
(defn- global-stroke-width-action
|
||||
"Single action that applies the token to the whole stroke width of the
|
||||
selection. It targets the per-side attributes so the design tab keeps
|
||||
showing the token on every side."
|
||||
[{:keys [token selected-shapes]}]
|
||||
(let [attributes ctt/per-side-stroke-width-keys
|
||||
{:keys [all-selected? shape-ids]} (attribute-actions token selected-shapes attributes)]
|
||||
{:title "Stroke Width"
|
||||
:hint (tr "workspace.tokens.stroke-width")
|
||||
:selected? all-selected?
|
||||
:action (fn []
|
||||
(if all-selected?
|
||||
(st/emit! (dwta/unapply-token {:token-name (:name token)
|
||||
:attributes attributes
|
||||
:shape-ids shape-ids}))
|
||||
(st/emit! (dwta/apply-token {:attributes attributes
|
||||
:token token
|
||||
:shape-ids shape-ids
|
||||
:on-update-shape dwta/update-stroke-width}))))}))
|
||||
|
||||
(defn stroke-width-actions
|
||||
"Actions for a stroke width token. Boards and rectangles get the per-side
|
||||
options; any other selection gets a single global action."
|
||||
[context-data]
|
||||
(let [allowed (:allowed-shape-attributes context-data)]
|
||||
(when (seq (set/intersection ctt/stroke-width-keys allowed))
|
||||
(if (per-side-stroke-eligible? context-data)
|
||||
(all-or-separate-actions
|
||||
{:attribute-labels {:stroke-width-top "Top"
|
||||
:stroke-width-right "Right"
|
||||
:stroke-width-bottom "Bottom"
|
||||
:stroke-width-left "Left"}
|
||||
:hint (tr "workspace.tokens.stroke-width")
|
||||
:on-update-shape-all dwta/update-stroke-width
|
||||
:on-update-shape dwta/update-stroke-width-side}
|
||||
context-data)
|
||||
[(global-stroke-width-action context-data)]))))
|
||||
|
||||
(defn- stroke-width-menu-entries
|
||||
"Entry shown inside the dimensions menu: a submenu for boards and
|
||||
rectangles, or the global action otherwise."
|
||||
[context-data]
|
||||
(if (per-side-stroke-eligible? context-data)
|
||||
[{:title "Stroke Width" :submenu :stroke-width}]
|
||||
(stroke-width-actions context-data)))
|
||||
|
||||
(def shape-attribute-actions-map
|
||||
(let [stroke-width (partial generic-attribute-actions #{:stroke-width} "Stroke Width")
|
||||
font-size (partial generic-attribute-actions #{:font-size} "Font Size")
|
||||
(let [font-size (partial generic-attribute-actions #{:font-size} "Font Size")
|
||||
letter-spacing (partial generic-attribute-actions #{:letter-spacing} "Letter Spacing")
|
||||
font-family (partial generic-attribute-actions #{:font-family} "Font Family")
|
||||
line-height #(generic-attribute-actions #{:line-height} "Line Height" (assoc % :on-update-shape dwta/update-line-height))
|
||||
@ -320,7 +377,7 @@
|
||||
(generic-attribute-actions #{:rotation} "Rotation" (assoc context-data :on-update-shape dwta/update-rotation))
|
||||
(let [line-height (line-height context-data)]
|
||||
(when (seq line-height) line-height))))
|
||||
:stroke-width stroke-width
|
||||
:stroke-width stroke-width-actions
|
||||
:font-size font-size
|
||||
:font-family font-family
|
||||
:line-height line-height
|
||||
@ -338,7 +395,7 @@
|
||||
(when (seq (border-radius context-data))
|
||||
[{:title "Border Radius" :submenu :border-radius}])
|
||||
[:separator]
|
||||
(stroke-width (assoc context-data :on-update-shape dwta/update-stroke-width))
|
||||
(stroke-width-menu-entries context-data)
|
||||
[:separator]
|
||||
(generic-attribute-actions #{:x} "X" (assoc context-data :on-update-shape dwta/update-shape-position :hint (tr "workspace.tokens.axis")))
|
||||
(generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape dwta/update-shape-position)))
|
||||
@ -514,6 +571,7 @@
|
||||
token (mf/deref (refs/workspace-token-in-selected-set token-id))
|
||||
token-type (:type token)
|
||||
selected-token-set-id (mf/deref refs/selected-token-set-id)
|
||||
render-wasm? (features/use-feature "render-wasm/v1")
|
||||
|
||||
selected-shapes
|
||||
(mf/with-memo [selected objects]
|
||||
@ -528,6 +586,7 @@
|
||||
[:& menu-tree {:submenu-offset width
|
||||
:token token
|
||||
:errors errors
|
||||
:render-wasm render-wasm?
|
||||
:selected-token-set-id selected-token-set-id
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
|
||||
@ -55,10 +55,19 @@
|
||||
|
||||
;; Color
|
||||
:fill "Fill"
|
||||
:stroke-color "Stroke Color"})
|
||||
:stroke-color "Stroke Color"
|
||||
|
||||
;; Stroke Width
|
||||
:stroke-width-top "Top"
|
||||
:stroke-width-right "Right"
|
||||
:stroke-width-bottom "Bottom"
|
||||
:stroke-width-left "Left"})
|
||||
|
||||
(def ^:private dimensions-dictionary
|
||||
{:stroke-width :stroke-width
|
||||
{:stroke-width-top :stroke-width
|
||||
:stroke-width-right :stroke-width
|
||||
:stroke-width-bottom :stroke-width
|
||||
:stroke-width-left :stroke-width
|
||||
:p1 :spacing
|
||||
:p2 :spacing
|
||||
:p3 :spacing
|
||||
|
||||
@ -214,17 +214,16 @@
|
||||
(fill->color (first fills)))))
|
||||
|
||||
(defn stroke-per-side-widths
|
||||
"Returns [top right bottom left] when the stroke has per-side widths
|
||||
enabled and the sides actually differ; nil otherwise."
|
||||
"Returns [top right bottom left] when the stroke sides actually
|
||||
differ; nil otherwise."
|
||||
[stroke]
|
||||
(when (:stroke-per-side stroke)
|
||||
(let [width (:stroke-width stroke)
|
||||
top (d/nilv (:stroke-width-top stroke) width)
|
||||
right (d/nilv (:stroke-width-right stroke) width)
|
||||
bottom (d/nilv (:stroke-width-bottom stroke) width)
|
||||
left (d/nilv (:stroke-width-left stroke) width)]
|
||||
(when-not (= top right bottom left)
|
||||
[top right bottom left]))))
|
||||
(let [width (:stroke-width stroke)
|
||||
top (d/nilv (:stroke-width-top stroke) width)
|
||||
right (d/nilv (:stroke-width-right stroke) width)
|
||||
bottom (d/nilv (:stroke-width-bottom stroke) width)
|
||||
left (d/nilv (:stroke-width-left stroke) width)]
|
||||
(when-not (= top right bottom left)
|
||||
[top right bottom left])))
|
||||
|
||||
(defn- get-border
|
||||
[shape]
|
||||
|
||||
@ -367,7 +367,7 @@
|
||||
(ctht/apply-token-to-shape :frame1 "token-radius" [:r1 :r2 :r3 :r4] [:r1 :r2 :r3 :r4] 10)
|
||||
(ctht/apply-token-to-shape :frame1 "token-rotation" [:rotation] [:rotation] 30)
|
||||
(ctht/apply-token-to-shape :frame1 "token-opacity" [:opacity] [:opacity] 0.7)
|
||||
(ctht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width] [:stroke-width] 2)
|
||||
(ctht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left] [:stroke-width] 2)
|
||||
(ctht/apply-token-to-shape :frame1 "token-color" [:stroke-color] [:stroke-color] "#00ff00")
|
||||
(ctht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00")
|
||||
(ctht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100)
|
||||
@ -408,14 +408,17 @@
|
||||
tokens-frame1' (:applied-tokens c-frame1')]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (= (count tokens-frame1') 11))
|
||||
(t/is (= (count tokens-frame1') 14))
|
||||
(t/is (= (get tokens-frame1' :r1) "token-radius"))
|
||||
(t/is (= (get tokens-frame1' :r2) "token-radius"))
|
||||
(t/is (= (get tokens-frame1' :r3) "token-radius"))
|
||||
(t/is (= (get tokens-frame1' :r4) "token-radius"))
|
||||
(t/is (= (get tokens-frame1' :rotation) "token-rotation"))
|
||||
(t/is (= (get tokens-frame1' :opacity) "token-opacity"))
|
||||
(t/is (= (get tokens-frame1' :stroke-width) "token-stroke-width"))
|
||||
(t/is (= (get tokens-frame1' :stroke-width-top) "token-stroke-width"))
|
||||
(t/is (= (get tokens-frame1' :stroke-width-right) "token-stroke-width"))
|
||||
(t/is (= (get tokens-frame1' :stroke-width-bottom) "token-stroke-width"))
|
||||
(t/is (= (get tokens-frame1' :stroke-width-left) "token-stroke-width"))
|
||||
(t/is (= (get tokens-frame1' :stroke-color) "token-color"))
|
||||
(t/is (= (get tokens-frame1' :fill) "token-color"))
|
||||
(t/is (= (get tokens-frame1' :width) "token-dimensions"))
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
[frontend-tests.svg-fills-test]
|
||||
[frontend-tests.svg-filters-test]
|
||||
[frontend-tests.text-editor-paste-guard-test]
|
||||
[frontend-tests.tokens.context-menu-test]
|
||||
[frontend-tests.tokens.copy-paste-props-test]
|
||||
[frontend-tests.tokens.import-export-test]
|
||||
[frontend-tests.tokens.logic.token-actions-test]
|
||||
@ -95,6 +96,7 @@
|
||||
[frontend-tests.tokens.token-errors-test]
|
||||
[frontend-tests.tokens.workspace-tokens-remap-test]
|
||||
[frontend-tests.ui.check-updates-test]
|
||||
[frontend-tests.ui.color-token-position-test]
|
||||
[frontend-tests.ui.colorpicker-token-set-order-test]
|
||||
[frontend-tests.ui.comment-input-ime-test]
|
||||
[frontend-tests.ui.comments-clustering-test]
|
||||
@ -110,6 +112,7 @@
|
||||
[frontend-tests.ui.settings-shortcuts-test]
|
||||
[frontend-tests.ui.shortcuts-labels-test]
|
||||
[frontend-tests.ui.sidebar-scroll-test]
|
||||
[frontend-tests.ui.stroke-menu-test]
|
||||
[frontend-tests.util-clipboard-test]
|
||||
[frontend-tests.util-object-test]
|
||||
[frontend-tests.util-queue-test]
|
||||
@ -214,6 +217,7 @@
|
||||
'frontend-tests.svg-fills-test
|
||||
'frontend-tests.svg-filters-test
|
||||
'frontend-tests.tokens.copy-paste-props-test
|
||||
'frontend-tests.tokens.context-menu-test
|
||||
'frontend-tests.tokens.import-export-test
|
||||
'frontend-tests.tokens.logic.token-actions-test
|
||||
'frontend-tests.tokens.logic.token-data-test
|
||||
@ -223,6 +227,7 @@
|
||||
'frontend-tests.tokens.logic.tokens-status-test
|
||||
'frontend-tests.tokens.workspace-tokens-remap-test
|
||||
'frontend-tests.ui.check-updates-test
|
||||
'frontend-tests.ui.color-token-position-test
|
||||
'frontend-tests.ui.colorpicker-token-set-order-test
|
||||
'frontend-tests.ui.comment-input-ime-test
|
||||
'frontend-tests.ui.comments-clustering-test
|
||||
@ -240,6 +245,7 @@
|
||||
'frontend-tests.ui.settings-shortcuts-test
|
||||
'frontend-tests.ui.shortcuts-labels-test
|
||||
'frontend-tests.ui.sidebar-scroll-test
|
||||
'frontend-tests.ui.stroke-menu-test
|
||||
'frontend-tests.util-clipboard-test
|
||||
'frontend-tests.util-object-test
|
||||
'frontend-tests.util-queue-test
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
(ns frontend-tests.tokens.context-menu-test
|
||||
(:require
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.test-helpers.tokens :as tht]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.types.tokens-status :as ctos]
|
||||
[app.config :as cf]
|
||||
[app.main.ui.workspace.tokens.management.context-menu :as wtcm]
|
||||
[app.util.i18n :as i18n]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
|
||||
(def ^:private all-label (i18n/tr "labels.all"))
|
||||
|
||||
(def ^:private per-side-flags (conj cf/flags :stroke-per-side))
|
||||
|
||||
(defn setup-file []
|
||||
(-> (tht/sample-file-with-tokens
|
||||
:lib-fn #(-> %
|
||||
@ -46,6 +53,10 @@
|
||||
(ctob/make-token :name "token-dimensions"
|
||||
:type :dimensions
|
||||
:value 10))
|
||||
(ctob/add-token (thi/id :test-token-set)
|
||||
(ctob/make-token :name "token-stroke-width"
|
||||
:type :stroke-width
|
||||
:value 10))
|
||||
(ctob/add-token (thi/id :test-token-set)
|
||||
(ctob/make-token :name "token-number"
|
||||
:type :number
|
||||
@ -56,18 +67,26 @@
|
||||
;; app.main.data.workspace.tokens.application/rect-attributes
|
||||
(tho/add-rect :rect1)
|
||||
;; app.main.data.workspace.tokens.application/frame-attributes
|
||||
(tho/add-frame :frame1)
|
||||
(tho/add-frame :frame1 :layout :flex)
|
||||
;; app.main.data.workspace.tokens.application/text-attributes
|
||||
(tho/add-text :text1 "Hello World!")))
|
||||
|
||||
(defn token-menu-actions [shape-names token-name]
|
||||
(defn- menu-actions*
|
||||
[shape-names token-name extra]
|
||||
(let [file (setup-file)
|
||||
token-set "test-token-set"
|
||||
token (tht/get-token file token-set token-name)
|
||||
token (ctob/get-token-by-name (tht/get-tokens-lib file) "test-token-set" token-name)
|
||||
selected-shapes (map #(ths/get-shape file %) shape-names)]
|
||||
(wtcm/menu-actions
|
||||
{:token token
|
||||
:selected-shapes selected-shapes})))
|
||||
(merge {:token token
|
||||
:render-wasm true
|
||||
:selected-shapes selected-shapes}
|
||||
extra))))
|
||||
|
||||
(defn token-menu-actions [shape-names token-name]
|
||||
(menu-actions* shape-names token-name {}))
|
||||
|
||||
(defn submenu-actions [shape-names token-name submenu-type]
|
||||
(menu-actions* shape-names token-name {:type submenu-type}))
|
||||
|
||||
(defn token-menu-action-labels [actions]
|
||||
(mapv #(if (keyword? %) % (:title %)) actions))
|
||||
@ -76,12 +95,12 @@
|
||||
(t/testing "shows radius items for selection of supported shapes"
|
||||
(let [actions (token-menu-actions [:frame1 :rect1] "token-radius")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["All" "Top Right" "Bottom Right" "Top Left" "Bottom Left"]))))
|
||||
(t/is (= action-titles [all-label "Top Right" "Bottom Right" "Top Left" "Bottom Left"]))))
|
||||
|
||||
(t/testing "shows radius items for mixed selection"
|
||||
(let [actions (token-menu-actions [:frame1 :text1] "token-radius")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["All" "Top Right" "Bottom Right" "Top Left" "Bottom Left"]))))
|
||||
(t/is (= action-titles [all-label "Top Right" "Bottom Right" "Top Left" "Bottom Left"]))))
|
||||
|
||||
(t/testing "hides radius for unrelated shapes"
|
||||
(let [actions (token-menu-actions [:text1 :group1] "token-radius")]
|
||||
@ -94,30 +113,26 @@
|
||||
(t/is (= action-titles ["Fill" "Stroke"])))))
|
||||
|
||||
(t/deftest spacing-items
|
||||
(t/testing "shows spacing items for selection of supported shapes"
|
||||
(t/testing "shows gap and padding items for layout frames"
|
||||
(let [actions (token-menu-actions [:frame1] "token-spacing")
|
||||
action-titles (mapv #(if (keyword? %) % (:title %)) actions)]
|
||||
(t/is (= action-titles ["All" "Column Gap" "Row Gap"
|
||||
(t/is (= action-titles [all-label "Column Gap" "Row Gap"
|
||||
:separator
|
||||
"All" "Horizontal" "Vertical"
|
||||
all-label "Horizontal" "Vertical"
|
||||
"Padding top" "Padding right" "Padding bottom" "Padding left"
|
||||
:separator
|
||||
"All" "Horizontal" "Vertical"
|
||||
"Margin top" "Margin right" "Margin bottom" "Margin left"]))))
|
||||
:separator]))))
|
||||
|
||||
(t/testing "shows radius items for mixed selection"
|
||||
(t/testing "shows gap and padding items for mixed selection"
|
||||
(let [actions (token-menu-actions [:frame1 :text1] "token-spacing")
|
||||
action-titles (mapv #(if (keyword? %) % (:title %)) actions)]
|
||||
(t/is (= action-titles ["All" "Column Gap" "Row Gap"
|
||||
(t/is (= action-titles [all-label "Column Gap" "Row Gap"
|
||||
:separator
|
||||
"All" "Horizontal" "Vertical"
|
||||
all-label "Horizontal" "Vertical"
|
||||
"Padding top" "Padding right" "Padding bottom" "Padding left"
|
||||
:separator
|
||||
"All" "Horizontal" "Vertical"
|
||||
"Margin top" "Margin right" "Margin bottom" "Margin left"]))))
|
||||
:separator]))))
|
||||
|
||||
(t/testing "hides radius for unrelated shapes"
|
||||
(let [actions (token-menu-actions [:text1 :group1] "token-radius")]
|
||||
(t/testing "hides spacing for unrelated shapes"
|
||||
(let [actions (token-menu-actions [:text1 :group1] "token-spacing")]
|
||||
(t/is (empty? actions)))))
|
||||
|
||||
(t/deftest sizing-items
|
||||
@ -125,11 +140,11 @@
|
||||
(let [actions (token-menu-actions [:frame1 :rect1 :group1 :text1] "token-sizing")
|
||||
action-titles (mapv #(if (keyword? %) % (:title %)) actions)]
|
||||
|
||||
(t/is (= action-titles ["All" "Width" "Height"
|
||||
(t/is (= action-titles [all-label "Width" "Height"
|
||||
:separator
|
||||
"All" "Min Width" "Min Height"
|
||||
all-label "Min Width" "Min Height"
|
||||
:separator
|
||||
"All" "Max Width" "Max Height"]))))
|
||||
all-label "Max Width" "Max Height"]))))
|
||||
|
||||
(t/testing "shows no sizing items for groups"
|
||||
(let [actions (token-menu-actions [:group1] "token-sizing")]
|
||||
@ -193,11 +208,73 @@
|
||||
(t/is (= action-titles ["Rotation"])))))
|
||||
|
||||
(t/deftest stroke-width-items
|
||||
(t/testing "shows stroke width items for all shapes"
|
||||
(let [actions (token-menu-actions [:frame1 :rect1 :group1 :text1] "token-dimensions")
|
||||
stroke-width-action (first (filter #(and (map? %) (= (:title %) "Stroke Width")) actions))]
|
||||
(t/is (some? stroke-width-action))
|
||||
(t/is (= (:title stroke-width-action) "Stroke Width")))))
|
||||
(t/testing "shows a single global item when per-side is disabled"
|
||||
(doseq [shape [:rect1 :frame1 :text1]]
|
||||
(let [actions (token-menu-actions [shape] "token-stroke-width")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"])))))
|
||||
|
||||
(t/testing "shows a single global item for mixed selections when per-side is disabled"
|
||||
(let [actions (token-menu-actions [:rect1 :text1] "token-stroke-width")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"])))))
|
||||
|
||||
(t/deftest stroke-width-items-per-side
|
||||
(with-redefs [cf/flags per-side-flags]
|
||||
(t/testing "shows per-side items for boards and rectangles"
|
||||
(doseq [shape [:rect1 :frame1]]
|
||||
(let [actions (token-menu-actions [shape] "token-stroke-width")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= (set action-titles) (set [all-label "Top" "Right" "Bottom" "Left"]))))))
|
||||
|
||||
(t/testing "shows a single global item for other shapes"
|
||||
(let [actions (token-menu-actions [:text1] "token-stroke-width")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"]))))
|
||||
|
||||
(t/testing "shows a single global item for mixed selections"
|
||||
(let [actions (token-menu-actions [:rect1 :text1] "token-stroke-width")
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"]))))
|
||||
|
||||
(t/testing "submenu contains the per-side items for rectangles"
|
||||
(let [actions (submenu-actions [:rect1] "token-dimensions" :stroke-width)
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= (set action-titles) (set [all-label "Top" "Right" "Bottom" "Left"])))))
|
||||
|
||||
(t/testing "submenu falls back to the global item for other shapes"
|
||||
(let [actions (submenu-actions [:text1] "token-dimensions" :stroke-width)
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"]))))
|
||||
|
||||
(t/testing "dimensions menu shows the stroke width submenu for rectangles"
|
||||
(let [actions (token-menu-actions [:rect1] "token-dimensions")
|
||||
action-titles (mapv #(if (keyword? %) % (select-keys % [:title :submenu])) actions)]
|
||||
(t/is (some #(= {:title "Stroke Width" :submenu :stroke-width} %) action-titles))))))
|
||||
|
||||
(t/deftest stroke-width-items-classic-renderer
|
||||
(t/testing "per-side is not offered when the WASM renderer is off"
|
||||
(with-redefs [cf/flags per-side-flags]
|
||||
(t/testing "shows a single global item for boards and rectangles"
|
||||
(doseq [shape [:rect1 :frame1]]
|
||||
(let [actions (menu-actions* [shape] "token-stroke-width" {:render-wasm false})
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"])))))
|
||||
|
||||
(t/testing "shows a single global item for mixed selections"
|
||||
(let [actions (menu-actions* [:rect1 :text1] "token-stroke-width" {:render-wasm false})
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"]))))
|
||||
|
||||
(t/testing "the stroke-width submenu falls back to the global item"
|
||||
(let [actions (menu-actions* [:rect1] "token-dimensions" {:type :stroke-width :render-wasm false})
|
||||
action-titles (mapv :title actions)]
|
||||
(t/is (= action-titles ["Stroke Width"]))))
|
||||
|
||||
(t/testing "the dimensions menu has no stroke-width submenu"
|
||||
(let [actions (menu-actions* [:rect1] "token-dimensions" {:render-wasm false})
|
||||
action-titles (mapv #(if (keyword? %) % (select-keys % [:title :submenu])) actions)]
|
||||
(t/is (not (some #(= {:title "Stroke Width" :submenu :stroke-width} %) action-titles))))))))
|
||||
|
||||
(t/deftest opacity-items
|
||||
(t/testing "shows opacity items for all shapes"
|
||||
|
||||
@ -12,8 +12,10 @@
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.test-helpers.tokens :as ctht]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.token :as ctt]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.types.tokens-status :as ctos]
|
||||
[app.main.data.workspace.colors :as dc]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
@ -545,9 +547,9 @@
|
||||
rect-with-stroke (cths/get-shape file :rect-1)
|
||||
rect-without-stroke (cths/get-shape file :rect-2)
|
||||
events [(dwta/apply-token {:shape-ids [(:id rect-with-stroke) (:id rect-without-stroke)]
|
||||
:attributes #{:stroke-width}
|
||||
:attributes ctt/per-side-stroke-width-keys
|
||||
:token (toht/get-token file "stroke-width.sm")
|
||||
:on-update-shape dwta/update-stroke-width})]]
|
||||
:on-update-shape dwta/update-stroke-width-side})]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
@ -556,12 +558,448 @@
|
||||
rect-with-stroke' (cths/get-shape file' :rect-1)
|
||||
rect-without-stroke' (cths/get-shape file' :rect-2)]
|
||||
(t/testing "token got applied to rect with stroke and shape stroke got updated"
|
||||
(t/is (= (:stroke-width (:applied-tokens rect-with-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect-with-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect-with-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect-with-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect-with-stroke')) (:name token-target')))
|
||||
(t/is (= (get-in rect-with-stroke' [:strokes 0 :stroke-width]) 10)))
|
||||
(t/testing "token got applied to rect without stroke and shape stroke got updated"
|
||||
(t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect-without-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect-without-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect-without-stroke')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect-without-stroke')) (:name token-target')))
|
||||
(t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10))))))))))
|
||||
|
||||
(t/deftest test-apply-stroke-width-per-side-new-shape
|
||||
(t/testing "applying a stroke-width token to one side of a shape without strokes zeroes the other sides"
|
||||
(t/async
|
||||
done
|
||||
(let [stroke-width-token {:name "stroke-width.sm"
|
||||
:value "8"
|
||||
:type :stroke-width}
|
||||
file (-> (setup-file-with-tokens)
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token stroke-width-token))))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dwta/apply-token {:shape-ids [(:id rect)]
|
||||
:attributes #{:stroke-width-top}
|
||||
:token (toht/get-token file "stroke-width.sm")
|
||||
:on-update-shape dwta/update-stroke-width-side})]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the applied side gets the token value"
|
||||
(t/is (= (:stroke-width-top stroke') 8)))
|
||||
(t/testing "the remaining sides go to zero"
|
||||
(t/is (= (:stroke-width-right stroke') 0))
|
||||
(t/is (= (:stroke-width-bottom stroke') 0))
|
||||
(t/is (= (:stroke-width-left stroke') 0)))
|
||||
(t/testing "the global width mirrors the top side"
|
||||
(t/is (= (:stroke-width stroke') 8)))
|
||||
(t/testing "only the applied side records the token"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect')) "stroke-width.sm"))
|
||||
(t/is (nil? (:stroke-width-right (:applied-tokens rect'))))
|
||||
(t/is (nil? (:stroke-width-bottom (:applied-tokens rect'))))
|
||||
(t/is (nil? (:stroke-width-left (:applied-tokens rect'))))))))))))
|
||||
|
||||
(t/deftest test-apply-stroke-width-per-side-keeps-other-sides
|
||||
(t/testing "applying a stroke-width token to one side preserves the other sides"
|
||||
(t/async
|
||||
done
|
||||
(let [stroke-width-token {:name "stroke-width.sm"
|
||||
:value "10"
|
||||
:type :stroke-width}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token stroke-width-token))))
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dwta/apply-token {:shape-ids [(:id rect)]
|
||||
:attributes #{:stroke-width-top}
|
||||
:token (toht/get-token file "stroke-width.sm")
|
||||
:on-update-shape dwta/update-stroke-width-side})]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the applied side gets the token value"
|
||||
(t/is (= (:stroke-width-top stroke') 10)))
|
||||
(t/testing "the remaining sides keep their width"
|
||||
(t/is (= (:stroke-width-right stroke') 5))
|
||||
(t/is (= (:stroke-width-bottom stroke') 5))
|
||||
(t/is (= (:stroke-width-left stroke') 5)))
|
||||
(t/testing "the global width mirrors the top side"
|
||||
(t/is (= (:stroke-width stroke') 10))))))))))
|
||||
|
||||
(t/deftest test-change-stroke-width-side-no-propagation
|
||||
(t/testing "editing the top side of a stroke with only a global width does not propagate to the other sides"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/change-stroke-side-width [(:id rect)] :stroke-width-top 10 0)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the edited side gets the new value"
|
||||
(t/is (= (:stroke-width-top stroke') 10)))
|
||||
(t/testing "the remaining sides keep their width"
|
||||
(t/is (= (:stroke-width-right stroke') 5))
|
||||
(t/is (= (:stroke-width-bottom stroke') 5))
|
||||
(t/is (= (:stroke-width-left stroke') 5)))
|
||||
(t/testing "the global width mirrors the top side"
|
||||
(t/is (= (:stroke-width stroke') 10))))))))))
|
||||
|
||||
(t/deftest test-change-stroke-width-side-keeps-other-token
|
||||
(t/testing "editing one side does not unapply a token applied to another side"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5
|
||||
:stroke-width-top 2
|
||||
:stroke-width-right 9}]
|
||||
:applied-tokens {:stroke-width-right "stroke-width.sm"}}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/change-stroke-side-width [(:id rect)] :stroke-width-top 10 0)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the edited side changes"
|
||||
(t/is (= (:stroke-width-top stroke') 10)))
|
||||
(t/testing "a side with its own value keeps it"
|
||||
(t/is (= (:stroke-width-right stroke') 9)))
|
||||
(t/testing "the token on the untouched side is not unapplied"
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect')) "stroke-width.sm"))))))))))
|
||||
|
||||
(t/deftest test-change-stroke-width-side-later-stroke-keeps-first-token
|
||||
(t/testing "editing a side of a later stroke does not unapply a token on the first stroke"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5
|
||||
:stroke-width-top 2}
|
||||
{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}]
|
||||
:applied-tokens {:stroke-width-top "stroke-width.sm"}}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/change-stroke-side-width [(:id rect)] :stroke-width-top 10 1)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke-0 (get-in rect' [:strokes 0])
|
||||
stroke-1 (get-in rect' [:strokes 1])]
|
||||
(t/testing "the edited second stroke gets the new value"
|
||||
(t/is (= (:stroke-width-top stroke-1) 10)))
|
||||
(t/testing "the first stroke keeps its width"
|
||||
(t/is (= (:stroke-width-top stroke-0) 2)))
|
||||
(t/testing "the token on the first stroke is not unapplied"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect')) "stroke-width.sm"))))))))))
|
||||
|
||||
(t/deftest test-remove-later-stroke-keeps-first-token
|
||||
(t/testing "removing a later stroke does not unapply tokens on the first stroke"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}
|
||||
{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}]
|
||||
:applied-tokens {:stroke-width-top "stroke-width.sm"
|
||||
:stroke-color "color.sm"}}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/remove-stroke [(:id rect)] 1)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)]
|
||||
(t/testing "only the first stroke remains"
|
||||
(t/is (= 1 (count (:strokes rect')))))
|
||||
(t/testing "the tokens on the first stroke are kept"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect')) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-color (:applied-tokens rect')) "color.sm"))))))))))
|
||||
|
||||
(t/deftest test-reorder-later-strokes-keeps-first-token
|
||||
(t/testing "reordering later strokes does not unapply tokens on the first stroke"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5
|
||||
:stroke-width-top 2}
|
||||
{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#111111"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 3}
|
||||
{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#222222"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 4}]
|
||||
:applied-tokens {:stroke-width-top "stroke-width.sm"}}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/reorder-strokes [(:id rect)] 1 3)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)]
|
||||
(t/testing "the token on the first stroke is kept"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect')) "stroke-width.sm")))
|
||||
(t/testing "the first stroke is still the first one"
|
||||
(t/is (= "#000000" (get-in rect' [:strokes 0 :stroke-color]))))
|
||||
(t/testing "the second and third strokes are swapped"
|
||||
(t/is (= "#222222" (get-in rect' [:strokes 1 :stroke-color])))
|
||||
(t/is (= "#111111" (get-in rect' [:strokes 2 :stroke-color])))))))))))
|
||||
|
||||
(t/deftest test-change-stroke-width-side-new-shape
|
||||
(t/testing "editing a side of a shape without strokes creates a stroke and zeroes the other sides"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens)
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/change-stroke-side-width [(:id rect)] :stroke-width-top 8 0)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the edited side gets the value"
|
||||
(t/is (= (:stroke-width-top stroke') 8)))
|
||||
(t/testing "the remaining sides go to zero"
|
||||
(t/is (= (:stroke-width-right stroke') 0))
|
||||
(t/is (= (:stroke-width-bottom stroke') 0))
|
||||
(t/is (= (:stroke-width-left stroke') 0)))
|
||||
(t/testing "the global width mirrors the top side"
|
||||
(t/is (= (:stroke-width stroke') 8))))))))))
|
||||
|
||||
(t/deftest test-change-stroke-width-side-right-does-not-touch-global
|
||||
(t/testing "editing a non-top side leaves the global width intact"
|
||||
(t/async
|
||||
done
|
||||
(let [file (setup-file-with-tokens
|
||||
{:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5
|
||||
:stroke-width-top 2}]}})
|
||||
store (ths/setup-store file)
|
||||
rect (cths/get-shape file :rect-1)
|
||||
events [(dc/change-stroke-side-width [(:id rect)] :stroke-width-right 7 0)]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect' [:strokes 0])]
|
||||
(t/testing "the right side gets the new value"
|
||||
(t/is (= (:stroke-width-right stroke') 7)))
|
||||
(t/testing "the global width keeps mirroring the top side"
|
||||
(t/is (= (:stroke-width stroke') 2)))
|
||||
(t/testing "the other sides keep their width"
|
||||
(t/is (= (:stroke-width-bottom stroke') 5))
|
||||
(t/is (= (:stroke-width-left stroke') 5))))))))))
|
||||
|
||||
(t/deftest test-apply-dimensions-token-to-stroke-width-per-side
|
||||
(t/testing "applying a dimension token to stroke width updates every side"
|
||||
(t/async
|
||||
done
|
||||
(let [dimensions-token {:name "dimensions.md"
|
||||
:value "8"
|
||||
:type :dimensions}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5
|
||||
:stroke-width-top 2
|
||||
:stroke-width-right 3
|
||||
:stroke-width-bottom 4
|
||||
:stroke-width-left 6}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token dimensions-token))))
|
||||
store (ths/setup-store file)
|
||||
rect-1 (cths/get-shape file :rect-1)
|
||||
events [(dwta/apply-token {:shape-ids [(:id rect-1)]
|
||||
:attributes ctt/per-side-stroke-width-keys
|
||||
:token (toht/get-token file "dimensions.md")
|
||||
:on-update-shape dwta/update-stroke-width-side})]]
|
||||
(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' "dimensions.md")
|
||||
rect-1' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect-1' [:strokes 0])]
|
||||
(t/testing "every side gets the resolved value"
|
||||
(t/is (= (:stroke-width-top stroke') 8))
|
||||
(t/is (= (:stroke-width-right stroke') 8))
|
||||
(t/is (= (:stroke-width-bottom stroke') 8))
|
||||
(t/is (= (:stroke-width-left stroke') 8))
|
||||
(t/is (= (:stroke-width stroke') 8)))
|
||||
(t/testing "the token is recorded on every side"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect-1')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect-1')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect-1')) (:name token-target')))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect-1')) (:name token-target')))))))))))
|
||||
|
||||
(t/deftest test-apply-stroke-width-overwrites-per-side-token
|
||||
(t/testing "applying token to all sides overwrites per-side token"
|
||||
(t/async
|
||||
done
|
||||
(let [token-sm {:name "stroke-width.sm"
|
||||
:value "6"
|
||||
:type :stroke-width}
|
||||
token-lg {:name "stroke-width.lg"
|
||||
:value "12"
|
||||
:type :stroke-width}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
(fn [lib]
|
||||
(-> lib
|
||||
(ctob/add-token (cthi/id :set-a) (ctob/make-token token-sm))
|
||||
(ctob/add-token (cthi/id :set-a) (ctob/make-token token-lg))))))
|
||||
store (ths/setup-store file)
|
||||
rect-1 (cths/get-shape file :rect-1)
|
||||
step2 (fn [state1]
|
||||
(let [file1 (ths/get-file-from-state state1)
|
||||
rect1 (cths/get-shape file1 :rect-1)]
|
||||
;; Verify token-sm applied to top only
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect1)) "stroke-width.sm"))
|
||||
(t/is (nil? (:stroke-width-right (:applied-tokens rect1))))
|
||||
;; Second: apply token-lg to all 4 sides
|
||||
(let [events2 [(dwta/apply-token {:shape-ids [(:id rect1)]
|
||||
:attributes ctt/per-side-stroke-width-keys
|
||||
:token (toht/get-token file1 "stroke-width.lg")
|
||||
:on-update-shape dwta/update-stroke-width})]]
|
||||
(tohs/run-store-async
|
||||
(ths/setup-store file1) done events2
|
||||
(fn [state2]
|
||||
(let [file2 (ths/get-file-from-state state2)
|
||||
rect2 (cths/get-shape file2 :rect-1)]
|
||||
(t/testing "token-lg overwrites all sides"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect2)) "stroke-width.lg"))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect2)) "stroke-width.lg"))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect2)) "stroke-width.lg"))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect2)) "stroke-width.lg"))
|
||||
(t/is (= (get-in rect2 [:strokes 0 :stroke-width]) 12)))))))))]
|
||||
;; First: apply token-sm to top side only
|
||||
(tohs/run-store-async
|
||||
store (constantly nil)
|
||||
[(dwta/apply-token {:shape-ids [(:id rect-1)]
|
||||
:attributes #{:stroke-width-top}
|
||||
:token (toht/get-token file "stroke-width.sm")
|
||||
:on-update-shape dwta/update-stroke-width-side})]
|
||||
step2)))))
|
||||
|
||||
(t/deftest test-detach-token-from-single-side
|
||||
(t/testing "detaching token from one side leaves other sides intact"
|
||||
(t/async
|
||||
done
|
||||
(let [token {:name "stroke-width.sm"
|
||||
:value "10"
|
||||
:type :stroke-width}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token token))))
|
||||
store (ths/setup-store file)
|
||||
rect-1 (cths/get-shape file :rect-1)
|
||||
step2 (fn [state1]
|
||||
(let [file1 (ths/get-file-from-state state1)
|
||||
rect1 (cths/get-shape file1 :rect-1)]
|
||||
;; Verify all sides have the token
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect1)) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect1)) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect1)) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect1)) "stroke-width.sm"))
|
||||
;; Second: unapply token from top side only
|
||||
(let [events2 [(dwta/unapply-token {:token-name "stroke-width.sm"
|
||||
:attributes #{:stroke-width-top}
|
||||
:shape-ids [(:id rect1)]})]]
|
||||
(tohs/run-store-async
|
||||
(ths/setup-store file1) done events2
|
||||
(fn [state2]
|
||||
(let [file2 (ths/get-file-from-state state2)
|
||||
rect2 (cths/get-shape file2 :rect-1)]
|
||||
(t/testing "top side token detached"
|
||||
(t/is (nil? (:stroke-width-top (:applied-tokens rect2)))))
|
||||
(t/testing "other sides retain the token"
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect2)) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect2)) "stroke-width.sm"))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect2)) "stroke-width.sm")))))))))]
|
||||
;; First: apply token to all 4 sides
|
||||
(tohs/run-store-async
|
||||
store (constantly nil)
|
||||
[(dwta/apply-token {:shape-ids [(:id rect-1)]
|
||||
:attributes ctt/per-side-stroke-width-keys
|
||||
:token (toht/get-token file "stroke-width.sm")
|
||||
:on-update-shape dwta/update-stroke-width})]
|
||||
step2)))))
|
||||
|
||||
(t/deftest test-apply-shadow
|
||||
(t/testing "applies shadow token and updates the shapes with shadow"
|
||||
(t/async
|
||||
@ -954,6 +1392,75 @@
|
||||
(t/is (= (:r1 (:applied-tokens rect-without-token')) (:name target-token)))
|
||||
(t/is (= (:r1 (:applied-tokens rect-with-other-token-2')) (:name target-token)))))))))))
|
||||
|
||||
(t/deftest test-toggle-token-completes-partial-per-side-application
|
||||
(t/testing "toggling a token already applied to some sides applies it to all"
|
||||
(t/async
|
||||
done
|
||||
(let [stroke-width-token {:name "strokeWidth.md" :value "10" :type :stroke-width}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token stroke-width-token)))
|
||||
(toht/apply-token-to-shape :rect-1 "strokeWidth.md"
|
||||
#{:stroke-width-right :stroke-width-bottom}))
|
||||
store (ths/setup-store file)
|
||||
rect-1 (cths/get-shape file :rect-1)
|
||||
events [(dwta/toggle-token {:shape-ids [(:id rect-1)]
|
||||
:token (toht/get-token file "strokeWidth.md")
|
||||
:attrs ctt/per-side-stroke-width-keys})]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect-1' (cths/get-shape file' :rect-1)
|
||||
stroke' (get-in rect-1' [:strokes 0])]
|
||||
(t/testing "the token is applied to every side"
|
||||
(t/is (= (:stroke-width-top (:applied-tokens rect-1')) "strokeWidth.md"))
|
||||
(t/is (= (:stroke-width-right (:applied-tokens rect-1')) "strokeWidth.md"))
|
||||
(t/is (= (:stroke-width-bottom (:applied-tokens rect-1')) "strokeWidth.md"))
|
||||
(t/is (= (:stroke-width-left (:applied-tokens rect-1')) "strokeWidth.md")))
|
||||
|
||||
(t/testing "every side gets the resolved value"
|
||||
(t/is (= (:stroke-width-top stroke') 10))
|
||||
(t/is (= (:stroke-width-right stroke') 10))
|
||||
(t/is (= (:stroke-width-bottom stroke') 10))
|
||||
(t/is (= (:stroke-width-left stroke') 10))))))))))
|
||||
|
||||
(t/deftest test-toggle-token-explicit-attrs-unapplies-when-fully-applied
|
||||
(t/testing "toggling a token applied to every side removes it"
|
||||
(t/async
|
||||
done
|
||||
(let [stroke-width-token {:name "strokeWidth.md" :value "10" :type :stroke-width}
|
||||
file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner
|
||||
:stroke-style :solid
|
||||
:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 5}]}})
|
||||
(update-in [:data :tokens-lib]
|
||||
#(ctob/add-token % (cthi/id :set-a)
|
||||
(ctob/make-token stroke-width-token)))
|
||||
(toht/apply-token-to-shape :rect-1 "strokeWidth.md"
|
||||
ctt/per-side-stroke-width-keys))
|
||||
store (ths/setup-store file)
|
||||
rect-1 (cths/get-shape file :rect-1)
|
||||
events [(dwta/toggle-token {:shape-ids [(:id rect-1)]
|
||||
:token (toht/get-token file "strokeWidth.md")
|
||||
:attrs ctt/per-side-stroke-width-keys})]]
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect-1' (cths/get-shape file' :rect-1)
|
||||
applied' (:applied-tokens rect-1')]
|
||||
(t/is (nil? (:stroke-width-top applied')))
|
||||
(t/is (nil? (:stroke-width-right applied')))
|
||||
(t/is (nil? (:stroke-width-bottom applied')))
|
||||
(t/is (nil? (:stroke-width-left applied'))))))))))
|
||||
|
||||
(t/deftest test-toggle-spacing-token
|
||||
(t/testing "applies spacing token only to layouts and layout children"
|
||||
(t/async
|
||||
|
||||
@ -172,7 +172,7 @@
|
||||
|
||||
{:case :stroke-width
|
||||
:token {:name "stroke-width-token" :type :stroke-width :value "2"}
|
||||
:attribute :stroke-width
|
||||
:attribute :stroke-width-top
|
||||
:shape :rect-shape}
|
||||
|
||||
{:case :text-case
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.ui.color-token-position-test
|
||||
(:require
|
||||
[app.main.ui.workspace.sidebar.options.common :as soc]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
;; https://github.com/penpot/penpot/issues/11819
|
||||
;; Design tokens only apply to the first fill or stroke in a shape's list.
|
||||
;; The sidebar rows and the color picker must not offer token controls on any
|
||||
;; later entry.
|
||||
|
||||
(t/deftest tokens-allowed-only-on-first-position-test
|
||||
(t/testing "the first entry of a position-limited list allows tokens"
|
||||
(t/is (true? (soc/tokens-allowed-position? true 0))))
|
||||
|
||||
(t/testing "later entries of a position-limited list do not allow tokens"
|
||||
(t/is (false? (soc/tokens-allowed-position? true 1)))
|
||||
(t/is (false? (soc/tokens-allowed-position? true 5))))
|
||||
|
||||
(t/testing "a missing index in a position-limited list does not allow tokens"
|
||||
(t/is (false? (soc/tokens-allowed-position? true nil))))
|
||||
|
||||
(t/testing "lists that are not position-limited always allow tokens"
|
||||
(t/is (true? (soc/tokens-allowed-position? false 0)))
|
||||
(t/is (true? (soc/tokens-allowed-position? false 3)))
|
||||
(t/is (true? (soc/tokens-allowed-position? nil 3)))))
|
||||
@ -7,7 +7,7 @@
|
||||
(ns frontend-tests.ui.ds-controls-numeric-input-test
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.main.ui.ds.controls.numeric-input :refer [next-focus-index parse-value]]
|
||||
[app.main.ui.ds.controls.numeric-input :refer [next-focus-index parse-value token-shortcut]]
|
||||
[app.main.ui.formats :as fmt]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
@ -101,6 +101,20 @@
|
||||
{:id "d" :type :separator :name "---"}
|
||||
{:id "e" :type :item :name "Echo"}])
|
||||
|
||||
(t/deftest test-token-shortcut
|
||||
(t/testing "opens the token dropdown on { when tokens are enabled"
|
||||
(t/is (= :open (token-shortcut #js {:key "{"} false))))
|
||||
|
||||
(t/testing "applies the token on } when tokens are enabled"
|
||||
(t/is (= :apply (token-shortcut #js {:key "}"} false))))
|
||||
|
||||
(t/testing "ignores other keys"
|
||||
(t/is (nil? (token-shortcut #js {:key "a"} false))))
|
||||
|
||||
(t/testing "is inert when tokens are disabled for the input"
|
||||
(t/is (nil? (token-shortcut #js {:key "{"} true)))
|
||||
(t/is (nil? (token-shortcut #js {:key "}"} true)))))
|
||||
|
||||
(t/deftest test-next-focus-index
|
||||
(t/testing "returns index of next focusable item going down"
|
||||
(t/is (= 2 (next-focus-index sample-options "a" :down))))
|
||||
|
||||
56
frontend/test/frontend_tests/ui/stroke_menu_test.cljs
Normal file
56
frontend/test/frontend_tests/ui/stroke_menu_test.cljs
Normal file
@ -0,0 +1,56 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.ui.stroke-menu-test
|
||||
(:require
|
||||
[app.config :as cf]
|
||||
[app.main.ui.workspace.sidebar.options.menus.stroke :as stroke]
|
||||
[clojure.test :as t :include-macros true]))
|
||||
|
||||
(def ^:private per-side-flags (conj cf/flags :stroke-per-side))
|
||||
|
||||
(def ^:private objects
|
||||
{:rect-a {:type :rect}
|
||||
:frame-a {:type :frame}
|
||||
:text-a {:type :text}})
|
||||
|
||||
(t/deftest stroke-width-all-attrs-test
|
||||
(t/testing "sets the global width and every independent side"
|
||||
(t/is (= (stroke/stroke-width-all-attrs 5)
|
||||
{:stroke-width 5
|
||||
:stroke-width-top 5
|
||||
:stroke-width-right 5
|
||||
:stroke-width-bottom 5
|
||||
:stroke-width-left 5}))))
|
||||
|
||||
(t/deftest per-side-stroke-available-test
|
||||
(with-redefs [cf/flags per-side-flags]
|
||||
(t/testing "single boards and rectangles are available"
|
||||
(t/is (true? (stroke/per-side-stroke-available? :rect :multiple [:rect-a] objects)))
|
||||
(t/is (true? (stroke/per-side-stroke-available? :frame :multiple [:frame-a] objects))))
|
||||
|
||||
(t/testing "other single shapes are not available"
|
||||
(t/is (false? (stroke/per-side-stroke-available? :circle :multiple [:text-a] objects)))
|
||||
(t/is (false? (stroke/per-side-stroke-available? :text :multiple [:text-a] objects))))
|
||||
|
||||
(t/testing "a multi-selection of equal board/rect strokes is available"
|
||||
(t/is (true? (stroke/per-side-stroke-available? :multiple [] [:rect-a :frame-a] objects))))
|
||||
|
||||
(t/testing "a mixed multi-selection is not available"
|
||||
(t/is (false? (stroke/per-side-stroke-available? :multiple [] [:rect-a :text-a] objects))))
|
||||
|
||||
(t/testing "a multi-selection with mixed strokes is not available"
|
||||
(t/is (false? (stroke/per-side-stroke-available? :multiple :multiple [:rect-a :frame-a] objects))))))
|
||||
|
||||
(t/deftest per-side-stroke-enabled-test
|
||||
(t/testing "enabled only with the feature flag and the WASM renderer"
|
||||
(with-redefs [cf/flags per-side-flags]
|
||||
(t/is (true? (stroke/per-side-stroke-enabled? true)))
|
||||
(t/is (false? (stroke/per-side-stroke-enabled? false)))))
|
||||
|
||||
(t/testing "disabled when the feature flag is off"
|
||||
(with-redefs [cf/flags (disj cf/flags :stroke-per-side)]
|
||||
(t/is (false? (stroke/per-side-stroke-enabled? true))))))
|
||||
@ -8903,6 +8903,10 @@ msgstr "Tokens source sets or themes have been modified"
|
||||
msgid "workspace.tokens.num-active-sets"
|
||||
msgstr "%s active sets"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:178, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:184, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:198, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:200, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:207, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:209, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:408, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:497, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:512, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:527, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:542
|
||||
msgid "workspace.tokens.only-first-fill-or-stroke"
|
||||
msgstr "Design tokens can only be applied to the first fill or stroke in the list."
|
||||
|
||||
#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104
|
||||
msgid "workspace.tokens.open-source-new-tab"
|
||||
msgstr "Open tokens' source in a new tab"
|
||||
@ -8919,7 +8923,11 @@ msgstr "Paddings"
|
||||
msgid "workspace.tokens.radius"
|
||||
msgstr "Radius"
|
||||
|
||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:58, src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs:60, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:123, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:130, src/app/main/ui/workspace/tokens/management/token_pill.cljs:130
|
||||
#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:287
|
||||
msgid "workspace.tokens.stroke-width"
|
||||
msgstr "Stroke Width"
|
||||
|
||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:47, src/app/main/ui/workspace/tokens/management/token_pill.cljs:130
|
||||
msgid "workspace.tokens.ref-not-valid"
|
||||
msgstr "Reference in {%s} is not valid or is not in any active set."
|
||||
|
||||
|
||||
@ -8981,6 +8981,11 @@ msgstr "Los sets o temas de la fuente de tokens se han modificado"
|
||||
msgid "workspace.tokens.num-active-sets"
|
||||
msgstr "%s sets activos"
|
||||
|
||||
msgid "workspace.tokens.only-first-fill-or-stroke"
|
||||
msgstr ""
|
||||
"Los tokens de diseño solo se pueden aplicar al primer relleno o trazo de la "
|
||||
"lista."
|
||||
|
||||
#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104
|
||||
msgid "workspace.tokens.open-source-new-tab"
|
||||
msgstr "Abrir la fuente de tokens en una pestaña nueva"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user