From 5f40673fdea4caa30322195c379df67297558510 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 6 May 2026 10:31:39 +0200 Subject: [PATCH 1/4] :bug: Remove drag to change when there is a token applied on numeric-inputs (#9314) --- .../main/ui/ds/controls/numeric_input.cljs | 99 ++++++++++--------- .../main/ui/ds/controls/numeric_input.scss | 6 +- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 54da21ed03..d25773c9a7 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -218,6 +218,8 @@ token-applied-name* (mf/use-state applied-token-name) token-applied-name (deref token-applied-name*) + is-token-applied? (and (some? token-applied-name) + (not= :multiple token-applied-name)) focused-id* (mf/use-state nil) focused-id (deref focused-id*) @@ -501,9 +503,9 @@ on-scrub-pointer-down (mf/use-fn - (mf/deps disabled is-open is-multiple? ref min max nillable default) + (mf/deps disabled is-open is-multiple? ref min max nillable default is-token-applied?) (fn [event] - (when-not (or disabled is-open is-multiple?) + (when-not (or disabled is-open is-multiple? is-token-applied?) (let [node (mf/ref-val ref) is-focused (and (some? node) (dom/active? node)) has-token (some? (deref token-applied-name*))] @@ -518,57 +520,60 @@ on-scrub-pointer-move (mf/use-fn - (mf/deps apply-value update-input step min max on-change-start) + (mf/deps apply-value update-input step min max on-change-start is-token-applied?) (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (or (= state :maybe-dragging) (= state :dragging)) - (let [client-x (.-clientX event) - start-x (mf/ref-val drag-start-x*) - delta-x (- client-x start-x)] - (when (and (= state :maybe-dragging) - (>= (js/Math.abs delta-x) 3)) - (mf/set-ref-val! drag-state* :dragging) - (dom/add-class! (dom/get-body) "cursor-drag-scrub") - (when (fn? on-change-start) - (on-change-start))) - (when (= (mf/ref-val drag-state*) :dragging) - (let [effective-step (cond - (.-shiftKey event) (* step 10) - (.-ctrlKey event) (* step 0.1) - :else step) - steps (js/Math.round (/ delta-x 1)) - new-val (mth/clamp (+ (mf/ref-val drag-start-val*) - (* steps effective-step)) - min max)] - (update-input (fmt/format-number new-val)) - (apply-value (dm/str new-val))))))))) + (when-not is-token-applied? + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (dom/add-class! (dom/get-body) "cursor-drag-scrub") + (when (fn? on-change-start) + (on-change-start))) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step 10) + (.-ctrlKey event) (* step 0.1) + :else step) + steps (js/Math.round (/ delta-x 1)) + new-val (mth/clamp (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + min max)] + (update-input (fmt/format-number new-val)) + (apply-value (dm/str new-val)))))))))) on-scrub-pointer-up (mf/use-fn - (mf/deps ref on-change-end) + (mf/deps ref on-change-end is-token-applied?) (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (= state :maybe-dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/release-pointer event) - (when-let [node (mf/ref-val ref)] - (dom/focus! node))) - (when (= state :dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (dom/release-pointer event) - (when (fn? on-change-end) - (on-change-end)))))) + (when-not is-token-applied? + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (dom/release-pointer event) + (when (fn? on-change-end) + (on-change-end))))))) on-scrub-lost-pointer-capture (mf/use-fn - (mf/deps on-change-end) + (mf/deps on-change-end is-token-applied?) (fn [_event] - (let [was-dragging (= :dragging (mf/ref-val drag-state*))] - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (when (and was-dragging (fn? on-change-end)) - (on-change-end))))) + (when-not is-token-applied? + (let [was-dragging (= :dragging (mf/ref-val drag-state*))] + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (when (and was-dragging (fn? on-change-end)) + (on-change-end)))))) open-dropdown (mf/use-fn @@ -766,15 +771,15 @@ (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) - [:div {:class [class (stl/css :input-wrapper)] + [:div {:class [class (stl/css-case :input-wrapper true + :resizable (not is-token-applied?))] :ref wrapper-ref :on-pointer-down on-scrub-pointer-down :on-pointer-move on-scrub-pointer-move :on-pointer-up on-scrub-pointer-up :on-lost-pointer-capture on-scrub-lost-pointer-capture} - (if (and (some? token-applied-name) - (not= :multiple token-applied-name)) + (if is-token-applied? [:> token-field* token-props] [:> input-field* input-props]) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index 60741e7d0b..80b44a94f1 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -22,8 +22,10 @@ inline-size: 100%; position: relative; - &:not(:focus-within) { - cursor: ew-resize; + &.resizable { + &:not(:focus-within) { + cursor: ew-resize; + } } &:hover { From 34cc0e9d566d5a3ab9a08a55bc422194d28edda8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 6 May 2026 09:11:50 +0000 Subject: [PATCH 2/4] :fire: Materialize the canary feature flag across the codebase Remove the :canary flag from the flags definition and make all features gated behind it always available: - Enable "download font" option in dashboard fonts context menu - Enable Tab/Shift+Tab keyboard navigation for renaming shapes in layer items - Enable "duplicate color" option in asset panel when applicable - Enable "duplicate typography" option in asset panel when applicable - Enable "copy as image" context menu option for frame shapes Also remove unused [app.config :as cf] requires from files that no longer reference it after the materialization. Signed-off-by: Andrey Antukh --- common/src/app/common/flags.cljc | 4 --- frontend/src/app/main/ui/dashboard/fonts.cljs | 7 ++--- .../app/main/ui/workspace/context_menu.cljs | 3 +- .../ui/workspace/sidebar/assets/colors.cljs | 4 +-- .../sidebar/assets/typographies.cljs | 4 +-- .../main/ui/workspace/sidebar/layer_item.cljs | 28 +++++++++---------- 6 files changed, 19 insertions(+), 31 deletions(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 7a6dc625f9..1cec7db935 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -148,10 +148,6 @@ ;; Enable performance logs in devconsole (disabled by default) :perf-logs - ;; Used for designate features that will be available in the next - ;; release - :canary - ;; Security layer middleware that filters request by fetch ;; metadata headers :sec-fetch-metadata-middleware diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index eaefe3925f..ab513250d7 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -263,10 +263,9 @@ [{:name (tr "labels.edit") :id "font-edit" :handler on-edit} - (when (contains? cf/flags :canary) - {:name (tr "labels.download-simple") - :id "font-download" - :handler on-download}) + {:name (tr "labels.download-simple") + :id "font-download" + :handler on-download} {:name (tr "labels.delete") :id "font-delete" :handler on-delete}])] diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index c70764ffd3..20ceb46dde 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -226,8 +226,7 @@ [:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg") :on-click handle-copy-svg}] - (when (and (some cfh/frame-shape? shapes) - (contains? cf/flags :canary)) + (when (some cfh/frame-shape? shapes) [:> menu-entry* {:title (tr "workspace.shape.menu.copy-as-image") :disabled multiple? :on-click handle-copy-as-image}]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index b4e94bb92d..9dd803ef2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -11,7 +11,6 @@ [app.common.data.macros :as dm] [app.common.math :as mth] [app.common.path-names :as cpn] - [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.event :as ev] [app.main.data.modal :as modal] @@ -261,8 +260,7 @@ {:name (tr "workspace.assets.edit") :id "assets-edit-color" :handler edit-color-clicked}) - (when (and (not (or multi-colors? multi-assets?)) - (contains? cf/flags :canary)) + (when-not (or multi-colors? multi-assets?) {:name (tr "workspace.assets.duplicate") :id "assets-duplicate-color" :handler duplicate-color}) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 174648e8ca..2a61045e48 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -10,7 +10,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.path-names :as cpn] - [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] @@ -469,8 +468,7 @@ :id "assets-edit-typography" :handler handle-edit-typography-clicked}) - (when (and (not (or multi-typographies? multi-assets?)) - (contains? cf/flags :canary)) + (when-not (or multi-typographies? multi-assets?) {:name (tr "workspace.assets.duplicate") :id "assets-duplicate-typography" :handler handle-duplicate-typography}) 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 d31351adc8..8ba48062fb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -16,7 +16,6 @@ [app.common.types.container :as ctn] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.workspace :as dw] [app.main.data.workspace.collapse :as dwc] [app.main.refs :as refs] @@ -441,20 +440,19 @@ (mf/use-fn (mf/deps id objects) (fn [event] - (when (contains? cf/flags :canary) - (let [shift? (kbd/shift? event) - shape (get objects id) - parent (get objects (:parent-id shape)) - siblings (:shapes parent) - pos (d/index-of siblings id)] - (when (some? pos) - (let [;; Layers render in reverse: Tab (visually down) = dec index, - ;; Shift+Tab (visually up) = inc index - target-id (if shift? - (get siblings (inc pos)) - (get siblings (dec pos)))] - (when (some? target-id) - (st/emit! (dw/start-rename-shape target-id)))))))))] + (let [shift? (kbd/shift? event) + shape (get objects id) + parent (get objects (:parent-id shape)) + siblings (:shapes parent) + pos (d/index-of siblings id)] + (when (some? pos) + (let [;; Layers render in reverse: Tab (visually down) = dec index, + ;; Shift+Tab (visually up) = inc index + target-id (if shift? + (get siblings (inc pos)) + (get siblings (dec pos)))] + (when (some? target-id) + (st/emit! (dw/start-rename-shape target-id))))))))] (mf/with-effect [is-selected selected] (let [single? (= (count selected) 1) From 528d006b8d2ba705a2912a9323c63ff0f4c0249f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 6 May 2026 12:16:09 +0200 Subject: [PATCH 3/4] :zap: Events enhancements * :zap: Compute moved-subtree data once in find-valid-parent-and-frame-ids * :zap: Smooth WASM drag preview stream * :zap: Limit WASM outline modifier application --- common/src/app/common/types/container.cljc | 92 +++++++++---------- .../app/main/data/workspace/transforms.cljs | 20 +++- .../app/main/ui/workspace/viewport_wasm.cljs | 49 +++++++++- 3 files changed, 103 insertions(+), 58 deletions(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index c3aab0df4e..1689386e36 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -491,60 +491,50 @@ ([parent-id objects children] (find-valid-parent-and-frame-ids parent-id objects children false nil)) ([parent-id objects children pasting? libraries] - (letfn [(get-frame [parent-id] - (if (cfh/frame-shape? objects parent-id) parent-id (get-in objects [parent-id :frame-id])))] - (let [parent (get objects parent-id) - - ;; We need to check only the top shapes - children-ids (set (map :id children)) + (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) - - ;; 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?)) - - ;; Are all the top-children a main-instance of a component? - all-main? - (every? ctk/main-instance? top-children) - - 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) - - get-variant-id (fn [shape] - (when (:component-id shape) - (-> (get-component-from-shape shape libraries) - :variant-id))) - + 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 (->> descendants - (map get-variant-id) - set) - any-main-descendant - (some - (fn [shape] - (some ctk/main-instance? (cfh/get-children-with-self objects (:id shape)))) - children)] - - (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))) - ;; 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))) - ;; 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)))))) - [parent-id (get-frame parent-id)] - (recur (:parent-id parent) objects children pasting? libraries)))))) + 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)] + (loop [parent-id parent-id] + (let [parent (get objects parent-id) + 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))) + ;; 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))) + ;; 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)))))) + [parent-id (get-frame parent-id)] + (recur (:parent-id parent))))))))) ;; --- SHAPE UPDATE diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 58982b33f0..540a0e2362 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -653,6 +653,10 @@ 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) @@ -713,15 +717,22 @@ (fn [[move-vector mod?]] (let [position (gpt/add from-position move-vector) exclude-frames (if mod? exclude-frames exclude-frames-siblings) - 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) + 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)) 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)) cell-data (when (and grid-layout? (not mod?)) (get-drop-cell target-frame objects position))] (array move-vector target-frame drop-index cell-data)))) - (rx/take-until stopper)) + (rx/take-until stopper) + (rx/share)) modifiers-stream (->> move-stream @@ -761,6 +772,9 @@ (rx/merge (->> modifiers-stream (rx/take-until duplicate-stopper) + ;; Sample at a fixed cadence to keep preview smooth. Unlike a throttle, + ;; this tends to avoid perceptible "jumps" while still capping WASM work. + (rx/sample 16) (rx/map (fn [[modifiers snap-ignore-axis]] (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 837c72f598..915fc01f81 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -78,6 +78,39 @@ [selected objects modifiers] (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) +(defn- apply-wasm-modifiers-to-ids + "Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM + drag, `wasm-modifiers` can list every propagated descendant (large variants); SVG + outlines only need geometry for `selected` / hover / highlight — not the whole page." + [objects wasm-modifiers id-set] + (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)) + (update objs id gsh/apply-transform t) + objs))) + objects + wasm-modifiers))) + +(defn- outline-wasm-source-ids + "Superset of shape ids that `shape-outlines` may look up (all outline usages here)." + [base-objects selected highlighted edition hover-ids hover frame-hover] + (let [outlined-frame-id (->> hover-ids + (filter #(cfh/frame-shape? (get base-objects %))) + (remove selected) + (last)) + ids (-> #{} + (into (or selected #{})) + (into (or highlighted #{})) + (into (or hover-ids #{})))] + (cond-> ids + (uuid? (:id hover)) (conj (:id hover)) + (uuid? frame-hover) (conj frame-hover) + (uuid? outlined-frame-id) (conj outlined-frame-id) + (uuid? edition) (disj edition)))) + (mf/defc viewport* [{:keys [selected wglobal layout file page palete-size]}] (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check @@ -129,10 +162,6 @@ (into [] (keep (d/getf objects-modified))) (not-empty)) - objects-for-outlines - (mf/with-memo [base-objects wasm-modifiers] - (apply-modifiers-to-objects base-objects wasm-modifiers)) - ;; STATE alt? (mf/use-state false) shift? (mf/use-state false) @@ -178,6 +207,18 @@ (when (some? parent-id) (get base-objects parent-id))))) + outline-wasm-ids + (mf/with-memo + [base-objects selected highlighted edition @hover-ids @hover @frame-hover] + (outline-wasm-source-ids base-objects selected highlighted edition @hover-ids @hover @frame-hover)) + + objects-for-outlines + (mf/with-memo + [base-objects wasm-modifiers outline-wasm-ids] + (if (seq wasm-modifiers) + (apply-wasm-modifiers-to-ids base-objects wasm-modifiers outline-wasm-ids) + base-objects)) + zoom (d/check-num zoom 1) drawing-tool (:tool drawing) From 2fbff4f88ef2419090f0ee66fd80a704635e046a Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 6 May 2026 12:16:35 +0200 Subject: [PATCH 4/4] :sparkles: Buffer update shapes changes on token application --- .../src/app/common/files/changes_builder.cljc | 2 +- common/src/app/common/logic/shapes.cljc | 37 ++-- .../src/app/main/data/workspace/shapes.cljs | 184 ++++++++++++++---- .../data/workspace/tokens/application.cljs | 10 +- .../data/workspace/tokens/propagation.cljs | 23 ++- .../app/main/data/workspace/transforms.cljs | 16 +- .../app/main/data/workspace/wasm_text.cljs | 26 ++- .../logic/components_and_tokens.cljs | 5 +- .../tokens/logic/token_actions_test.cljs | 12 +- 9 files changed, 221 insertions(+), 94 deletions(-) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 2520a3fe76..5a3838c8c1 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -168,7 +168,7 @@ (contains? (meta changes) ::file-data) "Call (with-file-data) before using this function")) -(defn- lookup-objects +(defn lookup-objects [changes] (let [data (::file-data (meta changes))] (dm/get-in data [:pages-index uuid/zero :objects]))) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index f350913987..7c45de0fdf 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -76,23 +76,26 @@ (defn generate-update-shapes [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects?]}] - (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)))) - (-> changes - (pcb/with-objects objects)) - ids) - grid-ids (->> ids (filter (partial ctl/grid-layout? objects))) - 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)))] + (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 + (-> 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)))] changes)) (defn- generate-update-shape-flags diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index dbfc1291c9..44fb7539ca 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -46,11 +46,113 @@ (cond-> changes add-undo-group? (assoc :undo-group undo-group)))) -(defn update-shapes - ([ids update-fn] (update-shapes ids update-fn nil)) +(defn update-shapes-buffer-start + [] + (ptk/reify ::update-shapes-buffer-start + ptk/UpdateEvent + (update [_ state] + (assoc state ::update-shapes-buffer true)))) + +(defn update-shapes-buffer-stop + [] + (ptk/reify ::update-shapes-buffer-stop + ptk/UpdateEvent + (update [_ state] + (assoc state ::update-shapes-buffer false)))) + +(defn update-shapes-buffer-commit + [] + (ptk/reify ::update-shapes-buffer-commit + ptk/WatchEvent + (watch [_ state _] + (->> (get state ::update-shapes-buffer-changes) + (vals) + (map dch/commit-changes) + (rx/from))))) + +;; Looks for the objects data in the state, if there is an "in progress" +;; update-shapes-buffer will return the objeccts inside the current changes +;; to be applied. +(defn lookup-changed-objects + [state page-id] + (let [changes-objects + (-> (get-in state [::update-shapes-buffer-changes page-id]) + (pcb/lookup-objects))] + (or changes-objects (dsh/lookup-page-objects state page-id)))) + +;; Accumulates the update shapes changes into a single commit-changes +;; The accumulation is marked between the events `start` and `stop` in between +;; those events all the `update-shapes` will be agregated together with this event. +;; After a `stop` arrives the `commit` will send the changes at the same time. +(defn update-shapes-buffer + ([ids update-fn] + (update-shapes-buffer ids update-fn nil)) ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects? changed-sub-attr translation?] + :or {reg-objects? false + save-undo? true + stack-undo? false + ignore-touched false + with-objects? false} + :as props}] + (let [cur-event (js/Symbol)] + (ptk/reify ::update-shapes-buffer + ptk/UpdateEvent + (update [it state] + (if (nil? (::update-shapes-buffer-event state)) + (assoc state ::update-shapes-buffer-event cur-event) + + (let [page-id (or page-id (get state :current-page-id)) + objects (dsh/lookup-page-objects state page-id)] + (-> state + (update-in + [::update-shapes-buffer-changes page-id] + (fn [changes] + (-> (or changes + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/set-save-undo? save-undo?) + (pcb/set-stack-undo? stack-undo?) + (cond-> undo-group + (pcb/set-undo-group undo-group)))) + (cls/generate-update-shapes + ids + update-fn + nil + {:attrs attrs + :changed-sub-attr changed-sub-attr + :ignore-tree ignore-tree + :ignore-touched ignore-touched + :with-objects? with-objects?}) + (cond-> reg-objects? (pcb/resize-parents ids)) + (pcb/set-translation? translation?)))))))) + + ptk/WatchEvent + (watch [_ state stream] + (if (= (::update-shapes-buffer-event state) cur-event) + (let [stopper (->> stream (rx/filter (ptk/type? ::update-shapes-buffer-stop)))] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::update-shapes-buffer)) + (rx/take-until stopper) + (rx/last) + (rx/map update-shapes-buffer-commit)) + (rx/of (update-shapes-buffer ids update-fn props))) + + (rx/of #(dissoc % + ::update-shapes-buffer-changes + ::update-shapes-buffer-event)))) + (rx/empty))))))) + +(defn update-shapes + ([ids update-fn] + (update-shapes ids update-fn nil)) + ([ids update-fn + {:as props + :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id + ignore-touched undo-group with-objects? changed-sub-attr translation?] :or {reg-objects? false save-undo? true stack-undo? false @@ -63,49 +165,53 @@ (ptk/reify ::update-shapes ptk/WatchEvent (watch [it state _] - (let [page-id (or page-id (get state :current-page-id)) - objects (dsh/lookup-page-objects state page-id) - ids (into [] (filter some?) ids) - xf-update-layout - (comp - (map (d/getf objects)) - (filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?}))) - (map :id)) + (if (::update-shapes-buffer state) + (rx/of (update-shapes-buffer ids update-fn props)) - update-layout-ids - (->> (into [] xf-update-layout ids) - (not-empty)) + (let [page-id (or page-id (get state :current-page-id)) + objects (dsh/lookup-page-objects state page-id) + ids (into [] (filter some?) ids) - changes - (-> (pcb/empty-changes it page-id) - (pcb/set-save-undo? save-undo?) - (pcb/set-stack-undo? stack-undo?) - (cls/generate-update-shapes ids - update-fn - objects - {:attrs attrs - :changed-sub-attr changed-sub-attr - :ignore-tree ignore-tree - :ignore-touched ignore-touched - :with-objects? with-objects?}) - (cond-> undo-group - (pcb/set-undo-group undo-group)) - (pcb/set-translation? translation?)) + xf-update-layout + (comp + (map (d/getf objects)) + (filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?}))) + (map :id)) - changes - (add-undo-group changes state)] + update-layout-ids + (->> (into [] xf-update-layout ids) + (not-empty)) - (rx/concat - (if (seq (:redo-changes changes)) - (let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] - (rx/of (dch/commit-changes changes))) - (rx/empty)) + changes + (-> (pcb/empty-changes it page-id) + (pcb/set-save-undo? save-undo?) + (pcb/set-stack-undo? stack-undo?) + (cls/generate-update-shapes ids + update-fn + objects + {:attrs attrs + :changed-sub-attr changed-sub-attr + :ignore-tree ignore-tree + :ignore-touched ignore-touched + :with-objects? with-objects?}) + (cond-> undo-group + (pcb/set-undo-group undo-group)) + (pcb/set-translation? translation?)) - ;; Update layouts for properties marked - (if update-layout-ids - (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) - (rx/empty)))))))) + changes + (add-undo-group changes state)] + + (rx/concat + (if (seq (:redo-changes changes)) + (let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] + (rx/of (dch/commit-changes changes))) + (rx/empty)) + + ;; Update layouts for properties marked + (if update-layout-ids + (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) + (rx/empty))))))))) (defn add-shape ([shape] diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 89cccdd869..c92a7cc14e 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -98,7 +98,8 @@ (udw/trigger-bounding-box-cloaking shape-ids) (udw/increase-rotation shape-ids value nil {:page-id page-id - :ignore-touched true}))))))) + :ignore-touched true + :no-wasm? true}))))))) (defn update-stroke-width ([value shape-ids attributes] (update-stroke-width value shape-ids attributes nil)) @@ -254,7 +255,8 @@ (->> (rx/from shape-ids) (rx/map #(dwtr/update-position % (zipmap attributes (repeat value)) {:ignore-touched true - :page-id page-id}))))))))) + :page-id page-id + :no-wasm? true}))))))))) (defn update-layout-gap [value shape-ids attributes page-id] @@ -493,8 +495,8 @@ (watch [_ _ _] (when (number? value) (rx/of - (when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id})) - (when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id})))))))) + (when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id :no-wasm? true})) + (when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id :no-wasm? true})))))))) (defn- attributes->actions [{:keys [value shape-ids attributes page-id]}] diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index d20b095e98..42e642571f 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -195,9 +195,20 @@ (rx/of (-> (ts/resolve-tokens tokens-tree) (d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) (sd/resolve-tokens tokens-tree)) - (rx/mapcat (fn [sd-tokens] - (let [undo-id (js/Symbol)] - (rx/concat - (rx/of (dwu/start-undo-transaction undo-id :timeout false)) - (propagate-tokens state sd-tokens) - (rx/of (dwu/commit-undo-transaction undo-id))))))))))) + (rx/mapcat + (fn [sd-tokens] + (let [undo-id (js/Symbol)] + (rx/concat + (rx/of (dwu/start-undo-transaction undo-id :timeout false)) + + ;; FIXME: now the tokens propagations is done by accumulating the update-shapes + ;; into a single commit-changes. This is not really the best way, the token application + ;; should be done with a changes_builder and sending only one `commit-changes` instead + ;; of creating lots of `update-shapes`. + (rx/of (dwsh/update-shapes-buffer-start)) + (->> (propagate-tokens state sd-tokens) + (rx/catch #(rx/concat + (rx/of (dwsh/update-shapes-buffer-stop)) + (rx/throw %)))) + (rx/of (dwsh/update-shapes-buffer-stop)) + (rx/of (dwu/commit-undo-transaction undo-id))))))))))) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 540a0e2362..a20794bfc2 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -33,6 +33,7 @@ [app.main.data.workspace.collapse :as dwc] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] [app.main.snap :as snap] @@ -373,7 +374,7 @@ "Change size of shapes, from the sidebar options form (will ignore pixel snap)" ([ids attr value] (update-dimensions ids attr value nil)) - ([ids attr value options] + ([ids attr value {:keys [no-wasm?] :as options}] (assert (number? value)) (assert (every? uuid? ids) "expected valid coll of uuids") @@ -388,7 +389,7 @@ (get state :current-page-id)) objects - (dsh/lookup-page-objects state page-id) + (dwsh/lookup-changed-objects state page-id) get-modifier (fn [shape] @@ -408,7 +409,7 @@ modif-tree (dwm/build-modif-tree ids objects get-modifier)] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true))) (let [modif-tree (gm/set-objects-modifiers modif-tree objects)] @@ -532,11 +533,11 @@ "Rotate shapes a fixed angle, from a keyboard action." ([ids rotation] (increase-rotation ids rotation nil)) - ([ids rotation {:keys [center delta?] :as params} & {:as options}] + ([ids rotation {:keys [center delta?] :as params} & {:keys [no-wasm?] :as options}] (ptk/reify ::increase-rotation ptk/WatchEvent (watch [_ state _] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (let [objects (dsh/lookup-page-objects state) get-modifier @@ -558,6 +559,7 @@ (rx/concat (rx/of (dwm/set-delta-rotation-modifiers rotation shapes (assoc params :page-id page-id))) (rx/of (dwm/apply-modifiers options))))))))) + ;; -- Move ---------------------------------------------------------- (declare start-move) @@ -1042,7 +1044,7 @@ The position is a map that can have a partial position (it means it can receive {:x 10}." ([id position] (update-position id position nil)) - ([id position options] + ([id position {:keys [no-wasm?] :as options}] (assert (uuid? id) "expected a valid uuid for `id`") (assert (map? position) "expected a valid map for `position`") @@ -1062,7 +1064,7 @@ delta (calculate-delta position bbox frame) modifiers (dwm/create-modif-tree [id] (ctm/move-modifiers delta))] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (rx/of (dwm/apply-wasm-modifiers modifiers {:ignore-constraints false :ignore-touched (:ignore-touched options) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 7effaef13e..9a18d0d83a 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -17,6 +17,7 @@ [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts] @@ -180,20 +181,31 @@ (let [font-data (wasm.fonts/make-font-data font)] (wasm.fonts/font-stored? font-data (:emoji? font-data))))))] - (if (not fonts-loaded?) - (->> (rx/of (resize-wasm-text-debounce id opts)) - (rx/delay 20)) + (if fonts-loaded? (let [pass-opts (when (or (some? undo-group) (some? undo-id)) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) (some? undo-id) (assoc :undo-id undo-id)))] - (rx/of (resize-wasm-text-debounce-inner id pass-opts))))))))) + (rx/of (resize-wasm-text-debounce-inner id pass-opts))) + + ;; Fonts not loaded; retry after 20 msecs + (->> (rx/of (resize-wasm-text-debounce id opts)) + (rx/delay 20)))))))) (defn resize-wasm-text-all "Resize all text shapes (auto-width/auto-height) from a collection of ids." [ids] (ptk/reify ::resize-wasm-text-all ptk/WatchEvent - (watch [_ _ _] - (->> (rx/from ids) - (rx/map resize-wasm-text-debounce))))) + (watch [_ state stream] + (let [resize-stream + (->> (rx/from ids) + (rx/map resize-wasm-text-debounce))] + (if (::dwsh/update-shapes-buffer state) + ;; If we're in the middle of a token propagation we wait until is finished to + ;; recalculate the text sizes + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) + (rx/take 1) + (rx/mapcat (constantly resize-stream))) + resize-stream))))) diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 2ec37b8db1..3c331bd98e 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -430,10 +430,7 @@ (t/is (mth/close? (get c-frame1' :width) 200)) (t/is (mth/close? (get c-frame1' :height) 200)) - (t/is (empty? (:touched c-frame1'))) - - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers)))))))))] + (t/is (empty? (:touched c-frame1'))))))))] (tohs/run-store-async store step2 events identity)))) diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 956a2977a0..281573631a 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -281,9 +281,7 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100))) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:height rect-1') 100)))))))))) (t/deftest test-apply-padding (t/testing "applies padding token to shapes with layout" @@ -356,9 +354,7 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100))) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:height rect-1') 100)))))))))) (t/deftest test-apply-opacity (t/testing "applies opacity token and updates the shapes opacity" @@ -443,9 +439,7 @@ rect-1' (cths/get-shape file' :rect-1)] (t/is (some? (:applied-tokens rect-1'))) (t/is (= (:rotation (:applied-tokens rect-1')) (:name token-target'))) - (t/is (= (:rotation rect-1') 120)) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:rotation rect-1') 120))))))))) (t/deftest test-apply-stroke-width (t/testing "applies stroke-width token and updates the shapes with stroke"