From 7ea99d8494d09cc2ebdaa2ad3d6673ae3cd573f1 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 9 Apr 2026 16:57:18 +0200 Subject: [PATCH] :wrench: Improve layer items lag --- .../main/data/workspace/thumbnails_wasm.cljs | 11 +- .../main/ui/workspace/sidebar/layer_item.cljs | 109 ++++++------- .../app/main/ui/workspace/sidebar/layers.cljs | 144 ++++++------------ 3 files changed, 104 insertions(+), 160 deletions(-) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 44459e9df8..7d4e3a2b66 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -44,13 +44,13 @@ (defn- render-component-pixels "Renders a component frame using the workspace WASM context. Returns an observable that emits a data-uri string. - Deferred by one animation frame so that process-shape-changes! - has time to sync all child shapes to WASM memory first." + Deferred with setTimeout so that React can paint pending state + updates before the synchronous WASM render blocks the main thread." [frame-id] (rx/create (fn [subs] - (js/requestAnimationFrame - (fn [_] + (js/setTimeout + (fn [] (try (let [png-bytes (wasm.api/render-shape-pixels frame-id 1)] (if (or (nil? png-bytes) (zero? (.-length png-bytes))) @@ -63,7 +63,8 @@ (fn [err] (rx/error! subs err))))) (catch :default err - (rx/error! subs err))))) + (rx/error! subs err)))) + 0) nil))) (defn render-thumbnail diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index b17698d659..2138c34c6f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -201,41 +201,22 @@ (mf/defc layer-item* {::mf/wrap [mf/memo]} - [{:keys [index item selected objects rename-id + [{:keys [index item-id selected rename-id is-sortable is-filtered depth is-component-child - highlighted style render-children parent-size] + style render-children parent-size] :or {render-children true}}] - (let [id (get item :id) + (let [item-iref (mf/with-memo [item-id] + (l/derived (fn [objects] (get objects item-id)) refs/workspace-page-objects)) + item (mf/deref item-iref) + + id item-id blocked? (get item :blocked) hidden? (get item :hidden) - shapes (get item :shapes) - shapes (mf/with-memo [shapes objects] - (loop [counter 0 - shapes (seq shapes) - result (list)] - - (if-let [id (first shapes)] - (if-let [obj (get objects id)] - (do - ;; NOTE: this is a bit hacky, but reduces substantially - ;; the allocation; If we use enumeration, we allocate - ;; new sequence and add one iteration on each render, - ;; independently if objects are changed or not. If we - ;; store counter on metadata, we still need to create a - ;; new allocation for each shape; with this method we - ;; bypass this by mutating a private property on the - ;; object removing extra allocation and extra iteration - ;; on every request. - (unchecked-set obj "__$__counter" counter) - (recur (inc counter) - (rest shapes) - (conj result obj))) - (recur (inc counter) - (rest shapes) - result)) - - (-> result vec not-empty)))) + ;; child shape IDs reversed for display (top z-order first in layer panel) + child-ids-raw (get item :shapes) + child-ids (mf/with-memo [child-ids-raw] + (some-> child-ids-raw rseq vec)) drag-disabled* (mf/use-state false) drag-disabled? (deref drag-disabled*) @@ -245,8 +226,11 @@ (l/derived #(dm/get-in % [:expanded id]) refs/workspace-local)) is-expanded (mf/deref expanded-iref) + highlighted-iref (mf/with-memo [id] + (l/derived #(contains? (get % :highlighted) id) refs/workspace-local)) + is-selected (contains? selected id) - is-highlighted (contains? highlighted id) + is-highlighted (mf/deref highlighted-iref) container? (or (cfh/frame-shape? item) (cfh/group-shape? item)) @@ -303,14 +287,15 @@ select-shape (mf/use-fn - (mf/deps id is-filtered objects) + (mf/deps id is-filtered) (fn [event] (dom/prevent-default event) (mf/set-ref-val! scroll-middle-ref false) (cond (kbd/shift? event) (if is-filtered - (st/emit! (dw/shift-select-shapes id objects)) + (let [objects (deref refs/workspace-page-objects)] + (st/emit! (dw/shift-select-shapes id objects))) (st/emit! (dw/shift-select-shapes id))) (kbd/mod? event) @@ -363,13 +348,14 @@ on-drop (mf/use-fn - (mf/deps id objects is-expanded selected) + (mf/deps id is-expanded selected) (fn [side _data] (let [single? (= (count selected) 1) same? (and single? (= (first selected) id))] (when-not same? - (let [files (deref refs/files) - shape (get objects id) + (let [objects (deref refs/workspace-page-objects) + files (deref refs/files) + shape (get objects id) parent-id (cond @@ -421,20 +407,22 @@ :on-hold on-hold :disabled drag-disabled? :detect-center? container? - :data {:id (:id item) + :data {:id id :index index :name (:name item)} ;; We don't want to change the structure of component copies :draggable? (and ^boolean is-sortable ^boolean (not is-read-only) - ^boolean (not (ctn/has-any-copy-parent? objects item)))) + ^boolean (not (let [objects (deref refs/workspace-page-objects)] + (ctn/has-any-copy-parent? objects item))))) on-tab-press (mf/use-fn - (mf/deps id objects) + (mf/deps id) (fn [event] (when (contains? cf/flags :canary) - (let [shift? (kbd/shift? event) + (let [objects (deref refs/workspace-page-objects) + shift? (kbd/shift? event) shape (get objects id) parent (get objects (:parent-id shape)) siblings (:shapes parent) @@ -473,15 +461,15 @@ ;; Setup scroll-driven lazy loading when expanded ;; and ensures selected children are loaded immediately - (mf/with-effect [is-expanded shapes selected] - (let [total (count shapes)] + (mf/with-effect [is-expanded child-ids selected] + (let [total (count child-ids)] (if ^boolean is-expanded (let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec ;; Find if any selected id is a direct child and get its render index selected-child-render-idx (when (> total default-chunk-size) (some (fn [sel-id] - (let [idx (.indexOf shapes sel-id)] + (let [idx (.indexOf child-ids sel-id)] (when (>= idx 0) idx))) selected)) @@ -508,9 +496,9 @@ (mf/set-ref-val! obs nil))))) ;; Re-observe sentinel whenever children-count changes (sentinel moves) - ;; and (shapes item) to reconnect observer after shape changes - (mf/with-effect [children-count is-expanded shapes] - (let [total (count shapes) + ;; and child-ids to reconnect observer after shape changes + (mf/with-effect [children-count is-expanded child-ids] + (let [total (count child-ids) name-node (mf/ref-val name-node-ref) scroll-node (dom/get-parent-with-data name-node "scroll-container") lazy-node (mf/ref-val lazy-ref)] @@ -565,27 +553,26 @@ :style style} (when (and ^boolean render-children - ^boolean shapes + ^boolean (seq child-ids) ^boolean is-expanded) [:div {:class (stl/css-case :element-children true :parent-selected is-selected :sticky-children root-board?) :data-testid (dm/str "children-" id)} - (for [item (take children-count shapes)] - [:> layer-item* - {:item item - :rename-id rename-id - :highlighted highlighted - :selected selected - :index (unchecked-get item "__$__counter") - :objects objects - :key (dm/str (get item :id)) - :is-sortable is-sortable - :depth depth - :parent-size parent-size - :is-component-child is-component-tree}]) + (let [total (count child-ids)] + (for [[display-idx child-id] (d/enumerate (take children-count child-ids))] + [:> layer-item* + {:item-id child-id + :rename-id rename-id + :selected selected + :index (- total 1 display-idx) + :key (dm/str child-id) + :is-sortable is-sortable + :depth depth + :parent-size parent-size + :is-component-child is-component-tree}])) - (when (< children-count (count shapes)) + (when (< children-count (count child-ids)) [:div {:ref lazy-ref :class (stl/css :lazy-load-sentinel)}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 49433489c6..9f77dbdf8d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -34,13 +34,6 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private ref:highlighted-shapes - (l/derived (fn [local] - (-> local - (get :highlighted) - (not-empty))) - refs/workspace-local)) - (def ^:private ref:shape-for-rename (l/derived (l/key :shape-for-rename) refs/workspace-local)) @@ -78,95 +71,61 @@ [:> layer-item* props])) +(defn- use-root-shape-ids + "Subscribe to the root shape's children IDs only, using = equality + so the component only re-renders when the list of IDs actually changes." + [] + (let [ref (mf/with-memo [] + (l/derived (fn [objects] + (get-in objects [uuid/zero :shapes])) + refs/workspace-page-objects + =)) + ids (mf/deref ref)] + ids)) + (mf/defc layers-tree* {::mf/wrap [mf/memo]} - [{:keys [objects is-filtered parent-size] :as props}] + [{:keys [is-filtered parent-size]}] (let [selected (use-selected-shapes) - highlighted (mf/deref ref:highlighted-shapes) - root (get objects uuid/zero) - rename-id (mf/deref ref:shape-for-rename) - - shapes (get root :shapes) - shapes (mf/with-memo [shapes objects] - (loop [counter 0 - shapes (seq shapes) - result (list)] - (if-let [id (first shapes)] - (if-let [obj (get objects id)] - (do - ;; NOTE: this is a bit hacky, but reduces substantially - ;; the allocation; If we use enumeration, we allocate - ;; new sequence and add one iteration on each render, - ;; independently if objects are changed or not. If we - ;; store counter on metadata, we still need to create a - ;; new allocation for each shape; with this method we - ;; bypass this by mutating a private property on the - ;; object removing extra allocation and extra iteration - ;; on every request. - (unchecked-set obj "__$__counter" counter) - (recur (inc counter) - (rest shapes) - (conj result obj))) - (recur (inc counter) - (rest shapes) - result)) - result)))] + root-ids (use-root-shape-ids) + objects (deref refs/workspace-page-objects)] [:div {:class (stl/css :element-list) :data-testid "layer-item"} [:> hooks/sortable-container* {} - (for [obj shapes] - (if (cfh/frame-shape? obj) - [:> frame-wrapper* - {:item obj - :rename-id rename-id - :selected selected - :highlighted highlighted - :index (unchecked-get obj "__$__counter") - :objects objects - :key (dm/str (get obj :id)) - :is-sortable true - :is-filtered is-filtered - :parent-size parent-size - :depth -1}] - [:> layer-item* - {:item obj - :rename-id rename-id - :selected selected - :highlighted highlighted - :index (unchecked-get obj "__$__counter") - :objects objects - :key (dm/str (get obj :id)) - :is-sortable true - :is-filtered is-filtered - :depth -1 - :parent-size parent-size}]))]])) - + ;; Layers display top z-order first, so reverse the shapes vector + (let [total (count root-ids)] + (for [[display-idx id] (d/enumerate (reverse root-ids))] + (let [index (- total 1 display-idx) + obj (get objects id)] + (when obj + (if (cfh/frame-shape? obj) + [:> frame-wrapper* + {:item-id id + :rename-id rename-id + :selected selected + :index index + :key (dm/str id) + :is-sortable true + :is-filtered is-filtered + :parent-size parent-size + :depth -1}] + [:> layer-item* + {:item-id id + :rename-id rename-id + :selected selected + :index index + :key (dm/str id) + :is-sortable true + :is-filtered is-filtered + :depth -1 + :parent-size parent-size}])))))]])) (mf/defc layers-tree-wrapper* {::mf/private true} - [{:keys [objects] :as props}] - ;; This is a performance sensitive componet, so we use lower-level primitives for - ;; reduce residual allocation for this specific case - (let [state-tmp (mf/useState objects) - objects' (aget state-tmp 0) - set-objects (aget state-tmp 1) - - subject-s (mf/with-memo [] - (rx/subject)) - changes-s (mf/with-memo [subject-s] - (->> subject-s - (rx/debounce 500))) - - props (mf/spread-props props {:objects objects'})] - - (mf/with-effect [objects subject-s] - (rx/push! subject-s objects)) - - (mf/with-effect [changes-s] - (let [sub (rx/subscribe changes-s set-objects)] - #(rx/dispose! sub))) - - [:> layers-tree* props])) + [props] + ;; layers-tree* now self-subscribes to objects per-item; no need to + ;; debounce the full objects map. Just pass through. + [:> layers-tree* props]) (mf/defc filters-tree* {::mf/wrap [mf/memo #(mf/throttle % 300)] @@ -176,12 +135,11 @@ root (get objects uuid/zero)] [:ul {:class (stl/css :element-list)} (for [[index id] (d/enumerate (:shapes root))] - (when-let [obj (get objects id)] + (when (some? (get objects id)) [:> layer-item* - {:item obj + {:item-id id :selected selected :index index - :objects objects :key id :is-sortable false :is-filtered true @@ -602,8 +560,7 @@ :data-scroll-container true :style {:display (when (some? filtered-objects) "none")}} - [:> layers-tree-wrapper* {:objects filtered-objects - :key (dm/str page-id) + [:> layers-tree-wrapper* {:key (dm/str page-id) :is-filtered true :parent-size size-parent}]]] @@ -611,7 +568,6 @@ :class (stl/css :tool-window-content) :data-scroll-container true :style {:display (when (some? filtered-objects) "none")}} - [:> layers-tree-wrapper* {:objects objects - :key (dm/str page-id) + [:> layers-tree-wrapper* {:key (dm/str page-id) :is-filtered false :parent-size size-parent}]])]))