🔧 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
"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

View File

@ -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)}])])]))

View File

@ -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}]])]))