🔧 Improve layer items lag

This commit is contained in:
Elena Torro 2026-04-09 16:57:18 +02:00
parent 3264bc746f
commit 7ea99d8494
3 changed files with 104 additions and 160 deletions

View File

@ -44,13 +44,13 @@
(defn- render-component-pixels (defn- render-component-pixels
"Renders a component frame using the workspace WASM context. "Renders a component frame using the workspace WASM context.
Returns an observable that emits a data-uri string. Returns an observable that emits a data-uri string.
Deferred by one animation frame so that process-shape-changes! Deferred with setTimeout so that React can paint pending state
has time to sync all child shapes to WASM memory first." updates before the synchronous WASM render blocks the main thread."
[frame-id] [frame-id]
(rx/create (rx/create
(fn [subs] (fn [subs]
(js/requestAnimationFrame (js/setTimeout
(fn [_] (fn []
(try (try
(let [png-bytes (wasm.api/render-shape-pixels frame-id 1)] (let [png-bytes (wasm.api/render-shape-pixels frame-id 1)]
(if (or (nil? png-bytes) (zero? (.-length png-bytes))) (if (or (nil? png-bytes) (zero? (.-length png-bytes)))
@ -63,7 +63,8 @@
(fn [err] (fn [err]
(rx/error! subs err))))) (rx/error! subs err)))))
(catch :default err (catch :default err
(rx/error! subs err))))) (rx/error! subs err))))
0)
nil))) nil)))
(defn render-thumbnail (defn render-thumbnail

View File

@ -201,41 +201,22 @@
(mf/defc layer-item* (mf/defc layer-item*
{::mf/wrap [mf/memo]} {::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 is-sortable is-filtered depth is-component-child
highlighted style render-children parent-size] style render-children parent-size]
:or {render-children true}}] :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) blocked? (get item :blocked)
hidden? (get item :hidden) hidden? (get item :hidden)
shapes (get item :shapes) ;; child shape IDs reversed for display (top z-order first in layer panel)
shapes (mf/with-memo [shapes objects] child-ids-raw (get item :shapes)
(loop [counter 0 child-ids (mf/with-memo [child-ids-raw]
shapes (seq shapes) (some-> child-ids-raw rseq vec))
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))))
drag-disabled* (mf/use-state false) drag-disabled* (mf/use-state false)
drag-disabled? (deref drag-disabled*) drag-disabled? (deref drag-disabled*)
@ -245,8 +226,11 @@
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local)) (l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
is-expanded (mf/deref expanded-iref) 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-selected (contains? selected id)
is-highlighted (contains? highlighted id) is-highlighted (mf/deref highlighted-iref)
container? (or (cfh/frame-shape? item) container? (or (cfh/frame-shape? item)
(cfh/group-shape? item)) (cfh/group-shape? item))
@ -303,14 +287,15 @@
select-shape select-shape
(mf/use-fn (mf/use-fn
(mf/deps id is-filtered objects) (mf/deps id is-filtered)
(fn [event] (fn [event]
(dom/prevent-default event) (dom/prevent-default event)
(mf/set-ref-val! scroll-middle-ref false) (mf/set-ref-val! scroll-middle-ref false)
(cond (cond
(kbd/shift? event) (kbd/shift? event)
(if is-filtered (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))) (st/emit! (dw/shift-select-shapes id)))
(kbd/mod? event) (kbd/mod? event)
@ -363,13 +348,14 @@
on-drop on-drop
(mf/use-fn (mf/use-fn
(mf/deps id objects is-expanded selected) (mf/deps id is-expanded selected)
(fn [side _data] (fn [side _data]
(let [single? (= (count selected) 1) (let [single? (= (count selected) 1)
same? (and single? (= (first selected) id))] same? (and single? (= (first selected) id))]
(when-not same? (when-not same?
(let [files (deref refs/files) (let [objects (deref refs/workspace-page-objects)
shape (get objects id) files (deref refs/files)
shape (get objects id)
parent-id parent-id
(cond (cond
@ -421,20 +407,22 @@
:on-hold on-hold :on-hold on-hold
:disabled drag-disabled? :disabled drag-disabled?
:detect-center? container? :detect-center? container?
:data {:id (:id item) :data {:id id
:index index :index index
:name (:name item)} :name (:name item)}
;; We don't want to change the structure of component copies ;; We don't want to change the structure of component copies
:draggable? (and ^boolean is-sortable :draggable? (and ^boolean is-sortable
^boolean (not is-read-only) ^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 on-tab-press
(mf/use-fn (mf/use-fn
(mf/deps id objects) (mf/deps id)
(fn [event] (fn [event]
(when (contains? cf/flags :canary) (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) shape (get objects id)
parent (get objects (:parent-id shape)) parent (get objects (:parent-id shape))
siblings (:shapes parent) siblings (:shapes parent)
@ -473,15 +461,15 @@
;; Setup scroll-driven lazy loading when expanded ;; Setup scroll-driven lazy loading when expanded
;; and ensures selected children are loaded immediately ;; and ensures selected children are loaded immediately
(mf/with-effect [is-expanded shapes selected] (mf/with-effect [is-expanded child-ids selected]
(let [total (count shapes)] (let [total (count child-ids)]
(if ^boolean is-expanded (if ^boolean is-expanded
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec (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 ;; Find if any selected id is a direct child and get its render index
selected-child-render-idx selected-child-render-idx
(when (> total default-chunk-size) (when (> total default-chunk-size)
(some (fn [sel-id] (some (fn [sel-id]
(let [idx (.indexOf shapes sel-id)] (let [idx (.indexOf child-ids sel-id)]
(when (>= idx 0) idx))) (when (>= idx 0) idx)))
selected)) selected))
@ -508,9 +496,9 @@
(mf/set-ref-val! obs nil))))) (mf/set-ref-val! obs nil)))))
;; Re-observe sentinel whenever children-count changes (sentinel moves) ;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes ;; and child-ids to reconnect observer after shape changes
(mf/with-effect [children-count is-expanded shapes] (mf/with-effect [children-count is-expanded child-ids]
(let [total (count shapes) (let [total (count child-ids)
name-node (mf/ref-val name-node-ref) name-node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data name-node "scroll-container") scroll-node (dom/get-parent-with-data name-node "scroll-container")
lazy-node (mf/ref-val lazy-ref)] lazy-node (mf/ref-val lazy-ref)]
@ -565,27 +553,26 @@
:style style} :style style}
(when (and ^boolean render-children (when (and ^boolean render-children
^boolean shapes ^boolean (seq child-ids)
^boolean is-expanded) ^boolean is-expanded)
[:div {:class (stl/css-case [:div {:class (stl/css-case
:element-children true :element-children true
:parent-selected is-selected :parent-selected is-selected
:sticky-children root-board?) :sticky-children root-board?)
:data-testid (dm/str "children-" id)} :data-testid (dm/str "children-" id)}
(for [item (take children-count shapes)] (let [total (count child-ids)]
[:> layer-item* (for [[display-idx child-id] (d/enumerate (take children-count child-ids))]
{:item item [:> layer-item*
:rename-id rename-id {:item-id child-id
:highlighted highlighted :rename-id rename-id
:selected selected :selected selected
:index (unchecked-get item "__$__counter") :index (- total 1 display-idx)
:objects objects :key (dm/str child-id)
:key (dm/str (get item :id)) :is-sortable is-sortable
:is-sortable is-sortable :depth depth
:depth depth :parent-size parent-size
:parent-size parent-size :is-component-child is-component-tree}]))
:is-component-child is-component-tree}])
(when (< children-count (count shapes)) (when (< children-count (count child-ids))
[:div {:ref lazy-ref [:div {:ref lazy-ref
:class (stl/css :lazy-load-sentinel)}])])])) :class (stl/css :lazy-load-sentinel)}])])]))

View File

@ -34,13 +34,6 @@
[okulary.core :as l] [okulary.core :as l]
[rumext.v2 :as mf])) [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 (def ^:private ref:shape-for-rename
(l/derived (l/key :shape-for-rename) refs/workspace-local)) (l/derived (l/key :shape-for-rename) refs/workspace-local))
@ -78,95 +71,61 @@
[:> layer-item* props])) [:> 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/defc layers-tree*
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [objects is-filtered parent-size] :as props}] [{:keys [is-filtered parent-size]}]
(let [selected (use-selected-shapes) (let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)
rename-id (mf/deref ref:shape-for-rename) rename-id (mf/deref ref:shape-for-rename)
root-ids (use-root-shape-ids)
shapes (get root :shapes) objects (deref refs/workspace-page-objects)]
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)))]
[:div {:class (stl/css :element-list) :data-testid "layer-item"} [:div {:class (stl/css :element-list) :data-testid "layer-item"}
[:> hooks/sortable-container* {} [:> hooks/sortable-container* {}
(for [obj shapes] ;; Layers display top z-order first, so reverse the shapes vector
(if (cfh/frame-shape? obj) (let [total (count root-ids)]
[:> frame-wrapper* (for [[display-idx id] (d/enumerate (reverse root-ids))]
{:item obj (let [index (- total 1 display-idx)
:rename-id rename-id obj (get objects id)]
:selected selected (when obj
:highlighted highlighted (if (cfh/frame-shape? obj)
:index (unchecked-get obj "__$__counter") [:> frame-wrapper*
:objects objects {:item-id id
:key (dm/str (get obj :id)) :rename-id rename-id
:is-sortable true :selected selected
:is-filtered is-filtered :index index
:parent-size parent-size :key (dm/str id)
:depth -1}] :is-sortable true
[:> layer-item* :is-filtered is-filtered
{:item obj :parent-size parent-size
:rename-id rename-id :depth -1}]
:selected selected [:> layer-item*
:highlighted highlighted {:item-id id
:index (unchecked-get obj "__$__counter") :rename-id rename-id
:objects objects :selected selected
:key (dm/str (get obj :id)) :index index
:is-sortable true :key (dm/str id)
:is-filtered is-filtered :is-sortable true
:depth -1 :is-filtered is-filtered
:parent-size parent-size}]))]])) :depth -1
:parent-size parent-size}])))))]]))
(mf/defc layers-tree-wrapper* (mf/defc layers-tree-wrapper*
{::mf/private true} {::mf/private true}
[{:keys [objects] :as props}] [props]
;; This is a performance sensitive componet, so we use lower-level primitives for ;; layers-tree* now self-subscribes to objects per-item; no need to
;; reduce residual allocation for this specific case ;; debounce the full objects map. Just pass through.
(let [state-tmp (mf/useState objects) [:> layers-tree* props])
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]))
(mf/defc filters-tree* (mf/defc filters-tree*
{::mf/wrap [mf/memo #(mf/throttle % 300)] {::mf/wrap [mf/memo #(mf/throttle % 300)]
@ -176,12 +135,11 @@
root (get objects uuid/zero)] root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)} [:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))] (for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)] (when (some? (get objects id))
[:> layer-item* [:> layer-item*
{:item obj {:item-id id
:selected selected :selected selected
:index index :index index
:objects objects
:key id :key id
:is-sortable false :is-sortable false
:is-filtered true :is-filtered true
@ -602,8 +560,7 @@
:data-scroll-container true :data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}} :style {:display (when (some? filtered-objects) "none")}}
[:> layers-tree-wrapper* {:objects filtered-objects [:> layers-tree-wrapper* {:key (dm/str page-id)
:key (dm/str page-id)
:is-filtered true :is-filtered true
:parent-size size-parent}]]] :parent-size size-parent}]]]
@ -611,7 +568,6 @@
:class (stl/css :tool-window-content) :class (stl/css :tool-window-content)
:data-scroll-container true :data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}} :style {:display (when (some? filtered-objects) "none")}}
[:> layers-tree-wrapper* {:objects objects [:> layers-tree-wrapper* {:key (dm/str page-id)
:key (dm/str page-id)
:is-filtered false :is-filtered false
:parent-size size-parent}]])])) :parent-size size-parent}]])]))