From 8dd4b486e74fb95761e4a9bdc90f32abb0ecdc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 19 May 2026 09:44:58 +0200 Subject: [PATCH] :zap: Improve drag performance avoiding unnecessary modifiers --- .../app/main/data/workspace/modifiers.cljs | 66 +++++--------- .../src/app/main/ui/workspace/viewport.cljs | 2 +- .../app/main/ui/workspace/viewport/hooks.cljs | 11 ++- .../app/main/ui/workspace/viewport_wasm.cljs | 28 +++--- render-wasm/src/state/shapes_pool.rs | 85 ++++++++++++++++++- 5 files changed, 125 insertions(+), 67 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 62bc42b50a..9c5323ba66 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -629,34 +629,10 @@ (ptk/reify ::set-temporary-modifiers ptk/EffectEvent (effect [_ _ _] - (rx/push! ms/wasm-modifiers modifiers)))) + (rx/push! ms/wasm-modifiers (into {} modifiers))))) (def ^:private xf:map-key (map key)) -(defn- expand-translation-entry - "Expand one translation-only geometry entry into [descendant-id matrix] - pairs covering the moved shape's full subtree (every descendant gets - the same matrix)." - [[id data] objects subtree-ids-by-id] - (let [m (:transform data) - sub (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (map (fn [sid] [sid m]) sub))) - -(defn- expand-translation-modifiers - "Pure translation propagates as identity to descendants: every shape in - the subtree gets the same matrix. Builds the flat [id matrix] list - directly, skipping the WASM tree walk + FFI roundtrip used by - `propagate-modifiers` for the general (resize/rotate) case. - - Only safe when pixel-snap is off: WASM applies pixel correction - per-shape (different scale/translation per descendant), which we - can't replicate cheaply on the CLJS side." - [geometry-entries objects subtree-ids-by-id] - (into [] - (mapcat #(expand-translation-entry % objects subtree-ids-by-id)) - geometry-entries)) - (defn- translate-selrect "Shift `selrect`'s center by (tx, ty). Width/height/transform are invariant under pure translation, so only `:center` moves." @@ -688,11 +664,12 @@ (ptk/reify ::set-wasm-modifiers ptk/UpdateEvent (update [_ state] - (let [property-changes - (extract-property-changes modif-tree)] - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)))) + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) ptk/WatchEvent (watch [_ state _] @@ -702,29 +679,26 @@ ;; thread is not blocked. The pair is closed in ;; `clear-local-transform`. (ensure-interactive-transform-start!) - (wasm.api/clean-modifiers) - (let [prev-wasm-props (:prev-wasm-props state) - wasm-props (:wasm-props state) - objects (dsh/lookup-page-objects state) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (set-wasm-props! objects prev-wasm-props wasm-props) + ;; Only geometry (transform matrix) changes during drag. (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))) (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) modifiers (if (and translation? (not snap-pixel?)) - (expand-translation-modifiers geometry-entries objects subtree-ids-by-id) + root-modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids))] + (let [ids (into [] xf:map-key geometry-entries) + selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids))] (rx/of (set-temporary-selrect selrect) (set-temporary-modifiers modifiers)))))))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 75cfbf4444..58b02a387e 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -310,7 +310,7 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-viewport-modifiers modifiers base-objects) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 140b5d5dd1..19b2b1b3fb 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -177,13 +177,14 @@ (dw/increase-zoom))))))) (defn setup-hover-shapes - [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only?] + [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only? transform] (let [;; We use ref so we don't recreate the stream on a change zoom-ref (mf/use-ref zoom) mod-ref (mf/use-ref @mod?) selected-ref (mf/use-ref selected) hover-disabled-ref (mf/use-ref hover-disabled?) focus-ref (mf/use-ref focus) + transform-ref (mf/use-ref transform) last-point-ref (mf/use-var nil) mod-str (mf/use-memo #(rx/subject)) @@ -251,6 +252,10 @@ (mf/deps focus) #(mf/set-ref-val! focus-ref focus)) + (mf/use-effect + (mf/deps transform) + #(mf/set-ref-val! transform-ref transform)) + (hooks/use-stream over-shapes-stream-debounced (mf/deps objects) @@ -361,7 +366,9 @@ (get objects)))] (reset! hover hover-shape) (reset! measure-hover measure-hover-shape) - (reset! hover-ids ids))) + ;; Skip hover-ids update during drag + (when (not= :move (mf/ref-val transform-ref)) + (reset! hover-ids ids)))) (fn [] ;; Clean the cache diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 150b2cd544..afe3543572 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -77,7 +77,7 @@ (defn apply-modifiers-to-selected [selected objects modifiers] - (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) + (apply-modifiers-to-objects objects (select-keys modifiers selected))) (defn- apply-wasm-modifiers-to-ids "Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM @@ -87,13 +87,15 @@ (if (or (empty? wasm-modifiers) (empty? id-set)) objects (reduce - (fn [objs pair] - (let [[id t] pair] - (if (and (contains? id-set id) (contains? objs id)) + (fn [objs id] + (if-let [t (get wasm-modifiers id)] + (if (contains? objs id) (update objs id gsh/apply-transform t) - objs))) + objs) + objs)) objects - wasm-modifiers))) + id-set))) + (defn- outline-wasm-source-ids "Superset of shape ids that `shape-outlines` may look up (all outline usages here)." @@ -142,7 +144,6 @@ drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) - workspace-editor-state (mf/deref refs/workspace-editor-state) file-id (get file :id) @@ -162,7 +163,6 @@ selected-shapes (->> selected (into [] (keep (d/getf objects-modified))) (not-empty)) - ;; STATE alt? (mf/use-state false) shift? (mf/use-state false) @@ -370,6 +370,7 @@ offset-y (if selecting-first-level-frame? (:y first-shape) (:y selected-frame)) + rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) @@ -495,7 +496,7 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) @@ -613,11 +614,10 @@ :ref text-editor-ref}])) (when show-frame-outline? - (let [outlined-frame-id - (->> @hover-ids - (filter #(cfh/frame-shape? (get base-objects %))) - (remove selected) - (last)) + (let [outlined-frame-id (->> @hover-ids + (filter #(cfh/frame-shape? (get base-objects %))) + (remove selected) + (last)) outlined-frame (get objects outlined-frame-id)] [:* [:& outline/shape-outlines diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index d51ce1cabe..f57bbcb51b 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::VecDeque; use std::iter; use crate::performance; @@ -191,6 +192,15 @@ impl ShapesPoolImpl { Some(shape) } } else { + if let Some(cell) = self.modified_shape_cache.get(&idx) { + return Some(cell.get_or_init(|| { + if let Some(m) = self.find_nearest_ancestor_modifier(idx) { + shape.transformed(Some(&m), None) + } else { + shape.clone() + } + })); + } Some(shape) } } @@ -230,9 +240,6 @@ impl ShapesPoolImpl { } pub fn set_modifiers(&mut self, modifiers: HashMap) { - // Convert HashMap to HashMap using indices - // Initialize the cache cells for affected shapes - let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -242,12 +249,47 @@ impl ShapesPoolImpl { ids.push(uuid); } } + + // Expand every root modifier to its full descendant subtree. + // When CLJS sends only root shapes (translation on drag), descendants + // need the same matrix. + // For resize/rotate, propagate-modifiers already includes all descendants. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation + // via rebuild_modifier_tiles only runs for roots, which is sufficient because + // descendants always lie inside the parent's bounding box and are therefore + // covered by the parent's old/new tile ranges. + let root_pairs: Vec<(usize, skia::Matrix)> = ids + .iter() + .filter_map(|uuid| { + let idx = self.uuid_to_idx.get(uuid).copied()?; + let matrix = modifiers_with_idx.get(&idx).copied()?; + Some((idx, matrix)) + }) + .collect(); + + let mut descendants_idxs: Vec = Vec::new(); + for (root_idx, matrix) in root_pairs { + for descendant_idx in self.collect_all_descendants(root_idx) { + if let std::collections::hash_map::Entry::Vacant(e) = + modifiers_with_idx.entry(descendant_idx) + { + e.insert(matrix); + descendants_idxs.push(descendant_idx); + } + } + } + self.modifiers = modifiers_with_idx; + for descendant_idx in descendants_idxs { + self.modified_shape_cache + .insert(descendant_idx, OnceCell::new()); + } + // Compute ancestors before consuming `ids` so we can move it into // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); - // Keep modifier_uuids in sync so modifier_ids() is O(K) not O(N_shapes). + // rebuild_modifier_tiles doesn't process every descendant individually. self.modifier_uuids = ids; for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { @@ -363,6 +405,41 @@ impl ShapesPoolImpl { } } + fn collect_all_descendants(&self, idx: usize) -> Vec { + let mut result = Vec::new(); + let mut queue: VecDeque<&Uuid> = VecDeque::new(); + let shape = &self.shapes[idx]; + for child_id in shape.children_ids_iter(false) { + queue.push_back(child_id); + } + while let Some(child_id) = queue.pop_front() { + if let Some(&child_idx) = self.uuid_to_idx.get(child_id) { + result.push(child_idx); + let child_shape = &self.shapes[child_idx]; + for grandchild_id in child_shape.children_ids_iter(false) { + queue.push_back(grandchild_id); + } + } + } + result + } + + fn find_nearest_ancestor_modifier(&self, idx: usize) -> Option { + let mut current_idx = idx; + loop { + let shape = &self.shapes[current_idx]; + let parent_id = shape.parent_id?; + if parent_id == Uuid::nil() { + return None; + } + let &parent_idx = self.uuid_to_idx.get(&parent_id)?; + if let Some(matrix) = self.modifiers.get(&parent_idx) { + return Some(*matrix); + } + current_idx = parent_idx; + } + } + fn to_update_bool(&self, shape: &Shape) -> bool { if !shape.is_bool() { return false;