From 92300914928682612f6dd1733d0abbdde11a40ff Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 5 May 2026 15:29:54 +0200 Subject: [PATCH 1/3] :zap: Coalesce live drag preview state and reduce sidebar churn --- frontend/src/app/main/data/workspace/modifiers.cljs | 13 +++++++------ .../src/app/main/data/workspace/transforms.cljs | 9 ++++++--- frontend/src/app/main/refs.cljs | 10 ++++++++-- frontend/src/app/main/streams.cljs | 10 ++++++++++ .../src/app/main/ui/workspace/sidebar/options.cljs | 2 +- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 63a4be935f..c3a13c0dba 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -30,6 +30,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] + [app.main.streams :as ms] [app.render-wasm.api :as wasm.api] [app.render-wasm.shape :as wasm.shape] [beicon.v2.core :as rx] @@ -617,16 +618,16 @@ (defn set-temporary-selrect [selrect] (ptk/reify ::set-temporary-selrect - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-selrect selrect)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/workspace-selrect selrect)))) (defn set-temporary-modifiers [modifiers] (ptk/reify ::set-temporary-modifiers - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-wasm-modifiers modifiers)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers modifiers)))) (def ^:private xf:map-key (map key)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index a20794bfc2..c6493c9d1a 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -138,9 +138,12 @@ (ptk/reify ::finish-transform ptk/UpdateEvent (update [_ state] - (-> state - (update :workspace-local dissoc :transform :duplicate-move-started?) - (dissoc :workspace-selrect :workspace-wasm-modifiers))))) + (update state :workspace-local dissoc :transform :duplicate-move-started?)) + + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers nil) + (rx/push! ms/workspace-selrect nil)))) ;; -- Resize -------------------------------------------------------- diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 870e659746..01bb43a0cb 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -17,6 +17,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace.tokens.selected-set :as dwts] [app.main.store :as st] + [app.main.streams :as ms] + [beicon.v2.core :as rx] [okulary.core :as l])) ;; ---- Global refs @@ -161,7 +163,9 @@ (l/derived :workspace-tokens st/state)) (def workspace-selrect - (l/derived :workspace-selrect st/state)) + (let [a (atom nil)] + (rx/sub! ms/workspace-selrect #(reset! a %)) + a)) ;; WARNING: Don't use directly from components, this is a proxy to ;; improve performance of selected-shapes and @@ -385,7 +389,9 @@ (l/derived :workspace-wasm-editor-styles st/state)) (def workspace-wasm-modifiers - (l/derived :workspace-wasm-modifiers st/state)) + (let [a (atom nil)] + (rx/sub! ms/wasm-modifiers #(reset! a %)) + a)) (def ^:private workspace-modifiers-with-objects (l/derived diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index d6ebf074b7..0cae9a246f 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -22,6 +22,16 @@ (or ^boolean (kbd/keyboard-event? event) ^boolean (mse/mouse-event? event))) +;; Live preview state for an interactive transform. Pushed by drag +;; events at the cadence set by upstream `rx/sample` (see +;; `transforms.cljs`); subscribed via plain atoms in `app.main.refs` so +;; updates bypass the Redux store and don't re-run unrelated lenses. +(defonce wasm-modifiers + (rx/behavior-subject nil)) + +(defonce workspace-selrect + (rx/behavior-subject nil)) + ;; --- Derived streams (defonce ^:private pointer diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index c7413af9ac..7f44c7502e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -73,7 +73,7 @@ nil))) (mf/defc shape-options* - {::mf/wrap [#(mf/throttle % 100)] + {::mf/wrap [#(mf/throttle % 200)] ::mf/private true} [{:keys [shapes shapes-with-children selected page-id file-id libraries]}] (if (= 1 (count selected)) From 97511ba6e509d192046e6a05f4a34337861a15b6 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 5 May 2026 15:35:00 +0200 Subject: [PATCH 2/3] :zap: Translation-aware modifier propagation and lazy parent walks --- common/src/app/common/logic/shapes.cljc | 41 ++-- common/src/app/common/types/container.cljc | 71 ++++-- .../app/main/data/workspace/modifiers.cljs | 221 ++++++++++++------ .../src/app/main/data/workspace/shapes.cljs | 10 +- .../app/main/data/workspace/transforms.cljs | 38 +-- .../test/frontend_tests/helpers/wasm.cljs | 8 + .../logic/frame_guides_test.cljs | 11 +- 7 files changed, 262 insertions(+), 138 deletions(-) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 7c45de0fdf..70c9b63fde 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -75,27 +75,28 @@ (reduce check-shape changes mod-obj-changes))) (defn generate-update-shapes - [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects?]}] - (let [changes - (->> ids - (reduce - (fn [changes id] - (let [opts {:attrs attrs - :ignore-geometry? (get ignore-tree id) - :ignore-touched ignore-touched - :with-objects? with-objects?}] - (pcb/update-shapes changes [id] update-fn (d/without-nils opts)))) - (cond-> changes - (some? objects) (pcb/with-objects objects)))) - grid-ids - (->> ids (filter (partial ctl/grid-layout? objects))) + [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}] + (let [changes (reduce + (fn [changes id] + (let [opts {:attrs attrs + :ignore-geometry? (get ignore-tree id) + :ignore-touched ignore-touched + :with-objects? with-objects?}] + (pcb/update-shapes changes [id] update-fn (d/without-nils opts)))) + (cond-> changes + (some? objects) (pcb/with-objects objects)) + ids) + ;; Translation doesn't shift children between grid cells, so + ;; cell reassignment + child reorder are no-ops. + grid-ids (when-not translation? + (->> ids (filter (partial ctl/grid-layout? objects)))) + changes (cond-> changes + (seq grid-ids) + (-> (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) + (pcb/reorder-grid-children ids)) - changes - (-> changes - (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) - (pcb/reorder-grid-children ids) - (cond-> (not ignore-touched) - (generate-unapply-tokens objects changed-sub-attr)))] + (not ignore-touched) + (generate-unapply-tokens objects changed-sub-attr))] changes)) (defn- generate-update-shape-flags diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 1689386e36..0a9ba9dae8 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -486,53 +486,78 @@ ;; or inside its main component if it's in a copy. comps-nesting-loop?))) +(defn parent-validation-cache + "Pre-computes the `children`-derived data for `find-valid-parent-and-frame-ids`. + Build once per gesture and pass as `cache`; values are delays so unused + branches stay unrealized." + [objects children libraries] + (let [children-ids (set (map :id children)) + top-children (remove #(contains? children-ids (:parent-id %)) children) + all-main? (every? ctk/main-instance? top-children) + get-variant-id (fn [shape] + (when (:component-id shape) + (-> (get-component-from-shape shape libraries) + :variant-id))) + descendants (delay (mapcat #(cfh/get-children-with-self objects %) children-ids)) + any-variant-container-descendant (delay (some ctk/is-variant-container? @descendants)) + descendants-variant-ids-set (delay (->> @descendants + (map get-variant-id) + set)) + any-main-descendant + (delay + (some + (fn [shape] + (some ctk/main-instance? (cfh/get-children-with-self objects (:id shape)))) + children))] + {:top-children top-children + :all-main? all-main? + :descendants descendants + :any-variant-container-descendant any-variant-container-descendant + :descendants-variant-ids-set descendants-variant-ids-set + :any-main-descendant any-main-descendant})) + (defn find-valid-parent-and-frame-ids "Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]" ([parent-id objects children] - (find-valid-parent-and-frame-ids parent-id objects children false nil)) + (find-valid-parent-and-frame-ids parent-id objects children false nil nil)) ([parent-id objects children pasting? libraries] + (find-valid-parent-and-frame-ids parent-id objects children pasting? libraries nil)) + ([parent-id objects children pasting? libraries cache] (letfn [(get-frame [pid] (if (cfh/frame-shape? objects pid) pid (get-in objects [pid :frame-id])))] - ;; `descendants`, variant-id set, etc. depend only on the moved shapes, not on the - ;; candidate parent. Computing them once per drag (this fn is hot during move) - ;; avoids O(depth * subtree) work when walking invalid ancestors — common with - ;; variants and nested components. - (let [children-ids (into #{} (map :id) children) - top-children (remove #(contains? children-ids (:parent-id %)) children) - all-main? (every? ctk/main-instance? top-children) - get-variant-id - (fn [shape] - (when (:component-id shape) - (-> (get-component-from-shape shape libraries) - :variant-id))) - descendants (mapcat #(cfh/get-children-with-self objects %) children-ids) - any-variant-container-descendant (some ctk/is-variant-container? descendants) - descendants-variant-ids-set (into #{} (map get-variant-id) descendants) - ;; Same as (some #(some ctk/main-instance? (cfh/get-children-with-self objects (:id %))) - ;; children) but a single walk over `descendants`. - any-main-descendant (some ctk/main-instance? descendants)] + ;; Predicates below are ordered so cheap parent/ascendant checks + ;; short-circuit before the descendant delays are forced. + (let [{:keys [top-children all-main? any-variant-container-descendant + descendants-variant-ids-set any-main-descendant]} + (or cache (parent-validation-cache objects children libraries))] + (loop [parent-id parent-id] (let [parent (get objects parent-id) + + ;; We can always move the children to the parent they already have. + ;; But if we are pasting, those are new items, so it is considered a change no-changes? (and (every? #(= parent-id (:parent-id %)) top-children) (not pasting?)) + ascendants (cfh/get-parents-with-self objects parent-id) any-main-ascendant (some ctk/main-instance? ascendants) any-variant-container-ascendant (some ctk/is-variant-container? ascendants)] + (if (or no-changes? (and (not (invalid-structure-for-component? objects parent children pasting? libraries)) ;; If we are moving (not pasting) into a main component, no descendant can be main - (or pasting? (nil? any-main-descendant) (not (ctk/main-instance? parent))) + (or pasting? (not (ctk/main-instance? parent)) (nil? @any-main-descendant)) ;; Don't allow variant-container inside variant container nor main - (or (not any-variant-container-descendant) - (and (not any-variant-container-ascendant) (not any-main-ascendant))) + (or (and (not any-variant-container-ascendant) (not any-main-ascendant)) + (not @any-variant-container-descendant)) ;; If the parent is a variant-container, all the items should be main (or (not (ctk/is-variant-container? parent)) all-main?) ;; If we are pasting, the parent can't be a "brother" of any of the pasted items, ;; so not have the same variant-id of any descendant (or (not pasting?) (not (ctk/is-variant? parent)) - (not (contains? descendants-variant-ids-set (:variant-id parent)))))) + (not (contains? @descendants-variant-ids-set (:variant-id parent)))))) [parent-id (get-frame parent-id)] (recur (:parent-id parent))))))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c3a13c0dba..cf954eee54 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -631,9 +631,56 @@ (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." + [selrect tx ty] + (update selrect :center + (fn [c] (gpt/point (+ (:x c) tx) (+ (:y c) ty))))) + +(defn- cached-translation-selrect + "Translation-only fast path for the live selection rect. Avoids a WASM + `get-selection-rect` call per drag frame by caching the gesture-start + base: on the first emission, ask WASM and back out the current delta + to recover the base; on every later emission, shift the cached base + by the new (tx, ty)." + [ids ^js first-matrix cache] + (let [tx (.-e first-matrix) + ty (.-f first-matrix)] + (if-let [base @cache] + (translate-selrect base tx ty) + (let [computed (wasm.api/get-selection-rect ids)] + (vreset! cache (translate-selrect computed (- tx) (- ty))) + computed)))) + #_:clj-kondo/ignore (defn set-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel + subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] (ptk/reify ::set-wasm-modifiers @@ -658,14 +705,24 @@ 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))] + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + + translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] (set-wasm-props! objects prev-wasm-props wasm-props) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) (let [geometry-entries (parse-geometry-modifiers modif-tree) - modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + modifiers + (if (and translation? (not snap-pixel?)) + (expand-translation-modifiers geometry-entries objects subtree-ids-by-id) + (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect (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)))))))) @@ -694,92 +751,110 @@ #_:clj-kondo/ignore (defn apply-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation? + subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] (ptk/reify ::apply-wasm-modifiesr ptk/WatchEvent (watch [_ state _] - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)) + transforms + (if (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - ;; Pure-translation gesture: every shape's modifier only - ;; contains `:move` operations (no resize/rotate/scale and - ;; no structural mutation) - translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + undo-id (js/Symbol)] + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) - - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty))))))) + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty)))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 44fb7539ca..cb2bcad26c 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -28,6 +28,8 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +;; If anything a translation can mutate is added here, drop the +;; `(when-not translation? …)` guard in `update-shapes` below. (def ^:private update-layout-attr? #{:hidden}) (defn- add-undo-group @@ -180,8 +182,9 @@ (map :id)) update-layout-ids - (->> (into [] xf-update-layout ids) - (not-empty)) + (when-not translation? + (->> (into [] xf-update-layout ids) + (not-empty))) changes (-> (pcb/empty-changes it page-id) @@ -194,7 +197,8 @@ :changed-sub-attr changed-sub-attr :ignore-tree ignore-tree :ignore-touched ignore-touched - :with-objects? with-objects?}) + :with-objects? with-objects? + :translation? translation?}) (cond-> undo-group (pcb/set-undo-group undo-group)) (pcb/set-translation? translation?)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index c6493c9d1a..1dcf6a2688 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -658,10 +658,6 @@ ptk/WatchEvent (watch [_ state stream] (let [prev-cell-data (volatile! nil) - ;; Cache the resolved valid parent while hovering the same raw target frame. - ;; `find-valid-parent-and-frame-ids` may walk many ancestors for variants/components, - ;; and the result is stable during the gesture (objects/libraries are constant here). - find-valid-for-raw-cache (volatile! {:raw nil :pair nil}) page-id (:current-page-id state) libraries (dsh/lookup-libraries state) objects (dsh/lookup-page-objects state page-id) @@ -708,7 +704,21 @@ (rx/map #(array pos %)))))))] (if (empty? shapes) (rx/of (finish-transform)) - (let [move-stream + ;; Per-gesture caches: `shapes`/`objects`/`libraries` are + ;; stable for the gesture, so build once and thread through. + (let [parent-validation-cache + (ctn/parent-validation-cache objects shapes libraries) + + subtree-ids-by-id + (into {} + (map (fn [id] + [id (cfh/get-children-ids-with-self objects id)])) + ids) + + selection-rect-cache + (volatile! nil) + + move-stream (->> position ;; We ask for the snap position but we continue even if the result is not available (rx/with-latest-from snap-delta) @@ -722,14 +732,8 @@ (fn [[move-vector mod?]] (let [position (gpt/add from-position move-vector) exclude-frames (if mod? exclude-frames exclude-frames-siblings) - raw-target (ctst/top-nested-frame objects position exclude-frames) - cache @find-valid-for-raw-cache - [target-frame _] - (if (= raw-target (:raw cache)) - (:pair cache) - (let [pair (ctn/find-valid-parent-and-frame-ids raw-target objects shapes false libraries)] - (vreset! find-valid-for-raw-cache {:raw raw-target :pair pair}) - pair)) + target-frame (ctst/top-nested-frame objects position exclude-frames) + [target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries parent-validation-cache) flex-layout? (ctl/flex-layout? objects target-frame) grid-layout? (ctl/grid-layout? objects target-frame) drop-index (when flex-layout? (gslf/get-drop-index target-frame objects position)) @@ -782,7 +786,10 @@ (rx/sample 16) (rx/map (fn [[modifiers snap-ignore-axis]] - (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) + (dwm/set-wasm-modifiers modifiers + :snap-ignore-axis snap-ignore-axis + :subtree-ids-by-id subtree-ids-by-id + :selection-rect-cache selection-rect-cache)))) (->> move-stream (rx/with-latest-from ms/mouse-position-alt) @@ -807,7 +814,8 @@ (dwu/start-undo-transaction undo-id) (dwm/apply-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis - :undo-transation? false) + :undo-transation? false + :subtree-ids-by-id subtree-ids-by-id) (move-shapes-to-frame ids target-frame drop-index drop-cell) (finish-transform) (dwu/commit-undo-transaction undo-id)))))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index f64d7b7d59..4bfe953c0a 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -75,6 +75,11 @@ (track! :set-structure-modifiers) nil) +(defn- mock-set-modifiers + [_modifiers] + (track! :set-modifiers) + nil) + (defn- mock-set-shape-grow-type [_grow-type] (track! :set-shape-grow-type) @@ -141,6 +146,7 @@ {:clean-modifiers wasm.api/clean-modifiers :set-structure-modifiers wasm.api/set-structure-modifiers :propagate-modifiers wasm.api/propagate-modifiers + :set-modifiers wasm.api/set-modifiers :set-shape-grow-type wasm.api/set-shape-grow-type :set-shape-text-content wasm.api/set-shape-text-content :set-shape-text-images wasm.api/set-shape-text-images @@ -152,6 +158,7 @@ (set! wasm.api/clean-modifiers mock-clean-modifiers) (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) (set! wasm.api/propagate-modifiers mock-propagate-modifiers) + (set! wasm.api/set-modifiers mock-set-modifiers) (set! wasm.api/set-shape-grow-type mock-set-shape-grow-type) (set! wasm.api/set-shape-text-content mock-set-shape-text-content) (set! wasm.api/set-shape-text-images mock-set-shape-text-images) @@ -167,6 +174,7 @@ (set! wasm.api/clean-modifiers (:clean-modifiers orig)) (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) (set! wasm.api/propagate-modifiers (:propagate-modifiers orig)) + (set! wasm.api/set-modifiers (:set-modifiers orig)) (set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig)) (set! wasm.api/set-shape-text-content (:set-shape-text-content orig)) (set! wasm.api/set-shape-text-images (:set-shape-text-images orig)) diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index e20bc99e26..3ed35d3a91 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -56,10 +56,13 @@ ;; guide has moved (t/is (= (:position guide') 100)) - ;; WASM mocks were exercised - (t/is (pos? (thw/call-count :clean-modifiers))) - (t/is (pos? (thw/call-count :set-structure-modifiers))) - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + ;; WASM bridge was exercised. `dw/update-position` + ;; routes through `apply-wasm-modifiers`, which for + ;; translation-only updates calls only `clean-modifiers` + ;; and computes the per-descendant transforms in CLJS + ;; (skipping `set-structure-modifiers` and + ;; `propagate-modifiers`). + (t/is (pos? (thw/call-count :clean-modifiers))))))))))) From 33396df2e2fca7415f5737d7715ed2e800ad723f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 6 May 2026 19:15:00 +0200 Subject: [PATCH 3/3] :bug: Avoid opaque fill check in drag crop cache hot path --- render-wasm/src/shapes.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 22e9e1e9d8..83ecb89023 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1411,16 +1411,10 @@ impl Shape { } } - let has_opaque_fill = self - .fills - .iter() - .any(|f| math::is_close_to(f.opacity(), 1.0)); - self.blur.is_none() && self.shadows.is_empty() && (self.opacity - 1.0).abs() <= 1e-4 && self.blend_mode().0 == skia::BlendMode::SrcOver - && has_opaque_fill } /// Fill + visible strokes in **document space** for clipping interactive drag textures.