From 822dbd6517cc4bd7c64fd73098f813a4f437194f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 12:05:25 +0200 Subject: [PATCH] :zap: Speed up sidebar menus for large selections (#12028) Reuse extracted colors for unchanged shapes and cache the text attrs read from each text content. --- .../src/app/main/data/workspace/colors.cljs | 76 +++++++++---------- .../options/menus/color_selection.cljs | 33 +++++++- .../sidebar/options/shapes/multiple.cljs | 16 +++- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/text_attrs_multiple_test.cljs | 40 ++++++++++ 5 files changed, 124 insertions(+), 43 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index c99f98239b..be0f95156c 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -1351,6 +1351,43 @@ :shape-id (:shape-id fill) :index (:index fill)}))) +(defn shape-colors + [shape file-id libraries] + (let [applied-tokens (:applied-tokens shape) + applied-fill (get applied-tokens :fill) + applied-stroke (get applied-tokens :stroke-color) + fills (:fills shape) + strokes (:strokes shape) + shadows (:shadow shape) + shape-id (:id shape) + + fills* (map-indexed + (fn [index fill] + (cond-> (assoc fill :shape-id shape-id :index index) + (and (zero? index) applied-fill) + (assoc :has-token-applied true + :token-name applied-fill))) + fills) + + strokes* (map-indexed + (fn [index stroke] + (cond-> (assoc stroke :shape-id shape-id :index index) + (and (zero? index) applied-stroke) + (assoc :has-token-applied true + :token-name applied-stroke))) + strokes) + + shadows* (map-indexed #(assoc %2 :shape-id shape-id :index %1) shadows)] + (if (= :text (:type shape)) + (-> [] + (into (keep #(stroke->color-att % file-id libraries)) strokes*) + (into (map #(shadow->color-attr % file-id libraries)) shadows*) + (into (extract-text-colors shape file-id libraries))) + (-> [] + (into (keep #(fill->color-att % file-id libraries)) fills*) + (into (keep #(stroke->color-att % file-id libraries)) strokes*) + (into (map #(shadow->color-attr % file-id libraries)) shadows*))))) + (defn extract-all-colors "Extracts color information from a list of shapes, including fills, strokes, and shadows. If a shape has applied tokens of type :fill or :stroke-color, the first fill or stroke @@ -1366,41 +1403,4 @@ Returns: A vector of color attribute maps with metadata for each shape." [shapes file-id libraries] - (reduce - (fn [result shape] - (let [applied-tokens (:applied-tokens shape) - applied-fill (get applied-tokens :fill) - applied-stroke (get applied-tokens :stroke-color) - fills (:fills shape) - strokes (:strokes shape) - shadows (:shadow shape) - shape-id (:id shape) - - fills* (map-indexed - (fn [index fill] - (cond-> (assoc fill :shape-id shape-id :index index) - (and (zero? index) applied-fill) - (assoc :has-token-applied true - :token-name applied-fill))) - fills) - - strokes* (map-indexed - (fn [index stroke] - (cond-> (assoc stroke :shape-id shape-id :index index) - (and (zero? index) applied-stroke) - (assoc :has-token-applied true - :token-name applied-stroke))) - strokes) - - shadows* (map-indexed #(assoc %2 :shape-id shape-id :index %1) shadows)] - (if (= :text (:type shape)) - (-> result - (into (keep #(stroke->color-att % file-id libraries)) strokes*) - (into (map #(shadow->color-attr % file-id libraries)) shadows*) - (into (extract-text-colors shape file-id libraries))) - (-> result - (into (keep #(fill->color-att % file-id libraries)) fills*) - (into (keep #(stroke->color-att % file-id libraries)) strokes*) - (into (map #(shadow->color-attr % file-id libraries)) shadows*))))) - [] - shapes)) + (into [] (mapcat #(shape-colors % file-id libraries)) shapes)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index cda718388c..5db6007cf7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.types.library :as ctl] [app.main.data.workspace.colors :as dwc] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.tokens.application :as dwta] @@ -17,6 +18,17 @@ [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) +(defn- cached-shape-colors + [prev-cache cache libraries-colors shape file-id libraries] + (let [inputs [(:type shape) (:fills shape) (:strokes shape) (:shadow shape) + (:applied-tokens shape) (:content shape) file-id libraries-colors] + [cached-inputs cached] (when prev-cache (.get prev-cache (:id shape))) + colors (if (and cached-inputs (every? true? (map identical? cached-inputs inputs))) + cached + (dwc/shape-colors shape file-id libraries))] + (.set cache (:id shape) [inputs colors]) + colors)) + (defn- prepare-colors "Prepares and groups extracted color information from shapes. Input: @@ -62,8 +74,11 @@ This structure allows fast lookups of all shapes using the same visual color, regardless of whether it comes from local fills, strokes or shadow-colors." - [shapes file-id libraries] - (let [data (into [] (remove nil?) (dwc/extract-all-colors shapes file-id libraries)) + [prev-cache cache libraries-colors shapes file-id libraries] + (let [data (into [] + (comp (mapcat #(cached-shape-colors prev-cache cache libraries-colors % file-id libraries)) + (remove nil?)) + shapes) groups (d/group-by :attrs #(dissoc % :attrs) data) ;; Unique color attribute maps @@ -98,9 +113,19 @@ (mf/defc color-selection-menu* {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes"]))]} [{:keys [shapes file-id libraries]}] - (let [{:keys [groups library-colors colors token-colors]} + (let [cache* (mf/use-ref nil) + libraries-colors* (mf/use-ref nil) + + {:keys [groups library-colors colors token-colors]} (mf/with-memo [file-id shapes libraries] - (prepare-colors shapes file-id libraries)) + (let [current (mapv (fn [[id library]] [id (ctl/get-colors (:data library))]) libraries) + previous (mf/ref-val libraries-colors*) + libraries-colors (if (= previous current) previous current) + cache (js/Map.) + result (prepare-colors (mf/ref-val cache*) cache libraries-colors shapes file-id libraries)] + (mf/set-ref-val! libraries-colors* libraries-colors) + (mf/set-ref-val! cache* cache) + result)) open* (mf/use-state true) open? (deref open*) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 8c4cad66b7..ce7f53496d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -262,6 +262,20 @@ (assoc :layout-padding (merge-layout-padding values shape-values)))] (promote-simple-layout-padding-type merged-values))) +(def ^:private content-attrs-cache (js/WeakMap.)) + +(defn- get-content-attrs + "Moving a text keeps its `:content`, so the attrs read from it are cached by content." + [{:keys [content] :as shape} attrs] + (if (some? content) + (let [[cached-attrs cached] (.get content-attrs-cache content)] + (if (identical? cached-attrs attrs) + cached + (let [result (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs)] + (.set content-attrs-cache content [attrs result]) + result))) + (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs))) + (defn get-attrs* "Given a group of attributes that we want to extract and the shapes to extract them from returns a list of tuples [id, values] with the extracted properties for the shapes that @@ -359,7 +373,7 @@ (let [shape-attrs (select-keys shape attrs) content-attrs - (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs) + (get-content-attrs shape attrs) new-values (-> values diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5a0110743e..349cdfd029 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -117,6 +117,7 @@ [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] + [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] @@ -254,6 +255,7 @@ 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test + 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test diff --git a/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs b/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs new file mode 100644 index 0000000000..4bf8018be6 --- /dev/null +++ b/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs @@ -0,0 +1,40 @@ +;; 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.text-attrs-multiple-test + (:require + [app.main.ui.workspace.sidebar.options.shapes.multiple :as multiple] + [cljs.test :as t :include-macros true])) + +(defn- content + [font-family] + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "a" :font-family font-family}]}]}]}) + +(def ^:private inter (content "Inter")) + +(def ^:private objects + {:board-1 {:id :board-1 :type :frame :shapes [:inner :text-1]} + :inner {:id :inner :type :frame :shapes [:text-2]} + :board-2 {:id :board-2 :type :frame :shapes [:text-3 :rect]} + :text-1 {:id :text-1 :type :text :content inter :applied-tokens {:typography "body"}} + :text-2 {:id :text-2 :type :text :content inter :applied-tokens {:typography "body"}} + :text-3 {:id :text-3 :type :text :content (content "Roboto") :applied-tokens {:typography "body"}} + :rect {:id :rect :type :rect}}) + +(defn- text-attrs + [labels objects] + (multiple/get-attrs* (map objects labels) objects :text)) + +(t/deftest boards-read-nested-text-values + (let [[ids values tokens] (text-attrs [:board-1] objects)] + (t/is (= [:text-2 :text-1] ids)) + (t/is (= "Inter" (:font-family values))) + (t/is (= "body" (:typography tokens)))) + (let [[_ values] (text-attrs [:board-1 :board-2] objects)] + (t/is (= :multiple (:font-family values)))))