diff --git a/CHANGES.md b/CHANGES.md index 943e6afba4..dc1616d59c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,6 +19,7 @@ - Copy and paste entire rows in existing table (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8498) - Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137) - Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568) +- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713) ### :bug: Bugs fixed diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 414179753c..2b9748183d 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1126,6 +1126,51 @@ (let [value (format-precision value precision)] (str value)))))) +(defn- natural-sort-key + "Splits a string into a sequence of alternating string and number segments, + converting numeric segments to longs/ints so they compare by value rather + than lexicographically. e.g. \"size10b\" => (\"size\" 10 \"b\")" + [s] + (map (fn [part] + (if (re-matches #"\d+" part) + #?(:clj (Long/parseLong part) + :cljs (js/parseInt part)) + part)) + (re-seq #"\d+|\D+" s))) + +(defn- natural-compare + "Comparator that orders strings naturally, sorting numeric segments by value + rather than lexicographically. Returns a negative number, zero, or positive + number when a is before, equal to, or after b respectively. + e.g. \"size2\" < \"size10\" instead of \"size10\" < \"size2\"." + [a b] + (loop [ka (natural-sort-key a) + kb (natural-sort-key b)] + (cond + (and (empty? ka) (empty? kb)) 0 + (empty? ka) -1 + (empty? kb) 1 + :else + (let [pa (first ka) + pb (first kb) + result (cond + (and (number? pa) (number? pb)) (compare pa pb) + (and (string? pa) (string? pb)) (compare pa pb) + (number? pa) -1 + :else 1)] + (if (zero? result) + (recur (rest ka) (rest kb)) + result))))) + +(defn natural-sort-by + "Sorts coll by extracting a string key with keyfn and ordering elements + using natural sort order, where embedded numbers are compared by value + rather than lexicographically. + e.g. (natural-sort-by :name [{:name \"size10\"} {:name \"size2\"}]) + => [{:name \"size2\"} {:name \"size10\"}]" + [key coll] + (sort-by key natural-compare coll)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index c4cbe4c100..cdc01a077c 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -137,3 +137,46 @@ (t/is (= (d/nth-index-of "abc*def*ghi" "*" 1) 3)) (t/is (= (d/nth-index-of "abc*def*ghi" "*" 2) 7)) (t/is (= (d/nth-index-of "abc*def*ghi" "*" 3) nil))) + +(t/deftest natural-sort-by-test + (t/is (= (d/natural-sort-by identity ["10" "2" "1" "11" "3" "30"]) + ["1" "2" "3" "10" "11" "30"])) + (t/is (= (d/natural-sort-by identity ["banana" "apple" "cherry"]) + ["apple" "banana" "cherry"])) + (t/is (= (d/natural-sort-by identity ["size10" "size2" "size1" "size20" "size3"]) + ["size1" "size2" "size3" "size10" "size20"])) + (t/is (= (d/natural-sort-by identity ["b1" "a2" "a10" "a1"]) + ["a1" "a2" "a10" "b1"])) + (t/is (= (d/natural-sort-by identity []) [])) + (t/is (= (d/natural-sort-by identity ["solo"]) ["solo"])) + (t/is (= (d/natural-sort-by identity ["b" "a" "a" "c"]) + ["a" "a" "b" "c"])) + (t/is (= (d/natural-sort-by :name + [{:name "big"} {:name "small"} {:name "medium"}]) + [{:name "big"} {:name "medium"} {:name "small"}])) + (t/is (= (d/natural-sort-by :name + [{:name "size10"} {:name "size2"} {:name "size1"}]) + [{:name "size1"} {:name "size2"} {:name "size10"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-radius-10"} + {:name "border-radius-2"} + {:name "border-radius-1"}]) + [{:name "border-radius-1"} + {:name "border-radius-2"} + {:name "border-radius-10"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-10-radius"} + {:name "border-2-radius"} + {:name "border-1-radius"}]) + [{:name "border-1-radius"} + {:name "border-2-radius"} + {:name "border-10-radius"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-10-radius"} + {:name "border-2-extra"} + {:name "border-2-radius"} + {:name "border-1-radius"}]) + [{:name "border-1-radius"} + {:name "border-2-extra"} + {:name "border-2-radius"} + {:name "border-10-radius"}]))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index c08c1cd618..3fdc067bd0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.tokens.management.token-tree (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.path-names :as cpn] [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.library-edit :as dwtl] @@ -69,8 +70,8 @@ [:div {:class (stl/css :folder-children-wrapper) :id (str "folder-children-" (:path node))} (when children-fn - (let [children (children-fn)] - (for [child children] + (let [sorted-children (d/natural-sort-by :name (children-fn))] + (for [child sorted-children] (if (not (:leaf child)) [:ul {:class (stl/css :node-parent) :key (:path child)} @@ -127,7 +128,8 @@ tree (mf/use-memo (mf/deps tokens) (fn [] - (cpn/build-tree-root tokens separator))) + (->> (cpn/build-tree-root tokens separator) + (d/natural-sort-by :name)))) can-edit? (:can-edit (deref refs/permissions)) on-node-context-menu (mf/use-fn (mf/deps can-edit? on-node-context-menu)