diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 43797c6e17..30d6067889 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -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 diff --git a/common/src/app/common/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs index 816c154cab..c8f71571e5 100644 --- a/common/src/app/common/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -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) diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs index eb2e42f57c..f7a26712bc 100644 --- a/common/src/app/common/render_wasm/api/upload.cljs +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -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) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 166c041f3b..80f2e608f6 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -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] diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index 792b56244a..2a95b57713 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -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}) \ No newline at end of file + :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)}))) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index b7f641709e..2ae1adc1aa 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -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] diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 8ca70b93e0..544af2ff9d 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -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) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index d1ae2bbce5..68e47e824e 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -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 diff --git a/common/test/common_tests/types/stroke_test.cljc b/common/test/common_tests/types/stroke_test.cljc new file mode 100644 index 0000000000..0e5726fcdb --- /dev/null +++ b/common/test/common_tests/types/stroke_test.cljc @@ -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))))) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index 3cded0dc4f..e29cd1b77b 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -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") diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 8418c2b6a2..b758696423 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -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, diff --git a/frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js b/frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js new file mode 100644 index 0000000000..73bd31655b --- /dev/null +++ b/frontend/playwright/ui/specs/tokens/stroke-per-side.spec.js @@ -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/, + ); + }); +}); diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index 433d9fea6f..c99f98239b 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -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] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 9c3878298a..0a170541cc 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -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? diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 8708fad699..13bfd42689 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -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 diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c6158548e5..84f6bd72f2 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 416669041d..56b24b18b6 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -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 diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index 10de19e494..ba4350b1e0 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -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); diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 3465e6a70c..8cfc39c798 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index 84aa1325b8..ad8bcc2a9c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -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)))) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 8863bbeedc..72a85acfa4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs index 5ca03e3d29..ea2265720a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs @@ -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])) \ No newline at end of file + [:> numeric-input* props])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index 1bd5bd2270..6b7cf0055f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index fd070ef0cf..a870362a86 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -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}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 1f84979d27..dd774a3390 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index b9c114bf67..d979f472a4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index d546bc9497..2c19792332 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -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 diff --git a/frontend/src/app/util/code_gen/style_css_values.cljs b/frontend/src/app/util/code_gen/style_css_values.cljs index 0b1dc28c7f..4052adba14 100644 --- a/frontend/src/app/util/code_gen/style_css_values.cljs +++ b/frontend/src/app/util/code_gen/style_css_values.cljs @@ -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] diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 3e0bc8f579..dee8c555e1 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -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")) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 85c980e34f..7e718bbf5d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/frontend/test/frontend_tests/tokens/context_menu_test.cljs b/frontend/test/frontend_tests/tokens/context_menu_test.cljs index 7094b28536..e50cf10915 100644 --- a/frontend/test/frontend_tests/tokens/context_menu_test.cljs +++ b/frontend/test/frontend_tests/tokens/context_menu_test.cljs @@ -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" diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 0377053128..7ba6ac9632 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -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 diff --git a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs index 40c20f1ce0..e83289c949 100644 --- a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs +++ b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs @@ -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 diff --git a/frontend/test/frontend_tests/ui/color_token_position_test.cljs b/frontend/test/frontend_tests/ui/color_token_position_test.cljs new file mode 100644 index 0000000000..1fede63161 --- /dev/null +++ b/frontend/test/frontend_tests/ui/color_token_position_test.cljs @@ -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))))) diff --git a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs index 4fe508ce56..9d9a7cd1a3 100644 --- a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs +++ b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/ui/stroke_menu_test.cljs b/frontend/test/frontend_tests/ui/stroke_menu_test.cljs new file mode 100644 index 0000000000..e219333e99 --- /dev/null +++ b/frontend/test/frontend_tests/ui/stroke_menu_test.cljs @@ -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)))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b2b0291b8e..e44efe4093 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 10e30b6bf3..ec04834dfb 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"